diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..cc09129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Options chain `dte` parameter widening** (API 2.0.0) — the options chain endpoint's + `dte` days-to-expiration filter now supports comma-separated lists, closed ranges, and + comparisons alongside single values. New sealed `ExpirationFilter` variants `DteList`, + `DteRange`, `DteComparison` and their factories `dteList(int...)`, `dteRange(int, int)`, + and `dteComparison(StrikeFilter.Operator, int)` expose all four syntaxes; a shared upper + bound `MAX_DTE_DAYS` limits values per the API. + ## [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..9693296 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)) // days-to-expiration, single value + .expirationFilter(ExpirationFilter.dteList(int... days)) // dte, comma-separated list + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte, closed range + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // dte, comparison .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -95,6 +98,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's other wire syntaxes: a comma-separated list, a closed range, and a comparison. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(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..39b571b 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,15 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte also accepts a comma-separated list, a closed range, and a comparison — on top of the + // single value used above. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .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..4e3aee3 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,10 +10,13 @@ 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.Set; +import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +136,118 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueSelectsOneNearestExpiration() { + // Regression: the single-value form of dte already worked before dte widened to also accept + // list / range / comparison syntax — assert the server still narrows to exactly one nearest + // expiration for it. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a single dte value narrows the chain to its one nearest expiration") + .isEqualTo(1); + } + + @Test + void chainDteCommaListSelectsClosestExpirationPerValue() { + // A full all() chain bounds what "a subset of the chain" means without reimplementing the + // server's own nearest-expiration tie-break: that algorithm is the server's to pick, and a + // local reimplementation of it (absolute distance, first-row tie-break) is not guaranteed to + // match, so this asserts shape instead — the list narrows to more than one and at most three + // of the chain's own expirations, which is only possible if the server honored every value. + Set chainExpirations = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values() + .stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate()) + .collect(Collectors.toSet()); + assertThat(chainExpirations).isNotEmpty(); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + Set actualExpirations = + chain.stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate()) + .collect(Collectors.toSet()); + assertThat(actualExpirations) + .as("every expiration a dte list selects is one the full chain actually has") + .isSubsetOf(chainExpirations); + assertThat(actualExpirations) + .as("three distinct dte targets (15, 30, 45) resolve to more than one distinct expiration") + .hasSizeGreaterThan(1); + assertThat(actualExpirations) + .as("a three-value dte list narrows to at most three expirations, not the whole chain") + .hasSizeLessThanOrEqualTo(3); + } + + @Test + void chainDteRangeReturnsExpirationsWithinInclusiveBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isBetween(0, 45); + } + } + + @Test + void chainDteComparisonReturnsExpirationsAboveThreshold() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()).isGreaterThanOrEqualTo(30); + } + } + @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..2980af0 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -312,6 +312,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", joinDays(v.days())); + } 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())); @@ -344,6 +350,17 @@ private static String formatStrike(double v) { return Double.toString(v); } + private static String joinDays(List days) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < days.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(days.get(i)); + } + return sb.toString(); + } + // ---------- wire-format module ---------- static SimpleModule wireFormatModule() { diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..8610b26 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,8 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -14,14 +16,32 @@ *

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

{@code dte} itself has four wire syntaxes — single value ({@link #dte(int)}), comma-separated + * list ({@link #dteList(int...)}), closed range ({@link #dteRange(int, int)}), and comparison + * ({@link #dteComparison(StrikeFilter.Operator, int)}) — modeled as four sibling variants rather + * than widening {@link #dte(int)} itself, so the pre-existing single-value call keeps compiling and + * keeps whatever error (local or server-side) it produced before. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { + /** + * Upper bound on a single {@code dte} value, per the API. Enforced locally by {@link + * #dteList(int...)}, {@link #dteRange(int, int)}, and {@link + * #dteComparison(StrikeFilter.Operator, int)}. {@link #dte(int)} predates this bound and does not + * check it — a value above it reaches the API and comes back as a server-side rejection rather + * than an {@link IllegalArgumentException}. + */ + int MAX_DTE_DAYS = 36500; + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); @@ -39,7 +59,15 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Single days-to-expiration filter — wire form {@code ?dte=N}. Limits the chain to the one + * expiration whose dte is closest to {@code days}. + * + *

Only rejects a negative {@code days} locally; unlike {@link #dteList(int...)}, {@link + * #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, it does not + * enforce {@link #MAX_DTE_DAYS} — a value above that bound reaches the API and fails as a server + * rejection instead of an {@link IllegalArgumentException}. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +75,40 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. The API resolves the closest + * expiration to each value independently, deduplicated. {@code days} must be non-empty; every + * value must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, both + * bounds are enforced locally. + */ + static DteList dteList(int... days) { + List list = new ArrayList<>(days.length); + for (int day : days) { + list.add(day); + } + return new DteList(list); + } + + /** + * Days-to-expiration range filter — wire form {@code ?dte=0-45}. Returns every expiration whose + * dte falls in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}; both bounds + * must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper + * bound is enforced locally. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form {@code ?dte=>=30} / {@code ?dte=<45}. Returns + * every expiration whose dte satisfies {@code operator days}. {@code days} must be non-negative + * and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper bound is enforced + * locally. + */ + 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}. @@ -77,12 +139,59 @@ record OnDate(LocalDate date) implements ExpirationFilter { } } + // Dte predates MAX_DTE_DAYS and keeps its original, factory-only non-negative check (no compact + // constructor) so an existing caller's behavior — including constructing it unvalidated via + // `new Dte(...)` — does not change for this release. record Dte(int days) implements ExpirationFilter {} + /** See {@link #dteList(int...)}. {@code days} is defensively copied and immutable. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("dteList must not be empty"); + } + for (Integer day : days) { + Objects.requireNonNull(day, "days element"); + validateDteValue(day); + } + days = List.copyOf(days); + } + } + + /** See {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDteValue(min); + validateDteValue(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** See {@link #dteComparison(StrikeFilter.Operator, int)}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + validateDteValue(days); + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} /** The whole chain across every expiration — see {@link #all()}. Carries no data of its own. */ record All() implements ExpirationFilter {} + + /** Bounds check backing {@link #dteList}, {@link #dteRange}, and {@link #dteComparison}. */ + private static void validateDteValue(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > MAX_DTE_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DTE_DAYS); + } + } } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..90ce552 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -27,6 +27,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 +976,121 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteStillAcceptsValuesBeyondTheNewUpperBoundRegression() { + // dte's wire type widened to accept list/range/comparison forms, but the pre-existing + // single-value factory must not gain a new local upper-bound check: a value over the API's + // documented 36500-day max still reaches the wire unchanged, so an existing caller keeps + // seeing the same (lack of) local validation — and the same API error type — as before this + // change. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(40000)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=40000"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonGtTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteTranslatesToOperatorPrefixedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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 +1319,96 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(ExpirationFilter::dteList) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("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 expirationFilterDteRangeRejectsMaxAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsDaysAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + // ---------- new dte-filter records: validation holds on direct construction too ---------- + + @Test + void dteListRecordRejectsEmptyWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be empty"); + } + + @Test + void dteListRecordRejectsNullElementWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(Arrays.asList(30, null))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteListRecordRejectsNullListWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteRangeRecordRejectsMinGreaterThanMaxWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteComparisonRecordRejectsNullOperatorWhenConstructedDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(