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

## [Unreleased]

### Added

- **Options chain `dte` parameter widening** (API 2.0.0) — the options chain endpoint's
`dte` days-to-expiration filter now supports comma-separated lists, closed ranges, and
comparisons alongside single values. New sealed `ExpirationFilter` variants `DteList`,
`DteRange`, `DteComparison` and their factories `dteList(int...)`, `dteRange(int, int)`,
and `dteComparison(StrikeFilter.Operator, int)` expose all four syntaxes; a shared upper
bound `MAX_DTE_DAYS` limits values per the API.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
19 changes: 18 additions & 1 deletion docs/options/chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ 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.dteList(int... days)) // dte, comma-separated list
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte, closed range
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // dte, comparison
.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 +98,20 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// dte's other wire syntaxes: a comma-separated list, a closed range, and a comparison.
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.GTE, 30))
.build());
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ public static void main(String[] args) {
.values();
System.out.println("Filtered chain: " + chain.size() + " contracts");

// dte also accepts a comma-separated list, a closed range, and a comparison — on top of the
// single value used above.
client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build());

// ExpirationFilter.all() spans every expiration at once — distinct from omitting the filter,
// which the API narrows to the front month.
long spans = client.options().chain(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
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;
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 +136,118 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteSingleValueSelectsOneNearestExpiration() {
// Regression: the single-value form of dte already worked before dte widened to also accept
// list / range / comparison syntax — assert the server still narrows to exactly one nearest
// expiration for it.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("a single dte value narrows the chain to its one nearest expiration")
.isEqualTo(1);
}

@Test
void chainDteCommaListSelectsClosestExpirationPerValue() {
// A full all() chain bounds what "a subset of the chain" means without reimplementing the
// server's own nearest-expiration tie-break: that algorithm is the server's to pick, and a
// local reimplementation of it (absolute distance, first-row tie-break) is not guaranteed to
// match, so this asserts shape instead — the list narrows to more than one and at most three
// of the chain's own expirations, which is only possible if the server honored every value.
Set<LocalDate> chainExpirations =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.all())
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values()
.stream()
.map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate())
.collect(Collectors.toSet());
assertThat(chainExpirations).isNotEmpty();

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(15, 30, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
Set<LocalDate> actualExpirations =
chain.stream()
.map(q -> java.util.Objects.requireNonNull(q.expiration(), "expiration").toLocalDate())
.collect(Collectors.toSet());
assertThat(actualExpirations)
.as("every expiration a dte list selects is one the full chain actually has")
.isSubsetOf(chainExpirations);
assertThat(actualExpirations)
.as("three distinct dte targets (15, 30, 45) resolve to more than one distinct expiration")
.hasSizeGreaterThan(1);
assertThat(actualExpirations)
.as("a three-value dte list narrows to at most three expirations, not the whole chain")
.hasSizeLessThanOrEqualTo(3);
}

@Test
void chainDteRangeReturnsExpirationsWithinInclusiveBounds() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
assertThat(q.dte()).isBetween(0, 45);
}
}

@Test
void chainDteComparisonReturnsExpirationsAboveThreshold() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
assertThat(q.dte()).isGreaterThanOrEqualTo(30);
}
}

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/marketdata/sdk/OptionsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,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", joinDays(v.days()));
} 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 Expand Up @@ -344,6 +350,17 @@ private static String formatStrike(double v) {
return Double.toString(v);
}

private static String joinDays(List<Integer> days) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < days.size(); i++) {
if (i > 0) {
sb.append(',');
}
sb.append(days.get(i));
}
return sb.toString();
}

// ---------- wire-format module ----------

