diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..54814eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`DteFilter` sealed type** (API 2.0.0) — multi-value days-to-expiration filtering for chain queries. Sealed record variants: `ValueList` (`list(int, int...)`), `Range` (`range(int, int)`), `Comparison` (`comparison(Operator, int)` where `Operator` = `GT`/`GTE`/`LT`/`LTE`). New `ExpirationFilter.dte(DteFilter)` overload; existing `dte(int)` single-value form unchanged. Constant `MAX_DAYS = 36500`. All values validated to `[0, 36500]`. + ## [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..4a929fb 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,8 @@ 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, list/range/comparison .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +41,18 @@ OptionsChainRequest.builder(String symbol) .build() ``` +#### DteFilter + +`ExpirationFilter.dte(DteFilter)` covers the `dte` query parameter's multi-value syntax — a sealed type alongside `ExpirationFilter.dte(int)`'s single-value form: + +```java +DteFilter.list(int first, int... rest) // comma-separated list, e.g. ?dte=15,30,45 +DteFilter.range(int min, int max) // closed, inclusive range, e.g. ?dte=0-45 +DteFilter.comparison(DteFilter.Operator op, int days) // e.g. ?dte=>=30 — GT, GTE, LT, LTE +``` + +Every value, in every form, must be a whole number of days in `[0, 36500]`. + #### Returns @@ -66,6 +79,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 +109,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // DteFilter: contracts between 30 and 60 days to expiry, inclusive. + var byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60))) + .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..7d87462 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,17 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // ExpirationFilter.dte(...) also accepts the chain's full dte syntax via DteFilter: a + // comma-separated list, a closed range, or a comparison — not just a single value. + List byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("dte in [30, 60]: " + byDteRange.size() + " contract(s)"); + 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..4fa5446 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; @@ -133,6 +134,114 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueNarrowsToFarDatedExpiration() { + // A single dte value narrows the chain to the expiration closest to it; a far-out value (180 + // days) necessarily lands well past the API's front-month default, so a loose lower bound on + // the result proves the filter reached the server rather than being ignored. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(180)) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain.get(0).dte()).isNotNull(); + assertThat(chain.get(0).dte()).isGreaterThan(60); + } + + @Test + void chainDteListSelectsMultipleDistinctExpirations() { + // A comma-separated dte list selects the closest expiration for each value, deduplicated + // server-side, so three requested values select at most three distinct expirations. + // Well-spaced values (7, 60, 180 days) land on different expiration cycles, so the result also + // spans more expirations than the same request without the filter — which returns only the + // single front-month expiration — proving the server honored the list rather than ignoring it. + List unfiltered = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + long unfilteredDistinctExpirations = + unfiltered.stream().map(OptionQuote::expiration).distinct().count(); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.list(7, 60, 180))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a comma-separated dte list selects at most one expiration per requested value") + .isLessThanOrEqualTo(3); + assertThat(distinctExpirations) + .as("the filtered result differs from the unfiltered chain's single front-month expiration") + .isGreaterThan(unfilteredDistinctExpirations); + } + + @Test + void chainDteRangeReturnsExpirationsWithinInclusiveBounds() { + // range(60, 180) excludes AAPL's near-term weekly expirations; every returned row's dte must + // fall within the closed, inclusive bounds the API documents. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(60, 180))) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(60, 180); + } + } + + @Test + void chainDteComparisonReturnsExpirationsSatisfyingOperator() { + // dte >= 60 excludes AAPL's near-term weekly expirations; every returned row must satisfy the + // inclusive comparison the API documents. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 60))) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(60); + } + } + @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..55232fa 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; @@ -312,6 +313,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.DteValues 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())); @@ -337,6 +340,25 @@ private static String strikeFilterWireValue(StrikeFilter f) { return v.operator().wireValue() + formatStrike(v.price()); } + private static String dteFilterWireValue(DteFilter f) { + if (f instanceof DteFilter.ValueList v) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < v.days().size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(v.days().get(i)); + } + return sb.toString(); + } 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 formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); 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..f895213 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,122 @@ +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 four syntactic forms: a single value + * (handled directly by {@link ExpirationFilter#dte(int)}), a comma-separated list, a closed range, + * and a comparison ({@code >=30}, {@code <45}, …). This sealed type covers the three multi-value + * forms — reach them through {@link ExpirationFilter#dte(DteFilter)}. + * + *

