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

## [Unreleased]

### Added

- `ExpirationFilter.dte(int...)`, the comma-separated dte list wire form (API release 2.0.0).
`ExpirationFilter.dte(int)` works as before.
- `ExpirationFilter.dteRange(int, int)`, the closed-range dte wire form (API release 2.0.0).
- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)`, the comparison-operator dte wire form
(API release 2.0.0), reusing `StrikeFilter.Operator` syntax.
- `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and `ExpirationFilter.DteComparison`: new
permitted subtypes of the sealed `ExpirationFilter` interface, supporting the API's three new dte
forms (API release 2.0.0).

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & 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)) // dte: single value
.expirationFilter(ExpirationFilter.dte(int... days)) // dte: comma-separated list
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte: closed range
.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,8 @@ OptionsChainRequest.builder(String symbol)
.build()
```

`dte` has four wire forms (the API's `dte` parameter is string-typed as of v2.0.0): a single value, a comma-separated list (`dte(15, 30, 45)`, closest expiration per value, deduplicated), a closed range (`dteRange(0, 45)`, every expiration with `dte` in `[0, 45]`), and a comparison operator (`dteComparison(StrikeFilter.Operator.GTE, 30)`, syntax shared with `StrikeFilter`). Every value must be in `[0, 36500]`.

<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 closed range — every expiration between 0 and 45 days out.
var inRange = 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,18 @@ public static void main(String[] args) {
.values();
System.out.println("Filtered chain: " + chain.size() + " contracts");

// dte's wire parameter is string-typed as of v2.0.0: besides the single value above, it also
// accepts a comma-separated list, a closed range, and a comparison operator (syntax shared
// with StrikeFilter).
long inRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(30, 60))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("dteRange(30, 60) spans " + inRange + " distinct expirations");

// 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,6 +10,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 @@ -158,6 +159,202 @@ void chainDecodesOptionalRhoColumn() {
}
}

// ---------- chain: dte parameter forms (v2.0.0 — dte became a string parameter) ----------

@Test
void chainDteSingleValueMovesExpirationFurtherOutAsValueGrows() {
// dte's wire type changed from integer to string; the single-value form must still pick the
// expiration closest to the given value. Compared against another value of the same
// parameter (not the unfiltered default) so an ignored filter would fail this assertion.
int nearDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).dte());
int farDte = java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).dte());

assertThat(farDte)
.as("dte(180) must resolve to a later expiration than dte(7)")
.isGreaterThan(nearDte);
}

@Test
void chainDteListReturnsClosestExpirationPerValueDeduplicated() {
LocalDate nearExpiration =
java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(7)).expiration())
.toLocalDate();
LocalDate farExpiration =
java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(180)).expiration())
.toLocalDate();

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

java.util.Set<LocalDate> distinctExpirations =
combined.stream()
.map(q -> java.util.Objects.requireNonNull(q.expiration()).toLocalDate())
.collect(java.util.stream.Collectors.toSet());

assertThat(distinctExpirations)
.as("dte(7, 180) must select exactly the two closest expirations, one per value")
.containsExactlyInAnyOrder(nearExpiration, farExpiration);
}

@Test
void chainDteRangeDegenerateToSingleDayIncludesBothInclusiveBounds() {
// The anchor comes from a dte(...) call itself — not the unfiltered default — and is seeded
// far enough out (180 days) that an answer which merely echoed the unfiltered front-month
// default would fail the dte-equality assertion below, catching an ignored parameter.
int anchorDte = anchorDte(180);

List<OptionQuote> rows =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(anchorDte, anchorDte))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

assertThat(rows)
.as("a min==max range must still include the expiration at exactly that dte")
.isNotEmpty();
assertThat(rows).allSatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte));
}

@Test
void chainDteRangeSpansMultipleExpirationsUnlikeASingleValue() {
List<OptionQuote> ranged =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(0, 400))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

long distinctExpirations = ranged.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("a closed range must span every expiration within bounds, not just the closest one")
.isGreaterThan(1);
assertThat(ranged)
.as("every row's dte must fall within the inclusive [0, 400] bound")
.allSatisfy(q -> assertThat(q.dte()).isBetween(0, 400));
}

@Test
void chainDteComparisonGteIncludesTheBoundaryValue() {
// Seeded far out (180 days), like the range test above, so an ignored filter (which would
// echo the near-term unfiltered default) fails both assertions below.
int anchorDte = anchorDte(180);

List<OptionQuote> rows =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, anchorDte))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

assertThat(rows)
.as(">=N must keep the expiration at exactly N")
.anySatisfy(q -> assertThat(q.dte()).isEqualTo(anchorDte));
assertThat(rows)
.as("every row returned by >=N must satisfy dte >= N")
.allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(anchorDte));
long distinctExpirations = rows.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as(">=N must span every expiration at or beyond N, not just the closest one")
.isGreaterThan(1);
}

@Test
void chainDteComparisonGteExcludesExpirationsBelowTheBoundary() {
int anchorDte = anchorDte(7);
int threshold = anchorDte + 60;

List<OptionQuote> rows =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, threshold))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

assertThat(rows)
.as(">=N must exclude an expiration below N — proves the filter is not ignored")
.allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(threshold));
}

@Test
void chainDteComparisonLtExcludesTheBoundaryValue() {
int anchorDte = anchorDte(7);

List<OptionQuote> rows =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, anchorDte))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

assertThat(rows)
.as("<N must leave out the expiration at exactly N — proves the filter is not ignored")
.allSatisfy(q -> assertThat(q.dte()).isLessThan(anchorDte));
}

/** The nearest-the-money call under {@code filter}; asserts the chain isn't empty. */
private OptionQuote chainRowForDte(ExpirationFilter filter) {
List<OptionQuote> rows =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(filter)
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();
assertThat(rows).isNotEmpty();
return rows.get(0);
}

/**
* The live dte of the expiration closest to {@code seedDays} — built from a {@code dte(...)} call
* itself (the parameter under test), never from the unfiltered default, per the live-test rule
* that a baseline must exercise the parameter it's anchoring.
*/
private int anchorDte(int seedDays) {
return java.util.Objects.requireNonNull(chainRowForDte(ExpirationFilter.dte(seedDays)).dte());
}

@Test
void quoteCountbackBoundsHistoricalSeries() {
// countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has
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