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

## [Unreleased]

### Added

- `DteFilter` — sealed interface for extended days-to-expiration filtering in
`GET /v1/options/chain/` (API release 2.0.0). Three static factories:
`values(int...)` for comma-separated lists, `range(int, int)` for closed
ranges, `comparison(StrikeFilter.Operator, int)` for numeric comparisons,
alongside the existing `ExpirationFilter.dte(int)` single-value form (unchanged).

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
30 changes: 29 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.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)

// Days-to-expiration, beyond a single value (sealed DteFilter — also an ExpirationFilter,
// so it plugs into the same expirationFilter(...) setter above):
.expirationFilter(DteFilter.values(int... days)) // comma-separated list
.expirationFilter(DteFilter.range(int min, int max)) // closed range, inclusive
.expirationFilter(DteFilter.comparison(StrikeFilter.Operator op, int days))

// 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,27 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// DteFilter: the closest expiration to each of 30/60/90 days, deduplicated.
var byDteList = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.values(30, 60, 90))
.side(OptionSide.CALL)
.build());

// DteFilter: every expiration within a closed day range, inclusive.
var byDteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.range(0, 45))
.side(OptionSide.CALL)
.build());

// DteFilter: every expiration at least 30 days out — same operator syntax as StrikeFilter.
var byDteComparison = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30))
.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,37 @@ public static void main(String[] args) {
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations");

// DteFilter: dte's forms beyond a single value — a comma-separated list (closest expiration
// per value, deduplicated), a closed range (inclusive), and a comparison (same operator
// syntax as StrikeFilter).
long dteValuesSpans = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.values(30, 60, 90))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("DteFilter.values(30, 60, 90) spans " + dteValuesSpans + " expirations");

List<OptionQuote> dteRangeChain = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.range(0, 45))
.strikeFilter(StrikeFilter.exact(150))
.side(OptionSide.CALL)
.build())
.values();
System.out.println("DteFilter.range(0, 45): " + dteRangeChain.size() + " contracts");

List<OptionQuote> dteComparisonChain = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30))
.strikeFilter(StrikeFilter.exact(150))
.side(OptionSide.CALL)
.build())
.values();
System.out.println(
"DteFilter.comparison(GTE, 30): " + dteComparisonChain.size() + " contracts");

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,163 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainExpirationDteSingleValueSelectsClosestExpiration() {
// A single dte value narrows to the one expiration closest to it, same as before the API's
// dte type change — but 365 is far enough from AAPL's front-month expiration (always well
// under 180 days for a name this liquid) that an unfiltered call could never satisfy the
// assertions below.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(365))
.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 selects exactly one expiration")
.isEqualTo(1);
Integer dte = chain.get(0).dte();
assertThat(dte).isNotNull();
assertThat(dte)
.as(
"the expiration closest to the requested 365 dte is far from the front-month"
+ " expiration an unfiltered call would return")
.isGreaterThan(180);
}

@Test
void chainExpirationDteValuesSelectsClosestPerValueDeduplicated() {
// Three widely-spaced dte values each resolve to their own closest expiration and dedupe —
// unlike a single dte value (or no filter at all), which always resolves to one expiration.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(DteFilter.values(30, 180, 365))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as(
"three widely-spaced dte values resolve to multiple distinct expirations, unlike the"
+ " single front-month expiration an unfiltered call returns")
.isGreaterThan(1);
}

@Test
void chainExpirationDteRangeReturnsAllExpirationsWithinBounds() {
// A wide closed range spans multiple expirations, unlike no filter (front-month only); every
// returned contract's dte must also fall within the bounds, inclusive.
int max = 180;
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(DteFilter.range(0, max))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as(
"a wide closed range spans multiple expirations, unlike the single front-month"
+ " expiration an unfiltered call returns")
.isGreaterThan(1);
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
assertThat(q.dte())
.as("every contract's dte falls within the closed range")
.isBetween(0, max);
}
}

@Test
void chainExpirationDteComparisonIsInclusiveAtLowerBound() {
// Anchor N on an actual expiration's dte, discovered via the range form (not the unfiltered
// call) — then probe the comparison form's boundary against it: >=N must keep the expiration
// whose dte is exactly N, <N must exclude it.
List<OptionQuote> withinRange =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(DteFilter.range(0, 365))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
assertThat(withinRange).isNotEmpty();
int n =
withinRange.stream()
.map(OptionQuote::dte)
.filter(java.util.Objects::nonNull)
.max(Integer::compareTo)
.orElseThrow();

List<OptionQuote> atLeastN =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, n))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
assertThat(atLeastN)
.as(">=N keeps the expiration whose dte is exactly N")
.extracting(OptionQuote::dte)
.contains(n);
for (OptionQuote q : atLeastN) {
assertThat(q.dte()).isGreaterThanOrEqualTo(n);
}

List<OptionQuote> lessThanN =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(DteFilter.comparison(StrikeFilter.Operator.LT, n))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
assertThat(lessThanN)
.as("<N excludes the expiration whose dte is exactly N")
.extracting(OptionQuote::dte)
.doesNotContain(n);
// An empty answer, or the unfiltered single front-month expiration, would also pass
// doesNotContain(n) above without proving the server applied the filter — require a
// non-empty, multi-expiration result where every row is strictly below N.
assertThat(lessThanN).as("<N is not vacuously satisfied by an empty answer").isNotEmpty();
for (OptionQuote q : lessThanN) {
assertThat(q.dte()).isLessThan(n);
}
long lessThanDistinctExpirations =
lessThanN.stream().map(OptionQuote::expiration).distinct().count();
assertThat(lessThanDistinctExpirations)
.as(
"<N spans multiple expirations below N, unlike the single front-month expiration an"
+ " unfiltered call returns")
.isGreaterThan(1);
}

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
12 changes: 12 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,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 DteFilter.Values v) {
b.query("dte", joinDays(v.days()));
} else if (f instanceof DteFilter.Range v) {
b.query("dte", v.min() + "-" + v.max());
} else if (f instanceof DteFilter.Comparison 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 @@ -337,6 +345,10 @@ private static String strikeFilterWireValue(StrikeFilter f) {
return v.operator().wireValue() + formatStrike(v.price());
}

private static String joinDays(List<Integer> days) {
return days.stream().map(String::valueOf).collect(Collectors.joining(","));
}

private static String formatStrike(double v) {
if (v == Math.floor(v) && !Double.isInfinite(v)) {
return Long.toString((long) v);
Expand Down
Loading
Loading