From fdde4428fda84d61a03b024703c535437e31571b Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 8 Oct 2026 09:45:51 -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: Build fails as expected (missing `DteFilter` and `ExpirationFilter.dte(DteFilter)`, not yet implemented). Confirmed per phase-1 requirements; stopping here. Summary: - Added unit wire tests in `OptionsResourceTest.java` for the three new `dte` forms the delta enumerates: comma-separated list (`DteFilter.values`), closed range (`DteFilter.range`), and comparison operator — one test per each of the four operators (`GT`/`GTE`/`LT`/`LTE`), mirroring `StrikeFilter.Operator`'s closed set. - Added validation tests for the new `DteFilter` factories: non-negative and ≤36500-day bounds on `values`/`range`/`comparison`, and `range`'s min≤max check — all on the new type, not on the pre-existing `ExpirationFilter.dte(int)`. - Left the existing single-value `dte(int)` wire/validation tests untouched as the regression case; the new forms are modeled as an additive `ExpirationFilter.dte(DteFilter)` overload so no existing caller breaks. - Added three integration tests against the live chain endpoint, one per new form (values/range/comparison), asserting decoded `dte` shape rather than exact values. - `gradle test --no-daemon --console=plain` fails at compile time on the missing `DteFilter` symbol, as required for phase 1. implement: Clean build succeeds from scratch, 100% coverage, formatting clean. Everything is in order. ## Summary Widened the chain endpoint's `dte` filter from a plain integer to the API's richer string syntax (comma list, closed range, comparison operator), via a new `DteFilter` sealed type and an additive `ExpirationFilter.dte(DteFilter)` overload — the existing `dte(int)` single-value overload is untouched, so no caller breaks. - Documentation: `docs/options/chain.md` now lists and demonstrates all three new forms. - Every new public member documented: `DteFilter` interface, its three factories, and its three records all carry Javadoc. - Wire test per form: comma list, closed range, and all four comparison operators (`GT`/`GTE`/`LT`/`LTE`) each assert the exact request sent, from phase 1. - Validation holds on every public path: bounds/operator checks live in the records' compact constructors, so both the static factories and direct `new DteFilter.X(...)` enforce them. - Coverage: 100% with no new `@Generated` exclusions. - Examples/docs that already showed `dte` usage (chain.md's existing example, `OptionsAdvancedExample.java`) still compile and were extended, not replaced, with a new-form example. Left unchanged, and why: `README.md`, `docs/client.md`, `docs/options/{quotes,expirations,lookup,README}.md`, `OptionsChain.java`, `OptionsCsvResource.java`, `OptionsHtmlResource.java`, `MarketDataClient.java`, `JsonResponseParserTest.java`, `RequestValidationTest.java` — their matches are incidental (generic "chain"/"ExpirationFilter" mentions, unrelated `dte` response field, or method-chaining prose), none describe the request-side `dte` syntax. `ADR-008` also left unchanged: `DteFilter` applies the sealed-type convention the ADR already accepted, rather than setting a new one. `OptionsExample.java` left unchanged — its reference to "DTE...filters" already points to `OptionsAdvancedExample`, which now covers the new form. Outside my limits, left as-is: nothing identified as now incorrect in `.github/ISSUE_TEMPLATE/bug.yml`, `build.gradle.kts`, or `.github/BUG_FINDING.md` — their matches predate and are unrelated to this parameter change. review fix 1: Within limits, nothing touched outside the allowed paths. ## Summary - Major — `DteFilter.Values` public construction path (`DteFilter.java:52`): added `isEmpty()` and per-element null checks to the record's canonical constructor, each throwing `IllegalArgumentException` before any unboxing happens, so `new DteFilter.Values(...)` enforces the same invariants as the `values(...)` factory regardless of entry point. Added two direct-constructor tests: `dteFilterValuesCanonicalConstructorRejectsEmptyList` and `...RejectsNullElement`, both calling `new DteFilter.Values(...)` directly rather than through the factory. - Major — missing live test for the single-value form (`OptionsIntegrationTest.java:138`): added `chainDteSingleValueStillResolvesViaStringHandler`, which calls `ExpirationFilter.dte(30)` against the live chain endpoint and asserts every returned row decodes a non-null `dte`. - Minor — weak assertion in `chainDteValuesReturnsExpirationPerValue`: kept the existing non-empty/non-null checks and added the market-independent consequence of the comma-list form: distinct expirations across the 3 requested values is asserted `<= 3`. - Updated `DteFilter.Values`'s Javadoc to state the non-empty/no-null-element invariant now enforced in the constructor itself. - Re-ran `gradle test` and `gradle clean test build` (`--no-daemon --console=plain`): both green, 100% JaCoCo line coverage held with no new `@Generated` exclusions. - No other files needed changes beyond the ones already touched in the prior cycle; nothing outside the test/integration-test/main-source limits was modified. changelog: Added `DteFilter` sealed type under `### Added` in `[Unreleased]`, describing v2.0.0's dte parameter widening and its three record forms and static factories. --- CHANGELOG.md | 10 + docs/options/chain.md | 23 ++- .../resources/OptionsAdvancedExample.java | 14 ++ .../sdk/OptionsIntegrationTest.java | 94 +++++++++ .../com/marketdata/sdk/OptionsResource.java | 16 ++ .../com/marketdata/sdk/options/DteFilter.java | 90 +++++++++ .../sdk/options/ExpirationFilter.java | 18 ++ .../marketdata/sdk/OptionsResourceTest.java | 182 ++++++++++++++++++ 8 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/marketdata/sdk/options/DteFilter.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..c8bdf2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`DteFilter` sealed type** — v2.0.0 widened the chain endpoint's `?dte=` + parameter to support comma-separated values, closed ranges, and comparison + operators (matching `StrikeFilter` syntax). Includes three record forms + (`Values`, `Range`, `Comparison`) and static factories `values(int, int...)`, + `range(int, int)`, and `comparison(StrikeFilter.Operator, int)`. Integrated + via new `ExpirationFilter.dte(DteFilter)` overload; single-value `dte` + remains via `ExpirationFilter.dte(int)`. + ## [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..55ff076 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,11 +20,17 @@ OptionsChainRequest.builder(String symbol) // Expiration selection (sealed ExpirationFilter — pick one): .expirationFilter(ExpirationFilter.onDate(LocalDate date)) - .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration + .expirationFilter(ExpirationFilter.dte(int days)) // days-to-expiration, single value + .expirationFilter(ExpirationFilter.dte(DteFilter filter)) // days-to-expiration, richer syntax .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) + // The richer dte syntax (sealed DteFilter — pick one, passed to ExpirationFilter.dte(...)): + .expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45))) // comma-separated list + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) // closed range, inclusive + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 30))) // comparison + // Strike selection (sealed StrikeFilter — pick one): .strikeFilter(StrikeFilter.exact(double price)) .strikeFilter(StrikeFilter.range(double min, double max)) @@ -66,6 +72,7 @@ public record OptionQuote( ```java import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -95,6 +102,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // DteFilter: the richer dte syntax — expirations between 30 and 90 days out. + var byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 90))) + .side(OptionSide.CALL) + .build()); + + // DteFilter: a comparison — expirations at least 180 days out (LEAPS). + var leaps = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180))) + .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..a616611 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -2,6 +2,7 @@ import com.marketdata.sdk.MarketDataClient; import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -51,6 +52,19 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // DteFilter: the richer dte syntax — comma-separated list, closed range, or comparison + // operator (same syntax as StrikeFilter.Operator) — for expirations a plain dte(int) can't + // express on its own. + long leaps = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().size(); + System.out.println("Contracts at least 180 days out: " + leaps); + if (chain.size() < 2) { System.out.println("Not enough contracts to demo the rest — try a more liquid underlying."); return; diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..9c64e3f 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -10,6 +11,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; @@ -133,6 +135,98 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteValuesReturnsExpirationPerValue() { + // dte widened from integer to string (v2.0.0): a comma-separated list selects the closest + // expiration for each of the 3 requested values, deduplicated — so however AAPL's own + // expiration cycle lands, three requested values can resolve to at most three distinct + // expirations. That bound (not just "non-empty") is the market-independent consequence of + // this specific form, as opposed to any other chain query. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.values(7, 30, 90))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).as("AAPL has expirations near 7, 30, and 90 days out").isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + } + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("3 requested values resolve to at most 3 distinct expirations") + .isLessThanOrEqualTo(3); + } + + @Test + void chainDteSingleValueStillResolvesViaStringHandler() { + // Regression: dte's plain-integer form (now parsed by the API's widened string handler) + // still resolves to a decoded dte on every row. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).as("AAPL has an expiration near 30 days out").isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + } + } + + @Test + void chainDteRangeReturnsExpirationsWithinBounds() { + // Closed range, inclusive at both ends. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 365))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).as("AAPL always has expirations within a year").isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(0, 365); + } + } + + @Test + void chainDteComparisonReturnsExpirationsPastThreshold() { + // Comparison operator — "the syntax matches strike and delta". + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).as("AAPL LEAPS extend well past 180 days out").isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(180); + } + } + @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..5384652 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionsChain; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +314,8 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteExpr v) { + b.query("dte", dteFilterWireValue(v.filter())); } else if (f instanceof ExpirationFilter.Between v) { b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); @@ -325,6 +329,18 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte // an if-chain, but there is no reachable else, so no defensive throw is needed. } + private static String dteFilterWireValue(DteFilter f) { + if (f instanceof DteFilter.Values v) { + return v.days().stream().map(String::valueOf).collect(Collectors.joining(",")); + } else if (f instanceof DteFilter.Range v) { + return v.min() + "-" + v.max(); + } + // DteFilter is sealed; the only remaining variant is Comparison. The cast documents that + // exhaustiveness (Java 17 can't prove it in an if-chain) and fails fast if a variant is added. + DteFilter.Comparison v = (DteFilter.Comparison) f; + return v.operator().wireValue() + v.days(); + } + private static String strikeFilterWireValue(StrikeFilter f) { if (f instanceof StrikeFilter.Exact v) { return formatStrike(v.price()); diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java new file mode 100644 index 0000000..5595bc3 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,90 @@ +package com.marketdata.sdk.options; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * The chain endpoint's {@code ?dte=} parameter accepts three syntactic forms beyond a bare integer + * — a comma-separated list, a closed range, and a comparison ({@code >=30}, {@code <45}, …) — "the + * syntax matches strike and delta" per the API's own description, i.e. the same shape {@link + * StrikeFilter} already models for {@code ?strike=}. Modeling them as a sealed type with factory + * entry-points gives the consumer compile-time autocomplete for valid shapes and prevents typos a + * raw-string passthrough would silently ship to the server. + * + *

