From 2587fb3295a58ff37345702e15baccb223915456 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 29 Oct 2026 09:46:47 -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 unit tests (OptionsResourceTest) for the chain endpoint's dte parameter's new wire forms: comma-separated list, closed range, and comparison operator (one wire test per operator GT/GTE/LT/LTE), plus validation tests for empty/negative list, min>max range, negative range/comparison values — all against new `ExpirationFilter.dteList`/`dteRange`/`dteComparison`/`Operator` symbols, additive alongside the existing `dte(int)`. Added integration tests (OptionsIntegrationTest) deriving a live baseline dte set per test and asserting list/range/comparison results against candidates computed from that baseline, never an exact value assumed in advance; left "closest" tie-breaking and bound inclusivity unasserted since the response can't verify them. Added the compat probe exercising today's `ExpirationFilter.dte(int)` + chain/chainAsync, verified it still compiles. No production code touched. `gradle compileTestJava --no-daemon --console=plain` and `gradle test --no-daemon --console=plain` both fail with "cannot find symbol" on the new `dteList`/`dteRange`/`dteComparison`/`Operator` references in OptionsResourceTest.java, as expected for phase 1; the compat probe file itself produced no errors in either run. implement: Implemented the `dte` chain parameter's widened type: `ExpirationFilter.dteList`/`dteRange`/`dteComparison` (plus a new `Operator` enum GT/GTE/LT/LTE) as additive sealed-interface variants alongside the unchanged `dte(int)`, wired through `OptionsResource.applyExpirationFilter`. Docs: every new method/type documented with javadoc and an example in docs/options/chain.md and OptionsAdvancedExample.java. New-member docs: static factories and the `Operator` enum have javadoc, matching the sibling `StrikeFilter` convention of no per-record/per-constant javadoc. Wire tests: one per form (list, range) plus one per operator (GT/GTE/LT/LTE) were already written in phase 1 and now pass. Validation: moved into each new record's compact constructor (not just the static factory), so `new ExpirationFilter.DteList(...)` etc. validate too; `List.copyOf` rejects null elements, empty-list and negative-value checks are explicit. Coverage: no `@Generated` exclusions added; `jacocoTestCoverageVerification` (100% line) passes on the new code as written. Examples/docs already showing the area (README, OptionsExample, quotes.md) use forms unaffected by this change and remain correct; chain.md and OptionsAdvancedExample.java updated. Docs promise only what's validated: "non-empty list", "min <= max" mirror actual checks; range/comparison inclusivity text cites `StrikeFilter`'s existing documentation of the same grammar rather than asserting new observed behavior. `ExpirationFilter.dte(int)` is untouched, so it keeps accepting everything it did. Left unchanged: docs/adr/ADR-008 (historical record of variants as of its acceptance date, not a living inventory) and docs/.sync-manifest.txt (auto-generated, self-marked do-not-hand-edit). Outside my limits, checked and found no stale references needing a fix: `.github/ISSUE_TEMPLATE/bug.yml` and `build.gradle.kts` don't name `dte` specifically. `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both green (build includes Spotless and 100% JaCoCo line-coverage verification); `gradle compileTestJava` also green now that the phase-1 compat probe has been removed by the pipeline. changelog: I've already called the StructuredOutput tool in my previous response to provide the summary and status. The changelog entry has been successfully added to `CHANGELOG.md` under the `[Unreleased]` section, documenting the three new `ExpirationFilter` static factory methods and the `Operator` enum for API release 2.0.0. --- CHANGELOG.md | 5 + docs/options/chain.md | 14 +- .../resources/OptionsAdvancedExample.java | 20 ++ .../sdk/OptionsIntegrationTest.java | 174 ++++++++++++++++++ .../com/marketdata/sdk/OptionsResource.java | 7 + .../sdk/options/ExpirationFilter.java | 85 +++++++++ .../marketdata/sdk/OptionsResourceTest.java | 132 +++++++++++++ 7 files changed, 436 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..c843137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(List)`, `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — New static factories for the chain endpoint's expanded `dte` parameter forms (comma-separated list, closed range, comparison operators). The existing `ExpirationFilter.dte(int)` works as before (API release 2.0.0). +- `ExpirationFilter.Operator` — New enum (GT, GTE, LT, LTE) for comparison operators, with `wireValue()` accessor. + ## [1.0.0] - 2026-06-29 First stable release of the Market Data Java & Kotlin SDK — a single JVM diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..28ef94a 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,10 @@ OptionsChainRequest.builder(String symbol) // Expiration selection (sealed ExpirationFilter — pick one): .expirationFilter(ExpirationFilter.onDate(LocalDate date)) - .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration + .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration + .expirationFilter(ExpirationFilter.dteList(List days)) // closest expiration per value + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // every expiration within [min, max] + .expirationFilter(ExpirationFilter.dteComparison(Operator op, int days)) // every expiration satisfying "op days" .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +43,8 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` accepts four forms: a single value (`dte(int)`), a comma-separated list (`dteList(List)` — for each value, the API selects its closest available expiration), a closed range (`dteRange(int, int)`, both ends inclusive — same grammar as `StrikeFilter.range`), and a comparison (`dteComparison(ExpirationFilter.Operator, int)`, operators `GT`/`GTE`/`LT`/`LTE` — same grammar as `StrikeFilter.comparison`). + #### Returns @@ -95,6 +100,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a list, a closed range, or a comparison. + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .build()); } ``` diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java index c4f0977..b95ebfe 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -40,6 +40,26 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte also accepts a comma-separated list, a closed range, or a comparison — same grammar + // as StrikeFilter's range/comparison forms. + List withinRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 60)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dteRange(0, 60): " + withinRange.size() + " contracts"); + + List atOrBeyond30 = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dteComparison(GTE, 30): " + atOrBeyond30.size() + " contracts"); + // ExpirationFilter.all() spans every expiration at once — distinct from omitting the filter, // which the API narrows to the front month. long spans = client.options().chain( diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..3b36d3d 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -133,6 +133,180 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainExpirationDteSingleValueMatchesClosestExpiration() { + // Request an available dte value exactly — its own closest expiration is unambiguous (zero + // distance), so the result's dte must equal it. Picking the farthest-out value keeps it + // distinct from the unfiltered default, which is the front-month (nearest) expiration. + List baseline = baselineDtes(); + int farthest = baseline.get(baseline.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(farthest)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isEqualTo(farthest); + } + } + + @Test + void chainExpirationDteListMatchesClosestExpirationPerValue() { + // Request two distinct, available dte values exactly: each is its own unambiguous closest + // candidate (zero distance), so the result can only ever contain those two values, and both + // must be present. Distinct from the unfiltered default, which carries a single expiration. + List baseline = baselineDtes(); + int low = baseline.get(0); + int high = baseline.get(baseline.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(low, high))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isIn(low, high); + } + assertThat(chain.stream().map(OptionQuote::dte).collect(java.util.stream.Collectors.toSet())) + .as("each requested value's own candidate must be present") + .contains(low, high); + } + + @Test + void chainExpirationDteRangeReturnsEveryExpirationWithinBounds() { + // A closed range spanning several available expirations: every row's dte must fall within + // [min, max], and the span must cover more than one distinct value to differ from the + // unfiltered (single front-month) default. + List baseline = baselineDtes(); + int min = baseline.get(baseline.size() / 4); + int max = baseline.get(3 * baseline.size() / 4); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isBetween(min, max); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a range spanning several expirations holds more than one distinct value") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteComparisonGteReturnsExpirationsAtOrAboveBound() { + // >= N: every row's dte is >= N, asserted in a way that holds whether the server treats the + // bound as inclusive or exclusive. The bound sits a third of the way up the baseline so the + // qualifying set spans more than one expiration, distinguishing it from the unfiltered + // default (a single front-month row). + List baseline = baselineDtes(); + int bound = baseline.get(baseline.size() / 3); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, bound)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isGreaterThanOrEqualTo(bound); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a >= bound below the top of the chain qualifies more than one expiration") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() { + // < N: every row's dte is <= N, asserted in a way that holds whether the server treats the + // bound as inclusive or exclusive. The bound sits two-thirds of the way up the baseline so the + // qualifying set spans more than one expiration, distinguishing it from the unfiltered + // default (a single front-month row). + List baseline = baselineDtes(); + int bound = baseline.get(2 * baseline.size() / 3); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, bound)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isLessThanOrEqualTo(bound); + } + long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count(); + assertThat(distinctDtes) + .as("a < bound above the bottom of the chain qualifies more than one expiration") + .isGreaterThan(1); + } + + /** + * Every distinct dte present across AAPL's full expiration chain, ascending — the baseline a + * dte-filter test derives its candidate values from, per the suite's own convention of deriving + * expectations from a baseline call rather than from values assumed in advance. + */ + private List baselineDtes() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List dtes = + chain.stream() + .map(OptionQuote::dte) + .distinct() + .sorted() + .collect(java.util.stream.Collectors.toList()); + assertThat(dtes) + .as("AAPL has at least two distinct expirations") + .hasSizeGreaterThanOrEqualTo(2); + return dtes; + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..587aa21 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -28,6 +28,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +313,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteList v) { + b.query("dte", v.days().stream().map(String::valueOf).collect(Collectors.joining(","))); + } else if (f instanceof ExpirationFilter.DteRange v) { + b.query("dte", v.min() + "-" + v.max()); + } else if (f instanceof ExpirationFilter.DteComparison v) { + b.query("dte", v.operator().wireValue() + v.days()); } else if (f instanceof ExpirationFilter.Between v) { b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..24d2131 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -18,6 +19,9 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +51,53 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Comma-separated days-to-expiration list — wire form {@code ?dte=15,30,45}. For each value, the + * API selects the closest available expiration; duplicates across values are deduplicated + * server-side. {@code days} must be non-empty, and every value non-negative. + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Closed days-to-expiration range — wire form {@code ?dte=0-45}, both ends inclusive (same + * grammar as {@link StrikeFilter#range}, which documents the same closed-range syntax). {@code + * min} must not exceed {@code max}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=>=30} (e.g. {@code operator=GTE}, {@code + * days=30}). Same comparison grammar as {@link StrikeFilter#comparison}. + */ + static DteComparison dteComparison(Operator operator, int days) { + return new DteComparison(operator, days); + } + + /** + * Comparison operators accepted by the {@code dte} comparison form — see {@link #dteComparison}. + */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -79,6 +130,40 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteList(List days) implements ExpirationFilter { + public DteList { + days = List.copyOf(Objects.requireNonNull(days, "days")); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must be non-empty"); + } + for (int day : days) { + if (day < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + } + } + } + + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + if (min < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record DteComparison(Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..9db031a 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,103 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + // Comma-separated dte list — selects, for each value, the closest available expiration. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()) + .join(); + + // The comma is URL-encoded on the wire, so decode before asserting. + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonGteTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — same encoding the strike comparison test observes. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonGtTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GT, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLtTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LTE, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1300,41 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must be non-empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(30, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( From 6f3f6db8b6b52e63fff751902c2cd7cc2fab0300 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 29 Oct 2026 09:59:16 -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: Fixed both review findings. 1) OptionsIntegrationTest.java: the five dte-filter tests and the shared `baselineDtes()` helper now capture one `LocalDate.now(ZoneId.of("America/New_York"))` per test and pass it via `.date(...)` to both the baseline call and the filtered call, so DTE values can't drift if the two live calls straddle US/Eastern midnight. 2) ExpirationFilter.java and docs/options/chain.md: javadoc for `dte(int)`, `dteList`, `dteRange`, and `dteComparison` now states both omitted constraints — values are relative to the request's `date` parameter (to "today" when unset) and capped at 36,500 days — matching the API's own description; no new runtime validation was added since the finding asked only for documentation. 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 green after `gradle spotlessApply` reformatted the new javadoc wrapping. review fix 1: [major] Fixed: replaced the wall-clock `LocalDate.now(ZoneId.of("America/New_York"))` in the five dte-filter integration tests and `baselineDtes()` with a single new constant, `DTE_FIXED_TRADING_DAY = LocalDate.of(2026, 10, 27)` (a recent Tuesday, no US market holiday nearby), removing the dependence on which day/weekend the CI job happens to run on. Updated the surrounding comments and the `baselineDtes()` javadoc to describe pinning to a fixed trading day rather than an Eastern "today," and dropped the now-unused `ZoneId` import. [minor] Not a separate fix: pinning to a fixed historical day (rather than today) also resolves the API-team question raised in this note, since `.date()` is now used exactly as documented — a historical trading day — rather than for "today." No further action taken; no one here can confirm server behavior beyond what the response itself shows, so nothing new is asserted about it. `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both green (build includes compileIntegrationTestJava, Spotless, and 100% JaCoCo coverage verification). --- docs/options/chain.md | 2 +- .../sdk/OptionsIntegrationTest.java | 47 ++++++++++++++++--- .../sdk/options/ExpirationFilter.java | 17 +++++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/docs/options/chain.md b/docs/options/chain.md index 28ef94a..9214779 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -43,7 +43,7 @@ OptionsChainRequest.builder(String symbol) .build() ``` -`dte` accepts four forms: a single value (`dte(int)`), a comma-separated list (`dteList(List)` — for each value, the API selects its closest available expiration), a closed range (`dteRange(int, int)`, both ends inclusive — same grammar as `StrikeFilter.range`), and a comparison (`dteComparison(ExpirationFilter.Operator, int)`, operators `GT`/`GTE`/`LT`/`LTE` — same grammar as `StrikeFilter.comparison`). +`dte` accepts four forms: a single value (`dte(int)`), a comma-separated list (`dteList(List)` — for each value, the API selects its closest available expiration), a closed range (`dteRange(int, int)`, both ends inclusive — same grammar as `StrikeFilter.range`), and a comparison (`dteComparison(ExpirationFilter.Operator, int)`, operators `GT`/`GTE`/`LT`/`LTE` — same grammar as `StrikeFilter.comparison`). Every `dte` value, in any of the four forms, is relative to the request's `date` parameter when one is set (to "today" otherwise), and is capped at 36,500 days (100 years). #### Returns diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 3b36d3d..9f0842a 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -40,6 +40,11 @@ class OptionsIntegrationTest { private static final String UNDERLYING = "AAPL"; + // A fixed, recent US trading day (not a weekend or market holiday) that the dte tests below + // pin their baseline and filtered calls to, so the chain's dte values can't shift with the + // wall clock the way LocalDate.now() would. + private static final LocalDate DTE_FIXED_TRADING_DAY = LocalDate.of(2026, 10, 27); + private MarketDataClient client; @BeforeAll @@ -138,7 +143,11 @@ void chainExpirationDteSingleValueMatchesClosestExpiration() { // Request an available dte value exactly — its own closest expiration is unambiguous (zero // distance), so the result's dte must equal it. Picking the farthest-out value keeps it // distinct from the unfiltered default, which is the front-month (nearest) expiration. - List baseline = baselineDtes(); + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); int farthest = baseline.get(baseline.size() - 1); List chain = @@ -149,6 +158,7 @@ void chainExpirationDteSingleValueMatchesClosestExpiration() { .expirationFilter(ExpirationFilter.dte(farthest)) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); @@ -163,7 +173,11 @@ void chainExpirationDteListMatchesClosestExpirationPerValue() { // Request two distinct, available dte values exactly: each is its own unambiguous closest // candidate (zero distance), so the result can only ever contain those two values, and both // must be present. Distinct from the unfiltered default, which carries a single expiration. - List baseline = baselineDtes(); + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); int low = baseline.get(0); int high = baseline.get(baseline.size() - 1); @@ -175,6 +189,7 @@ void chainExpirationDteListMatchesClosestExpirationPerValue() { .expirationFilter(ExpirationFilter.dteList(List.of(low, high))) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); @@ -192,7 +207,11 @@ void chainExpirationDteRangeReturnsEveryExpirationWithinBounds() { // A closed range spanning several available expirations: every row's dte must fall within // [min, max], and the span must cover more than one distinct value to differ from the // unfiltered (single front-month) default. - List baseline = baselineDtes(); + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); int min = baseline.get(baseline.size() / 4); int max = baseline.get(3 * baseline.size() / 4); @@ -204,6 +223,7 @@ void chainExpirationDteRangeReturnsEveryExpirationWithinBounds() { .expirationFilter(ExpirationFilter.dteRange(min, max)) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); @@ -223,7 +243,11 @@ void chainExpirationDteComparisonGteReturnsExpirationsAtOrAboveBound() { // bound as inclusive or exclusive. The bound sits a third of the way up the baseline so the // qualifying set spans more than one expiration, distinguishing it from the unfiltered // default (a single front-month row). - List baseline = baselineDtes(); + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); int bound = baseline.get(baseline.size() / 3); List chain = @@ -235,6 +259,7 @@ void chainExpirationDteComparisonGteReturnsExpirationsAtOrAboveBound() { ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, bound)) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); @@ -254,7 +279,11 @@ void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() { // bound as inclusive or exclusive. The bound sits two-thirds of the way up the baseline so the // qualifying set spans more than one expiration, distinguishing it from the unfiltered // default (a single front-month row). - List baseline = baselineDtes(); + // The baseline and the filtered call share one fixed trading day so dte is computed against + // the same session for both, instead of a wall-clock "today" that would shift with the + // calendar and could land on a weekend or market holiday. + LocalDate date = DTE_FIXED_TRADING_DAY; + List baseline = baselineDtes(date); int bound = baseline.get(2 * baseline.size() / 3); List chain = @@ -266,6 +295,7 @@ void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() { ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, bound)) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); @@ -282,9 +312,11 @@ void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() { /** * Every distinct dte present across AAPL's full expiration chain, ascending — the baseline a * dte-filter test derives its candidate values from, per the suite's own convention of deriving - * expectations from a baseline call rather than from values assumed in advance. + * expectations from a baseline call rather than from values assumed in advance. {@code date} is + * the fixed trading day the caller also passes to its filtered call, so both calls see the same + * session and dte can't shift between them. */ - private List baselineDtes() { + private List baselineDtes(LocalDate date) { List chain = client .options() @@ -293,6 +325,7 @@ private List baselineDtes() { .expirationFilter(ExpirationFilter.all()) .side(OptionSide.CALL) .strikeLimit(1) + .date(date) .build()) .values(); List dtes = diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 24d2131..c099125 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -43,7 +43,11 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration filter — wire form {@code ?dte=N}. {@code days} is relative to the request's + * {@code date} parameter when one is set (to "today" otherwise), and is capped at 36,500 days + * (100 years). + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -54,7 +58,9 @@ static Dte dte(int days) { /** * Comma-separated days-to-expiration list — wire form {@code ?dte=15,30,45}. For each value, the * API selects the closest available expiration; duplicates across values are deduplicated - * server-side. {@code days} must be non-empty, and every value non-negative. + * server-side. {@code days} must be non-empty, and every value non-negative. Each value is + * relative to the request's {@code date} parameter when one is set (to "today" otherwise), and is + * capped at 36,500 days (100 years). */ static DteList dteList(List days) { return new DteList(days); @@ -63,7 +69,8 @@ static DteList dteList(List days) { /** * Closed days-to-expiration range — wire form {@code ?dte=0-45}, both ends inclusive (same * grammar as {@link StrikeFilter#range}, which documents the same closed-range syntax). {@code - * min} must not exceed {@code max}. + * min} must not exceed {@code max}. Both bounds are relative to the request's {@code date} + * parameter when one is set (to "today" otherwise), and are capped at 36,500 days (100 years). */ static DteRange dteRange(int min, int max) { return new DteRange(min, max); @@ -71,7 +78,9 @@ static DteRange dteRange(int min, int max) { /** * Days-to-expiration comparison — wire form {@code ?dte=>=30} (e.g. {@code operator=GTE}, {@code - * days=30}). Same comparison grammar as {@link StrikeFilter#comparison}. + * days=30}). Same comparison grammar as {@link StrikeFilter#comparison}. {@code days} is relative + * to the request's {@code date} parameter when one is set (to "today" otherwise), and is capped + * at 36,500 days (100 years). */ static DteComparison dteComparison(Operator operator, int days) { return new DteComparison(operator, days);