diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..0502412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `DteFilter` — sealed interface for extended days-to-expiration filtering in + `GET /v1/options/chain/` (API release 2.0.0). Three static factories: + `values(int...)` for comma-separated lists, `range(int, int)` for closed + ranges, `comparison(StrikeFilter.Operator, int)` for numeric comparisons, + alongside the existing `ExpirationFilter.dte(int)` single-value form (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..908228f 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,11 +20,17 @@ 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.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) + // Days-to-expiration, beyond a single value (sealed DteFilter — also an ExpirationFilter, + // so it plugs into the same expirationFilter(...) setter above): + .expirationFilter(DteFilter.values(int... days)) // comma-separated list + .expirationFilter(DteFilter.range(int min, int max)) // closed range, inclusive + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator op, int days)) + // Strike selection (sealed StrikeFilter — pick one): .strikeFilter(StrikeFilter.exact(double price)) .strikeFilter(StrikeFilter.range(double min, double max)) @@ -66,6 +72,7 @@ public record OptionQuote( ```java import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -95,6 +102,27 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // DteFilter: the closest expiration to each of 30/60/90 days, deduplicated. + var byDteList = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.values(30, 60, 90)) + .side(OptionSide.CALL) + .build()); + + // DteFilter: every expiration within a closed day range, inclusive. + var byDteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.range(0, 45)) + .side(OptionSide.CALL) + .build()); + + // DteFilter: every expiration at least 30 days out — same operator syntax as StrikeFilter. + var byDteComparison = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.comparison(StrikeFilter.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..1adbaea 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -2,6 +2,7 @@ import com.marketdata.sdk.MarketDataClient; import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -51,6 +52,37 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // DteFilter: dte's forms beyond a single value — a comma-separated list (closest expiration + // per value, deduplicated), a closed range (inclusive), and a comparison (same operator + // syntax as StrikeFilter). + long dteValuesSpans = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.values(30, 60, 90)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("DteFilter.values(30, 60, 90) spans " + dteValuesSpans + " expirations"); + + List dteRangeChain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.range(0, 45)) + .strikeFilter(StrikeFilter.exact(150)) + .side(OptionSide.CALL) + .build()) + .values(); + System.out.println("DteFilter.range(0, 45): " + dteRangeChain.size() + " contracts"); + + List dteComparisonChain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30)) + .strikeFilter(StrikeFilter.exact(150)) + .side(OptionSide.CALL) + .build()) + .values(); + System.out.println( + "DteFilter.comparison(GTE, 30): " + dteComparisonChain.size() + " contracts"); + 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..203742a 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -10,6 +11,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; @@ -133,6 +135,163 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainExpirationDteSingleValueSelectsClosestExpiration() { + // A single dte value narrows to the one expiration closest to it, same as before the API's + // dte type change — but 365 is far enough from AAPL's front-month expiration (always well + // under 180 days for a name this liquid) that an unfiltered call could never satisfy the + // assertions below. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(365)) + .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 selects exactly one expiration") + .isEqualTo(1); + Integer dte = chain.get(0).dte(); + assertThat(dte).isNotNull(); + assertThat(dte) + .as( + "the expiration closest to the requested 365 dte is far from the front-month" + + " expiration an unfiltered call would return") + .isGreaterThan(180); + } + + @Test + void chainExpirationDteValuesSelectsClosestPerValueDeduplicated() { + // Three widely-spaced dte values each resolve to their own closest expiration and dedupe — + // unlike a single dte value (or no filter at all), which always resolves to one expiration. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.values(30, 180, 365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as( + "three widely-spaced dte values resolve to multiple distinct expirations, unlike the" + + " single front-month expiration an unfiltered call returns") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteRangeReturnsAllExpirationsWithinBounds() { + // A wide closed range spans multiple expirations, unlike no filter (front-month only); every + // returned contract's dte must also fall within the bounds, inclusive. + int max = 180; + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.range(0, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as( + "a wide closed range spans multiple expirations, unlike the single front-month" + + " expiration an unfiltered call returns") + .isGreaterThan(1); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()) + .as("every contract's dte falls within the closed range") + .isBetween(0, max); + } + } + + @Test + void chainExpirationDteComparisonIsInclusiveAtLowerBound() { + // Anchor N on an actual expiration's dte, discovered via the range form (not the unfiltered + // call) — then probe the comparison form's boundary against it: >=N must keep the expiration + // whose dte is exactly N, withinRange = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.range(0, 365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(withinRange).isNotEmpty(); + int n = + withinRange.stream() + .map(OptionQuote::dte) + .filter(java.util.Objects::nonNull) + .max(Integer::compareTo) + .orElseThrow(); + + List atLeastN = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(atLeastN) + .as(">=N keeps the expiration whose dte is exactly N") + .extracting(OptionQuote::dte) + .contains(n); + for (OptionQuote q : atLeastN) { + assertThat(q.dte()).isGreaterThanOrEqualTo(n); + } + + List lessThanN = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.LT, n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(lessThanN) + .as(" days) { + return days.stream().map(String::valueOf).collect(Collectors.joining(",")); + } + private static String formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java new file mode 100644 index 0000000..a283499 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,92 @@ +package com.marketdata.sdk.options; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Extended days-to-expiration filter for {@code /v1/options/chain/} — the forms of {@code ?dte=} + * beyond a single value ({@link ExpirationFilter#dte(int)}): a comma-separated list of values, a + * closed range, and a comparison. The range and comparison syntax match {@link StrikeFilter}'s + * ({@code 0-45}, {@code >=30}) — {@link #comparison} reuses {@link StrikeFilter.Operator}. + * + *

