diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..ac69315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Options chain days-to-expiration filters** (API 2.0.0 compatibility) — three new + `ExpirationFilter` variants expand the `dte` parameter beyond single values: + `dteList(List)` selects the closest expiration for each value in a + comma-separated list; `dteRange(int, int)` matches every expiration within an + inclusive range (e.g., 0–45 days); `dteComparison(Operator, int)` matches + expirations satisfying a comparison (e.g., `>= 30` days). The new `Operator` + enum provides `GT`, `GTE`, `LT`, `LTE` with a `wireValue()` accessor. + ## [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..500eec6 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, closest match + .expirationFilter(ExpirationFilter.dteList(List days)) // closest match per value + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // inclusive dte range + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -95,6 +98,14 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a comma-separated list, an inclusive range, or a comparison. Every dte + // value must be between 0 and 36500 inclusive; dte and from/to are mutually exclusive (the + // sealed ExpirationFilter has no variant carrying both, so the compiler enforces it). + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); } ``` 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..b75c71a 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; /** @@ -18,6 +19,9 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +51,64 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list — wire form {@code ?dte=15,30,45}. Selects the closest expiration for + * each value (deduplicated server-side). Each value must be between 0 and 36500 inclusive; dte + * and {@code from}/{@code to} are mutually exclusive — this sealed type has no variant that + * carries both. + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Days-to-expiration range — wire form {@code ?dte=0-45}. Matches every expiration whose dte + * falls within the bounds, inclusive. {@code min} must not exceed {@code max}, and both bounds + * must be between 0 and 36500 inclusive; dte and {@code from}/{@code to} are mutually exclusive — + * this sealed type has no variant that carries both. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=>=30}. Matches every expiration whose dte + * satisfies {@code operator days}. {@code days} must be between 0 and 36500 inclusive; dte and + * {@code from}/{@code to} are mutually exclusive — this sealed type has no variant that carries + * both. + */ + static DteComparison dteComparison(Operator operator, int days) { + return new DteComparison(operator, days); + } + + 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 not exceed 36500"); + } + } + + /** Comparison operators accepted by the {@code dte} filter. */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + /** * 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 +141,38 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + days = List.copyOf(days); + for (int d : days) { + validateDte(d); + } + } + } + + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDte(min); + validateDte(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record DteComparison(Operator operator, int days) implements ExpirationFilter { + public DteComparison { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + 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..f0ae921 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; import com.marketdata.sdk.exception.ParseError; import com.marketdata.sdk.options.ExpirationFilter; @@ -30,7 +31,11 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class OptionsResourceTest { @@ -1025,6 +1030,96 @@ void chainExpirationFilterMonthYearTranslatesToMonthYear() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparated() { + 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(); + + // Comma is URL-encoded, so decode before asserting. + 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() { + 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"); + } + + @ParameterizedTest + @MethodSource("dteComparisonOperators") + void chainExpirationFilterDteComparisonTranslatesToOperatorPrefix( + ExpirationFilter.Operator operator, String expectedEncodedPrefix) { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(operator, 30)) + .build()) + .join(); + + // The operator prefix is URL-encoded — e.g. ">=" becomes "%3E%3D". + assertThat(client.captured.get(0).uri().toString()) + .contains("dte=" + expectedEncodedPrefix + "30"); + } + + static Stream dteComparisonOperators() { + return Stream.of( + arguments(ExpirationFilter.Operator.GT, "%3E"), + arguments(ExpirationFilter.Operator.GTE, "%3E%3D"), + arguments(ExpirationFilter.Operator.LT, "%3C"), + arguments(ExpirationFilter.Operator.LTE, "%3C%3D")); + } + + @Test + void chainExpirationFilterDteSingleValueStillWorksAfterRangeSupport() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); + } + + @Test + void chainSyncHonorsDteRangeFilter() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + @Test void chainStrikeFilterExactRendersAsBareNumber() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1298,69 @@ 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(15, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMaximum() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, 36501))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must not exceed 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 expirationFilterDteRangeRejectsMaxAboveMaximum() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must not exceed 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveMaximum() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must not exceed 36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNullOperator() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy( diff --git a/src/test/java/com/marketdata/sdk/options/ExpirationFilterTest.java b/src/test/java/com/marketdata/sdk/options/ExpirationFilterTest.java new file mode 100644 index 0000000..5e297ad --- /dev/null +++ b/src/test/java/com/marketdata/sdk/options/ExpirationFilterTest.java @@ -0,0 +1,57 @@ +package com.marketdata.sdk.options; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Focused tests for the {@link ExpirationFilter} records' canonical constructors, exercised + * directly (bypassing the {@code dteList}/{@code dteRange}/{@code dteComparison} factories) to + * confirm the invariants hold for any caller, not just the factory entry points. The wire-level + * path is exercised end-to-end in {@code OptionsResourceTest}. + */ +class ExpirationFilterTest { + + @Test + void dteListCanonicalConstructorRejectsReversedInvariantsLikeFactory() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of(15, -1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteListCanonicalConstructorDefensivelyCopiesInputList() { + List mutable = new ArrayList<>(); + mutable.add(15); + mutable.add(30); + ExpirationFilter.DteList filter = new ExpirationFilter.DteList(mutable); + + mutable.add(999); + + assertThat(filter.days()).containsExactly(15, 30); + } + + @Test + void dteListCanonicalConstructorReturnsUnmodifiableList() { + ExpirationFilter.DteList filter = new ExpirationFilter.DteList(List.of(15, 30)); + + assertThat(filter.days()).isUnmodifiable(); + } + + @Test + void dteRangeCanonicalConstructorRejectsReversedRange() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteComparisonCanonicalConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("operator must not be null"); + } +}