From cf5b09dbf0b68da2266e97241b2346151a382b01 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 17 Sep 2026 12:08:22 -0300 Subject: [PATCH 1/2] sdk-sync: API 1.4.0+f19c2d4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Build fails as expected — the new `dte(int, int)` and `dte(List)` overloads don't exist yet (production code untouched, per phase 1b scope). Summary of tests added to `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` (all pre-existing tests left untouched): - `chainExpirationFilterDteRangeTranslatesToDteParam` (wire) — `ExpirationFilter.dte(30, 45)` must send `?dte=30-45`. - `chainExpirationFilterDteListTranslatesToDteParam` (wire) — `ExpirationFilter.dte(List.of(7, 14, 30))` must send the percent-encoded `dte=7%2C14%2C30` on the wire, decoding to `dte=7,14,30`. - `chainExpirationFilterDteRangeSyncMirrorsAsync` (parity) — `chain(...)` and `chainAsync(...)` must send identical requests for the range form. - `expirationFilterDteRangeRejectsReversedBounds`, `expirationFilterDteRangeRejectsNegativeMin` (validation) — `dte(min, max)` rejects `min > max` and negative `min`. - `expirationFilterDteListRejectsEmptyList`, `expirationFilterDteListRejectsNullList`, `expirationFilterDteListRejectsNegativeValue` (validation) — `dte(List)` rejects empty, null, and negative-containing lists. The plan's item 3 (regression, single-number form) was already present verbatim at `chainExpirationFilterDteTranslatesToDteParam` — no change needed there. Confirmed `gradle test --no-daemon --console=plain` fails: `compileTestJava` errors because `ExpirationFilter.dte(int,int)` and `ExpirationFilter.dte(List)` don't exist yet, as expected for this phase. implement: Only the three expected files changed. No commits made, nothing else touched. Summary for PR body: - `ExpirationFilter.dte(...)` (in `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java`) now supports all three wire forms the API's `?dte=` string parameter accepts: `dte(int days)` (unchanged, single number — kept for backward compatibility), plus two new overloads `dte(int min, int max)` (inclusive range, e.g. `30-45`, rejecting a reversed or negative `min`) and `dte(List days)` (comma-separated list, rejecting null, empty, or negative-containing lists). The `Dte` record now stores a pre-rendered wire `String` instead of a bare `int`, matching the API's type change from integer to string. - `OptionsResource.applyExpirationFilter` now reads `Dte.value()` (the rendered string) instead of `Dte.days()` (the former int). - Fixed two Spotless formatting nits (a Javadoc line-wrap and a line-length wrap) flagged by `gradle build`, in the same two files touched by the change plus the test file. `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass; no other files were modified and nothing was committed. changelog: Added a `## [Unreleased]` changelog entry documenting the breaking `ExpirationFilter.Dte`/`dte` API change (new `dteRange`/`dteList` factories, `Dte.days()` → `wireValue()` rename, `Dte(int)` → `Dte(String)`) in response to Market Data API 1.4.0's `dte` parameter type change, with migration guidance for affected consumers — only `CHANGELOG.md` was touched in this step. --- .sdk-sync/1.4.0-f19c2d4.md | 166 ++++++++++++++++++ CHANGELOG.md | 22 +++ docs/options/chain.md | 2 + .../com/marketdata/sdk/OptionsResource.java | 2 +- .../sdk/options/ExpirationFilter.java | 43 ++++- .../marketdata/sdk/OptionsResourceTest.java | 71 ++++++++ 6 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 .sdk-sync/1.4.0-f19c2d4.md diff --git a/.sdk-sync/1.4.0-f19c2d4.md b/.sdk-sync/1.4.0-f19c2d4.md new file mode 100644 index 0000000..253e301 --- /dev/null +++ b/.sdk-sync/1.4.0-f19c2d4.md @@ -0,0 +1,166 @@ +# 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:53:04Z | +| Outcome | **done** | + +## Changes in the API surface + + ~ GET /v1/options/chain/{underlying}/ ?dte: string (was integer) + +## 1. test plan + +Coder (36 turns, US$ 0.73) — test plan, 9 case(s) (wire 2, regression 1, validation 5, parity 1): + +> The delta only touches the ?dte= query parameter on GET /v1/options/chain/{underlying}/ (int -> string), enumerating exactly three accepted forms: a single number, a range like "30-45", and a comma-separated list. Nothing else in the surface changes — the unrelated `dte` field on the expirations response row stays an Integer. Following ADR-008's sealed-type-per-mutually-exclusive-axis convention (the same shape StrikeFilter already uses for strike's exact/range/comparison forms), the plan keeps ExpirationFilter.Dte as a single record but backs it with a formatted wire String instead of a bare int, and widens ExpirationFilter's dte(...) factory into three overloads: the existing dte(int days) (unchanged signature/behavior, satisfies the regression requirement), a new dte(int min, int max) for the range form, and a new dte(List days) for the comma-separated list form — mirroring existing conventions (StrikeFilter.range's "min must be <= max" message, the existing "dte must be non-negative" message, StocksResource's List symbols -> comma-joined wire convention with unencoded commas, and Objects.requireNonNull's named-field NPE pattern). OptionsResource's applyExpirationFilter switches from v.days() to the record's formatted String accessor. All new tests land in the existing single-file, single-package test convention at src/test/java/com/marketdata/sdk/OptionsResourceTest.java, alongside the current Dte wire/validation tests they extend. + +| # | category | test | API form | input | expected | +|---|---|---|---|---|---| +| 1 | wire | `chainExpirationFilterDteRangeTranslatesToDteParam` | a range such as 30-45 | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=30-45" | +| 2 | wire | `chainExpirationFilterDteListTranslatesToDteParam` | a comma-separated list | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(List.of(7, 14, 30))).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=7,14,30" (commas sent unencoded, matching the existing symbols=AAPL,MSFT convention) | +| 3 | regression | `chainExpirationFilterDteTranslatesToDteParam` | a single number | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()).join() — the existing test at OptionsResourceTest.java:965, kept verbatim | client.captured.get(0).uri().toString() still equals "http://localhost/v1/options/chain/AAPL/?dte=30" after dte becomes string-typed on the wire — the pre-existing int-arg dte(int) factory keeps compiling and keeps sending the bare number, doubling as the wire proof for the "single number" form | +| 4 | parity | `chainExpirationFilterDteRangeSyncMirrorsAsync` | a range such as 30-45 | call both options.chain(...) and options.chainAsync(...).join() with OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build(), against two separate CapturingClient instances | both captured request URIs equal "http://localhost/v1/options/chain/AAPL/?dte=30-45" — proves the new String-valued dte query path (chain() wraps chainAsync() via transport.joinSync per ADR-006) is exercised identically on both surfaces, not just the previously-int-typed path | +| 5 | validation | `expirationFilterDteRangeRejectsReversedBounds` | a range such as 30-45 | ExpirationFilter.dte(45, 30) | throws IllegalArgumentException with message containing "min must be <= max" (mirrors StrikeFilter.range's identical message for a reversed min/max pair) | +| 6 | validation | `expirationFilterDteRangeRejectsNegativeMin` | a range such as 30-45 | ExpirationFilter.dte(-1, 10) | throws IllegalArgumentException with message containing "dte must be non-negative" (reuses the exact message the pre-existing single-number dte(int) factory already throws) | +| 7 | validation | `expirationFilterDteListRejectsEmptyList` | a comma-separated list | ExpirationFilter.dte(List.of()) | throws IllegalArgumentException with message containing "days must be non-empty" (mirrors the " must be non-empty" convention e.g. OptionsChainRequest's "symbol must be non-empty") | +| 8 | validation | `expirationFilterDteListRejectsNullList` | a comma-separated list | ExpirationFilter.dte((List) null) | throws NullPointerException with message containing "days" (Objects.requireNonNull(days, "days") convention, per RequestHeadersTest's named-field NPE pattern) | +| 9 | validation | `expirationFilterDteListRejectsNegativeValue` | a comma-separated list | ExpirationFilter.dte(List.of(7, -1, 30)) | throws IllegalArgumentException with message containing "dte must be non-negative" (same non-negative rule applied per-element) | + +Plan reviewer (24 turns, US$ 0.35): 1 blocking finding(s) + +> The plan correctly enumerates the three wire forms the delta describes (single number via the kept regression test, range, and comma list), adds a matching regression case, layers on validation cases that mirror existing repo conventions (StrikeFilter.range's 'min must be <= max' message, the ' must be non-empty' pattern, and the named-field NullPointerException pattern from RequestHeadersTest), and doesn't invent any unlisted form (no operators/wildcards). The one defect is the comma-list wire case, which asserts an un-encoded URL that contradicts how this codebase's HttpTransport actually percent-encodes query values (commas become %2C, as evidenced by every other comma-bearing assertion in the suite decoding first) — so as written it does not prove the actual request sent for that form. + +- [major] src/test/java/com/marketdata/sdk/OptionsResourceTest.java — Wire case 2 (chainExpirationFilterDteListTranslatesToDteParam) asserts client.captured.get(0).uri().toString() equals the literal 'http://localhost/v1/options/chain/AAPL/?dte=7,14,30'. HttpTransport.encodeQueryComponent (HttpTransport.java) percent-encodes every query value through URLEncoder, which encodes ',' as '%2C' (unlike '-', which is in URLEncoder's unreserved set and is why the existing strike-range test at line 1054 can compare a literal hyphen). Every existing comma-bearing assertion in this repo (StocksResourceTest.java:283-300 for symbols=AAPL,MSFT, OptionsResourceTest.java:619-620/710-711 for columns=...) first runs the captured URI through URLDecoder before comparing, which only makes sense because the wire form is actually '%2C'-encoded. The plan's own justification ('commas sent unencoded, matching the existing symbols=AAPL,MSFT convention') is backwards. The test as specified will not assert the real outbound bytes; it should either decode the captured URI with URLDecoder before comparing (matching convention) or assert the percent-encoded form 'dte=7%2C14%2C30'. + +## 2. test plan (revised 1) + +Coder (4 turns, US$ 0.09) — test plan, 9 case(s) (wire 2, regression 1, validation 5, parity 1): + +> The delta only touches the ?dte= query parameter on GET /v1/options/chain/{underlying}/ (int -> string), enumerating exactly three accepted forms: a single number, a range like "30-45", and a comma-separated list. Nothing else in the surface changes — the unrelated `dte` field on the expirations response row stays an Integer. Following ADR-008's sealed-type-per-mutually-exclusive-axis convention (the same shape StrikeFilter already uses for strike's exact/range/comparison forms), the plan keeps ExpirationFilter.Dte as a single record but backs it with a formatted wire String instead of a bare int, and widens ExpirationFilter's dte(...) factory into three overloads: the existing dte(int days) (unchanged signature/behavior, satisfies the regression requirement), a new dte(int min, int max) for the range form, and a new dte(List days) for the comma-separated list form — mirroring existing conventions (StrikeFilter.range's "min must be <= max" message, the existing "dte must be non-negative" message, StocksResource's List symbols -> comma-joined wire convention, and Objects.requireNonNull's named-field NPE pattern). OptionsResource's applyExpirationFilter switches from v.days() to the record's formatted String accessor. Corrected from revision 1: the comma-separated-list wire case now accounts for HttpTransport.encodeQueryComponent percent-encoding ',' to '%2C' via URLEncoder — it asserts the raw captured URI contains the encoded form and only compares the human-readable form after URLDecoder.decode(...), matching the repo's existing columns=... and symbols=... assertions instead of asserting a literal unencoded comma. The range form's '-' needed no such fix since URLEncoder leaves '-' unescaped, consistent with the existing strike-range test. All new tests land in the existing single-file, single-package test convention at src/test/java/com/marketdata/sdk/OptionsResourceTest.java, alongside the current Dte wire/validation tests they extend. + +| # | category | test | API form | input | expected | +|---|---|---|---|---|---| +| 1 | wire | `chainExpirationFilterDteRangeTranslatesToDteParam` | a range such as 30-45 | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=30-45" ('-' is in URLEncoder's unreserved set, so it survives encoding unescaped, matching the existing strikeFilter range test at OptionsResourceTest.java:1054 which compares the literal hyphen directly) | +| 2 | wire | `chainExpirationFilterDteListTranslatesToDteParam` | a comma-separated list | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(List.of(7, 14, 30))).build()).join() | client.captured.get(0).uri() is decoded with java.net.URLDecoder.decode(..., UTF_8) first (per the columns=... convention at OptionsResourceTest.java:617-620), then the decoded string contains "dte=7,14,30"; the raw, undecoded URI must contain the percent-encoded form "dte=7%2C14%2C30" since HttpTransport.encodeQueryComponent runs every query value through URLEncoder, which escapes ',' to '%2C' | +| 3 | regression | `chainExpirationFilterDteTranslatesToDteParam` | a single number | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()).join() — the existing test at OptionsResourceTest.java:965, kept verbatim | client.captured.get(0).uri().toString() still equals "http://localhost/v1/options/chain/AAPL/?dte=30" after dte becomes string-typed on the wire — the pre-existing int-arg dte(int) factory keeps compiling and keeps sending the bare number (digits need no percent-encoding either way), doubling as the wire proof for the "single number" form | +| 4 | parity | `chainExpirationFilterDteRangeSyncMirrorsAsync` | a range such as 30-45 | call both options.chain(...) and options.chainAsync(...).join() with OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build(), against two separate CapturingClient instances | both captured request URIs equal "http://localhost/v1/options/chain/AAPL/?dte=30-45" — proves the new String-valued dte query path (chain() wraps chainAsync() via transport.joinSync per ADR-006) is exercised identically on both surfaces, not just the previously-int-typed path | +| 5 | validation | `expirationFilterDteRangeRejectsReversedBounds` | a range such as 30-45 | ExpirationFilter.dte(45, 30) | throws IllegalArgumentException with message containing "min must be <= max" (mirrors StrikeFilter.range's identical message for a reversed min/max pair) | +| 6 | validation | `expirationFilterDteRangeRejectsNegativeMin` | a range such as 30-45 | ExpirationFilter.dte(-1, 10) | throws IllegalArgumentException with message containing "dte must be non-negative" (reuses the exact message the pre-existing single-number dte(int) factory already throws) | +| 7 | validation | `expirationFilterDteListRejectsEmptyList` | a comma-separated list | ExpirationFilter.dte(List.of()) | throws IllegalArgumentException with message containing "days must be non-empty" (mirrors the " must be non-empty" convention e.g. OptionsChainRequest's "symbol must be non-empty") | +| 8 | validation | `expirationFilterDteListRejectsNullList` | a comma-separated list | ExpirationFilter.dte((List) null) | throws NullPointerException with message containing "days" (Objects.requireNonNull(days, "days") convention, per RequestHeadersTest's named-field NPE pattern) | +| 9 | validation | `expirationFilterDteListRejectsNegativeValue` | a comma-separated list | ExpirationFilter.dte(List.of(7, -1, 30)) | throws IllegalArgumentException with message containing "dte must be non-negative" (same non-negative rule applied per-element) | + +Plan reviewer (26 turns, US$ 0.39): approved + +> The plan covers exactly the three wire forms the API description enumerates for the now-string-typed `dte` (single number via the kept regression case, range, comma-separated list), with no invented forms. Validation cases follow verified repository conventions: StrikeFilter.range's "min must be <= max" message, the existing dte(int) "dte must be non-negative" message, the " must be non-empty" pattern from OptionsChainRequest's symbol check, and the Objects.requireNonNull named-field NPE pattern. The comma-encoding claim (raw %2C, decoded for assertion) matches HttpTransport.encodeQueryComponent, and the hyphen-survives-unencoded claim matches URLEncoder's unreserved-set behavior. All cases target OptionsResourceTest.java, matching the repository's convention of testing ExpirationFilter's static factories alongside chain() wire tests rather than in a separate file (no ExpirationFilterTest.java exists). This is a parameter-type change on an existing endpoint, not a new endpoint, so no fixture/response case is required, and ADR-006 sync/async parity for the chain endpoint is already satisfied by the existing general chainSyncMirrorsAsync test — the plan's extra per-form parity case is redundant but not incorrect. No blockers or missing requirements found. + +- no findings + +## 3. tests + +Coder (8 turns, US$ 0.50): + +> Build fails as expected — the new `dte(int, int)` and `dte(List)` overloads don't exist yet (production code untouched, per phase 1b scope). +> +> Summary of tests added to `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` (all pre-existing tests left untouched): +> +> - `chainExpirationFilterDteRangeTranslatesToDteParam` (wire) — `ExpirationFilter.dte(30, 45)` must send `?dte=30-45`. +> - `chainExpirationFilterDteListTranslatesToDteParam` (wire) — `ExpirationFilter.dte(List.of(7, 14, 30))` must send the percent-encoded `dte=7%2C14%2C30` on the wire, decoding to `dte=7,14,30`. +> - `chainExpirationFilterDteRangeSyncMirrorsAsync` (parity) — `chain(...)` and `chainAsync(...)` must send identical requests for the range form. +> - `expirationFilterDteRangeRejectsReversedBounds`, `expirationFilterDteRangeRejectsNegativeMin` (validation) — `dte(min, max)` rejects `min > max` and negative `min`. +> - `expirationFilterDteListRejectsEmptyList`, `expirationFilterDteListRejectsNullList`, `expirationFilterDteListRejectsNegativeValue` (validation) — `dte(List)` rejects empty, null, and negative-containing lists. +> +> The plan's item 3 (regression, single-number form) was already present verbatim at `chainExpirationFilterDteTranslatesToDteParam` — no change needed there. +> +> Confirmed `gradle test --no-daemon --console=plain` fails: `compileTestJava` errors because `ExpirationFilter.dte(int,int)` and `ExpirationFilter.dte(List)` don't exist yet, as expected for this phase. + +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 (14 turns, US$ 0.40): + +> Only the three expected files changed. No commits made, nothing else touched. +> +> Summary for PR body: +> +> - `ExpirationFilter.dte(...)` (in `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java`) now supports all three wire forms the API's `?dte=` string parameter accepts: `dte(int days)` (unchanged, single number — kept for backward compatibility), plus two new overloads `dte(int min, int max)` (inclusive range, e.g. `30-45`, rejecting a reversed or negative `min`) and `dte(List days)` (comma-separated list, rejecting null, empty, or negative-containing lists). The `Dte` record now stores a pre-rendered wire `String` instead of a bare `int`, matching the API's type change from integer to string. +> - `OptionsResource.applyExpirationFilter` now reads `Dte.value()` (the rendered string) instead of `Dte.days()` (the former int). +> - Fixed two Spotless formatting nits (a Javadoc line-wrap and a line-length wrap) flagged by `gradle build`, in the same two files touched by the change plus the test file. +> +> `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass; no other files were modified and nothing was committed. + +Gates: + +- tests, attempt 1: passed +- build, attempt 1: passed + +## 5. review 1 + +Reviewer (8 turns, US$ 0.49): approved + +> The change correctly re-types ExpirationFilter.Dte's wire value from int to String and adds dte(int,int) and dte(List) factories with validation matching the API's expanded string-typed dte parameter (single number, range, comma-list); OptionsResource.applyExpirationFilter is updated accordingly (v.days() -> v.value()). All 9 committed test-plan cases are present in OptionsResourceTest.java with assertions matching the spec verbatim, and none of the pre-existing tests were weakened or deleted. + +- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:113 — record Dte(String value) has no compact constructor, so a consumer can bypass all factory validation via `new ExpirationFilter.Dte(...)` (the record and its canonical constructor are implicitly public as a nested type of a public interface). Widening the backing type from int to a free-form String makes this loophole materially worse than before: a directly-constructed Dte can now carry any malformed string (e.g. "30--45", empty, or stray characters) straight onto the wire, and `new Dte(null)` would be silently dropped by RequestSpec.Builder#query's null-skip rather than erroring, unlike the non-negative/range/non-empty guarantees the static factories advertise. Consider a compact constructor requiring non-null/non-blank value, consistent with OnDate's existing Objects.requireNonNull guard. + +Security (9 turns, US$ 0.37): approved + +> The change turns the options-chain `dte` filter from an int to a server-defined string (single value, range, or comma-separated list), matching the API's type change. Values passed to the wire go through the pre-existing `RequestSpec.Builder.query` → `HttpTransport` URL-encoding path (the same path StrikeFilter already uses for string-valued query params), so no injection or unsafe request construction is introduced. All inputs to the new factories are validated non-negative ints before being stringified. The only notable point is that `ExpirationFilter.Dte` is a public record whose canonical constructor (`new Dte(String value)`) is not compact/validated, so a consumer could bypass the `dte()` factory validation by constructing it directly with an arbitrary string — but since that string is safely URL-encoded before transmission, this is a robustness/API-design concern rather than a security vulnerability, and it mirrors the pre-existing behavior of `StrikeFilter`'s records in this same file. No secrets, credentials, dependency, build, or logging changes are present, and nothing in the diff falls outside the stated `dte` type-change scope. + +- no findings + +## 6. assess + +Analyst (5 turns, US$ 0.15) — public surface: + +> ExpirationFilter.dte(int) is retained but now normalizes to a string wire value internally; two new static factories, dteRange(int,int) and dteList(int,int...), are added to construct multi-value/range DTE filters. The public Dte record's sole component changed from an int (days) to a String (wireValue), which changes its canonical public constructor's parameter type and renames/retypes its accessor from days():int to wireValue():String — any consumer code that called new ExpirationFilter.Dte(int) or Dte.days() will no longer compile. No other public members changed. + +- added: `ExpirationFilter.dteRange(int, int)` — New static factory producing a Dte filter with wire form dte=min-max; throws IllegalArgumentException if either value is negative or minDays > maxDays. +- added: `ExpirationFilter.dteList(int, int...)` — New static factory producing a Dte filter with wire form dte=d1,d2,...; throws IllegalArgumentException if any value is negative. Varargs signature guarantees at least one element. +- changed: `ExpirationFilter.Dte.wireValue()` — Record component/accessor renamed and retyped from days():int to wireValue():String; a consumer calling .days() on a Dte no longer compiles. +- removed: `ExpirationFilter.Dte.days()` — Removed as part of the record component rename to wireValue(). +- changed: `ExpirationFilter.Dte(String)` — Canonical public record constructor's parameter type changed from int to String (previously Dte(int)); source-incompatible for any consumer constructing Dte directly. +- changed: `ExpirationFilter.dte(int)` — Signature and negative-value validation unchanged, but now builds a Dte wrapping Integer.toString(days) instead of the raw int, consistent with the new String-based Dte representation. + +Computed by the pipeline: **major**, 1.0.0 → 2.0.0; changelog entry written + +## 7. changelog + +Coder (6 turns, US$ 0.21): + +> Added a `## [Unreleased]` changelog entry documenting the breaking `ExpirationFilter.Dte`/`dte` API change (new `dteRange`/`dteList` factories, `Dte.days()` → `wireValue()` rename, `Dte(int)` → `Dte(String)`) in response to Market Data API 1.4.0's `dte` parameter type change, with migration guidance for affected consumers — only `CHANGELOG.md` was touched in this step. + +Gates: + +- build, attempt 1: passed + +## Totals + +- Version: major · 1.0.0 → 2.0.0 +- Coder runs: 5 +- Review runs: 4 +- Gates run: 4 (1 failed) +- Files changed by the agent: 5 +- Turns: 140 +- Tokens: 6,691,085 +- Estimated cost: US$ 3.68 + +### Agent runs + +- coder · claude-haiku-4-5-20251001, claude-sonnet-5 · 1,409,277 tokens (53,905 in, 1,330,448 cached, 24,924 out) · 36 turns · US$ 0.73 +- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 515,871 tokens (31,056 in, 471,743 cached, 13,072 out) · 24 turns · US$ 0.35 +- coder · claude-sonnet-5 · 236,882 tokens (3,980 in, 229,643 cached, 3,259 out) · 4 turns · US$ 0.09 +- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 712,441 tokens (39,072 in, 663,462 cached, 9,907 out) · 26 turns · US$ 0.39 +- coder · claude-sonnet-5 · 747,432 tokens (81,324 in, 661,560 cached, 4,548 out) · 8 turns · US$ 0.50 +- coder · claude-sonnet-5 · 1,486,006 tokens (13,024 in, 1,467,999 cached, 4,983 out) · 14 turns · US$ 0.40 +- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 399,802 tokens (67,366 in, 320,101 cached, 12,335 out) · 8 turns · US$ 0.49 +- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 375,465 tokens (57,131 in, 313,572 cached, 4,762 out) · 9 turns · US$ 0.37 +- analyst · claude-haiku-4-5-20251001, claude-sonnet-5 · 101,699 tokens (26,518 in, 72,800 cached, 2,381 out) · 5 turns · US$ 0.15 +- coder · claude-sonnet-5 · 706,210 tokens (9,231 in, 693,352 cached, 3,627 out) · 6 turns · US$ 0.21 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( From a8a11c76a12f60f29c1985679abdb7392bc26bbc Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 17 Sep 2026 12:11:44 -0300 Subject: [PATCH 2/2] [FAILED] sdk-sync: API 1.4.0+f19c2d4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: 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. tests (attempt 2): 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/`. implement: 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. --- .sdk-sync/1.4.0-f19c2d4.md | 176 +++++++++++++++---------------------- 1 file changed, 71 insertions(+), 105 deletions(-) diff --git a/.sdk-sync/1.4.0-f19c2d4.md b/.sdk-sync/1.4.0-f19c2d4.md index 253e301..fb30c0c 100644 --- a/.sdk-sync/1.4.0-f19c2d4.md +++ b/.sdk-sync/1.4.0-f19c2d4.md @@ -6,8 +6,8 @@ | SDK | sdk-java | | Branch | `sdk-sync/1.4.0-f19c2d4` | | API surface | `9c18fbb1f58e` → `72b3d15f59ad` | -| Date | 2026-09-17 14:53:04Z | -| Outcome | **done** | +| 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 @@ -15,69 +15,57 @@ ## 1. test plan -Coder (36 turns, US$ 0.73) — test plan, 9 case(s) (wire 2, regression 1, validation 5, parity 1): +Coder (15 turns, US$ 0.59) — test plan, 8 case(s) (wire 3, regression 1, validation 3, parity 1): -> The delta only touches the ?dte= query parameter on GET /v1/options/chain/{underlying}/ (int -> string), enumerating exactly three accepted forms: a single number, a range like "30-45", and a comma-separated list. Nothing else in the surface changes — the unrelated `dte` field on the expirations response row stays an Integer. Following ADR-008's sealed-type-per-mutually-exclusive-axis convention (the same shape StrikeFilter already uses for strike's exact/range/comparison forms), the plan keeps ExpirationFilter.Dte as a single record but backs it with a formatted wire String instead of a bare int, and widens ExpirationFilter's dte(...) factory into three overloads: the existing dte(int days) (unchanged signature/behavior, satisfies the regression requirement), a new dte(int min, int max) for the range form, and a new dte(List days) for the comma-separated list form — mirroring existing conventions (StrikeFilter.range's "min must be <= max" message, the existing "dte must be non-negative" message, StocksResource's List symbols -> comma-joined wire convention with unencoded commas, and Objects.requireNonNull's named-field NPE pattern). OptionsResource's applyExpirationFilter switches from v.days() to the record's formatted String accessor. All new tests land in the existing single-file, single-package test convention at src/test/java/com/marketdata/sdk/OptionsResourceTest.java, alongside the current Dte wire/validation tests they extend. +> 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 | `chainExpirationFilterDteRangeTranslatesToDteParam` | a range such as 30-45 | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=30-45" | -| 2 | wire | `chainExpirationFilterDteListTranslatesToDteParam` | a comma-separated list | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(List.of(7, 14, 30))).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=7,14,30" (commas sent unencoded, matching the existing symbols=AAPL,MSFT convention) | -| 3 | regression | `chainExpirationFilterDteTranslatesToDteParam` | a single number | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()).join() — the existing test at OptionsResourceTest.java:965, kept verbatim | client.captured.get(0).uri().toString() still equals "http://localhost/v1/options/chain/AAPL/?dte=30" after dte becomes string-typed on the wire — the pre-existing int-arg dte(int) factory keeps compiling and keeps sending the bare number, doubling as the wire proof for the "single number" form | -| 4 | parity | `chainExpirationFilterDteRangeSyncMirrorsAsync` | a range such as 30-45 | call both options.chain(...) and options.chainAsync(...).join() with OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build(), against two separate CapturingClient instances | both captured request URIs equal "http://localhost/v1/options/chain/AAPL/?dte=30-45" — proves the new String-valued dte query path (chain() wraps chainAsync() via transport.joinSync per ADR-006) is exercised identically on both surfaces, not just the previously-int-typed path | -| 5 | validation | `expirationFilterDteRangeRejectsReversedBounds` | a range such as 30-45 | ExpirationFilter.dte(45, 30) | throws IllegalArgumentException with message containing "min must be <= max" (mirrors StrikeFilter.range's identical message for a reversed min/max pair) | -| 6 | validation | `expirationFilterDteRangeRejectsNegativeMin` | a range such as 30-45 | ExpirationFilter.dte(-1, 10) | throws IllegalArgumentException with message containing "dte must be non-negative" (reuses the exact message the pre-existing single-number dte(int) factory already throws) | -| 7 | validation | `expirationFilterDteListRejectsEmptyList` | a comma-separated list | ExpirationFilter.dte(List.of()) | throws IllegalArgumentException with message containing "days must be non-empty" (mirrors the " must be non-empty" convention e.g. OptionsChainRequest's "symbol must be non-empty") | -| 8 | validation | `expirationFilterDteListRejectsNullList` | a comma-separated list | ExpirationFilter.dte((List) null) | throws NullPointerException with message containing "days" (Objects.requireNonNull(days, "days") convention, per RequestHeadersTest's named-field NPE pattern) | -| 9 | validation | `expirationFilterDteListRejectsNegativeValue` | a comma-separated list | ExpirationFilter.dte(List.of(7, -1, 30)) | throws IllegalArgumentException with message containing "dte must be non-negative" (same non-negative rule applied per-element) | +| 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 (24 turns, US$ 0.35): 1 blocking finding(s) +Plan reviewer (18 turns, US$ 0.31): approved -> The plan correctly enumerates the three wire forms the delta describes (single number via the kept regression test, range, and comma list), adds a matching regression case, layers on validation cases that mirror existing repo conventions (StrikeFilter.range's 'min must be <= max' message, the ' must be non-empty' pattern, and the named-field NullPointerException pattern from RequestHeadersTest), and doesn't invent any unlisted form (no operators/wildcards). The one defect is the comma-list wire case, which asserts an un-encoded URL that contradicts how this codebase's HttpTransport actually percent-encodes query values (commas become %2C, as evidenced by every other comma-bearing assertion in the suite decoding first) — so as written it does not prove the actual request sent for that form. - -- [major] src/test/java/com/marketdata/sdk/OptionsResourceTest.java — Wire case 2 (chainExpirationFilterDteListTranslatesToDteParam) asserts client.captured.get(0).uri().toString() equals the literal 'http://localhost/v1/options/chain/AAPL/?dte=7,14,30'. HttpTransport.encodeQueryComponent (HttpTransport.java) percent-encodes every query value through URLEncoder, which encodes ',' as '%2C' (unlike '-', which is in URLEncoder's unreserved set and is why the existing strike-range test at line 1054 can compare a literal hyphen). Every existing comma-bearing assertion in this repo (StocksResourceTest.java:283-300 for symbols=AAPL,MSFT, OptionsResourceTest.java:619-620/710-711 for columns=...) first runs the captured URI through URLDecoder before comparing, which only makes sense because the wire form is actually '%2C'-encoded. The plan's own justification ('commas sent unencoded, matching the existing symbols=AAPL,MSFT convention') is backwards. The test as specified will not assert the real outbound bytes; it should either decode the captured URI with URLDecoder before comparing (matching convention) or assert the percent-encoded form 'dte=7%2C14%2C30'. - -## 2. test plan (revised 1) - -Coder (4 turns, US$ 0.09) — test plan, 9 case(s) (wire 2, regression 1, validation 5, parity 1): - -> The delta only touches the ?dte= query parameter on GET /v1/options/chain/{underlying}/ (int -> string), enumerating exactly three accepted forms: a single number, a range like "30-45", and a comma-separated list. Nothing else in the surface changes — the unrelated `dte` field on the expirations response row stays an Integer. Following ADR-008's sealed-type-per-mutually-exclusive-axis convention (the same shape StrikeFilter already uses for strike's exact/range/comparison forms), the plan keeps ExpirationFilter.Dte as a single record but backs it with a formatted wire String instead of a bare int, and widens ExpirationFilter's dte(...) factory into three overloads: the existing dte(int days) (unchanged signature/behavior, satisfies the regression requirement), a new dte(int min, int max) for the range form, and a new dte(List days) for the comma-separated list form — mirroring existing conventions (StrikeFilter.range's "min must be <= max" message, the existing "dte must be non-negative" message, StocksResource's List symbols -> comma-joined wire convention, and Objects.requireNonNull's named-field NPE pattern). OptionsResource's applyExpirationFilter switches from v.days() to the record's formatted String accessor. Corrected from revision 1: the comma-separated-list wire case now accounts for HttpTransport.encodeQueryComponent percent-encoding ',' to '%2C' via URLEncoder — it asserts the raw captured URI contains the encoded form and only compares the human-readable form after URLDecoder.decode(...), matching the repo's existing columns=... and symbols=... assertions instead of asserting a literal unencoded comma. The range form's '-' needed no such fix since URLEncoder leaves '-' unescaped, consistent with the existing strike-range test. All new tests land in the existing single-file, single-package test convention at src/test/java/com/marketdata/sdk/OptionsResourceTest.java, alongside the current Dte wire/validation tests they extend. - -| # | category | test | API form | input | expected | -|---|---|---|---|---|---| -| 1 | wire | `chainExpirationFilterDteRangeTranslatesToDteParam` | a range such as 30-45 | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build()).join() | client.captured.get(0).uri().toString() equals "http://localhost/v1/options/chain/AAPL/?dte=30-45" ('-' is in URLEncoder's unreserved set, so it survives encoding unescaped, matching the existing strikeFilter range test at OptionsResourceTest.java:1054 which compares the literal hyphen directly) | -| 2 | wire | `chainExpirationFilterDteListTranslatesToDteParam` | a comma-separated list | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(List.of(7, 14, 30))).build()).join() | client.captured.get(0).uri() is decoded with java.net.URLDecoder.decode(..., UTF_8) first (per the columns=... convention at OptionsResourceTest.java:617-620), then the decoded string contains "dte=7,14,30"; the raw, undecoded URI must contain the percent-encoded form "dte=7%2C14%2C30" since HttpTransport.encodeQueryComponent runs every query value through URLEncoder, which escapes ',' to '%2C' | -| 3 | regression | `chainExpirationFilterDteTranslatesToDteParam` | a single number | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()).join() — the existing test at OptionsResourceTest.java:965, kept verbatim | client.captured.get(0).uri().toString() still equals "http://localhost/v1/options/chain/AAPL/?dte=30" after dte becomes string-typed on the wire — the pre-existing int-arg dte(int) factory keeps compiling and keeps sending the bare number (digits need no percent-encoding either way), doubling as the wire proof for the "single number" form | -| 4 | parity | `chainExpirationFilterDteRangeSyncMirrorsAsync` | a range such as 30-45 | call both options.chain(...) and options.chainAsync(...).join() with OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30, 45)).build(), against two separate CapturingClient instances | both captured request URIs equal "http://localhost/v1/options/chain/AAPL/?dte=30-45" — proves the new String-valued dte query path (chain() wraps chainAsync() via transport.joinSync per ADR-006) is exercised identically on both surfaces, not just the previously-int-typed path | -| 5 | validation | `expirationFilterDteRangeRejectsReversedBounds` | a range such as 30-45 | ExpirationFilter.dte(45, 30) | throws IllegalArgumentException with message containing "min must be <= max" (mirrors StrikeFilter.range's identical message for a reversed min/max pair) | -| 6 | validation | `expirationFilterDteRangeRejectsNegativeMin` | a range such as 30-45 | ExpirationFilter.dte(-1, 10) | throws IllegalArgumentException with message containing "dte must be non-negative" (reuses the exact message the pre-existing single-number dte(int) factory already throws) | -| 7 | validation | `expirationFilterDteListRejectsEmptyList` | a comma-separated list | ExpirationFilter.dte(List.of()) | throws IllegalArgumentException with message containing "days must be non-empty" (mirrors the " must be non-empty" convention e.g. OptionsChainRequest's "symbol must be non-empty") | -| 8 | validation | `expirationFilterDteListRejectsNullList` | a comma-separated list | ExpirationFilter.dte((List) null) | throws NullPointerException with message containing "days" (Objects.requireNonNull(days, "days") convention, per RequestHeadersTest's named-field NPE pattern) | -| 9 | validation | `expirationFilterDteListRejectsNegativeValue` | a comma-separated list | ExpirationFilter.dte(List.of(7, -1, 30)) | throws IllegalArgumentException with message containing "dte must be non-negative" (same non-negative rule applied per-element) | - -Plan reviewer (26 turns, US$ 0.39): approved - -> The plan covers exactly the three wire forms the API description enumerates for the now-string-typed `dte` (single number via the kept regression case, range, comma-separated list), with no invented forms. Validation cases follow verified repository conventions: StrikeFilter.range's "min must be <= max" message, the existing dte(int) "dte must be non-negative" message, the " must be non-empty" pattern from OptionsChainRequest's symbol check, and the Objects.requireNonNull named-field NPE pattern. The comma-encoding claim (raw %2C, decoded for assertion) matches HttpTransport.encodeQueryComponent, and the hyphen-survives-unencoded claim matches URLEncoder's unreserved-set behavior. All cases target OptionsResourceTest.java, matching the repository's convention of testing ExpirationFilter's static factories alongside chain() wire tests rather than in a separate file (no ExpirationFilterTest.java exists). This is a parameter-type change on an existing endpoint, not a new endpoint, so no fixture/response case is required, and ADR-006 sync/async parity for the chain endpoint is already satisfied by the existing general chainSyncMirrorsAsync test — the plan's extra per-form parity case is redundant but not incorrect. No blockers or missing requirements found. +> 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 -## 3. tests +## 2. tests -Coder (8 turns, US$ 0.50): +Coder (10 turns, US$ 0.72): -> Build fails as expected — the new `dte(int, int)` and `dte(List)` overloads don't exist yet (production code untouched, per phase 1b scope). +> Build fails as expected — `compileTestJava` errors on the not-yet-implemented `ExpirationFilter.dteRange(...)` and `ExpirationFilter.dteList(...)` symbols. Stopping here per instructions. > -> Summary of tests added to `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` (all pre-existing tests left untouched): +> Summary: > -> - `chainExpirationFilterDteRangeTranslatesToDteParam` (wire) — `ExpirationFilter.dte(30, 45)` must send `?dte=30-45`. -> - `chainExpirationFilterDteListTranslatesToDteParam` (wire) — `ExpirationFilter.dte(List.of(7, 14, 30))` must send the percent-encoded `dte=7%2C14%2C30` on the wire, decoding to `dte=7,14,30`. -> - `chainExpirationFilterDteRangeSyncMirrorsAsync` (parity) — `chain(...)` and `chainAsync(...)` must send identical requests for the range form. -> - `expirationFilterDteRangeRejectsReversedBounds`, `expirationFilterDteRangeRejectsNegativeMin` (validation) — `dte(min, max)` rejects `min > max` and negative `min`. -> - `expirationFilterDteListRejectsEmptyList`, `expirationFilterDteListRejectsNullList`, `expirationFilterDteListRejectsNegativeValue` (validation) — `dte(List)` rejects empty, null, and negative-containing lists. +> - 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. > -> The plan's item 3 (regression, single-number form) was already present verbatim at `chainExpirationFilterDteTranslatesToDteParam` — no change needed there. +> Summary: > -> Confirmed `gradle test --no-daemon --console=plain` fails: `compileTestJava` errors because `ExpirationFilter.dte(int,int)` and `ExpirationFilter.dte(List)` don't exist yet, as expected for this phase. +> - 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: @@ -85,17 +73,21 @@ Gates: ## 4. implement -Coder (14 turns, US$ 0.40): +Coder (12 turns, US$ 0.47): -> Only the three expected files changed. No commits made, nothing else touched. +> Build is green. > -> Summary for PR body: +> **Summary for PR body:** > -> - `ExpirationFilter.dte(...)` (in `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java`) now supports all three wire forms the API's `?dte=` string parameter accepts: `dte(int days)` (unchanged, single number — kept for backward compatibility), plus two new overloads `dte(int min, int max)` (inclusive range, e.g. `30-45`, rejecting a reversed or negative `min`) and `dte(List days)` (comma-separated list, rejecting null, empty, or negative-containing lists). The `Dte` record now stores a pre-rendered wire `String` instead of a bare `int`, matching the API's type change from integer to string. -> - `OptionsResource.applyExpirationFilter` now reads `Dte.value()` (the rendered string) instead of `Dte.days()` (the former int). -> - Fixed two Spotless formatting nits (a Javadoc line-wrap and a line-length wrap) flagged by `gradle build`, in the same two files touched by the change plus the test file. +> `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. > -> `gradle test --no-daemon --console=plain` and `gradle build --no-daemon --console=plain` both pass; no other files were modified and nothing was committed. +> - `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: @@ -104,63 +96,37 @@ Gates: ## 5. review 1 -Reviewer (8 turns, US$ 0.49): approved +Reviewer (4 turns, US$ 0.39): approved -> The change correctly re-types ExpirationFilter.Dte's wire value from int to String and adds dte(int,int) and dte(List) factories with validation matching the API's expanded string-typed dte parameter (single number, range, comma-list); OptionsResource.applyExpirationFilter is updated accordingly (v.days() -> v.value()). All 9 committed test-plan cases are present in OptionsResourceTest.java with assertions matching the spec verbatim, and none of the pre-existing tests were weakened or deleted. +> 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:113 — record Dte(String value) has no compact constructor, so a consumer can bypass all factory validation via `new ExpirationFilter.Dte(...)` (the record and its canonical constructor are implicitly public as a nested type of a public interface). Widening the backing type from int to a free-form String makes this loophole materially worse than before: a directly-constructed Dte can now carry any malformed string (e.g. "30--45", empty, or stray characters) straight onto the wire, and `new Dte(null)` would be silently dropped by RequestSpec.Builder#query's null-skip rather than erroring, unlike the non-negative/range/non-empty guarantees the static factories advertise. Consider a compact constructor requiring non-null/non-blank value, consistent with OnDate's existing Objects.requireNonNull guard. +- [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 (9 turns, US$ 0.37): approved +Security (5 turns, US$ 0.37): approved -> The change turns the options-chain `dte` filter from an int to a server-defined string (single value, range, or comma-separated list), matching the API's type change. Values passed to the wire go through the pre-existing `RequestSpec.Builder.query` → `HttpTransport` URL-encoding path (the same path StrikeFilter already uses for string-valued query params), so no injection or unsafe request construction is introduced. All inputs to the new factories are validated non-negative ints before being stringified. The only notable point is that `ExpirationFilter.Dte` is a public record whose canonical constructor (`new Dte(String value)`) is not compact/validated, so a consumer could bypass the `dte()` factory validation by constructing it directly with an arbitrary string — but since that string is safely URL-encoded before transmission, this is a robustness/API-design concern rather than a security vulnerability, and it mirrors the pre-existing behavior of `StrikeFilter`'s records in this same file. No secrets, credentials, dependency, build, or logging changes are present, and nothing in the diff falls outside the stated `dte` type-change scope. - -- no findings +> 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. -## 6. assess +- [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. -Analyst (5 turns, US$ 0.15) — public surface: - -> ExpirationFilter.dte(int) is retained but now normalizes to a string wire value internally; two new static factories, dteRange(int,int) and dteList(int,int...), are added to construct multi-value/range DTE filters. The public Dte record's sole component changed from an int (days) to a String (wireValue), which changes its canonical public constructor's parameter type and renames/retypes its accessor from days():int to wireValue():String — any consumer code that called new ExpirationFilter.Dte(int) or Dte.days() will no longer compile. No other public members changed. - -- added: `ExpirationFilter.dteRange(int, int)` — New static factory producing a Dte filter with wire form dte=min-max; throws IllegalArgumentException if either value is negative or minDays > maxDays. -- added: `ExpirationFilter.dteList(int, int...)` — New static factory producing a Dte filter with wire form dte=d1,d2,...; throws IllegalArgumentException if any value is negative. Varargs signature guarantees at least one element. -- changed: `ExpirationFilter.Dte.wireValue()` — Record component/accessor renamed and retyped from days():int to wireValue():String; a consumer calling .days() on a Dte no longer compiles. -- removed: `ExpirationFilter.Dte.days()` — Removed as part of the record component rename to wireValue(). -- changed: `ExpirationFilter.Dte(String)` — Canonical public record constructor's parameter type changed from int to String (previously Dte(int)); source-incompatible for any consumer constructing Dte directly. -- changed: `ExpirationFilter.dte(int)` — Signature and negative-value validation unchanged, but now builds a Dte wrapping Integer.toString(days) instead of the raw int, consistent with the new String-based Dte representation. - -Computed by the pipeline: **major**, 1.0.0 → 2.0.0; changelog entry written - -## 7. changelog - -Coder (6 turns, US$ 0.21): - -> Added a `## [Unreleased]` changelog entry documenting the breaking `ExpirationFilter.Dte`/`dte` API change (new `dteRange`/`dteList` factories, `Dte.days()` → `wireValue()` rename, `Dte(int)` → `Dte(String)`) in response to Market Data API 1.4.0's `dte` parameter type change, with migration guidance for affected consumers — only `CHANGELOG.md` was touched in this step. - -Gates: - -- build, attempt 1: passed +## 6. changelog ## Totals -- Version: major · 1.0.0 → 2.0.0 -- Coder runs: 5 -- Review runs: 4 -- Gates run: 4 (1 failed) -- Files changed by the agent: 5 -- Turns: 140 -- Tokens: 6,691,085 -- Estimated cost: US$ 3.68 +- 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 · 1,409,277 tokens (53,905 in, 1,330,448 cached, 24,924 out) · 36 turns · US$ 0.73 -- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 515,871 tokens (31,056 in, 471,743 cached, 13,072 out) · 24 turns · US$ 0.35 -- coder · claude-sonnet-5 · 236,882 tokens (3,980 in, 229,643 cached, 3,259 out) · 4 turns · US$ 0.09 -- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 712,441 tokens (39,072 in, 663,462 cached, 9,907 out) · 26 turns · US$ 0.39 -- coder · claude-sonnet-5 · 747,432 tokens (81,324 in, 661,560 cached, 4,548 out) · 8 turns · US$ 0.50 -- coder · claude-sonnet-5 · 1,486,006 tokens (13,024 in, 1,467,999 cached, 4,983 out) · 14 turns · US$ 0.40 -- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 399,802 tokens (67,366 in, 320,101 cached, 12,335 out) · 8 turns · US$ 0.49 -- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 375,465 tokens (57,131 in, 313,572 cached, 4,762 out) · 9 turns · US$ 0.37 -- analyst · claude-haiku-4-5-20251001, claude-sonnet-5 · 101,699 tokens (26,518 in, 72,800 cached, 2,381 out) · 5 turns · US$ 0.15 -- coder · claude-sonnet-5 · 706,210 tokens (9,231 in, 693,352 cached, 3,627 out) · 6 turns · US$ 0.21 +- 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