From 6fc37a6280fe54206a22311ff27f7e1cdffe2019 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 13 Oct 2026 11:23:01 -0400 Subject: [PATCH 1/5] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Only the two permitted test files were touched; `gradle test --no-daemon --console=plain` was run once and failed at `compileTestJava`, as required. Summary: - Unit tests (`OptionsResourceTest.java`): added wire tests for the four new `dte` forms the delta enumerates — comma list (`dteList`), closed range (`dteRange`), and comparison (`dteComparison`, one test per `StrikeFilter.Operator` value: GT/GTE/LT/LTE) — plus a regression test proving the pre-existing `dte(int)` single-value factory still sends values above the new 36500-day bound unchanged (no new local check, no new error type), and validation tests for the new factories' empty-list, out-of-range, and min>max inputs. - Integration tests (`OptionsIntegrationTest.java`): added one live test per `dte` form (single, list, range, comparison) asserting decoded `OptionQuote` shape/bounds, not just status. - Chose to keep `ExpirationFilter.dte(int)` untouched and add new sealed-interface factories (`dteList`, `dteRange`, `dteComparison` reusing `StrikeFilter.Operator`) so the existing method's error behavior is preserved per repo convention — this is a design choice for phase 2 to implement, left undone here by design. implement: All within allowed limits (main sources, test sources, docs, and the separately-built examples tree). No `CHANGELOG.md` edit, no commit made. Implemented the `dte` type widening as three new `ExpirationFilter` sealed variants (`DteList`, `DteRange`, `DteComparison`, the last reusing `StrikeFilter.Operator`) alongside the unchanged `Dte(int)`, so the pre-existing single-value call keeps its old behavior and error types. - Documentation: added each new factory's Javadoc, an interface-level note on the four `dte` syntaxes, and updated `docs/options/chain.md`'s signature list and example. - Public member docs: every new public method, record, and constant carries its own doc comment. - Wire test per form/enum value: unit tests cover single (regression), list, range, and each of the four comparison operators individually. - Validation on every public path: each new record has a compact constructor (not just the static factory) enforcing non-negative/≤36500/min≤max/non-null, with direct-construction tests including an empty list, a null list, a null list element, and a null operator. - Coverage: no exclusions added; the only uncovered branches are pre-existing ones unrelated to this change (`monthYear`, `formatStrike`, a parser attribute check). - Examples/docs still correct: `OptionsExample.java`, `README.md`, and the Kotlin snippet in `chain.md` don't touch `dte` and remain accurate as-is; added a `dteRange` call to `OptionsAdvancedExample.java` (not compiled by this build) for coverage of the new forms. - Left unchanged, as they don't reference `dte`/`ExpirationFilter` or are historical: `docs/adr/ADR-008-*.md` (decision record, additive change doesn't contradict it), `docs/client.md`, `docs/options/quotes.md` (describes the unrelated response field), `docs/options/{README,expirations,lookup}.md`, `docs/.sync-manifest.txt`, `docs/adr/ADR-006-*.md`, `MarketDataClient.java`, `OptionsCsvResource.java`, `OptionsChain.java`, `OptionsHtmlResource.java`, `JsonResponseParserTest.java`, `RequestValidationTest.java`. - Outside my limits and left as-is: `.github/ISSUE_TEMPLATE/bug.yml`, `build.gradle.kts`, `.github/BUG_FINDING.md` — none reference `dte` specifically, so nothing is left wrong by this change. changelog: Added `### Added` section under `## [Unreleased]` documenting the new `DteList`, `DteRange`, `DteComparison` sealed variants and their factory methods (`dteList`, `dteRange`, `dteComparison`), the `MAX_DTE_DAYS` constant, and the API 2.0.0 release that prompted them. --- CHANGELOG.md | 9 + docs/options/chain.md | 19 +- .../resources/OptionsAdvancedExample.java | 9 + .../sdk/OptionsIntegrationTest.java | 84 +++++++ .../com/marketdata/sdk/OptionsResource.java | 17 ++ .../sdk/options/ExpirationFilter.java | 99 ++++++++- .../marketdata/sdk/OptionsResourceTest.java | 206 ++++++++++++++++++ 7 files changed, 441 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..cc09129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Options chain `dte` parameter widening** (API 2.0.0) — the options chain endpoint's + `dte` days-to-expiration filter now supports comma-separated lists, closed ranges, and + comparisons alongside single values. New sealed `ExpirationFilter` variants `DteList`, + `DteRange`, `DteComparison` and their factories `dteList(int...)`, `dteRange(int, int)`, + and `dteComparison(StrikeFilter.Operator, int)` expose all four syntaxes; a shared upper + bound `MAX_DTE_DAYS` limits values per the API. + ## [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..9693296 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... days)) // dte, comma-separated list + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte, closed range + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // dte, comparison .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -95,6 +98,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's other wire syntaxes: a comma-separated list, a closed range, and a comparison. + 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.GTE, 30)) + .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..39b571b 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,15 @@ 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, and a comparison — on top of the + // single value used above. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()); + // 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..213834f 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 chainDteSingleValueSelectsOneNearestExpiration() { + // Regression: the single-value form of dte already worked before dte widened to also accept + // list / range / comparison syntax — assert the server still narrows to exactly one nearest + // expiration for it. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .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 nearest expiration") + .isEqualTo(1); + } + + @Test + void chainDteCommaListSelectsClosestExpirationPerValue() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a dte list selects at most one nearest expiration per value, deduplicated") + .isBetween(1L, 3L); + } + + @Test + void chainDteRangeReturnsExpirationsWithinInclusiveBounds() { + 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 chainDteComparisonReturnsExpirationsAboveThreshold() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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..2980af0 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", joinDays(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())); @@ -344,6 +350,17 @@ private static String formatStrike(double v) { return Double.toString(v); } + private static String joinDays(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(); + } + // ---------- wire-format module ---------- static SimpleModule wireFormatModule() { diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..510aa4a 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; /** @@ -14,14 +16,28 @@ *

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} itself has four wire syntaxes — single value ({@link #dte(int)}), comma-separated + * list ({@link #dteList(int...)}), closed range ({@link #dteRange(int, int)}), and comparison + * ({@link #dteComparison(StrikeFilter.Operator, int)}) — modeled as four sibling variants rather + * than widening {@link #dte(int)} itself, so the pre-existing single-value call keeps compiling and + * keeps whatever error (local or server-side) it produced before. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { + /** + * Upper bound on a single {@code dte} value, shared by every {@code dte} syntax — per the API. + */ + int MAX_DTE_DAYS = 36500; + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); @@ -39,7 +55,10 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Single days-to-expiration filter — wire form {@code ?dte=N}. Limits the chain to the one + * expiration whose dte is closest to {@code days}. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +66,37 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. The API resolves the closest + * expiration to each value independently, deduplicated. {@code days} must be non-empty; every + * value is validated the same way as {@link #dte(int)}. + */ + static DteList dteList(int... days) { + List list = new ArrayList<>(days.length); + for (int day : days) { + list.add(day); + } + return new DteList(list); + } + + /** + * Days-to-expiration range filter — wire form {@code ?dte=0-45}. Returns every expiration whose + * dte falls in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}; both bounds + * are validated the same way as {@link #dte(int)}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form {@code ?dte=>=30} / {@code ?dte=<45}. Returns + * every expiration whose dte satisfies {@code operator days}. {@code days} is validated the same + * way as {@link #dte(int)}. + */ + 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}. @@ -77,12 +127,59 @@ record OnDate(LocalDate date) implements ExpirationFilter { } } + // Dte predates MAX_DTE_DAYS and keeps its original, factory-only non-negative check (no compact + // constructor) so an existing caller's behavior — including constructing it unvalidated via + // `new Dte(...)` — does not change for this release. record Dte(int days) implements ExpirationFilter {} + /** See {@link #dteList(int...)}. {@code days} is defensively copied and immutable. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("dteList must not be empty"); + } + for (Integer day : days) { + Objects.requireNonNull(day, "days element"); + validateDteValue(day); + } + days = List.copyOf(days); + } + } + + /** See {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDteValue(min); + validateDteValue(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** See {@link #dteComparison(StrikeFilter.Operator, int)}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + validateDteValue(days); + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} /** The whole chain across every expiration — see {@link #all()}. Carries no data of its own. */ record All() implements ExpirationFilter {} + + /** Bounds check backing {@link #dteList}, {@link #dteRange}, and {@link #dteComparison}. */ + private static void validateDteValue(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > MAX_DTE_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DTE_DAYS); + } + } } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..90ce552 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -27,6 +27,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -975,6 +976,121 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteStillAcceptsValuesBeyondTheNewUpperBoundRegression() { + // dte's wire type widened to accept list/range/comparison forms, but the pre-existing + // single-value factory must not gain a new local upper-bound check: a value over the API's + // documented 36500-day max still reaches the wire unchanged, so an existing caller keeps + // seeing the same (lack of) local validation — and the same API error type — as before this + // change. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(40000)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=40000"); + } + + @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(); + + 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 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 chainExpirationFilterDteComparisonGtTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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 +1319,96 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(ExpirationFilter::dteList) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("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("36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsDaysAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + // ---------- new dte-filter records: validation holds on direct construction too ---------- + + @Test + void dteListRecordRejectsEmptyWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be empty"); + } + + @Test + void dteListRecordRejectsNullElementWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(Arrays.asList(30, null))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteListRecordRejectsNullListWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteRangeRecordRejectsMinGreaterThanMaxWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteComparisonRecordRejectsNullOperatorWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( From 98c4879937dbeaab964e535aab353f4c4304aee9 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 13 Oct 2026 11:31:40 -0400 Subject: [PATCH 2/5] 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: Finding: Document the differing DTE validation (`ExpirationFilter.java`) — fixed. Reworded the `MAX_DTE_DAYS` javadoc, `dte(int)`'s javadoc, and each of `dteList`/`dteRange`/`dteComparison`'s javadocs to state plainly that only the three new forms enforce the 36500-day cap locally, while `dte(int)` checks only non-negativity and lets an over-cap value reach the API as a server-side rejection. No behavior, public surface, or test changed — doc wording only, so `CHANGELOG.md` is left as is. `gradle test` and `gradle build` both pass (the latter required a `spotlessApply` re-wrap of the new javadoc comment to satisfy formatting). --- .../sdk/options/ExpirationFilter.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 510aa4a..8610b26 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -34,7 +34,11 @@ public sealed interface ExpirationFilter ExpirationFilter.All { /** - * Upper bound on a single {@code dte} value, shared by every {@code dte} syntax — per the API. + * Upper bound on a single {@code dte} value, per the API. Enforced locally by {@link + * #dteList(int...)}, {@link #dteRange(int, int)}, and {@link + * #dteComparison(StrikeFilter.Operator, int)}. {@link #dte(int)} predates this bound and does not + * check it — a value above it reaches the API and comes back as a server-side rejection rather + * than an {@link IllegalArgumentException}. */ int MAX_DTE_DAYS = 36500; @@ -58,6 +62,11 @@ static All all() { /** * Single days-to-expiration filter — wire form {@code ?dte=N}. Limits the chain to the one * expiration whose dte is closest to {@code days}. + * + *

Only rejects a negative {@code days} locally; unlike {@link #dteList(int...)}, {@link + * #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, it does not + * enforce {@link #MAX_DTE_DAYS} — a value above that bound reaches the API and fails as a server + * rejection instead of an {@link IllegalArgumentException}. */ static Dte dte(int days) { if (days < 0) { @@ -69,7 +78,8 @@ static Dte dte(int days) { /** * Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. The API resolves the closest * expiration to each value independently, deduplicated. {@code days} must be non-empty; every - * value is validated the same way as {@link #dte(int)}. + * value must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, both + * bounds are enforced locally. */ static DteList dteList(int... days) { List list = new ArrayList<>(days.length); @@ -82,7 +92,8 @@ static DteList dteList(int... days) { /** * Days-to-expiration range filter — wire form {@code ?dte=0-45}. Returns every expiration whose * dte falls in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}; both bounds - * are validated the same way as {@link #dte(int)}. + * must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper + * bound is enforced locally. */ static DteRange dteRange(int min, int max) { return new DteRange(min, max); @@ -90,8 +101,9 @@ static DteRange dteRange(int min, int max) { /** * Days-to-expiration comparison filter — wire form {@code ?dte=>=30} / {@code ?dte=<45}. Returns - * every expiration whose dte satisfies {@code operator days}. {@code days} is validated the same - * way as {@link #dte(int)}. + * every expiration whose dte satisfies {@code operator days}. {@code days} must be non-negative + * and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper bound is enforced + * locally. */ static DteComparison dteComparison(StrikeFilter.Operator operator, int days) { return new DteComparison(operator, days); From 6b2b63a2062439f6b0fb4e3c789ad968cb5ac38a Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 13 Oct 2026 11:40:50 -0400 Subject: [PATCH 3/5] 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: This fix is doc/test-only (no public surface or behavior change), so `CHANGELOG.md` is left as-is. [Should fix] Live list test does not prove filtering — fixed in `OptionsIntegrationTest.java`. The test now independently resolves the nearest expiration for each of 15, 30, and 45 days via three single-value `dte()` calls, then asserts the `dteList(15, 30, 45)` chain's distinct expirations equal exactly that expected set — so the test fails if the server ignores the list or returns a different set, rather than merely bounding the count to 1–3. `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass (the latter also compiles the integration-test source set); the new/extra live calls are not run here per the brief. --- .../sdk/OptionsIntegrationTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 213834f..832cc12 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -15,6 +15,8 @@ import java.time.LocalDate; 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; @@ -159,6 +161,10 @@ void chainDteSingleValueSelectsOneNearestExpiration() { @Test void chainDteCommaListSelectsClosestExpirationPerValue() { + Set expectedExpirations = + Set.of( + nearestExpirationForDte(15), nearestExpirationForDte(30), nearestExpirationForDte(45)); + List chain = client .options() @@ -171,10 +177,30 @@ void chainDteCommaListSelectsClosestExpirationPerValue() { .values(); assertThat(chain).isNotEmpty(); - long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); - assertThat(distinctExpirations) - .as("a dte list selects at most one nearest expiration per value, deduplicated") - .isBetween(1L, 3L); + Set actualExpirations = + chain.stream().map(q -> q.expiration().toLocalDate()).collect(Collectors.toSet()); + assertThat(actualExpirations) + .as( + "a dte list resolves to exactly the nearest expirations independently resolved for" + + " 15, 30, and 45 days, deduplicated") + .isEqualTo(expectedExpirations); + } + + /** Resolves the single nearest expiration a single-value {@code dte} filter selects. */ + private LocalDate nearestExpirationForDte(int dte) { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(dte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).as("dte(%d) resolves to a nearest expiration", dte).isNotEmpty(); + return chain.get(0).expiration().toLocalDate(); } @Test From 3ef4f77055dbaea593a2987012b70b6f309d3ee6 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 13 Oct 2026 11:54:48 -0400 Subject: [PATCH 4/5] CI repair 1: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fix 1: CI's `integration` job failed because `chainDteCommaListSelectsClosestExpirationPerValue` threw an `AssertionError` at line 202, inside its `nearestExpirationForDte` helper's `isNotEmpty()` check — one of three separate live `dte(15)`/`dte(30)`/`dte(45)` round trips used as the test's oracle came back empty against real data. I rewrote the oracle to derive the expected nearest expirations locally from a single `ExpirationFilter.all()` chain (already proven non-empty by the neighboring `chainExpirationAllSpansMultipleExpirations` test) instead of three extra live calls, removing the dependency on the single-value `dte()` form sharing the list form's tie-breaking and removing two round trips that could land on a different trading moment than the call under test. This also fixes both residual review findings from round 2: the oracle is no longer derived from the same parameter under test, and nullable `expiration()` dereferences now go through `Objects.requireNonNull`. The change is test-only (no production code, no public-surface or behavior change), so `CHANGELOG.md` is left as is; `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass. --- .../sdk/OptionsIntegrationTest.java | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 832cc12..dd4c57a 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -161,9 +161,30 @@ void chainDteSingleValueSelectsOneNearestExpiration() { @Test void chainDteCommaListSelectsClosestExpirationPerValue() { + // The oracle is one all() chain, not three separate dte() calls: deriving "nearest" locally + // from a single request avoids assuming the single-value and list forms break ties the same + // way, and avoids extra round trips that could land on a different trading moment than the + // list call under test. + List fullChain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(fullChain).isNotEmpty(); + Set expectedExpirations = Set.of( - nearestExpirationForDte(15), nearestExpirationForDte(30), nearestExpirationForDte(45)); + nearestExpirationTo(fullChain, 15), + nearestExpirationTo(fullChain, 30), + nearestExpirationTo(fullChain, 45)); + assertThat(expectedExpirations) + .as("15, 30, and 45 days independently resolve to more than one distinct expiration") + .hasSizeGreaterThan(1); List chain = client @@ -178,7 +199,9 @@ void chainDteCommaListSelectsClosestExpirationPerValue() { assertThat(chain).isNotEmpty(); Set actualExpirations = - chain.stream().map(q -> q.expiration().toLocalDate()).collect(Collectors.toSet()); + chain.stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate()) + .collect(Collectors.toSet()); assertThat(actualExpirations) .as( "a dte list resolves to exactly the nearest expirations independently resolved for" @@ -186,21 +209,23 @@ void chainDteCommaListSelectsClosestExpirationPerValue() { .isEqualTo(expectedExpirations); } - /** Resolves the single nearest expiration a single-value {@code dte} filter selects. */ - private LocalDate nearestExpirationForDte(int dte) { - List chain = - client - .options() - .chain( - OptionsChainRequest.builder(UNDERLYING) - .expirationFilter(ExpirationFilter.dte(dte)) - .side(OptionSide.CALL) - .strikeLimit(1) - .build()) - .values(); - - assertThat(chain).as("dte(%d) resolves to a nearest expiration", dte).isNotEmpty(); - return chain.get(0).expiration().toLocalDate(); + /** Finds, among {@code chain}'s rows, the expiration whose dte is closest to {@code target}. */ + private static LocalDate nearestExpirationTo(List chain, int target) { + OptionQuote nearest = null; + int nearestDistance = Integer.MAX_VALUE; + for (OptionQuote q : chain) { + Integer dte = q.dte(); + if (dte == null) { + continue; + } + int distance = Math.abs(dte - target); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = q; + } + } + java.util.Objects.requireNonNull(nearest, "no row carried a dte value"); + return java.util.Objects.requireNonNull(nearest.expiration(), "expiration").toLocalDate(); } @Test From e1790885fefa6d4f67b954670899291a26725344 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 13 Oct 2026 12:04:04 -0400 Subject: [PATCH 5/5] CI repair 2: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fix 2: `gradle test` and `gradle build` both pass. Change is within `src/integrationTest/`. CI failed in the `integration` job (`Integration tests (live API, JDK 17)`) at `OptionsIntegrationTest.chainDteCommaListSelectsClosestExpirationPerValue` (line 209, `isEqualTo(expectedExpirations)`): the oracle reimplemented the server's nearest-expiration tie-break (absolute distance, first-row) locally, and that doesn't reliably match the server's own choice — exactly the residual risk the prior repair round's reviewer had already flagged. I replaced the exact-equality oracle with the shape assertion that reviewer suggested as the fallback: actual expirations must be a subset of the full `all()` chain's expirations, number more than one, and at most three — this proves the list reaches the server and narrows per-value without assuming its tie-break algorithm. Removed the now-unused `nearestExpirationTo` helper; no production code, public surface, or CHANGELOG change, since this is test-only. --- .../sdk/OptionsIntegrationTest.java | 58 ++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index dd4c57a..4e3aee3 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -161,11 +161,12 @@ void chainDteSingleValueSelectsOneNearestExpiration() { @Test void chainDteCommaListSelectsClosestExpirationPerValue() { - // The oracle is one all() chain, not three separate dte() calls: deriving "nearest" locally - // from a single request avoids assuming the single-value and list forms break ties the same - // way, and avoids extra round trips that could land on a different trading moment than the - // list call under test. - List fullChain = + // A full all() chain bounds what "a subset of the chain" means without reimplementing the + // server's own nearest-expiration tie-break: that algorithm is the server's to pick, and a + // local reimplementation of it (absolute distance, first-row tie-break) is not guaranteed to + // match, so this asserts shape instead — the list narrows to more than one and at most three + // of the chain's own expirations, which is only possible if the server honored every value. + Set chainExpirations = client .options() .chain( @@ -174,17 +175,11 @@ void chainDteCommaListSelectsClosestExpirationPerValue() { .side(OptionSide.CALL) .strikeLimit(1) .build()) - .values(); - assertThat(fullChain).isNotEmpty(); - - Set expectedExpirations = - Set.of( - nearestExpirationTo(fullChain, 15), - nearestExpirationTo(fullChain, 30), - nearestExpirationTo(fullChain, 45)); - assertThat(expectedExpirations) - .as("15, 30, and 45 days independently resolve to more than one distinct expiration") - .hasSizeGreaterThan(1); + .values() + .stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate()) + .collect(Collectors.toSet()); + assertThat(chainExpirations).isNotEmpty(); List chain = client @@ -203,29 +198,14 @@ void chainDteCommaListSelectsClosestExpirationPerValue() { .map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate()) .collect(Collectors.toSet()); assertThat(actualExpirations) - .as( - "a dte list resolves to exactly the nearest expirations independently resolved for" - + " 15, 30, and 45 days, deduplicated") - .isEqualTo(expectedExpirations); - } - - /** Finds, among {@code chain}'s rows, the expiration whose dte is closest to {@code target}. */ - private static LocalDate nearestExpirationTo(List chain, int target) { - OptionQuote nearest = null; - int nearestDistance = Integer.MAX_VALUE; - for (OptionQuote q : chain) { - Integer dte = q.dte(); - if (dte == null) { - continue; - } - int distance = Math.abs(dte - target); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = q; - } - } - java.util.Objects.requireNonNull(nearest, "no row carried a dte value"); - return java.util.Objects.requireNonNull(nearest.expiration(), "expiration").toLocalDate(); + .as("every expiration a dte list selects is one the full chain actually has") + .isSubsetOf(chainExpirations); + assertThat(actualExpirations) + .as("three distinct dte targets (15, 30, 45) resolve to more than one distinct expiration") + .hasSizeGreaterThan(1); + assertThat(actualExpirations) + .as("a three-value dte list narrows to at most three expirations, not the whole chain") + .hasSizeLessThanOrEqualTo(3); } @Test