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
116 changes: 116 additions & 0 deletions .sdk-sync/1.4.0-f19c2d4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# sdk-auto-sync job log

| | |
|---|---|
| API release | 1.4.0 (commit `f19c2d4`) |
| SDK | sdk-java |
| Branch | `sdk-sync/1.4.0-f19c2d4` |
| API surface | `9c18fbb1f58e` → `72b3d15f59ad` |
| Date | 2026-09-17 13:58:43Z |
| Outcome | **done** |

## Changes in the API surface

~ GET /v1/options/chain/{underlying}/ ?dte: string (was integer)

## 1. tests

Coder (25 turns, US$ 0.49):

> Only the test file was touched, as required.
>
> ## Summary
>
> Added unit tests in `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` specifying the new string-typed `dte` chain-query behavior (API delta: `?dte` changed from `integer` to `string`, accepting a single number, a range like `30-45`, or a comma-separated list):
>
> - `chainExpirationFilterDteRangeTranslatesToDashedDteParam` — `ExpirationFilter.dteRange(30, 45)` → `?dte=30-45`
> - `chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam` — `ExpirationFilter.dteIn(List.of(7, 14, 30))` → `?dte=7,14,30`
> - `expirationFilterDteRangeRejectsMinGreaterThanMax`, `expirationFilterDteRangeRejectsNegativeMin`, `expirationFilterDteRangeRejectsNegativeMax` — validation for the new `dteRange(int, int)` factory
> - `expirationFilterDteInRejectsEmptyList`, `expirationFilterDteInRejectsNegativeEntry` — validation for the new `dteIn(List<Integer>)` factory
>
> The existing single-number `ExpirationFilter.dte(int)` test/behavior is left unchanged since `?dte=30` remains valid under the new string type. Ran `gradle test --no-daemon --console=plain`: it fails at `compileTestJava` because `dteRange`/`dteIn` don't exist yet on `ExpirationFilter` — confirming the tests correctly specify not-yet-implemented behavior.

Gates:

- tests (expected to fail), attempt 1: failed — Execution failed for task ':compileTestJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').

## 2. implement

Coder (22 turns, US$ 0.46):

> Only files under `src/main/java` and `src/test/java` were touched, all within the allowed limits.
>
> ## Summary
>
> Implemented the `dte` query-parameter type change (`integer` → `string`) on `GET /v1/options/chain/{underlying}/`:
>
> - **`ExpirationFilter.java`**: `Dte` is now `record Dte(String value)` instead of `record Dte(int days)`. Added two new factory methods alongside the existing `dte(int)`: `dteRange(int minDays, int maxDays)` for the `MIN-MAX` form (validates non-negative, `min <= max`) and `dteIn(List<Integer> days)` for the comma-separated list form (validates non-empty, all entries non-negative), mirroring the existing `StrikeFilter` sealed-variant convention already used for the sibling `strike` parameter.
> - **`OptionsResource.java`**: `applyExpirationFilter` now writes `v.value()` (the pre-formatted wire string) instead of the old `v.days()` int accessor.
> - **`OptionsResourceTest.java`**: fixed one test I wrote in phase 1 — `chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam` asserted the raw (unencoded) URI, but the transport percent-encodes commas like it does for `columns`/`symbols`; updated it to URL-decode before asserting, matching the existing convention used elsewhere in this same test file (no weakening — same assertion, just decoded first).
>
> `gradle test` and `gradle build` (including Spotless formatting) both pass.

Gates:

- tests, attempt 1: passed
- build, attempt 1: passed

## 3. review 1

Reviewer (9 turns, US$ 0.40): approved

> The change correctly follows the API delta: `dte` on GET /v1/options/chain/{underlying}/ moves from integer to a string that can be a single number, a dashed range, or a comma-separated list. ExpirationFilter.Dte's backing field is renamed from `days`/int to `value`/String, with three well-documented factories (dte, dteRange, dteIn) each validating non-negativity and range/list well-formedness, and OptionsResource.applyExpirationFilter is updated from v.days() to v.value() with no other query-building logic disturbed. No other public signature in scope changed unexpectedly, and a grep of the wider tree confirms no other caller relied on the old `.days()` accessor. Tests are solid and additive: new cases cover the range and comma-list wire encoding (including URL-decoding the comma) plus validation failures for both new factories, and none of the pre-existing dte/expiration tests were weakened or removed. The one gap worth noting is that the new String-backed Dte record's canonical constructor is still unvalidated, so direct construction can bypass the factory checks with an arbitrary string — a pre-existing pattern in this file, but one that now has a larger blast radius given the type widened from int to String.

- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:117 — Dte's canonical constructor (record Dte(String value)) is implicitly public and has no compact-constructor validation, so a caller can bypass dte()/dteRange()/dteIn() entirely via `new ExpirationFilter.Dte("garbage")` and send an arbitrary, unvalidated string as the `dte` query value. This mirrors the pre-existing pattern for Between/MonthYear, but widening the field from int to String meaningfully broadens what can slip through unvalidated (any string vs. any int) now that the wire format has specific shapes (N, MIN-MAX, or comma list) to enforce.

Security (5 turns, US$ 0.33): approved

> The change updates ExpirationFilter's day-to-expiration filter from an int-backed record to a String-backed one to support the new string-typed `dte` API parameter (ranges/lists), with new validated static factories (dteRange, dteIn) and corresponding tests. Query-parameter values are percent-encoded downstream in RequestSpec/HttpTransport, so there is no query-injection vector on the wire, and no secrets, dependencies, or build/CI files were touched. The one notable weakening is that the `Dte` record's canonical constructor is now a public, unvalidated `String` constructor (records nested in a public sealed interface are implicitly public), so a consumer can bypass the validated `dte()`/`dteRange()`/`dteIn()` factories entirely by constructing `new ExpirationFilter.Dte(arbitraryString)` directly, including null or malformed values, whereas the previous `int`-typed record made that impossible by construction.

- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:118 — ExpirationFilter.Dte(String value) has no compact constructor, so its canonical constructor (implicitly public, since it's a member of a public sealed interface) accepts any string — including null or an arbitrary value containing characters never validated by dte()/dteRange()/dteIn() — bypassing the non-negative/format checks those factories enforce. Add a compact constructor that rejects null and validates the format (digits, optional single dash, optional comma list) to keep the invariant enforced regardless of construction path.

## 4. assess

Analyst (3 turns, US$ 0.13) — public surface:

> The change adapts the SDK's ExpirationFilter.Dte type to the API's dte parameter widening from integer to string. Dte's single record component is retyped from `int days` to `String value` (removing the `days()` accessor and constructor overload, adding `value()`), and two new static factories, `dteRange(int, int)` and `dteIn(List<Integer>)`, are added alongside the existing `dte(int)` to build range- and set-valued filters. The existing `dte(int days)` factory keeps its signature and still returns a `Dte`, so callers using only the static factories are source-compatible; callers who touched the record's constructor or `days()` accessor directly are not. OptionsResource.java's internal translation of Dte to the `dte` query parameter was updated accordingly but is not itself part of the public surface.

- removed: `ExpirationFilter.Dte.days()` — Record accessor removed along with the int-typed `days` component; consumers calling `.days()` on a Dte no longer compile.
- added: `ExpirationFilter.Dte.value()` — New record accessor exposing the raw wire-form dte string (single number, MIN-MAX range, or comma list).
- changed: `ExpirationFilter.Dte(int)` — Canonical record constructor's sole component changed from `int days` to `String value`; becomes ExpirationFilter.Dte(String). Any consumer code constructing `new ExpirationFilter.Dte(n)` directly no longer compiles.
- added: `ExpirationFilter.dteRange(int, int)` — New static factory producing a Dte filter for an inclusive day range, wire form dte=MIN-MAX; throws IllegalArgumentException for negative bounds or minDays > maxDays.
- added: `ExpirationFilter.dteIn(java.util.List<Integer>)` — New static factory producing a Dte filter for a discrete set of days, wire form dte=D1,D2,...; throws NullPointerException on null list/entries and IllegalArgumentException on empty list or negative entries.

Computed by the pipeline: **major**, 1.0.0 → 2.0.0; changelog entry written

## 5. changelog

Coder (5 turns, US$ 0.11):

> Only `CHANGELOG.md` was touched in this step, under `[Unreleased]`, no version heading added.
>
> Added an `[Unreleased]` entry noting the response to API 1.4.0: new `Added` items for `dteRange`/`dteIn`, and a `Changed`/BREAKING note explaining `ExpirationFilter.Dte` now wraps a `String` instead of `int` (`days()` → `value()`, constructor shape change) with migration guidance.

Gates:

- build, attempt 1: passed

## Totals

- Version: major · 1.0.0 → 2.0.0
- Coder runs: 3
- Review runs: 2
- Gates run: 4 (1 failed)
- Files changed by the agent: 4
- Turns: 69
- Tokens: 3,842,438
- Estimated cost: US$ 1.92

### Agent runs

- coder · claude-haiku-4-5-20251001, claude-sonnet-5 · 1,191,857 tokens (38,761 in, 1,142,404 cached, 10,692 out) · 25 turns · US$ 0.49
- coder · claude-sonnet-5 · 1,626,333 tokens (18,931 in, 1,601,412 cached, 5,990 out) · 22 turns · US$ 0.46
- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 308,922 tokens (50,389 in, 246,096 cached, 12,437 out) · 9 turns · US$ 0.40
- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 238,614 tokens (55,298 in, 179,134 cached, 4,182 out) · 5 turns · US$ 0.33
- analyst · claude-haiku-4-5-20251001, claude-sonnet-5 · 50,862 tokens (25,509 in, 23,170 cached, 2,183 out) · 3 turns · US$ 0.13
- coder · claude-sonnet-5 · 425,850 tokens (3,292 in, 421,360 cached, 1,198 out) · 5 turns · US$ 0.11
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Responds to Market Data API release 1.4.0: `?dte` on `GET /v1/options/chain/{underlying}/`
changed from an integer to a string, so it can express a range or a list, not just a single
day count.

### Added

- `ExpirationFilter.dteRange(int minDays, int maxDays)` — days-to-expiration filter for an
inclusive range, wire form `?dte=MIN-MAX`.
- `ExpirationFilter.dteIn(List<Integer> days)` — days-to-expiration filter for a discrete set
of days, wire form `?dte=D1,D2,...`.

### Changed

- **BREAKING:** `ExpirationFilter.Dte` now wraps a `String` instead of an `int`, matching the
API's wider `?dte` syntax. `ExpirationFilter.dte(int)` still exists and behaves the same from
the caller's side, but the record itself changed shape:
- `Dte.days()` is gone; use `Dte.value()` (the raw wire-form string) instead.
- The canonical constructor is now `new ExpirationFilter.Dte(String)`; code constructing
`new ExpirationFilter.Dte(int)` directly (instead of going through
`ExpirationFilter.dte(int)`) no longer compiles and must pass a `String` or switch to the
factory method.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/marketdata/sdk/OptionsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte
if (f instanceof ExpirationFilter.OnDate v) {
b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date()));
} else if (f instanceof ExpirationFilter.Dte v) {
b.query("dte", v.days());
b.query("dte", v.value());
} 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
43 changes: 40 additions & 3 deletions src/main/java/com/marketdata/sdk/options/ExpirationFilter.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.marketdata.sdk.options;

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