Modeling them as a sealed type (mirroring {@link StrikeFilter}) gives the consumer + * compile-time autocomplete for valid shapes and prevents a hand-assembled range or comparison + * string from shipping a typo to the server. Every value, in every form, is a whole number of days + * in {@code [0, 36500]} — the bound the API documents. + */ +public sealed interface DteFilter + permits DteFilter.ValueList, DteFilter.Range, DteFilter.Comparison { + + /** The largest {@code dte} value the API accepts. */ + int MAX_DAYS = 36500; + + /** + * Comma-separated list of values — wire form {@code ?dte=15,30,45}. Selects the expiration + * closest to each value (deduplicated server-side). At least one value is required. + */ + static ValueList list(int first, int... rest) { + List days = new ArrayList<>(); + days.add(first); + for (int d : rest) { + days.add(d); + } + return new ValueList(days); + } + + /** + * Closed, inclusive range — wire form {@code ?dte=0-45}. {@code min} must not exceed {@code max}. + */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** Match {@code dte} values satisfying {@code operator days} (e.g. {@code >= 30}). */ + static Comparison comparison(Operator operator, int days) { + return new Comparison(operator, days); + } + + private static int validate(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > MAX_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DAYS); + } + return days; + } + + /** Comparison operators accepted by the API — same syntax as {@link StrikeFilter.Operator}. */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + + /** + * @param days at least one value, each in {@code [0, 36500]}. Immutable; never {@code null}, + * empty, or containing a {@code null} element. + */ + record ValueList(List days) implements DteFilter { + public ValueList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + List validated = new ArrayList<>(days.size()); + for (Integer d : days) { + validated.add(validate(Objects.requireNonNull(d, "days element"))); + } + days = List.copyOf(validated); + } + } + + /** + * @param min lower bound of the closed range, inclusive, in {@code [0, 36500]}. Must not exceed + * {@code max}. + * @param max upper bound of the closed range, inclusive, in {@code [0, 36500]}. Must not be less + * than {@code min}. + */ + record Range(int min, int max) implements DteFilter { + public Range { + validate(min); + validate(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** + * @param operator the comparison to apply against {@code days}; never {@code null}. + * @param days the threshold, in {@code [0, 36500]}. + */ + record Comparison(Operator operator, int days) implements DteFilter { + public Comparison { + Objects.requireNonNull(operator, "operator"); + validate(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..4c4d490 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -11,6 +11,11 @@ * expirationFilter(...)} setter on the request builder makes that exclusivity compiler-enforced: * there is no way to assign two variants at once. * + *

{@code dte} alone has two entry points — {@link #dte(int)} for a single value, {@link + * #dte(DteFilter)} for the list/range/comparison forms ({@link DteFilter}) — but both produce an + * {@code ExpirationFilter}, so they still compete with {@code onDate}/{@code between}/{@code + * monthYear}/{@code all} for the one setter slot. + * *

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. @@ -18,6 +23,7 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteValues, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +45,7 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** Days-to-expiration, single value — wire form {@code ?dte=N}. */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +53,15 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration, multi-value form — a comma-separated list, a closed range, or a comparison, + * per {@link DteFilter}. Wire form is whichever {@code DteFilter} variant produces (e.g. {@code + * ?dte=15,30,45}, {@code ?dte=0-45}, {@code ?dte=>=30}). + */ + static DteValues dte(DteFilter filter) { + return new DteValues(Objects.requireNonNull(filter, "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 +94,13 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** See {@link #dte(DteFilter)}. */ + record DteValues(DteFilter filter) implements ExpirationFilter { + public DteValues { + 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..2315b01 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; @@ -27,6 +28,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -975,6 +977,106 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.list(15, 30, 45))) + .build()) + .join(); + + // The comma is URL-encoded on the wire (same as ?columns=), so decode before asserting. + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.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 chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GT, 30))) + .build()) + .join(); + + // "%3E" is the URL-encoded ">" — the query encoder pushes reserved characters through. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .build()) + .join(); + + 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.dte(DteFilter.comparison(DteFilter.Operator.LT, 45))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LTE, 45))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1305,84 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void dteFilterListRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.list(-1, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterListRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.list(15, 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 dteFilterRangeRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.range(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterRangeAcceptsInclusiveMaxBoundary() { + assertThat(DteFilter.range(0, 36500).max()).isEqualTo(36500); + } + + @Test + void dteFilterComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsValueAboveMax() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNullOperator() { + assertThatThrownBy(() -> DteFilter.comparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + + // ---------- sealed type validation: direct record construction (bypassing the factories) + // ---------- + + @Test + void dteFilterValueListDirectConstructionRejectsEmptyList() { + assertThatThrownBy(() -> new DteFilter.ValueList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteFilterValueListDirectConstructionRejectsNullElement() { + assertThatThrownBy(() -> new DteFilter.ValueList(Arrays.asList(15, null))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days element"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(