rows =
+ client
+ .options()
+ .chain(
+ OptionsChainRequest.builder(UNDERLYING)
+ .expirationFilter(filter)
+ .side(OptionSide.CALL)
+ .strikeLimit(1)
+ .strikeRange(StrikeRange.ITM)
+ .build())
+ .values();
+ assertThat(rows).isNotEmpty();
+ return rows.get(0);
+ }
+
+ /**
+ * The live dte of the expiration closest to {@code seedDays} — built from a {@code dte(...)} call
+ * itself (the parameter under test), never from the unfiltered default, per the live-test rule
+ * that a baseline must exercise the parameter it's anchoring.
+ */
+ private int anchorDte(int seedDays) {
+ return java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(seedDays)).dte());
+ }
+
@Test
void quoteCountbackBoundsHistoricalSeries() {
// countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has
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..0bec8d7 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.Arrays;
+import java.util.List;
import java.util.Objects;
/**
@@ -14,10 +16,18 @@
* 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 has four wire forms (the API's {@code dte} parameter is string-typed as of
+ * v2.0.0): a single value ({@link #dte(int)}), a comma-separated list ({@link #dte(int...)}), a
+ * closed range ({@link #dteRange}), and a comparison operator ({@link #dteComparison}) — the last
+ * two share their operator/range syntax with {@link StrikeFilter}.
*/
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
+ ExpirationFilter.DteList,
+ ExpirationFilter.DteRange,
+ ExpirationFilter.DteComparison,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
@@ -47,6 +57,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, deduplicated server-side. {@code days} must be non-empty; every value
+ * must be in {@code [0, 36500]}.
+ */
+ static DteList dte(int... days) {
+ return new DteList(Arrays.stream(days).boxed().toList());
+ }
+
+ /**
+ * Days-to-expiration closed range filter — wire form {@code ?dte=min-max}. Matches every
+ * expiration whose dte falls within {@code [min, max]}, inclusive. {@code min} must not exceed
+ * {@code max}; both bounds must be in {@code [0, 36500]}.
+ */
+ static DteRange dteRange(int min, int max) {
+ return new DteRange(min, max);
+ }
+
+ /**
+ * Days-to-expiration comparison filter — wire form {@code ?dte=} (e.g. {@code
+ * ?dte=>=30}). Matches every expiration satisfying {@code operator days}. {@code days} must be in
+ * {@code [0, 36500]}. Syntax mirrors {@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 +116,55 @@ record OnDate(LocalDate date) implements ExpirationFilter {
record Dte(int days) implements ExpirationFilter {}
+ /**
+ * Validated in the compact constructor (not just {@link #dte(int...)}) so the guarantee holds on
+ * every public construction path, including a direct {@code new DteList(...)} call.
+ */
+ record DteList(List days) implements ExpirationFilter {
+ public DteList {
+ Objects.requireNonNull(days, "days");
+ if (days.isEmpty()) {
+ throw new IllegalArgumentException("days must not be empty");
+ }
+ for (Integer d : days) {
+ if (d == null) {
+ throw new IllegalArgumentException("days must not contain null elements");
+ }
+ requireDteInRange(d);
+ }
+ days = List.copyOf(days);
+ }
+ }
+
+ /** Validated in the compact constructor — see {@link DteList}. */
+ record DteRange(int min, int max) implements ExpirationFilter {
+ public DteRange {
+ if (min > max) {
+ throw new IllegalArgumentException("min must be <= max");
+ }
+ requireDteInRange(min);
+ requireDteInRange(max);
+ }
+ }
+
+ /** Validated in the compact constructor — see {@link DteList}. */
+ record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter {
+ public DteComparison {
+ Objects.requireNonNull(operator, "operator");
+ requireDteInRange(days);
+ }
+ }
+
+ /** Shared {@code [0, 36500]} bound — the API's documented limit on every {@code dte} value. */
+ private static void requireDteInRange(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..ac2c171 100644
--- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java
+++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java
@@ -975,6 +975,108 @@ void chainExpirationFilterDteTranslatesToDteParam() {
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
}
+ // dte's wire type changed from integer to string in v2.0.0 — it now also accepts a
+ // comma-separated list, a closed range, and a comparison operator (syntax shared with
+ // StrikeFilter). dte(int) above is unchanged and keeps its own test as the regression case.
+
+ @Test
+ void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(ExpirationFilter.dte(15, 30, 45))
+ .build())
+ .join();
+
+ String url =
+ java.net.URLDecoder.decode(
+ client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8);
+ assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45");
+ }
+
+ @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 chainExpirationFilterDteComparisonRendersWithOperatorPrefix() {
+ 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 ">=" — mirrors StrikeFilter's comparison encoding.
+ 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 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 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 +1305,118 @@ void expirationFilterDteRejectsNegative() {
.hasMessageContaining("dte must be non-negative");
}
+ @Test
+ void expirationFilterDteListRejectsEmpty() {
+ assertThatThrownBy(ExpirationFilter::dte)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must not be empty");
+ }
+
+ @Test
+ void expirationFilterDteListRejectsNegativeValue() {
+ assertThatThrownBy(() -> ExpirationFilter.dte(15, -1, 45))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
+ @Test
+ void expirationFilterDteListRejectsValueAboveMax() {
+ assertThatThrownBy(() -> ExpirationFilter.dte(15, 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");
+ }
+
+ // ---------- new dte variants: validation holds on the direct record constructor too ----------
+
+ @Test
+ void dteListRecordConstructorRejectsNullList() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteList(null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void dteListRecordConstructorRejectsEmptyList() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must not be empty");
+ }
+
+ @Test
+ void dteListRecordConstructorRejectsNullElement() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteList(java.util.Arrays.asList(15, null, 45)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must not contain null elements");
+ }
+
+ @Test
+ void dteListRecordConstructorRejectsOutOfRangeElement() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of(15, -1)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
+ @Test
+ void dteRangeRecordConstructorRejectsMinGreaterThanMax() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("min must be <= max");
+ }
+
+ @Test
+ void dteRangeRecordConstructorRejectsOutOfRangeBound() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteRange(0, 36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be at most 36500");
+ }
+
+ @Test
+ void dteComparisonRecordConstructorRejectsNullOperator() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void dteComparisonRecordConstructorRejectsOutOfRangeDays() {
+ assertThatThrownBy(() -> new ExpirationFilter.DteComparison(StrikeFilter.Operator.GTE, -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
@Test
void expirationFilterBetweenRejectsReversedDates() {
assertThatThrownBy(