diff --git a/.sdk-sync/1.4.0-f19c2d4.md b/.sdk-sync/1.4.0-f19c2d4.md new file mode 100644 index 0000000..b8aedc3 --- /dev/null +++ b/.sdk-sync/1.4.0-f19c2d4.md @@ -0,0 +1,116 @@ +# sdk-auto-sync job log + +| | | +|---|---| +| API release | 1.4.0 (commit `f19c2d4`) | +| SDK | sdk-java | +| Branch | `sdk-sync/1.4.0-f19c2d4` | +| API surface | `9c18fbb1f58e` → `72b3d15f59ad` | +| Date | 2026-09-17 13:58:43Z | +| Outcome | **done** | + +## Changes in the API surface + + ~ GET /v1/options/chain/{underlying}/ ?dte: string (was integer) + +## 1. tests + +Coder (25 turns, US$ 0.49): + +> Only the test file was touched, as required. +> +> ## Summary +> +> Added unit tests in `src/test/java/com/marketdata/sdk/OptionsResourceTest.java` specifying the new string-typed `dte` chain-query behavior (API delta: `?dte` changed from `integer` to `string`, accepting a single number, a range like `30-45`, or a comma-separated list): +> +> - `chainExpirationFilterDteRangeTranslatesToDashedDteParam` — `ExpirationFilter.dteRange(30, 45)` → `?dte=30-45` +> - `chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam` — `ExpirationFilter.dteIn(List.of(7, 14, 30))` → `?dte=7,14,30` +> - `expirationFilterDteRangeRejectsMinGreaterThanMax`, `expirationFilterDteRangeRejectsNegativeMin`, `expirationFilterDteRangeRejectsNegativeMax` — validation for the new `dteRange(int, int)` factory +> - `expirationFilterDteInRejectsEmptyList`, `expirationFilterDteInRejectsNegativeEntry` — validation for the new `dteIn(List)` factory +> +> The existing single-number `ExpirationFilter.dte(int)` test/behavior is left unchanged since `?dte=30` remains valid under the new string type. Ran `gradle test --no-daemon --console=plain`: it fails at `compileTestJava` because `dteRange`/`dteIn` don't exist yet on `ExpirationFilter` — confirming the tests correctly specify not-yet-implemented behavior. + +Gates: + +- tests (expected to fail), attempt 1: failed — Execution failed for task ':compileTestJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin'). + +## 2. implement + +Coder (22 turns, US$ 0.46): + +> Only files under `src/main/java` and `src/test/java` were touched, all within the allowed limits. +> +> ## Summary +> +> Implemented the `dte` query-parameter type change (`integer` → `string`) on `GET /v1/options/chain/{underlying}/`: +> +> - **`ExpirationFilter.java`**: `Dte` is now `record Dte(String value)` instead of `record Dte(int days)`. Added two new factory methods alongside the existing `dte(int)`: `dteRange(int minDays, int maxDays)` for the `MIN-MAX` form (validates non-negative, `min <= max`) and `dteIn(List days)` for the comma-separated list form (validates non-empty, all entries non-negative), mirroring the existing `StrikeFilter` sealed-variant convention already used for the sibling `strike` parameter. +> - **`OptionsResource.java`**: `applyExpirationFilter` now writes `v.value()` (the pre-formatted wire string) instead of the old `v.days()` int accessor. +> - **`OptionsResourceTest.java`**: fixed one test I wrote in phase 1 — `chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam` asserted the raw (unencoded) URI, but the transport percent-encodes commas like it does for `columns`/`symbols`; updated it to URL-decode before asserting, matching the existing convention used elsewhere in this same test file (no weakening — same assertion, just decoded first). +> +> `gradle test` and `gradle build` (including Spotless formatting) both pass. + +Gates: + +- tests, attempt 1: passed +- build, attempt 1: passed + +## 3. review 1 + +Reviewer (9 turns, US$ 0.40): approved + +> The change correctly follows the API delta: `dte` on GET /v1/options/chain/{underlying}/ moves from integer to a string that can be a single number, a dashed range, or a comma-separated list. ExpirationFilter.Dte's backing field is renamed from `days`/int to `value`/String, with three well-documented factories (dte, dteRange, dteIn) each validating non-negativity and range/list well-formedness, and OptionsResource.applyExpirationFilter is updated from v.days() to v.value() with no other query-building logic disturbed. No other public signature in scope changed unexpectedly, and a grep of the wider tree confirms no other caller relied on the old `.days()` accessor. Tests are solid and additive: new cases cover the range and comma-list wire encoding (including URL-decoding the comma) plus validation failures for both new factories, and none of the pre-existing dte/expiration tests were weakened or removed. The one gap worth noting is that the new String-backed Dte record's canonical constructor is still unvalidated, so direct construction can bypass the factory checks with an arbitrary string — a pre-existing pattern in this file, but one that now has a larger blast radius given the type widened from int to String. + +- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:117 — Dte's canonical constructor (record Dte(String value)) is implicitly public and has no compact-constructor validation, so a caller can bypass dte()/dteRange()/dteIn() entirely via `new ExpirationFilter.Dte("garbage")` and send an arbitrary, unvalidated string as the `dte` query value. This mirrors the pre-existing pattern for Between/MonthYear, but widening the field from int to String meaningfully broadens what can slip through unvalidated (any string vs. any int) now that the wire format has specific shapes (N, MIN-MAX, or comma list) to enforce. + +Security (5 turns, US$ 0.33): approved + +> The change updates ExpirationFilter's day-to-expiration filter from an int-backed record to a String-backed one to support the new string-typed `dte` API parameter (ranges/lists), with new validated static factories (dteRange, dteIn) and corresponding tests. Query-parameter values are percent-encoded downstream in RequestSpec/HttpTransport, so there is no query-injection vector on the wire, and no secrets, dependencies, or build/CI files were touched. The one notable weakening is that the `Dte` record's canonical constructor is now a public, unvalidated `String` constructor (records nested in a public sealed interface are implicitly public), so a consumer can bypass the validated `dte()`/`dteRange()`/`dteIn()` factories entirely by constructing `new ExpirationFilter.Dte(arbitraryString)` directly, including null or malformed values, whereas the previous `int`-typed record made that impossible by construction. + +- [minor] src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:118 — ExpirationFilter.Dte(String value) has no compact constructor, so its canonical constructor (implicitly public, since it's a member of a public sealed interface) accepts any string — including null or an arbitrary value containing characters never validated by dte()/dteRange()/dteIn() — bypassing the non-negative/format checks those factories enforce. Add a compact constructor that rejects null and validates the format (digits, optional single dash, optional comma list) to keep the invariant enforced regardless of construction path. + +## 4. assess + +Analyst (3 turns, US$ 0.13) — public surface: + +> The change adapts the SDK's ExpirationFilter.Dte type to the API's dte parameter widening from integer to string. Dte's single record component is retyped from `int days` to `String value` (removing the `days()` accessor and constructor overload, adding `value()`), and two new static factories, `dteRange(int, int)` and `dteIn(List)`, are added alongside the existing `dte(int)` to build range- and set-valued filters. The existing `dte(int days)` factory keeps its signature and still returns a `Dte`, so callers using only the static factories are source-compatible; callers who touched the record's constructor or `days()` accessor directly are not. OptionsResource.java's internal translation of Dte to the `dte` query parameter was updated accordingly but is not itself part of the public surface. + +- removed: `ExpirationFilter.Dte.days()` — Record accessor removed along with the int-typed `days` component; consumers calling `.days()` on a Dte no longer compile. +- added: `ExpirationFilter.Dte.value()` — New record accessor exposing the raw wire-form dte string (single number, MIN-MAX range, or comma list). +- changed: `ExpirationFilter.Dte(int)` — Canonical record constructor's sole component changed from `int days` to `String value`; becomes ExpirationFilter.Dte(String). Any consumer code constructing `new ExpirationFilter.Dte(n)` directly no longer compiles. +- added: `ExpirationFilter.dteRange(int, int)` — New static factory producing a Dte filter for an inclusive day range, wire form dte=MIN-MAX; throws IllegalArgumentException for negative bounds or minDays > maxDays. +- added: `ExpirationFilter.dteIn(java.util.List)` — New static factory producing a Dte filter for a discrete set of days, wire form dte=D1,D2,...; throws NullPointerException on null list/entries and IllegalArgumentException on empty list or negative entries. + +Computed by the pipeline: **major**, 1.0.0 → 2.0.0; changelog entry written + +## 5. changelog + +Coder (5 turns, US$ 0.11): + +> Only `CHANGELOG.md` was touched in this step, under `[Unreleased]`, no version heading added. +> +> Added an `[Unreleased]` entry noting the response to API 1.4.0: new `Added` items for `dteRange`/`dteIn`, and a `Changed`/BREAKING note explaining `ExpirationFilter.Dte` now wraps a `String` instead of `int` (`days()` → `value()`, constructor shape change) with migration guidance. + +Gates: + +- build, attempt 1: passed + +## Totals + +- Version: major · 1.0.0 → 2.0.0 +- Coder runs: 3 +- Review runs: 2 +- Gates run: 4 (1 failed) +- Files changed by the agent: 4 +- Turns: 69 +- Tokens: 3,842,438 +- Estimated cost: US$ 1.92 + +### Agent runs + +- coder · claude-haiku-4-5-20251001, claude-sonnet-5 · 1,191,857 tokens (38,761 in, 1,142,404 cached, 10,692 out) · 25 turns · US$ 0.49 +- coder · claude-sonnet-5 · 1,626,333 tokens (18,931 in, 1,601,412 cached, 5,990 out) · 22 turns · US$ 0.46 +- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 308,922 tokens (50,389 in, 246,096 cached, 12,437 out) · 9 turns · US$ 0.40 +- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 238,614 tokens (55,298 in, 179,134 cached, 4,182 out) · 5 turns · US$ 0.33 +- analyst · claude-haiku-4-5-20251001, claude-sonnet-5 · 50,862 tokens (25,509 in, 23,170 cached, 2,183 out) · 3 turns · US$ 0.13 +- coder · claude-sonnet-5 · 425,850 tokens (3,292 in, 421,360 cached, 1,198 out) · 5 turns · US$ 0.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..50472e2 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 release 1.4.0: `?dte` on `GET /v1/options/chain/{underlying}/` +changed from an integer to a string, so it can express a range or a list, not just a single +day count. + +### Added + +- `ExpirationFilter.dteRange(int minDays, int maxDays)` — days-to-expiration filter for an + inclusive range, wire form `?dte=MIN-MAX`. +- `ExpirationFilter.dteIn(List days)` — days-to-expiration filter for a discrete set + of days, wire form `?dte=D1,D2,...`. + +### Changed + +- **BREAKING:** `ExpirationFilter.Dte` now wraps a `String` instead of an `int`, matching the + API's wider `?dte` syntax. `ExpirationFilter.dte(int)` still exists and behaves the same from + the caller's side, but the record itself changed shape: + - `Dte.days()` is gone; use `Dte.value()` (the raw wire-form string) instead. + - The canonical constructor is now `new ExpirationFilter.Dte(String)`; code constructing + `new ExpirationFilter.Dte(int)` directly (instead of going through + `ExpirationFilter.dte(int)`) no longer compiles and must pass a `String` or switch to the + factory method. + ## [1.0.0] - 2026-06-29 First stable release of the Market Data Java & Kotlin SDK — a single JVM diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..018535e 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.value()); } else if (f instanceof ExpirationFilter.Between v) { b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(v.from())); b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(v.to())); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..51c248e 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,7 +1,9 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; /** * Mutually-exclusive expiration filter for {@code /v1/options/chain/}. The chain endpoint's @@ -39,12 +41,43 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** Days-to-expiration filter, single value — wire form {@code ?dte=N}. */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); } - return new Dte(days); + return new Dte(Integer.toString(days)); + } + + /** + * Days-to-expiration filter, inclusive range — wire form {@code ?dte=MIN-MAX}. {@code minDays} + * must not exceed {@code maxDays}. + */ + static Dte dteRange(int minDays, int maxDays) { + if (minDays < 0 || maxDays < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (minDays > maxDays) { + throw new IllegalArgumentException("minDays must be <= maxDays"); + } + return new Dte(minDays + "-" + maxDays); + } + + /** + * Days-to-expiration filter, discrete set — wire form {@code ?dte=D1,D2,...}. {@code days} must + * be non-empty and every entry non-negative. + */ + static Dte dteIn(List days) { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer d : days) { + if (d == null || d < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + } + return new Dte(days.stream().map(String::valueOf).collect(Collectors.joining(","))); } /** @@ -77,7 +110,11 @@ record OnDate(LocalDate date) implements ExpirationFilter { } } - record Dte(int days) implements ExpirationFilter {} + /** + * @param value the raw wire-form {@code dte} value — single number, {@code MIN-MAX}, or a + * comma-separated list. + */ + record Dte(String value) implements ExpirationFilter {} record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..a5bc3e9 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -975,6 +975,42 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteRangeTranslatesToDashedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30-45"); + } + + @Test + void chainExpirationFilterDteInTranslatesToCommaSeparatedDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteIn(List.of(7, 14, 30))) + .build()) + .join(); + + // The comma is URL-encoded on the wire, so decode before asserting (same convention as the + // columns/symbols comma-joined params). + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=7,14,30"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1239,41 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minDays must be <= maxDays"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, -30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterDteInRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteIn(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteInRejectsNegativeEntry() { + assertThatThrownBy(() -> ExpirationFilter.dteIn(List.of(7, -14, 30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(