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

- **Options chain days-to-expiration filters** (API 2.0.0 compatibility) — three new
`ExpirationFilter` variants expand the `dte` parameter beyond single values:
`dteList(List<Integer>)` selects the closest expiration for each value in a
comma-separated list; `dteRange(int, int)` matches every expiration within an
inclusive range (e.g., 0–45 days); `dteComparison(Operator, int)` matches
expirations satisfying a comparison (e.g., `>= 30` days). The new `Operator`
enum provides `GT`, `GTE`, `LT`, `LTE` with a `wireValue()` accessor.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
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, closest match
.expirationFilter(ExpirationFilter.dteList(List<Integer> days)) // closest match per value
.expirationFilter(ExpirationFilter.dteRange(int min, int max)) // inclusive dte range
.expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.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 @@ -95,6 +98,14 @@ try (MarketDataClient client = new MarketDataClient()) {
.strikeFilter(StrikeFilter.range(150, 250))
.side(OptionSide.CALL)
.build());

// dte also accepts a comma-separated list, an inclusive range, or a comparison. Every dte
// value must be between 0 and 36500 inclusive; dte and from/to are mutually exclusive (the
// sealed ExpirationFilter has no variant carrying both, so the compiler enforces it).
var dteRange = client.options().chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.build());
}
```

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
94 changes: 94 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,7 @@
package com.marketdata.sdk.options;

import java.time.LocalDate;
import java.util.List;
import java.util.Objects;

/**
Expand All @@ -18,6 +19,9 @@
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 +51,64 @@ static Dte dte(int days) {
return new Dte(days);
}

/**
* Days-to-expiration list — wire form {@code ?dte=15,30,45}. Selects the closest expiration for
* each value (deduplicated server-side). Each value must be between 0 and 36500 inclusive; dte
* and {@code from}/{@code to} are mutually exclusive — this sealed type has no variant that
* carries both.
*/
static DteList dteList(List<Integer> days) {
return new DteList(days);
}

/**
* Days-to-expiration range — wire form {@code ?dte=0-45}. Matches every expiration whose dte
* falls within the bounds, inclusive. {@code min} must not exceed {@code max}, and both bounds
* must be between 0 and 36500 inclusive; dte and {@code from}/{@code to} are mutually exclusive —
* this sealed type has no variant that carries both.
*/
static DteRange dteRange(int min, int max) {
return new DteRange(min, max);
}

/**
* Days-to-expiration comparison — wire form {@code ?dte=>=30}. Matches every expiration whose dte
* satisfies {@code operator days}. {@code days} must be between 0 and 36500 inclusive; dte and
* {@code from}/{@code to} are mutually exclusive — this sealed type has no variant that carries
* both.
*/
static DteComparison dteComparison(Operator operator, int days) {
return new DteComparison(operator, days);
}

private static void validateDte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
if (days > 36500) {
throw new IllegalArgumentException("dte must not exceed 36500");
}
}

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

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

record Dte(int days) implements ExpirationFilter {}

record DteList(List<Integer> days) implements ExpirationFilter {
public DteList {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("days must not be empty");
}
days = List.copyOf(days);
for (int d : days) {
validateDte(d);
}
}
}

record DteRange(int min, int max) implements ExpirationFilter {
public DteRange {
validateDte(min);
validateDte(max);
if (min > max) {
throw new IllegalArgumentException("min must be <= max");
}
}
}

record DteComparison(Operator operator, int days) implements ExpirationFilter {
public DteComparison {
if (operator == null) {
throw new IllegalArgumentException("operator must not be null");
}
validateDte(days);
}
}

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

record MonthYear(int year, int month) implements ExpirationFilter {}
Expand Down
158 changes: 158 additions & 0 deletions src/test/java/com/marketdata/sdk/OptionsResourceTest.java
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 static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.arguments;

import com.marketdata.sdk.exception.ParseError;
import com.marketdata.sdk.options.ExpirationFilter;
Expand Down Expand Up @@ -30,7 +31,11 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

class OptionsResourceTest {

Expand Down Expand Up @@ -1024,7 +1029,97 @@
assertThat(client.captured.get(0).uri().toString())
.isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025");
}

Check warning on line 1032 in src/test/java/com/marketdata/sdk/OptionsResourceTest.java

View check run for this annotation

marketdata-code-review / Code review

Add a live dte integration test

The new list, range, and comparison syntax is tested only through a canned client, so nothing verifies that the live API accepts it or returns the modeled response. Add an integration test using at least one distinguishing form such as `dteRange`, and assert non-empty decoded rows whose DTE values satisfy the filter.
@Test
void chainExpirationFilterDteListTranslatesToCommaSeparated() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45)))
.build())
.join();

// Comma is URL-encoded, so decode before asserting.
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 chainExpirationFilterDteRangeTranslatesToDashedRange() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

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

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

@ParameterizedTest
@MethodSource("dteComparisonOperators")
void chainExpirationFilterDteComparisonTranslatesToOperatorPrefix(
ExpirationFilter.Operator operator, String expectedEncodedPrefix) {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteComparison(operator, 30))
.build())
.join();

// The operator prefix is URL-encoded — e.g. ">=" becomes "%3E%3D".
assertThat(client.captured.get(0).uri().toString())
.contains("dte=" + expectedEncodedPrefix + "30");
}

static Stream<Arguments> dteComparisonOperators() {
return Stream.of(
arguments(ExpirationFilter.Operator.GT, "%3E"),
arguments(ExpirationFilter.Operator.GTE, "%3E%3D"),
arguments(ExpirationFilter.Operator.LT, "%3C"),
arguments(ExpirationFilter.Operator.LTE, "%3C%3D"));
}

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

options
.chainAsync(
OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build())
.join();

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

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

options.chain(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteRange(0, 45))
.build());

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

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

@Test
void expirationFilterDteListRejectsEmptyList() {
assertThatThrownBy(() -> ExpirationFilter.dteList(List.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("days must not be empty");
}

@Test
void expirationFilterDteListRejectsNegativeValue() {
assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, -1)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be non-negative");
}

@Test
void expirationFilterDteListRejectsValueAboveMaximum() {
assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, 36501)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must not exceed 36500");
}

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

@Test
void expirationFilterDteRangeRejectsNegativeMin() {
assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be non-negative");
}

@Test
void expirationFilterDteRangeRejectsMaxAboveMaximum() {
assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must not exceed 36500");
}

@Test
void expirationFilterDteComparisonRejectsNegativeValue() {
assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, -1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be non-negative");
}

@Test
void expirationFilterDteComparisonRejectsValueAboveMaximum() {
assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 36501))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must not exceed 36500");
}

@Test
void expirationFilterDteComparisonRejectsNullOperator() {
assertThatThrownBy(() -> ExpirationFilter.dteComparison(null, 30))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("operator must not be null");
}

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