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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`ExpirationFilter` dte variants** — `dteList(int, int...)`, `dteRange(int, int)`,
and `dteComparison(StrikeFilter.Operator, int)` for comma-separated, closed-range,
and comparison forms of the `?dte` parameter on `GET /v1/options/chain/` (API 2.0.0).

## [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,7 +20,11 @@ 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 — API max 36500, not enforced locally
.expirationFilter(ExpirationFilter.dteList(int first, int... rest)) // comma-separated list, e.g. 15,30,45
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. 0-45
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // e.g. >=30
// all dte* forms above count days from .date(...) below when set (a historical chain), or from today
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)
Expand Down Expand Up @@ -95,6 +99,23 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// dte also accepts a comma-separated list, a closed range, or a comparison — same syntax as
// strike.
client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(15, 30, 45))
.build());

client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.build());

client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45))
.build());
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ public static void main(String[] args) {
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations");

// dte also accepts a comma-separated list, a closed range, or a comparison — same syntax as
// strike (StrikeFilter.Operator is shared between the two).
long nearTerm = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 45))
.side(OptionSide.CALL)
.build())
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("dte < 45 spans " + nearTerm + " distinct expirations");

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 @@ -10,10 +10,15 @@
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.time.ZonedDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -133,6 +138,147 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

/**
* A fixed historical trading day for every dte* test below. All {@code dte} forms count days from
* the chain request's {@code date} when one is set, so pinning it keeps the results stable
* regardless of when the test runs — unlike omitting {@code date}, which computes dte from
* "today" and can change at the Eastern-midnight rollover between two requests in the same test.
*/
private static final LocalDate FIXED_CHAIN_DATE = LocalDate.of(2025, 6, 2);

@Test
void chainDteSingleValueNarrowsToOneExpiration() {
// A single dte value selects the one expiration closest to it. 365 days is far past the
// front-month expiration the unfiltered chain returns, so comparing against the unfiltered
// baseline settles whether the dte syntax actually reached the server: an ignored dte would
// fall back to that same front-month expiration instead of a distinct, far-dated one.
Set<ZonedDateTime> baseline = unfilteredChainExpirations();
Set<ZonedDateTime> farDte = singleDteExpirations(365);

assertThat(farDte)
.as("a single dte value narrows the chain to its one closest expiration")
.hasSize(1);
assertThat(farDte)
.as("dte(365) must resolve to an expiration the unfiltered (front-month) chain does not")
.isNotEqualTo(baseline);
}

@Test
void chainDteCommaListMatchesUnionOfSingleDteRequests() {
// A comma-separated dte list selects the closest expiration for each value, deduplicated.
// Near/mid/far values that must resolve to more than one expiration close the loophole an
// ignored dte parameter would otherwise slip through: an ignored list falls back to the single
// front-month expiration, failing both the "> 1" and the union-equality assertions below.
Set<ZonedDateTime> union = new HashSet<>();
union.addAll(singleDteExpirations(15));
union.addAll(singleDteExpirations(180));
union.addAll(singleDteExpirations(365));

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(15, 180, 365))
.side(OptionSide.CALL)
.date(FIXED_CHAIN_DATE)
.build())
.values();

assertThat(chain).isNotEmpty();
Set<ZonedDateTime> listExpirations =
chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet());
assertThat(listExpirations)
.as("a list spanning near/mid/far dte values must resolve to more than one expiration")
.hasSizeGreaterThan(1);
assertThat(listExpirations)
.as("dte list expirations equal the union of the single-value requests for 15, 180, 365")
.isEqualTo(union);
}

/**
* The distinct expirations a single {@code dte(days)} request resolves to, on {@link
* #FIXED_CHAIN_DATE}.
*/
private Set<ZonedDateTime> singleDteExpirations(int days) {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(days))
.side(OptionSide.CALL)
.date(FIXED_CHAIN_DATE)
.build())
.values();
return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet());
}

/**
* The distinct expirations the chain resolves to with no expiration filter at all, on {@link
* #FIXED_CHAIN_DATE} — the front-month default that an ignored dte parameter would fall back to.
*/
private Set<ZonedDateTime> unfilteredChainExpirations() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.side(OptionSide.CALL)
.date(FIXED_CHAIN_DATE)
.build())
.values();
assertThat(chain).isNotEmpty();
return chain.stream().map(OptionQuote::expiration).collect(Collectors.toSet());
}

