From 39491b73a723e60f2463b3ec2b0a461d2d528a31 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Fri, 30 Oct 2026 11:28:07 -0400 Subject: [PATCH] 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 GET /v1/options/chain/{underlying}/'s dte parameter, now a string accepting four forms. ExpirationFilter.dte(int) is unchanged (regression test already existed); added wire + validation tests for three new additive overloads the implementation must add: dte(int... days) for the comma-separated list, dteRange(int,int) for the closed range, and dteComparison(StrikeFilter.Operator, int) for the comparison form (reusing StrikeFilter's Operator per "the syntax matches strike"). Integration tests cover all four forms against live AAPL chain data, deriving boundary values (e.g. the front-month dte) from the live response so inclusive/exclusive bounds and non-ignorance are asserted without depending on wall-clock or fixed calendar assumptions. No production code touched; the delta is purely additive to ExpirationFilter (new permitted sealed-type variants + new static factories), so per the brief's escape clause no SdkSyncCompatProbe was written. implement: Added three additive ExpirationFilter forms for GET /v1/options/chain/{underlying}/'s now-string dte: dte(int... days) (list), dteRange(int,int) (closed range), dteComparison(Operator,int) (reuses StrikeFilter.Operator). dte(int days) is untouched. Validation (bounds [0,36500], min<=max, non-empty, no-null-elements) lives in each record's compact constructor so it holds via the static factories and via direct `new DteList/DteRange/DteComparison(...)` alike; added tests for the direct-constructor path. OptionsResource wires the three new variants to `dte` (comma-joined, dashed, operator-prefixed). gradle test and gradle build (incl. spotless, jacoco) are green. - doc for new methods/types + example: docs/options/chain.md (forms list + dteRange example) and examples/.../OptionsAdvancedExample.java. - javadoc on every new member: dte(int...), dteRange, dteComparison, DteList, DteRange, DteComparison all documented. - wire test per new form: kept from phase 1 (list/range/comparison), unchanged. - validation on every public path incl. collections: compact constructors + new direct-constructor tests (null list, empty, null element, out-of-range). - coverage: no exclusions added; jacocoTestCoverageVerification passed unmodified. - existing examples/docs still correct: dte(45) and all() usages elsewhere are unaffected and left as-is. - docs promise nothing the API doesn't state: messages mirror only the documented [0,36500]/ordering bounds. - existing public dte(int) keeps accepting every int it did before, unchanged. Left unchanged (no dte-specific content, so unaffected by this delta): README.md, docs/client.md, docs/options/{README,quotes,expirations,lookup}.md, docs/.sync-manifest.txt, ADR-006, ADR-008, MarketDataClient.java, OptionsCsvResource.java, OptionsHtmlResource.java, OptionsChain.java, JsonResponseParserTest.java, RequestValidationTest.java. Noticed but left alone (pre-existing, unrelated to this delta): docs/options/chain.md never documented StrikeFilter.comparison either. review fix 1: Fixed both major findings in OptionsIntegrationTest.java: removed frontMonthRow() (built its baseline from the unfiltered default, which the brief forbids) and replaced it with anchorDte(seedDays), which derives the boundary from a dte(seedDays) call itself. The two tests the reviewer flagged for sharing a boundary with the default answer (chainDteRangeDegenerateToSingleDayIncludesBothInclusiveBounds, chainDteComparisonGteIncludesTheBoundaryValue) now anchor on dte(180) — far enough out that an ignored filter, which would echo the near-term default, fails; the GTE-includes test also now asserts the answer spans more than one distinct expiration. The other two (GteExcludesExpirationsBelowTheBoundary, LtExcludesTheBoundaryValue) keep their existing ignore-detecting logic but now anchor on dte(7) instead of the unfiltered default. Addressed the minor finding: added LT, GT and LTE wire tests for dteComparison in OptionsResourceTest.java (GTE's test was already present), so all four StrikeFilter.Operator constants now have their own wire assertion for dte. The "question for a person" about ExpirationFilter's permits clause: left as-is per the instruction not to resolve or argue it — it stands as the reviewer recorded it. The "question for a person" about `<` being exclusive against documentation's "inclusive" wording: the SDK's javadoc/docs already avoid claiming either inclusive or exclusive for the comparison form, stating only "matches every expiration satisfying operator days" and "syntax mirrors StrikeFilter" — consistent with the case-2 handling the reviewer described, so no doc change was needed; left the question for a person as given. `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass (including spotless and jacoco coverage verification) after one spotlessApply pass for formatting. changelog: I've already called StructuredOutput in my previous response above. The changelog entry has been successfully added to `CHANGELOG.md` under the `[Unreleased]` section with the `### Added` subsection, documenting all three new `ExpirationFilter` methods and sealed subtypes for API release 2.0.0. --- CHANGELOG.md | 11 + docs/options/chain.md | 14 +- .../resources/OptionsAdvancedExample.java | 12 + .../sdk/OptionsIntegrationTest.java | 197 ++++++++++++++++ .../com/marketdata/sdk/OptionsResource.java | 7 + .../sdk/options/ExpirationFilter.java | 86 +++++++ .../marketdata/sdk/OptionsResourceTest.java | 214 ++++++++++++++++++ 7 files changed, 540 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..3fce5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dte(int...)`, the comma-separated dte list wire form (API release 2.0.0). + `ExpirationFilter.dte(int)` works as before. +- `ExpirationFilter.dteRange(int, int)`, the closed-range dte wire form (API release 2.0.0). +- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)`, the comparison-operator dte wire form + (API release 2.0.0), reusing `StrikeFilter.Operator` syntax. +- `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and `ExpirationFilter.DteComparison`: new + permitted subtypes of the sealed `ExpirationFilter` interface, supporting the API's three new dte + forms (API release 2.0.0). + ## [1.0.0] - 2026-06-29 First stable release of the Market Data Java & Kotlin SDK — a single JVM diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..2965fc2 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)) // dte: single value + .expirationFilter(ExpirationFilter.dte(int... days)) // dte: comma-separated list + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte: closed range + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // dte: comparison .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +43,8 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` has four wire forms (the API's `dte` parameter is string-typed as of v2.0.0): a single value, a comma-separated list (`dte(15, 30, 45)`, closest expiration per value, deduplicated), a closed range (`dteRange(0, 45)`, every expiration with `dte` in `[0, 45]`), and a comparison operator (`dteComparison(StrikeFilter.Operator.GTE, 30)`, syntax shared with `StrikeFilter`). Every value must be in `[0, 36500]`. + #### Returns @@ -95,6 +100,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a closed range — every expiration between 0 and 45 days out. + var inRange = 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..37db43b 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,18 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte's wire parameter is string-typed as of v2.0.0: besides the single value above, it also + // accepts a comma-separated list, a closed range, and a comparison operator (syntax shared + // with StrikeFilter). + long inRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 60)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("dteRange(30, 60) spans " + inRange + " distinct expirations"); + // 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..42269a7 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,6 +10,7 @@ import com.marketdata.sdk.options.OptionsLookupRequest; import com.marketdata.sdk.options.OptionsQuoteRequest; import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; import java.time.LocalDate; import java.util.List; @@ -158,6 +159,202 @@ void chainDecodesOptionalRhoColumn() { } } + // ---------- chain: dte parameter forms (v2.0.0 — dte became a string parameter) ---------- + + @Test + void chainDteSingleValueMovesExpirationFurtherOutAsValueGrows() { + // dte's wire type changed from integer to string; the single-value form must still pick the + // expiration closest to the given value. Compared against another value of the same + // parameter (not the unfiltered default) so an ignored filter would fail this assertion. + int nearDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).dte()); + int farDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).dte()); + + assertThat(farDte) + .as("dte(180) must resolve to a later expiration than dte(7)") + .isGreaterThan(nearDte); + } + + @Test + void chainDteListReturnsClosestExpirationPerValueDeduplicated() { + LocalDate nearExpiration = + java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).expiration()) + .toLocalDate(); + LocalDate farExpiration = + java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).expiration()) + .toLocalDate(); + + List combined = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(7, 180)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + java.util.Set distinctExpirations = + combined.stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration()).toLocalDate()) + .collect(java.util.stream.Collectors.toSet()); + + assertThat(distinctExpirations) + .as("dte(7, 180) must select exactly the two closest expirations, one per value") + .containsExactlyInAnyOrder(nearExpiration, farExpiration); + } + + @Test + void chainDteRangeDegenerateToSingleDayIncludesBothInclusiveBounds() { + // The anchor comes from a dte(...) call itself — not the unfiltered default — and is seeded + // far enough out (180 days) that an answer which merely echoed the unfiltered front-month + // default would fail the dte-equality assertion below, catching an ignored parameter. + int anchorDte = anchorDte(180); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(anchorDte, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as("a min==max range must still include the expiration at exactly that dte") + .isNotEmpty(); + assertThat(rows).allSatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte)); + } + + @Test + void chainDteRangeSpansMultipleExpirationsUnlikeASingleValue() { + List ranged = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(0, 400)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + long distinctExpirations = ranged.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a closed range must span every expiration within bounds, not just the closest one") + .isGreaterThan(1); + assertThat(ranged) + .as("every row's dte must fall within the inclusive [0, 400] bound") + .allSatisfy(q -> assertThat(q.dte()).isBetween(0, 400)); + } + + @Test + void chainDteComparisonGteIncludesTheBoundaryValue() { + // Seeded far out (180 days), like the range test above, so an ignored filter (which would + // echo the near-term unfiltered default) fails both assertions below. + int anchorDte = anchorDte(180); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(">=N must keep the expiration at exactly N") + .anySatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte)); + assertThat(rows) + .as("every row returned by >=N must satisfy dte >= N") + .allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(anchorDte)); + long distinctExpirations = rows.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as(">=N must span every expiration at or beyond N, not just the closest one") + .isGreaterThan(1); + } + + @Test + void chainDteComparisonGteExcludesExpirationsBelowTheBoundary() { + int anchorDte = anchorDte(7); + int threshold = anchorDte + 60; + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, threshold)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(">=N must exclude an expiration below N — proves the filter is not ignored") + .allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(threshold)); + } + + @Test + void chainDteComparisonLtExcludesTheBoundaryValue() { + int anchorDte = anchorDte(7); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(" assertThat(q.dte()).isLessThan(anchorDte)); + } + + /** The nearest-the-money call under {@code filter}; asserts the chain isn't empty. */ + private OptionQuote chainRowForDte(ExpirationFilter filter) { + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(filter) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + assertThat(rows).isNotEmpty(); + return rows.get(0); + } + + /** + * The live dte of the expiration closest to {@code seedDays} — built from a {@code dte(...)} call + * itself (the parameter under test), never from the unfiltered default, per the live-test rule + * that a baseline must exercise the parameter it's anchoring. + */ + private int anchorDte(int seedDays) { + return java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(seedDays)).dte()); + } + @Test void quoteCountbackBoundsHistoricalSeries() { // countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has 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..0bec8d7 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,8 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; import java.util.Objects; /** @@ -14,10 +16,18 @@ *

Additive expiration-type predicates ({@code weekly}/{@code monthly}/{@code quarterly}/{@code * am}/{@code pm}) are not part of this hierarchy — they intersect freely with any variant and stay * as separate booleans on the request builder. + * + *

{@code dte} itself has four wire forms (the API's {@code dte} parameter is string-typed as of + * v2.0.0): a single value ({@link #dte(int)}), a comma-separated list ({@link #dte(int...)}), a + * closed range ({@link #dteRange}), and a comparison operator ({@link #dteComparison}) — the last + * two share their operator/range syntax with {@link StrikeFilter}. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +57,33 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. Selects the expiration + * closest to each value, deduplicated server-side. {@code days} must be non-empty; every value + * must be in {@code [0, 36500]}. + */ + static DteList dte(int... days) { + return new DteList(Arrays.stream(days).boxed().toList()); + } + + /** + * Days-to-expiration closed range filter — wire form {@code ?dte=min-max}. Matches every + * expiration whose dte falls within {@code [min, max]}, inclusive. {@code min} must not exceed + * {@code max}; both bounds must be in {@code [0, 36500]}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form {@code ?dte=} (e.g. {@code + * ?dte=>=30}). Matches every expiration satisfying {@code operator days}. {@code days} must be in + * {@code [0, 36500]}. Syntax mirrors {@link StrikeFilter#comparison}. + */ + static DteComparison dteComparison(StrikeFilter.Operator operator, int days) { + return new DteComparison(operator, days); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -79,6 +116,55 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** + * Validated in the compact constructor (not just {@link #dte(int...)}) so the guarantee holds on + * every public construction path, including a direct {@code new DteList(...)} call. + */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer d : days) { + if (d == null) { + throw new IllegalArgumentException("days must not contain null elements"); + } + requireDteInRange(d); + } + days = List.copyOf(days); + } + } + + /** Validated in the compact constructor — see {@link DteList}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + requireDteInRange(min); + requireDteInRange(max); + } + } + + /** Validated in the compact constructor — see {@link DteList}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + requireDteInRange(days); + } + } + + /** Shared {@code [0, 36500]} bound — the API's documented limit on every {@code dte} value. */ + private static void requireDteInRange(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > 36500) { + throw new IllegalArgumentException("dte must be at most 36500"); + } + } + 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..ac2c171 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,108 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + // dte's wire type changed from integer to string in v2.0.0 — it now also accepts a + // comma-separated list, a closed range, and a comparison operator (syntax shared with + // StrikeFilter). dte(int) above is unchanged and keeps its own test as the regression case. + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(15, 30, 45)) + .build()) + .join(); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — mirrors StrikeFilter's comparison encoding. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .build()) + .join(); + + // "%3C" is the URL-encoded "<". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, 30)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LTE, 45)) + .build()) + .join(); + + // "%3C%3D" is the URL-encoded "<=". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1305,118 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(ExpirationFilter::dte) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dte(15, -1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dte(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsDaysAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + // ---------- new dte variants: validation holds on the direct record constructor too ---------- + + @Test + void dteListRecordConstructorRejectsNullList() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteListRecordConstructorRejectsEmptyList() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteListRecordConstructorRejectsNullElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(java.util.Arrays.asList(15, null, 45))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not contain null elements"); + } + + @Test + void dteListRecordConstructorRejectsOutOfRangeElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of(15, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteRangeRecordConstructorRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteRangeRecordConstructorRejectsOutOfRangeBound() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteComparisonRecordConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteComparisonRecordConstructorRejectsOutOfRangeDays() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(