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

## [Unreleased]

### Added

- `ExpirationFilter.dteList(List<Integer>)`, `ExpirationFilter.dteRange(int, int)`, `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — the API now accepts string forms of the `dte` parameter on `GET /v1/options/chain/{underlying}/` (API release 2.0.0). Sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, `ExpirationFilter.DteComparison`; `ExpirationFilter.Operator` enum (GT, GTE, LT, LTE) with `ExpirationFilter.Operator.wireValue()` accessor; and constants `ExpirationFilter.MIN_DTE` / `ExpirationFilter.MAX_DTE`. Existing `ExpirationFilter.dte(int)` works unchanged.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; 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,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)) // closest to N days
.expirationFilter(ExpirationFilter.dteList(List<Integer> days)) // closest to each of N1,N2,...
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte in [min, max], inclusive
.expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) // dte 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,12 @@ OptionsChainRequest.builder(String symbol)
.build()
```

`dte` alone carries four wire syntaxes: a single value (`dte(45)` → `?dte=45`), a comma-separated
list (`dteList(List.of(15, 30, 45))` → `?dte=15,30,45`, selecting the closest expiration for each
value), a closed range (`dteRange(0, 45)` → `?dte=0-45`, inclusive), and a comparison
(`dteComparison(ExpirationFilter.Operator.GTE, 30)` → `?dte=>=30`). Every `dte` value must be a
whole number of days in `[0, 36500]`.

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

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

// dte also accepts a range or a comparison, in addition to a single value or a list.
var within30To45Days = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(30, 45))
.side(OptionSide.CALL)
.build());
var at30DaysOrFurther = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30))
.side(OptionSide.CALL)
.build());
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ 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, and a comparison, in addition to
// the single value used above.
long between30And45 = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(30, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.dteRange(30, 45) spans " + between30And45 + " 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 @@ -14,6 +14,7 @@
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -133,6 +134,166 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteSingleValueSelectsTheClosestExpiration() {
// Pick the farthest real expiration (not the front-month) so a server that silently ignored
// the filter — falling back to its own front-month default — would be caught: that default
// would not match "target" here.
List<Integer> dtes = liveDistinctDtes();
int target = dtes.get(dtes.size() - 1);

List<OptionQuote> result =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(target))
.side(OptionSide.CALL)
.build())
.values();

assertThat(result).isNotEmpty();
assertThat(result.stream().map(OptionQuote::dte).distinct().toList())
.as("dte=%d resolves to the single expiration closest to that value", target)
.containsExactly(target);
}

@Test
void chainDteListResolvesToEachValuesClosestExpiration() {
List<Integer> dtes = liveDistinctDtes();
int first = dtes.get(0);
int last = dtes.get(dtes.size() - 1);

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

assertThat(result.stream().map(OptionQuote::dte).distinct().toList())
.as("dte list resolves to exactly the two requested expirations, nothing else")
.containsExactlyInAnyOrder(first, last);
}

@Test
void chainDteRangeKeepsExpirationsWithinInclusiveBounds() {
// An interior subrange (second through second-to-last baseline dte) so the requested range is
// a strict subset of the full chain — unlike the full span, this distinguishes a working range
// filter from one that was ignored (expiration=all's own front-to-back span would otherwise
// look
// identical to the filtered answer).
List<Integer> dtes = liveDistinctDtes();
int min = dtes.get(1);
int max = dtes.get(dtes.size() - 2);
int belowRange = dtes.get(0);
int aboveRange = dtes.get(dtes.size() - 1);

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

List<Integer> resultDtes = result.stream().map(OptionQuote::dte).distinct().toList();
assertThat(resultDtes)
.as("every returned expiration falls within the inclusive [%d, %d] bound", min, max)
.allMatch(d -> d >= min && d <= max);
assertThat(resultDtes)
.as("the range's own endpoints are kept (bounds are inclusive)")
.contains(min, max);
assertThat(resultDtes)
.as("expirations outside the requested range are excluded, not just the ones inside kept")
.doesNotContain(belowRange, aboveRange);
}

@Test
void chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt() {
// Compare each comparison answer against the exact baseline slice it ought to produce (not
// just "contains" / "does not contain" the boundary): a server that ignored the dte filter
// entirely would fall back to its own front-month default, which happens not to carry
// "boundary" either, so a bare doesNotContain(boundary) on the "<" half would pass whether or
// not the filter was honored. The exact-match on both halves proves inclusion and exclusion at
// once: nothing below the boundary leaks into ">=", and nothing at or above it leaks into "<".
List<Integer> dtes = liveDistinctDtes();
int boundary = dtes.get(dtes.size() / 2);
List<Integer> atOrAboveBoundary = dtes.stream().filter(d -> d >= boundary).toList();
List<Integer> belowBoundary = dtes.stream().filter(d -> d < boundary).toList();

List<OptionQuote> included =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, boundary))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
List<Integer> includedDtes =
included.stream().map(OptionQuote::dte).distinct().sorted().toList();
assertThat(includedDtes)
.as(
">=%d keeps every baseline expiration at or above %d, boundary included",
boundary, boundary)
.allMatch(d -> d >= boundary)
.containsExactlyElementsOf(atOrAboveBoundary);

List<OptionQuote> excluded =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, boundary))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
List<Integer> excludedDtes =
excluded.stream().map(OptionQuote::dte).distinct().sorted().toList();
assertThat(excludedDtes)
.as(
"<%d keeps exactly the baseline expirations below %d, boundary excluded",
boundary, boundary)
.containsExactlyElementsOf(belowBoundary);
}

/**
* Ground truth for the dte-filter assertions: the distinct dte values present in today's full
* AAPL call chain, discovered via the already-covered {@code expiration=all} filter rather than
* computed from the local clock. AAPL carries far more than four standard expirations at any
* time, so the index picks used by the dte tests (first / second / middle / second-to-last /
* last) are always distinct, and the range test's interior subrange always has at least one
* baseline dte strictly outside it on each side.
*/
private List<Integer> liveDistinctDtes() {
List<OptionQuote> all =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.all())
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
List<Integer> dtes =
all.stream().map(OptionQuote::dte).filter(Objects::nonNull).distinct().sorted().toList();
assertThat(dtes).as("AAPL carries several distinct expirations").hasSizeGreaterThanOrEqualTo(4);
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
11 changes: 11 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", dteListWireValue(v));
} 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 All @@ -325,6 +332,10 @@ 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 dteListWireValue(ExpirationFilter.DteList f) {
return f.days().stream().map(String::valueOf).collect(Collectors.joining(","));
}

private static String strikeFilterWireValue(StrikeFilter f) {
if (f instanceof StrikeFilter.Exact v) {
return formatStrike(v.price());
Expand Down
Loading
Loading