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

## [Unreleased]

### Added

- `ExpirationFilter.dteList(int...)`, `ExpirationFilter.dteRange(int, int)`, and
`ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` — factory methods
for `/v1/options/chain/` `dte` filtering: comma-separated list of days, closed
inclusive range, and comparison operators (≥, >, ≤, <) (API release 2.0.0).
Sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and
`ExpirationFilter.DteComparison` implement these forms. `ExpirationFilter.dte(int)`
unchanged.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
21 changes: 20 additions & 1 deletion docs/options/chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ 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
.expirationFilter(ExpirationFilter.dteList(int... days)) // e.g. 15,30,45
.expirationFilter(ExpirationFilter.dteRange(int minDays, int maxDays)) // closed range, e.g. 0-45
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.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)

// Strike selection (sealed StrikeFilter — pick one):
.strikeFilter(StrikeFilter.exact(double price))
.strikeFilter(StrikeFilter.range(double min, double max))
.strikeFilter(StrikeFilter.comparison(StrikeFilter.Operator op, double price))

// Additive filters:
.side(OptionSide.CALL) // CALL or PUT
Expand Down Expand Up @@ -95,6 +99,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 — the same
// syntax as strike's range()/comparison().
var dteList = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(15, 30, 45))
.build());
var dteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.build());
var dteComparison = 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,17 @@ 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 — use the
// same syntax as StrikeFilter.range()/comparison().
long beyond180 = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 180))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values().size();
System.out.println("dteComparison(GTE, 180) matched " + beyond180 + " 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 @@ -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 @@ -133,6 +134,194 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

// ---------- chain: dte query param (integer -> string) ----------

@Test
void chainDteSingleValueSelectsOneExpirationFarFromFrontMonth() {
// A far-dated single dte value selects the one expiration closest to it — a window chosen
// well past where the front-month (a bare, unfiltered chain call's default) sits, so the
// filter is provably doing the selecting rather than just reproducing the default.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(300))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("a single dte value selects exactly one expiration")
.isEqualTo(1);
assertThat(chain.get(0).dte())
.as("the selected expiration lands well past the front month a bare call would return")
.isGreaterThan(200);
}

@Test
void chainDteListSelectsOneExpirationPerValue() {
// A comma-separated dte list spans several widely-separated windows; a bare chain call
// returns only the single front-month expiration, so more than one distinct expiration here
// proves the list reached the server rather than being collapsed to one value.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteList(30, 120, 300))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
// 30/120/300 are far enough apart that dedup can't collapse them — the API documents one
// closest expiration selected per value, so this must land on exactly three, not merely
// "more than the one expiration a bare call returns" (which a range or single-value misread
// of the list would also satisfy).
assertThat(distinctExpirations)
.as("a comma-separated dte list selects the closest expiration for each of the 3 values")
.isEqualTo(3);
}

@Test
void chainDteRangeReturnsEveryExpirationWithinBounds() {
// A closed dte range should return every expiration inside it (inclusive), not just the
// closest one — AAPL's weekly cycle guarantees more than one expiration lands in a 90-day
// window that starts well past the front month.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(60, 150))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
assertThat(chain)
.as("every row's dte falls within the closed, inclusive range")
.allSatisfy(q -> assertThat(q.dte()).isBetween(60, 150));
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("the range spans more than the single front-month expiration a bare call returns")
.isGreaterThan(1);
}

@Test
void chainDteComparisonGteExcludesTheFrontMonth() {
// >=180 excludes the near-term front-month expiration a bare call returns — every row must
// sit at or beyond the bound.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 180))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
assertThat(chain)
.as(">=180 keeps only expirations at or beyond the bound")
.allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(180));
// A single closest-to-180 expiration would also satisfy the per-row bound above — the
// documented comparison-form behavior ("every expiration whose dte falls within the bounds")
// is only distinguished from the single-value form by spanning more than one expiration.
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as(">=180 spans more than the single front-month expiration a bare call returns")
.isGreaterThan(1);
}

@Test
void chainDteComparisonLtSpansMultipleExpirationsUnderBound() {
// <365 stays under the bound on every row, but spans many expirations — unlike the single
// front-month expiration a bare call returns.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 365))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(chain).isNotEmpty();
assertThat(chain)
.as("every row stays under the bound")
.allSatisfy(q -> assertThat(q.dte()).isLessThan(365));
long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count();
assertThat(distinctExpirations)
.as("<365 spans many expirations, unlike the single front-month a bare call returns")
.isGreaterThan(1);
}

