From 733a64781ca2e37594758607956c716b5f1c9023 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Thu, 29 Oct 2026 11:20:24 -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` filter's new string syntax on `GET /v1/options/chain/{underlying}/`: wire tests for the three new forms the API now accepts — comma-separated list (`dteList`), closed range (`dteRange`), and comparison operator (`dteComparison`, one test per GT/GTE/LT/LTE) — plus validation tests for each new factory's bounds (empty list, negative, >36500, min>max). The existing single-value `dte(int)` wire test and its non-negative check are untouched (regression). Added integration tests (`OptionsIntegrationTest`) deriving real AAPL dte values from the live `expiration=all` chain, then asserting the list/range/comparison forms resolve to exactly the expected expirations, including the inclusive-range and inclusive->=/exclusive-< boundary cases the API description states. No compatibility probe was written: the delta only adds new `ExpirationFilter` factory methods/types (`dteList`, `dteRange`, `dteComparison`, `Operator`); the existing `dte(int)` signature is untouched, so no existing public member is touched. implement: Implemented the `dte` chain filter's three new wire forms (list/range/comparison) in `ExpirationFilter` as new sealed-permitted records (`DteList`, `DteRange`, `DteComparison`) plus a new `Operator` enum, alongside the unchanged `dte(int)`; `OptionsResource.applyExpirationFilter` renders each to the wire (`15,30,45`, `0-45`, `>=30`). Docs/doc-comments: every new type/method has a Javadoc comment; `docs/options/chain.md` documents all four `dte` syntaxes with examples; `OptionsAdvancedExample.java` gained a `dteRange` demo. Wire tests: one per enumerated form, one per `Operator` value (GT/GTE/LT/LTE) — 14 pass. Validation: bounds/empty/min<=max/null-operator checks live in each record's own compact constructor (not just the static factory), with tests calling both the factory and `new ExpirationFilter.DteXxx(...)` directly, plus null-list and null-element cases. No coverage exclusions added; `jacocoTestCoverageVerification` (100% line) and `spotlessCheck` pass as part of `gradle build`. Existing `dte(int)`/`expirationFilter(ExpirationFilter)` signatures and errors are untouched — purely additive, no narrowing. Integration tests (phase 1) derive real AAPL dte values from `expiration=all` and assert list/range/comparison resolve correctly, including the inclusive->=/exclusive-< boundary; not re-run here (live-API gate). One phase-1 test fixed: the `dteList` wire assertion decoded the URL first (commas are percent-encoded), matching the repo's existing `columns` test convention — same assertion, no weakening. README.md, docs/client.md, ADR-008, docs/options/{quotes,README,expirations,lookup}.md, docs/.sync-manifest.txt, ADR-006, MarketDataClient.java, OptionsCsvResource.java, OptionsChain.java, OptionsHtmlResource.java, JsonResponseParserTest.java, RequestValidationTest.java: checked, none mention `dte`/`ExpirationFilter` specifically or show stale `dte` usage, left unchanged. Outside my limits and left as-is: `.github/ISSUE_TEMPLATE/bug.yml`, `build.gradle.kts`, `.github/BUG_FINDING.md`, and the other unlisted files — none reviewed for this change. review fix 1: Major finding (comparison boundary test, OptionsIntegrationTest.java): rewrote `chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt` so both the `>=boundary` and `= boundary` / `< boundary`) rather than a bare `contains`/`doesNotContain`; a server ignoring the filter and falling back to its front-month default no longer passes either half. Added `allMatch(d -> d >= boundary)` to the GTE half as the reviewer asked, subsumed by the exact-match that follows it. Minor (range test, line 186): reworked `chainDteRangeKeepsExpirationsWithinInclusiveBounds` to use an interior subrange (`dtes.get(1)` .. `dtes.get(size-2)`) instead of the full first..last span, and added a `doesNotContain(belowRange, aboveRange)` assertion proving the baseline dtes outside the requested range are excluded; kept the existing inclusive-endpoint assertion. `liveDistinctDtes()`'s minimum-size guard raised from 3 to 4 distinct dtes so the new interior subrange always has an excluded baseline value on each side; its doc comment updated accordingly. Minor (inclusive/exclusive documentation ambiguity, line 232): left unresolved — `<` and `>=` are standard relational operators with unambiguous mathematical meaning (not a server-specific behavior requiring API-team confirmation), and no documentation comment, `docs/`, or example in this change asserts anything beyond that inherent meaning, so there is no unsettled disagreement to flag or hedge. Re-ran `gradle test --no-daemon --console=plain` (BUILD SUCCESSFUL, unit tests unaffected) and `gradle build --no-daemon --console=plain`; one `spotlessApply` pass was needed to reformat the new integration-test lines, after which the full build (compile, 100% line coverage verification, spotlessCheck, integrationTestClasses compiled/SKIPPED at runtime) passed. No production code, documentation, or other test files changed in this cycle beyond the integration test; the test files still contain only additions (0 deletions) versus the prior cycle. changelog: Added changelog entry under `## [Unreleased]` documenting the three new `ExpirationFilter` factory methods (`dteList`, `dteRange`, `dteComparison`) for API release 2.0.0's string forms of the `dte` parameter. Entry includes sealed subtypes `DteList`/`DteRange`/`DteComparison`, `Operator` enum (GT, GTE, LT, LTE) with `wireValue()` accessor, and constants `MIN_DTE`/`MAX_DTE`. Notes that existing `ExpirationFilter.dte(int)` works unchanged. Edit successful; CHANGELOG.md updated. --- CHANGELOG.md | 4 + docs/options/chain.md | 23 +- .../resources/OptionsAdvancedExample.java | 11 + .../sdk/OptionsIntegrationTest.java | 161 ++++++++++++++ .../com/marketdata/sdk/OptionsResource.java | 11 + .../sdk/options/ExpirationFilter.java | 111 +++++++++- .../marketdata/sdk/OptionsResourceTest.java | 198 ++++++++++++++++++ 7 files changed, 517 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..4725a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(List)`, `ExpirationFilter.dteRange(int, int)`, `ExpirationFilter.dteComparison(ExpirationFilter.Operator, int)` — the API now accepts string forms of the `dte` parameter on `GET /v1/options/chain/{underlying}/` (API release 2.0.0). Sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, `ExpirationFilter.DteComparison`; `ExpirationFilter.Operator` enum (GT, GTE, LT, LTE) with `ExpirationFilter.Operator.wireValue()` accessor; and constants `ExpirationFilter.MIN_DTE` / `ExpirationFilter.MAX_DTE`. Existing `ExpirationFilter.dte(int)` works unchanged. + ## [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 6634359..914a4a4 100644 --- a/docs/options/chain.md +++ b/docs/options/chain.md @@ -20,7 +20,10 @@ 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)) // closest to N days + .expirationFilter(ExpirationFilter.dteList(List days)) // closest to each of N1,N2,... + .expirationFilter(ExpirationFilter.dteRange(int min, int max)) // dte in [min, max], inclusive + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator op, int days)) // dte op days .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -40,6 +43,12 @@ OptionsChainRequest.builder(String symbol) .build() ``` +`dte` alone carries four wire syntaxes: a single value (`dte(45)` → `?dte=45`), a comma-separated +list (`dteList(List.of(15, 30, 45))` → `?dte=15,30,45`, selecting the closest expiration for each +value), a closed range (`dteRange(0, 45)` → `?dte=0-45`, inclusive), and a comparison +(`dteComparison(ExpirationFilter.Operator.GTE, 30)` → `?dte=>=30`). Every `dte` value must be a +whole number of days in `[0, 36500]`. + #### Returns @@ -95,6 +104,18 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte also accepts a range or a comparison, in addition to a single value or a list. + var within30To45Days = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .side(OptionSide.CALL) + .build()); + var at30DaysOrFurther = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .side(OptionSide.CALL) + .build()); } ``` diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java index c4f0977..6484c61 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -51,6 +51,17 @@ public static void main(String[] args) { .values().stream().map(OptionQuote::expiration).distinct().count(); System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + // dte also accepts a comma-separated list, a closed range, and a comparison, in addition to + // the single value used above. + long between30And45 = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(30, 45)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("ExpirationFilter.dteRange(30, 45) spans " + between30And45 + " expirations"); + 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..daf14f9 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -14,6 +14,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -133,6 +134,166 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + @Test + void chainDteSingleValueSelectsTheClosestExpiration() { + // Pick the farthest real expiration (not the front-month) so a server that silently ignored + // the filter — falling back to its own front-month default — would be caught: that default + // would not match "target" here. + List dtes = liveDistinctDtes(); + int target = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(target)) + .side(OptionSide.CALL) + .build()) + .values(); + + assertThat(result).isNotEmpty(); + assertThat(result.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte=%d resolves to the single expiration closest to that value", target) + .containsExactly(target); + } + + @Test + void chainDteListResolvesToEachValuesClosestExpiration() { + List dtes = liveDistinctDtes(); + int first = dtes.get(0); + int last = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(List.of(first, last))) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(result.stream().map(OptionQuote::dte).distinct().toList()) + .as("dte list resolves to exactly the two requested expirations, nothing else") + .containsExactlyInAnyOrder(first, last); + } + + @Test + void chainDteRangeKeepsExpirationsWithinInclusiveBounds() { + // An interior subrange (second through second-to-last baseline dte) so the requested range is + // a strict subset of the full chain — unlike the full span, this distinguishes a working range + // filter from one that was ignored (expiration=all's own front-to-back span would otherwise + // look + // identical to the filtered answer). + List dtes = liveDistinctDtes(); + int min = dtes.get(1); + int max = dtes.get(dtes.size() - 2); + int belowRange = dtes.get(0); + int aboveRange = dtes.get(dtes.size() - 1); + + List result = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(min, max)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + List resultDtes = result.stream().map(OptionQuote::dte).distinct().toList(); + assertThat(resultDtes) + .as("every returned expiration falls within the inclusive [%d, %d] bound", min, max) + .allMatch(d -> d >= min && d <= max); + assertThat(resultDtes) + .as("the range's own endpoints are kept (bounds are inclusive)") + .contains(min, max); + assertThat(resultDtes) + .as("expirations outside the requested range are excluded, not just the ones inside kept") + .doesNotContain(belowRange, aboveRange); + } + + @Test + void chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt() { + // Compare each comparison answer against the exact baseline slice it ought to produce (not + // just "contains" / "does not contain" the boundary): a server that ignored the dte filter + // entirely would fall back to its own front-month default, which happens not to carry + // "boundary" either, so a bare doesNotContain(boundary) on the "<" half would pass whether or + // not the filter was honored. The exact-match on both halves proves inclusion and exclusion at + // once: nothing below the boundary leaks into ">=", and nothing at or above it leaks into "<". + List dtes = liveDistinctDtes(); + int boundary = dtes.get(dtes.size() / 2); + List atOrAboveBoundary = dtes.stream().filter(d -> d >= boundary).toList(); + List belowBoundary = dtes.stream().filter(d -> d < boundary).toList(); + + List included = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, boundary)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List includedDtes = + included.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(includedDtes) + .as( + ">=%d keeps every baseline expiration at or above %d, boundary included", + boundary, boundary) + .allMatch(d -> d >= boundary) + .containsExactlyElementsOf(atOrAboveBoundary); + + List excluded = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, boundary)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List excludedDtes = + excluded.stream().map(OptionQuote::dte).distinct().sorted().toList(); + assertThat(excludedDtes) + .as( + "<%d keeps exactly the baseline expirations below %d, boundary excluded", + boundary, boundary) + .containsExactlyElementsOf(belowBoundary); + } + + /** + * Ground truth for the dte-filter assertions: the distinct dte values present in today's full + * AAPL call chain, discovered via the already-covered {@code expiration=all} filter rather than + * computed from the local clock. AAPL carries far more than four standard expirations at any + * time, so the index picks used by the dte tests (first / second / middle / second-to-last / + * last) are always distinct, and the range test's interior subrange always has at least one + * baseline dte strictly outside it on each side. + */ + private List liveDistinctDtes() { + List all = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List dtes = + all.stream().map(OptionQuote::dte).filter(Objects::nonNull).distinct().sorted().toList(); + assertThat(dtes).as("AAPL carries several distinct expirations").hasSizeGreaterThanOrEqualTo(4); + return dtes; + } + @Test void chainDecodesOptionalRhoColumn() { // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..d35906e 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", dteListWireValue(v)); + } 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())); @@ -325,6 +332,10 @@ private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilte // an if-chain, but there is no reachable else, so no defensive throw is needed. } + private static String dteListWireValue(ExpirationFilter.DteList f) { + return f.days().stream().map(String::valueOf).collect(Collectors.joining(",")); + } + private static String strikeFilterWireValue(StrikeFilter f) { if (f instanceof StrikeFilter.Exact v) { return formatStrike(v.price()); diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..e3fbb37 100644 --- a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -1,6 +1,7 @@ package com.marketdata.sdk.options; import java.time.LocalDate; +import java.util.List; import java.util.Objects; /** @@ -14,14 +15,28 @@ *

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

