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

## [Unreleased]

### Added

- `ExpirationFilter.dteList(List<Integer>)` — comma-separated days-to-expiration
list; selects the expiration nearest each value (API 2.0.0). `ExpirationFilter.dte(int)`
continues to work as before.
- `ExpirationFilter.dteRange(int, int)` — closed-range days-to-expiration filter
(API 2.0.0). Returns every expiration whose DTE falls within `[min, max]`
inclusive.
- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` —
comparison-operator days-to-expiration filter (API 2.0.0). Returns every
expiration satisfying the condition (e.g., `>= 30`).

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
34 changes: 33 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)) // a single dte value
.expirationFilter(ExpirationFilter.dteList(List<Integer> days)) // comma-separated dte values
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed dte range, inclusive
.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 All @@ -40,6 +43,21 @@ OptionsChainRequest.builder(String symbol)
.build()
```

`dte` itself accepts four forms — a single value, a comma-separated list, a closed range, or a
comparison — the same range/comparison syntax `StrikeFilter` uses for `strike`:

```java
ExpirationFilter.dte(30) // ?dte=30
ExpirationFilter.dteList(List.of(15, 30, 45)) // ?dte=15,30,45
ExpirationFilter.dteRange(0, 45) // ?dte=0-45
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30) // ?dte=>=30
```

Every `dte` value accepted by the API is a non-negative whole number of days, at most 36500. The
SDK enforces that upper bound client-side on the three new forms (`dteList`/`dteRange`/
`dteComparison`); the original single-value `dte(int)` predates the bound and still lets an
out-of-range value reach the API rather than rejecting it locally.

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

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

// dte's own sub-syntax: list, closed range, and comparison, matching StrikeFilter's syntax.
client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(List.of(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,24 @@ public static void main(String[] args) {
.values();
System.out.println("Filtered chain: " + chain.size() + " contracts");

// dte's own sub-syntax: a comma-separated list, a closed range, or a comparison — the same
// range/comparison syntax StrikeFilter uses for strike.
long dteFormVariants = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.dteList(15, 30, 45) spans " + dteFormVariants
+ " distinct expirations");
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());

// 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,12 @@
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.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 +135,151 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteSingleValueSelectsExactExpirationWhenAvailable() {
// Pick the furthest real expiration (far from the front-month the unfiltered call would
// return) and request it by its own dte: the "closest expiration" to a value that is itself
// a real expiration is that expiration exactly, with zero distance.
List<Integer> liveDtes = liveDteValues();
assertThat(liveDtes)
.as("AAPL has at least two live expirations")
.hasSizeGreaterThanOrEqualTo(2);
int farthest = liveDtes.get(liveDtes.size() - 1);

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

assertThat(chain).isNotEmpty();
assertThat(chain.stream().map(OptionQuote::dte).distinct().toList())
.as(
"dte=%d selects exactly the expiration %d days out, not the front month",
farthest, farthest)
.containsExactly(farthest);
}

@Test
void chainDteListSelectsClosestExpirationForEachValue() {
// Three real, widely-spaced expirations: requesting them by dte should return all three
// distinct expirations, which the unfiltered (front-month-only) answer never does.
List<Integer> liveDtes = liveDteValues();
assertThat(liveDtes)
.as("AAPL has at least three live expirations")
.hasSizeGreaterThanOrEqualTo(3);
int low = liveDtes.get(0);
int mid = liveDtes.get(liveDtes.size() / 2);
int high = liveDtes.get(liveDtes.size() - 1);

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

assertThat(chain.stream().map(OptionQuote::dte).distinct().toList())
.as(
"dte=%d,%d,%d selects all three expirations, unlike the single front-month default",
low, mid, high)
.containsExactlyInAnyOrder(low, mid, high);
}

@Test
void chainDteRangeIncludesEveryExpirationWithinBoundsInclusive() {
// A closed range starting after the front month: every expiration from the second-smallest
// live dte through the largest must come back, and the front month (the unfiltered answer)
// must be excluded — proving both the inclusive bounds and that the filter actually narrowed
// the result away from the default.
List<Integer> liveDtes = liveDteValues();
assertThat(liveDtes)
.as("AAPL has at least three live expirations")
.hasSizeGreaterThanOrEqualTo(3);
int frontMonth = liveDtes.get(0);
int low = liveDtes.get(1);
int high = liveDtes.get(liveDtes.size() - 1);
List<Integer> expectedInRange = liveDtes.stream().filter(d -> d >= low && d <= high).toList();

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

List<Integer> distinctDtes = chain.stream().map(OptionQuote::dte).distinct().sorted().toList();
assertThat(distinctDtes)
.as(
"dte=%d-%d keeps every expiration in [%d, %d] inclusive, including both bounds",
low, high, low, high)
.containsExactlyInAnyOrderElementsOf(expectedInRange)
.contains(low, high);
assertThat(distinctDtes)
.as("the range excludes the front month %d", frontMonth)
.doesNotContain(frontMonth);
}

@Test
void chainDteComparisonGteIncludesBoundaryLtExcludesIt() {
// Anchor on the furthest real expiration M: ">=M" must keep exactly M (nothing exceeds the
// maximum, so the bound is kept), while "<M" must return more than one expiration and never
// include M itself — demonstrating the inclusive/exclusive boundary on real data rather than
// on the unfiltered (single front-month) answer, which satisfies neither assertion.
List<Integer> liveDtes = liveDteValues();
assertThat(liveDtes)
.as("AAPL has at least three live expirations")
.hasSizeGreaterThanOrEqualTo(3);
int anchor = liveDtes.get(liveDtes.size() - 1);

List<OptionQuote> gteChain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchor))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
assertThat(gteChain.stream().map(OptionQuote::dte).distinct().toList())
.as("dte>=%d keeps exactly the boundary expiration %d", anchor, anchor)
.containsExactly(anchor);

List<OptionQuote> ltChain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchor))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
List<Integer> ltDtes = ltChain.stream().map(OptionQuote::dte).distinct().toList();
assertThat(ltDtes)
.as(
"dte<%d excludes the boundary expiration %d and returns more than the front month",
anchor, anchor)
.doesNotContain(anchor)
.hasSizeGreaterThan(1);
}

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down Expand Up @@ -219,6 +366,30 @@ void quotesFansOutToMultipleContracts() {
assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second);
}

/**
* Every distinct dte (days-to-expiration) across AAPL's full expiration calendar today, sorted
* ascending — used as real anchors for the dte filter tests above instead of values guessed from
* today's date, which expirations drift around daily.
*/
private List<Integer> liveDteValues() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.all())
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
return chain.stream()
.map(OptionQuote::dte)
.filter(Objects::nonNull)
.distinct()
.sorted()
.toList();
}

/** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */
private String sampleOptionSymbol() {
List<OptionQuote> chain =
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