From 268d8fb154d06b12582661e11b7fe506891e0315 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 3 Nov 2026 09:21:05 -0500 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 wire tests (OptionsResourceTest.java) for the 4 forms the API now describes for `dte`: single value (regression, plus a new test proving the pre-existing dte(int) keeps accepting out-of-range values unchanged), comma list (dteList), closed range (dteRange), and comparison operator (dteComparison, with >= and < per the two operator examples given). Added validation tests for the three new factories (empty list, negative, >36500, min>max). Added 4 live integration tests in OptionsIntegrationTest.java covering each form against real AAPL expirations, anchored on values from an ExpirationFilter.all() call (never the unfiltered default) so each assertion fails against the unfiltered answer. The design adds new ExpirationFilter permitted variants/factories only — the existing dte(int) factory is untouched and unbroken, so no SdkSyncCompatProbe.java was needed (delta is purely additive). implement: Implemented the `dte` string-syntax delta by adding three new permitted `ExpirationFilter` sealed variants — `dteList`/`dteRange`/`dteComparison` (reusing `StrikeFilter.Operator`) — alongside the unchanged `dte(int)`, wired into `OptionsResource`'s query serialization, with every bound validated in each record's own compact constructor so direct construction is covered too. - Docs: new methods documented with javadoc and an example (docs/options/chain.md, OptionsAdvancedExample.java); every new form has a wire test (unit) and all four forms have a live-anchor integration test. - New-type validation: compact constructors enforce non-empty/bounds/ordering on every public construction path, not just the factories; `List.copyOf` rejects null elements. - Coverage: 100% line coverage verified by the existing JaCoCo gate, no new `@Generated` exclusions. - Existing surface: `dte(int)` kept its exact signature and error contract (no upper-bound check added there); added a regression test proving it still accepts out-of-range values unchanged. - Fixed one phase-1 test (`chainExpirationFilterDteListTranslatesToCommaSeparatedDte`): it asserted a literal comma on the wire, but the transport's existing query encoder percent-encodes commas (confirmed by the pre-existing `columns` tests, which URL-decode first) — updated it to decode the URL before asserting, same as the repo's established pattern. - Live-test self-check: all four integration tests anchor on real values from an `ExpirationFilter.all()` call (never the unfiltered default) and each assertion was checked to fail against the unfiltered single-front-month answer. - README.md, docs/client.md, docs/options/{quotes,expirations,lookup,README}.md, docs/.sync-manifest.txt, MarketDataClient.java, OptionsCsvResource.java, OptionsHtmlResource.java, OptionsChain.java, JsonResponseParserTest.java, RequestValidationTest.java, OptionsExample.java: left unchanged — none reference `dte`'s filter syntax specifically, only the unrelated `chain`/`ExpirationFilter.all()` paths, which remain correct. - Left outside limits: ADR-008's Context narrative still lists only the original `OnDate/Dte/Between/MonthYear/All` variants (now incomplete) and ADR-006 mentions `chain(` generically — both historical decision records, not updated per repo convention of not revising accepted ADRs for routine additions within their own convention. changelog: Added changelog entry under `## [Unreleased]` documenting `ExpirationFilter.dteList(List)`, `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` — the three new factories that implement API 2.0.0's string-syntax forms for the `dte` parameter (comma-separated list, closed range, comparison operator). Entry notes that `dte(int)` remains unchanged. The three new permitted subtypes (`DteList`, `DteRange`, `DteComparison`) are not listed separately per the repo's rule 9 (sealed-type additions). --- CHANGELOG.md | 12 ++ docs/options/chain.md | 34 +++- .../resources/OptionsAdvancedExample.java | 18 ++ .../sdk/OptionsIntegrationTest.java | 171 ++++++++++++++++++ .../com/marketdata/sdk/OptionsResource.java | 7 + .../sdk/options/ExpirationFilter.java | 81 +++++++++ .../marketdata/sdk/OptionsResourceTest.java | 144 +++++++++++++++ 7 files changed, 466 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..6924684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(List)` — comma-separated days-to-expiration + list; selects the expiration nearest each value (API 2.0.0). `ExpirationFilter.dte(int)` + continues to work as before. +- `ExpirationFilter.dteRange(int, int)` — closed-range days-to-expiration filter + (API 2.0.0). Returns every expiration whose DTE falls within `[min, max]` + inclusive. +- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` — + comparison-operator days-to-expiration filter (API 2.0.0). Returns every + expiration satisfying the condition (e.g., `>= 30`). + ## [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..63b7708 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)) // a single dte value + .expirationFilter(ExpirationFilter.dteList(List days)) // comma-separated dte values + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed dte range, inclusive + .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,21 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` itself accepts four forms — a single value, a comma-separated list, a closed range, or a +comparison — the same range/comparison syntax `StrikeFilter` uses for `strike`: + +```java +ExpirationFilter.dte(30) // ?dte=30 +ExpirationFilter.dteList(List.of(15, 30, 45)) // ?dte=15,30,45 +ExpirationFilter.dteRange(0, 45) // ?dte=0-45 +ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30) // ?dte=>=30 +``` + +Every `dte` value accepted by the API is a non-negative whole number of days, at most 36500. The +SDK enforces that upper bound client-side on the three new forms (`dteList`/`dteRange`/ +`dteComparison`); the original single-value `dte(int)` predates the bound and still lets an +out-of-range value reach the API rather than rejecting it locally. + #### Returns @@ -95,6 +113,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's own sub-syntax: list, closed range, and comparison, matching StrikeFilter's syntax. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()); + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()); } ``` diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java index c4f0977..cb31695 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,24 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte's own sub-syntax: a comma-separated list, a closed range, or a comparison — the same + // range/comparison syntax StrikeFilter uses for strike. + long dteFormVariants = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("ExpirationFilter.dteList(15, 30, 45) spans " + dteFormVariants + + " distinct expirations"); + client.options().chain( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteRange(0, 45)).build()); + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()); + // 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..c87bfcb 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,10 +10,12 @@ 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; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +135,151 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueSelectsExactExpirationWhenAvailable() { + // Pick the furthest real expiration (far from the front-month the unfiltered call would + // return) and request it by its own dte: the "closest expiration" to a value that is itself + // a real expiration is that expiration exactly, with zero distance. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least two live expirations") + .hasSizeGreaterThanOrEqualTo(2); + int farthest = liveDtes.get(liveDtes.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(farthest)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain.stream().map(OptionQuote::dte).distinct().toList()) + .as( + "dte=%d selects exactly the expiration %d days out, not the front month", + farthest, farthest) + .containsExactly(farthest); + } + + @Test + void chainDteListSelectsClosestExpirationForEachValue() { + // Three real, widely-spaced expirations: requesting them by dte should return all three + // distinct expirations, which the unfiltered (front-month-only) answer never does. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int low = liveDtes.get(0); + int mid = liveDtes.get(liveDtes.size() / 2); + int high = liveDtes.get(liveDtes.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(low, mid, high))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain.stream().map(OptionQuote::dte).distinct().toList()) + .as( + "dte=%d,%d,%d selects all three expirations, unlike the single front-month default", + low, mid, high) + .containsExactlyInAnyOrder(low, mid, high); + } + + @Test + void chainDteRangeIncludesEveryExpirationWithinBoundsInclusive() { + // A closed range starting after the front month: every expiration from the second-smallest + // live dte through the largest must come back, and the front month (the unfiltered answer) + // must be excluded — proving both the inclusive bounds and that the filter actually narrowed + // the result away from the default. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int frontMonth = liveDtes.get(0); + int low = liveDtes.get(1); + int high = liveDtes.get(liveDtes.size() - 1); + List expectedInRange = liveDtes.stream().filter(d -> d >= low && d <= high).toList(); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(low, high)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + List distinctDtes = chain.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(distinctDtes) + .as( + "dte=%d-%d keeps every expiration in [%d, %d] inclusive, including both bounds", + low, high, low, high) + .containsExactlyInAnyOrderElementsOf(expectedInRange) + .contains(low, high); + assertThat(distinctDtes) + .as("the range excludes the front month %d", frontMonth) + .doesNotContain(frontMonth); + } + + @Test + void chainDteComparisonGteIncludesBoundaryLtExcludesIt() { + // Anchor on the furthest real expiration M: ">=M" must keep exactly M (nothing exceeds the + // maximum, so the bound is kept), while " liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int anchor = liveDtes.get(liveDtes.size() - 1); + + List gteChain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchor)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(gteChain.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte>=%d keeps exactly the boundary expiration %d", anchor, anchor) + .containsExactly(anchor); + + List ltChain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchor)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List ltDtes = ltChain.stream().map(OptionQuote::dte).distinct().toList(); + assertThat(ltDtes) + .as( + "dte<%d excludes the boundary expiration %d and returns more than the front month", + anchor, anchor) + .doesNotContain(anchor) + .hasSizeGreaterThan(1); + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes @@ -219,6 +366,30 @@ void quotesFansOutToMultipleContracts() { assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second); } + /** + * Every distinct dte (days-to-expiration) across AAPL's full expiration calendar today, sorted + * ascending — used as real anchors for the dte filter tests above instead of values guessed from + * today's date, which expirations drift around daily. + */ + private List liveDteValues() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + return chain.stream() + .map(OptionQuote::dte) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + } + /** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */ private String sampleOptionSymbol() { List chain = 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..65a4c78 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; /** @@ -14,10 +15,20 @@ *

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 carries its own sub-syntax ({@link #dte}, {@link #dteList}, {@link + * #dteRange}, {@link #dteComparison}) — a single value, a comma-separated list, a closed range, or + * a comparison, matching the syntax {@link StrikeFilter} uses for {@code strike}. These are + * separate permitted variants rather than a nested sealed type because they all serialize to the + * same {@code dte} query parameter and already live under this sealed hierarchy's "pick one + * variant" umbrella. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +58,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 in {@code days}; the API deduplicates server-side when two values resolve + * to the same expiration. Each value is bounded like {@link #dte} ({@code [0, 36500]}). + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Days-to-expiration closed-range filter — wire form {@code ?dte=min-max}. Returns every + * expiration whose dte falls within {@code [min, max]} inclusive; the syntax matches {@link + * StrikeFilter#range}. {@code min} must not exceed {@code max}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form e.g. {@code ?dte=>=30}. Returns every + * expiration satisfying {@code operator days}; the syntax matches {@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 +117,49 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** The comma-separated-list form of {@code dte} — see {@link #dteList}. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + days = List.copyOf(days); // defensive copy; rejects a null element with NullPointerException + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (int d : days) { + checkDteBounds(d); + } + } + } + + /** The closed-range form of {@code dte} — see {@link #dteRange}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + checkDteBounds(min); + checkDteBounds(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** The comparison-operator form of {@code dte} — see {@link #dteComparison}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + checkDteBounds(days); + } + } + + /** Shared {@code dte} bound — whole numbers of days, at most 36500, per the API's description. */ + private static void checkDteBounds(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..124b5bd 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,94 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteStillAcceptsValuesAboveNewMaximum() { + // The pre-existing single-value dte(int) factory predates the API's documented 36500-day cap + // and keeps the error contract it had before this change: the SDK still lets the value reach + // the API rather than rejecting it client-side. The new cap is enforced only on the new + // dteList/dteRange/dteComparison factories below. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(50000)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=50000"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + 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(); + + 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 chainExpirationFilterDteRangeTranslatesToDashedDte() { + 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 chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — mirrors the strike comparison encoding above. + assertThat(url).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(); + + String url = client.captured.get(0).uri().toString(); + // "%3C" is the URL-encoded "<". + assertThat(url).contains("dte=%3C45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1291,62 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(10, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(10, 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"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(