A permitted subtype of {@link ExpirationFilter}, not a replacement for {@link + * ExpirationFilter#dte(int)}: the chain endpoint's expiration-selection axis stays a single pick, + * enforced by the one {@code expirationFilter(...)} setter on the request builder. + */ +public sealed interface DteFilter extends ExpirationFilter + permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** + * A comma-separated list of days-to-expiration values — wire form {@code ?dte=15,30,45}. Each + * value selects its own closest expiration; the server deduplicates the result. Each value must + * be non-negative and at most 36500. + */ + static Values values(int... days) { + Objects.requireNonNull(days, "days"); + List boxed = new ArrayList<>(days.length); + for (int day : days) { + boxed.add(day); + } + return new Values(boxed); + } + + /** + * Closed days-to-expiration range, inclusive on both ends — wire form {@code ?dte=min-max}. + * {@code min} must not exceed {@code max}, and both must be non-negative and at most 36500. + */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** + * Days-to-expiration comparison — wire form e.g. {@code ?dte=>=30}. {@code days} must be + * non-negative and at most 36500. + */ + static Comparison comparison(StrikeFilter.Operator operator, int days) { + return new Comparison(operator, days); + } + + /** Days to expiry are whole numbers, non-negative, and at most 36500. */ + private static void validateDays(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 Values(List days) implements DteFilter { + public Values { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer day : days) { + if (day == null) { + throw new IllegalArgumentException("days must not contain null elements"); + } + validateDays(day); + } + days = List.copyOf(days); + } + } + + record Range(int min, int max) implements DteFilter { + public Range { + validateDays(min); + validateDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record Comparison(StrikeFilter.Operator operator, int days) implements DteFilter { + public Comparison { + Objects.requireNonNull(operator, "operator"); + validateDays(days); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..5bb271a 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -14,13 +14,19 @@ *

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. + * + *

{@link DteFilter} is a further permitted subtype: the {@code dte} parameter accepts a single + * value ({@link #dte(int)}, below) or, for the comma-separated-list/range/comparison forms, a + * {@code DteFilter}. Both still plug into the one {@code expirationFilter(...)} setter, so {@code + * dte} stays mutually exclusive with the other variants here. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, ExpirationFilter.Between, ExpirationFilter.MonthYear, - ExpirationFilter.All { + ExpirationFilter.All, + DteFilter { /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { @@ -39,7 +45,10 @@ 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}. For a comma-separated list, + * a closed range, or a comparison, use {@link DteFilter} instead. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..464eee1 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; @@ -27,6 +28,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -1025,6 +1027,60 @@ void chainExpirationFilterMonthYearTranslatesToMonthYear() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); } + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedList() { + // dte's API-level type change (integer -> string) adds a comma-separated-list form: each + // value selects its own closest expiration, deduplicated server-side. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.values(15, 30, 45)) + .build()) + .join(); + + // The comma is URL-encoded, so decode before asserting (same convention as columns=). + 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 chainExpirationFilterDteRangeTranslatesToDashedRange() { + // dte's closed-range form, e.g. "0-45" — same dashed syntax as StrikeFilter.Range. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(DteFilter.range(0, 45)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + // dte's comparison-operator form reuses StrikeFilter.Operator — same wire syntax as strike. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through. + assertThat(url).contains("dte=%3E%3D30"); + } + @Test void chainStrikeFilterExactRendersAsBareNumber() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1220,6 +1276,92 @@ void expirationFilterMonthYearRejectsInvalidMonth() { .hasMessageContaining("month must be in 1..12"); } + @Test + void dteFilterValuesRejectsEmptyArray() { + assertThatThrownBy(() -> DteFilter.values()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteFilterValuesRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.values(15, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterValuesRejectsDayAboveMaximum() { + assertThatThrownBy(() -> DteFilter.values(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeMin() { + assertThatThrownBy(() -> DteFilter.range(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsMaxAboveMaximum() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsDayAboveMaximum() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.GTE, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNullOperatorThroughDirectConstruction() { + // The static factory's parameter is typed, so only a direct call to the record's public + // canonical constructor can pass null — validation has to hold there too. + assertThatThrownBy(() -> new DteFilter.Comparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullArrayThroughVarargsFactory() { + // A null array reaches the varargs parameter directly (no reflection trick needed) if a + // caller passes one explicitly instead of omitting the argument. + assertThatThrownBy(() -> DteFilter.values((int[]) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullListThroughDirectConstruction() { + assertThatThrownBy(() -> new DteFilter.Values(null)).isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullElementThroughDirectConstruction() { + // The varargs factory can't carry a null int, so only a direct call to the record's public + // canonical constructor with a boxed list can — validation has to hold there too. + assertThatThrownBy(() -> new DteFilter.Values(Arrays.asList(15, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not contain null elements"); + } + @Test void strikeFilterRangeRejectsMinGreaterThanMax() { assertThatThrownBy(() -> StrikeFilter.range(160, 140))