From 54b84ea2a6b33e6ecb1ba04dae8b41ca85386e16 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 1 Oct 2026 09:08:47 -0400 Subject: [PATCH 1/3] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Build fails as expected — missing symbols (`dteList`, `dteRange`, `dteComparison`, `DteOperator`) confirm the tests target the not-yet-implemented API surface. There's also one benign redundant-cast warning I'll leave, since it's valid for the regression test's intent (documenting the record cast explicitly). Added 14 tests to `OptionsResourceTest.java` per the reviewed plan: 4 wire cases (single value, comma-list, closed range, comparison operator), 1 regression case (legacy `dte(int)`/`Dte` shape and negative-rejection unchanged), 8 validation cases (non-negative/≤36500 bounds across the single, list, range, and comparison entry points, plus range min>max), and 1 sync/async parity case for the range form. `gradle test --no-daemon --console=plain` fails at `compileTestJava` on the missing `dteList`/`dteRange`/`dteComparison`/`DteOperator` symbols, as expected since no production code was touched. implement: Only the expected files changed. Not committing, per instructions. Added three additive `ExpirationFilter` factories — `dteList`, `dteRange`, `dteComparison` — plus a `DteOperator` enum, implementing the API's new comma-list/range/comparison `dte` syntax while leaving the existing `dte(int)`/`Dte` record untouched for binary/source compatibility (per the repo's own ADR-008/REVIEW.md convention). Added shared `MAX_DTE_DAYS` (36500) bound validation across all four entry points. Wired the new variants into `OptionsResource.applyExpirationFilter` for wire encoding. Fixed one redundant-cast and two Spotless line-wrap issues in the tests I wrote in phase 1 (no assertions weakened). `gradle test` and `gradle build` both pass. changelog: Added two changelog sections under `[Unreleased]`: Added documenting the new `dteList`, `dteRange`, `dteComparison` factories and `DteOperator` enum for API 2.0.0, and Changed noting the breaking upper-bound validation on `dte(int)` with migration guidance. --- .sdk-sync/2.0.0-ffb34b7.md | 137 +++++++++++++++ CHANGELOG.md | 14 ++ .../com/marketdata/sdk/OptionsResource.java | 7 + .../sdk/options/ExpirationFilter.java | 93 +++++++++- .../marketdata/sdk/OptionsResourceTest.java | 164 ++++++++++++++++++ 5 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .sdk-sync/2.0.0-ffb34b7.md diff --git a/.sdk-sync/2.0.0-ffb34b7.md b/.sdk-sync/2.0.0-ffb34b7.md new file mode 100644 index 0000000..206a20b --- /dev/null +++ b/.sdk-sync/2.0.0-ffb34b7.md @@ -0,0 +1,137 @@ +# sdk-auto-sync job log + +| | | +|---|---| +| API release | 2.0.0 (commit `ffb34b7`) | +| SDK | sdk-java | +| Branch | `sdk-sync/2.0.0-ffb34b7` | +| API surface | `ae2b047c8f6f` → `c2a31e277345` | +| Date | 2026-10-01 12:57:05Z | +| Outcome | **done** | + +## Changes in the API surface + + ~ GET /v1/options/chain/{underlying}/ ?dte: string (was integer) + +## 1. test plan + +Coder (12 turns, US$ 0.65) — test plan, 14 case(s) (wire 4, regression 1, validation 8, parity 1): + +> Adds three new additive ExpirationFilter factories (dteList, dteRange, dteComparison) plus a DteOperator enum, leaving the existing dte(int)/Dte record untouched (per the repo's own binary/source-compatibility rule against retyping an existing parameter). Tests cover one wire case per enumerated form (single, comma-list, closed range, comparison), a regression case proving the legacy dte(int) factory and Dte record shape are unchanged, 8 validation cases for the new "non-negative, at most 36500" bound across the three new entry points plus the legacy factory's new upper bound, and one sync/async parity case for the new range form. All cases land in OptionsResourceTest.java, matching where ExpirationFilter is already tested. + +| # | category | test | API form | input | expected | +|---|---|---|---|---|---| +| 1 | wire | `dte_single_value_renders_bare_number` | A single value | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) | captured URI equals .../options/chain/AAPL/?dte=30 | +| 2 | wire | `dte_comma_separated_list_renders_joined_values` | A comma-separated list (15,30,45) | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteList(15, 30, 45)).build()) | URL-decoded captured URI contains dte=15,30,45 | +| 3 | wire | `dte_closed_range_renders_as_dashed` | A closed range (0-45) | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteRange(0, 45)).build()) | captured URI equals .../options/chain/AAPL/?dte=0-45 | +| 4 | wire | `dte_comparison_operator_renders_with_operator_prefix` | A comparison operator (>=30, <45) | options.chainAsync(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.GTE, 30)).build()) | captured URI contains dte=%3E%3D30 | +| 5 | regression | `legacy_dte_int_factory_and_record_shape_unchanged` | A single value (pre-existing integer form) | ExpirationFilter.Dte d = (ExpirationFilter.Dte) ExpirationFilter.dte(45); d.days(); and ExpirationFilter.dte(-1) | d.days() == 45; ExpirationFilter.dte(-1) still throws IllegalArgumentException "dte must be non-negative" | +| 6 | validation | `dte_single_value_rejects_above_36500` | Values are whole numbers of days, at most 36500 | ExpirationFilter.dte(36501) | IllegalArgumentException "dte must be at most 36500" | +| 7 | validation | `dte_list_rejects_negative_element` | A comma-separated list (15,30,45) | ExpirationFilter.dteList(15, -1, 45) | IllegalArgumentException "dte must be non-negative" | +| 8 | validation | `dte_list_rejects_element_above_36500` | Values are whole numbers of days, at most 36500 | ExpirationFilter.dteList(15, 36501, 45) | IllegalArgumentException "dte must be at most 36500" | +| 9 | validation | `dte_range_rejects_min_greater_than_max` | A closed range (0-45) | ExpirationFilter.dteRange(45, 0) | IllegalArgumentException "min must be <= max" | +| 10 | validation | `dte_range_rejects_negative_min` | A closed range (0-45) | ExpirationFilter.dteRange(-1, 45) | IllegalArgumentException "dte must be non-negative" | +| 11 | validation | `dte_range_rejects_max_above_36500` | Values are whole numbers of days, at most 36500 | ExpirationFilter.dteRange(0, 36501) | IllegalArgumentException "dte must be at most 36500" | +| 12 | validation | `dte_comparison_rejects_negative_value` | A comparison operator (>=30, <45) | ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.GTE, -1) | IllegalArgumentException "dte must be non-negative" | +| 13 | validation | `dte_comparison_rejects_value_above_36500` | Values are whole numbers of days, at most 36500 | ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.LT, 36501) | IllegalArgumentException "dte must be at most 36500" | +| 14 | parity | `dte_range_sync_mirrors_async_wire_request` | A closed range (0-45) | options.chain(OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dteRange(0, 45)).build()) vs chainAsync with same filter | both captured URIs equal .../options/chain/AAPL/?dte=0-45 | + +Plan reviewer (11 turns, US$ 0.26): approved + +> The plan covers all four enumerated dte forms (single, list, range, comparison) with wire cases, a regression case for the legacy integer factory, validation for non-negative/36500-max/range-order across all variants, and a sync/async parity case — matching the sealed ExpirationFilter/StrikeFilter patterns already in the codebase. No invented forms, no missing forms, and placement in OptionsResourceTest.java is correct; the only issue is a naming-convention mismatch. + +- [minor] src/test/java/com/marketdata/sdk/OptionsResourceTest.java — All 14 planned test names use snake_case (e.g. dte_single_value_renders_bare_number), but every existing test method in this file uses camelCase (chainExpirationFilterDteTranslatesToDteParam, expirationFilterDteRejectsNegative). Rename to match the file's convention. + +## 2. tests + +Coder (4 turns, US$ 0.57): + +> Build fails as expected — missing symbols (`dteList`, `dteRange`, `dteComparison`, `DteOperator`) confirm the tests target the not-yet-implemented API surface. There's also one benign redundant-cast warning I'll leave, since it's valid for the regression test's intent (documenting the record cast explicitly). +> +> Added 14 tests to `OptionsResourceTest.java` per the reviewed plan: 4 wire cases (single value, comma-list, closed range, comparison operator), 1 regression case (legacy `dte(int)`/`Dte` shape and negative-rejection unchanged), 8 validation cases (non-negative/≤36500 bounds across the single, list, range, and comparison entry points, plus range min>max), and 1 sync/async parity case for the range form. `gradle test --no-daemon --console=plain` fails at `compileTestJava` on the missing `dteList`/`dteRange`/`dteComparison`/`DteOperator` symbols, as expected since no production code was touched. + +Gates: + +- tests (expected to fail), attempt 1: failed — Execution failed for task ':compileTestJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin'). + +## 3. implement + +Coder (20 turns, US$ 0.65): + +> Only the expected files changed. Not committing, per instructions. +> +> Added three additive `ExpirationFilter` factories — `dteList`, `dteRange`, `dteComparison` — plus a `DteOperator` enum, implementing the API's new comma-list/range/comparison `dte` syntax while leaving the existing `dte(int)`/`Dte` record untouched for binary/source compatibility (per the repo's own ADR-008/REVIEW.md convention). Added shared `MAX_DTE_DAYS` (36500) bound validation across all four entry points. Wired the new variants into `OptionsResource.applyExpirationFilter` for wire encoding. Fixed one redundant-cast and two Spotless line-wrap issues in the tests I wrote in phase 1 (no assertions weakened). `gradle test` and `gradle build` both pass. + +Gates: + +- tests, attempt 1: passed +- build, attempt 1: passed + +## 4. review 1 + +Reviewer (6 turns, US$ 0.39): approved + +> The implementation correctly adds dteList/dteRange/dteComparison variants to ExpirationFilter, wires them into OptionsResource's query building with the right wire syntax, and preserves the legacy dte(int) factory and Dte record shape. All 14 specified test cases are present with matching inputs, expected messages, and assertions, and none appear weakened. No blocker or major issues found in the three in-scope files. + +- [minor] docs/options/chain.md — Docs only show ExpirationFilter.dte(int) for the dte filter; the new dteList/dteRange/dteComparison factories added for the dte string-syntax expansion aren't documented. Not in the author's changed-file set for this review but should be updated by a maintainer. + +Security (2 turns, US$ 0.27): approved + +> This change adds three new `dte` wire syntaxes (list, range, comparison) to `ExpirationFilter`, mirroring the existing `StrikeFilter` pattern. All new values are validated integers (0–36500) or enum-controlled operator prefixes, so there is no injection surface — the query builder (unchanged, pre-existing) URL-encodes the resulting strings, as confirmed by the `%3E%3D` test assertions. No new dependencies, secrets, logging, or file I/O are introduced, and the test file matches the committed test plan exactly with no weakened cases. + +- no findings + +## 5. assess + +Analyst (4 turns, US$ 0.09) — public surface: + +> The `ExpirationFilter` sealed interface gains four variants to support the API's new string-based `dte` parameter syntax (comma-separated list, range, and comparison operators). The existing `dte(int)` method's validation is stricter (now rejects values above 36500 days); new consumers can use `dteList()`, `dteRange()`, and `dteComparison()` factories alongside it. + +- added: `ExpirationFilter.MAX_DTE_DAYS` +- added: `ExpirationFilter.dteList(int...)` +- added: `ExpirationFilter.DteList` +- added: `ExpirationFilter.dteRange(int, int)` +- added: `ExpirationFilter.DteRange` +- added: `ExpirationFilter.dteComparison(DteOperator, int)` +- added: `ExpirationFilter.DteComparison` +- added: `ExpirationFilter.DteOperator` +- added: `ExpirationFilter.DteOperator.GT` +- added: `ExpirationFilter.DteOperator.GTE` +- added: `ExpirationFilter.DteOperator.LT` +- added: `ExpirationFilter.DteOperator.LTE` +- added: `ExpirationFilter.DteOperator.wireValue()` +- changed: `ExpirationFilter.dte(int)` — Now validates upper bound (36500 days); previously accepted any non-negative value. + +Computed by the pipeline: **major**, 1.0.0 → 2.0.0; changelog entry written + +## 6. changelog + +Coder (4 turns, US$ 0.06): + +> Added two changelog sections under `[Unreleased]`: **Added** documenting the new `dteList`, `dteRange`, `dteComparison` factories and `DteOperator` enum for API 2.0.0, and **Changed** noting the breaking upper-bound validation on `dte(int)` with migration guidance. + +Gates: + +- build, attempt 1: passed + +## Totals + +- Version: major · 1.0.0 → 2.0.0 +- Coder runs: 4 +- Review runs: 3 +- Gates run: 4 (1 failed) +- Files changed by the agent: 4 +- Turns: 63 +- Tokens: 4,108,833 +- Estimated cost: US$ 2.95 + +### Agent runs + +- coder · claude-haiku-4-5-20251001, claude-sonnet-5 · 431,010 tokens (80,589 in, 324,225 cached, 26,196 out) · 12 turns · US$ 0.65 +- plan reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 188,576 tokens (40,036 in, 141,765 cached, 6,775 out) · 11 turns · US$ 0.26 +- coder · claude-sonnet-5 · 444,745 tokens (113,580 in, 326,244 cached, 4,921 out) · 4 turns · US$ 0.57 +- coder · claude-sonnet-5 · 2,499,649 tokens (18,642 in, 2,472,530 cached, 8,477 out) · 20 turns · US$ 0.65 +- reviewer · claude-haiku-4-5-20251001, claude-sonnet-5 · 341,012 tokens (62,802 in, 272,662 cached, 5,548 out) · 6 turns · US$ 0.39 +- security · claude-haiku-4-5-20251001, claude-sonnet-5 · 63,930 tokens (56,232 in, 6,534 cached, 1,164 out) · 2 turns · US$ 0.27 +- analyst · claude-haiku-4-5-20251001 · 53,637 tokens (29,781 in, 19,381 cached, 4,475 out) · 4 turns · US$ 0.09 +- changelog · claude-haiku-4-5-20251001 · 86,274 tokens (22,468 in, 62,292 cached, 1,514 out) · 4 turns · US$ 0.06 diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..fdebf29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Options `dte` parameter enhancements** (API 2.0.0) — `ExpirationFilter` now + supports comma-separated lists (`dteList`), ranges (`dteRange`), and comparison + operators (`dteComparison` with `DteOperator.GT/GTE/LT/LTE`) for richer + expiration filtering. `MAX_DTE_DAYS` constant (36500) documents the valid range. + +### Changed + +- **Options `dte(int)` validation** (API 2.0.0) — now enforces an upper bound of + 36500 days; calls passing larger values will throw `BadRequestError`. Migrate + by clamping values to the valid range or using the new `dteRange` / `dteList` + factories. + ## [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..587aa21 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -28,6 +28,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -312,6 +313,12 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); } else if (f instanceof ExpirationFilter.Dte v) { b.query("dte", v.days()); + } else if (f instanceof ExpirationFilter.DteList v) { + b.query("dte", v.days().stream().map(String::valueOf).collect(Collectors.joining(","))); + } else if (f instanceof ExpirationFilter.DteRange v) { + b.query("dte", v.min() + "-" + v.max()); + } else if (f instanceof ExpirationFilter.DteComparison v) { + b.query("dte", v.operator().wireValue() + v.days()); } 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..01a658e 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,8 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -14,14 +16,27 @@ *

