diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..3fce5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dte(int...)`, the comma-separated dte list wire form (API release 2.0.0). + `ExpirationFilter.dte(int)` works as before. +- `ExpirationFilter.dteRange(int, int)`, the closed-range dte wire form (API release 2.0.0). +- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)`, the comparison-operator dte wire form + (API release 2.0.0), reusing `StrikeFilter.Operator` syntax. +- `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and `ExpirationFilter.DteComparison`: new + permitted subtypes of the sealed `ExpirationFilter` interface, supporting the API's three new dte + forms (API release 2.0.0). + ## [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..2965fc2 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)) // dte: single value + .expirationFilter(ExpirationFilter.dte(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) @@ -40,6 +43,8 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` has four wire forms (the API's `dte` parameter is string-typed as of v2.0.0): a single value, a comma-separated list (`dte(15, 30, 45)`, closest expiration per value, deduplicated), a closed range (`dteRange(0, 45)`, every expiration with `dte` in `[0, 45]`), and a comparison operator (`dteComparison(StrikeFilter.Operator.GTE, 30)`, syntax shared with `StrikeFilter`). Every value must be in `[0, 36500]`. + #### Returns @@ -95,6 +100,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a closed range — every expiration between 0 and 45 days out. + var inRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .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..37db43b 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,18 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte's wire parameter is string-typed as of v2.0.0: besides the single value above, it also + // accepts a comma-separated list, a closed range, and a comparison operator (syntax shared + // with StrikeFilter). + long inRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 60)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("dteRange(30, 60) spans " + inRange + " distinct expirations"); + // 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..42269a7 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,6 +10,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; @@ -158,6 +159,202 @@ void chainDecodesOptionalRhoColumn() { } } + // ---------- chain: dte parameter forms (v2.0.0 — dte became a string parameter) ---------- + + @Test + void chainDteSingleValueMovesExpirationFurtherOutAsValueGrows() { + // dte's wire type changed from integer to string; the single-value form must still pick the + // expiration closest to the given value. Compared against another value of the same + // parameter (not the unfiltered default) so an ignored filter would fail this assertion. + int nearDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).dte()); + int farDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).dte()); + + assertThat(farDte) + .as("dte(180) must resolve to a later expiration than dte(7)") + .isGreaterThan(nearDte); + } + + @Test + void chainDteListReturnsClosestExpirationPerValueDeduplicated() { + LocalDate nearExpiration = + java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).expiration()) + .toLocalDate(); + LocalDate farExpiration = + java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).expiration()) + .toLocalDate(); + + List combined = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(7, 180)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + java.util.Set distinctExpirations = + combined.stream() + .map(q -> java.util.Objects.requireNonNull(q.expiration()).toLocalDate()) + .collect(java.util.stream.Collectors.toSet()); + + assertThat(distinctExpirations) + .as("dte(7, 180) must select exactly the two closest expirations, one per value") + .containsExactlyInAnyOrder(nearExpiration, farExpiration); + } + + @Test + void chainDteRangeDegenerateToSingleDayIncludesBothInclusiveBounds() { + // The anchor comes from a dte(...) call itself — not the unfiltered default — and is seeded + // far enough out (180 days) that an answer which merely echoed the unfiltered front-month + // default would fail the dte-equality assertion below, catching an ignored parameter. + int anchorDte = anchorDte(180); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(anchorDte, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as("a min==max range must still include the expiration at exactly that dte") + .isNotEmpty(); + assertThat(rows).allSatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte)); + } + + @Test + void chainDteRangeSpansMultipleExpirationsUnlikeASingleValue() { + List ranged = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(0, 400)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + long distinctExpirations = ranged.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a closed range must span every expiration within bounds, not just the closest one") + .isGreaterThan(1); + assertThat(ranged) + .as("every row's dte must fall within the inclusive [0, 400] bound") + .allSatisfy(q -> assertThat(q.dte()).isBetween(0, 400)); + } + + @Test + void chainDteComparisonGteIncludesTheBoundaryValue() { + // Seeded far out (180 days), like the range test above, so an ignored filter (which would + // echo the near-term unfiltered default) fails both assertions below. + int anchorDte = anchorDte(180); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(">=N must keep the expiration at exactly N") + .anySatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte)); + assertThat(rows) + .as("every row returned by >=N must satisfy dte >= N") + .allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(anchorDte)); + long distinctExpirations = rows.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as(">=N must span every expiration at or beyond N, not just the closest one") + .isGreaterThan(1); + } + + @Test + void chainDteComparisonGteExcludesExpirationsBelowTheBoundary() { + int anchorDte = anchorDte(7); + int threshold = anchorDte + 60; + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, threshold)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(">=N must exclude an expiration below N — proves the filter is not ignored") + .allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(threshold)); + } + + @Test + void chainDteComparisonLtExcludesTheBoundaryValue() { + int anchorDte = anchorDte(7); + + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchorDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(rows) + .as(" assertThat(q.dte()).isLessThan(anchorDte)); + } + + /** The nearest-the-money call under {@code filter}; asserts the chain isn't empty. */ + private OptionQuote chainRowForDte(ExpirationFilter filter) { + List rows = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(filter) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + assertThat(rows).isNotEmpty(); + return rows.get(0); + } + + /** + * The live dte of the expiration closest to {@code seedDays} — built from a {@code dte(...)} call + * itself (the parameter under test), never from the unfiltered default, per the live-test rule + * that a baseline must exercise the parameter it's anchoring. + */ + private int anchorDte(int seedDays) { + return java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(seedDays)).dte()); + } + @Test void quoteCountbackBoundsHistoricalSeries() { // countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..587aa21 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", v.days().stream().map(String::valueOf).collect(Collectors.joining(","))); + } 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())); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..0bec8d7 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.Arrays; +import java.util.List; import java.util.Objects; /** @@ -14,10 +16,18 @@ *

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 forms (the API's {@code dte} parameter is string-typed as of + * v2.0.0): a single value ({@link #dte(int)}), a comma-separated list ({@link #dte(int...)}), a + * closed range ({@link #dteRange}), and a comparison operator ({@link #dteComparison}) — the last + * two share their operator/range syntax with {@link StrikeFilter}. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +57,33 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. Selects the expiration + * closest to each value, deduplicated server-side. {@code days} must be non-empty; every value + * must be in {@code [0, 36500]}. + */ + static DteList dte(int... days) { + return new DteList(Arrays.stream(days).boxed().toList()); + } + + /** + * Days-to-expiration closed range filter — wire form {@code ?dte=min-max}. Matches every + * expiration whose dte falls within {@code [min, max]}, inclusive. {@code min} must not exceed + * {@code max}; both bounds must be in {@code [0, 36500]}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form {@code ?dte=} (e.g. {@code + * ?dte=>=30}). Matches every expiration satisfying {@code operator days}. {@code days} must be in + * {@code [0, 36500]}. Syntax mirrors {@link StrikeFilter#comparison}. + */ + 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}. @@ -79,6 +116,55 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** + * Validated in the compact constructor (not just {@link #dte(int...)}) so the guarantee holds on + * every public construction path, including a direct {@code new DteList(...)} call. + */ + 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 d : days) { + if (d == null) { + throw new IllegalArgumentException("days must not contain null elements"); + } + requireDteInRange(d); + } + days = List.copyOf(days); + } + } + + /** Validated in the compact constructor — see {@link DteList}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + requireDteInRange(min); + requireDteInRange(max); + } + } + + /** Validated in the compact constructor — see {@link DteList}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + requireDteInRange(days); + } + } + + /** Shared {@code [0, 36500]} bound — the API's documented limit on every {@code dte} value. */ + private static void requireDteInRange(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"); + } + } + 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..ac2c171 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,108 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + // dte's wire type changed from integer to string in v2.0.0 — it now also accepts a + // comma-separated list, a closed range, and a comparison operator (syntax shared with + // StrikeFilter). dte(int) above is unchanged and keeps its own test as the regression case. + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(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 chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — mirrors StrikeFilter's comparison encoding. + 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(StrikeFilter.Operator.LT, 45)) + .build()) + .join(); + + // "%3C" is the URL-encoded "<". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, 30)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(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); @@ -1203,6 +1305,118 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(ExpirationFilter::dte) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dte(15, -1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dte(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 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 expirationFilterDteRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeDays() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsDaysAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + // ---------- new dte variants: validation holds on the direct record constructor too ---------- + + @Test + void dteListRecordConstructorRejectsNullList() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteListRecordConstructorRejectsEmptyList() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteListRecordConstructorRejectsNullElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(java.util.Arrays.asList(15, null, 45))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not contain null elements"); + } + + @Test + void dteListRecordConstructorRejectsOutOfRangeElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of(15, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteRangeRecordConstructorRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteRangeRecordConstructorRejectsOutOfRangeBound() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteComparisonRecordConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteComparisonRecordConstructorRejectsOutOfRangeDays() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(