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
132 changes: 132 additions & 0 deletions .sdk-sync/1.4.0-f19c2d4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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 14:57:17Z |
| Outcome | **failed — the coder did not finish: success (1 turns · US$ 0.00)** |

## Changes in the API surface

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

## 1. test plan

Coder (15 turns, US$ 0.59) — test plan, 8 case(s) (wire 3, regression 1, validation 3, parity 1):

> The delta only widens `?dte=` on `GET /v1/options/chain/{underlying}/` from integer to string, accepting three forms: a single number, a range ("30-45"), or a comma-separated list. The mutually-exclusive `ExpirationFilter` sealed interface already owns this parameter via its `Dte` variant and the single `dte(int)` factory (which stays exactly as-is for the "single number" form — the wire bytes for that form are unchanged). Two new factories are added directly on `ExpirationFilter`, following the same flat-factory-name convention already used for `onDate`/`between`/`monthYear`/`all`, and mirroring `StrikeFilter`'s sibling-parameter precedent for multi-syntax filters: `ExpirationFilter.dteRange(int minDays, int maxDays)` (dash-joined wire value, `min <= max` and non-negative validated, same style as `StrikeFilter.range`), and `ExpirationFilter.dteList(int first, int... rest)` (comma-joined wire value, "at least one" structurally enforced by the `first, rest...` signature like `OptionsQuotesRequest.builder`, non-negative validated per element). All three still funnel through the single `Dte` arm of the sealed `ExpirationFilter`, so the compiler-enforced mutual exclusivity with `OnDate`/`Between`/`MonthYear`/`All` is untouched, and `OptionsResource.applyExpirationFilter` keeps writing the query key as `"dte"`. No response-model changes: the delta only touches the request-side query parameter, not the `dte` column the chain/quote responses already decode.

