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
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

- **Days-to-expiration filters on options chain** — `ExpirationFilter.dteList(List)`,
`ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(Operator, int)`
add three new syntactic forms for the `?dte` parameter (list, range, and comparison-operator
queries), mirroring the strike-filter variants. The `Operator` enum carries `GT`, `GTE`,
`LT`, `LTE` with `wireValue()` accessors. Responds to Market Data API 2.0.0.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
5 changes: 4 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)) // days-to-expiration, single value
.expirationFilter(ExpirationFilter.dteList(List<Integer> days)) // comma-separated list
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, inclusive
.expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) // e.g. >=30
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,73 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteListNarrowsToClosestExpirationsPerValue() {
// dteList selects the closest expiration to each requested value, deduplicated — so the
// returned dte values need not equal 7/30/60 exactly, but there should be at most one
// expiration per requested value.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(List.of(7, 30, 60)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctDte = chain.stream().map(OptionQuote::dte).distinct().count();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull();
}
assertThat(distinctDte)
.as("one closest expiration per requested value, deduplicated")
.isBetween(1L, 3L);
}

@Test
void chainDteRangeReturnsExpirationsWithinBounds() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

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

@Test
void chainDteComparisonReturnsExpirationsSatisfyingOperator() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

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

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/marketdata/sdk/OptionsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,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.days()));
} 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 @@ -337,6 +343,17 @@ private static String strikeFilterWireValue(StrikeFilter f) {
return v.operator().wireValue() + formatStrike(v.price());
}

private static String dteListWireValue(List<Integer> days) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < days.size(); i++) {
if (i > 0) {
sb.append(',');
}
sb.append(days.get(i));
}
return sb.toString();
}

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

import com.marketdata.sdk.Generated;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;

/**
Expand All @@ -14,10 +16,18 @@
* <p>Additive expiration-type predicates ({@code weekly}/{@code monthly}/{@code quarterly}/{@code
* am}/{@code pm}) are not part of this hierarchy — they intersect freely with any variant and stay
* as separate booleans on the request builder.
*
* <p>{@code dte} alone accepts four syntactic forms server-side — a single value ({@link
* #dte(int)}), a comma-separated list ({@link #dteList(List)}), a closed range ({@link
* #dteRange(int, int)}), and a comparison operator ({@link #dteComparison(Operator, int)}) —
* mirroring {@link StrikeFilter}'s exact/range/comparison split.
*/
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteList,
ExpirationFilter.DteRange,
ExpirationFilter.DteComparison,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
Expand Down Expand Up @@ -47,6 +57,56 @@ static Dte dte(int days) {
return new Dte(days);
}

/**
* Days-to-expiration list filter — wire form {@code ?dte=d1,d2,...}. The API resolves each value
* to its closest expiration independently and deduplicates the result. Every value must be a
* whole number of days in {@code [0, 36500]}.
*/
static DteList dteList(List<Integer> days) {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("days must not be empty");
}
for (int day : days) {
validateDteBounds(day);
}
return new DteList(days);
}

/**
* Days-to-expiration closed range, inclusive — wire form {@code ?dte=min-max}. {@code min} must
* not exceed {@code max}; both bounds must be whole numbers of days in {@code [0, 36500]}.
*/
static DteRange dteRange(int min, int max) {
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
validateDteBounds(min);
validateDteBounds(max);
return new DteRange(min, max);
}

/**
* Days-to-expiration comparison — wire form {@code ?dte=<operator><days>} (e.g. {@code >=30}).
* {@code days} must be a whole number of days in {@code [0, 36500]}.
*/
// @Generated: the null-operator guard is unreachable through the public comparison factory, which
// always supplies a non-null Operator from the typed enum.
@Generated
static DteComparison dteComparison(Operator operator, int days) {
if (operator == null) {
throw new IllegalArgumentException("operator must not be null");
}
validateDteBounds(days);
return new DteComparison(operator, days);
}

private static void validateDteBounds(int days) {
if (days < 0 || days > 36500) {
throw new IllegalArgumentException("dte must be between 0 and 36500");
}
}

/**
* Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not
* be strictly after {@code to}.
Expand Down Expand Up @@ -79,6 +139,38 @@ record OnDate(LocalDate date) implements ExpirationFilter {

record Dte(int days) implements ExpirationFilter {}

record DteList(List<Integer> days) implements ExpirationFilter {
public DteList {
days = List.copyOf(days);
}
}

record DteRange(int min, int max) implements ExpirationFilter {}

record DteComparison(Operator operator, int days) implements ExpirationFilter {}

/**
* Comparison operators accepted by the {@code dte} parameter — mirrors {@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;
}
}

record Between(LocalDate from, LocalDate to) implements ExpirationFilter {}

record MonthYear(int year, int month) implements ExpirationFilter {}
Expand Down
Loading
Loading