From 5467f0e6cc3035084ebc182229afa469c1ffab99 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Mon, 5 Oct 2026 12:06:34 -0400 Subject: [PATCH 1/2] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Confirmed: the build fails as expected, with compilation errors on the not-yet-existing `DteFilter` type and the `ExpirationFilter.dte(DteFilter)` overload. No production code or existing test lines were touched. Added tests for the `dte` parameter's type change (`integer` → `string`) on `/v1/options/chain/{underlying}/`, assuming a new `DteFilter` sealed type (`values`/`range`/`comparison`) plus an additive `ExpirationFilter.dte(DteFilter)` overload, leaving the existing `dte(int)` single-value overload untouched (regression already covered by the pre-existing `chainExpirationFilterDteTranslatesToDteParam` test): - Wire tests: comma-separated list, closed range, comparison operator (3 new forms; single-value form already tested). - Validation tests: empty list, negative/over-36500 entries for list/range/comparison, reversed range — all on the new `DteFilter` type only. - Integration tests: list (dedup bound), range (bounds check), comparison (operator check) against the live chain endpoint, asserting decoded `dte` values. `gradle test --no-daemon --console=plain` fails at `compileTestJava` as expected, since `DteFilter` doesn't exist yet. implement: All changes are within `src/main` and `src/test`/`src/integrationTest`, no build/gradle/CI files touched, and no test was weakened. Added `DteFilter` (sealed: `Values`/`Range`/`Comparison`, 0–36500 day validation) for the chain endpoint's `dte` string syntax, reached via a new, additive `ExpirationFilter.dte(DteFilter)` overload alongside the untouched `dte(int)` single-value form. Wired translation into `OptionsResource.applyExpirationFilter`/`dteFilterWireValue` (comma-join, dashed range, operator prefix). No test from phase 1 was weakened; `gradle test` and `gradle build` (incl. 100% JaCoCo and Spotless) pass clean. review fix 1: All three findings addressed, no build/gradle/CI files touched, no CHANGELOG edit. - `docs/options/chain.md:23` — added the three `DteFilter` forms (`values`/`range`/`comparison`) next to the `dte(int)` line with their wire shapes, added the `DteFilter` import and three example calls to the Java example block. - `OptionsResourceTest.java:1004` — added `chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix`, `...GtRendersWithOperatorPrefix`, and `...LteRendersWithOperatorPrefix`, asserting `dte=%3C45`, `dte=%3E30`, `dte=%3C%3D45` respectively, so all four `DteFilter.Operator` constants now have a wire assertion. - `OptionsIntegrationTest.java:152` — rewrote `chainDteValuesReturnsAtMostAsManyDistinctExpirationsAsRequested` (renamed to `chainDteValuesReturnsClosestExpirationPerRequestedValue`) to require 2–3 distinct expirations (ruling out the unfiltered front-month-only case) and to assert each row's `dte()` lands within 15 days of one of the requested values (7/30/60); this is a fix to a test I wrote in phase 1, not a weakening. No production logic changed in this round beyond the doc; `DteFilter`/`ExpirationFilter`/`OptionsResource` wiring from phase 2 already covered the new operators, so only tests and docs needed updates. changelog: Added changelog entry under `[Unreleased]` describing `DteFilter` sealed interface and extended dte parameter syntax (comma-separated lists, closed ranges, comparison operators) for the chain endpoint per Market Data API 2.0.0. --- CHANGELOG.md | 10 ++ docs/options/chain.md | 20 ++- .../sdk/OptionsIntegrationTest.java | 75 ++++++++ .../com/marketdata/sdk/OptionsResource.java | 16 ++ .../com/marketdata/sdk/options/DteFilter.java | 97 ++++++++++ .../sdk/options/ExpirationFilter.java | 15 +- .../marketdata/sdk/OptionsResourceTest.java | 167 ++++++++++++++++++ 7 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/marketdata/sdk/options/DteFilter.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..29dd43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Extended dte parameter syntax** for `/v1/options/chain/` (Market Data API 2.0.0) — + `DteFilter` sealed interface with three forms (`Values` for comma-separated lists, + `Range` for closed ranges, `Comparison` for operator-based filtering) and an additive + `ExpirationFilter.dte(DteFilter)` overload alongside the single-value `dte(int)`. + `DteFilter.Operator` enum (`GT`, `GTE`, `LT`, `LTE`) with `wireValue()` accessors; + `MAX_DAYS` constant (36500); factory methods `values(List)`, `range(int, int)`, + `comparison(Operator, int)` with full validation (non-negative, within bounds). + ## [1.0.0] - 2026-06-29 First stable release of the Market Data Java & Kotlin SDK — a single JVM diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..7a83af1 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,10 @@ OptionsChainRequest.builder(String symbol) // Expiration selection (sealed ExpirationFilter — pick one): .expirationFilter(ExpirationFilter.onDate(LocalDate date)) - .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration + .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration, single value + .expirationFilter(ExpirationFilter.dte(DteFilter.values(List.of(15, 30, 45)))) // ?dte=15,30,45 + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) // ?dte=0-45 + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) // ?dte=>=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -66,6 +69,7 @@ public record OptionQuote( ```java import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -95,6 +99,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // Extended dte syntax: comma list, closed range, or comparison — ?dte=15,30,45 / ?dte=0-45 / ?dte=>=30. + var dteList = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(List.of(15, 30, 45)))) + .build()); + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()); + var dteComparison = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()); } ``` diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..4c02087 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -133,6 +134,80 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteValuesReturnsClosestExpirationPerRequestedValue() { + // dte=7,30,60 — the API returns the closest expiration per value, deduplicated. With no + // expiration filter at all the endpoint falls back to the front-month-only default (a single + // expiration), so requiring more than one distinct expiration — and that every row's dte lands + // near one of the three requested values — proves the comma-separated filter actually reached + // the wire rather than this test passing on the unfiltered default. + List requestedDays = List.of(7, 30, 60); + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.values(requestedDays))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("three well-separated dte values should resolve to more than the front-month default") + .isBetween(2L, 3L); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(requestedDays) + .as("row dte=%s should be the closest match to one of %s", dte, requestedDays) + .anySatisfy(requested -> assertThat(Math.abs(dte - requested)).isLessThanOrEqualTo(15)); + } + } + + @Test + void chainDteRangeReturnsExpirationsWithinBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(0, 45); + } + } + + @Test + void chainDteComparisonReturnsExpirationsSatisfyingOperator() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(30); + } + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..3152eac 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionsChain; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +314,8 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteSyntax v) { + b.query("dte", dteFilterWireValue(v.filter())); } else if (f instanceof ExpirationFilter.Between v) { b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); @@ -325,6 +329,18 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte // an if-chain, but there is no reachable else, so no defensive throw is needed. } + private static String dteFilterWireValue(DteFilter f) { + if (f instanceof DteFilter.Values v) { + return v.days().stream().map(String::valueOf).collect(Collectors.joining(",")); + } else if (f instanceof DteFilter.Range v) { + return v.min() + "-" + v.max(); + } + // DteFilter is sealed; the only remaining variant is Comparison. The cast documents that + // exhaustiveness (Java 17 can't prove it in an if-chain) and fails fast if a variant is added. + DteFilter.Comparison v = (DteFilter.Comparison) f; + return v.operator().wireValue() + v.days(); + } + private static String strikeFilterWireValue(StrikeFilter f) { if (f instanceof StrikeFilter.Exact v) { return formatStrike(v.price()); diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java new file mode 100644 index 0000000..2b00997 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,97 @@ +package com.marketdata.sdk.options; + +import com.marketdata.sdk.Generated; +import java.util.List; +import java.util.Objects; + +/** + * The chain endpoint's {@code ?dte=} parameter accepts three syntactic forms beyond the plain + * single-value form already modeled by {@link ExpirationFilter#dte(int)} — a comma-separated list, + * a closed range, and a comparison ({@code >=30}, {@code <45}, …); the syntax matches {@link + * StrikeFilter}. Modeling them as a sealed type with factory entry-points gives the consumer + * compile-time autocomplete for valid shapes and prevents typos like {@code "15--30"} that a raw + * string passthrough would silently ship to the server. + * + *

