diff --git a/.sdk-sync/1.4.0-f19c2d4.md b/.sdk-sync/1.4.0-f19c2d4.md new file mode 100644 index 0000000..fb30c0c --- /dev/null +++ b/.sdk-sync/1.4.0-f19c2d4.md @@ -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)` 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)` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..2b27062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 & Kotlin SDK — a single JVM diff --git a/docs/options/chain.md b/docs/options/chain.md index d71c38b..e75b485 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -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) diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..bf8b4f0 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -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())); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..d7193b4 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -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); } /** @@ -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 {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..6949031 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,56 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteRangeTranslatesToDteParam() { + 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 chainExpirationFilterDteListTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(7, 14, 30)) + .build()) + .join(); + + // The comma is URL-encoded on the wire (same convention as the columns-projection test) — + // 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=7,14,30"); + } + + @Test + void chainSyncThreadsDteRangeToWire() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .build()); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30-45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1253,27 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteRangeRejectsReversedRange() { + 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 expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(