A single value is still expressed via {@link ExpirationFilter#dte(int)} (unchanged, + * non-breaking) — this type covers only the forms a plain {@code int} cannot, reached through + * {@link ExpirationFilter#dte(DteFilter)}. + */ +public sealed interface DteFilter permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** Comma-separated list — selects the closest expiration for each value, deduplicated. */ + static Values values(int first, int... rest) { + List days = new ArrayList<>(); + days.add(first); + for (int d : rest) { + days.add(d); + } + return new Values(days); + } + + /** Closed range {@code [min, max]}, inclusive. {@code min} must not exceed {@code max}. */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** Match dte satisfying {@code operator days} (e.g. {@code >= 30}). */ + static Comparison comparison(StrikeFilter.Operator operator, int days) { + return new Comparison(operator, days); + } + + /** Whole days, at most 36500, per the API's own description of the parameter. */ + private static void validateDay(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"); + } + } + + /** + * Comma-separated {@code dte} values — see {@link #values(int, int...)}. {@code days} must be + * non-empty and carry no {@code null} elements — enforced here, not only in the static factory, + * since the canonical constructor is itself a public path. + */ + record Values(List days) implements DteFilter { + public Values { + 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"); + } + validateDay(d); + } + days = List.copyOf(days); + } + } + + /** Closed {@code dte} range — see {@link #range(int, int)}. */ + record Range(int min, int max) implements DteFilter { + public Range { + validateDay(min); + validateDay(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** {@code dte} comparison — see {@link #comparison(StrikeFilter.Operator, int)}. */ + record Comparison(StrikeFilter.Operator operator, int days) implements DteFilter { + public Comparison { + Objects.requireNonNull(operator, "operator"); + validateDay(days); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..cfcfccd 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -18,6 +18,7 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteExpr, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +48,16 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration filter using the richer {@code dte} syntax — comma-separated list, closed + * range, or comparison operator (v2.0.0 widened {@code dte} from a plain integer to this string + * syntax server-side). Wire form depends on the {@link DteFilter} variant; see {@link DteFilter} + * for each. A single value is still expressed via {@link #dte(int)}. + */ + static DteExpr dte(DteFilter filter) { + return new DteExpr(filter); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -79,6 +90,13 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** The richer {@code dte} syntax — see {@link #dte(DteFilter)}. */ + record DteExpr(DteFilter filter) implements ExpirationFilter { + public DteExpr { + Objects.requireNonNull(filter, "filter"); + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..7e60219 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; @@ -975,6 +976,116 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + // ---------- chain: dte filter forms (v2.0.0 — dte widened from integer to string) ---------- + + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedDte() { + // Comma-separated list: "selects the closest expiration for each value, deduplicated." + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45))) + .build()) + .join(); + + // Comma is URL-encoded (%2C) by the transport's encoder, 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 chainExpirationFilterDteRangeTranslatesToDashedDte() { + // Closed range, inclusive: "A closed range (0-45) ... returns every expiration whose dte + // falls within the bounds, inclusive." + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonGtTranslatesToOperatorPrefixedDte() { + // Comparison operator: "the syntax matches strike and delta" — same 4-operator closed set as + // StrikeFilter.Operator. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GT, 30))) + .build()) + .join(); + + // "%3E" is the URL-encoded ">". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 30))) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — matches the API description's own example (>=30). + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.LT, 45))) + .build()) + .join(); + + // "%3C" is the URL-encoded "<" — matches the API description's own example (<45). + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(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); @@ -1227,6 +1338,77 @@ void strikeFilterRangeRejectsMinGreaterThanMax() { .hasMessageContaining("min must be <= max"); } + // ---------- DteFilter: sealed type factory validation (v2.0.0) ---------- + + @Test + void dteFilterValuesRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.values(30, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterValuesCanonicalConstructorRejectsEmptyList() { + // The static factory can't build an empty list (its first value is mandatory), but the + // record's public canonical constructor is a separate path that must refuse it too — an + // empty list would otherwise render as the invalid wire value "?dte=". + assertThatThrownBy(() -> new DteFilter.Values(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteFilterValuesCanonicalConstructorRejectsNullElement() { + // Arrays.asList (unlike List.of) permits a null element, so this exercises the canonical + // constructor's own guard rather than List.of's built-in null rejection. + assertThatThrownBy(() -> new DteFilter.Values(java.util.Arrays.asList(30, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not contain null elements"); + } + + @Test + void dteFilterValuesRejectsDayAboveMaximum() { + // "Values are whole numbers of days, at most 36500." + assertThatThrownBy(() -> DteFilter.values(30, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeMin() { + assertThatThrownBy(() -> DteFilter.range(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsMaxAboveMaximum() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsDayAboveMaximum() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + // ---------- helpers ---------- private static CapturingClient okWith(String body) {