Modeling them as a sealed type (mirroring {@link StrikeFilter}) gives the consumer
+ * compile-time autocomplete for valid shapes and prevents a hand-assembled range or comparison
+ * string from shipping a typo to the server. Every value, in every form, is a whole number of days
+ * in {@code [0, 36500]} — the bound the API documents.
+ */
+public sealed interface DteFilter
+ permits DteFilter.ValueList, DteFilter.Range, DteFilter.Comparison {
+
+ /** The largest {@code dte} value the API accepts. */
+ int MAX_DAYS = 36500;
+
+ /**
+ * Comma-separated list of values — wire form {@code ?dte=15,30,45}. Selects the expiration
+ * closest to each value (deduplicated server-side). At least one value is required.
+ */
+ static ValueList list(int first, int... rest) {
+ List {@code dte} alone has two entry points — {@link #dte(int)} for a single value, {@link
+ * #dte(DteFilter)} for the list/range/comparison forms ({@link DteFilter}) — but both produce an
+ * {@code ExpirationFilter}, so they still compete with {@code onDate}/{@code between}/{@code
+ * monthYear}/{@code all} for the one setter slot.
+ *
* 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.
@@ -18,6 +23,7 @@
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
+ ExpirationFilter.DteValues,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
@@ -39,7 +45,7 @@ static All all() {
return new All();
}
- /** Days-to-expiration filter — wire form {@code ?dte=N}. */
+ /** Days-to-expiration, single value — wire form {@code ?dte=N}. */
static Dte dte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
@@ -47,6 +53,15 @@ static Dte dte(int days) {
return new Dte(days);
}
+ /**
+ * Days-to-expiration, multi-value form — a comma-separated list, a closed range, or a comparison,
+ * per {@link DteFilter}. Wire form is whichever {@code DteFilter} variant produces (e.g. {@code
+ * ?dte=15,30,45}, {@code ?dte=0-45}, {@code ?dte=>=30}).
+ */
+ static DteValues dte(DteFilter filter) {
+ return new DteValues(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 +94,13 @@ record OnDate(LocalDate date) implements ExpirationFilter {
record Dte(int days) implements ExpirationFilter {}
+ /** See {@link #dte(DteFilter)}. */
+ record DteValues(DteFilter filter) implements ExpirationFilter {
+ public DteValues {
+ Objects.requireNonNull(filter, "filter");
+ }
+ }
+
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..2315b01 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;
@@ -27,6 +28,7 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -975,6 +977,106 @@ void chainExpirationFilterDteTranslatesToDteParam() {
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
}
+ @Test
+ void chainExpirationFilterDteListTranslatesToCommaSeparatedDteParam() {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(ExpirationFilter.dte(DteFilter.list(15, 30, 45)))
+ .build())
+ .join();
+
+ // The comma is URL-encoded on the wire (same as ?columns=), 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 chainExpirationFilterDteRangeTranslatesToDashedDteParam() {
+ 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 chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(
+ ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GT, 30)))
+ .build())
+ .join();
+
+ // "%3E" is the URL-encoded ">" — the query encoder pushes reserved characters through.
+ assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E30");
+ }
+
+ @Test
+ void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() {
+ 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();
+
+ 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.dte(DteFilter.comparison(DteFilter.Operator.LT, 45)))
+ .build())
+ .join();
+
+ assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45");
+ }
+
+ @Test
+ void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() {
+ CapturingClient client = okWith(CANNED_CHAIN_BODY);
+ OptionsResource options = resourceWith(client);
+
+ options
+ .chainAsync(
+ OptionsChainRequest.builder("AAPL")
+ .expirationFilter(
+ ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.LTE, 45)))
+ .build())
+ .join();
+
+ assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D45");
+ }
+
@Test
void chainExpirationFilterBetweenTranslatesToFromTo() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
@@ -1203,6 +1305,84 @@ void expirationFilterDteRejectsNegative() {
.hasMessageContaining("dte must be non-negative");
}
+ @Test
+ void dteFilterListRejectsNegativeValue() {
+ assertThatThrownBy(() -> DteFilter.list(-1, 30))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
+ @Test
+ void dteFilterListRejectsValueAboveMax() {
+ assertThatThrownBy(() -> DteFilter.list(15, 36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be at most 36500");
+ }
+
+ @Test
+ void dteFilterRangeRejectsMinGreaterThanMax() {
+ assertThatThrownBy(() -> DteFilter.range(45, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("min must be <= max");
+ }
+
+ @Test
+ void dteFilterRangeRejectsNegativeValue() {
+ assertThatThrownBy(() -> DteFilter.range(-1, 45))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
+ @Test
+ void dteFilterRangeRejectsValueAboveMax() {
+ assertThatThrownBy(() -> DteFilter.range(0, 36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be at most 36500");
+ }
+
+ @Test
+ void dteFilterRangeAcceptsInclusiveMaxBoundary() {
+ assertThat(DteFilter.range(0, 36500).max()).isEqualTo(36500);
+ }
+
+ @Test
+ void dteFilterComparisonRejectsNegativeValue() {
+ assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be non-negative");
+ }
+
+ @Test
+ void dteFilterComparisonRejectsValueAboveMax() {
+ assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, 36501))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dte must be at most 36500");
+ }
+
+ @Test
+ void dteFilterComparisonRejectsNullOperator() {
+ assertThatThrownBy(() -> DteFilter.comparison(null, 30))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("operator");
+ }
+
+ // ---------- sealed type validation: direct record construction (bypassing the factories)
+ // ----------
+
+ @Test
+ void dteFilterValueListDirectConstructionRejectsEmptyList() {
+ assertThatThrownBy(() -> new DteFilter.ValueList(List.of()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("days must not be empty");
+ }
+
+ @Test
+ void dteFilterValueListDirectConstructionRejectsNullElement() {
+ assertThatThrownBy(() -> new DteFilter.ValueList(Arrays.asList(15, null)))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("days element");
+ }
+
@Test
void expirationFilterBetweenRejectsReversedDates() {
assertThatThrownBy(