diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..c843137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(List)`, `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — New static factories for the chain endpoint's expanded `dte` parameter forms (comma-separated list, closed range, comparison operators). The existing `ExpirationFilter.dte(int)` works as before (API release 2.0.0). +- `ExpirationFilter.Operator` — New enum (GT, GTE, LT, LTE) for comparison operators, with `wireValue()` accessor. + ## [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..9214779 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 + .expirationFilter(ExpirationFilter.dteList(List days)) // closest expiration per value + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // every expiration within [min, max] + .expirationFilter(ExpirationFilter.dteComparison(Operator op, int days)) // every expiration satisfying "op days" .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +43,8 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` accepts four forms: a single value (`dte(int)`), a comma-separated list (`dteList(List)` — for each value, the API selects its closest available expiration), a closed range (`dteRange(int, int)`, both ends inclusive — same grammar as `StrikeFilter.range`), and a comparison (`dteComparison(ExpirationFilter.Operator, int)`, operators `GT`/`GTE`/`LT`/`LTE` — same grammar as `StrikeFilter.comparison`). Every `dte` value, in any of the four forms, is relative to the request's `date` parameter when one is set (to "today" otherwise), and is capped at 36,500 days (100 years). + #### Returns @@ -95,6 +100,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a list, a closed range, or a comparison. + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .build()); } ``` diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java index c4f0977..b95ebfe 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -40,6 +40,26 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte also accepts a comma-separated list, a closed range, or a comparison — same grammar + // as StrikeFilter's range/comparison forms. + List withinRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 60)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dteRange(0, 60): " + withinRange.size() + " contracts"); + + List atOrBeyond30 = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dteComparison(GTE, 30): " + atOrBeyond30.size() + " contracts"); + // ExpirationFilter.all() spans every expiration at once — distinct from omitting the filter, // which the API narrows to the front month. long spans = client.options().chain( diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..9f0842a 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -40,6 +40,11 @@ class OptionsIntegrationTest { private static final String UNDERLYING = "AAPL"; + // A fixed, recent US trading day (not a weekend or market holiday) that the dte tests below + // pin their baseline and filtered calls to, so the chain's dte values can't shift with the + // wall clock the way LocalDate.now() would. + private static final LocalDate DTE_FIXED_TRADING_DAY = LocalDate.of(2026, 10, 27); + private MarketDataClient client; @BeforeAll @@ -133,6 +138,208 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainExpirationDteSingleValueMatchesClosestExpiration() { + // Request an available dte value exactly — its own closest expiration is unambiguous (zero + // distance), so the result's dte must equal it. Picking the farthest-out value keeps it + // distinct from the unfiltered default, which is the front-month (nearest) expiration. + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); + int farthest = baseline.get(baseline.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(farthest)) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isEqualTo(farthest); + } + } + + @Test + void chainExpirationDteListMatchesClosestExpirationPerValue() { + // Request two distinct, available dte values exactly: each is its own unambiguous closest + // candidate (zero distance), so the result can only ever contain those two values, and both + // must be present. Distinct from the unfiltered default, which carries a single expiration. + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); + int low = baseline.get(0); + int high = baseline.get(baseline.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(low, high))) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isIn(low, high); + } + assertThat(chain.stream().map(OptionQuote::dte).collect(java.util.stream.Collectors.toSet())) + .as("each requested value's own candidate must be present") + .contains(low, high); + } + + @Test + void chainExpirationDteRangeReturnsEveryExpirationWithinBounds() { + // A closed range spanning several available expirations: every row's dte must fall within + // [min, max], and the span must cover more than one distinct value to differ from the + // unfiltered (single front-month) default. + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); + int min = baseline.get(baseline.size() / 4); + int max = baseline.get(3 * baseline.size() / 4); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isBetween(min, max); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a range spanning several expirations holds more than one distinct value") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteComparisonGteReturnsExpirationsAtOrAboveBound() { + // >= N: every row's dte is >= N, asserted in a way that holds whether the server treats the + // bound as inclusive or exclusive. The bound sits a third of the way up the baseline so the + // qualifying set spans more than one expiration, distinguishing it from the unfiltered + // default (a single front-month row). + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); + int bound = baseline.get(baseline.size() / 3); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, bound)) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isGreaterThanOrEqualTo(bound); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a >= bound below the top of the chain qualifies more than one expiration") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() { + // < N: every row's dte is <= N, asserted in a way that holds whether the server treats the + // bound as inclusive or exclusive. The bound sits two-thirds of the way up the baseline so the + // qualifying set spans more than one expiration, distinguishing it from the unfiltered + // default (a single front-month row). + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); + int bound = baseline.get(2 * baseline.size() / 3); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, bound)) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isLessThanOrEqualTo(bound); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a < bound above the bottom of the chain qualifies more than one expiration") + .isGreaterThan(1); + } + + /** + * Every distinct dte present across AAPL's full expiration chain, ascending — the baseline a + * dte-filter test derives its candidate values from, per the suite's own convention of deriving + * expectations from a baseline call rather than from values assumed in advance. {@code date} is + * the fixed trading day the caller also passes to its filtered call, so both calls see the same + * session and dte can't shift between them. + */ + private List baselineDtes(LocalDate date) { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .date(date) + .build()) + .values(); + List dtes = + chain.stream() + .map(OptionQuote::dte) + .distinct() + .sorted() + .collect(java.util.stream.Collectors.toList()); + assertThat(dtes) + .as("AAPL has at least two distinct expirations") + .hasSizeGreaterThanOrEqualTo(2); + return dtes; + } + @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..587aa21 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -28,6 +28,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 +313,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", v.days().stream().map(String::valueOf).collect(Collectors.joining(","))); + } 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())); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..c099125 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -18,6 +19,9 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +43,11 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration filter — wire form {@code ?dte=N}. {@code days} is relative to the request's + * {@code date} parameter when one is set (to "today" otherwise), and is capped at 36,500 days + * (100 years). + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +55,58 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Comma-separated days-to-expiration list — wire form {@code ?dte=15,30,45}. For each value, the + * API selects the closest available expiration; duplicates across values are deduplicated + * server-side. {@code days} must be non-empty, and every value non-negative. Each value is + * relative to the request's {@code date} parameter when one is set (to "today" otherwise), and is + * capped at 36,500 days (100 years). + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Closed days-to-expiration range — wire form {@code ?dte=0-45}, both ends inclusive (same + * grammar as {@link StrikeFilter#range}, which documents the same closed-range syntax). {@code + * min} must not exceed {@code max}. Both bounds are relative to the request's {@code date} + * parameter when one is set (to "today" otherwise), and are capped at 36,500 days (100 years). + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=>=30} (e.g. {@code operator=GTE}, {@code + * days=30}). Same comparison grammar as {@link StrikeFilter#comparison}. {@code days} is relative + * to the request's {@code date} parameter when one is set (to "today" otherwise), and is capped + * at 36,500 days (100 years). + */ + static DteComparison dteComparison(Operator operator, int days) { + return new DteComparison(operator, days); + } + + /** + * Comparison operators accepted by the {@code dte} comparison form — see {@link #dteComparison}. + */ + 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; + } + } + /** * 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,40 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteList(List days) implements ExpirationFilter { + public DteList { + days = List.copyOf(Objects.requireNonNull(days, "days")); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must be non-empty"); + } + for (int day : days) { + if (day < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + } + } + } + + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + if (min < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record DteComparison(Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + } + } + 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..9db031a 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,103 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + // Comma-separated dte list — selects, for each value, the closest available expiration. + 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. + 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 chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + 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 chainExpirationFilterDteComparisonGteTranslatesToDteParam() { + 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 ">=" — same encoding the strike comparison test observes. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonGtTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GT, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLtTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LTE, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1300,41 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must be non-empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(30, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(