diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..4725a4d 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 + +- `ExpirationFilter.dteList(List)`, `ExpirationFilter.dteRange(int, int)`, `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — the API now accepts string forms of the `dte` parameter on `GET /v1/options/chain/{underlying}/` (API release 2.0.0). Sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, `ExpirationFilter.DteComparison`; `ExpirationFilter.Operator` enum (GT, GTE, LT, LTE) with `ExpirationFilter.Operator.wireValue()` accessor; and constants `ExpirationFilter.MIN_DTE` / `ExpirationFilter.MAX_DTE`. Existing `ExpirationFilter.dte(int)` works unchanged. + ## [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..914a4a4 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)) // closest to N days + .expirationFilter(ExpirationFilter.dteList(List days)) // closest to each of N1,N2,... + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte in [min, max], inclusive + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) // dte op days .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +43,12 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` alone carries four wire syntaxes: a single value (`dte(45)` → `?dte=45`), a comma-separated +list (`dteList(List.of(15, 30, 45))` → `?dte=15,30,45`, selecting the closest expiration for each +value), a closed range (`dteRange(0, 45)` → `?dte=0-45`, inclusive), and a comparison +(`dteComparison(ExpirationFilter.Operator.GTE, 30)` → `?dte=>=30`). Every `dte` value must be a +whole number of days in `[0, 36500]`. + #### Returns @@ -95,6 +104,18 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a range or a comparison, in addition to a single value or a list. + var within30To45Days = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .side(OptionSide.CALL) + .build()); + var at30DaysOrFurther = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .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..6484c61 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 @@ -51,6 +51,17 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // dte also accepts a comma-separated list, a closed range, and a comparison, in addition to + // the single value used above. + long between30And45 = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("ExpirationFilter.dteRange(30, 45) spans " + between30And45 + " expirations"); + 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..daf14f9 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -14,6 +14,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +134,166 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueSelectsTheClosestExpiration() { + // Pick the farthest real expiration (not the front-month) so a server that silently ignored + // the filter — falling back to its own front-month default — would be caught: that default + // would not match "target" here. + List dtes = liveDistinctDtes(); + int target = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(target)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(result).isNotEmpty(); + assertThat(result.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte=%d resolves to the single expiration closest to that value", target) + .containsExactly(target); + } + + @Test + void chainDteListResolvesToEachValuesClosestExpiration() { + List dtes = liveDistinctDtes(); + int first = dtes.get(0); + int last = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(first, last))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(result.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte list resolves to exactly the two requested expirations, nothing else") + .containsExactlyInAnyOrder(first, last); + } + + @Test + void chainDteRangeKeepsExpirationsWithinInclusiveBounds() { + // An interior subrange (second through second-to-last baseline dte) so the requested range is + // a strict subset of the full chain — unlike the full span, this distinguishes a working range + // filter from one that was ignored (expiration=all's own front-to-back span would otherwise + // look + // identical to the filtered answer). + List dtes = liveDistinctDtes(); + int min = dtes.get(1); + int max = dtes.get(dtes.size() - 2); + int belowRange = dtes.get(0); + int aboveRange = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + List resultDtes = result.stream().map(OptionQuote::dte).distinct().toList(); + assertThat(resultDtes) + .as("every returned expiration falls within the inclusive [%d, %d] bound", min, max) + .allMatch(d -> d >= min && d <= max); + assertThat(resultDtes) + .as("the range's own endpoints are kept (bounds are inclusive)") + .contains(min, max); + assertThat(resultDtes) + .as("expirations outside the requested range are excluded, not just the ones inside kept") + .doesNotContain(belowRange, aboveRange); + } + + @Test + void chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt() { + // Compare each comparison answer against the exact baseline slice it ought to produce (not + // just "contains" / "does not contain" the boundary): a server that ignored the dte filter + // entirely would fall back to its own front-month default, which happens not to carry + // "boundary" either, so a bare doesNotContain(boundary) on the "<" half would pass whether or + // not the filter was honored. The exact-match on both halves proves inclusion and exclusion at + // once: nothing below the boundary leaks into ">=", and nothing at or above it leaks into "<". + List dtes = liveDistinctDtes(); + int boundary = dtes.get(dtes.size() / 2); + List atOrAboveBoundary = dtes.stream().filter(d -> d >= boundary).toList(); + List belowBoundary = dtes.stream().filter(d -> d < boundary).toList(); + + List included = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, boundary)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List includedDtes = + included.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(includedDtes) + .as( + ">=%d keeps every baseline expiration at or above %d, boundary included", + boundary, boundary) + .allMatch(d -> d >= boundary) + .containsExactlyElementsOf(atOrAboveBoundary); + + List excluded = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, boundary)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List excludedDtes = + excluded.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(excludedDtes) + .as( + "<%d keeps exactly the baseline expirations below %d, boundary excluded", + boundary, boundary) + .containsExactlyElementsOf(belowBoundary); + } + + /** + * Ground truth for the dte-filter assertions: the distinct dte values present in today's full + * AAPL call chain, discovered via the already-covered {@code expiration=all} filter rather than + * computed from the local clock. AAPL carries far more than four standard expirations at any + * time, so the index picks used by the dte tests (first / second / middle / second-to-last / + * last) are always distinct, and the range test's interior subrange always has at least one + * baseline dte strictly outside it on each side. + */ + private List liveDistinctDtes() { + List all = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List dtes = + all.stream().map(OptionQuote::dte).filter(Objects::nonNull).distinct().sorted().toList(); + assertThat(dtes).as("AAPL carries several distinct expirations").hasSizeGreaterThanOrEqualTo(4); + return dtes; + } + @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..d35906e 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -28,6 +28,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +313,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteList v) { + b.query("dte", dteListWireValue(v)); + } 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())); @@ -325,6 +332,10 @@ 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 dteListWireValue(ExpirationFilter.DteList f) { + return f.days().stream().map(String::valueOf).collect(Collectors.joining(",")); + } + 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/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..e3fbb37 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -14,14 +15,28 @@ *

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} alone carries four wire syntaxes — a single value ({@link #dte(int)}), a + * comma-separated list ({@link #dteList(List)}), a closed range ({@link #dteRange(int, int)}), and + * a comparison ({@link #dteComparison(Operator, int)}) — mirroring {@link StrikeFilter}'s + * exact/range/comparison forms plus the list form the API allows only for {@code dte}. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { + /** Smallest number of days to expiry the API accepts for any {@code dte} form. */ + int MIN_DTE = 0; + + /** Largest number of days to expiry the API accepts for any {@code dte} form. */ + int MAX_DTE = 36500; + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); @@ -39,7 +54,10 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration filter — wire form {@code ?dte=N}. Limits the option chain to the expiration + * date closest to {@code days}. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +65,33 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list — wire form {@code ?dte=15,30,45}. Selects, for each value in {@code + * days}, the expiration closest to it; the API deduplicates the result. Each value must be in + * {@code [0, 36500]}. + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Days-to-expiration closed range — wire form {@code ?dte=0-45}. Returns every expiration whose + * dte falls within {@code [min, max]}, inclusive. {@code min} must not exceed {@code max}, and + * both must be in {@code [0, 36500]}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=>=30} (e.g. {@code operator} {@code GTE}, + * {@code days} {@code 30}). Returns every expiration satisfying {@code operator days}. {@code + * days} must be in {@code [0, 36500]}. + */ + static DteComparison dteComparison(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}. @@ -71,6 +116,36 @@ static MonthYear monthYear(int year, int month) { return new MonthYear(year, month); } + /** + * Comparison operators accepted by the {@code dte} parameter — mirrors {@link StrikeFilter}'s. + */ + 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; + } + } + + /** + * Rejects a {@code dte} value outside {@code [0, 36500]} — shared by every new {@code dte} form. + */ + private static void checkDteBounds(int days) { + if (days < MIN_DTE || days > MAX_DTE) { + throw new IllegalArgumentException("dte must be in [0, 36500]"); + } + } + record OnDate(LocalDate date) implements ExpirationFilter { public OnDate { Objects.requireNonNull(date, "date"); @@ -79,6 +154,40 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** See {@link #dteList(List)}. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer day : days) { + Objects.requireNonNull(day, "days must not contain null elements"); + checkDteBounds(day); + } + days = List.copyOf(days); + } + } + + /** See {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + checkDteBounds(min); + checkDteBounds(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** See {@link #dteComparison(Operator, int)}. */ + record DteComparison(Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + checkDteBounds(days); + } + } + 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..53d519f 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,103 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()) + .join(); + + // The query encoder percent-encodes the comma; decode before asserting the exact wire value, + // matching how the repository's other comma-joined params (e.g. columns) are asserted. + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GT, 10)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">" — the query encoder pushes reserved characters through. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E10"); + } + + @Test + void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.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.dteComparison(ExpirationFilter.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.dteComparison(ExpirationFilter.Operator.LTE, 60)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D60"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1301,106 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(-1, 30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, 36501))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteListRejectsNullList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days"); + } + + @Test + void expirationFilterDteListRejectsNullElement() { + assertThatThrownBy(() -> ExpirationFilter.dteList(Arrays.asList(15, null))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days must not contain null elements"); + } + + @Test + void expirationFilterDteListRecordConstructorAlsoValidates() { + // The static factory isn't the only public path: the record's own canonical constructor is + // public too, so validation has to live there, not just in dteList(...). + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @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 in [0, 36500]"); + } + + @Test + void expirationFilterDteRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteRangeRecordConstructorAlsoValidates() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteComparisonRejectsNegative() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteComparisonRejectsNullOperator() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + + @Test + void expirationFilterDteComparisonRecordConstructorAlsoValidates() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(