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

## [Unreleased]

### Added

- `ExpirationFilter.dteList(List<Integer>)`, `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — New static factories for the chain endpoint's expanded `dte` parameter forms (comma-separated list, closed range, comparison operators). The existing `ExpirationFilter.dte(int)` works as before (API release 2.0.0).
- `ExpirationFilter.Operator` — New enum (GT, GTE, LT, LTE) for comparison operators, with `wireValue()` accessor.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
14 changes: 13 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
.expirationFilter(ExpirationFilter.dteList(List<Integer> days)) // closest expiration per value
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // every expiration within [min, max]
.expirationFilter(ExpirationFilter.dteComparison(Operator op, int days)) // every expiration satisfying "op days"
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)
Expand All @@ -40,6 +43,8 @@ OptionsChainRequest.builder(String symbol)
.build()
```

`dte` accepts four forms: a single value (`dte(int)`), a comma-separated list (`dteList(List<Integer>)` — for each value, the API selects its closest available expiration), a closed range (`dteRange(int, int)`, both ends inclusive — same grammar as `StrikeFilter.range`), and a comparison (`dteComparison(ExpirationFilter.Operator, int)`, operators `GT`/`GTE`/`LT`/`LTE` — same grammar as `StrikeFilter.comparison`). Every `dte` value, in any of the four forms, is relative to the request's `date` parameter when one is set (to "today" otherwise), and is capped at 36,500 days (100 years).

<a name="optionquote"></a>
#### Returns

Expand Down Expand Up @@ -95,6 +100,13 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// dte also accepts a list, a closed range, or a comparison.
var dteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.side(OptionSide.CALL)
.build());
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ 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, or a comparison — same grammar
// as StrikeFilter's range/comparison forms.
List<OptionQuote> withinRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 60))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
System.out.println("dteRange(0, 60): " + withinRange.size() + " contracts");

List<OptionQuote> atOrBeyond30 = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
System.out.println("dteComparison(GTE, 30): " + atOrBeyond30.size() + " contracts");

// 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 @@ -40,6 +40,11 @@

private static final String UNDERLYING = "AAPL";

// A fixed, recent US trading day (not a weekend or market holiday) that the dte tests below
// pin their baseline and filtered calls to, so the chain's dte values can't shift with the
// wall clock the way LocalDate.now() would.
private static final LocalDate DTE_FIXED_TRADING_DAY = LocalDate.of(2026, 10, 27);

private MarketDataClient client;

@BeforeAll
Expand Down Expand Up @@ -133,6 +138,208 @@
.isGreaterThan(1);
}

@Test
void chainExpirationDteSingleValueMatchesClosestExpiration() {
// Request an available dte value exactly — its own closest expiration is unambiguous (zero
// distance), so the result's dte must equal it. Picking the farthest-out value keeps it
// distinct from the unfiltered default, which is the front-month (nearest) expiration.
// The baseline and the filtered call share one fixed trading day so dte is computed against
// the same session for both, instead of a wall-clock "today" that would shift with the
// calendar and could land on a weekend or market holiday.
LocalDate date = DTE_FIXED_TRADING_DAY;
List<Integer> baseline = baselineDtes(date);
int farthest = baseline.get(baseline.size() - 1);

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(farthest))
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isEqualTo(farthest);
}
}

@Test
void chainExpirationDteListMatchesClosestExpirationPerValue() {
// Request two distinct, available dte values exactly: each is its own unambiguous closest
// candidate (zero distance), so the result can only ever contain those two values, and both
// must be present. Distinct from the unfiltered default, which carries a single expiration.
// The baseline and the filtered call share one fixed trading day so dte is computed against
// the same session for both, instead of a wall-clock "today" that would shift with the
// calendar and could land on a weekend or market holiday.
LocalDate date = DTE_FIXED_TRADING_DAY;
List<Integer> baseline = baselineDtes(date);
int low = baseline.get(0);
int high = baseline.get(baseline.size() - 1);

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(List.of(low, high)))
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isIn(low, high);
}
assertThat(chain.stream().map(OptionQuote::dte).collect(java.util.stream.Collectors.toSet()))
.as("each requested value's own candidate must be present")
.contains(low, high);
}

