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

- **`DteFilter` sealed type** (API 2.0.0) — multi-value days-to-expiration filtering for chain queries. Sealed record variants: `ValueList` (`list(int, int...)`), `Range` (`range(int, int)`), `Comparison` (`comparison(Operator, int)` where `Operator` = `GT`/`GTE`/`LT`/`LTE`). New `ExpirationFilter.dte(DteFilter)` overload; existing `dte(int)` single-value form unchanged. Constant `MAX_DAYS = 36500`. All values validated to `[0, 36500]`.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & 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,8 @@ 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.dte(DteFilter filter)) // days-to-expiration, list/range/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 +41,18 @@ OptionsChainRequest.builder(String symbol)
.build()
```

#### DteFilter

`ExpirationFilter.dte(DteFilter)` covers the `dte` query parameter's multi-value syntax — a sealed type alongside `ExpirationFilter.dte(int)`'s single-value form:

```java
DteFilter.list(int first, int... rest) // comma-separated list, e.g. ?dte=15,30,45
DteFilter.range(int min, int max) // closed, inclusive range, e.g. ?dte=0-45
DteFilter.comparison(DteFilter.Operator op, int days) // e.g. ?dte=>=30 — GT, GTE, LT, LTE
```

Every value, in every form, must be a whole number of days in `[0, 36500]`.

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

Expand All @@ -66,6 +79,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 +109,13 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// DteFilter: contracts between 30 and 60 days to expiry, inclusive.
var byDteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60)))
.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,17 @@ public static void main(String[] args) {
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations");

// ExpirationFilter.dte(...) also accepts the chain's full dte syntax via DteFilter: a
// comma-separated list, a closed range, or a comparison — not just a single value.
List<OptionQuote> byDteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(30, 60)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
System.out.println("dte in [30, 60]: " + byDteRange.size() + " contract(s)");

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 Down Expand Up @@ -133,6 +134,114 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteSingleValueNarrowsToFarDatedExpiration() {
// A single dte value narrows the chain to the expiration closest to it; a far-out value (180
// days) necessarily lands well past the API's front-month default, so a loose lower bound on
// the result proves the filter reached the server rather than being ignored.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(180))
.side(OptionSide.CALL)
.strikeRange(StrikeRange.ITM)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
assertThat(chain.get(0).dte()).isNotNull();
assertThat(chain.get(0).dte()).isGreaterThan(60);
}

@Test
void chainDteListSelectsMultipleDistinctExpirations() {
// A comma-separated dte list selects the closest expiration for each value, deduplicated
// server-side, so three requested values select at most three distinct expirations.
// Well-spaced values (7, 60, 180 days) land on different expiration cycles, so the result also
// spans more expirations than the same request without the filter — which returns only the
// single front-month expiration — proving the server honored the list rather than ignoring it.
List<OptionQuote> unfiltered =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
long unfilteredDistinctExpirations =
unfiltered.stream().map(OptionQuote::expiration).distinct().count();

List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.list(7, 60, 180)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("a comma-separated dte list selects at most one expiration per requested value")
.isLessThanOrEqualTo(3);
assertThat(distinctExpirations)
.as("the filtered result differs from the unfiltered chain's single front-month expiration")
.isGreaterThan(unfilteredDistinctExpirations);
}

@Test
void chainDteRangeReturnsExpirationsWithinInclusiveBounds() {
// range(60, 180) excludes AAPL's near-term weekly expirations; every returned row's dte must
// fall within the closed, inclusive bounds the API documents.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.range(60, 180)))
.side(OptionSide.CALL)
.strikeRange(StrikeRange.ITM)
.strikeLimit(1)
.build())
.values();

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

@Test
void chainDteComparisonReturnsExpirationsSatisfyingOperator() {
// dte >= 60 excludes AAPL's near-term weekly expirations; every returned row must satisfy the
// inclusive comparison the API documents.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 60)))
.side(OptionSide.CALL)
.strikeRange(StrikeRange.ITM)
.strikeLimit(1)
.build())
.values();

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

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
22 changes: 22 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 Down Expand Up @@ -312,6 +313,8 @@ 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.DteValues v) {
b.query("dte", dteFilterWireValue(v.filter()));
} 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 +340,25 @@ private static String strikeFilterWireValue(StrikeFilter f) {
return v.operator().wireValue() + formatStrike(v.price());
}

private static String dteFilterWireValue(DteFilter f) {
if (f instanceof DteFilter.ValueList v) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < v.days().size(); i++) {
if (i > 0) {
sb.append(',');
}
sb.append(v.days().get(i));
}
return sb.toString();
} else if (f instanceof DteFilter.Range v) {
return v.min() + "-" + v.max();
}
// DteFilter is sealed; the only remaining variant is Comparison. The cast documents that
// exhaustiveness (Java 17 can't prove it in an if-chain) and fails fast if a variant is added.
DteFilter.Comparison v = (DteFilter.Comparison) f;
return v.operator().wireValue() + v.days();
}

private static String formatStrike(double v) {
if (v == Math.floor(v) && !Double.isInfinite(v)) {
return Long.toString((long) v);
Expand Down
122 changes: 122 additions & 0 deletions src/main/java/com/marketdata/sdk/options/DteFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.marketdata.sdk.options;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
* The chain endpoint's {@code ?dte=} parameter accepts four syntactic forms: a single value
* (handled directly by {@link ExpirationFilter#dte(int)}), a comma-separated list, a closed range,
* and a comparison ({@code >=30}, {@code <45}, …). This sealed type covers the three multi-value
* forms — reach them through {@link ExpirationFilter#dte(DteFilter)}.
*
* <p>Modeling them as a sealed type (mirroring {@link StrikeFilter}) gives the consumer
* compile-time autocomplete for valid shapes and prevents a hand-assembled range or comparison
* string from shipping a typo to the server. Every value, in every form, is a whole number of days
* in {@code [0, 36500]} — the bound the API documents.
*/
public sealed interface DteFilter
permits DteFilter.ValueList, DteFilter.Range, DteFilter.Comparison {

/** The largest {@code dte} value the API accepts. */
int MAX_DAYS = 36500;

/**
* Comma-separated list of values — wire form {@code ?dte=15,30,45}. Selects the expiration
* closest to each value (deduplicated server-side). At least one value is required.
*/
static ValueList list(int first, int... rest) {
List<Integer> days = new ArrayList<>();
days.add(first);
for (int d : rest) {
days.add(d);
}
return new ValueList(days);
}

/**
* Closed, inclusive range — wire form {@code ?dte=0-45}. {@code min} must not exceed {@code max}.
*/
static Range range(int min, int max) {
return new Range(min, max);
}

/** Match {@code dte} values satisfying {@code operator days} (e.g. {@code >= 30}). */
static Comparison comparison(Operator operator, int days) {
return new Comparison(operator, days);
}

private static int validate(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
if (days > MAX_DAYS) {
throw new IllegalArgumentException("dte must be at most " + MAX_DAYS);
}
return days;
}

/** Comparison operators accepted by the API — same syntax as {@link StrikeFilter.Operator}. */
enum Operator {
GT(">"),
GTE(">="),
LT("<"),
LTE("<=");

private final String wireValue;

Operator(String wireValue) {
this.wireValue = wireValue;
}

/** The wire-form prefix the API expects, e.g. {@code ">="}. */
public String wireValue() {
return wireValue;
}
}

/**
* @param days at least one value, each in {@code [0, 36500]}. Immutable; never {@code null},
* empty, or containing a {@code null} element.
*/
record ValueList(List<Integer> days) implements DteFilter {
public ValueList {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("days must not be empty");
}
List<Integer> validated = new ArrayList<>(days.size());
for (Integer d : days) {
validated.add(validate(Objects.requireNonNull(d, "days element")));
}
days = List.copyOf(validated);
}
}

/**
* @param min lower bound of the closed range, inclusive, in {@code [0, 36500]}. Must not exceed
* {@code max}.
* @param max upper bound of the closed range, inclusive, in {@code [0, 36500]}. Must not be less
* than {@code min}.
*/
record Range(int min, int max) implements DteFilter {
public Range {
validate(min);
validate(max);
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
}
}

/**
* @param operator the comparison to apply against {@code days}; never {@code null}.
* @param days the threshold, in {@code [0, 36500]}.
*/
record Comparison(Operator operator, int days) implements DteFilter {
public Comparison {
Objects.requireNonNull(operator, "operator");
validate(days);
}
}
}
Loading
Loading