Additive expiration-type predicates ({@code weekly}/{@code monthly}/{@code quarterly}/{@code * am}/{@code pm}) are not part of this hierarchy — they intersect freely with any variant and stay * as separate booleans on the request builder. + * + *

The {@code dte} axis itself accepts four wire syntaxes — a single value ({@link #dte(int)}), a + * comma-separated list ({@link #dteList}), a closed range ({@link #dteRange}), and a comparison + * operator ({@link #dteComparison}), mirroring {@link StrikeFilter}'s syntax. {@code dte(int)} + * keeps its original signature and {@link Dte} record shape unchanged — the new syntaxes are + * additive variants rather than a retyping, so existing callers and compiled callers are + * unaffected. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { + /** The API's upper bound on any individual {@code dte} value, in days (~100 years). */ + int MAX_DTE_DAYS = 36500; + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); @@ -41,10 +56,54 @@ static All all() { /** Days-to-expiration filter — wire form {@code ?dte=N}. */ static Dte dte(int days) { + validateDteDays(days); + return new Dte(days); + } + + /** + * Days-to-expiration comma-separated list — wire form {@code ?dte=15,30,45}. Selects the closest + * expiration for each value, deduplicated by the API. Mirrors {@link StrikeFilter}'s syntax. + */ + static DteList dteList(int... days) { + Objects.requireNonNull(days, "days"); + List copy = new ArrayList<>(days.length); + for (int day : days) { + validateDteDays(day); + copy.add(day); + } + return new DteList(List.copyOf(copy)); + } + + /** + * Days-to-expiration closed range — wire form {@code ?dte=min-max}, inclusive. {@code min} must + * not exceed {@code max}. Mirrors {@link StrikeFilter}'s syntax. + */ + static DteRange dteRange(int min, int max) { + validateDteDays(min); + validateDteDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=operator+days} (e.g. {@code >=30}). + * Mirrors {@link StrikeFilter}'s syntax. + */ + static DteComparison dteComparison(DteOperator operator, int days) { + Objects.requireNonNull(operator, "operator"); + validateDteDays(days); + return new DteComparison(operator, days); + } + + private static void validateDteDays(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); } - return new Dte(days); + if (days > MAX_DTE_DAYS) { + throw new IllegalArgumentException("dte must be at most " + MAX_DTE_DAYS); + } } /** @@ -79,6 +138,38 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + record DteList(List days) implements ExpirationFilter { + public DteList { + days = List.copyOf(days); + } + } + + record DteRange(int min, int max) implements ExpirationFilter {} + + record DteComparison(DteOperator operator, int days) implements ExpirationFilter {} + + /** + * Comparison operators accepted by the {@code dte} parameter. Mirrors {@link + * StrikeFilter.Operator}. + */ + enum DteOperator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + DteOperator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} record MonthYear(int year, int month) implements ExpirationFilter {} diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..4732243 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1227,6 +1227,170 @@ void strikeFilterRangeRejectsMinGreaterThanMax() { .hasMessageContaining("min must be <= max"); } + // ---------- chain: dte string syntax (wire forms) ---------- + + @Test + void dteSingleValueRendersBareNumber() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); + } + + @Test + void dteCommaSeparatedListRendersJoinedValues() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).contains("dte=15,30,45"); + } + + @Test + void dteClosedRangeRendersAsDashed() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void dteComparisonOperatorRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — mirrors the strike comparison encoding. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + // ---------- chain: dte legacy single-int factory (regression) ---------- + + @Test + void legacyDteIntFactoryAndRecordShapeUnchanged() { + // Binary/source compatibility: dte(int) must keep returning a Dte record whose sole component + // is the int it was given, and must keep rejecting negative input with the original message. + ExpirationFilter.Dte d = ExpirationFilter.dte(45); + assertThat(d.days()).isEqualTo(45); + + assertThatThrownBy(() -> ExpirationFilter.dte(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + // ---------- chain: dte string syntax (validation) ---------- + + @Test + void dteSingleValueRejectsAbove36500() { + assertThatThrownBy(() -> ExpirationFilter.dte(36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteListRejectsNegativeElement() { + assertThatThrownBy(() -> ExpirationFilter.dteList(15, -1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteListRejectsElementAbove36500() { + assertThatThrownBy(() -> ExpirationFilter.dteList(15, 36501, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteRangeRejectsMaxAbove36500() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteComparisonRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteComparisonRejectsValueAbove36500() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.DteOperator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + // ---------- chain: dte string syntax (sync/async parity) ---------- + + @Test + void dteRangeSyncMirrorsAsyncWireRequest() { + CapturingClient asyncClient = okWith(CANNED_CHAIN_BODY); + resourceWith(asyncClient) + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()) + .join(); + assertThat(asyncClient.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + + CapturingClient syncClient = okWith(CANNED_CHAIN_BODY); + resourceWith(syncClient) + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); + + assertThat(syncClient.captured.get(0).uri().toString()) + .isEqualTo(asyncClient.captured.get(0).uri().toString()); + } + // ---------- helpers ---------- private static CapturingClient okWith(String body) { From f98cd2f773877593850a6fa36a98e7fcf6aba1f0 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 1 Oct 2026 09:16:43 -0400 Subject: [PATCH 2/3] Code review fix 1: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code review fix 1: All changed files are within the allowed limits (`src/main/java/`, `src/test/java/`, `docs/`). Not committing, per instructions. Summary: - Public constructors bypass validation (Blocking): Added compact constructors to `DteList`, `DteRange`, and `DteComparison` records in `ExpirationFilter.java` that perform the same non-negative/≤36500/min≤max/non-null-operator checks previously only enforced by the factory methods. The factories now delegate directly to the record constructors instead of duplicating validation. Added three new tests in `OptionsResourceTest.java` that construct these records directly (bypassing the factories) to prove the invariants now hold at the constructor level. - Resource documentation omits new filters (Should fix): Updated `docs/options/chain.md` to document `dteList`, `dteRange`, and `dteComparison` alongside their wire forms (`?dte=15,30,45`, `?dte=0-45`, `?dte=>=30`) and bounds (non-negative, ≤36500 days, `min <= max` for range, `DteOperator` values). `gradle test` and `gradle build` both pass. --- .sdk-sync/2.0.0-ffb34b7.md | 46 +++++++++++++++++++ docs/options/chain.md | 8 +++- .../sdk/options/ExpirationFilter.java | 28 +++++++---- .../marketdata/sdk/OptionsResourceTest.java | 22 +++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/.sdk-sync/2.0.0-ffb34b7.md b/.sdk-sync/2.0.0-ffb34b7.md index 206a20b..c7bf91f 100644 --- a/.sdk-sync/2.0.0-ffb34b7.md +++ b/.sdk-sync/2.0.0-ffb34b7.md @@ -135,3 +135,49 @@ Gates: - security · claude-haiku-4-5-20251001, claude-sonnet-5 · 63,930 tokens (56,232 in, 6,534 cached, 1,164 out) · 2 turns · US$ 0.27 - analyst · claude-haiku-4-5-20251001 · 53,637 tokens (29,781 in, 19,381 cached, 4,475 out) · 4 turns · US$ 0.09 - changelog · claude-haiku-4-5-20251001 · 86,274 tokens (22,468 in, 62,292 cached, 1,514 out) · 4 turns · US$ 0.06 + + +# Code review fix 1 — 2026-10-01 13:08:53Z + +Outcome: **done** + +## 1. code review fix 1 + +The code review asked for changes: + +> - **[Blocking] Public constructors bypass validation** (`src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:138-146`) +> The factories enforce bounds, range order, and a non-null operator, but the new public record constructors do not. Callers can construct `new DteRange(45, 0)`, `new DteList(List.of(-1))`, or `new DteComparison(null, -1)` and pass them to `OptionsResource`, bypassing the advertised invariants. Add compact constructors that perform the validation and have the factories delegate to them. +> Evidence: `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:138-146` adds public records whose canonical constructors lack the checks performed by lines 60-96. +> - **[Should fix] Resource documentation omits new filters** +> The resource page still documents only `ExpirationFilter.dte(int)`, while this change adds three public filter forms. Document `dteList`, `dteRange`, and `dteComparison`, including their wire semantics and bounds, in this pull request. +> Evidence: The diff adds the public factories at `src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:60-96`, but the changed-file list contains no update to `docs/options/chain.md`; the pull request's reviewer notes also state that this page only shows `dte(int)`. + +Coder (20 turns, US$ 0.39): + +> All changed files are within the allowed limits (`src/main/java/**`, `src/test/java/**`, `docs/**`). Not committing, per instructions. +> +> Summary: +> - **Public constructors bypass validation (Blocking)**: Added compact constructors to `DteList`, `DteRange`, and `DteComparison` records in `ExpirationFilter.java` that perform the same non-negative/≤36500/min≤max/non-null-operator checks previously only enforced by the factory methods. The factories now delegate directly to the record constructors instead of duplicating validation. Added three new tests in `OptionsResourceTest.java` that construct these records directly (bypassing the factories) to prove the invariants now hold at the constructor level. +> - **Resource documentation omits new filters (Should fix)**: Updated `docs/options/chain.md` to document `dteList`, `dteRange`, and `dteComparison` alongside their wire forms (`?dte=15,30,45`, `?dte=0-45`, `?dte=>=30`) and bounds (non-negative, ≤36500 days, `min <= max` for range, `DteOperator` values). +> +> `gradle test` and `gradle build` both pass. + +Gates: + +- tests, attempt 1: passed +- build, attempt 1: passed + +## 2. review 1 + +Reviewer (2 turns, US$ 0.25): approved + +> The fix correctly moves bounds/order/null validation into compact constructors for DteRange, DteList, and DteComparison, closing the exact bypass paths (new DteRange(45,0), new DteList(List.of(-1)), new DteComparison(null,-1)) named in the blocking finding; factories now delegate without duplicating checks, and the added tests exercise each canonical constructor directly and would fail without the fix. The docs update adds the three new filter forms with their bounds, adequately addressing the "should fix" documentation gap. No tests were weakened or removed, and no public-API or behavior regressions were introduced. +> + +- [minor] docs/options/chain.md — The dteList doc comment omits the API's "closest expiration for each value, deduplicated" semantic that the matching ExpirationFilter.dteList javadoc states explicitly — a consumer reading only this resource page would not know duplicates collapse. + +Security (2 turns, US$ 0.21): approved + +> The fix correctly moves bounds/null validation into the compact constructors of DteList, DteRange, and DteComparison so that direct record construction (bypassing the static factories) can no longer produce invalid state, resolving the blocking finding; the factories now simply delegate to the validating constructors with no duplicated or weakened logic. The docs update adds the three new filter forms and their bounds, resolving the second finding, and the new tests exercise the compact constructors directly without removing or weakening any prior coverage. No injection, secrets, or unsafe-default concerns arise since all validated values are plain ints rendered into query strings elsewhere in already-reviewed code. + +- no findings diff --git a/docs/options/chain.md b/docs/options/chain.md index 6634359..75289d4 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,10 +20,16 @@ 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.dte(int days)) // days-to-expiration, e.g. ?dte=30 + .expirationFilter(ExpirationFilter.dteList(int... days)) // comma list, e.g. ?dte=15,30,45 + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // closed range, e.g. ?dte=0-45 + .expirationFilter(ExpirationFilter.dteComparison(DteOperator operator, int days)) + // comparison, e.g. ?dte=>=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) + // dte/dteList/dteRange/dteComparison values must be non-negative and at most 36500 days; + // dteRange additionally requires min <= max. DteOperator is GT, GTE, LT, or LTE. // Strike selection (sealed StrikeFilter — pick one): .strikeFilter(StrikeFilter.exact(double price)) diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 01a658e..b4b5d14 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -68,7 +68,6 @@ static DteList dteList(int... days) { Objects.requireNonNull(days, "days"); List copy = new ArrayList<>(days.length); for (int day : days) { - validateDteDays(day); copy.add(day); } return new DteList(List.copyOf(copy)); @@ -79,11 +78,6 @@ static DteList dteList(int... days) { * not exceed {@code max}. Mirrors {@link StrikeFilter}'s syntax. */ static DteRange dteRange(int min, int max) { - validateDteDays(min); - validateDteDays(max); - if (min > max) { - throw new IllegalArgumentException("min must be <= max"); - } return new DteRange(min, max); } @@ -92,8 +86,6 @@ static DteRange dteRange(int min, int max) { * Mirrors {@link StrikeFilter}'s syntax. */ static DteComparison dteComparison(DteOperator operator, int days) { - Objects.requireNonNull(operator, "operator"); - validateDteDays(days); return new DteComparison(operator, days); } @@ -141,12 +133,28 @@ record Dte(int days) implements ExpirationFilter {} record DteList(List days) implements ExpirationFilter { public DteList { days = List.copyOf(days); + for (int day : days) { + validateDteDays(day); + } } } - record DteRange(int min, int max) implements ExpirationFilter {} + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + validateDteDays(min); + validateDteDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } - record DteComparison(DteOperator operator, int days) implements ExpirationFilter {} + record DteComparison(DteOperator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + validateDteDays(days); + } + } /** * Comparison operators accepted by the {@code dte} parameter. Mirrors {@link diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 4732243..6b3671d 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1366,6 +1366,28 @@ void dteComparisonRejectsValueAbove36500() { .hasMessageContaining("dte must be at most 36500"); } + // ---------- chain: dte string syntax (compact constructors) ---------- + + @Test + void dteRangeRecordConstructorRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteListRecordConstructorRejectsNegativeElement() { + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteComparisonRecordConstructorRejectsNullOperator() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, -1)) + .isInstanceOf(NullPointerException.class); + } + // ---------- chain: dte string syntax (sync/async parity) ---------- @Test From 4ae31a3ac43ccea41e0ccd8fa9468250451e1f36 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 1 Oct 2026 09:24:30 -0400 Subject: [PATCH 3/3] Code review fix 2: sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code review fix 2: Both `test` and `build` pass. [Blocking] Public constructors bypass validation — Added a validating compact constructor to the `Dte` record (`ExpirationFilter.java`), enforcing the same non-negative/≤36500 bounds that `DteList`/`DteRange`/`DteComparison` already had from fix round 1. The `dte(int)` factory now just delegates to `new Dte(days)` instead of duplicating the check. Added `dteRecordConstructorRejectsAbove36500` in `OptionsResourceTest.java`, proving `new ExpirationFilter.Dte(36501)` now throws directly, alongside the existing sibling tests for the other three records. --- .sdk-sync/2.0.0-ffb34b7.md | 39 +++++++++++++++++++ .../sdk/options/ExpirationFilter.java | 7 +++- .../marketdata/sdk/OptionsResourceTest.java | 7 ++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.sdk-sync/2.0.0-ffb34b7.md b/.sdk-sync/2.0.0-ffb34b7.md index c7bf91f..433cd6d 100644 --- a/.sdk-sync/2.0.0-ffb34b7.md +++ b/.sdk-sync/2.0.0-ffb34b7.md @@ -181,3 +181,42 @@ Security (2 turns, US$ 0.21): approved > The fix correctly moves bounds/null validation into the compact constructors of DteList, DteRange, and DteComparison so that direct record construction (bypassing the static factories) can no longer produce invalid state, resolving the blocking finding; the factories now simply delegate to the validating constructors with no duplicated or weakened logic. The docs update adds the three new filter forms and their bounds, resolving the second finding, and the new tests exercise the compact constructors directly without removing or weakening any prior coverage. No injection, secrets, or unsafe-default concerns arise since all validated values are plain ints rendered into query strings elsewhere in already-reviewed code. - no findings + + +# Code review fix 2 — 2026-10-01 13:21:56Z + +Outcome: **done** + +## 1. code review fix 2 + +The code review asked for changes: + +> - **[Blocking] Public constructors bypass validation** (`src/main/java/com/marketdata/sdk/options/ExpirationFilter.java:56-132`) +> The upper bound is enforced by `dte(int)`, but the public `new ExpirationFilter.Dte(36501)` constructor still creates an invalid filter that `OptionsResource` sends. Add a validating compact constructor to `Dte`, delegate the factory to it, and cover direct construction with a regression test. +> Evidence: `ExpirationFilter.java:56-60` validates the factory, while `ExpirationFilter.java:132` remains `record Dte(int days) implements ExpirationFilter {}`. + +Coder (10 turns, US$ 0.18): + +> Both `test` and `build` pass. +> +> **[Blocking] Public constructors bypass validation** — Added a validating compact constructor to the `Dte` record (`ExpirationFilter.java`), enforcing the same non-negative/≤36500 bounds that `DteList`/`DteRange`/`DteComparison` already had from fix round 1. The `dte(int)` factory now just delegates to `new Dte(days)` instead of duplicating the check. Added `dteRecordConstructorRejectsAbove36500` in `OptionsResourceTest.java`, proving `new ExpirationFilter.Dte(36501)` now throws directly, alongside the existing sibling tests for the other three records. + +Gates: + +- tests, attempt 1: passed +- build, attempt 1: passed + +## 2. review 1 + +Reviewer (2 turns, US$ 0.19): approved + +> The fix correctly moves validation into a compact constructor on the Dte record and simplifies the dte(int) factory to delegate to it, closing the bypass where direct construction skipped the upper-bound check. The added test directly exercises `new ExpirationFilter.Dte(36501)` as requested by the finding, and no existing tests were weakened. This matches the reviewer's prescribed fix exactly. +> + +- [minor] src/test/java/com/marketdata/sdk/OptionsResourceTest.java — The new regression test only covers direct construction with a too-large value; there's no direct-construction test for a negative Dte (e.g. new Dte(-1)) to fully mirror the negative-value coverage that already exists for the factory path. + +Security (2 turns, US$ 0.04): approved + +> The fix correctly closes the validation-bypass hole by moving the bound check into Dte's compact constructor so both the dte(int) factory and direct `new ExpirationFilter.Dte(...)` construction enforce MAX_DTE_DAYS; the factory's redundant explicit call was removed since the record now validates itself. A regression test (dteRecordConstructorRejectsAbove36500) covers the previously-exploitable path. No injection, secrets, dependency, or unrelated-change concerns in this diff — it's a narrowly scoped, correct fix. + +- no findings diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index b4b5d14..5fcf4a8 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -56,7 +56,6 @@ static All all() { /** Days-to-expiration filter — wire form {@code ?dte=N}. */ static Dte dte(int days) { - validateDteDays(days); return new Dte(days); } @@ -128,7 +127,11 @@ record OnDate(LocalDate date) implements ExpirationFilter { } } - record Dte(int days) implements ExpirationFilter {} + record Dte(int days) implements ExpirationFilter { + public Dte { + validateDteDays(days); + } + } record DteList(List days) implements ExpirationFilter { public DteList { diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 6b3671d..3d44085 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -1368,6 +1368,13 @@ void dteComparisonRejectsValueAbove36500() { // ---------- chain: dte string syntax (compact constructors) ---------- + @Test + void dteRecordConstructorRejectsAbove36500() { + assertThatThrownBy(() -> new ExpirationFilter.Dte(36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + @Test void dteRangeRecordConstructorRejectsMinGreaterThanMax() { assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0))