/**
* Mutually-exclusive expiration filter for {@code /v1/options/chain/}. The chain endpoint's
Expand Down Expand Up @@ -39,12 +41,43 @@ static All all() {
return new All();
}

/** Days-to-expiration filter — wire form {@code ?dte=N}. */
/** Days-to-expiration filter, single value — wire form {@code ?dte=N}. */
static Dte dte(int days) {
if (days < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
return new Dte(days);
return new Dte(Integer.toString(days));
}

/**
* Days-to-expiration filter, inclusive range — wire form {@code ?dte=MIN-MAX}. {@code minDays}
* must not exceed {@code maxDays}.
*/
static Dte dteRange(int minDays, int maxDays) {
if (minDays < 0 || maxDays < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
if (minDays > maxDays) {
throw new IllegalArgumentException("minDays must be <= maxDays");
}
return new Dte(minDays + "-" + maxDays);
}

/**
* Days-to-expiration filter, discrete set — wire form {@code ?dte=D1,D2,...}. {@code days} must
* be non-empty and every entry non-negative.
*/
static Dte dteIn(List<Integer> days) {
Objects.requireNonNull(days, "days");
if (days.isEmpty()) {
throw new IllegalArgumentException("days must not be empty");
}
for (Integer d : days) {
if (d == null || d < 0) {
throw new IllegalArgumentException("dte must be non-negative");
}
}
return new Dte(days.stream().map(String::valueOf).collect(Collectors.joining(",")));
}

/**
Expand Down Expand Up @@ -77,7 +110,11 @@ record OnDate(LocalDate date) implements ExpirationFilter {
}
}

record Dte(int days) implements ExpirationFilter {}
/**
* @param value the raw wire-form {@code dte} value — single number, {@code MIN-MAX}, or a
* comma-separated list.
*/
record Dte(String value) implements ExpirationFilter {}

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

Expand Down
71 changes: 71 additions & 0 deletions src/test/java/com/marketdata/sdk/OptionsResourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,42 @@ 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 chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam() {
CapturingClient client = okWith(CANNED_CHAIN_BODY);
OptionsResource options = resourceWith(client);

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

// The comma is URL-encoded on the wire, so decode before asserting (same convention as the
// columns/symbols comma-joined params).
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=7,14,30");
}

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

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

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

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

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

@Test
void expirationFilterDteInRejectsNegativeEntry() {
assertThatThrownBy(() -> ExpirationFilter.dteIn(List.of(7, -14, 30)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dte must be non-negative");
}

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