From d280c450779e2db4c680a4b6711a49c6383219ca Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Wed, 14 Oct 2026 08:59:03 -0400 Subject: [PATCH 1/3] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Build fails as expected (compilation errors for not-yet-implemented symbols), confirming phase-1 tests are in place. Integration test file wasn't reached since unit test compilation already failed first — expected, not run by this command anyway. Added wire, validation, regression, and integration tests for the `dte` parameter's type change (integer → string) in `OptionsResourceTest.java` and `OptionsIntegrationTest.java`: - Wire tests: single value (existing, unchanged), new `dteList` (comma-separated), `dteRange` (closed range), and `dteComparison` for each of the four operators (GT/GTE/LT/LTE). - Regression: the pre-existing `dte(int)` factory still accepts a value above the new 36500 upper bound and sends it to the wire unchanged — no new check added to it. - Validation: negative and above-36500 values rejected for the three new factories, plus min>max for `dteRange` — all on newly-added methods, so no existing caller's error type changes. - Integration: one test per form (single/list/range/comparison) against the live chain endpoint, asserting shape (distinct-expiration counts, bounds satisfied) rather than exact server-side "closest" picks. `gradle test --no-daemon --console=plain` fails to compile as expected (`dteList`/`dteRange`/`dteComparison` don't exist yet). implement: Both green. Implemented `dte`'s type change (integer → string) on `/v1/options/chain/`: added three new `ExpirationFilter` variants (`DteList`, `DteRange`, `DteComparison`) covering the comma-list, closed-range and comparison forms, alongside the unchanged single-value `Dte`, reusing `StrikeFilter.Operator` for the shared comparison syntax. - Documentation: `docs/options/chain.md` now lists and demonstrates all four `dte` forms. - Doc comments: every new factory and record carries its own Javadoc. - Wire test per form/value: already written in phase 1 (list, range, GT/GTE/LT/LTE each asserted individually); unchanged here. - Validation on every construction path: compact constructors on the new records enforce bounds/non-null directly (not only the factories), with added tests constructing `DteList` directly (empty, null list, null element). - Coverage: verified via the JaCoCo HTML report — no uncovered lines/branches in the touched classes; no exclusions added. - Examples/README: `OptionsAdvancedExample.java` gained a `dteComparison` demo; `OptionsExample.java`, `README.md`, `docs/client.md`, `docs/options/{README,quotes,expirations,lookup}.md`, `docs/.sync-manifest.txt`, ADR-006/ADR-008 left unchanged — their "chain"/"expiration" matches are unrelated (CompletableFuture chaining, other endpoints, or the ADR's historical decision record, which a non-breaking sealed-type addition doesn't invalidate). The existing `dte(int)` factory keeps its original check only (no upper bound added), confirmed by a regression test that a value above 36500 still reaches the wire unchanged. changelog: Added changelog entry under `## [Unreleased]` documenting the three new `ExpirationFilter` dte factory methods (`dteList`, `dteRange`, `dteComparison`) for API 2.0.0's expanded string `?dte` parameter forms. --- CHANGELOG.md | 6 + docs/options/chain.md | 22 +- .../resources/OptionsAdvancedExample.java | 10 + .../sdk/OptionsIntegrationTest.java | 84 ++++++++ .../com/marketdata/sdk/OptionsResource.java | 7 + .../sdk/options/ExpirationFilter.java | 90 ++++++++- .../marketdata/sdk/OptionsResourceTest.java | 190 ++++++++++++++++++ 7 files changed, 407 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..73b69bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`ExpirationFilter` dte variants** — `dteList(int, int...)`, `dteRange(int, int)`, + and `dteComparison(StrikeFilter.Operator, int)` for comma-separated, closed-range, + and comparison forms of the `?dte` parameter on `GET /v1/options/chain/` (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..f5e980e 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(int first, int... rest)) // comma-separated list, e.g. 15,30,45 + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. 0-45 + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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) @@ -95,6 +98,23 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a comma-separated list, a closed range, or a comparison — same syntax as + // strike. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()); + + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); + + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .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..8e6f464 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 @@ -51,6 +51,16 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // dte also accepts a comma-separated list, a closed range, or a comparison — same syntax as + // strike (StrikeFilter.Operator is shared between the two). + long nearTerm = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .side(OptionSide.CALL) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("dte < 45 spans " + nearTerm + " distinct expirations"); + if (chain.size() < 2) { System.out.println("Not enough contracts to demo the rest — try a more liquid underlying."); return; diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..8c00326 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,6 +10,7 @@ import com.marketdata.sdk.options.OptionsLookupRequest; import com.marketdata.sdk.options.OptionsQuoteRequest; import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; import java.time.LocalDate; import java.util.List; @@ -133,6 +134,89 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueNarrowsToOneExpiration() { + // A single dte value selects the one expiration closest to it — not an exact date this test + // can predict, but the row count across expirations is checkable: exactly one. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(30)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a single dte value narrows the chain to its one closest expiration") + .isEqualTo(1); + } + + @Test + void chainDteCommaListSpansAtMostOneExpirationPerValue() { + // A comma-separated dte list selects the closest expiration for each value, deduplicated — + // this test can't predict which expirations without assuming the server's "closest" rule, but + // the resulting expiration count is bounded by the number of distinct values requested. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("deduplicated closest expirations for 3 requested values") + .isBetween(1L, 3L); + } + + @Test + void chainDteRangeReturnsOnlyExpirationsWithinBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(dte.intValue()).isBetween(0, 45); + } + } + + @Test + void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(dte.intValue()).isLessThan(45); + } + } + @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..1fcd3ce 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 java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -18,6 +20,9 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +44,10 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration, single value — wire form {@code ?dte=N}. Limits the option chain to the + * expiration date closest to {@code days}. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +55,37 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration, comma-separated list — wire form {@code ?dte=15,30,45}. Selects the closest + * expiration for each value, deduplicated server-side. At least one value is required; each value + * must be a non-negative whole number of days, at most 36500. + */ + static DteList dteList(int first, int... rest) { + List days = new ArrayList<>(rest.length + 1); + days.add(first); + for (int d : rest) { + days.add(d); + } + return new DteList(days); + } + + /** + * Days-to-expiration, closed range — wire form {@code ?dte=0-45}. Returns every expiration whose + * dte falls within {@code [min, max]} inclusive. {@code min} must not exceed {@code max}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration, comparison — wire form {@code ?dte=>=30} (operator prefix + value). Returns + * every expiration whose dte satisfies {@code operator days}. The operator set is shared with + * {@link StrikeFilter.Operator} — the two parameters use the same comparison syntax. + */ + static DteComparison dteComparison(StrikeFilter.Operator operator, int days) { + return new DteComparison(operator, days); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -71,6 +110,17 @@ static MonthYear monthYear(int year, int month) { return new MonthYear(year, month); } + /** Validates a single dte value: a non-negative whole number of days, at most 36500. */ + private static int validateDte(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > 36500) { + throw new IllegalArgumentException("dte must be at most 36500"); + } + return days; + } + record OnDate(LocalDate date) implements ExpirationFilter { public OnDate { Objects.requireNonNull(date, "date"); @@ -79,6 +129,44 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** + * Comma-separated dte list — see {@link #dteList(int, int...)}. Validated on every construction + * path, not only the static factory: {@code days} must be non-null, non-empty, and free of null + * elements, and every value must be a non-negative whole number of days, at most 36500. + */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("dte list must not be empty"); + } + for (Integer d : days) { + Objects.requireNonNull(d, "dte list must not contain null elements"); + validateDte(d); + } + days = List.copyOf(days); + } + } + + /** Closed dte range — see {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDte(min); + validateDte(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** Dte comparison — see {@link #dteComparison(StrikeFilter.Operator, int)}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + validateDte(days); + } + } + 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..a352e44 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,123 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void expirationFilterDteSingleValueAboveNewUpperBoundStillReachesTheWire() { + // Regression: dte's wire type changed from integer to string, but the pre-existing + // single-value factory gained no new upper-bound check — a value that reached the API before + // (and whatever the API does with it) still reaches the API unchanged. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(99999)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=99999"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + // "," is not in URLEncoder's safe set, so the comma-separated list encodes each comma as + // %2C. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15%2C30%2C45"); + } + + @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 chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, 30)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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(StrikeFilter.Operator.LTE, 45)) + .build()) + .join(); + + // "%3C%3D" is the URL-encoded "<=". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1320,79 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -5, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @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 expirationFilterDteRangeRejectsMaxAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteListRecordRejectsEmptyListDirectly() { + // DteList's validation holds on every public construction path — not only the dteList(...) + // factory, which can't even express an empty list (its first parameter is mandatory). + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte list must not be empty"); + } + + @Test + void expirationFilterDteListRecordRejectsNullElementDirectly() { + List daysWithNull = new ArrayList<>(); + daysWithNull.add(30); + daysWithNull.add(null); + assertThatThrownBy(() -> new ExpirationFilter.DteList(daysWithNull)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void expirationFilterDteListRecordRejectsNullListDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( From 64ed1fff0e94dda5778a60b3aea512bbc722583c Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Wed, 14 Oct 2026 09:10:42 -0400 Subject: [PATCH 2/3] Code review fix 1: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code review fix 1: Both green from a clean build. - List test accepts an ignored filter: Replaced the `isBetween(1, 3)` assertion with `chainDteCommaListMatchesUnionOfSingleDteRequests`, which compares `dteList(15, 30, 45)`'s resulting expiration set against the union of the three corresponding single-`dte` requests — a comma-list ignored by the server would fall back to the front-month expiration and fail to match that union. - Comparison test excludes an inclusive bound: Changed the comparison test from `Operator.LT, 45` / `isLessThan(45)` to `Operator.LTE, 45` / `isLessThanOrEqualTo(45)`, matching the API's documented inclusive bounds. - DTE docs omit date and limit behavior: Added a shared note on `ExpirationFilter`'s interface Javadoc that all `dte*` variants count days from the chain request's `date` when set, or from today; added a note on `dte(int)` specifically that the API's max is 36500 and that, unlike the three new variants, this legacy factory doesn't enforce it locally; mirrored both notes in `docs/options/chain.md`. No CHANGELOG change was needed — nothing here alters behavior or the public surface, only tests and documentation. `gradle test` and `gradle build` (including Spotless, which required `spotlessApply` to reformat the new lines) both pass from a clean build. --- docs/options/chain.md | 3 +- .../sdk/OptionsIntegrationTest.java | 48 +++++++++++++++---- .../sdk/options/ExpirationFilter.java | 9 ++++ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/options/chain.md b/docs/options/chain.md index f5e980e..7ad2404 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,10 +20,11 @@ OptionsChainRequest.builder(String symbol) // Expiration selection (sealed ExpirationFilter — pick one): .expirationFilter(ExpirationFilter.onDate(LocalDate date)) - .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration, single value + .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration, single value — API max 36500, not enforced locally .expirationFilter(ExpirationFilter.dteList(int first, int... rest)) // comma-separated list, e.g. 15,30,45 .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. 0-45 .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // e.g. >=30 + // all dte* forms above count days from .date(...) below when set (a historical chain), or from today .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 8c00326..89d745d 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -13,8 +13,12 @@ import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -156,10 +160,17 @@ void chainDteSingleValueNarrowsToOneExpiration() { } @Test - void chainDteCommaListSpansAtMostOneExpirationPerValue() { - // A comma-separated dte list selects the closest expiration for each value, deduplicated — - // this test can't predict which expirations without assuming the server's "closest" rule, but - // the resulting expiration count is bounded by the number of distinct values requested. + void chainDteCommaListMatchesUnionOfSingleDteRequests() { + // A comma-separated dte list selects the closest expiration for each value, deduplicated. + // Comparing against the union of the three corresponding single-dte requests settles whether + // the list syntax actually reached the server: if it were ignored, the list call would fall + // back to the single front-month expiration, which an unconditional "1..3 expirations" + // assertion would not catch (1 is also the front-month default). + Set union = new HashSet<>(); + union.addAll(singleDteExpirations(15)); + union.addAll(singleDteExpirations(30)); + union.addAll(singleDteExpirations(45)); + List chain = client .options() @@ -171,10 +182,25 @@ void chainDteCommaListSpansAtMostOneExpirationPerValue() { .values(); assertThat(chain).isNotEmpty(); - long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); - assertThat(distinctExpirations) - .as("deduplicated closest expirations for 3 requested values") - .isBetween(1L, 3L); + Set listExpirations = + chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); + assertThat(listExpirations) + .as("dte list expirations equal the union of the single-value requests for 15, 30, 45") + .isEqualTo(union); + } + + /** The distinct expirations a single {@code dte(days)} request resolves to. */ + private Set singleDteExpirations(int days) { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(days)) + .side(OptionSide.CALL) + .build()) + .values(); + return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); } @Test @@ -199,12 +225,14 @@ void chainDteRangeReturnsOnlyExpirationsWithinBounds() { @Test void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { + // The API documents comparison bounds as inclusive, so LTE is the only operator that stays + // correct on a day an AAPL expiration lands exactly on the boundary. List chain = client .options() .chain( OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LTE, 45)) .side(OptionSide.CALL) .build()) .values(); @@ -213,7 +241,7 @@ void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { for (OptionQuote q : chain) { Integer dte = q.dte(); assertThat(dte).isNotNull(); - assertThat(dte.intValue()).isLessThan(45); + assertThat(dte.intValue()).isLessThanOrEqualTo(45); } } diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 1fcd3ce..a0bfebc 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -16,6 +16,10 @@ *

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. + * + *

