Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`DteFilter` sealed type** — v2.0.0 widened the chain endpoint's `?dte=`
parameter to support comma-separated values, closed ranges, and comparison
operators (matching `StrikeFilter` syntax). Includes three record forms
(`Values`, `Range`, `Comparison`) and static factories `values(int, int...)`,
`range(int, int)`, and `comparison(StrikeFilter.Operator, int)`. Integrated
via new `ExpirationFilter.dte(DteFilter)` overload; single-value `dte`
remains via `ExpirationFilter.dte(int)`.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
23 changes: 22 additions & 1 deletion docs/options/chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ 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 filter)) // days-to-expiration, richer syntax
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)

// The richer dte syntax (sealed DteFilter — pick one, passed to ExpirationFilter.dte(...)):
.expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45))) // comma-separated list
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45))) // closed range, inclusive
.expirationFilter(ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 30))) // comparison

// Strike selection (sealed StrikeFilter — pick one):
.strikeFilter(StrikeFilter.exact(double price))
.strikeFilter(StrikeFilter.range(double min, double max))
Expand Down Expand Up @@ -66,6 +72,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;
Expand Down Expand Up @@ -95,6 +102,20 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// DteFilter: the richer dte syntax — expirations between 30 and 90 days out.
var byDteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 90)))
.side(OptionSide.CALL)
.build());

// DteFilter: a comparison — expirations at least 180 days out (LEAPS).
var leaps = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180)))
.side(OptionSide.CALL)
.build());
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.marketdata.sdk.MarketDataClient;
import com.marketdata.sdk.exception.AuthenticationError;
import com.marketdata.sdk.options.DteFilter;
import com.marketdata.sdk.options.ExpirationFilter;
import com.marketdata.sdk.options.OptionQuote;
import com.marketdata.sdk.options.OptionSide;
Expand Down Expand Up @@ -51,6 +52,19 @@ public static void main(String[] args) {
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations");

// DteFilter: the richer dte syntax — comma-separated list, closed range, or comparison
// operator (same syntax as StrikeFilter.Operator) — for expirations a plain dte(int) can't
// express on its own.
long leaps = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().size();
System.out.println("Contracts at least 180 days out: " + leaps);

if (chain.size() < 2) {
System.out.println("Not enough contracts to demo the rest — try a more liquid underlying.");
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -10,6 +11,7 @@
import com.marketdata.sdk.options.OptionsLookupRequest;
import com.marketdata.sdk.options.OptionsQuoteRequest;
import com.marketdata.sdk.options.OptionsQuotesRequest;
import com.marketdata.sdk.options.StrikeFilter;
import com.marketdata.sdk.options.StrikeRange;
import java.time.LocalDate;
import java.util.List;
Expand Down Expand Up @@ -133,6 +135,98 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteValuesReturnsExpirationPerValue() {
// dte widened from integer to string (v2.0.0): a comma-separated list selects the closest
// expiration for each of the 3 requested values, deduplicated — so however AAPL's own
// expiration cycle lands, three requested values can resolve to at most three distinct
// expirations. That bound (not just "non-empty") is the market-independent consequence of
// this specific form, as opposed to any other chain query.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.values(7, 30, 90)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).as("AAPL has expirations near 7, 30, and 90 days out").isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
}
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("3 requested values resolve to at most 3 distinct expirations")
.isLessThanOrEqualTo(3);
}

@Test
void chainDteSingleValueStillResolvesViaStringHandler() {
// Regression: dte's plain-integer form (now parsed by the API's widened string handler)
// still resolves to a decoded dte on every row.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).as("AAPL has an expiration near 30 days out").isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
}
}

@Test
void chainDteRangeReturnsExpirationsWithinBounds() {
// Closed range, inclusive at both ends.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 365)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).as("AAPL always has expirations within a year").isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
assertThat(q.dte()).isBetween(0, 365);
}
}

