chain =
+ client
+ .options()
+ .chain(
+ OptionsChainRequest.builder(UNDERLYING)
+ .expirationFilter(
+ ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30)))
+ .side(OptionSide.CALL)
+ .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..badc9d0 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;
/**
@@ -312,6 +314,8 @@ 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.DteExpression v) {
+ b.query("dte", dteFilterWireValue(v.filter()));
} 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()));
@@ -325,6 +329,20 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte
// an if-chain, but there is no reachable else, so no defensive throw is needed.
}
+ private static String dteFilterWireValue(DteFilter f) {
+ if (f instanceof DteFilter.Exact v) {
+ return Integer.toString(v.days());
+ } else if (f instanceof DteFilter.Values v) {
+ return v.days().stream().map(String::valueOf).collect(Collectors.joining(","));
+ } else if (f instanceof DteFilter.Range v) {
+ return v.min() + "-" + v.max();
+ }
+ // DteFilter is sealed; the only remaining variant is Comparison. The cast documents that
+ // exhaustiveness (Java 17 can't prove it in an if-chain) and fails fast if a variant is added.
+ DteFilter.Comparison v = (DteFilter.Comparison) f;
+ return v.operator().wireValue() + v.days();
+ }
+
private static String strikeFilterWireValue(StrikeFilter f) {
if (f instanceof StrikeFilter.Exact v) {
return formatStrike(v.price());
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..ba8b0e5
--- /dev/null
+++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java
@@ -0,0 +1,108 @@
+package com.marketdata.sdk.options;
+
+import com.marketdata.sdk.Generated;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The chain endpoint's {@code ?dte=} parameter accepts four syntactic forms — a single value, a
+ * comma-separated list, a closed range, and a comparison — the same syntax the API uses for {@code
+ * strike} (see {@link StrikeFilter}). Modeling them as a sealed type with factory entry-points
+ * gives the consumer compile-time autocomplete for valid shapes and prevents typos a raw-string
+ * passthrough would silently ship to the server.
+ *
+ * Reached through {@link ExpirationFilter#dte(DteFilter)}, a new overload sitting alongside the
+ * pre-existing {@link ExpirationFilter#dte(int)} (single-value only) — the older overload is left
+ * unchanged so existing callers keep compiling.
+ */
+public sealed interface DteFilter
+ permits DteFilter.Exact, DteFilter.Values, DteFilter.Range, DteFilter.Comparison {
+
+ /** Days of expiry, whole numbers, per the API's stated bound. */
+ int MIN_DAYS = 0;
+
+ int MAX_DAYS = 36500;
+
+ /** Match the expiration closest to {@code days}. */
+ static Exact exact(int days) {
+ validate(days);
+ return new Exact(days);
+ }
+
+ /**
+ * Match the expiration closest to each value in {@code days}, deduplicated server-side. {@code
+ * days} must not be empty.
+ */
+ static Values values(int... days) {
+ List boxed = new ArrayList<>(days.length);
+ for (int d : days) {
+ boxed.add(d);
+ }
+ return new Values(boxed);
+ }
+
+ /** Match every expiration whose dte falls in {@code [min, max]} inclusive. */
+ static Range range(int min, int max) {
+ if (min > max) {
+ throw new IllegalArgumentException("min must be <= max");
+ }
+ validate(min);
+ validate(max);
+ return new Range(min, max);
+ }
+
+ /** Match every expiration satisfying {@code operator days} (e.g. {@code >= 30}). */
+ // @Generated: the null-operator guard is unreachable through the public comparison factories,
+ // which always supply a non-null Operator from the typed enum.
+ @Generated
+ static Comparison comparison(Operator operator, int days) {
+ if (operator == null) {
+ throw new IllegalArgumentException("operator must not be null");
+ }
+ validate(days);
+ return new Comparison(operator, days);
+ }
+
+ private static void validate(int days) {
+ if (days < MIN_DAYS || days > MAX_DAYS) {
+ throw new IllegalArgumentException("days must be in " + MIN_DAYS + ".." + MAX_DAYS);
+ }
+ }
+
+ /** Comparison operators accepted by the API — the same vocabulary as {@link StrikeFilter}. */
+ 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 Exact(int days) implements DteFilter {}
+
+ record Values(List days) implements DteFilter {
+ public Values {
+ days = List.copyOf(days);
+ if (days.isEmpty()) {
+ throw new IllegalArgumentException("values must not be empty");
+ }
+ for (int d : days) {
+ validate(d);
+ }
+ }
+ }
+
+ 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..5318683 100644
--- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
+++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
@@ -18,6 +18,7 @@
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
+ ExpirationFilter.DteExpression,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
@@ -47,6 +48,15 @@ static Dte dte(int days) {
return new Dte(days);
}
+ /**
+ * Days-to-expiration filter using the richer {@link DteFilter} syntax (comma-separated list,
+ * closed range, or comparison operator), alongside the single value {@link #dte(int)} already
+ * covers.
+ */
+ static DteExpression dte(DteFilter filter) {
+ return new DteExpression(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}.
@@ -79,6 +89,9 @@ record OnDate(LocalDate date) implements ExpirationFilter {
record Dte(int days) implements ExpirationFilter {}
+ /** Backs {@link #dte(DteFilter)} — the list/range/comparison forms of the dte parameter. */
+ record DteExpression(DteFilter filter) implements ExpirationFilter {}
+
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..86c783e 100644
--- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java
+++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java
@@ -2,8 +2,10 @@
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.DteFilter;
import com.marketdata.sdk.options.ExpirationFilter;
import com.marketdata.sdk.options.Greek;
import com.marketdata.sdk.options.OptionQuote;
@@ -30,7 +32,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 {
@@ -975,6 +981,100 @@ void chainExpirationFilterDteTranslatesToDteParam() {
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
}
+ @Test
+ void chainDteFilterExactTranslatesToSingleValue() {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(ExpirationFilter.dte(DteFilter.exact(30)))
+ .build())
+ .join();
+
+ assertThat(client.captured.get(0).uri().toString())
+ .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
+ }
+
+ @Test
+ void chainDteFilterValuesTranslatesToCommaSeparatedList() {
+ 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 query encoder percent-encodes "," like other reserved characters (see the strike
+ // comparison test below) — the wire value is still the comma-separated list, just encoded.
+ assertThat(client.captured.get(0).uri().toString())
+ .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15%2C30%2C45");
+ }
+
+ @Test
+ void chainDteFilterRangeTranslatesToClosedRange() {
+ 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");
+ }
+
+ @ParameterizedTest
+ @MethodSource("dteComparisonOperators")
+ void chainDteFilterComparisonTranslatesToOperatorPrefix(
+ DteFilter.Operator operator, String encodedPrefix) {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(ExpirationFilter.dte(DteFilter.comparison(operator, 30)))
+ .build())
+ .join();
+
+ // The query encoder percent-encodes the reserved operator characters (">" -> %3E, "<" ->
+ // %3C, "=" -> %3D) — one case per Operator constant so every wireValue() prefix is exercised.
+ assertThat(client.captured.get(0).uri().toString()).contains("dte=" + encodedPrefix + "30");
+ }
+
+ static Stream dteComparisonOperators() {
+ return Stream.of(
+ arguments(DteFilter.Operator.GT, "%3E"),
+ arguments(DteFilter.Operator.GTE, "%3E%3D"),
+ arguments(DteFilter.Operator.LT, "%3C"),
+ arguments(DteFilter.Operator.LTE, "%3C%3D"));
+ }
+
+ @Test
+ void chainDteFilterRangeSyncAndAsyncProduceIdenticalRequest() {
+ OptionsChainRequest request =
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45)))
+ .build();
+ String expected = "http://localhost/v1/options/chain/AAPL/?dte=0-45";
+
+ CapturingClient asyncClient = okWith(CANNED_CHAIN_BODY);
+ resourceWith(asyncClient).chainAsync(request).join();
+ assertThat(asyncClient.captured.get(0).uri().toString()).isEqualTo(expected);
+
+ CapturingClient syncClient = okWith(CANNED_CHAIN_BODY);
+ resourceWith(syncClient).chain(request);
+ assertThat(syncClient.captured.get(0).uri().toString()).isEqualTo(expected);
+ }
+
@Test
void chainExpirationFilterBetweenTranslatesToFromTo() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
@@ -1227,6 +1327,55 @@ void strikeFilterRangeRejectsMinGreaterThanMax() {
.hasMessageContaining("min must be <= max");
}
+ @Test
+ void dteFilterExactRejectsBelowMin() {
+ assertThatThrownBy(() -> DteFilter.exact(-1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must be in 0..36500");
+ }
+
+ @Test
+ void dteFilterExactRejectsAboveMax() {
+ assertThatThrownBy(() -> DteFilter.exact(36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must be in 0..36500");
+ }
+
+ @Test
+ void dteFilterRangeRejectsMinGreaterThanMax() {
+ assertThatThrownBy(() -> DteFilter.range(45, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("min must be <= max");
+ }
+
+ @Test
+ void dteFilterRangeRejectsOutOfBoundsMin() {
+ assertThatThrownBy(() -> DteFilter.range(-1, 45))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must be in 0..36500");
+ }
+
+ @Test
+ void dteFilterValuesRejectsEmptyList() {
+ assertThatThrownBy(() -> DteFilter.values())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("values must not be empty");
+ }
+
+ @Test
+ void dteFilterValuesRejectsOutOfBoundsValue() {
+ assertThatThrownBy(() -> DteFilter.values(15, -1, 45))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must be in 0..36500");
+ }
+
+ @Test
+ void dteFilterComparisonRejectsAboveMax() {
+ assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.LT, 36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must be in 0..36500");
+ }
+
// ---------- helpers ----------
private static CapturingClient okWith(String body) {