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) {