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
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)) // dte: 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,8 @@ OptionsChainRequest.builder(String symbol)
.build()
```

`DteFilter` itself is a sealed type with four forms — `DteFilter.exact(int days)`, `DteFilter.values(int... days)` (comma-separated, deduplicated server-side), `DteFilter.range(int min, int max)` (closed range), and `DteFilter.comparison(DteFilter.Operator op, int days)` (`>`, `>=`, `<`, `<=`) — the same syntax the API uses for `strike`.

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

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

// DteFilter: every expiration between 0 and 45 days out.
var byDteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45)))
.side(OptionSide.CALL)
.build());
}
```

### Kotlin

```kotlin
import com.marketdata.sdk.MarketDataClient
import com.marketdata.sdk.options.DteFilter
import com.marketdata.sdk.options.ExpirationFilter
import com.marketdata.sdk.options.OptionSide
import com.marketdata.sdk.options.OptionsChainRequest

Expand All @@ -116,5 +129,13 @@ MarketDataClient().use { client ->
for (c in chain) {
println("${c.optionSymbol()} strike=${c.strike()} delta=${c.delta()}")
}

// DteFilter: every expiration 30 days out or further.
val byDte = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30)))
.side(OptionSide.CALL)
.build())
.values()
}
```
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,84 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteFilterExactReturnsSingleClosestExpiration() {
// A single dte value narrows to the one expiration closest to it — the distinguishing
// property versus the list/range/comparison forms below.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.exact(30)))
.side(OptionSide.CALL)
.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);
}

@Test
void chainDteFilterValuesReturnsAtMostOneExpirationPerRequestedValue() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

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

@Test
void chainDteFilterRangeReturnsExpirationsWithinBounds() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45)))
.side(OptionSide.CALL)
.build())
.values();

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

@Test
void chainDteFilterComparisonReturnsExpirationsAboveThreshold() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30)))
.side(OptionSide.CALL)
.build())
.values();

assertThat(chain).isNotEmpty();
for (OptionQuote q : chain) {
assertThat(q.dte()).isNotNull().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
18 changes: 18 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 All @@ -28,6 +29,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 +314,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.DteExpression 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 @@ -325,6 +329,20 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte
// an if-chain, but there is no reachable else, so no defensive throw is needed.
}

private static String dteFilterWireValue(DteFilter f) {
if (f instanceof DteFilter.Exact v) {
return Integer.toString(v.days());
} else if (f instanceof DteFilter.Values v) {
return v.days().stream().map(String::valueOf).collect(Collectors.joining(","));
} 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 strikeFilterWireValue(StrikeFilter f) {
if (f instanceof StrikeFilter.Exact v) {
return formatStrike(v.price());
Expand Down
108 changes: 108 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,108 @@
package com.marketdata.sdk.options;

import com.marketdata.sdk.Generated;
import java.util.ArrayList;
import java.util.List;

/**
* The chain endpoint's {@code ?dte=} parameter accepts four syntactic forms — a single value, a
* comma-separated list, a closed range, and a comparison — the same syntax the API uses for {@code
* strike} (see {@link StrikeFilter}). Modeling them as a sealed type with factory entry-points
* gives the consumer compile-time autocomplete for valid shapes and prevents typos a raw-string
* passthrough would silently ship to the server.
*
* <p>Reached through {@link ExpirationFilter#dte(DteFilter)}, a new overload sitting alongside the
* pre-existing {@link ExpirationFilter#dte(int)} (single-value only) — the older overload is left
* unchanged so existing callers keep compiling.
*/
public sealed interface DteFilter
permits DteFilter.Exact, DteFilter.Values, DteFilter.Range, DteFilter.Comparison {

/** Days of expiry, whole numbers, per the API's stated bound. */
int MIN_DAYS = 0;

int MAX_DAYS = 36500;

/** Match the expiration closest to {@code days}. */
static Exact exact(int days) {
validate(days);
return new Exact(days);
}

/**
* Match the expiration closest to each value in {@code days}, deduplicated server-side. {@code
* days} must not be empty.
*/
static Values values(int... days) {
List<Integer> boxed = new ArrayList<>(days.length);
for (int d : days) {
boxed.add(d);
}
return new Values(boxed);
}

/** Match every expiration whose dte falls in {@code [min, max]} inclusive. */
static Range range(int min, int max) {
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
validate(min);
validate(max);
return new Range(min, max);
}

/** Match every expiration satisfying {@code operator days} (e.g. {@code >= 30}). */
// @Generated: the null-operator guard is unreachable through the public comparison factories,
// which always supply a non-null Operator from the typed enum.
@Generated
static Comparison comparison(Operator operator, int days) {
if (operator == null) {
throw new IllegalArgumentException("operator must not be null");
}
validate(days);
return new Comparison(operator, days);
}

private static void validate(int days) {
if (days < MIN_DAYS || days > MAX_DAYS) {
throw new IllegalArgumentException("days must be in " + MIN_DAYS + ".." + MAX_DAYS);
}
}

/** Comparison operators accepted by the API — the same vocabulary as {@link StrikeFilter}. */
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 Exact(int days) implements DteFilter {}

record Values(List<Integer> days) implements DteFilter {
public Values {
days = List.copyOf(days);
if (days.isEmpty()) {
throw new IllegalArgumentException("values must not be empty");
}
for (int d : days) {
validate(d);
}
}
}

record Range(int min, int max) implements DteFilter {}

record Comparison(Operator operator, int days) implements DteFilter {}
}
13 changes: 13 additions & 0 deletions src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteExpression,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
Expand Down Expand Up @@ -47,6 +48,15 @@ static Dte dte(int days) {
return new Dte(days);
}

/**
* Days-to-expiration filter using the richer {@link DteFilter} syntax (comma-separated list,
* closed range, or comparison operator), alongside the single value {@link #dte(int)} already
* covers.
*/
static DteExpression dte(DteFilter filter) {
return new DteExpression(Objects.requireNonNull(filter, "filter"));
}

/**
* 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 +89,9 @@ record OnDate(LocalDate date) implements ExpirationFilter {

record Dte(int days) implements ExpirationFilter {}

/** Backs {@link #dte(DteFilter)} — the list/range/comparison forms of the dte parameter. */
record DteExpression(DteFilter filter) implements ExpirationFilter {}

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

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