diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..29dd43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Extended dte parameter syntax** for `/v1/options/chain/` (Market Data API 2.0.0) — + `DteFilter` sealed interface with three forms (`Values` for comma-separated lists, + `Range` for closed ranges, `Comparison` for operator-based filtering) and an additive + `ExpirationFilter.dte(DteFilter)` overload alongside the single-value `dte(int)`. + `DteFilter.Operator` enum (`GT`, `GTE`, `LT`, `LTE`) with `wireValue()` accessors; + `MAX_DAYS` constant (36500); factory methods `values(List)`, `range(int, int)`, + `comparison(Operator, int)` with full validation (non-negative, within bounds). + ## [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..7a83af1 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.dte(DteFilter.values(List.of(15, 30, 45)))) // ?dte=15,30,45 + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) // ?dte=0-45 + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) // ?dte=>=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -66,6 +69,7 @@ public record OptionQuote( ```java import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -95,6 +99,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // Extended dte syntax: comma list, closed range, or comparison — ?dte=15,30,45 / ?dte=0-45 / ?dte=>=30. + var dteList = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(List.of(15, 30, 45)))) + .build()); + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()); + var dteComparison = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()); } ``` diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..4c02087 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -133,6 +134,80 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteValuesReturnsClosestExpirationPerRequestedValue() { + // dte=7,30,60 — the API returns the closest expiration per value, deduplicated. With no + // expiration filter at all the endpoint falls back to the front-month-only default (a single + // expiration), so requiring more than one distinct expiration — and that every row's dte lands + // near one of the three requested values — proves the comma-separated filter actually reached + // the wire rather than this test passing on the unfiltered default. + List requestedDays = List.of(7, 30, 60); + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.values(requestedDays))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("three well-separated dte values should resolve to more than the front-month default") + .isBetween(2L, 3L); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(requestedDays) + .as("row dte=%s should be the closest match to one of %s", dte, requestedDays) + .anySatisfy(requested -> assertThat(Math.abs(dte - requested)).isLessThanOrEqualTo(15)); + } + } + + @Test + void chainDteRangeReturnsExpirationsWithinBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(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.dte(DteFilter.comparison(DteFilter.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..3152eac 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionsChain; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +314,8 @@ 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.DteSyntax v) { + b.query("dte", dteFilterWireValue(v.filter())); } 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())); @@ -325,6 +329,18 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte // an if-chain, but there is no reachable else, so no defensive throw is needed. } + private static String dteFilterWireValue(DteFilter f) { + if (f instanceof DteFilter.Values v) { + return v.days().stream().map(String::valueOf).collect(Collectors.joining(",")); + } else if (f instanceof DteFilter.Range v) { + return v.min() + "-" + v.max(); + } + // DteFilter is sealed; the only remaining variant is Comparison. The cast documents that + // exhaustiveness (Java 17 can't prove it in an if-chain) and fails fast if a variant is added. + DteFilter.Comparison v = (DteFilter.Comparison) f; + return v.operator().wireValue() + v.days(); + } + private static String strikeFilterWireValue(StrikeFilter f) { if (f instanceof StrikeFilter.Exact v) { return formatStrike(v.price()); diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java new file mode 100644 index 0000000..1f8ba68 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,99 @@ +package com.marketdata.sdk.options; + +import java.util.List; +import java.util.Objects; + +/** + * The chain endpoint's {@code ?dte=} parameter accepts three syntactic forms beyond the plain + * single-value form already modeled by {@link ExpirationFilter#dte(int)} — a comma-separated list, + * a closed range, and a comparison ({@code >=30}, {@code <45}, …); the syntax matches {@link + * StrikeFilter}. Modeling them as a sealed type with factory entry-points gives the consumer + * compile-time autocomplete for valid shapes and prevents typos like {@code "15--30"} that a raw + * string passthrough would silently ship to the server. + * + *

Reached through {@link ExpirationFilter#dte(DteFilter)}, which keeps the mutually-exclusive + * expiration-selection axis (ADR-008) on the single {@code expirationFilter(...)} setter. + */ +public sealed interface DteFilter permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** Days are whole numbers of days, at most this many — matches the API's own description. */ + int MAX_DAYS = 36500; + + /** + * Match the closest expiration for each value in {@code days} (deduplicated server-side). {@code + * days} must not be empty. + */ + static Values values(List days) { + return new Values(days); + } + + /** Match every expiration whose dte falls in {@code [min, max]} inclusive. */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** Match dte satisfying {@code operator days} (e.g. {@code >= 30}). */ + static Comparison comparison(Operator operator, int days) { + return new Comparison(operator, days); + } + + private static void validateDays(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > MAX_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DAYS); + } + } + + /** Comparison operators accepted by the API — 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 Values(List days) implements DteFilter { + public Values { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must be non-empty"); + } + days = List.copyOf(days); + for (int d : days) { + validateDays(d); + } + } + } + + record Range(int min, int max) implements DteFilter { + public Range { + validateDays(min); + validateDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record Comparison(Operator operator, int days) implements DteFilter { + public Comparison { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + validateDays(days); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..0fdb625 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -18,6 +18,7 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteSyntax, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +40,7 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** Days-to-expiration filter, single value — wire form {@code ?dte=N}. */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +48,16 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration filter using the extended {@link DteFilter} syntax (comma-separated list, + * closed range, or comparison) — wire forms {@code ?dte=15,30,45}, {@code ?dte=0-45}, {@code + * ?dte=>=30}. The plain single-value form stays on {@link #dte(int)}. + */ + static DteSyntax dte(DteFilter filter) { + Objects.requireNonNull(filter, "filter"); + return new DteSyntax(filter); + } + /** * 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 +90,8 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteSyntax(DteFilter filter) implements ExpirationFilter {} + 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..ffcc8f5 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; @@ -975,6 +976,116 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedList() { + // dte=15,30,45 — the API picks the closest expiration for each value, deduplicated. The SDK + // just joins the values; dedup/closest-match is server-side behavior. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(List.of(15, 30, 45)))) + .build()) + .join(); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedRange() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — mirrors the strike comparison encoding. + assertThat(url).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + // The API's own dte description cites "<45" as its comparison example alongside ">=30" — both + // operators get a wire assertion, not just the one the GTE test happens to share with strike. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LT, 45))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3C" is the URL-encoded "<". + assertThat(url).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GT, 30))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E" is the URL-encoded ">". + assertThat(url).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LTE, 45))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3C%3D" is the URL-encoded "<=". + assertThat(url).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1314,90 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void dteFilterValuesRejectsEmptyList() { + assertThatThrownBy(() -> DteFilter.values(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void dteFilterValuesRejectsNegativeEntry() { + assertThatThrownBy(() -> DteFilter.values(List.of(10, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterValuesRejectsEntryAboveMax() { + assertThatThrownBy(() -> DteFilter.values(List.of(10, 36501))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeMin() { + assertThatThrownBy(() -> DteFilter.range(-1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsDaysAboveLimit() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void dteFilterComparisonRejectsNullOperator() { + assertThatThrownBy(() -> DteFilter.comparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } + + @Test + void dteFilterValuesRecordConstructorRejectsEmptyList() { + assertThatThrownBy(() -> new DteFilter.Values(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void dteFilterRangeRecordConstructorRejectsReversedRange() { + assertThatThrownBy(() -> new DteFilter.Range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterComparisonRecordConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new DteFilter.Comparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(