static SimpleModule wireFormatModule() {
Expand Down
111 changes: 110 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,14 +16,32 @@
* <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>{@code dte} itself has four wire syntaxes — single value ({@link #dte(int)}), comma-separated
* list ({@link #dteList(int...)}), closed range ({@link #dteRange(int, int)}), and comparison
* ({@link #dteComparison(StrikeFilter.Operator, int)}) — modeled as four sibling variants rather
* than widening {@link #dte(int)} itself, so the pre-existing single-value call keeps compiling and
* keeps whatever error (local or server-side) it produced before.
*/
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteList,
ExpirationFilter.DteRange,
ExpirationFilter.DteComparison,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {

/**
* Upper bound on a single {@code dte} value, per the API. Enforced locally by {@link
* #dteList(int...)}, {@link #dteRange(int, int)}, and {@link
* #dteComparison(StrikeFilter.Operator, int)}. {@link #dte(int)} predates this bound and does not
* check it — a value above it reaches the API and comes back as a server-side rejection rather
* than an {@link IllegalArgumentException}.
*/
int MAX_DTE_DAYS = 36500;

/** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */
static OnDate onDate(LocalDate date) {
return new OnDate(date);
Expand All @@ -39,14 +59,56 @@ static All all() {
return new All();
}

/** Days-to-expiration filter — wire form {@code ?dte=N}. */
/**
* Single days-to-expiration filter — wire form {@code ?dte=N}. Limits the chain to the one
* expiration whose dte is closest to {@code days}.
*
* <p>Only rejects a negative {@code days} locally; unlike {@link #dteList(int...)}, {@link
* #dteRange(int, int)}, and {@link #dteComparison(StrikeFilter.Operator, int)}, it does not
* enforce {@link #MAX_DTE_DAYS} — a value above that bound reaches the API and fails as a server
* rejection instead of an {@link IllegalArgumentException}.
*/
static Dte dte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
return new Dte(days);
}

/**
* Days-to-expiration list filter — wire form {@code ?dte=15,30,45}. The API resolves the closest
* expiration to each value independently, deduplicated. {@code days} must be non-empty; every
* value must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, both
* bounds are enforced locally.
*/
static DteList dteList(int... days) {
List<Integer> list = new ArrayList<>(days.length);
for (int day : days) {
list.add(day);
}
return new DteList(list);
}

/**
* Days-to-expiration range filter — wire form {@code ?dte=0-45}. Returns every expiration whose
* dte falls in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}; both bounds
* must be non-negative and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper
* bound is enforced locally.
*/
static DteRange dteRange(int min, int max) {
return new DteRange(min, max);
}

/**
* Days-to-expiration comparison filter — wire form {@code ?dte=>=30} / {@code ?dte=<45}. Returns
* every expiration whose dte satisfies {@code operator days}. {@code days} must be non-negative
* and at most {@link #MAX_DTE_DAYS} — unlike {@link #dte(int)}, the upper bound is enforced
* locally.
*/
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 Down Expand Up @@ -77,12 +139,59 @@ record OnDate(LocalDate date) implements ExpirationFilter {
}
}

// Dte predates MAX_DTE_DAYS and keeps its original, factory-only non-negative check (no compact
// constructor) so an existing caller's behavior — including constructing it unvalidated via
// `new Dte(...)` — does not change for this release.
record Dte(int days) implements ExpirationFilter {}

/** See {@link #dteList(int...)}. {@code days} is defensively copied and immutable. */
record DteList(List<Integer> days) implements ExpirationFilter {
public DteList {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("dteList must not be empty");
}
for (Integer day : days) {
Objects.requireNonNull(day, "days element");
validateDteValue(day);
}
days = List.copyOf(days);
}
}

/** See {@link #dteRange(int, int)}. */
record DteRange(int min, int max) implements ExpirationFilter {
public DteRange {
validateDteValue(min);
validateDteValue(max);
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
}
}

/** See {@link #dteComparison(StrikeFilter.Operator, int)}. */
record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter {
public DteComparison {
Objects.requireNonNull(operator, "operator");
validateDteValue(days);
}
}

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

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

/** The whole chain across every expiration — see {@link #all()}. Carries no data of its own. */
record All() implements ExpirationFilter {}

/** Bounds check backing {@link #dteList}, {@link #dteRange}, and {@link #dteComparison}. */
private static void validateDteValue(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
if (days > MAX_DTE_DAYS) {
throw new IllegalArgumentException("dte must be at most " + MAX_DTE_DAYS);
}
}
}
Loading
Loading