Reached through {@link ExpirationFilter#dte(DteFilter)}, which keeps the mutually-exclusive + * expiration-selection axis (ADR-008) on the single {@code expirationFilter(...)} setter. + */ +public sealed interface DteFilter permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** Days are whole numbers of days, at most this many — matches the API's own description. */ + int MAX_DAYS = 36500; + + /** + * Match the closest expiration for each value in {@code days} (deduplicated server-side). {@code + * days} must not be empty. + */ + static Values values(List days) { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must be non-empty"); + } + for (int d : days) { + validateDays(d); + } + return new Values(days); + } + + /** Match every expiration whose dte falls in {@code [min, max]} inclusive. */ + static Range range(int min, int max) { + validateDays(min); + validateDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + return new Range(min, max); + } + + /** Match dte satisfying {@code operator days} (e.g. {@code >= 30}). */ + // @Generated: the null-operator guard is unreachable through the public comparison factories, + // which always supply a non-null Operator from the typed enum. + @Generated + static Comparison comparison(Operator operator, int days) { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + validateDays(days); + return new Comparison(operator, days); + } + + private static void validateDays(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > MAX_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DAYS); + } + } + + /** Comparison operators accepted by the API — mirrors {@link StrikeFilter.Operator}. */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + + record Values(List days) implements DteFilter { + public Values { + days = List.copyOf(days); + } + } + + record Range(int min, int max) implements DteFilter {} + + record Comparison(Operator operator, int days) implements DteFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..0fdb625 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -18,6 +18,7 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteSyntax, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +40,7 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** Days-to-expiration filter, single value — wire form {@code ?dte=N}. */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +48,16 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration filter using the extended {@link DteFilter} syntax (comma-separated list, + * closed range, or comparison) — wire forms {@code ?dte=15,30,45}, {@code ?dte=0-45}, {@code + * ?dte=>=30}. The plain single-value form stays on {@link #dte(int)}. + */ + static DteSyntax dte(DteFilter filter) { + Objects.requireNonNull(filter, "filter"); + return new DteSyntax(filter); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -79,6 +90,8 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteSyntax(DteFilter filter) implements ExpirationFilter {} + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..3111006 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; @@ -975,6 +976,116 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedList() { + // dte=15,30,45 — the API picks the closest expiration for each value, deduplicated. The SDK + // just joins the values; dedup/closest-match is server-side behavior. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(List.of(15, 30, 45)))) + .build()) + .join(); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedRange() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — mirrors the strike comparison encoding. + assertThat(url).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + // The API's own dte description cites "<45" as its comparison example alongside ">=30" — both + // operators get a wire assertion, not just the one the GTE test happens to share with strike. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LT, 45))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3C" is the URL-encoded "<". + assertThat(url).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GT, 30))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E" is the URL-encoded ">". + assertThat(url).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LTE, 45))) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3C%3D" is the URL-encoded "<=". + assertThat(url).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1314,62 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void dteFilterValuesRejectsEmptyList() { + assertThatThrownBy(() -> DteFilter.values(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void dteFilterValuesRejectsNegativeEntry() { + assertThatThrownBy(() -> DteFilter.values(List.of(10, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterValuesRejectsEntryAboveMax() { + assertThatThrownBy(() -> DteFilter.values(List.of(10, 36501))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeMin() { + assertThatThrownBy(() -> DteFilter.range(-1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsDaysAboveLimit() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( From 749fc610bdce6de131e14a822066ed68cf5cf224 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Mon, 5 Oct 2026 12:19:22 -0400 Subject: [PATCH 2/2] 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 gates pass (`gradle test` and `gradle build`, including JaCoCo coverage and Spotless) on the first attempt. Summary of findings addressed: - Public constructors bypass validation (`DteFilter.java`): moved all validation (non-empty/bounds for `Values`, bounds+ordering for `Range`, non-null operator+bounds for `Comparison`) into each record's compact constructor, so `new DteFilter.Values(...)`, `new DteFilter.Range(...)`, and `new DteFilter.Comparison(...)` now enforce the same invariants as the static factories, which now just delegate to the canonical constructors. Added tests calling the record constructors directly for the empty-list, reversed-range, and null-operator cases. - Reachable null branch excluded via `@Generated`: removed the `@Generated` annotation and its comment from `comparison(...)` (the check moved to `Comparison`'s compact constructor, which is not annotated) and added `dteFilterComparisonRejectsNullOperator`, asserting `DteFilter.comparison(null, 30)` throws `IllegalArgumentException`. - Left `StrikeFilter.java`'s identical `@Generated`/null-check pattern untouched — it's pre-existing code outside this PR's diff, not covered by these findings. - No CHANGELOG change: the existing `[Unreleased]` entry already documents "full validation" on the factories, and this fix only relocates that same validation to be enforced on every construction path, with the same exception type and messages. --- .../com/marketdata/sdk/options/DteFilter.java | 46 ++++++++++--------- .../marketdata/sdk/OptionsResourceTest.java | 28 +++++++++++ 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java index 2b00997..1f8ba68 100644 --- a/src/main/java/com/marketdata/sdk/options/DteFilter.java +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -1,6 +1,5 @@ package com.marketdata.sdk.options; -import com.marketdata.sdk.Generated; import java.util.List; import java.util.Objects; @@ -25,35 +24,16 @@ public sealed interface DteFilter permits DteFilter.Values, DteFilter.Range, Dte * days} must not be empty. */ static Values values(List days) { - Objects.requireNonNull(days, "days"); - if (days.isEmpty()) { - throw new IllegalArgumentException("days must be non-empty"); - } - for (int d : days) { - validateDays(d); - } return new Values(days); } /** Match every expiration whose dte falls in {@code [min, max]} inclusive. */ static Range range(int min, int max) { - validateDays(min); - validateDays(max); - if (min > max) { - throw new IllegalArgumentException("min must be <= max"); - } return new Range(min, max); } /** Match dte satisfying {@code operator days} (e.g. {@code >= 30}). */ - // @Generated: the null-operator guard is unreachable through the public comparison factories, - // which always supply a non-null Operator from the typed enum. - @Generated static Comparison comparison(Operator operator, int days) { - if (operator == null) { - throw new IllegalArgumentException("operator must not be null"); - } - validateDays(days); return new Comparison(operator, days); } @@ -87,11 +67,33 @@ public String wireValue() { record Values(List days) implements DteFilter { public Values { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must be non-empty"); + } days = List.copyOf(days); + for (int d : days) { + validateDays(d); + } } } - record Range(int min, int max) implements DteFilter {} + record Range(int min, int max) implements DteFilter { + public Range { + validateDays(min); + validateDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } - record Comparison(Operator operator, int days) implements DteFilter {} + record Comparison(Operator operator, int days) implements DteFilter { + public Comparison { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + validateDays(days); + } + } } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 3111006..ffcc8f5 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1370,6 +1370,34 @@ void dteFilterComparisonRejectsDaysAboveLimit() { .hasMessageContaining("36500"); } + @Test + void dteFilterComparisonRejectsNullOperator() { + assertThatThrownBy(() -> DteFilter.comparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } + + @Test + void dteFilterValuesRecordConstructorRejectsEmptyList() { + assertThatThrownBy(() -> new DteFilter.Values(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void dteFilterRangeRecordConstructorRejectsReversedRange() { + assertThatThrownBy(() -> new DteFilter.Range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterComparisonRecordConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new DteFilter.Comparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(