diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..73b69bd 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` dte variants** — `dteList(int, int...)`, `dteRange(int, int)`, + and `dteComparison(StrikeFilter.Operator, int)` for comma-separated, closed-range, + and comparison forms of the `?dte` parameter on `GET /v1/options/chain/` (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..7ad2404 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,11 @@ 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 — API max 36500, not enforced locally + .expirationFilter(ExpirationFilter.dteList(int first, int... rest)) // 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 op, int days)) // e.g. >=30 + // all dte* forms above count days from .date(...) below when set (a historical chain), or from today .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -95,6 +99,23 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a comma-separated list, a closed range, or a comparison — same syntax as + // 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.LT, 45)) + .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..8e6f464 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,16 @@ 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, or a comparison — same syntax as + // strike (StrikeFilter.Operator is shared between the two). + long nearTerm = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45)) + .side(OptionSide.CALL) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("dte < 45 spans " + nearTerm + " distinct 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..3afed3d 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,10 +10,15 @@ 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.time.ZonedDateTime; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +138,147 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + /** + * A fixed historical trading day for every dte* test below. All {@code dte} forms count days from + * the chain request's {@code date} when one is set, so pinning it keeps the results stable + * regardless of when the test runs — unlike omitting {@code date}, which computes dte from + * "today" and can change at the Eastern-midnight rollover between two requests in the same test. + */ + private static final LocalDate FIXED_CHAIN_DATE = LocalDate.of(2025, 6, 2); + + @Test + void chainDteSingleValueNarrowsToOneExpiration() { + // A single dte value selects the one expiration closest to it. 365 days is far past the + // front-month expiration the unfiltered chain returns, so comparing against the unfiltered + // baseline settles whether the dte syntax actually reached the server: an ignored dte would + // fall back to that same front-month expiration instead of a distinct, far-dated one. + Set baseline = unfilteredChainExpirations(); + Set farDte = singleDteExpirations(365); + + assertThat(farDte) + .as("a single dte value narrows the chain to its one closest expiration") + .hasSize(1); + assertThat(farDte) + .as("dte(365) must resolve to an expiration the unfiltered (front-month) chain does not") + .isNotEqualTo(baseline); + } + + @Test + void chainDteCommaListMatchesUnionOfSingleDteRequests() { + // A comma-separated dte list selects the closest expiration for each value, deduplicated. + // Near/mid/far values that must resolve to more than one expiration close the loophole an + // ignored dte parameter would otherwise slip through: an ignored list falls back to the single + // front-month expiration, failing both the "> 1" and the union-equality assertions below. + Set union = new HashSet<>(); + union.addAll(singleDteExpirations(15)); + union.addAll(singleDteExpirations(180)); + union.addAll(singleDteExpirations(365)); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(15, 180, 365)) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + Set listExpirations = + chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); + assertThat(listExpirations) + .as("a list spanning near/mid/far dte values must resolve to more than one expiration") + .hasSizeGreaterThan(1); + assertThat(listExpirations) + .as("dte list expirations equal the union of the single-value requests for 15, 180, 365") + .isEqualTo(union); + } + + /** + * The distinct expirations a single {@code dte(days)} request resolves to, on {@link + * #FIXED_CHAIN_DATE}. + */ + private Set singleDteExpirations(int days) { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(days)) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); + } + + /** + * The distinct expirations the chain resolves to with no expiration filter at all, on {@link + * #FIXED_CHAIN_DATE} — the front-month default that an ignored dte parameter would fall back to. + */ + private Set unfilteredChainExpirations() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + assertThat(chain).isNotEmpty(); + return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet()); + } + + @Test + void chainDteRangeReturnsOnlyExpirationsWithinBounds() { + // The lower bound is set above the front-month window so an ignored dte would return rows + // outside [90, 365] and fail the per-row assertion below, rather than passing by coincidence. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(90, 365)) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(dte.intValue()).isBetween(90, 365); + } + } + + @Test + void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() { + // The API documents comparison bounds as inclusive. The threshold is set above the + // front-month window so an ignored dte would return rows that violate the >= 90 bound below, + // rather than passing by coincidence the way a loose "<= 45" would against the front month. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 90)) + .side(OptionSide.CALL) + .date(FIXED_CHAIN_DATE) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + Integer dte = q.dte(); + assertThat(dte).isNotNull(); + assertThat(dte.intValue()).isGreaterThanOrEqualTo(90); + } + } + @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..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..a0bfebc 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,17 @@ *

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

All {@code dte}/{@code dteList}/{@code dteRange}/{@code dteComparison} variants count days + * from the chain request's {@code date} parameter when one is set (a historical chain), or from + * today otherwise. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -39,7 +48,15 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration, single value — wire form {@code ?dte=N}. Limits the option chain to the + * expiration date closest to {@code days}. + * + *

The API's documented maximum is 36500. Unlike {@link #dteList(int, int...)}, {@link + * #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, this factory does + * not enforce that bound locally — a larger value is sent to the wire unchanged and left for the + * API to reject. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +64,37 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration, comma-separated list — wire form {@code ?dte=15,30,45}. Selects the closest + * expiration for each value, deduplicated server-side. At least one value is required; each value + * must be a non-negative whole number of days, at most 36500. + */ + static DteList dteList(int first, int... rest) { + List days = new ArrayList<>(rest.length + 1); + days.add(first); + for (int d : rest) { + days.add(d); + } + 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}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration, comparison — wire form {@code ?dte=>=30} (operator prefix + value). Returns + * every expiration whose dte satisfies {@code operator days}. The operator set is shared with + * {@link StrikeFilter.Operator} — the two parameters use the same comparison syntax. + */ + 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}. @@ -71,6 +119,17 @@ static MonthYear monthYear(int year, int month) { return new MonthYear(year, month); } + /** Validates a single dte value: a non-negative whole number of days, at most 36500. */ + private static int 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"); + } + return days; + } + record OnDate(LocalDate date) implements ExpirationFilter { public OnDate { Objects.requireNonNull(date, "date"); @@ -79,6 +138,44 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** + * Comma-separated dte list — see {@link #dteList(int, int...)}. Validated on every construction + * path, not only the static factory: {@code days} must be non-null, non-empty, and free of null + * elements, and every value must be a non-negative whole number of days, at most 36500. + */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("dte list must not be empty"); + } + for (Integer d : days) { + Objects.requireNonNull(d, "dte list must not contain null elements"); + validateDte(d); + } + days = List.copyOf(days); + } + } + + /** Closed dte 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"); + } + } + } + + /** Dte comparison — 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 {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..a352e44 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,123 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void expirationFilterDteSingleValueAboveNewUpperBoundStillReachesTheWire() { + // Regression: dte's wire type changed from integer to string, but the pre-existing + // single-value factory gained no new upper-bound check — a value that reached the API before + // (and whatever the API does with it) still reaches the API unchanged. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(99999)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=99999"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + // "," is not in URLEncoder's safe set, so the comma-separated list encodes each comma as + // %2C. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15%2C30%2C45"); + } + + @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 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 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()).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 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 +1320,79 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -5, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, 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 expirationFilterDteRangeRejectsMaxAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GT, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveUpperBound() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void expirationFilterDteListRecordRejectsEmptyListDirectly() { + // DteList's validation holds on every public construction path — not only the dteList(...) + // factory, which can't even express an empty list (its first parameter is mandatory). + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte list must not be empty"); + } + + @Test + void expirationFilterDteListRecordRejectsNullElementDirectly() { + List daysWithNull = new ArrayList<>(); + daysWithNull.add(30); + daysWithNull.add(null); + assertThatThrownBy(() -> new ExpirationFilter.DteList(daysWithNull)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void expirationFilterDteListRecordRejectsNullListDirectly() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(null)) + .isInstanceOf(NullPointerException.class); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(