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
13 changes: 12 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.dte(DteFilter.values(int first, int... rest))) // comma-separated list
.expirationFilter(ExpirationFilter.dte(DteFilter.range(int min, int max))) // closed range, inclusive
.expirationFilter(ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator op, int days))) // >, >=, <, <=
.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 @@ -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,6 +99,13 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

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

Expand Down
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,67 @@ void chainExpirationAllSpansMultipleExpirations() {
.isGreaterThan(1);
}

@Test
void chainDteValuesReturnsClosestExpirationPerValueDeduplicated() {
// Each requested value maps to the closest available expiration, deduplicated — so the
// distinct expiration count is at most the number of requested values.
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(ExpirationFilter.dte(DteFilter.values(7, 14, 30)))
.side(OptionSide.CALL)
.strikeLimit(1)
.build())
.values();

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

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

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

@Test
void chainDteComparisonReturnsExpirationsSatisfyingOperator() {
List<OptionQuote> chain =
client
.options()
.chain(
OptionsChainRequest.builder(UNDERLYING)
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30)))
.side(OptionSide.CALL)
.strikeLimit(1)
.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
8 changes: 8 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 @@ -320,6 +322,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte
b.query("year", v.year());
} else if (f instanceof ExpirationFilter.All) {
b.query("expiration", "all");
} else if (f instanceof DteFilter.Values v) {
b.query("dte", v.days().stream().map(String::valueOf).collect(Collectors.joining(",")));
} else if (f instanceof DteFilter.Range v) {
b.query("dte", v.min() + "-" + v.max());
} else if (f instanceof DteFilter.Comparison v) {
b.query("dte", v.operator().wireValue() + v.days());
}
// ExpirationFilter is sealed and every variant is handled above; Java 17 can't prove that in
// an if-chain, but there is no reachable else, so no defensive throw is needed.
Expand Down
87 changes: 87 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,87 @@
package com.marketdata.sdk.options;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
* Richer forms of the chain endpoint's {@code ?dte=} parameter, beyond the single value already
* covered by {@link ExpirationFilter#dte(int)}: a comma-separated list, a closed range, and a
* comparison operator — the same three syntactic shapes {@link StrikeFilter} models for {@code
* ?strike=}. {@code DteFilter} extends {@link ExpirationFilter} directly (rather than wrapping it)
* so every variant can be passed straight to {@code expirationFilter(...)}, preserving the "pick
* one variant" compile-time exclusivity ADR-008 established.
*
* <p>{@link ExpirationFilter#dte(DteFilter)} is a pass-through overload kept for discoverability
* under the existing {@code dte(...)} name.
*/
public sealed interface DteFilter extends ExpirationFilter
permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison {

/** Upper bound the API documents for a single {@code dte} value, inclusive. */
int MAX_DAYS = 36500;

/**
* Comma-separated list — wire form {@code ?dte=15,30,45}. The API selects the closest expiration
* for each value, deduplicated.
*/
static Values values(int first, int... rest) {
List<Integer> days = new ArrayList<>();
days.add(first);
for (int day : rest) {
days.add(day);
}
for (int day : days) {
requireValidDays(day);
}
return new Values(List.copyOf(days));
}

/** Closed range — wire form {@code ?dte=min-max}, inclusive of both bounds. */
static Range range(int min, int max) {
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
requireValidDays(min);
requireValidDays(max);
return new Range(min, max);
}

/** Comparison operator — wire form e.g. {@code ?dte=>=30}. */
static Comparison comparison(Operator operator, int days) {
Objects.requireNonNull(operator, "operator");
requireValidDays(days);
return new Comparison(operator, days);
}

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

/** Comparison operators accepted by the API — same four as {@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 Values(List<Integer> days) implements DteFilter {}

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

record Comparison(Operator operator, int days) implements DteFilter {}
}
12 changes: 11 additions & 1 deletion src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public sealed interface ExpirationFilter
ExpirationFilter.Dte,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
ExpirationFilter.All,
DteFilter {

/** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */
static OnDate onDate(LocalDate date) {
Expand All @@ -47,6 +48,15 @@ static Dte dte(int days) {
return new Dte(days);
}

/**
* Days-to-expiration filter, comma-list / range / comparison forms — wire forms {@code
* ?dte=15,30,45}, {@code ?dte=0-45}, {@code ?dte=>=30}. Pass-through kept under the {@code
* dte(...)} name for discoverability; see {@link DteFilter} for the factories.
*/
static DteFilter dte(DteFilter filter) {
return 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
96 changes: 96 additions & 0 deletions src/test/java/com/marketdata/sdk/OptionsResourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.marketdata.sdk.exception.ParseError;
import com.marketdata.sdk.options.DteFilter;
import com.marketdata.sdk.options.ExpirationFilter;
import com.marketdata.sdk.options.Greek;
import com.marketdata.sdk.options.OptionQuote;
Expand Down Expand Up @@ -975,6 +976,73 @@ void chainExpirationFilterDteTranslatesToDteParam() {
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
}

@Test
void chainExpirationFilterDteValuesTranslatesToCommaSeparatedDte() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.values(15, 30, 45)))
.build())
.join();

// The comma is URL-encoded on the wire, so decode before asserting (same convention as the
// columns param).
String decodedUri =
java.net.URLDecoder.decode(
client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8);
assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=15,30,45");
}

@Test
void chainExpirationFilterDteRangeTranslatesToDashedDte() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45)))
.build())
.join();

assertThat(client.captured.get(0).uri().toString())
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45");
}

@Test
void chainExpirationFilterDteComparisonTranslatesToOperatorPrefixedDte() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(
ExpirationFilter.dte(DteFilter.comparison(DteFilter.Operator.GTE, 30)))
.build())
.join();

String url = client.captured.get(0).uri().toString();
// "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through.
assertThat(url).contains("dte=%3E%3D30");
}

@Test
void chainExpirationFilterDteRangeSyncMatchesAsyncWire() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);

resourceWith(client)
.chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dte(DteFilter.range(0, 45)))
.build());

assertThat(client.captured.get(0).uri().toString()).contains("dte=0-45");
}

@Test
void chainExpirationFilterBetweenTranslatesToFromTo() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
Expand Down Expand Up @@ -1203,6 +1271,34 @@ void expirationFilterDteRejectsNegative() {
.hasMessageContaining("dte must be non-negative");
}

@Test
void dteFilterValuesRejectsOutOfRangeValue() {
assertThatThrownBy(() -> DteFilter.values(15, 36501))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be in 0..36500");
}

@Test
void dteFilterRangeRejectsMinGreaterThanMax() {
assertThatThrownBy(() -> DteFilter.range(45, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("min must be <= max");
}

@Test
void dteFilterRangeRejectsOutOfRangeBound() {
assertThatThrownBy(() -> DteFilter.range(0, 36501))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be in 0..36500");
}

@Test
void dteFilterComparisonRejectsOutOfRangeValue() {
assertThatThrownBy(() -> DteFilter.comparison(DteFilter.Operator.GTE, 36501))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be in 0..36500");
}

@Test
void expirationFilterBetweenRejectsReversedDates() {
assertThatThrownBy(
Expand Down
Loading