From b66b715d8b1230050b8fd4ea9152ba343c3d89b1 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Fri, 30 Oct 2026 12:38:00 -0400 Subject: [PATCH] sdk-sync: API 2.0.0+ffb34b7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests: Added tests for the `dte` chain-filter type change (int -> string, forms: single value, comma list, closed range, comparison) against a new sealed `DteFilter` type (not yet implemented) added as a new permitted subtype of `ExpirationFilter`, reused alongside the existing `StrikeFilter.Operator`. Unit tests in OptionsResourceTest.java: 3 wire tests (one per new form: `dte=15,30,45`, `dte=0-45`, `dte=%3E%3D30`) plus 8 validation tests on the new factories (empty list, non-negative, max 36500, min<=max). The existing `ExpirationFilter.dte(int)` single-value test and method are untouched — it keeps its current error behavior and needs no probe since nothing existing changes. Added 4 live integration tests in OptionsIntegrationTest.java, one per form, each built to fail against the unfiltered (front-month-only) answer. No compatibility probe written: the delta is purely additive. implement: Added `DteFilter` (new sealed permitted subtype of `ExpirationFilter`) for the API's dte type change: `values(int...)` (comma list), `range(int,int)` (closed, inclusive), `comparison(StrikeFilter.Operator,int)` — reusing StrikeFilter's existing operator syntax/type. `ExpirationFilter.dte(int)` is untouched, still the single-value form. `OptionsResource.applyExpirationFilter` renders the three new wire forms. Docs/examples: updated — one line each per checklist item below. 1. docs/options/chain.md and OptionsAdvancedExample.java now show all three new forms with runnable examples. 2. DteFilter's interface and its three static factories carry javadoc; records follow the existing StrikeFilter convention of no per-record doc. 3. Unit tests add one wire assertion per form (values/range/comparison); the reused Operator enum isn't new to this delta so isn't re-tested per value. 4. Validation (non-negative, <=36500, non-empty list, no nulls, min<=max, non-null operator) lives in each record's compact constructor, so it holds through direct construction too; added tests for the null-operator/null-list/null-element paths the varargs factories can't reach. 5. JaCoCo report shows 0 uncovered lines for DteFilter, ExpirationFilter, OptionsResource. 6. README.md, docs/client.md, docs/options/{README,expirations,lookup,quotes}.md, MarketDataClient.java, OptionsCsvResource.java, OptionsChain.java, OptionsHtmlResource.java, JsonResponseParserTest.java, RequestValidationTest.java, OptionsExample.java, ADR-006 — checked, none describe `dte` specifically, left unchanged. ADR-008's historical "ExpirationFilter → OnDate/Dte/Between/MonthYear/All" listing is a point-in-time decision record, not a living enumeration — left as-is since its decision (sealed groups retained, new permitted subtypes non-breaking) still holds. docs/.sync-manifest.txt is auto-generated — not touched. 7. Docs state the bounds (non-negative, ≤36500) verbatim from the API's own parameter description; no stricter client-side promise is made. 8. `ExpirationFilter.dte(int)`'s signature, behavior, and exceptions are unchanged. Outside my limits, nothing I found (bug.yml, build.gradle.kts, BUG_FINDING.md) describes `dte`'s syntax, so none are left wrong by this change. review fix 1: Major finding (OptionsIntegrationTest.java `chainExpirationDteComparisonIsInclusiveAtLowerBound`): strengthened the `1` check — so the test now fails on an empty answer and on the unfiltered single-front-month answer, not just on `n` being present. Minor note (DteFilter.java:26, `values(int...)`'s null-array guard): resolved by testing it rather than annotating it `@Generated` — added `dteFilterValuesRejectsNullArrayThroughVarargsFactory`, which calls `DteFilter.values((int[]) null)` directly (no cast-trick or reflection needed, since the varargs parameter is `int[]` and there is no other `values` overload); this keeps the guard's line under normal coverage rather than excluding it, which is stronger than the StrikeFilter precedent. This is distinct from the existing `dteFilterValuesRejectsNullListThroughDirectConstruction` test, which exercises a separate `requireNonNull` inside the `Values` record's own compact constructor. Questions for a person: left both as raised, made no code or doc change for either, and did not treat them as settled. No new evidence to add on the `ExpirationFilter` exhaustive-switch question. On the ` dteRangeChain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.range(0, 45)) + .strikeFilter(StrikeFilter.exact(150)) + .side(OptionSide.CALL) + .build()) + .values(); + System.out.println("DteFilter.range(0, 45): " + dteRangeChain.size() + " contracts"); + + List dteComparisonChain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30)) + .strikeFilter(StrikeFilter.exact(150)) + .side(OptionSide.CALL) + .build()) + .values(); + System.out.println( + "DteFilter.comparison(GTE, 30): " + dteComparisonChain.size() + " contracts"); + if (chain.size() < 2) { System.out.println("Not enough contracts to demo the rest — try a more liquid underlying."); return; diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java index 4b8cff6..203742a 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.OptionQuote; import com.marketdata.sdk.options.OptionSide; @@ -10,6 +11,7 @@ import com.marketdata.sdk.options.OptionsLookupRequest; import com.marketdata.sdk.options.OptionsQuoteRequest; import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; import java.time.LocalDate; import java.util.List; @@ -133,6 +135,163 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainExpirationDteSingleValueSelectsClosestExpiration() { + // A single dte value narrows to the one expiration closest to it, same as before the API's + // dte type change — but 365 is far enough from AAPL's front-month expiration (always well + // under 180 days for a name this liquid) that an unfiltered call could never satisfy the + // assertions below. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("a single dte value selects exactly one expiration") + .isEqualTo(1); + Integer dte = chain.get(0).dte(); + assertThat(dte).isNotNull(); + assertThat(dte) + .as( + "the expiration closest to the requested 365 dte is far from the front-month" + + " expiration an unfiltered call would return") + .isGreaterThan(180); + } + + @Test + void chainExpirationDteValuesSelectsClosestPerValueDeduplicated() { + // Three widely-spaced dte values each resolve to their own closest expiration and dedupe — + // unlike a single dte value (or no filter at all), which always resolves to one expiration. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.values(30, 180, 365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as( + "three widely-spaced dte values resolve to multiple distinct expirations, unlike the" + + " single front-month expiration an unfiltered call returns") + .isGreaterThan(1); + } + + @Test + void chainExpirationDteRangeReturnsAllExpirationsWithinBounds() { + // A wide closed range spans multiple expirations, unlike no filter (front-month only); every + // returned contract's dte must also fall within the bounds, inclusive. + int max = 180; + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.range(0, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as( + "a wide closed range spans multiple expirations, unlike the single front-month" + + " expiration an unfiltered call returns") + .isGreaterThan(1); + for (OptionQuote q : chain) { + assertThat(q.dte()).isNotNull(); + assertThat(q.dte()) + .as("every contract's dte falls within the closed range") + .isBetween(0, max); + } + } + + @Test + void chainExpirationDteComparisonIsInclusiveAtLowerBound() { + // Anchor N on an actual expiration's dte, discovered via the range form (not the unfiltered + // call) — then probe the comparison form's boundary against it: >=N must keep the expiration + // whose dte is exactly N, withinRange = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.range(0, 365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(withinRange).isNotEmpty(); + int n = + withinRange.stream() + .map(OptionQuote::dte) + .filter(java.util.Objects::nonNull) + .max(Integer::compareTo) + .orElseThrow(); + + List atLeastN = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(atLeastN) + .as(">=N keeps the expiration whose dte is exactly N") + .extracting(OptionQuote::dte) + .contains(n); + for (OptionQuote q : atLeastN) { + assertThat(q.dte()).isGreaterThanOrEqualTo(n); + } + + List lessThanN = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.LT, n)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(lessThanN) + .as(" days) { + return days.stream().map(String::valueOf).collect(Collectors.joining(",")); + } + private static String formatStrike(double v) { if (v == Math.floor(v) && !Double.isInfinite(v)) { return Long.toString((long) v); diff --git a/src/main/java/com/marketdata/sdk/options/DteFilter.java b/src/main/java/com/marketdata/sdk/options/DteFilter.java new file mode 100644 index 0000000..a283499 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/DteFilter.java @@ -0,0 +1,92 @@ +package com.marketdata.sdk.options; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Extended days-to-expiration filter for {@code /v1/options/chain/} — the forms of {@code ?dte=} + * beyond a single value ({@link ExpirationFilter#dte(int)}): a comma-separated list of values, a + * closed range, and a comparison. The range and comparison syntax match {@link StrikeFilter}'s + * ({@code 0-45}, {@code >=30}) — {@link #comparison} reuses {@link StrikeFilter.Operator}. + * + *

A permitted subtype of {@link ExpirationFilter}, not a replacement for {@link + * ExpirationFilter#dte(int)}: the chain endpoint's expiration-selection axis stays a single pick, + * enforced by the one {@code expirationFilter(...)} setter on the request builder. + */ +public sealed interface DteFilter extends ExpirationFilter + permits DteFilter.Values, DteFilter.Range, DteFilter.Comparison { + + /** + * A comma-separated list of days-to-expiration values — wire form {@code ?dte=15,30,45}. Each + * value selects its own closest expiration; the server deduplicates the result. Each value must + * be non-negative and at most 36500. + */ + static Values values(int... days) { + Objects.requireNonNull(days, "days"); + List boxed = new ArrayList<>(days.length); + for (int day : days) { + boxed.add(day); + } + return new Values(boxed); + } + + /** + * Closed days-to-expiration range, inclusive on both ends — wire form {@code ?dte=min-max}. + * {@code min} must not exceed {@code max}, and both must be non-negative and at most 36500. + */ + static Range range(int min, int max) { + return new Range(min, max); + } + + /** + * Days-to-expiration comparison — wire form e.g. {@code ?dte=>=30}. {@code days} must be + * non-negative and at most 36500. + */ + static Comparison comparison(StrikeFilter.Operator operator, int days) { + return new Comparison(operator, days); + } + + /** Days to expiry are whole numbers, non-negative, and at most 36500. */ + private static void validateDays(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + if (days > 36500) { + throw new IllegalArgumentException("dte must be at most 36500"); + } + } + + record Values(List days) implements DteFilter { + public Values { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer day : days) { + if (day == null) { + throw new IllegalArgumentException("days must not contain null elements"); + } + validateDays(day); + } + days = List.copyOf(days); + } + } + + record Range(int min, int max) implements DteFilter { + public Range { + validateDays(min); + validateDays(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record Comparison(StrikeFilter.Operator operator, int days) implements DteFilter { + public Comparison { + Objects.requireNonNull(operator, "operator"); + validateDays(days); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..5bb271a 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -14,13 +14,19 @@ *

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. + * + *

{@link DteFilter} is a further permitted subtype: the {@code dte} parameter accepts a single + * value ({@link #dte(int)}, below) or, for the comma-separated-list/range/comparison forms, a + * {@code DteFilter}. Both still plug into the one {@code expirationFilter(...)} setter, so {@code + * dte} stays mutually exclusive with the other variants here. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, ExpirationFilter.Between, ExpirationFilter.MonthYear, - ExpirationFilter.All { + ExpirationFilter.All, + DteFilter { /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { @@ -39,7 +45,10 @@ 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}. For a comma-separated list, + * a closed range, or a comparison, use {@link DteFilter} instead. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..464eee1 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.DteFilter; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.Greek; import com.marketdata.sdk.options.OptionQuote; @@ -27,6 +28,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -1025,6 +1027,60 @@ void chainExpirationFilterMonthYearTranslatesToMonthYear() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); } + @Test + void chainExpirationFilterDteValuesTranslatesToCommaSeparatedList() { + // dte's API-level type change (integer -> string) adds a comma-separated-list form: each + // value selects its own closest expiration, deduplicated server-side. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.values(15, 30, 45)) + .build()) + .join(); + + // The comma is URL-encoded, so decode before asserting (same convention as columns=). + 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=15,30,45"); + } + + @Test + void chainExpirationFilterDteRangeTranslatesToDashedRange() { + // dte's closed-range form, e.g. "0-45" — same dashed syntax as StrikeFilter.Range. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(DteFilter.range(0, 45)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=0-45"); + } + + @Test + void chainExpirationFilterDteComparisonRendersWithOperatorPrefix() { + // dte's comparison-operator form reuses StrikeFilter.Operator — same wire syntax as strike. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(DteFilter.comparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through. + assertThat(url).contains("dte=%3E%3D30"); + } + @Test void chainStrikeFilterExactRendersAsBareNumber() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1220,6 +1276,92 @@ void expirationFilterMonthYearRejectsInvalidMonth() { .hasMessageContaining("month must be in 1..12"); } + @Test + void dteFilterValuesRejectsEmptyArray() { + assertThatThrownBy(() -> DteFilter.values()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void dteFilterValuesRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.values(15, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterValuesRejectsDayAboveMaximum() { + assertThatThrownBy(() -> DteFilter.values(15, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> DteFilter.range(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void dteFilterRangeRejectsNegativeMin() { + assertThatThrownBy(() -> DteFilter.range(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterRangeRejectsMaxAboveMaximum() { + assertThatThrownBy(() -> DteFilter.range(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNegativeDay() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void dteFilterComparisonRejectsDayAboveMaximum() { + assertThatThrownBy(() -> DteFilter.comparison(StrikeFilter.Operator.GTE, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be at most 36500"); + } + + @Test + void dteFilterComparisonRejectsNullOperatorThroughDirectConstruction() { + // The static factory's parameter is typed, so only a direct call to the record's public + // canonical constructor can pass null — validation has to hold there too. + assertThatThrownBy(() -> new DteFilter.Comparison(null, 30)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullArrayThroughVarargsFactory() { + // A null array reaches the varargs parameter directly (no reflection trick needed) if a + // caller passes one explicitly instead of omitting the argument. + assertThatThrownBy(() -> DteFilter.values((int[]) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullListThroughDirectConstruction() { + assertThatThrownBy(() -> new DteFilter.Values(null)).isInstanceOf(NullPointerException.class); + } + + @Test + void dteFilterValuesRejectsNullElementThroughDirectConstruction() { + // The varargs factory can't carry a null int, so only a direct call to the record's public + // canonical constructor with a boxed list can — validation has to hold there too. + assertThatThrownBy(() -> new DteFilter.Values(Arrays.asList(15, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not contain null elements"); + } + @Test void strikeFilterRangeRejectsMinGreaterThanMax() { assertThatThrownBy(() -> StrikeFilter.range(160, 140))