From 7e16145d11e725e0c1f701e934036cd6f5793e27 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 15 Oct 2026 10:13:53 -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: Added tests for the dte query-parameter type change (integer → string) on GET /v1/options/chain/{underlying}/. OptionsResourceTest.java gets wire tests for a new DteFilter sealed type's three new forms (comma list, closed range, and all four comparison operators), plus validation tests (non-negative, max 36500, min<=max) on the new factory methods only — the existing dte(int) single-value overload and its "non-negative" check are left untouched per no-change-to-existing-error-types. OptionsIntegrationTest.java gets 4 live tests, one per form (single value, list, range, comparison), each asserting response-derived bounds/distinctness that would fail if the server ignored the filter. Left the existing dte(-1) regression test as-is to cover backward compatibility. implement: Added `DteFilter` (sealed: ValueList/Range/Comparison, each validating 0-36500 inclusive in its own canonical constructor) and a new `ExpirationFilter.dte(DteFilter)` overload/`DteValues` variant, covering the API's dte type change (integer -> string) without touching the existing `dte(int)` single-value overload or its error type. OptionsResource now renders the three new wire forms (comma list, dashed range, operator-prefixed comparison). - Resource page/method doc: docs/options/chain.md documents DteFilter and all three forms with a Java example; every new public member has a Javadoc. - Every enumerated form has its own wire test (list, range, and all 4 Operator values individually since Operator is a new enum); none beyond what the API describes. - Validation holds on every public construction path: factories and direct `new DteFilter.ValueList/Range/Comparison(...)` all validate (added direct-construction tests for empty list, null element, null operator) — existing `dte(int)`'s error type is untouched. - No coverage exclusions added; JaCoCo HTML report shows full coverage on DteFilter.java, ExpirationFilter.java and OptionsResource.java. - Examples (README/client.md/other options docs) show only generic chain usage and weren't claiming filter completeness, so left unchanged; OptionsAdvancedExample.java (not built here) got a DteFilter.range demo added. - ADR-008 left unchanged: its sealed-type list is a historical record of the convention at acceptance time, not a living spec, so a later variant doesn't make it wrong. - Fixed one test I wrote in phase 1: the comma-list wire assertion compared against the raw (URL-encoded) URI; updated it to decode first, matching the existing `?columns=` test convention in this file. Outside my limits, nothing else named in the search results referenced dte/chain specifics needing an update (bug.yml, build.gradle.kts, BUG_FINDING.md, and the other unlisted files only matched generically on "options"). changelog: Added changelog entry for DteFilter sealed type supporting multi-value dte filtering on chain endpoint (API 2.0.0): lists three record variants (ValueList, Range, Comparison) with factories, Operator enum (GT/GTE/LT/LTE), new ExpirationFilter.dte(DteFilter) overload, existing dte(int) unchanged, MAX_DAYS constant, validation to [0, 36500]. --- CHANGELOG.md | 4 + docs/options/chain.md | 23 ++- .../resources/OptionsAdvancedExample.java | 12 ++ .../sdk/OptionsIntegrationTest.java | 92 +++++++++ .../com/marketdata/sdk/OptionsResource.java | 22 +++ .../com/marketdata/sdk/options/DteFilter.java | 112 +++++++++++ .../sdk/options/ExpirationFilter.java | 24 ++- .../marketdata/sdk/OptionsResourceTest.java | 175 ++++++++++++++++++ 8 files changed, 462 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..54814eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`DteFilter` sealed type** (API 2.0.0) — multi-value days-to-expiration filtering for chain queries. Sealed record variants: `ValueList` (`list(int, int...)`), `Range` (`range(int, int)`), `Comparison` (`comparison(Operator, int)` where `Operator` = `GT`/`GTE`/`LT`/`LTE`). New `ExpirationFilter.dte(DteFilter)` overload; existing `dte(int)` single-value form unchanged. Constant `MAX_DAYS = 36500`. All values validated to `[0, 36500]`. + ## [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..4a929fb 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,8 @@ 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 filter)) // days-to-expiration, list/range/comparison .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +41,18 @@ OptionsChainRequest.builder(String symbol) .build() ``` +#### DteFilter + +`ExpirationFilter.dte(DteFilter)` covers the `dte` query parameter's multi-value syntax — a sealed type alongside `ExpirationFilter.dte(int)`'s single-value form: + +```java +DteFilter.list(int first, int... rest) // comma-separated list, e.g. ?dte=15,30,45 +DteFilter.range(int min, int max) // closed, inclusive range, e.g. ?dte=0-45 +DteFilter.comparison(DteFilter.Operator op, int days) // e.g. ?dte=>=30 — GT, GTE, LT, LTE +``` + +Every value, in every form, must be a whole number of days in `[0, 36500]`. + #### Returns @@ -66,6 +79,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 +109,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // DteFilter: contracts between 30 and 60 days to expiry, inclusive. + var byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60))) + .side(OptionSide.CALL) + .build()); } ``` diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java index c4f0977..7d87462 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 @@ -2,6 +2,7 @@ import com.marketdata.sdk.MarketDataClient; import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -51,6 +52,17 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // ExpirationFilter.dte(...) also accepts the chain's full dte syntax via DteFilter: a + // comma-separated list, a closed range, or a comparison — not just a single value. + List byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dte in [30, 60]: " + byDteRange.size() + " contract(s)"); + 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..11ac372 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,97 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueNarrowsToFarDatedExpiration() { + // A single dte value narrows the chain to the expiration closest to it; a far-out value (180 + // days) necessarily lands well past the API's front-month default, so a loose lower bound on + // the result proves the filter reached the server rather than being ignored. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(180)) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain.get(0).dte()).isNotNull(); + assertThat(chain.get(0).dte()).isGreaterThan(60); + } + + @Test + void chainDteListSelectsMultipleDistinctExpirations() { + // A comma-separated dte list selects the closest expiration for each value; well-spaced + // values (7, 60, 180 days) land on different expiration cycles, so the result spans more than + // the single front-month expiration the unfiltered chain returns. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.list(7, 60, 180))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a comma-separated dte list spans more than the single front-month expiration") + .isGreaterThan(1); + } + + @Test + void chainDteRangeReturnsExpirationsWithinInclusiveBounds() { + // range(60, 180) excludes AAPL's near-term weekly expirations; every returned row's dte must + // fall within the closed, inclusive bounds the API documents. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(60, 180))) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(60, 180); + } + } + + @Test + void chainDteComparisonReturnsExpirationsSatisfyingOperator() { + // dte >= 60 excludes AAPL's near-term weekly expirations; every returned row must satisfy the + // inclusive comparison the API documents. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 60))) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(60); + } + } + @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..55232fa 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; @@ -312,6 +313,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.DteValues 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())); @@ -337,6 +340,25 @@ private static String strikeFilterWireValue(StrikeFilter f) { return v.operator().wireValue() + formatStrike(v.price()); } + private static String dteFilterWireValue(DteFilter f) { + if (f instanceof DteFilter.ValueList v) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < v.days().size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(v.days().get(i)); + } + return sb.toString(); + } 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 formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); 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..e3d81c4 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,112 @@ +package com.marketdata.sdk.options; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * The chain endpoint's {@code ?dte=} parameter accepts four syntactic forms: a single value + * (handled directly by {@link ExpirationFilter#dte(int)}), a comma-separated list, a closed range, + * and a comparison ({@code >=30}, {@code <45}, …). This sealed type covers the three multi-value + * forms — reach them through {@link ExpirationFilter#dte(DteFilter)}. + * + *

