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

## [Unreleased]

### Added

- `ExpirationFilter.dteList(int...)`, for comma-separated days-to-expiration selection (API 2.0.0).
- `ExpirationFilter.dteRange(int, int)`, for closed-range days-to-expiration selection (API 2.0.0).
- `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)`, for comparison-operator days-to-expiration selection, reusing `StrikeFilter.Operator` (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
20 changes: 19 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(int... days)) // comma-separated list, e.g. 15,30,45
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. 0-45
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator operator, 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 Expand Up @@ -95,6 +98,21 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// dte's other forms: a comma-separated list, a closed range, and a comparison operator — the
// comparison operator reuses StrikeFilter.Operator, since the syntax matches strike.
client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(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 @@ -51,6 +51,36 @@ public static void main(String[] args) {
.values().stream().map(OptionQuote::expiration).distinct().count();
System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations");

// dte's other forms: a comma-separated list, a closed range, and a comparison operator — the
// comparison operator reuses StrikeFilter.Operator, since the syntax matches strike.
long nearTerm = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(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 " + nearTerm + " expirations");

List<OptionQuote> withinRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
System.out.println("ExpirationFilter.dteRange(0, 45): " + withinRange.size() + " row(s)");

List<OptionQuote> atLeast30Days = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
System.out.println(
"ExpirationFilter.dteComparison(GTE, 30): " + atLeast30Days.size() + " row(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 @@ -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,220 @@
.isGreaterThan(1);
}

@Test
void chainDteSingleValueStillSelectsClosestExpiration() {
// API 2.0.0 changed dte's query parameter from integer to string; this regression test proves
// the already-existing single-value form behaves the same against the live API. Picking the
// front-of-board value would pass even if the server ignored dte entirely — an unfiltered
// chain also narrows to the front month (docs/options/chain.md) — so pick a value away from
// the front instead.
List<Integer> board = discoverDteBoard();
assertThat(board).as("AAPL has at least 2 distinct expirations").hasSizeGreaterThanOrEqualTo(2);
int n = board.get(board.size() - 1);

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

assertThat(single).isNotEmpty();
assertThat(single.get(0).dte()).isEqualTo(n);

Check warning on line 162 in src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java

View check run for this annotation

marketdata-code-review / Code review

DTE tests race the market clock

Each test discovers live integer DTE values and then makes a separate request that expects those exact values. If US/Eastern midnight occurs between the calls, the server's DTE values change and the suite can fail without a code regression. Make the assertions rollover-safe, for example by retaining and asserting stable expiration dates or using a repository clock helper.
}

@Test
void chainDteListReturnsClosestExpirationPerValue() {
// "A comma-separated list (15,30,45) selects the closest expiration for each value" — querying
// three real dte values (each an exact expiration on the live board) must return all three,
// since the closest expiration to an exact match is itself.
List<Integer> board = discoverDteBoard();
assertThat(board).as("AAPL has at least 3 distinct expirations").hasSizeGreaterThanOrEqualTo(3);
int a = board.get(0);
int b = board.get(board.size() / 2);
int c = board.get(board.size() - 1);

List<OptionQuote> listResult =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(a, b, c))
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();

List<Integer> returnedDtes = listResult.stream().map(OptionQuote::dte).toList();
assertThat(returnedDtes).contains(a, b, c);
}

@Test
void chainDteRangeIncludesInclusiveBoundaries() {
// "A closed range (0-45) ... returns every expiration whose dte falls within the bounds,
// inclusive." Use the live board's own min/max dte as the range bounds so both boundary values
// are guaranteed to exist as real expirations.
List<Integer> board = discoverDteBoard();
int min = board.get(0);
int max = board.get(board.size() - 1);

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

List<Integer> returnedDtes = inRange.stream().map(OptionQuote::dte).toList();
assertThat(returnedDtes).as("the lower bound is inclusive").contains(min);
assertThat(returnedDtes).as("the upper bound is inclusive").contains(max);
}

@Test
void chainDteRangeExcludesDteBelowRaisedLowerBound() {
// Narrowing the lower bound past the board's minimum dte must drop the expiration that used to
// sit exactly on it — proving the bound is honored, not ignored.
List<Integer> board = discoverDteBoard();
int min = board.get(0);
int max = board.get(board.size() - 1);

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

List<Integer> returnedDtes = narrowed.stream().map(OptionQuote::dte).toList();
assertThat(returnedDtes)
.as("raising the lower bound past the board minimum excludes that expiration")
.doesNotContain(min);
}

@Test
void chainDteComparisonGteIncludesExactBoundaryDte() {
// The comparison form reuses StrikeFilter.Operator ("the syntax matches strike"): >= keeps the
// row whose dte equals the bound. Using the board's front-month dte as the bound would pass
// even if the server ignored dte — the unfiltered front-month response also carries exactly
// that dte — so use a bound above the front of the board, and require every returned row to
// respect it (a front-month-only answer would carry a dte below the bound and fail the second
// assertion).
List<Integer> board = discoverDteBoard();
assertThat(board).as("AAPL has at least 2 distinct expirations").hasSizeGreaterThanOrEqualTo(2);
int bound = board.get(board.size() / 2);

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

Check failure on line 265 in src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java

View check run for this annotation

marketdata-code-review / Code review

Resolve DTE comparison boundary semantics

The tests and public documentation treat `>` and `<` as strict, while the cited API release language says comparison bounds are inclusive. This pins an unsettled API contract into the SDK. Confirm the intended semantics with the API team, then align the canonical documentation and tests.
.build())
.values();

List<Integer> dtes = gte.stream().map(OptionQuote::dte).toList();
assertThat(dtes).as(">= must keep the row whose dte equals the bound").contains(bound);
assertThat(dtes).as("every returned dte must be >= the bound").allMatch(d -> d >= bound);
}

@Test
void chainDteComparisonGtExcludesExactBoundaryDte() {
// > is strict, matching StrikeFilter's own ">" semantics ("the syntax matches strike"): it
// must drop the row whose dte exactly equals the bound, unlike >=.
int n = discoverDteBoard().get(0);

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

assertThat(gt.stream().map(OptionQuote::dte).toList())
.as("> must drop the row whose dte equals the bound")
.doesNotContain(n);
}

@Test
void chainDteComparisonLteIncludesExactBoundaryDte() {
List<Integer> board = discoverDteBoard();
int max = board.get(board.size() - 1);

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

assertThat(lte.stream().map(OptionQuote::dte).toList())
.as("<= must keep the row whose dte equals the bound")
.contains(max);
}

@Test
void chainDteComparisonLtExcludesExactBoundaryDte() {
// < is strict, matching StrikeFilter's own "<" semantics ("the syntax matches strike"): it
// must drop the row whose dte exactly equals the bound, unlike <=. Asserting only
// doesNotContain(max) would pass even if the server ignored dte — an unfiltered,
// front-month-only response also excludes max (since max differs from the front) — so also
// require every returned dte to sit below the bound and more than one distinct expiration to
// come back, which a front-month-only answer could not satisfy.
List<Integer> board = discoverDteBoard();
assertThat(board).as("AAPL has at least 3 distinct expirations").hasSizeGreaterThanOrEqualTo(3);
int max = board.get(board.size() - 1);

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

List<Integer> dtes = lt.stream().map(OptionQuote::dte).toList();
assertThat(dtes).as("< must drop the row whose dte equals the bound").doesNotContain(max);
assertThat(dtes).as("every returned dte must be < the bound").allMatch(d -> d < max);
assertThat(dtes.stream().distinct().count())
.as("the filter must span more than the front month alone")
.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 Expand Up @@ -219,6 +435,30 @@
assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second);
}

/**
* Fetch the live, sorted, distinct set of {@code dte} values across every AAPL call expiration —
* used by the {@code dte}-filter tests to derive real boundary values instead of guessing at
* today's market calendar. {@code expiration=all} spans the whole board; {@code strikeLimit(1)}
* keeps the payload to one row per expiration.
*/
private List<Integer> discoverDteBoard() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.all())
.side(OptionSide.CALL)
.strikeLimit(1)
.strikeRange(StrikeRange.ITM)
.build())
.values();
List<Integer> dtes =
chain.stream().map(OptionQuote::dte).filter(Objects::nonNull).distinct().sorted().toList();
assertThat(dtes).as("AAPL has at least one live option expiration").isNotEmpty();
return dtes;
}

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