@Test
void chainExpirationDteRangeReturnsEveryExpirationWithinBounds() {
// A closed range spanning several available expirations: every row's dte must fall within
// [min, max], and the span must cover more than one distinct value to differ from the
// unfiltered (single front-month) default.
// The baseline and the filtered call share one fixed trading day so dte is computed against
// the same session for both, instead of a wall-clock "today" that would shift with the
// calendar and could land on a weekend or market holiday.
LocalDate date = DTE_FIXED_TRADING_DAY;
List<Integer> baseline = baselineDtes(date);
int min = baseline.get(baseline.size() / 4);
int max = baseline.get(3 * baseline.size() / 4);

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(min, max))
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isBetween(min, max);
}
long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count();
assertThat(distinctDtes)
.as("a range spanning several expirations holds more than one distinct value")
.isGreaterThan(1);
}

@Test
void chainExpirationDteComparisonGteReturnsExpirationsAtOrAboveBound() {
// >= N: every row's dte is >= N, asserted in a way that holds whether the server treats the
// bound as inclusive or exclusive. The bound sits a third of the way up the baseline so the
// qualifying set spans more than one expiration, distinguishing it from the unfiltered
// default (a single front-month row).
// The baseline and the filtered call share one fixed trading day so dte is computed against
// the same session for both, instead of a wall-clock "today" that would shift with the
// calendar and could land on a weekend or market holiday.
LocalDate date = DTE_FIXED_TRADING_DAY;
List<Integer> baseline = baselineDtes(date);
int bound = baseline.get(baseline.size() / 3);

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, bound))
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isGreaterThanOrEqualTo(bound);
}
long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count();
assertThat(distinctDtes)
.as("a >= bound below the top of the chain qualifies more than one expiration")
.isGreaterThan(1);
}

@Test
void chainExpirationDteComparisonLtReturnsExpirationsBelowBound() {
// < N: every row's dte is <= N, asserted in a way that holds whether the server treats the
// bound as inclusive or exclusive. The bound sits two-thirds of the way up the baseline so the
// qualifying set spans more than one expiration, distinguishing it from the unfiltered
// default (a single front-month row).
// The baseline and the filtered call share one fixed trading day so dte is computed against
// the same session for both, instead of a wall-clock "today" that would shift with the
// calendar and could land on a weekend or market holiday.
LocalDate date = DTE_FIXED_TRADING_DAY;
List<Integer> baseline = baselineDtes(date);
int bound = baseline.get(2 * baseline.size() / 3);

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, bound))
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();

assertThat(chain).isNotEmpty();

Check warning on line 302 in src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java

View check run for this annotation

marketdata-code-review / Code review

Live tests miss operator inclusivity

The GTE test passes if the API behaves as strict GT because it never requires the available bound, while the LT test passes for LTE because it allows the bound. Since each bound comes from the baseline, assert its presence for inclusive operators and absence for strict operators, including analogous GT and LTE checks.
for (OptionQuote q : chain) {
assertThat(q.dte()).isLessThanOrEqualTo(bound);
}
long distinctDtes = chain.stream().map(OptionQuote::dte).distinct().count();
assertThat(distinctDtes)
.as("a < bound above the bottom of the chain qualifies more than one expiration")
.isGreaterThan(1);
}

/**
* Every distinct dte present across AAPL's full expiration chain, ascending — the baseline a
* dte-filter test derives its candidate values from, per the suite's own convention of deriving
* expectations from a baseline call rather than from values assumed in advance. {@code date} is
* the fixed trading day the caller also passes to its filtered call, so both calls see the same
* session and dte can't shift between them.
*/
private List<Integer> baselineDtes(LocalDate date) {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.all())
.side(OptionSide.CALL)
.strikeLimit(1)
.date(date)
.build())
.values();
List<Integer> dtes =
chain.stream()
.map(OptionQuote::dte)
.distinct()
.sorted()
.collect(java.util.stream.Collectors.toList());
assertThat(dtes)
.as("AAPL has at least two distinct expirations")
.hasSizeGreaterThanOrEqualTo(2);
return dtes;
}

@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
Loading
Loading