@Test
void chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt() {
// Discover a real dte value via the range filter (a call made with the parameter, not the
// unfiltered chain), then prove the inclusive/exclusive boundary the comparison operators
// claim: the exact same dte is kept by >=N and left out by <N.
List<OptionQuote> withinRange =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dteRange(60, 150))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
assertThat(withinRange).isNotEmpty();
int boundaryDte = withinRange.stream().mapToInt(OptionQuote::dte).min().orElseThrow();

List<OptionQuote> atOrAfter =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, boundaryDte))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();
List<OptionQuote> strictlyBefore =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, boundaryDte))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

assertThat(atOrAfter)
.as(">=N keeps the expiration at exactly N")
.anySatisfy(q -> assertThat(q.dte()).isEqualTo(boundaryDte));
// noneSatisfy below passes vacuously on an empty list — assert there's actually something
// returned for <N (the near-term/front-month expirations below the [60,150] window) before
// checking that none of them is the boundary itself.
assertThat(strictlyBefore).as("<N returns the expirations below the boundary").isNotEmpty();
assertThat(strictlyBefore)
.as("<N excludes the expiration at exactly N")
.noneSatisfy(q -> assertThat(q.dte()).isEqualTo(boundaryDte));
}

@Test
void chainDecodesOptionalRhoColumn() {
// rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/com/marketdata/sdk/FundsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,9 @@ public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOExcepti

/**
* Option A: for every required column the consumer asked for (explicitly via {@code columns}, or
* implicitly by not projecting at all), verify the API actually returned it. A requested-but-
* absent required column throws, so a {@code null} a consumer sees only ever means "I projected
* it away" — never "the backend silently dropped it".
* implicitly by not projecting at all), verify the API actually returned it. A
* requested-but-absent required column throws, so a {@code null} a consumer sees only ever means
* "I projected it away" — never "the backend silently dropped it".
*/
private static void validateRequestedColumns(
JsonParser p,
Expand Down
76 changes: 34 additions & 42 deletions src/main/java/com/marketdata/sdk/HttpDispatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,11 @@ CompletableFuture<HttpResponse<byte[]>> dispatch(HttpRequest request) {
// permit by completing the waiter, but thenCompose's function wouldn't run (its dependent
// is already cancelled), and send — which registers whenComplete(release) — would never
// fire. Cancelling `permit` here makes AsyncSemaphore.release skip the waiter.
dispatched.whenComplete(
(r, t) -> {
if (t instanceof CancellationException) {
permit.cancel(false);
}
});
dispatched.whenComplete((r, t) -> {
if (t instanceof CancellationException) {
permit.cancel(false);
}
});

return dispatched;
}
Expand All @@ -84,12 +83,8 @@ private CompletableFuture<HttpResponse<byte[]>> send(HttpRequest request) {
// never formed, so the whenComplete below would never fire — release the permit here
// to prevent a permanent leak that would degrade the pool to deadlock.
permits.release();
LOGGER.warning(
() ->
"Request to "
+ safeUri(request.uri())
+ " failed before dispatch: "
+ t.getMessage());
LOGGER.warning(() ->
"Request to " + safeUri(request.uri()) + " failed before dispatch: " + t.getMessage());
if (t instanceof Error err) {
throw err;
}
Expand All @@ -102,36 +97,33 @@ private CompletableFuture<HttpResponse<byte[]>> send(HttpRequest request) {

return sendFuture
.whenComplete((r, t) -> permits.release())
.handle(
(response, error) -> {
long elapsedMs = Duration.between(start, clock.instant()).toMillis();
if (error != null) {
Throwable root = unwrap(error);
LOGGER.warning(
() ->
"Request to "
+ safeUri(request.uri())
+ " failed after "
+ elapsedMs
+ "ms: "
+ root.getMessage());
throw new CompletionException(
new NetworkError(
"Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(),
ErrorContext.forNoResponse(request.uri().toString(), clock.instant()),
root));
}
LOGGER.fine(
() ->
"Response "
+ response.statusCode()
+ " from "
+ safeUri(request.uri())
+ " in "
+ elapsedMs
+ "ms");
return response;
});
.handle((response, error) -> {
long elapsedMs = Duration.between(start, clock.instant()).toMillis();
if (error != null) {
Throwable root = unwrap(error);
LOGGER.warning(() ->
"Request to "
+ safeUri(request.uri())
+ " failed after "
+ elapsedMs
+ "ms: "
+ root.getMessage());
throw new CompletionException(
new NetworkError(
"Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(),
ErrorContext.forNoResponse(request.uri().toString(), clock.instant()),
root));
}
LOGGER.fine(() ->
"Response "
+ response.statusCode()
+ " from "
+ safeUri(request.uri())
+ " in "
+ elapsedMs
+ "ms");
return response;
});
}

/**
Expand Down
Loading
Loading