diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..7990e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Days-to-expiration filters on options chain** — `ExpirationFilter.dteList(List)`, + `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(Operator, int)` + add three new syntactic forms for the `?dte` parameter (list, range, and comparison-operator + queries), mirroring the strike-filter variants. The `Operator` enum carries `GT`, `GTE`, + `LT`, `LTE` with `wireValue()` accessors. Responds to Market Data API 2.0.0. + ## [1.0.0] - 2026-06-29 First stable release of the Market Data Java & Kotlin SDK — a single JVM diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..3c5b98d 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,10 @@ OptionsChainRequest.builder(String symbol) // Expiration selection (sealed ExpirationFilter — pick one): .expirationFilter(ExpirationFilter.onDate(LocalDate date)) - .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration + .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration, single value + .expirationFilter(ExpirationFilter.dteList(List days)) // comma-separated list + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, inclusive + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) // e.g. >=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..27fba4d 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -133,6 +133,73 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteListNarrowsToClosestExpirationsPerValue() { + // dteList selects the closest expiration to each requested value, deduplicated — so the + // returned dte values need not equal 7/30/60 exactly, but there should be at most one + // expiration per requested value. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(7, 30, 60))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctDte = chain.stream().map(OptionQuote::dte).distinct().count(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + } + assertThat(distinctDte) + .as("one closest expiration per requested value, deduplicated") + .isBetween(1L, 3L); + } + + @Test + void chainDteRangeReturnsExpirationsWithinBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(0, 45); + } + } + + @Test + void chainDteComparisonReturnsExpirationsSatisfyingOperator() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(30); + } + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..fe82638 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -312,6 +312,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteList v) { + b.query("dte", dteListWireValue(v.days())); + } else if (f instanceof ExpirationFilter.DteRange v) { + b.query("dte", v.min() + "-" + v.max()); + } else if (f instanceof ExpirationFilter.DteComparison v) { + b.query("dte", v.operator().wireValue() + v.days()); } else if (f instanceof ExpirationFilter.Between v) { b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); @@ -337,6 +343,17 @@ private static String strikeFilterWireValue(StrikeFilter f) { return v.operator().wireValue() + formatStrike(v.price()); } + private static String dteListWireValue(List days) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < days.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(days.get(i)); + } + return sb.toString(); + } + private static String formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..c9020a1 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,8 @@ package com.marketdata.sdk.options; +import com.marketdata.sdk.Generated; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -14,10 +16,18 @@ *

Additive expiration-type predicates ({@code weekly}/{@code monthly}/{@code quarterly}/{@code * am}/{@code pm}) are not part of this hierarchy — they intersect freely with any variant and stay * as separate booleans on the request builder. + * + *

{@code dte} alone accepts four syntactic forms server-side — a single value ({@link + * #dte(int)}), a comma-separated list ({@link #dteList(List)}), a closed range ({@link + * #dteRange(int, int)}), and a comparison operator ({@link #dteComparison(Operator, int)}) — + * mirroring {@link StrikeFilter}'s exact/range/comparison split. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +57,56 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list filter — wire form {@code ?dte=d1,d2,...}. The API resolves each value + * to its closest expiration independently and deduplicates the result. Every value must be a + * whole number of days in {@code [0, 36500]}. + */ + static DteList dteList(List days) { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (int day : days) { + validateDteBounds(day); + } + return new DteList(days); + } + + /** + * Days-to-expiration closed range, inclusive — wire form {@code ?dte=min-max}. {@code min} must + * not exceed {@code max}; both bounds must be whole numbers of days in {@code [0, 36500]}. + */ + static DteRange dteRange(int min, int max) { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + validateDteBounds(min); + validateDteBounds(max); + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=} (e.g. {@code >=30}). + * {@code days} must be a whole number of days in {@code [0, 36500]}. + */ + // @Generated: the null-operator guard is unreachable through the public comparison factory, which + // always supplies a non-null Operator from the typed enum. + @Generated + static DteComparison dteComparison(Operator operator, int days) { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + validateDteBounds(days); + return new DteComparison(operator, days); + } + + private static void validateDteBounds(int days) { + if (days < 0 || days > 36500) { + throw new IllegalArgumentException("dte must be between 0 and 36500"); + } + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -79,6 +139,38 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteList(List days) implements ExpirationFilter { + public DteList { + days = List.copyOf(days); + } + } + + record DteRange(int min, int max) implements ExpirationFilter {} + + record DteComparison(Operator operator, int days) implements ExpirationFilter {} + + /** + * Comparison operators accepted by the {@code dte} parameter — mirrors {@link + * StrikeFilter.Operator}. + */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..3220380 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1025,6 +1025,141 @@ void chainExpirationFilterMonthYearTranslatesToMonthYear() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); } + @Test + void chainExpirationFilterDteSingleValueTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()) + .join(); + + // The comma is URL-encoded on the wire, so decode before asserting (same convention as the + // columns projection tests). + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GT, 30)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 45)) + .build()) + .join(); + + // "%3C" is the URL-encoded "<". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LTE, 45)) + .build()) + .join(); + + // "%3C%3D" is the URL-encoded "<=". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + + @Test + void chainSyncExpirationFilterDteComparisonTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .build()); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void expirationFilterDteSingleValueStillReturnsDteRecordWithDaysAccessor() { + ExpirationFilter filter = ExpirationFilter.dte(30); + + assertThat(filter).isInstanceOf(ExpirationFilter.Dte.class); + assertThat(((ExpirationFilter.Dte) filter).days()).isEqualTo(30); + } + @Test void chainStrikeFilterExactRendersAsBareNumber() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1227,6 +1362,41 @@ void strikeFilterRangeRejectsMinGreaterThanMax() { .hasMessageContaining("min must be <= max"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsValueOutOfBounds() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be between 0 and 36500"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsValueOutOfBounds() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 40000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be between 0 and 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsValueOutOfBounds() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, -5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be between 0 and 36500"); + } + // ---------- helpers ---------- private static CapturingClient okWith(String body) {