@Test
void chainDteRangeReturnsOnlyExpirationsWithinBounds() {
// The lower bound is set above the front-month window so an ignored dte would return rows
// outside [90, 365] and fail the per-row assertion below, rather than passing by coincidence.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(90, 365))
.side(OptionSide.CALL)
.date(FIXED_CHAIN_DATE)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
Integer dte = q.dte();
assertThat(dte).isNotNull();
assertThat(dte.intValue()).isBetween(90, 365);
}
}

@Test
void chainDteComparisonReturnsOnlyExpirationsSatisfyingOperator() {
// The API documents comparison bounds as inclusive. The threshold is set above the
// front-month window so an ignored dte would return rows that violate the >= 90 bound below,
// rather than passing by coincidence the way a loose "<= 45" would against the front month.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 90))
.side(OptionSide.CALL)
.date(FIXED_CHAIN_DATE)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
Integer dte = q.dte();
assertThat(dte).isNotNull();
assertThat(dte.intValue()).isGreaterThanOrEqualTo(90);
}
}

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

/**
Expand Down Expand Up @@ -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()));
Expand Down
99 changes: 98 additions & 1 deletion src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.marketdata.sdk.options;

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

/**
Expand All @@ -14,10 +16,17 @@
* <p>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.
*
* <p>All {@code dte}/{@code dteList}/{@code dteRange}/{@code dteComparison} variants count days
* from the chain request's {@code date} parameter when one is set (a historical chain), or from
* today otherwise.
*/
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteList,
ExpirationFilter.DteRange,
ExpirationFilter.DteComparison,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
Expand All @@ -39,14 +48,53 @@ 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}. Limits the option chain to the
* expiration date closest to {@code days}.
*
* <p>The API's documented maximum is 36500. Unlike {@link #dteList(int, int...)}, {@link
* #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, this factory does
* not enforce that bound locally — a larger value is sent to the wire unchanged and left for the
* API to reject.
*/
static Dte dte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
return new Dte(days);
}

/**
* Days-to-expiration, comma-separated list — wire form {@code ?dte=15,30,45}. Selects the closest
* expiration for each value, deduplicated server-side. At least one value is required; each value
* must be a non-negative whole number of days, at most 36500.
*/
static DteList dteList(int first, int... rest) {
List<Integer> days = new ArrayList<>(rest.length + 1);
days.add(first);
for (int d : rest) {
days.add(d);
}
return new DteList(days);
}

/**
* Days-to-expiration, closed range — wire form {@code ?dte=0-45}. Returns every expiration whose
* dte falls within {@code [min, max]} inclusive. {@code min} must not exceed {@code max}.
*/
static DteRange dteRange(int min, int max) {
return new DteRange(min, max);
}

/**
* Days-to-expiration, comparison — wire form {@code ?dte=>=30} (operator prefix + value). Returns
* every expiration whose dte satisfies {@code operator days}. The operator set is shared with
* {@link StrikeFilter.Operator} — the two parameters use the same comparison syntax.
*/
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}.
Expand All @@ -71,6 +119,17 @@ static MonthYear monthYear(int year, int month) {
return new MonthYear(year, month);
}

/** Validates a single dte value: a non-negative whole number of days, at most 36500. */
private static int validateDte(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");
}
return days;
}

record OnDate(LocalDate date) implements ExpirationFilter {
public OnDate {
Objects.requireNonNull(date, "date");
Expand All @@ -79,6 +138,44 @@ record OnDate(LocalDate date) implements ExpirationFilter {

record Dte(int days) implements ExpirationFilter {}

/**
* Comma-separated dte list — see {@link #dteList(int, int...)}. Validated on every construction
* path, not only the static factory: {@code days} must be non-null, non-empty, and free of null
* elements, and every value must be a non-negative whole number of days, at most 36500.
*/
record DteList(List<Integer> days) implements ExpirationFilter {
public DteList {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("dte list must not be empty");
}
for (Integer d : days) {
Objects.requireNonNull(d, "dte list must not contain null elements");
validateDte(d);
}
days = List.copyOf(days);
}
}

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

/** Dte comparison — see {@link #dteComparison(StrikeFilter.Operator, int)}. */
record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter {
public DteComparison {
Objects.requireNonNull(operator, "operator");
validateDte(days);
}
}

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

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