@Test
void chainDteComparisonReturnsExpirationsPastThreshold() {
// Comparison operator — "the syntax matches strike and delta".
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(StrikeFilter.Operator.GTE, 180)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).as("AAPL LEAPS extend well past 180 days out").isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
assertThat(q.dte()).isGreaterThanOrEqualTo(180);
}
}

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/marketdata/sdk/OptionsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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.DteExpr 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()));
Expand All @@ -325,6 +329,18 @@ 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.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());
Expand Down
90 changes: 90 additions & 0 deletions src/main/java/com/marketdata/sdk/options/DteFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.marketdata.sdk.options;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
* The chain endpoint's {@code ?dte=} parameter accepts three syntactic forms beyond a bare integer
* — a comma-separated list, a closed range, and a comparison ({@code >=30}, {@code <45}, …) — "the
* syntax matches strike and delta" per the API's own description, i.e. the same shape {@link
* StrikeFilter} already models for {@code ?strike=}. 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.
*
* <p>A single value is still expressed via {@link ExpirationFilter#dte(int)} (unchanged,
* non-breaking) — this type covers only the forms a plain {@code int} cannot, reached through
* {@link ExpirationFilter#dte(DteFilter)}.
*/
public sealed interface DteFilter permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison {

/** Comma-separated list — selects the closest expiration for each value, deduplicated. */
static Values values(int first, int... rest) {
List<Integer> days = new ArrayList<>();
days.add(first);
for (int d : rest) {
days.add(d);
}
return new Values(days);
}

/** Closed range {@code [min, max]}, inclusive. {@code min} must not exceed {@code max}. */
static Range range(int min, int max) {
return new Range(min, max);
}

/** Match dte satisfying {@code operator days} (e.g. {@code >= 30}). */
static Comparison comparison(StrikeFilter.Operator operator, int days) {
return new Comparison(operator, days);
}

/** Whole days, at most 36500, per the API's own description of the parameter. */
private static void validateDay(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");
}
}

/**
* Comma-separated {@code dte} values — see {@link #values(int, int...)}. {@code days} must be
* non-empty and carry no {@code null} elements — enforced here, not only in the static factory,
* since the canonical constructor is itself a public path.
*/
record Values(List<Integer> days) implements DteFilter {
public Values {
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");
}
validateDay(d);
}
days = List.copyOf(days);
}
}

/** Closed {@code dte} range — see {@link #range(int, int)}. */
record Range(int min, int max) implements DteFilter {
public Range {
validateDay(min);
validateDay(max);
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
}
}

/** {@code dte} comparison — see {@link #comparison(StrikeFilter.Operator, int)}. */
record Comparison(StrikeFilter.Operator operator, int days) implements DteFilter {
public Comparison {
Objects.requireNonNull(operator, "operator");
validateDay(days);
}
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteExpr,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
Expand Down Expand Up @@ -47,6 +48,16 @@ static Dte dte(int days) {
return new Dte(days);
}

/**
* Days-to-expiration filter using the richer {@code dte} syntax — comma-separated list, closed
* range, or comparison operator (v2.0.0 widened {@code dte} from a plain integer to this string
* syntax server-side). Wire form depends on the {@link DteFilter} variant; see {@link DteFilter}
* for each. A single value is still expressed via {@link #dte(int)}.
*/
static DteExpr dte(DteFilter filter) {
return new DteExpr(filter);
}

/**
* Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not
* be strictly after {@code to}.
Expand Down Expand Up @@ -79,6 +90,13 @@ record OnDate(LocalDate date) implements ExpirationFilter {

record Dte(int days) implements ExpirationFilter {}

/** The richer {@code dte} syntax — see {@link #dte(DteFilter)}. */
record DteExpr(DteFilter filter) implements ExpirationFilter {
public DteExpr {
Objects.requireNonNull(filter, "filter");
}
}

record Between(LocalDate from, LocalDate to) implements ExpirationFilter {}

record MonthYear(int year, int month) implements ExpirationFilter {}
Expand Down
Loading
Loading