Modeling them as a sealed type (mirroring {@link StrikeFilter}) gives the consumer + * compile-time autocomplete for valid shapes and prevents a hand-assembled range or comparison + * string from shipping a typo to the server. Every value, in every form, is a whole number of days + * in {@code [0, 36500]} — the bound the API documents. + */ +public sealed interface DteFilter + permits DteFilter.ValueList, DteFilter.Range, DteFilter.Comparison { + + /** The largest {@code dte} value the API accepts. */ + int MAX_DAYS = 36500; + + /** + * Comma-separated list of values — wire form {@code ?dte=15,30,45}. Selects the expiration + * closest to each value (deduplicated server-side). At least one value is required. + */ + static ValueList list(int first, int... rest) { + List days = new ArrayList<>(); + days.add(first); + for (int d : rest) { + days.add(d); + } + return new ValueList(days); + } + + /** + * Closed, inclusive range — wire form {@code ?dte=0-45}. {@code min} must not exceed {@code max}. + */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** Match {@code dte} values satisfying {@code operator days} (e.g. {@code >= 30}). */ + static Comparison comparison(Operator operator, int days) { + return new Comparison(operator, days); + } + + private static int validate(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); + } + return days; + } + + /** Comparison operators accepted by the API — same syntax as {@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; + } + } + + /** + * @param days at least one value, each in {@code [0, 36500]}. Immutable; never {@code null}, + * empty, or containing a {@code null} element. + */ + record ValueList(List days) implements DteFilter { + public ValueList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + List validated = new ArrayList<>(days.size()); + for (Integer d : days) { + validated.add(validate(Objects.requireNonNull(d, "days element"))); + } + days = List.copyOf(validated); + } + } + + record Range(int min, int max) implements DteFilter { + public Range { + validate(min); + validate(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record Comparison(Operator operator, int days) implements DteFilter { + public Comparison { + Objects.requireNonNull(operator, "operator"); + validate(days); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..4c4d490 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -11,6 +11,11 @@ * expirationFilter(...)} setter on the request builder makes that exclusivity compiler-enforced: * there is no way to assign two variants at once. * + *

{@code dte} alone has two entry points — {@link #dte(int)} for a single value, {@link + * #dte(DteFilter)} for the list/range/comparison forms ({@link DteFilter}) — but both produce an + * {@code ExpirationFilter}, so they still compete with {@code onDate}/{@code between}/{@code + * monthYear}/{@code all} for the one setter slot. + * *

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. @@ -18,6 +23,7 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteValues, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +45,7 @@ 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}. */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +53,15 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration, multi-value form — a comma-separated list, a closed range, or a comparison, + * per {@link DteFilter}. Wire form is whichever {@code DteFilter} variant produces (e.g. {@code + * ?dte=15,30,45}, {@code ?dte=0-45}, {@code ?dte=>=30}). + */ + static DteValues dte(DteFilter filter) { + return new DteValues(Objects.requireNonNull(filter, "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 +94,13 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** See {@link #dte(DteFilter)}. */ + record DteValues(DteFilter filter) implements ExpirationFilter { + public DteValues { + Objects.requireNonNull(filter, "filter"); + } + } + 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..821ab5b 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; @@ -27,6 +28,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 +977,106 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.list(15, 30, 45))) + .build()) + .join(); + + // The comma is URL-encoded on the wire (same as ?columns=), so decode before asserting. + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.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 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(); + + // "%3E" is the URL-encoded ">" — the query encoder pushes reserved characters through. + 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.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()) + .join(); + + 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.dte(DteFilter.comparison(DteFilter.Operator.LT, 45))) + .build()) + .join(); + + 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.dte(DteFilter.comparison(DteFilter.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 +1305,79 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void dteFilterListRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.list(-1, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterListRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.list(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.range(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNullOperator() { + assertThatThrownBy(() -> DteFilter.comparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + + // ---------- sealed type validation: direct record construction (bypassing the factories) + // ---------- + + @Test + void dteFilterValueListDirectConstructionRejectsEmptyList() { + assertThatThrownBy(() -> new DteFilter.ValueList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteFilterValueListDirectConstructionRejectsNullElement() { + assertThatThrownBy(() -> new DteFilter.ValueList(Arrays.asList(15, null))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days element"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( From 2d528c531747e67e18ebc3d7611eadf676b34c39 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 15 Oct 2026 10:24:37 -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: Round-1 fixes: (1) Added record-level Javadocs to `DteFilter.Range` and `DteFilter.Comparison` documenting each component and its [0, 36500] constraint. (2) `chainDteListSelectsMultipleDistinctExpirations` now fetches the same chain request without the dte filter and asserts the filtered result is both bounded (≤3 distinct expirations, matching the 3 requested values) and strictly greater than the unfiltered front-month count, so a server that ignores the comma list or falls back to the full chain fails the test. (3) Added `dteFilterRangeAcceptsInclusiveMaxBoundary`, constructing `DteFilter.range(0, 36500)` and asserting `max()` is 36500, covering the documented inclusive upper bound that only the rejection side had before. No public-surface or behavior change, so `CHANGELOG.md` is left as is. --- .../sdk/OptionsIntegrationTest.java | 27 +++++++++++++++---- .../com/marketdata/sdk/options/DteFilter.java | 10 +++++++ .../marketdata/sdk/OptionsResourceTest.java | 5 ++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 11ac372..4fa5446 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -158,9 +158,23 @@ void chainDteSingleValueNarrowsToFarDatedExpiration() { @Test void chainDteListSelectsMultipleDistinctExpirations() { - // A comma-separated dte list selects the closest expiration for each value; well-spaced - // values (7, 60, 180 days) land on different expiration cycles, so the result spans more than - // the single front-month expiration the unfiltered chain returns. + // A comma-separated dte list selects the closest expiration for each value, deduplicated + // server-side, so three requested values select at most three distinct expirations. + // Well-spaced values (7, 60, 180 days) land on different expiration cycles, so the result also + // spans more expirations than the same request without the filter — which returns only the + // single front-month expiration — proving the server honored the list rather than ignoring it. + List unfiltered = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + long unfilteredDistinctExpirations = + unfiltered.stream().map(OptionQuote::expiration).distinct().count(); + List chain = client .options() @@ -174,8 +188,11 @@ void chainDteListSelectsMultipleDistinctExpirations() { long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); assertThat(distinctExpirations) - .as("a comma-separated dte list spans more than the single front-month expiration") - .isGreaterThan(1); + .as("a comma-separated dte list selects at most one expiration per requested value") + .isLessThanOrEqualTo(3); + assertThat(distinctExpirations) + .as("the filtered result differs from the unfiltered chain's single front-month expiration") + .isGreaterThan(unfilteredDistinctExpirations); } @Test diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java index e3d81c4..f895213 100644 --- a/src/main/java/com/marketdata/sdk/options/DteFilter.java +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -93,6 +93,12 @@ record ValueList(List days) implements DteFilter { } } + /** + * @param min lower bound of the closed range, inclusive, in {@code [0, 36500]}. Must not exceed + * {@code max}. + * @param max upper bound of the closed range, inclusive, in {@code [0, 36500]}. Must not be less + * than {@code min}. + */ record Range(int min, int max) implements DteFilter { public Range { validate(min); @@ -103,6 +109,10 @@ record Range(int min, int max) implements DteFilter { } } + /** + * @param operator the comparison to apply against {@code days}; never {@code null}. + * @param days the threshold, in {@code [0, 36500]}. + */ record Comparison(Operator operator, int days) implements DteFilter { public Comparison { Objects.requireNonNull(operator, "operator"); diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 821ab5b..2315b01 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1340,6 +1340,11 @@ void dteFilterRangeRejectsValueAboveMax() { .hasMessageContaining("dte must be at most 36500"); } + @Test + void dteFilterRangeAcceptsInclusiveMaxBoundary() { + assertThat(DteFilter.range(0, 36500).max()).isEqualTo(36500); + } + @Test void dteFilterComparisonRejectsNegativeValue() { assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1))