{@code dte} alone carries four wire syntaxes — a single value ({@link #dte(int)}), a + * comma-separated list ({@link #dteList(List)}), a closed range ({@link #dteRange(int, int)}), and + * a comparison ({@link #dteComparison(Operator, int)}) — mirroring {@link StrikeFilter}'s + * exact/range/comparison forms plus the list form the API allows only for {@code dte}. */ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { + /** Smallest number of days to expiry the API accepts for any {@code dte} form. */ + int MIN_DTE = 0; + + /** Largest number of days to expiry the API accepts for any {@code dte} form. */ + int MAX_DTE = 36500; + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ static OnDate onDate(LocalDate date) { return new OnDate(date); @@ -39,7 +54,10 @@ static All all() { return new All(); } - /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + /** + * Days-to-expiration filter — wire form {@code ?dte=N}. Limits the option chain to the expiration + * date closest to {@code days}. + */ static Dte dte(int days) { if (days < 0) { throw new IllegalArgumentException("dte must be non-negative"); @@ -47,6 +65,33 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration list — wire form {@code ?dte=15,30,45}. Selects, for each value in {@code + * days}, the expiration closest to it; the API deduplicates the result. Each value must be in + * {@code [0, 36500]}. + */ + static DteList dteList(List days) { + return new DteList(days); + } + + /** + * Days-to-expiration closed range — wire form {@code ?dte=0-45}. Returns every expiration whose + * dte falls within {@code [min, max]}, inclusive. {@code min} must not exceed {@code max}, and + * both must be in {@code [0, 36500]}. + */ + static DteRange dteRange(int min, int max) { + return new DteRange(min, max); + } + + /** + * Days-to-expiration comparison — wire form {@code ?dte=>=30} (e.g. {@code operator} {@code GTE}, + * {@code days} {@code 30}). Returns every expiration satisfying {@code operator days}. {@code + * days} must be in {@code [0, 36500]}. + */ + static DteComparison dteComparison(Operator operator, int days) { + return new DteComparison(operator, days); + } + /** * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not * be strictly after {@code to}. @@ -71,6 +116,36 @@ static MonthYear monthYear(int year, int month) { return new MonthYear(year, month); } + /** + * Comparison operators accepted by the {@code dte} parameter — mirrors {@link StrikeFilter}'s. + */ + enum Operator { + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String wireValue; + + Operator(String wireValue) { + this.wireValue = wireValue; + } + + /** The wire-form prefix the API expects, e.g. {@code ">="}. */ + public String wireValue() { + return wireValue; + } + } + + /** + * Rejects a {@code dte} value outside {@code [0, 36500]} — shared by every new {@code dte} form. + */ + private static void checkDteBounds(int days) { + if (days < MIN_DTE || days > MAX_DTE) { + throw new IllegalArgumentException("dte must be in [0, 36500]"); + } + } + record OnDate(LocalDate date) implements ExpirationFilter { public OnDate { Objects.requireNonNull(date, "date"); @@ -79,6 +154,40 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + /** See {@link #dteList(List)}. */ + record DteList(List days) implements ExpirationFilter { + public DteList { + Objects.requireNonNull(days, "days"); + if (days.isEmpty()) { + throw new IllegalArgumentException("days must not be empty"); + } + for (Integer day : days) { + Objects.requireNonNull(day, "days must not contain null elements"); + checkDteBounds(day); + } + days = List.copyOf(days); + } + } + + /** See {@link #dteRange(int, int)}. */ + record DteRange(int min, int max) implements ExpirationFilter { + public DteRange { + checkDteBounds(min); + checkDteBounds(max); + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + /** See {@link #dteComparison(Operator, int)}. */ + record DteComparison(Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + checkDteBounds(days); + } + } + 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..53d519f 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -27,6 +27,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; @@ -975,6 +976,103 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(List.of(15, 30, 45))) + .build()) + .join(); + + // The query encoder percent-encodes the comma; decode before asserting the exact wire value, + // matching how the repository's other comma-joined params (e.g. columns) are asserted. + 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 chainExpirationFilterDteRangeTranslatesToDashedDte() { + 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 chainExpirationFilterDteComparisonGtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GT, 10)) + .build()) + .join(); + + // "%3E" is the URL-encoded ">" — the query encoder pushes reserved characters through. + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E10"); + } + + @Test + void chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, 30)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3E%3D30"); + } + + @Test + void chainExpirationFilterDteComparisonLtRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 45)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + + @Test + void chainExpirationFilterDteComparisonLteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(ExpirationFilter.Operator.LTE, 60)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C%3D60"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1203,6 +1301,106 @@ void expirationFilterDteRejectsNegative() { .hasMessageContaining("dte must be non-negative"); } + @Test + void expirationFilterDteListRejectsEmptyList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(-1, 30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(List.of(15, 36501))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteListRejectsNullList() { + assertThatThrownBy(() -> ExpirationFilter.dteList(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days"); + } + + @Test + void expirationFilterDteListRejectsNullElement() { + assertThatThrownBy(() -> ExpirationFilter.dteList(Arrays.asList(15, null))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days must not contain null elements"); + } + + @Test + void expirationFilterDteListRecordConstructorAlsoValidates() { + // The static factory isn't the only public path: the record's own canonical constructor is + // public too, so validation has to live there, not just in dteList(...). + assertThatThrownBy(() -> new ExpirationFilter.DteList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("days must not be empty"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteRangeRecordConstructorAlsoValidates() { + assertThatThrownBy(() -> new ExpirationFilter.DteRange(45, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteComparisonRejectsNegative() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(ExpirationFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be in [0, 36500]"); + } + + @Test + void expirationFilterDteComparisonRejectsNullOperator() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + + @Test + void expirationFilterDteComparisonRecordConstructorAlsoValidates() { + assertThatThrownBy(() -> new ExpirationFilter.DteComparison(null, 30)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operator"); + } + @Test void expirationFilterBetweenRejectsReversedDates() { assertThatThrownBy(