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
272 changes: 272 additions & 0 deletions .sdk-sync/1.4.0-f19c2d4.md

Large diffs are not rendered by default.

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

## [Unreleased]

### Changed

- **BREAKING:** In response to Market Data API 1.4.0, the `chain()` endpoint's `dte`
expiration filter now accepts a range or a discrete set of days in addition to a
single value. `ExpirationFilter.dte(int)` is unchanged, but the sealed
`ExpirationFilter` interface gains two new variants, `ExpirationFilter.DteRange`
and `ExpirationFilter.DteList`, reached via the new `ExpirationFilter.dteRange(int
minDays, int maxDays)` (wire form `?dte=MIN-MAX`) and `ExpirationFilter.dteList(int
first, int... rest)` (wire form `?dte=D1,D2,...`) factories. **Migration:** any
consumer with an exhaustive `switch` over `ExpirationFilter` will no longer compile
until it adds cases for `DteRange` and `DteList` — add them (or a `default`) to
restore exhaustiveness.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
4 changes: 3 additions & 1 deletion docs/options/chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ 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, a single value
.expirationFilter(ExpirationFilter.dteRange(int minDays, int maxDays)) // days-to-expiration, an inclusive range
.expirationFilter(ExpirationFilter.dteList(int first, int... rest)) // days-to-expiration, a discrete set
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/marketdata/sdk/OptionsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ 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.DteRange v) {
b.query("dte", v.minDays() + "-" + v.maxDays());
} else if (f instanceof ExpirationFilter.DteList v) {
b.query("dte", dteListWireValue(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 All @@ -325,6 +329,17 @@ 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 dteListWireValue(List<Integer> days) {
StringBuilder wire = new StringBuilder();
for (int i = 0; i < days.size(); i++) {
if (i > 0) {
wire.append(',');
}
wire.append(days.get(i));
}
return wire.toString();
}

private static String strikeFilterWireValue(StrikeFilter f) {
if (f instanceof StrikeFilter.Exact v) {
return formatStrike(v.price());
Expand Down
50 changes: 48 additions & 2 deletions src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.marketdata.sdk.options;

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

/**
Expand All @@ -18,6 +20,8 @@
public sealed interface ExpirationFilter
permits ExpirationFilter.OnDate,
ExpirationFilter.Dte,
ExpirationFilter.DteRange,
ExpirationFilter.DteList,
ExpirationFilter.Between,
ExpirationFilter.MonthYear,
ExpirationFilter.All {
Expand All @@ -39,12 +43,45 @@ static All all() {
return new All();
}

/** Days-to-expiration filter — wire form {@code ?dte=N}. */
/** Days-to-expiration filter, a single value — wire form {@code ?dte=N}. */
static Dte dte(int days) {
requireNonNegativeDte(days);
return new Dte(days);
}

/**
* Days-to-expiration filter, an inclusive range — wire form {@code ?dte=MIN-MAX} (e.g. {@code
* 30-45}). {@code minDays} must not exceed {@code maxDays}.
*/
static DteRange dteRange(int minDays, int maxDays) {
requireNonNegativeDte(minDays);
requireNonNegativeDte(maxDays);
if (minDays > maxDays) {
throw new IllegalArgumentException("min must be <= max");
}
return new DteRange(minDays, maxDays);
}

/**
* Days-to-expiration filter, a discrete set — wire form {@code ?dte=D1,D2,...} (e.g. {@code
* 7,14,30}). At least one value is required; the varargs signature makes that unrepresentable
* rather than a runtime check.
*/
static DteList dteList(int first, int... rest) {
requireNonNegativeDte(first);
List<Integer> days = new ArrayList<>();
days.add(first);
for (int day : rest) {
requireNonNegativeDte(day);
days.add(day);
}
return new DteList(days);
}

private static void requireNonNegativeDte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
return new Dte(days);
}

/**
Expand Down Expand Up @@ -79,6 +116,15 @@ record OnDate(LocalDate date) implements ExpirationFilter {

record Dte(int days) implements ExpirationFilter {}

record DteRange(int minDays, int maxDays) implements ExpirationFilter {}

/** {@code days} is defensively copied and immutable; always non-empty (see {@link #dteList}). */
record DteList(List<Integer> days) implements ExpirationFilter {
public DteList {
days = List.copyOf(days);
}
}

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

record MonthYear(int year, int month) implements ExpirationFilter {}
Expand Down
80 changes: 80 additions & 0 deletions src/test/java/com/marketdata/sdk/OptionsResourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
import com.marketdata.sdk.options.StrikeFilter;
import com.marketdata.sdk.options.StrikeRange;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
Expand Down Expand Up @@ -975,6 +977,56 @@ void chainExpirationFilterDteTranslatesToDteParam() {
.isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30");
}

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

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

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

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

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(7, 14, 30))
.build())
.join();

// Commas are valid unencoded in a query string, but URLEncoder (correctly) percent-encodes
// them anyway — decode before comparing, matching the convention used for symbols=A,B,C.
String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8);
assertThat(url).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=7,14,30");
}

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

options
.chainAsync(
OptionsChainRequest.builder("AAPL")
.expirationFilter(ExpirationFilter.dteList(7))
.build())
.join();

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

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

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

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

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

@Test
void expirationFilterDteListRejectsNegative() {
assertThatThrownBy(() -> ExpirationFilter.dteList(7, -1, 30))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be non-negative");
}

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