diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..2dae6fe 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.dte(DteFilter.values(int first, int... rest))) // comma-separated list + .expirationFilter(ExpirationFilter.dte(DteFilter.range(int min, int max))) // closed range, inclusive + .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.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) @@ -66,6 +69,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 +99,13 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // DteFilter: every expiration between 30 and 45 days out. + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 45))) + .side(OptionSide.CALL) + .build()); } ``` diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..eb001ae 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; @@ -133,6 +134,67 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteValuesReturnsClosestExpirationPerValueDeduplicated() { + // Each requested value maps to the closest available expiration, deduplicated — so the + // distinct expiration count is at most the number of requested values. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.values(7, 14, 30))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("closest expiration per requested value, deduplicated") + .isBetween(1L, 3L); + } + + @Test + void chainDteRangeReturnsExpirationsWithinBounds() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull().isBetween(0, 45); + } + } + + @Test + void chainDteComparisonReturnsExpirationsSatisfyingOperator() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull().isGreaterThanOrEqualTo(30); + } + } + @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..ebdfa62 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionsChain; @@ -28,6 +29,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; /** @@ -320,6 +322,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("year", v.year()); } else if (f instanceof ExpirationFilter.All) { b.query("expiration", "all"); + } else if (f instanceof DteFilter.Values v) { + b.query("dte", v.days().stream().map(String::valueOf).collect(Collectors.joining(","))); + } else if (f instanceof DteFilter.Range v) { + b.query("dte", v.min() + "-" + v.max()); + } else if (f instanceof DteFilter.Comparison v) { + b.query("dte", v.operator().wireValue() + v.days()); } // ExpirationFilter is sealed and every variant is handled above; Java 17 can't prove that in // an if-chain, but there is no reachable else, so no defensive throw is needed. 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..63f8647 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,87 @@ +package com.marketdata.sdk.options; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Richer forms of the chain endpoint's {@code ?dte=} parameter, beyond the single value already + * covered by {@link ExpirationFilter#dte(int)}: a comma-separated list, a closed range, and a + * comparison operator — the same three syntactic shapes {@link StrikeFilter} models for {@code + * ?strike=}. {@code DteFilter} extends {@link ExpirationFilter} directly (rather than wrapping it) + * so every variant can be passed straight to {@code expirationFilter(...)}, preserving the "pick + * one variant" compile-time exclusivity ADR-008 established. + * + *

{@link ExpirationFilter#dte(DteFilter)} is a pass-through overload kept for discoverability + * under the existing {@code dte(...)} name. + */ +public sealed interface DteFilter extends ExpirationFilter + permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** Upper bound the API documents for a single {@code dte} value, inclusive. */ + int MAX_DAYS = 36500; + + /** + * Comma-separated list — wire form {@code ?dte=15,30,45}. The API selects the closest expiration + * for each value, deduplicated. + */ + static Values values(int first, int... rest) { + List days = new ArrayList<>(); + days.add(first); + for (int day : rest) { + days.add(day); + } + for (int day : days) { + requireValidDays(day); + } + return new Values(List.copyOf(days)); + } + + /** Closed range — wire form {@code ?dte=min-max}, inclusive of both bounds. */ + static Range range(int min, int max) { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + requireValidDays(min); + requireValidDays(max); + return new Range(min, max); + } + + /** Comparison operator — wire form e.g. {@code ?dte=>=30}. */ + static Comparison comparison(Operator operator, int days) { + Objects.requireNonNull(operator, "operator"); + requireValidDays(days); + return new Comparison(operator, days); + } + + private static void requireValidDays(int days) { + if (days < 0 || days > MAX_DAYS) { + throw new IllegalArgumentException("dte must be in 0.." + MAX_DAYS); + } + } + + /** Comparison operators accepted by the API — same four as {@link StrikeFilter.Operator}. */ + 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; + } + } + + record Values(List days) implements DteFilter {} + + record Range(int min, int max) implements DteFilter {} + + record Comparison(Operator operator, int days) implements DteFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..48af057 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -20,7 +20,8 @@ public sealed interface ExpirationFilter 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) { @@ -47,6 +48,15 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration filter, comma-list / range / comparison forms — wire forms {@code + * ?dte=15,30,45}, {@code ?dte=0-45}, {@code ?dte=>=30}. Pass-through kept under the {@code + * dte(...)} name for discoverability; see {@link DteFilter} for the factories. + */ + static DteFilter dte(DteFilter filter) { + return Objects.requireNonNull(filter, "filter"); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..11e81fb 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; @@ -975,6 +976,73 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45))) + .build()) + .join(); + + // The comma is URL-encoded on the wire, so decode before asserting (same convention as the + // columns param). + 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.dte(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 chainExpirationFilterDteComparisonTranslatesToOperatorPrefixedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dte(DteFilter.comparison(DteFilter.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 chainExpirationFilterDteRangeSyncMatchesAsyncWire() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + + resourceWith(client) + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) + .build()); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=0-45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1271,34 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void dteFilterValuesRejectsOutOfRangeValue() { + assertThatThrownBy(() -> DteFilter.values(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in 0..36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsOutOfRangeBound() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in 0..36500"); + } + + @Test + void dteFilterComparisonRejectsOutOfRangeValue() { + assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in 0..36500"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(