diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..6924684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(List)` — comma-separated days-to-expiration + list; selects the expiration nearest each value (API 2.0.0). `ExpirationFilter.dte(int)` + continues to work as before. +- `ExpirationFilter.dteRange(int, int)` — closed-range days-to-expiration filter + (API 2.0.0). Returns every expiration whose DTE falls within `[min, max]` + inclusive. +- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` — + comparison-operator days-to-expiration filter (API 2.0.0). Returns every + expiration satisfying the condition (e.g., `>= 30`). + ## [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..63b7708 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)) // a single dte value + .expirationFilter(ExpirationFilter.dteList(List days)) // comma-separated dte values + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed dte range, inclusive + .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,21 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` itself accepts four forms — a single value, a comma-separated list, a closed range, or a +comparison — the same range/comparison syntax `StrikeFilter` uses for `strike`: + +```java +ExpirationFilter.dte(30) // ?dte=30 +ExpirationFilter.dteList(List.of(15, 30, 45)) // ?dte=15,30,45 +ExpirationFilter.dteRange(0, 45) // ?dte=0-45 +ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30) // ?dte=>=30 +``` + +Every `dte` value accepted by the API is a non-negative whole number of days, at most 36500. The +SDK enforces that upper bound client-side on the three new forms (`dteList`/`dteRange`/ +`dteComparison`); the original single-value `dte(int)` predates the bound and still lets an +out-of-range value reach the API rather than rejecting it locally. + #### Returns @@ -95,6 +113,20 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's own sub-syntax: list, closed range, and comparison, matching StrikeFilter's syntax. + client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(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..cb31695 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,24 @@ public static void main(String[] args) { .values(); System.out.println("Filtered chain: " + chain.size() + " contracts"); + // dte's own sub-syntax: a comma-separated list, a closed range, or a comparison — the same + // range/comparison syntax StrikeFilter uses for strike. + long dteFormVariants = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(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 " + dteFormVariants + + " distinct expirations"); + 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()); + // 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..c87bfcb 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,151 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueSelectsExactExpirationWhenAvailable() { + // Pick the furthest real expiration (far from the front-month the unfiltered call would + // return) and request it by its own dte: the "closest expiration" to a value that is itself + // a real expiration is that expiration exactly, with zero distance. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least two live expirations") + .hasSizeGreaterThanOrEqualTo(2); + int farthest = liveDtes.get(liveDtes.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(farthest)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain.stream().map(OptionQuote::dte).distinct().toList()) + .as( + "dte=%d selects exactly the expiration %d days out, not the front month", + farthest, farthest) + .containsExactly(farthest); + } + + @Test + void chainDteListSelectsClosestExpirationForEachValue() { + // Three real, widely-spaced expirations: requesting them by dte should return all three + // distinct expirations, which the unfiltered (front-month-only) answer never does. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int low = liveDtes.get(0); + int mid = liveDtes.get(liveDtes.size() / 2); + int high = liveDtes.get(liveDtes.size() - 1); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(low, mid, high))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain.stream().map(OptionQuote::dte).distinct().toList()) + .as( + "dte=%d,%d,%d selects all three expirations, unlike the single front-month default", + low, mid, high) + .containsExactlyInAnyOrder(low, mid, high); + } + + @Test + void chainDteRangeIncludesEveryExpirationWithinBoundsInclusive() { + // A closed range starting after the front month: every expiration from the second-smallest + // live dte through the largest must come back, and the front month (the unfiltered answer) + // must be excluded — proving both the inclusive bounds and that the filter actually narrowed + // the result away from the default. + List liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int frontMonth = liveDtes.get(0); + int low = liveDtes.get(1); + int high = liveDtes.get(liveDtes.size() - 1); + List expectedInRange = liveDtes.stream().filter(d -> d >= low && d <= high).toList(); + + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(low, high)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + List distinctDtes = chain.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(distinctDtes) + .as( + "dte=%d-%d keeps every expiration in [%d, %d] inclusive, including both bounds", + low, high, low, high) + .containsExactlyInAnyOrderElementsOf(expectedInRange) + .contains(low, high); + assertThat(distinctDtes) + .as("the range excludes the front month %d", frontMonth) + .doesNotContain(frontMonth); + } + + @Test + void chainDteComparisonGteIncludesBoundaryLtExcludesIt() { + // Anchor on the furthest real expiration M: ">=M" must keep exactly M (nothing exceeds the + // maximum, so the bound is kept), while " liveDtes = liveDteValues(); + assertThat(liveDtes) + .as("AAPL has at least three live expirations") + .hasSizeGreaterThanOrEqualTo(3); + int anchor = liveDtes.get(liveDtes.size() - 1); + + List gteChain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchor)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(gteChain.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte>=%d keeps exactly the boundary expiration %d", anchor, anchor) + .containsExactly(anchor); + + List ltChain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchor)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List ltDtes = ltChain.stream().map(OptionQuote::dte).distinct().toList(); + assertThat(ltDtes) + .as( + "dte<%d excludes the boundary expiration %d and returns more than the front month", + anchor, anchor) + .doesNotContain(anchor) + .hasSizeGreaterThan(1); + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes @@ -219,6 +366,30 @@ void quotesFansOutToMultipleContracts() { assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second); } + /** + * Every distinct dte (days-to-expiration) across AAPL's full expiration calendar today, sorted + * ascending — used as real anchors for the dte filter tests above instead of values guessed from + * today's date, which expirations drift around daily. + */ + private List liveDteValues() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + return chain.stream() + .map(OptionQuote::dte) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + } + /** 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..65a4c78 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -14,10 +15,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 carries its own sub-syntax ({@link #dte}, {@link #dteList}, {@link + * #dteRange}, {@link #dteComparison}) — a single value, a comma-separated list, a closed range, or + * a comparison, matching the syntax {@link StrikeFilter} uses for {@code strike}. These are + * separate permitted variants rather than a nested sealed type because they all serialize to the + * same {@code dte} query parameter and already live under this sealed hierarchy's "pick one + * variant" umbrella. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +58,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 in {@code days}; the API deduplicates server-side when two values resolve + * to the same expiration. Each value is bounded like {@link #dte} ({@code [0, 36500]}). + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Days-to-expiration closed-range filter — wire form {@code ?dte=min-max}. Returns every + * expiration whose dte falls within {@code [min, max]} inclusive; the syntax matches {@link + * StrikeFilter#range}. {@code min} must not exceed {@code max}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison filter — wire form e.g. {@code ?dte=>=30}. Returns every + * expiration satisfying {@code operator days}; the syntax matches {@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 +117,49 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** The comma-separated-list form of {@code dte} — see {@link #dteList}. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + days = List.copyOf(days); // defensive copy; rejects a null element with NullPointerException + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (int d : days) { + checkDteBounds(d); + } + } + } + + /** The closed-range form of {@code dte} — see {@link #dteRange}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + checkDteBounds(min); + checkDteBounds(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** The comparison-operator form of {@code dte} — see {@link #dteComparison}. */ + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + checkDteBounds(days); + } + } + + /** Shared {@code dte} bound — whole numbers of days, at most 36500, per the API's description. */ + private static void checkDteBounds(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..124b5bd 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,94 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteStillAcceptsValuesAboveNewMaximum() { + // The pre-existing single-value dte(int) factory predates the API's documented 36500-day cap + // and keeps the error contract it had before this change: the SDK still lets the value reach + // the API rather than rejecting it client-side. The new cap is enforced only on the new + // dteList/dteRange/dteComparison factories below. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(50000)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=50000"); + } + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()) + .join(); + + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void 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(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — mirrors the strike comparison encoding above. + assertThat(url).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(); + + String url = client.captured.get(0).uri().toString(); + // "%3C" is the URL-encoded "<". + assertThat(url).contains("dte=%3C45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1291,62 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(10, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(10, 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"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(