All {@code dte}/{@code dteList}/{@code dteRange}/{@code dteComparison} variants count days + * from the chain request's {@code date} parameter when one is set (a historical chain), or from + * today otherwise. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, @@ -47,6 +51,11 @@ static All all() { /** * Days-to-expiration, single value — wire form {@code ?dte=N}. Limits the option chain to the * expiration date closest to {@code days}. + * + *

The API's documented maximum is 36500. Unlike {@link #dteList(int, int...)}, {@link + * #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, this factory does + * not enforce that bound locally — a larger value is sent to the wire unchanged and left for the + * API to reject. */ static Dte dte(int days) { if (days < 0) { From 2c071225b13bed6fbeb9f880cba24c5ce2bdfc56 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Wed, 14 Oct 2026 09:23:12 -0400 Subject: [PATCH 3/3] Code review fix 2: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code review fix 2: Only the integration test file changed, within limits. No CHANGELOG update needed since this round changes only integration test assertions, no behavior or public-surface change. Finding 1 (dte filtering not proven): Rewrote the four dte integration tests so each one can fail on an ignored/broken parameter: the single-value test now compares `dte(365)` against an explicit unfiltered-chain baseline and asserts the expiration differs; the list test now requires more than one distinct expiration (on top of the existing union check) using near/mid/far values (15/180/365); the range and comparison tests now use bounds (`[90, 365]`, `GTE 90`) that the front-month default cannot satisfy, instead of `[0,45]`/`LTE 45` which the unfiltered chain also happens to pass. Finding 2 (wall-clock dependency): Added a `FIXED_CHAIN_DATE` constant (`2025-06-02`, a historical NYSE trading day) and passed `.date(FIXED_CHAIN_DATE)` to every chain call used by these tests, so `dte` is always computed from that fixed day rather than from "today," removing the Eastern-midnight rollover risk between sequential requests. Ran `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` (plus `spotlessApply` for formatting); both pass. The new/changed assertions are not run here — no `MARKETDATA_TOKEN` is configured, consistent with the brief. --- .../sdk/OptionsIntegrationTest.java | 96 +++++++++++++------ 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 89d745d..3afed3d 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -138,46 +138,50 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + /** + * A fixed historical trading day for every dte* test below. All {@code dte} forms count days from + * the chain request's {@code date} when one is set, so pinning it keeps the results stable + * regardless of when the test runs — unlike omitting {@code date}, which computes dte from + * "today" and can change at the Eastern-midnight rollover between two requests in the same test. + */ + private static final LocalDate FIXED_CHAIN_DATE = LocalDate.of(2025, 6, 2); + @Test void chainDteSingleValueNarrowsToOneExpiration() { - // A single dte value selects the one expiration closest to it — not an exact date this test - // can predict, but the row count across expirations is checkable: exactly one. - List chain = - client - .options() - .chain( - OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dte(30)) - .side(OptionSide.CALL) - .build()) - .values(); - - assertThat(chain).isNotEmpty(); - long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); - assertThat(distinctExpirations) + // A single dte value selects the one expiration closest to it. 365 days is far past the + // front-month expiration the unfiltered chain returns, so comparing against the unfiltered + // baseline settles whether the dte syntax actually reached the server: an ignored dte would + // fall back to that same front-month expiration instead of a distinct, far-dated one. + Set baseline = unfilteredChainExpirations(); + Set farDte = singleDteExpirations(365); + + assertThat(farDte) .as("a single dte value narrows the chain to its one closest expiration") - .isEqualTo(1); + .hasSize(1); + assertThat(farDte) + .as("dte(365) must resolve to an expiration the unfiltered (front-month) chain does not") + .isNotEqualTo(baseline); } @Test void chainDteCommaListMatchesUnionOfSingleDteRequests() { // A comma-separated dte list selects the closest expiration for each value, deduplicated. - // Comparing against the union of the three corresponding single-dte requests settles whether - // the list syntax actually reached the server: if it were ignored, the list call would fall - // back to the single front-month expiration, which an unconditional "1..3 expirations" - // assertion would not catch (1 is also the front-month default). + // Near/mid/far values that must resolve to more than one expiration close the loophole an + // ignored dte parameter would otherwise slip through: an ignored list falls back to the single + // front-month expiration, failing both the "> 1" and the union-equality assertions below. Set union = new HashSet<>(); union.addAll(singleDteExpirations(15)); - union.addAll(singleDteExpirations(30)); - union.addAll(singleDteExpirations(45)); + union.addAll(singleDteExpirations(180)); + union.addAll(singleDteExpirations(365)); List chain = client .options() .chain( OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .expirationFilter(ExpirationFilter.dteList(15, 180, 365)) .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) .build()) .values(); @@ -185,11 +189,17 @@ void chainDteCommaListMatchesUnionOfSingleDteRequests() { Set listExpirations = chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); assertThat(listExpirations) - .as("dte list expirations equal the union of the single-value requests for 15, 30, 45") + .as("a list spanning near/mid/far dte values must resolve to more than one expiration") + .hasSizeGreaterThan(1); + assertThat(listExpirations) + .as("dte list expirations equal the union of the single-value requests for 15, 180, 365") .isEqualTo(union); } - /** The distinct expirations a single {@code dte(days)} request resolves to. */ + /** + * The distinct expirations a single {@code dte(days)} request resolves to, on {@link + * #FIXED_CHAIN_DATE}. + */ private Set singleDteExpirations(int days) { List chain = client @@ -198,20 +208,42 @@ private Set singleDteExpirations(int days) { OptionsChainRequest.builder(UNDERLYING) .expirationFilter(ExpirationFilter.dte(days)) .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) .build()) .values(); return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); } + /** + * The distinct expirations the chain resolves to with no expiration filter at all, on {@link + * #FIXED_CHAIN_DATE} — the front-month default that an ignored dte parameter would fall back to. + */ + private Set unfilteredChainExpirations() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + assertThat(chain).isNotEmpty(); + return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); + } + @Test void chainDteRangeReturnsOnlyExpirationsWithinBounds() { + // The lower bound is set above the front-month window so an ignored dte would return rows + // outside [90, 365] and fail the per-row assertion below, rather than passing by coincidence. List chain = client .options() .chain( OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .expirationFilter(ExpirationFilter.dteRange(90, 365)) .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) .build()) .values(); @@ -219,21 +251,23 @@ void chainDteRangeReturnsOnlyExpirationsWithinBounds() { for (OptionQuote q : chain) { Integer dte = q.dte(); assertThat(dte).isNotNull(); - assertThat(dte.intValue()).isBetween(0, 45); + assertThat(dte.intValue()).isBetween(90, 365); } } @Test void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { - // The API documents comparison bounds as inclusive, so LTE is the only operator that stays - // correct on a day an AAPL expiration lands exactly on the boundary. + // The API documents comparison bounds as inclusive. The threshold is set above the + // front-month window so an ignored dte would return rows that violate the >= 90 bound below, + // rather than passing by coincidence the way a loose "<= 45" would against the front month. List chain = client .options() .chain( OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LTE, 45)) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 90)) .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) .build()) .values(); @@ -241,7 +275,7 @@ void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { for (OptionQuote q : chain) { Integer dte = q.dte(); assertThat(dte).isNotNull(); - assertThat(dte.intValue()).isLessThanOrEqualTo(45); + assertThat(dte.intValue()).isGreaterThanOrEqualTo(90); } }