diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..fd87798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(int...)`, for comma-separated days-to-expiration selection (API 2.0.0). +- `ExpirationFilter.dteRange(int, int)`, for closed-range days-to-expiration selection (API 2.0.0). +- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)`, for comparison-operator days-to-expiration selection, reusing `StrikeFilter.Operator` (API 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..fd6711c 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)) // comma-separated list, e.g. 15,30,45 + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. 0-45 + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator operator, int days)) // e.g. >=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -95,6 +98,21 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's other forms: a comma-separated list, a closed range, and a comparison operator — the + // comparison operator reuses StrikeFilter.Operator, since the syntax matches strike. + 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..ba41d69 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,36 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // dte's other forms: a comma-separated list, a closed range, and a comparison operator — the + // comparison operator reuses StrikeFilter.Operator, since the syntax matches strike. + long nearTerm = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("ExpirationFilter.dteList(15, 30, 45) spans " + nearTerm + " expirations"); + + List withinRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println("ExpirationFilter.dteRange(0, 45): " + withinRange.size() + " row(s)"); + + List atLeast30Days = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + System.out.println( + "ExpirationFilter.dteComparison(GTE, 30): " + atLeast30Days.size() + " row(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..7146f3c 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,10 +10,12 @@ 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.Objects; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +135,220 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueStillSelectsClosestExpiration() { + // API 2.0.0 changed dte's query parameter from integer to string; this regression test proves + // the already-existing single-value form behaves the same against the live API. Picking the + // front-of-board value would pass even if the server ignored dte entirely — an unfiltered + // chain also narrows to the front month (docs/options/chain.md) — so pick a value away from + // the front instead. + List board = discoverDteBoard(); + assertThat(board).as("AAPL has at least 2 distinct expirations").hasSizeGreaterThanOrEqualTo(2); + int n = board.get(board.size() - 1); + + List single = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(single).isNotEmpty(); + assertThat(single.get(0).dte()).isEqualTo(n); + } + + @Test + void chainDteListReturnsClosestExpirationPerValue() { + // "A comma-separated list (15,30,45) selects the closest expiration for each value" — querying + // three real dte values (each an exact expiration on the live board) must return all three, + // since the closest expiration to an exact match is itself. + List board = discoverDteBoard(); + assertThat(board).as("AAPL has at least 3 distinct expirations").hasSizeGreaterThanOrEqualTo(3); + int a = board.get(0); + int b = board.get(board.size() / 2); + int c = board.get(board.size() - 1); + + List listResult = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(a, b, c)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + List returnedDtes = listResult.stream().map(OptionQuote::dte).toList(); + assertThat(returnedDtes).contains(a, b, c); + } + + @Test + void chainDteRangeIncludesInclusiveBoundaries() { + // "A closed range (0-45) ... returns every expiration whose dte falls within the bounds, + // inclusive." Use the live board's own min/max dte as the range bounds so both boundary values + // are guaranteed to exist as real expirations. + List board = discoverDteBoard(); + int min = board.get(0); + int max = board.get(board.size() - 1); + + List inRange = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + List returnedDtes = inRange.stream().map(OptionQuote::dte).toList(); + assertThat(returnedDtes).as("the lower bound is inclusive").contains(min); + assertThat(returnedDtes).as("the upper bound is inclusive").contains(max); + } + + @Test + void chainDteRangeExcludesDteBelowRaisedLowerBound() { + // Narrowing the lower bound past the board's minimum dte must drop the expiration that used to + // sit exactly on it — proving the bound is honored, not ignored. + List board = discoverDteBoard(); + int min = board.get(0); + int max = board.get(board.size() - 1); + + List narrowed = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min + 1, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + List returnedDtes = narrowed.stream().map(OptionQuote::dte).toList(); + assertThat(returnedDtes) + .as("raising the lower bound past the board minimum excludes that expiration") + .doesNotContain(min); + } + + @Test + void chainDteComparisonGteIncludesExactBoundaryDte() { + // The comparison form reuses StrikeFilter.Operator ("the syntax matches strike"): >= keeps the + // row whose dte equals the bound. Using the board's front-month dte as the bound would pass + // even if the server ignored dte — the unfiltered front-month response also carries exactly + // that dte — so use a bound above the front of the board, and require every returned row to + // respect it (a front-month-only answer would carry a dte below the bound and fail the second + // assertion). + List board = discoverDteBoard(); + assertThat(board).as("AAPL has at least 2 distinct expirations").hasSizeGreaterThanOrEqualTo(2); + int bound = board.get(board.size() / 2); + + List gte = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, bound)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + List dtes = gte.stream().map(OptionQuote::dte).toList(); + assertThat(dtes).as(">= must keep the row whose dte equals the bound").contains(bound); + assertThat(dtes).as("every returned dte must be >= the bound").allMatch(d -> d >= bound); + } + + @Test + void chainDteComparisonGtExcludesExactBoundaryDte() { + // > is strict, matching StrikeFilter's own ">" semantics ("the syntax matches strike"): it + // must drop the row whose dte exactly equals the bound, unlike >=. + int n = discoverDteBoard().get(0); + + List gt = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(gt.stream().map(OptionQuote::dte).toList()) + .as("> must drop the row whose dte equals the bound") + .doesNotContain(n); + } + + @Test + void chainDteComparisonLteIncludesExactBoundaryDte() { + List board = discoverDteBoard(); + int max = board.get(board.size() - 1); + + List lte = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LTE, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(lte.stream().map(OptionQuote::dte).toList()) + .as("<= must keep the row whose dte equals the bound") + .contains(max); + } + + @Test + void chainDteComparisonLtExcludesExactBoundaryDte() { + // < is strict, matching StrikeFilter's own "<" semantics ("the syntax matches strike"): it + // must drop the row whose dte exactly equals the bound, unlike <=. Asserting only + // doesNotContain(max) would pass even if the server ignored dte — an unfiltered, + // front-month-only response also excludes max (since max differs from the front) — so also + // require every returned dte to sit below the bound and more than one distinct expiration to + // come back, which a front-month-only answer could not satisfy. + List board = discoverDteBoard(); + assertThat(board).as("AAPL has at least 3 distinct expirations").hasSizeGreaterThanOrEqualTo(3); + int max = board.get(board.size() - 1); + + List lt = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + List dtes = lt.stream().map(OptionQuote::dte).toList(); + assertThat(dtes).as("< must drop the row whose dte equals the bound").doesNotContain(max); + assertThat(dtes).as("every returned dte must be < the bound").allMatch(d -> d < max); + assertThat(dtes.stream().distinct().count()) + .as("the filter must span more than the front month alone") + .isGreaterThan(1); + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes @@ -219,6 +435,30 @@ void quotesFansOutToMultipleContracts() { assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second); } + /** + * Fetch the live, sorted, distinct set of {@code dte} values across every AAPL call expiration — + * used by the {@code dte}-filter tests to derive real boundary values instead of guessing at + * today's market calendar. {@code expiration=all} spans the whole board; {@code strikeLimit(1)} + * keeps the payload to one row per expiration. + */ + private List discoverDteBoard() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + List dtes = + chain.stream().map(OptionQuote::dte).filter(Objects::nonNull).distinct().sorted().toList(); + assertThat(dtes).as("AAPL has at least one live option expiration").isNotEmpty(); + return dtes; + } + /** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */ private String sampleOptionSymbol() { List chain = 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..4181ee2 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,10 +16,20 @@ *

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 forms on the wire (API 2.0.0 widened it from a bare integer to a + * string: a single value, a comma-separated list, a closed range, or a comparison operator — "the + * syntax matches strike and delta" for the latter two). {@link #dte(int)} already covered the + * single-value form; {@link #dteList}, {@link #dteRange} and {@link #dteComparison} are additive + * variants covering the other three, reusing {@link StrikeFilter.Operator} for the comparison form + * since the operator syntax is shared with {@code strike}. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +51,7 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** Days-to-expiration filter, 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 +59,40 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration filter, comma-separated list — wire form {@code ?dte=15,30,45}. Selects the + * closest expiration for each value (the API deduplicates the result). {@code days} must not be + * empty, and every value must be in {@code [0, 36500]}. + */ + static DteList dteList(int... days) { + if (days.length == 0) { + throw new IllegalArgumentException("dteList must not be empty"); + } + List boxed = new ArrayList<>(days.length); + for (int day : days) { + boxed.add(day); + } + return new DteList(boxed); + } + + /** + * Days-to-expiration filter, closed range — wire form {@code ?dte=0-45}. Returns every expiration + * whose {@code 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 filter, comparison operator — wire form {@code ?dte=>=30} — "the syntax + * matches strike", so the operator set is {@link StrikeFilter.Operator}. {@code days} must be in + * {@code [0, 36500]}. + */ + 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,10 +125,61 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** Days-to-expiration, comma-separated list — see {@link #dteList(int...)}. */ + 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 must not contain null values"); + validateDte(day); + } + days = List.copyOf(days); + } + } + + /** Days-to-expiration, closed range — see {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDte(min); + validateDte(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** + * Days-to-expiration, comparison operator — see {@link #dteComparison(StrikeFilter.Operator, + * int)}. + */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + validateDte(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 {} + + /** + * Shared bound for every {@code dte} value across the new forms (API 2.0.0: "Values are whole + * numbers of days, at most 36500"). Not applied to {@link #dte(int)} — that form predates the + * bound and keeps the error it has always failed with. + */ + private static void validateDte(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"); + } + } } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..3d0c2e6 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,116 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + // API 2.0.0: dte's query parameter is now a string accepting (among other forms) a + // comma-separated list — "A comma-separated list (15,30,45) selects the closest expiration for + // each value, deduplicated." + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + 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() { + // "A closed range (0-45) ... returns every expiration whose dte falls within the bounds, + // inclusive; the syntax matches strike" — same dashed form as StrikeFilter.range. + 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() { + // "a comparison operator (>=30, <45) returns every expiration whose dte falls within the + // bounds ...; the syntax matches strike" — same operator-prefixed form as + // StrikeFilter.comparison. + 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()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=%3E30"); + } + + @Test + void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + 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 ">=". + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?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()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=%3C45"); + } + + @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()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=%3C%3D45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1314,85 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(ExpirationFilter::dteList) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dteList must not be empty"); + } + + @Test + void expirationFilterDteListRecordRejectsEmptyListDirectly() { + // Validation lives in DteList's compact constructor, not only in the dteList(int...) factory — + // this exercises the record's own public constructor, the other public path that builds one. + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dteList must not be empty"); + } + + @Test + void expirationFilterDteListRecordRejectsNullList() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void expirationFilterDteListRecordRejectsNullElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(Arrays.asList(15, null, 45))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days must not contain null values"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(15, -1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + // §: "Values are whole numbers of days, at most 36500." + assertThatThrownBy(() -> ExpirationFilter.dteList(15, 36501, 45)) + .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 expirationFilterDteRangeRejectsMaxAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(