| # | category | test | API form | input | expected |
|---|---|---|---|---|---|
| 1 | wire | `chainExpirationFilterDteTranslatesToDteParam` | A single number | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) — pre-existing test, assertions unchanged | GET http://localhost/v1/options/chain/AAPL/?dte=30 |
| 2 | wire | `chainExpirationFilterDteRangeTranslatesToDteParam` | a range such as 30-45 | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteRange(30, 45)).build()) | GET http://localhost/v1/options/chain/AAPL/?dte=30-45 |
| 3 | wire | `chainExpirationFilterDteListTranslatesToDteParam` | a comma-separated list | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteList(7, 14, 30)).build()); URI decoded before assertion (comma is percent-encoded on the wire, matching the columns-projection test's decode-then-assert convention) | Decoded GET http://localhost/v1/options/chain/AAPL/?dte=7,14,30 |
| 4 | regression | `expirationFilterDteRejectsNegative` | A single number (pre-existing form, unaffected by the string-type widening) | ExpirationFilter.dte(-1) — pre-existing test, left untouched | throws IllegalArgumentException with message containing "dte must be non-negative", proving the pre-change single-value validation path still applies after Dte gains sibling factories |
| 5 | validation | `expirationFilterDteRangeRejectsReversedRange` | a range such as 30-45 | ExpirationFilter.dteRange(45, 30) | throws IllegalArgumentException with message containing "min must be <= max" (mirrors StrikeFilter.range's message for the structurally identical two-value range check) |
| 6 | validation | `expirationFilterDteRangeRejectsNegativeMin` | a range such as 30-45 | ExpirationFilter.dteRange(-1, 30) | throws IllegalArgumentException with message containing "dte must be non-negative" (reuses the single-value dte(int) message since days-to-expiry can never be negative in either form) |
| 7 | validation | `expirationFilterDteListRejectsNegativeValue` | a comma-separated list | ExpirationFilter.dteList(30, -5) | throws IllegalArgumentException with message containing "dte must be non-negative" (each element validated with the same rule as the single-value form; the empty-list case is structurally unrepresentable via the `dteList(int first, int... rest)` signature, mirroring OptionsQuotesRequest.builder(first, rest...), so no runtime empty-list case is needed) |
| 8 | parity | `chainSyncThreadsDteRangeToWire` | a range such as 30-45 | options.chain(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteRange(30, 45)).build()) — sync surface, mirroring the async wire test for the same filter (ADR-006 parity: both surfaces must thread the new dteRange param identically; the endpoint-level chainSyncMirrorsAsync test already exists but predates dteRange/dteList and doesn't exercise this branch) | GET http://localhost/v1/options/chain/AAPL/?dte=30-45, identical to the async wire assertion |

Plan reviewer (18 turns, US$ 0.31): approved

> The plan enumerates wire cases for all three forms the API delta describes (single number, range, comma-separated list) with no invented forms, includes a regression case proving the pre-existing single-value dte(int) path is unaffected by the string-type widening, adds validation cases with exception types/messages that match repository convention (StrikeFilter.range's "min must be <= max" and Dte's existing "dte must be non-negative"), adds a sync parity case consistent with how the repo already tests sync threading (one representative case rather than one per filter variant, matching the existing single chainSyncMirrorsAsync test), and correctly omits a response/fixture case since this is a parameter-type change on an existing endpoint, not a new one. Naming (chainExpirationFilterXxxTranslatesToYyy / expirationFilterXxxRejectsYyy) and file placement in OptionsResourceTest.java match the conventions used by the existing onDate/between/monthYear/all cases in that file, and the decode-then-assert approach for the comma-separated list mirrors the columns-projection test's established pattern. No coverage, invention, or convention gaps found.

- no findings

## 2. tests

Coder (10 turns, US$ 0.72):

> Build fails as expected — `compileTestJava` errors on the not-yet-implemented `ExpirationFilter.dteRange(...)` and `ExpirationFilter.dteList(...)` symbols. Stopping here per instructions.
>
> Summary:
>
> - Found the test file already contained stray, unreviewed test code (a different, conflicting design using `ExpirationFilter.dte(int,int)`/`dte(List<Integer>)` overloads, plus duplicate method names). Removed that block since it didn't match the reviewed plan and would have caused a hard compile error from duplicate methods.
> - Added exactly the 6 new tests from the plan (cases 2, 3, 5, 6, 7, 8 — cases 1 and 4 were already present and untouched):
> - `chainExpirationFilterDteRangeTranslatesToDteParam` — `ExpirationFilter.dteRange(30, 45)` → `?dte=30-45`
> - `chainExpirationFilterDteListTranslatesToDteParam` — `ExpirationFilter.dteList(7, 14, 30)` → `?dte=7,14,30` (decoded)
> - `chainSyncThreadsDteRangeToWire` — sync `chain(...)` threads `dteRange` to the same wire URL as async
> - `expirationFilterDteRangeRejectsReversedRange` — `dteRange(45, 30)` → `IllegalArgumentException("min must be <= max")`
> - `expirationFilterDteRangeRejectsNegativeMin` — `dteRange(-1, 30)` → `IllegalArgumentException("dte must be non-negative")`
> - `expirationFilterDteListRejectsNegativeValue` — `dteList(30, -5)` → `IllegalArgumentException("dte must be non-negative")`
> - No existing lines were removed or changed relative to `git HEAD`; the diff is a pure addition.
> - Ran `gradle test --no-daemon --console=plain` once as instructed: it fails at `compileTestJava` with "cannot find symbol" for `ExpirationFilter.dteRange`/`dteList`, confirming the tests correctly anticipate not-yet-implemented production code.

## 3. tests (attempt 2)

Coder (13 turns, US$ 0.43):

> Confirmed: build fails as expected, with production code untouched.
>
> Summary:
>
> - Undid: production-code edits to `src/main/java/com/marketdata/sdk/OptionsResource.java` (the `v.days()` → `v.value()` change) and `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java` (the `Dte(int days)` → `Dte(String value)` rewrite plus new `dte(int,int)`/`dte(List<Integer>)` overloads). Both files now match `git HEAD` exactly — no production code is touched.
> - Kept: the 6 plan tests in `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` (pure additions, nothing removed/changed relative to HEAD) — `chainExpirationFilterDteRangeTranslatesToDteParam`, `chainExpirationFilterDteListTranslatesToDteParam`, `chainSyncThreadsDteRangeToWire`, `expirationFilterDteRangeRejectsReversedRange`, `expirationFilterDteRangeRejectsNegativeMin`, `expirationFilterDteListRejectsNegativeValue`.
> - Confirmed `gradle test --no-daemon --console=plain` still fails, now purely on missing `ExpirationFilter.dteRange`/`dteList` symbols (compile error), with no other diffs outside `src/test/java/**`.

Gates:

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

## 4. implement

Coder (12 turns, US$ 0.47):

> Build is green.
>
> **Summary for PR body:**
>
> `GET /v1/options/chain/{underlying}/`'s `?dte=` parameter widened from integer to string, accepting a single number, an inclusive range (`30-45`), or a comma-separated list.
>
> - `ExpirationFilter.Dte` now stores a pre-rendered wire string instead of a raw `int`.
> - `ExpirationFilter.dte(int days)` keeps its existing signature and wire output (`?dte=N`) — fully backward compatible.
> - Added `ExpirationFilter.dteRange(int minDays, int maxDays)` → `?dte=min-max`, validating `minDays <= maxDays` and both non-negative (mirrors `StrikeFilter.range`'s validation style).
> - Added `ExpirationFilter.dteList(int first, int... rest)` → `?dte=d1,d2,...`, validating every value non-negative; the `first, rest...` signature makes an empty list unrepresentable at compile time (mirrors `OptionsQuotesRequest.builder`).
> - `OptionsResource.applyExpirationFilter` now reads `Dte.wireValue()` instead of `Dte.days()`.
> - Updated `docs/options/chain.md` to document the two new factory forms alongside the existing one.
> - No test weakening: all 6 tests written in phase 1 pass unmodified against this implementation. `gradle test` and `gradle build` (including Spotless and JaCoCo coverage) both pass.

Gates:

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

## 5. review 1

Reviewer (4 turns, US$ 0.39): approved

> The change correctly implements the API's dte type widening from integer to string, adding ExpirationFilter.dteRange and dteList alongside the existing dte(int), threading the pre-rendered wire value through OptionsResource.applyExpirationFilter via the renamed Dte.wireValue() accessor. All eight committed test-plan cases are present, unweakened, and exercise the wire encoding (single/range/list), validation (negative single value, reversed range, negative range endpoint, negative list element), and sync/async parity exactly as specified. Docs were updated with the two new builder lines in the right place. The only notable gap is a pre-existing but now slightly worse loophole: Dte's public canonical constructor is unvalidated, so callers can bypass the new range/list validation by constructing the record directly with an arbitrary string.

- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:118 — The Dte record's canonical constructor is public (nested types in a public interface are implicitly public) and unvalidated, so `new ExpirationFilter.Dte("anything")` bypasses the dte/dteRange/dteList validation entirely. This predates the change (the old Dte(int) had the same issue), but widening the field from int to a freeform String broadens what can be smuggled onto the wire versus just an out-of-range integer. Consider a compact constructor that validates the wire-value shape, or a package-private constructor.

Security (5 turns, US$ 0.37): approved

> This is a narrowly-scoped change (options chain `dte` filter widened from int to string form) with no new dependencies, no credential handling, no logging changes, and consistent, non-weakened test coverage matching the committed test plan. The only notable issue is a pre-existing (and slightly worsened) design gap: the `ExpirationFilter.Dte` record's canonical constructor is unavoidably public because it's nested in a public interface, so consumers can bypass the new range/list/non-negative validation by constructing `Dte` directly with an arbitrary string; actual injection risk is mitigated by the transport's existing URL-encoding of query values, so this is reported as a minor hardening item rather than a blocker.

- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:118 — The `Dte` record is nested inside the public sealed interface `ExpirationFilter`, so per JLS its canonical constructor is implicitly public and cannot be made more restrictive. Any consumer can therefore call `new ExpirationFilter.Dte(someArbitraryString)` directly, completely bypassing the `requireNonNegativeDte`/range/list validation done in the `dte()`, `dteRange()`, and `dteList()` factory methods, and have that raw string sent as the `?dte=` query value. This pattern already existed pre-change for the `int`-typed `Dte(int days)` constructor (which could already be bypassed with a negative int), but widening the field to `String` enlarges the bypass surface from 'any int' to 'any string', including empty strings, control characters, or overlong values reaching the wire unchecked. Downstream URL-encoding (per RequestSpec's documented contract) likely prevents this from becoming a request-splitting/query-injection vector, but it defeats the compiler-enforced validation invariant the sealed-type design is meant to guarantee. Consider validating `wireValue` in a compact constructor on `Dte` (format/charset check) so even direct construction can't produce an unvalidated value.

## 6. changelog

## Totals

- Version: not assessed
- Coder runs: 4
- Review runs: 3
- Gates run: 3 (1 failed)
- Files changed by the agent: 4
- Turns: 77
- Tokens: 5,801,203
- Estimated cost: US$ 3.27

### Agent runs

- coder · claude-haiku-4-5-20251001, claude-sonnet-5 · 461,363 tokens (65,367 in, 371,172 cached, 24,824 out) · 15 turns · US$ 0.59
- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 313,710 tokens (35,116 in, 266,954 cached, 11,640 out) · 18 turns · US$ 0.31
- coder · claude-sonnet-5 · 1,142,618 tokens (105,559 in, 1,028,195 cached, 8,864 out) · 10 turns · US$ 0.72
- coder · claude-sonnet-5 · 1,675,214 tokens (12,965 in, 1,657,696 cached, 4,553 out) · 13 turns · US$ 0.43
- coder · claude-sonnet-5 · 1,740,197 tokens (14,293 in, 1,719,051 cached, 6,853 out) · 12 turns · US$ 0.47
- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 208,283 tokens (57,317 in, 140,401 cached, 10,565 out) · 4 turns · US$ 0.39
- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 259,818 tokens (56,011 in, 196,485 cached, 7,322 out) · 5 turns · US$ 0.37
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 1.4.0, which changed `GET /v1/options/chain/{underlying}/`'s
`dte` query parameter from an integer to a string accepting a single number, a range
(`30-45`), or a comma-separated list.

### Added

- `ExpirationFilter.dteRange(int minDays, int maxDays)` — inclusive days-to-expiration
range filter for `options().chain(...)`, wire form `?dte=min-max`.
- `ExpirationFilter.dteList(int first, int... rest)` — comma-separated days-to-expiration
list filter for `options().chain(...)`, wire form `?dte=d1,d2,...`.

### Changed

- **Breaking:** `ExpirationFilter.Dte` now carries its wire value as a `String`
(`wireValue()`) rather than an `int` (`days()`), mirroring the API's `dte` type change.
`Dte.days()` is removed, and the canonical constructor is now `Dte(String)` instead of
`Dte(int)`. Consumers reading `.days()` off a `Dte` must switch to `.wireValue()`;
consumers constructing `Dte` directly must go through `ExpirationFilter.dte(int)`,
`.dteRange(int, int)`, or `.dteList(int, int...)` instead of the constructor.
`ExpirationFilter.dte(int days)` itself keeps its signature and behavior, so most call
sites are unaffected.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java &amp; Kotlin SDK — a single JVM
Expand Down
2 changes: 2 additions & 0 deletions docs/options/chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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.dteRange(int minDays, int maxDays)) // dte range, e.g. 30-45
.expirationFilter(ExpirationFilter.dteList(int first, int... rest)) // dte comma-separated list
.expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to))
.expirationFilter(ExpirationFilter.all()) // every expiration
// (if omitted, the API narrows to the front month)
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.wireValue());
} 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
Expand Up @@ -39,12 +39,44 @@ 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) {
requireNonNegativeDte(days);
return new Dte(Integer.toString(days));
}

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

/**
* Days-to-expiration filter, comma-separated list — wire form {@code ?dte=d1,d2,...}. At least
* one value is required — enforced by the {@code first, rest...} signature, so an empty list is
* unrepresentable through the public API.
*/
static Dte dteList(int first, int... rest) {
requireNonNegativeDte(first);
StringBuilder wireValue = new StringBuilder(Integer.toString(first));
for (int day : rest) {
requireNonNegativeDte(day);
wireValue.append(',').append(day);
}
return new Dte(wireValue.toString());
}

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 @@ -77,7 +109,12 @@ record OnDate(LocalDate date) implements ExpirationFilter {
}
}

record Dte(int days) implements ExpirationFilter {}
/**
* Wire value already rendered for {@code ?dte=} — a single number, a range ({@code min-max}), or
* a comma-separated list, per {@link #dte(int)}, {@link #dteRange(int, int)}, and {@link
* #dteList(int, int...)}.
*/
record Dte(String wireValue) implements ExpirationFilter {}

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

Expand Down
Loading
Loading