From c54fbe534fe3db7efb48b37b6f57649a4e1b3962 Mon Sep 17 00:00:00 2001 From: sdk-bot Date: Tue, 10 Nov 2026 08:42:51 -0500 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 unit tests in OptionsResourceTest.java for the chain endpoint's dte parameter widening (integer -> string): wire tests for the comma-separated list, closed range, and comparison-operator forms (ExpirationFilter.dteList/dteRange/dteComparison, reusing StrikeFilter.Operator per "syntax matches strike"), plus validation tests for empty list, out-of-range/negative values, and min>max range, scoped only to these new methods. The existing single-value dte wire test and its negative-value validation test are untouched (regression). Added integration tests in OptionsIntegrationTest.java: one per dte form (single value, list, range, >=, <) each proving the filter changes the result versus a bare/unfiltered call, plus a boundary test that discovers a real dte via a filtered range call and proves >=N includes it while string): added ExpirationFilter.dteList/dteRange/dteComparison as new sealed-interface variants beside the unchanged dte(int), reusing StrikeFilter.Operator, wired to ?dte= as comma/dash/operator-prefixed wire forms. Docs: updated docs/options/chain.md (signatures + Java example) and examples/consumer-test's OptionsAdvancedExample.java (not built here) with a dteComparison demo; every new method/record has a Javadoc comment. Wire tests: phase-1's per-form tests (list, range, >=, <) are the only ones kept — no operator or form added beyond what phase 1 specified. Validation: each new record's compact constructor validates directly (not only the static factories); DteList rejects an empty list and a null element, in addition to the factory-level checks. Coverage: reran jacocoTestCoverageVerification uncached; it passed with no coverage-ignore added. Existing examples/docs showing dte(45)/.all() are untouched and still correct; no change weakens them. No check was added to the existing dte(int) factory or to OptionsChainRequest.Builder.expirationFilter(ExpirationFilter) — both keep accepting what they did; the 0..36500 bound only guards the three new methods. Live tests: re-checked each new/changed integration test against the unfiltered-call rule before finishing; all fail on an unfiltered chain call (none pass vacuously). Left unchanged, no dte-specific content to update: README.md, docs/client.md, docs/options/{README,quotes,expirations,lookup}.md, ADR-006, examples/OptionsExample.java. ADR-008's "OnDate/Dte/Between/MonthYear/All" list is historical context describing what shipped at acceptance; left as-is since new sealed-type variants are non-breaking and don't falsify it. `gradle build` fails only on pre-existing Spotless violations in ~30 files this change never touched (confirmed via git status, e.g. FundsResource.java, HttpDispatcher.java) — left unfixed to keep this diff minimal; `gradle test` and `gradle spotlessJavaCheck` on the changed files are green. implement (attempt 2): The build failure was spotlessJavaCheck, flagging pre-existing formatting drift (likely from the recent Spotless dependency bump) in files this task hadn't touched, plus a few lines in files I had edited that didn't match the formatter's exact style. Ran `gradle spotlessApply` to reformat the whole Java source tree; this rewrote whitespace/line-wrapping only (confirmed via diff) across ~27 files I had not otherwise changed (FundsResource.java, HttpDispatcher.java, HttpTransport.java, and others) plus the files from this change (OptionsResource.java, ExpirationFilter.java, OptionsResourceTest.java) — no assertions, method bodies, or production logic were altered in any of them. `gradle test` and `gradle build` both now succeed. review fix 1: Finding 1 (line 308, vacuous noneSatisfy): added `assertThat(strictlyBefore).isNotEmpty()` before the `noneSatisfy` check so the "=180` bound, mirroring the LT test, so a single closest-to-180 expiration no longer satisfies the comparison-form test. Minor note: added `expirationFilterDteListRecordConstructorRejectsNullElement`, constructing `ExpirationFilter.DteList` directly with a null element and asserting the `NullPointerException` the compact constructor's `Objects.requireNonNull` already throws. Re-checked all touched live tests against the unfiltered/ignored-parameter rule: each still fails if the server ignored the parameter (the dteList test needs exactly 3, the GTE test now needs >1 distinct expirations, and the boundary test's `anySatisfy` on `atOrAfter` already fails if GTE were ignored). Ran `gradle spotlessApply`-equivalent manual reformatting on the two added assertion blocks to match the project's line-wrapping; no other files needed touching this round. changelog: Added changelog entry for API release 2.0.0: `ExpirationFilter.dteList(int...)`, `ExpirationFilter.dteRange(int, int)`, and `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` factory methods for the `/v1/options/chain/` endpoint's widened `dte` parameter (comma-separated list, closed range, and comparison operator forms), implemented by sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and `ExpirationFilter.DteComparison`. Existing `ExpirationFilter.dte(int)` unchanged. --- CHANGELOG.md | 10 + docs/options/chain.md | 21 +- .../resources/OptionsAdvancedExample.java | 11 + .../sdk/OptionsIntegrationTest.java | 189 +++++++++++++++++ .../com/marketdata/sdk/FundsResource.java | 6 +- .../com/marketdata/sdk/HttpDispatcher.java | 76 +++---- .../com/marketdata/sdk/HttpTransport.java | 15 +- .../com/marketdata/sdk/JsonResponses.java | 12 +- .../com/marketdata/sdk/MarketDataClient.java | 19 +- .../com/marketdata/sdk/MarketDataLogging.java | 30 ++- .../com/marketdata/sdk/MarketsResource.java | 8 +- .../marketdata/sdk/OptionsCsvResource.java | 17 +- .../com/marketdata/sdk/OptionsResource.java | 34 ++- .../com/marketdata/sdk/RateLimitHeaders.java | 15 +- .../com/marketdata/sdk/RetryExecutor.java | 61 +++--- .../java/com/marketdata/sdk/RetryPolicy.java | 13 +- .../java/com/marketdata/sdk/StatusCache.java | 29 ++- .../com/marketdata/sdk/StocksCsvResource.java | 25 +-- .../com/marketdata/sdk/StocksResource.java | 34 ++- .../com/marketdata/sdk/TextResponses.java | 5 +- .../sdk/options/ExpirationFilter.java | 80 +++++++ .../marketdata/sdk/AsyncSemaphoreTest.java | 18 +- .../com/marketdata/sdk/ConfigurationTest.java | 50 ++--- .../com/marketdata/sdk/DotEnvLoaderTest.java | 98 +++++---- .../com/marketdata/sdk/FundsResourceTest.java | 42 ++-- .../com/marketdata/sdk/HttpTransportTest.java | 36 ++-- .../sdk/JsonResponseParserTest.java | 81 ++++--- .../marketdata/sdk/MarketsResourceTest.java | 33 ++- .../marketdata/sdk/OptionsResourceTest.java | 199 +++++++++++++++--- .../marketdata/sdk/ParallelArraysTest.java | 99 ++++----- .../sdk/ParserAndUtilCoverageTest.java | 5 +- .../marketdata/sdk/RequestValidationTest.java | 11 +- .../com/marketdata/sdk/RetryExecutorTest.java | 47 ++--- .../marketdata/sdk/StocksResourceTest.java | 33 ++- 34 files changed, 918 insertions(+), 544 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..b5ada7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ExpirationFilter.dteList(int...)`, `ExpirationFilter.dteRange(int, int)`, and + `ExpirationFilter.dteComparison(StrikeFilter.Operator, int)` — factory methods + for `/v1/options/chain/` `dte` filtering: comma-separated list of days, closed + inclusive range, and comparison operators (≥, >, ≤, <) (API release 2.0.0). + Sealed subtypes `ExpirationFilter.DteList`, `ExpirationFilter.DteRange`, and + `ExpirationFilter.DteComparison` implement these forms. `ExpirationFilter.dte(int)` + 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..2c8b205 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)) // days-to-expiration + .expirationFilter(ExpirationFilter.dteList(int... days)) // e.g. 15,30,45 + .expirationFilter(ExpirationFilter.dteRange(int minDays, int maxDays)) // closed range, e.g. 0-45 + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator op, int days)) // e.g. >=30 .expirationFilter(ExpirationFilter.between(LocalDate from, LocalDate to)) .expirationFilter(ExpirationFilter.all()) // every expiration // (if omitted, the API narrows to the front month) @@ -28,6 +31,7 @@ OptionsChainRequest.builder(String symbol) // Strike selection (sealed StrikeFilter — pick one): .strikeFilter(StrikeFilter.exact(double price)) .strikeFilter(StrikeFilter.range(double min, double max)) + .strikeFilter(StrikeFilter.comparison(StrikeFilter.Operator op, double price)) // Additive filters: .side(OptionSide.CALL) // CALL or PUT @@ -95,6 +99,21 @@ try (MarketDataClient client = new MarketDataClient()) { .strikeFilter(StrikeFilter.range(150, 250)) .side(OptionSide.CALL) .build()); + + // dte's other forms: a comma-separated list, a closed range, and a comparison — the same + // syntax as strike's range()/comparison(). + var dteList = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()); + var dteRange = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteRange(0, 45)) + .build()); + var dteComparison = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .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..09b347e 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's other forms — a comma-separated list, a closed range, and a comparison — use the + // same syntax as StrikeFilter.range()/comparison(). + long beyond180 = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 180)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().size(); + System.out.println("dteComparison(GTE, 180) matched " + beyond180 + " contract(s)"); + 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..ad8ffd2 100644 --- a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -10,6 +10,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 +134,194 @@ void chainExpirationAllSpansMultipleExpirations() { .isGreaterThan(1); } + // ---------- chain: dte query param (integer -> string) ---------- + + @Test + void chainDteSingleValueSelectsOneExpirationFarFromFrontMonth() { + // A far-dated single dte value selects the one expiration closest to it — a window chosen + // well past where the front-month (a bare, unfiltered chain call's default) sits, so the + // filter is provably doing the selecting rather than just reproducing the default. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dte(300)) + .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); + assertThat(chain.get(0).dte()) + .as("the selected expiration lands well past the front month a bare call would return") + .isGreaterThan(200); + } + + @Test + void chainDteListSelectsOneExpirationPerValue() { + // A comma-separated dte list spans several widely-separated windows; a bare chain call + // returns only the single front-month expiration, so more than one distinct expiration here + // proves the list reached the server rather than being collapsed to one value. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteList(30, 120, 300)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + // 30/120/300 are far enough apart that dedup can't collapse them — the API documents one + // closest expiration selected per value, so this must land on exactly three, not merely + // "more than the one expiration a bare call returns" (which a range or single-value misread + // of the list would also satisfy). + assertThat(distinctExpirations) + .as("a comma-separated dte list selects the closest expiration for each of the 3 values") + .isEqualTo(3); + } + + @Test + void chainDteRangeReturnsEveryExpirationWithinBounds() { + // A closed dte range should return every expiration inside it (inclusive), not just the + // closest one — AAPL's weekly cycle guarantees more than one expiration lands in a 90-day + // window that starts well past the front month. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(60, 150)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain) + .as("every row's dte falls within the closed, inclusive range") + .allSatisfy(q -> assertThat(q.dte()).isBetween(60, 150)); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("the range spans more than the single front-month expiration a bare call returns") + .isGreaterThan(1); + } + + @Test + void chainDteComparisonGteExcludesTheFrontMonth() { + // >=180 excludes the near-term front-month expiration a bare call returns — every row must + // sit at or beyond the bound. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 180)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain) + .as(">=180 keeps only expirations at or beyond the bound") + .allSatisfy(q -> assertThat(q.dte()).isGreaterThanOrEqualTo(180)); + // A single closest-to-180 expiration would also satisfy the per-row bound above — the + // documented comparison-form behavior ("every expiration whose dte falls within the bounds") + // is only distinguished from the single-value form by spanning more than one expiration. + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as(">=180 spans more than the single front-month expiration a bare call returns") + .isGreaterThan(1); + } + + @Test + void chainDteComparisonLtSpansMultipleExpirationsUnderBound() { + // <365 stays under the bound on every row, but spans many expirations — unlike the single + // front-month expiration a bare call returns. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 365)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + assertThat(chain) + .as("every row stays under the bound") + .allSatisfy(q -> assertThat(q.dte()).isLessThan(365)); + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("<365 spans many expirations, unlike the single front-month a bare call returns") + .isGreaterThan(1); + } + + @Test + void chainDteComparisonBoundaryIsInclusiveAtGteAndExclusiveAtLt() { + // Discover a real dte value via the range filter (a call made with the parameter, not the + // unfiltered chain), then prove the inclusive/exclusive boundary the comparison operators + // claim: the exact same dte is kept by >=N and left out by withinRange = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.dteRange(60, 150)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + assertThat(withinRange).isNotEmpty(); + int boundaryDte = withinRange.stream().mapToInt(OptionQuote::dte).min().orElseThrow(); + + List atOrAfter = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, boundaryDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + List strictlyBefore = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter( + ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, boundaryDte)) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + assertThat(atOrAfter) + .as(">=N keeps the expiration at exactly N") + .anySatisfy(q -> assertThat(q.dte()).isEqualTo(boundaryDte)); + // noneSatisfy below passes vacuously on an empty list — assert there's actually something + // returned for assertThat(q.dte()).isEqualTo(boundaryDte)); + } + @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/FundsResource.java b/src/main/java/com/marketdata/sdk/FundsResource.java index 9a30704..2204922 100644 --- a/src/main/java/com/marketdata/sdk/FundsResource.java +++ b/src/main/java/com/marketdata/sdk/FundsResource.java @@ -173,9 +173,9 @@ public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOExcepti /** * Option A: for every required column the consumer asked for (explicitly via {@code columns}, or - * implicitly by not projecting at all), verify the API actually returned it. A requested-but- - * absent required column throws, so a {@code null} a consumer sees only ever means "I projected - * it away" — never "the backend silently dropped it". + * implicitly by not projecting at all), verify the API actually returned it. A + * requested-but-absent required column throws, so a {@code null} a consumer sees only ever means + * "I projected it away" — never "the backend silently dropped it". */ private static void validateRequestedColumns( JsonParser p, diff --git a/src/main/java/com/marketdata/sdk/HttpDispatcher.java b/src/main/java/com/marketdata/sdk/HttpDispatcher.java index 957b2d5..c906617 100644 --- a/src/main/java/com/marketdata/sdk/HttpDispatcher.java +++ b/src/main/java/com/marketdata/sdk/HttpDispatcher.java @@ -62,12 +62,11 @@ CompletableFuture> dispatch(HttpRequest request) { // permit by completing the waiter, but thenCompose's function wouldn't run (its dependent // is already cancelled), and send — which registers whenComplete(release) — would never // fire. Cancelling `permit` here makes AsyncSemaphore.release skip the waiter. - dispatched.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - permit.cancel(false); - } - }); + dispatched.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + permit.cancel(false); + } + }); return dispatched; } @@ -84,12 +83,8 @@ private CompletableFuture> send(HttpRequest request) { // never formed, so the whenComplete below would never fire — release the permit here // to prevent a permanent leak that would degrade the pool to deadlock. permits.release(); - LOGGER.warning( - () -> - "Request to " - + safeUri(request.uri()) - + " failed before dispatch: " - + t.getMessage()); + LOGGER.warning(() -> + "Request to " + safeUri(request.uri()) + " failed before dispatch: " + t.getMessage()); if (t instanceof Error err) { throw err; } @@ -102,36 +97,33 @@ private CompletableFuture> send(HttpRequest request) { return sendFuture .whenComplete((r, t) -> permits.release()) - .handle( - (response, error) -> { - long elapsedMs = Duration.between(start, clock.instant()).toMillis(); - if (error != null) { - Throwable root = unwrap(error); - LOGGER.warning( - () -> - "Request to " - + safeUri(request.uri()) - + " failed after " - + elapsedMs - + "ms: " - + root.getMessage()); - throw new CompletionException( - new NetworkError( - "Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(), - ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), - root)); - } - LOGGER.fine( - () -> - "Response " - + response.statusCode() - + " from " - + safeUri(request.uri()) - + " in " - + elapsedMs - + "ms"); - return response; - }); + .handle((response, error) -> { + long elapsedMs = Duration.between(start, clock.instant()).toMillis(); + if (error != null) { + Throwable root = unwrap(error); + LOGGER.warning(() -> + "Request to " + + safeUri(request.uri()) + + " failed after " + + elapsedMs + + "ms: " + + root.getMessage()); + throw new CompletionException( + new NetworkError( + "Request to " + safeUri(request.uri()) + " failed: " + root.getMessage(), + ErrorContext.forNoResponse(request.uri().toString(), clock.instant()), + root)); + } + LOGGER.fine(() -> + "Response " + + response.statusCode() + + " from " + + safeUri(request.uri()) + + " in " + + elapsedMs + + "ms"); + return response; + }); } /** diff --git a/src/main/java/com/marketdata/sdk/HttpTransport.java b/src/main/java/com/marketdata/sdk/HttpTransport.java index 32e7d88..bbf5e44 100644 --- a/src/main/java/com/marketdata/sdk/HttpTransport.java +++ b/src/main/java/com/marketdata/sdk/HttpTransport.java @@ -323,14 +323,13 @@ private HttpResponseEnvelope routeAndEnvelope(HttpResponse response, URI requireMapped(HttpStatusMapper.map(status, context, retryAfter), status, uri, context); // §16: route the URI through safeUri so getMessage() — accessible to any consumer that logs // the exception — never carries query strings (token, account_id, symbols, …). - LOGGER.warning( - () -> - "Request to " - + HttpDispatcher.safeUri(uri) - + " returned HTTP " - + status - + ": " - + ex.getMessage()); + LOGGER.warning(() -> + "Request to " + + HttpDispatcher.safeUri(uri) + + " returned HTTP " + + status + + ": " + + ex.getMessage()); throw ex; } diff --git a/src/main/java/com/marketdata/sdk/JsonResponses.java b/src/main/java/com/marketdata/sdk/JsonResponses.java index 493a7a9..6a807e3 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponses.java +++ b/src/main/java/com/marketdata/sdk/JsonResponses.java @@ -27,10 +27,8 @@ static CompletableFuture execute( ResponseFactory factory) { return transport .executeAsync(spec) - .thenApply( - env -> - factory.create( - parser.parse(env, decodeType, requestedColumns), env, spec.format())); + .thenApply(env -> + factory.create(parser.parse(env, decodeType, requestedColumns), env, spec.format())); } static CompletableFuture execute( @@ -43,9 +41,7 @@ static CompletableFuture execute( ResponseFactory factory) { return transport .executeAsync(spec, policy) - .thenApply( - env -> - factory.create( - parser.parse(env, decodeType, requestedColumns), env, spec.format())); + .thenApply(env -> + factory.create(parser.parse(env, decodeType, requestedColumns), env, spec.format())); } } diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index d00221e..58aade3 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -79,16 +79,15 @@ public MarketDataClient( for (DotEnvLoader.Warning w : pendingWarnings) { LOGGER.log(w.level(), w.message(), w.cause()); } - LOGGER.info( - () -> - "MarketDataClient initialized: baseUrl=" - + config.baseUrl() - + ", apiVersion=" - + config.apiVersion() - + ", token=" - + Tokens.redact(config.apiKey()) - + ", demoMode=" - + DemoMode.isDemo(config)); + LOGGER.info(() -> + "MarketDataClient initialized: baseUrl=" + + config.baseUrl() + + ", apiVersion=" + + config.apiVersion() + + ", token=" + + Tokens.redact(config.apiKey()) + + ", demoMode=" + + DemoMode.isDemo(config)); // §9.5: the status cache pre-checks /status/ before retrying 5xx. The cache's fetcher uses // `utilities.statusAsync()`, which goes through this transport — a chicken-and-egg. We diff --git a/src/main/java/com/marketdata/sdk/MarketDataLogging.java b/src/main/java/com/marketdata/sdk/MarketDataLogging.java index 8b8877e..1ffe9ee 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataLogging.java +++ b/src/main/java/com/marketdata/sdk/MarketDataLogging.java @@ -68,13 +68,12 @@ static void configure(@Nullable String levelSpec) { // surprise has a breadcrumb. Level installed = Logger.getLogger(SDK_LOGGER_NAME).getLevel(); if (installed != null && !installed.equals(requested)) { - LOG.fine( - () -> - "MarketDataLogging.configure called with level " - + requested.getName() - + " but logger is already configured at " - + installed.getName() - + "; ignoring (first-install-wins)."); + LOG.fine(() -> + "MarketDataLogging.configure called with level " + + requested.getName() + + " but logger is already configured at " + + installed.getName() + + "; ignoring (first-install-wins)."); } return; } @@ -129,15 +128,14 @@ static Level parseLevel(@Nullable String levelSpec) { // Unknown spec — fall back to the default level, but loudly. A silent fallback was the // worst of both worlds: the consumer typed something wrong and saw INFO output instead of // the DEBUG they expected, with no breadcrumb pointing at the typo. - LOG.warning( - () -> - "Unrecognized MARKETDATA_LOGGING_LEVEL value '" - + levelSpec - + "'; expected one of " - + VALID_LEVEL_NAMES - + ". Falling back to " - + DEFAULT_LEVEL.getName() - + "."); + LOG.warning(() -> + "Unrecognized MARKETDATA_LOGGING_LEVEL value '" + + levelSpec + + "'; expected one of " + + VALID_LEVEL_NAMES + + ". Falling back to " + + DEFAULT_LEVEL.getName() + + "."); return DEFAULT_LEVEL; } diff --git a/src/main/java/com/marketdata/sdk/MarketsResource.java b/src/main/java/com/marketdata/sdk/MarketsResource.java index 52a0d68..ab43275 100644 --- a/src/main/java/com/marketdata/sdk/MarketsResource.java +++ b/src/main/java/com/marketdata/sdk/MarketsResource.java @@ -167,10 +167,10 @@ public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOExcepti /** * Option A: for every required column the consumer asked for (explicitly via {@code columns}, or - * implicitly by not projecting at all), verify the API actually returned it. A requested-but- - * absent required column throws, so a {@code null} a consumer sees only ever means "I projected - * it away" (or, for {@code status} cells, "outside calendar coverage") — never "the backend - * silently dropped the column". + * implicitly by not projecting at all), verify the API actually returned it. A + * requested-but-absent required column throws, so a {@code null} a consumer sees only ever means + * "I projected it away" (or, for {@code status} cells, "outside calendar coverage") — never "the + * backend silently dropped the column". */ private static void validateRequestedColumns( JsonParser p, diff --git a/src/main/java/com/marketdata/sdk/OptionsCsvResource.java b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java index 21d71f0..9f4a3bb 100644 --- a/src/main/java/com/marketdata/sdk/OptionsCsvResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java @@ -68,15 +68,14 @@ public CompletableFuture> quotesAsync(OptionsQuotesRequ .thenApply(resp -> Map.entry(symbol, resp))); } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenApply( - unused -> { - Map result = new LinkedHashMap<>(); - for (CompletableFuture> f : futures) { - Map.Entry entry = f.join(); - result.put(entry.getKey(), entry.getValue()); - } - return result; - }); + .thenApply(unused -> { + Map result = new LinkedHashMap<>(); + for (CompletableFuture> f : futures) { + Map.Entry entry = f.join(); + result.put(entry.getKey(), entry.getValue()); + } + return result; + }); } public Map quotes(OptionsQuotesRequest request) { diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java index 81fc7ee..9cf475d 100644 --- a/src/main/java/com/marketdata/sdk/OptionsResource.java +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -156,15 +156,14 @@ public CompletableFuture> quotesAsync( .thenApply(resp -> Map.entry(symbol, resp))); } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenApply( - unused -> { - Map result = new LinkedHashMap<>(); - for (CompletableFuture> f : futures) { - Map.Entry entry = f.join(); - result.put(entry.getKey(), entry.getValue()); - } - return result; - }); + .thenApply(unused -> { + Map result = new LinkedHashMap<>(); + for (CompletableFuture> f : futures) { + Map.Entry entry = f.join(); + result.put(entry.getKey(), entry.getValue()); + } + return result; + }); } /** Sync wrapper for {@link #quotesAsync(OptionsQuotesRequest)}. */ @@ -312,6 +311,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.minDays() + "-" + v.maxDays()); + } 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 +330,17 @@ 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) { + StringBuilder sb = new StringBuilder(); + for (Integer day : f.days()) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(day); + } + return sb.toString(); + } + 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/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java index 544dc59..2397fe4 100644 --- a/src/main/java/com/marketdata/sdk/RateLimitHeaders.java +++ b/src/main/java/com/marketdata/sdk/RateLimitHeaders.java @@ -40,14 +40,13 @@ private RateLimitHeaders() {} private static @Nullable Long readLong(HttpHeaders headers, String name) { return headers .firstValue(name) - .map( - v -> { - try { - return Long.parseLong(v.trim()); - } catch (NumberFormatException e) { - return null; - } - }) + .map(v -> { + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + return null; + } + }) .orElse(null); } } diff --git a/src/main/java/com/marketdata/sdk/RetryExecutor.java b/src/main/java/com/marketdata/sdk/RetryExecutor.java index dcfd9f2..36f8733 100644 --- a/src/main/java/com/marketdata/sdk/RetryExecutor.java +++ b/src/main/java/com/marketdata/sdk/RetryExecutor.java @@ -83,15 +83,14 @@ CompletableFuture execute( // already done by the time the next one overwrites the reference, so this avoids // accumulating one handler per attempt. AtomicReference<@Nullable CompletableFuture> currentAttempt = new AtomicReference<>(); - result.whenComplete( - (r, t) -> { - if (t instanceof CancellationException) { - CompletableFuture inFlight = currentAttempt.get(); - if (inFlight != null && !inFlight.isDone()) { - inFlight.cancel(false); - } - } - }); + result.whenComplete((r, t) -> { + if (t instanceof CancellationException) { + CompletableFuture inFlight = currentAttempt.get(); + if (inFlight != null && !inFlight.isDone()) { + inFlight.cancel(false); + } + } + }); attempt(supplier, shouldRetry, 0, null, result, currentAttempt); return result; } @@ -117,32 +116,24 @@ private void attempt( // currentAttempt under that race: if it sees the previous (already-done) attempt, it // doesn't cancel the new one. Re-check after publishing the new attempt. if (!cancelIfRaceLost(result, dispatched)) { - dispatched.whenComplete( - (value, error) -> { - if (result.isDone()) { - return; - } - if (error == null) { - result.complete(value); - return; - } - Throwable cause = unwrap(error); - if (shouldRetry.test(cause, attemptIdx)) { - long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); - CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) - .execute( - () -> - attempt( - supplier, - shouldRetry, - attemptIdx + 1, - cause, - result, - currentAttempt)); - } else { - result.completeExceptionally(cause); - } - }); + dispatched.whenComplete((value, error) -> { + if (result.isDone()) { + return; + } + if (error == null) { + result.complete(value); + return; + } + Throwable cause = unwrap(error); + if (shouldRetry.test(cause, attemptIdx)) { + long delayMs = policy.backoffDelay(cause, attemptIdx).toMillis(); + CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS) + .execute(() -> + attempt(supplier, shouldRetry, attemptIdx + 1, cause, result, currentAttempt)); + } else { + result.completeExceptionally(cause); + } + }); } } diff --git a/src/main/java/com/marketdata/sdk/RetryPolicy.java b/src/main/java/com/marketdata/sdk/RetryPolicy.java index 3ad2de6..7987d4e 100644 --- a/src/main/java/com/marketdata/sdk/RetryPolicy.java +++ b/src/main/java/com/marketdata/sdk/RetryPolicy.java @@ -97,13 +97,12 @@ Duration backoffDelay(Throwable cause, int attempt) { // to the calculated exponential backoff. The unbounded value is still preserved on the // ServerError instance for consumer inspection — only the SDK's own automatic wait is // capped. - LOGGER.warning( - () -> - "Server-supplied Retry-After of " - + override.toSeconds() - + "s exceeds cap of " - + MAX_RETRY_AFTER.toSeconds() - + "s; ignoring and using calculated exponential backoff"); + LOGGER.warning(() -> + "Server-supplied Retry-After of " + + override.toSeconds() + + "s exceeds cap of " + + MAX_RETRY_AFTER.toSeconds() + + "s; ignoring and using calculated exponential backoff"); } else { return override; } diff --git a/src/main/java/com/marketdata/sdk/StatusCache.java b/src/main/java/com/marketdata/sdk/StatusCache.java index 96bfd37..f45c75c 100644 --- a/src/main/java/com/marketdata/sdk/StatusCache.java +++ b/src/main/java/com/marketdata/sdk/StatusCache.java @@ -101,21 +101,20 @@ void triggerRefresh() { refreshInFlight.set(false); return; } - future.whenComplete( - (apiStatus, err) -> { - try { - if (err == null && apiStatus != null) { - snapshot.set(Snapshot.from(apiStatus, clock.instant())); - } else if (err != null) { - // On error: cache persists — §9.5 "Cache persists across failed refresh attempts" — - // but the failure is logged so operators can detect a /status/ outage instead of - // wondering why the SDK keeps blocking retries against a stale snapshot. - LOGGER.log(Level.WARNING, "StatusCache refresh failed; snapshot persists.", err); - } - } finally { - refreshInFlight.set(false); - } - }); + future.whenComplete((apiStatus, err) -> { + try { + if (err == null && apiStatus != null) { + snapshot.set(Snapshot.from(apiStatus, clock.instant())); + } else if (err != null) { + // On error: cache persists — §9.5 "Cache persists across failed refresh attempts" — + // but the failure is logged so operators can detect a /status/ outage instead of + // wondering why the SDK keeps blocking retries against a stale snapshot. + LOGGER.log(Level.WARNING, "StatusCache refresh failed; snapshot persists.", err); + } + } finally { + refreshInFlight.set(false); + } + }); } /** diff --git a/src/main/java/com/marketdata/sdk/StocksCsvResource.java b/src/main/java/com/marketdata/sdk/StocksCsvResource.java index 100e11b..00cfaea 100644 --- a/src/main/java/com/marketdata/sdk/StocksCsvResource.java +++ b/src/main/java/com/marketdata/sdk/StocksCsvResource.java @@ -54,22 +54,19 @@ public CompletableFuture candlesAsync(StockCandlesRequest request) futures.add( transport .executeAsync(spec) - .thenApply( - env -> - new EnvBody( - new String(env.body(), StandardCharsets.UTF_8), env, spec.format()))); + .thenApply(env -> + new EnvBody(new String(env.body(), StandardCharsets.UTF_8), env, spec.format()))); } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenApply( - unused -> { - EnvBody last = futures.get(futures.size() - 1).join(); - List bodies = new ArrayList<>(futures.size()); - for (CompletableFuture f : futures) { - bodies.add(f.join().body()); - } - return new CsvResponse( - mergeCsvBodies(bodies, headersIncluded), last.envelope(), last.format()); - }); + .thenApply(unused -> { + EnvBody last = futures.get(futures.size() - 1).join(); + List bodies = new ArrayList<>(futures.size()); + for (CompletableFuture f : futures) { + bodies.add(f.join().body()); + } + return new CsvResponse( + mergeCsvBodies(bodies, headersIncluded), last.envelope(), last.format()); + }); } private record EnvBody(String body, HttpResponseEnvelope envelope, Format format) {} diff --git a/src/main/java/com/marketdata/sdk/StocksResource.java b/src/main/java/com/marketdata/sdk/StocksResource.java index 90e713b..ff1f4ed 100644 --- a/src/main/java/com/marketdata/sdk/StocksResource.java +++ b/src/main/java/com/marketdata/sdk/StocksResource.java @@ -112,24 +112,22 @@ public java.util.concurrent.CompletableFuture candlesAsync futures.add( transport .executeAsync(spec) - .thenApply( - env -> - new DecodedChunk( - parser.parse(env, StockCandles.class, config.columns()), - env, - spec.format()))); + .thenApply(env -> + new DecodedChunk( + parser.parse(env, StockCandles.class, config.columns()), + env, + spec.format()))); } return java.util.concurrent.CompletableFuture.allOf( futures.toArray(new java.util.concurrent.CompletableFuture[0])) - .thenApply( - unused -> { - List merged = new java.util.ArrayList<>(); - DecodedChunk last = futures.get(futures.size() - 1).join(); - for (java.util.concurrent.CompletableFuture f : futures) { - merged.addAll(f.join().decoded().candles()); - } - return new StockCandlesResponse(List.copyOf(merged), last.envelope(), last.format()); - }); + .thenApply(unused -> { + List merged = new java.util.ArrayList<>(); + DecodedChunk last = futures.get(futures.size() - 1).join(); + for (java.util.concurrent.CompletableFuture f : futures) { + merged.addAll(f.join().decoded().candles()); + } + return new StockCandlesResponse(List.copyOf(merged), last.envelope(), last.format()); + }); } /** One decoded candle sub-request — its rows plus the envelope/format for metadata. */ @@ -602,9 +600,9 @@ public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOExcepti /** * Option A: for every required column the consumer asked for (explicitly via {@code columns}, or - * implicitly by not projecting at all), verify the API actually returned it. A requested-but- - * absent required column throws, so a {@code null} a consumer sees only ever means "I projected - * it away" — never "the backend silently dropped it". + * implicitly by not projecting at all), verify the API actually returned it. A + * requested-but-absent required column throws, so a {@code null} a consumer sees only ever means + * "I projected it away" — never "the backend silently dropped it". */ private static void validateRequestedColumns( JsonParser p, diff --git a/src/main/java/com/marketdata/sdk/TextResponses.java b/src/main/java/com/marketdata/sdk/TextResponses.java index a218d16..35f8cd2 100644 --- a/src/main/java/com/marketdata/sdk/TextResponses.java +++ b/src/main/java/com/marketdata/sdk/TextResponses.java @@ -35,8 +35,7 @@ static CompletableFuture execute( RequestSpec spec = builder.build(); return transport .executeAsync(spec) - .thenApply( - env -> - factory.create(new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); + .thenApply(env -> + factory.create(new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); } } diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java index 3b05379..12c3e16 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; /** @@ -18,6 +20,9 @@ public sealed interface ExpirationFilter permits ExpirationFilter.OnDate, ExpirationFilter.Dte, + ExpirationFilter.DteList, + ExpirationFilter.DteRange, + ExpirationFilter.DteComparison, ExpirationFilter.Between, ExpirationFilter.MonthYear, ExpirationFilter.All { @@ -47,6 +52,40 @@ static Dte dte(int days) { return new Dte(days); } + /** + * Days-to-expiration, comma-separated list — wire form {@code ?dte=15,30,45}. Selects the + * expiration closest to each value, deduplicated server-side. Each value must be non-negative and + * at most 36500 days, per the API. + */ + static DteList dteList(int... days) { + Objects.requireNonNull(days, "days"); + List boxed = new ArrayList<>(days.length); + for (int day : days) { + boxed.add(day); + } + return new DteList(boxed); + } + + /** + * Days-to-expiration, closed inclusive range — wire form {@code ?dte=0-45}. Returns every + * expiration whose dte falls within {@code [minDays, maxDays]}. {@code minDays} must not exceed + * {@code maxDays}; both must be non-negative and at most 36500 days, per the API. The syntax + * matches {@link StrikeFilter#range}. + */ + static DteRange dteRange(int minDays, int maxDays) { + return new DteRange(minDays, maxDays); + } + + /** + * Days-to-expiration, comparison — wire form {@code ?dte=>=30} or {@code ?dte=<45}. Returns every + * expiration whose dte satisfies {@code operator days}, inclusive where the operator is. {@code + * days} must be non-negative and at most 36500, per the API. The syntax matches {@link + * StrikeFilter#comparison}, and reuses its {@link StrikeFilter.Operator}. + */ + static DteComparison dteComparison(StrikeFilter.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}. @@ -79,6 +118,47 @@ record OnDate(LocalDate date) implements ExpirationFilter { record Dte(int days) implements ExpirationFilter {} + 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 element"); + checkDteBounds(day, "days element"); + } + days = List.copyOf(days); + } + } + + record DteRange(int minDays, int maxDays) implements ExpirationFilter { + public DteRange { + checkDteBounds(minDays, "minDays"); + checkDteBounds(maxDays, "maxDays"); + if (minDays > maxDays) { + throw new IllegalArgumentException("min must be <= max"); + } + } + } + + record DteComparison(StrikeFilter.Operator operator, int days) implements ExpirationFilter { + public DteComparison { + Objects.requireNonNull(operator, "operator"); + checkDteBounds(days, "days"); + } + } + + /** Shared bound for every dte form, per the API: "whole numbers of days, at most 36500." */ + private static void checkDteBounds(int days, String label) { + if (days < 0) { + throw new IllegalArgumentException(label + " must be non-negative"); + } + if (days > 36500) { + throw new IllegalArgumentException(label + " must be at most 36500"); + } + } + 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/AsyncSemaphoreTest.java b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java index d9fb713..b6ac391 100644 --- a/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java +++ b/src/test/java/com/marketdata/sdk/AsyncSemaphoreTest.java @@ -166,17 +166,15 @@ void releaseDoesNotLosePermitWhenWaiterIsCancelledMidRelease() throws Exception CyclicBarrier barrier = new CyclicBarrier(2); Thread releaser = - new Thread( - () -> { - awaitBarrier(barrier); - sem.release(); - }); + new Thread(() -> { + awaitBarrier(barrier); + sem.release(); + }); Thread canceller = - new Thread( - () -> { - awaitBarrier(barrier); - waiter.cancel(false); - }); + new Thread(() -> { + awaitBarrier(barrier); + waiter.cancel(false); + }); releaser.start(); canceller.start(); diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 0ae3160..1fc41db 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -69,10 +69,10 @@ void resolve_falls_back_to_dotenv_when_explicit_and_env_missing(@TempDir Path tm Files.writeString( tmp.resolve(".env"), """ - MARKETDATA_TOKEN=dotenv-key - MARKETDATA_BASE_URL=https://dotenv.example - MARKETDATA_API_VERSION=v3 - """); + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + MARKETDATA_API_VERSION=v3 + """); Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); @@ -91,10 +91,10 @@ void resolve_ignores_non_marketdata_keys_in_dotenv(@TempDir Path tmp) throws IOE Files.writeString( tmp.resolve(".env"), """ - MARKETDATA_TOKEN=marketdata-key - AWS_SECRET_ACCESS_KEY=sk-aws-supersecret - GITHUB_TOKEN=ghp-leaked - """); + MARKETDATA_TOKEN=marketdata-key + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + """); Configuration config = Configuration.resolve(null, null, null, NO_ENV, dotEnv); @@ -144,9 +144,9 @@ void resolve_explicit_beats_env_beats_dotenv_beats_default(@TempDir Path tmp) th Files.writeString( tmp.resolve(".env"), """ - MARKETDATA_TOKEN=dotenv-key - MARKETDATA_BASE_URL=https://dotenv.example - """); + MARKETDATA_TOKEN=dotenv-key + MARKETDATA_BASE_URL=https://dotenv.example + """); Configuration withExplicit = Configuration.resolve( @@ -331,20 +331,18 @@ void resolve_rejects_baseUrl_with_query_string(@TempDir Path tmp) { // Query belongs on requests, not the origin. Letting it through would corrupt every URL the // transport composes (`?token=abc/v1/markets/status/`). assertThatIllegalArgumentException() - .isThrownBy( - () -> - Configuration.resolve( - null, "https://api.example.com?token=x", null, NO_ENV, noDotEnv(tmp))) + .isThrownBy(() -> + Configuration.resolve( + null, "https://api.example.com?token=x", null, NO_ENV, noDotEnv(tmp))) .withMessageContaining("query string"); } @Test void resolve_rejects_baseUrl_with_fragment(@TempDir Path tmp) { assertThatIllegalArgumentException() - .isThrownBy( - () -> - Configuration.resolve( - null, "https://api.example.com#frag", null, NO_ENV, noDotEnv(tmp))) + .isThrownBy(() -> + Configuration.resolve( + null, "https://api.example.com#frag", null, NO_ENV, noDotEnv(tmp))) .withMessageContaining("fragment"); } @@ -353,10 +351,9 @@ void resolve_rejects_baseUrl_with_user_info(@TempDir Path tmp) { // user:pass@host has Basic-auth semantics the SDK does not support — and would leak // credentials into log lines that include the URL. assertThatIllegalArgumentException() - .isThrownBy( - () -> - Configuration.resolve( - null, "https://user:pass@api.example.com", null, NO_ENV, noDotEnv(tmp))) + .isThrownBy(() -> + Configuration.resolve( + null, "https://user:pass@api.example.com", null, NO_ENV, noDotEnv(tmp))) .withMessageContaining("user-info"); } @@ -499,10 +496,9 @@ void resolve_does_not_validate_null_apiKey(@TempDir Path tmp) { @Test void apiKey_validation_error_does_not_leak_token(@TempDir Path tmp) { assertThatIllegalArgumentException() - .isThrownBy( - () -> - Configuration.resolve( - "supersecret-prefix\rsuffix-leak", null, null, NO_ENV, noDotEnv(tmp))) + .isThrownBy(() -> + Configuration.resolve( + "supersecret-prefix\rsuffix-leak", null, null, NO_ENV, noDotEnv(tmp))) .withMessageNotContaining("supersecret-prefix") .withMessageNotContaining("suffix-leak"); } diff --git a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java index 6d8031a..5fc70bc 100644 --- a/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java +++ b/src/test/java/com/marketdata/sdk/DotEnvLoaderTest.java @@ -62,12 +62,11 @@ void load_unreadable_file_emits_warning_and_returns_empty(@TempDir Path tmp) thr assertThat(result).isEmpty(); assertThat(warnings) .singleElement() - .satisfies( - w -> { - assertThat(w.level()).isEqualTo(Level.WARNING); - assertThat(w.message()).contains("not readable").contains(file.toString()); - assertThat(w.cause()).isNull(); - }); + .satisfies(w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("not readable").contains(file.toString()); + assertThat(w.cause()).isNull(); + }); } finally { // Restore so @TempDir cleanup can delete the file. Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------")); @@ -87,12 +86,11 @@ void load_io_exception_during_read_emits_warning_with_cause(@TempDir Path tmp) t assertThat(result).isEmpty(); assertThat(warnings) .singleElement() - .satisfies( - w -> { - assertThat(w.level()).isEqualTo(Level.WARNING); - assertThat(w.message()).contains("Failed to read .env").contains(asDir.toString()); - assertThat(w.cause()).isNotNull(); - }); + .satisfies(w -> { + assertThat(w.level()).isEqualTo(Level.WARNING); + assertThat(w.message()).contains("Failed to read .env").contains(asDir.toString()); + assertThat(w.cause()).isNotNull(); + }); } @Test @@ -108,9 +106,9 @@ void load_parses_simple_key_value_pairs(@TempDir Path tmp) throws IOException { Files.writeString( tmp.resolve(".env"), """ - MARKETDATA_TOKEN=abc123 - MARKETDATA_BASE_URL=https://example.com - """); + MARKETDATA_TOKEN=abc123 + MARKETDATA_BASE_URL=https://example.com + """); Map result = load(file); @@ -146,10 +144,10 @@ void load_ignores_comment_lines(@TempDir Path tmp) throws IOException { Files.writeString( tmp.resolve(".env"), """ - # a comment - TOKEN=abc - #TOKEN=should-be-ignored - """); + # a comment + TOKEN=abc + #TOKEN=should-be-ignored + """); Map result = load(file); @@ -164,10 +162,10 @@ void load_ignores_comment_lines_with_leading_whitespace(@TempDir Path tmp) throw Files.writeString( tmp.resolve(".env"), """ - # leading-spaces comment - \t# leading-tab comment - TOKEN=abc - """); + # leading-spaces comment + \t# leading-tab comment + TOKEN=abc + """); assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } @@ -179,11 +177,11 @@ void load_ignores_blank_lines(@TempDir Path tmp) throws IOException { tmp.resolve(".env"), """ - TOKEN=abc + TOKEN=abc - BASE_URL=https://x + BASE_URL=https://x - """); + """); assertThat(load(file)).containsEntry("TOKEN", "abc").containsEntry("BASE_URL", "https://x"); } @@ -216,9 +214,9 @@ void load_keeps_hash_when_not_preceded_by_whitespace(@TempDir Path tmp) throws I Files.writeString( tmp.resolve(".env"), """ - TOKEN=abc#123 - BASE_URL=https://example.com/path#frag - """); + TOKEN=abc#123 + BASE_URL=https://example.com/path#frag + """); assertThat(load(file)) .containsEntry("TOKEN", "abc#123") @@ -259,10 +257,10 @@ void load_records_empty_value_when_value_is_blank(@TempDir Path tmp) throws IOEx Files.writeString( tmp.resolve(".env"), """ - EMPTY_BARE= - EMPTY_SPACES=\s\s\s - KEPT=value - """); + EMPTY_BARE= + EMPTY_SPACES=\s\s\s + KEPT=value + """); Map result = load(file); assertThat(result) @@ -292,10 +290,10 @@ void load_strips_value_when_hash_is_first_non_whitespace_char(@TempDir Path tmp) Files.writeString( tmp.resolve(".env"), """ - EMPTY1=#comment-immediately - EMPTY2= # comment after spaces - KEPT=value - """); + EMPTY1=#comment-immediately + EMPTY2= # comment after spaces + KEPT=value + """); Map result = load(file); assertThat(result) @@ -343,10 +341,10 @@ void load_last_assignment_wins_for_duplicate_keys(@TempDir Path tmp) throws IOEx Files.writeString( tmp.resolve(".env"), """ - TOKEN=first - TOKEN=second - TOKEN=third - """); + TOKEN=first + TOKEN=second + TOKEN=third + """); assertThat(load(file)).containsEntry("TOKEN", "third"); } @@ -364,10 +362,10 @@ void load_skips_lines_without_equals(@TempDir Path tmp) throws IOException { Files.writeString( tmp.resolve(".env"), """ - this-is-not-a-pair - TOKEN=abc - also-not-a-pair - """); + this-is-not-a-pair + TOKEN=abc + also-not-a-pair + """); assertThat(load(file)).containsExactlyEntriesOf(Map.of("TOKEN", "abc")); } @@ -406,11 +404,11 @@ void load_with_allowlist_drops_keys_outside_the_set(@TempDir Path tmp) throws IO Files.writeString( tmp.resolve(".env"), """ - MARKETDATA_TOKEN=abc123 - AWS_SECRET_ACCESS_KEY=sk-aws-supersecret - GITHUB_TOKEN=ghp-leaked - MARKETDATA_BASE_URL=https://example.com - """); + MARKETDATA_TOKEN=abc123 + AWS_SECRET_ACCESS_KEY=sk-aws-supersecret + GITHUB_TOKEN=ghp-leaked + MARKETDATA_BASE_URL=https://example.com + """); Map result = DotEnvLoader.load(file, w -> {}, Set.of("MARKETDATA_TOKEN", "MARKETDATA_BASE_URL")); diff --git a/src/test/java/com/marketdata/sdk/FundsResourceTest.java b/src/test/java/com/marketdata/sdk/FundsResourceTest.java index bfea3db..a78ccd3 100644 --- a/src/test/java/com/marketdata/sdk/FundsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/FundsResourceTest.java @@ -226,11 +226,10 @@ void columnsRequestedButOmittedByApiThrowsParseError() { CapturingClient client = okWith("{\"s\":\"ok\",\"t\":[1705276800],\"c\":[452.84]}"); FundsResource funds = resourceWith(client); - assertThatThrownBy( - () -> - funds - .columns("t", "c", "o") - .candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX"))) + assertThatThrownBy(() -> + funds + .columns("t", "c", "o") + .candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX"))) .isInstanceOf(ParseError.class) .hasMessageContaining("o"); } @@ -355,36 +354,33 @@ void responseRateLimitIsNullWhenHeadersAbsent() { @Test void candlesRequestRejectsDateWithRange() { - assertThatThrownBy( - () -> - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .date(LocalDate.of(2025, Month.JANUARY, 1)) - .from(LocalDate.of(2024, Month.DECEMBER, 1)) - .build()) + assertThatThrownBy(() -> + FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } @Test void candlesRequestRejectsCountbackWithFrom() { - assertThatThrownBy( - () -> - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .from(LocalDate.of(2024, Month.JANUARY, 1)) - .countback(3) - .build()) + assertThatThrownBy(() -> + FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .countback(3) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("countback and from"); } @Test void candlesRequestRejectsFromAfterTo() { - assertThatThrownBy( - () -> - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .from(LocalDate.of(2025, Month.JANUARY, 31)) - .to(LocalDate.of(2025, Month.JANUARY, 1)) - .build()) + assertThatThrownBy(() -> + FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .from(LocalDate.of(2025, Month.JANUARY, 31)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("from must not be after to"); } diff --git a/src/test/java/com/marketdata/sdk/HttpTransportTest.java b/src/test/java/com/marketdata/sdk/HttpTransportTest.java index 284f2a5..bfb0fd7 100644 --- a/src/test/java/com/marketdata/sdk/HttpTransportTest.java +++ b/src/test/java/com/marketdata/sdk/HttpTransportTest.java @@ -715,11 +715,10 @@ void serverErrorCarriesParsedRetryAfterDuration() { () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(ServerError.class) - .satisfies( - t -> { - ServerError se = (ServerError) t.getCause(); - assertThat(se.getRetryAfter()).contains(Duration.ofSeconds(7)); - }); + .satisfies(t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).contains(Duration.ofSeconds(7)); + }); } @Test @@ -732,11 +731,10 @@ void serverErrorRetryAfterIsEmptyWhenHeaderAbsent() { () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(ServerError.class) - .satisfies( - t -> { - ServerError se = (ServerError) t.getCause(); - assertThat(se.getRetryAfter()).isEmpty(); - }); + .satisfies(t -> { + ServerError se = (ServerError) t.getCause(); + assertThat(se.getRetryAfter()).isEmpty(); + }); } /** @@ -754,11 +752,10 @@ void rateLimitErrorCarriesParsedRetryAfterDuration() { () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(RateLimitError.class) - .satisfies( - t -> { - RateLimitError rle = (RateLimitError) t.getCause(); - assertThat(rle.getRetryAfter()).contains(Duration.ofSeconds(30)); - }); + .satisfies(t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).contains(Duration.ofSeconds(30)); + }); } @Test @@ -771,11 +768,10 @@ void rateLimitErrorRetryAfterIsEmptyWhenHeaderAbsent() { () -> transport.executeAsync(RequestSpec.get("markets/status").build()).join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(RateLimitError.class) - .satisfies( - t -> { - RateLimitError rle = (RateLimitError) t.getCause(); - assertThat(rle.getRetryAfter()).isEmpty(); - }); + .satisfies(t -> { + RateLimitError rle = (RateLimitError) t.getCause(); + assertThat(rle.getRetryAfter()).isEmpty(); + }); } // ---------- §9.5 status-cache gate ---------- diff --git a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java index 5e9aa20..3ecc27d 100644 --- a/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java +++ b/src/test/java/com/marketdata/sdk/JsonResponseParserTest.java @@ -120,14 +120,13 @@ void userNumericFieldOfWrongTypeRaisesParseError() { // String "500" instead of integer 500 — strict rejection rather than Jackson's lax coercion. JsonResponseParser parser = parserWithUtilitiesModule(); - assertThatThrownBy( - () -> - parser.parse( - env( - "{\"x-ratelimit-requests-remaining\":\"5\"," - + "\"x-ratelimit-requests-limit\":10," - + "\"x-options-data-permissions\":\"\"}"), - User.class)) + assertThatThrownBy(() -> + parser.parse( + env( + "{\"x-ratelimit-requests-remaining\":\"5\"," + + "\"x-ratelimit-requests-limit\":10," + + "\"x-options-data-permissions\":\"\"}"), + User.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("non-integer") .hasMessageContaining("x-ratelimit-requests-remaining"); @@ -139,13 +138,10 @@ void userMissingOptionsPermsRaisesParseError() { // present as a JSON string. Absence is treated as a backend regression, not a default. JsonResponseParser parser = parserWithUtilitiesModule(); - assertThatThrownBy( - () -> - parser.parse( - env( - "{\"x-ratelimit-requests-remaining\":1," - + "\"x-ratelimit-requests-limit\":2}"), - User.class)) + assertThatThrownBy(() -> + parser.parse( + env("{\"x-ratelimit-requests-remaining\":1," + "\"x-ratelimit-requests-limit\":2}"), + User.class)) .isInstanceOf(ParseError.class) .hasMessageContaining("missing or non-string") .hasMessageContaining("x-options-data-permissions"); @@ -343,14 +339,13 @@ void malformedJsonRaisesParseErrorCarryingResponseContext() { assertThatThrownBy(() -> parser.parse(env("{not-json"), RequestHeaders.class)) .isInstanceOf(ParseError.class) - .satisfies( - t -> { - ParseError err = (ParseError) t; - assertThat(err.getRequestUrl()).isEqualTo("http://localhost/headers/"); - assertThat(err.getStatusCode()).isEqualTo(200); - assertThat(err.getRequestId()).isEqualTo("test-request-id"); - assertThat(err.getCause()).isNotNull(); - }); + .satisfies(t -> { + ParseError err = (ParseError) t; + assertThat(err.getRequestUrl()).isEqualTo("http://localhost/headers/"); + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + assertThat(err.getCause()).isNotNull(); + }); } /** @@ -374,16 +369,15 @@ void emptyBodyRaisesParseErrorWithExplicitMessage() { .isInstanceOf(ParseError.class) .hasMessageContaining("Empty response body") .hasMessageContaining("0 bytes") - .satisfies( - t -> { - ParseError err = (ParseError) t; - // Context is still populated — consumers can correlate via requestId, see the - // status code, etc. - assertThat(err.getStatusCode()).isEqualTo(200); - assertThat(err.getRequestId()).isEqualTo("test-request-id"); - // No Jackson cause when we short-circuit at the empty-body check. - assertThat(err.getCause()).isNull(); - }); + .satisfies(t -> { + ParseError err = (ParseError) t; + // Context is still populated — consumers can correlate via requestId, see the + // status code, etc. + assertThat(err.getStatusCode()).isEqualTo(200); + assertThat(err.getRequestId()).isEqualTo("test-request-id"); + // No Jackson cause when we short-circuit at the empty-body check. + assertThat(err.getCause()).isNull(); + }); } /** @@ -405,17 +399,16 @@ void parseErrorMessageRedactsQueryString() { assertThatThrownBy(() -> parser.parse(envWithQuery, RequestHeaders.class)) .isInstanceOf(ParseError.class) - .satisfies( - t -> { - ParseError err = (ParseError) t; - // Message redacts the query. - assertThat(err.getMessage()).doesNotContain("secret-xyz").doesNotContain("AAPL"); - assertThat(err.getMessage()).contains("?…"); - // getRequestUrl() also redacts (pre-existing policy on MarketDataException). - assertThat(err.getRequestUrl()).doesNotContain("secret-xyz"); - // The raw context retains the full URI for diagnostic use that won't be persisted. - assertThat(err.getContext().requestUrl()).contains("token=secret-xyz"); - }); + .satisfies(t -> { + ParseError err = (ParseError) t; + // Message redacts the query. + assertThat(err.getMessage()).doesNotContain("secret-xyz").doesNotContain("AAPL"); + assertThat(err.getMessage()).contains("?…"); + // getRequestUrl() also redacts (pre-existing policy on MarketDataException). + assertThat(err.getRequestUrl()).doesNotContain("secret-xyz"); + // The raw context retains the full URI for diagnostic use that won't be persisted. + assertThat(err.getContext().requestUrl()).contains("token=secret-xyz"); + }); } // ---------- §9 / ADR-007: parser is resource-agnostic ---------- diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java index b0c59e6..29af75e 100644 --- a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java @@ -325,24 +325,22 @@ void responseRateLimitIsNullWhenHeadersAbsent() { @Test void statusRequestRejectsDateWithRange() { - assertThatThrownBy( - () -> - MarketStatusRequest.builder() - .date(LocalDate.of(2025, Month.JANUARY, 1)) - .from(LocalDate.of(2024, Month.DECEMBER, 1)) - .build()) + assertThatThrownBy(() -> + MarketStatusRequest.builder() + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } @Test void statusRequestRejectsCountbackWithFrom() { - assertThatThrownBy( - () -> - MarketStatusRequest.builder() - .from(LocalDate.of(2024, Month.JANUARY, 1)) - .countback(3) - .build()) + assertThatThrownBy(() -> + MarketStatusRequest.builder() + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .countback(3) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("countback and from"); } @@ -356,12 +354,11 @@ void statusRequestRejectsNonPositiveCountback() { @Test void statusRequestRejectsFromAfterTo() { - assertThatThrownBy( - () -> - MarketStatusRequest.builder() - .from(LocalDate.of(2025, Month.JANUARY, 31)) - .to(LocalDate.of(2025, Month.JANUARY, 1)) - .build()) + assertThatThrownBy(() -> + MarketStatusRequest.builder() + .from(LocalDate.of(2025, Month.JANUARY, 31)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("from must not be after to"); } diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java index 292ee83..2c4a003 100644 --- a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -420,12 +420,11 @@ void quoteAttachesFromToFilters() { @Test void quoteRequestRejectsDateAndFromToTogether() { - assertThatThrownBy( - () -> - OptionsQuoteRequest.builder("X") - .date(LocalDate.of(2025, Month.JANUARY, 1)) - .from(LocalDate.of(2024, Month.DECEMBER, 1)) - .build()) + assertThatThrownBy(() -> + OptionsQuoteRequest.builder("X") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } @@ -451,24 +450,22 @@ void quoteAttachesCountbackWithTo() { @Test void quoteRequestRejectsCountbackWithDate() { - assertThatThrownBy( - () -> - OptionsQuoteRequest.builder("X") - .date(LocalDate.of(2025, Month.JANUARY, 1)) - .countback(5) - .build()) + assertThatThrownBy(() -> + OptionsQuoteRequest.builder("X") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(5) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } @Test void quoteRequestRejectsCountbackWithFrom() { - assertThatThrownBy( - () -> - OptionsQuoteRequest.builder("X") - .from(LocalDate.of(2024, Month.DECEMBER, 1)) - .countback(5) - .build()) + assertThatThrownBy(() -> + OptionsQuoteRequest.builder("X") + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .countback(5) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("countback and from"); } @@ -482,12 +479,11 @@ void quoteRequestRejectsNonPositiveCountback() { @Test void quoteRequestRejectsFromAfterTo() { - assertThatThrownBy( - () -> - OptionsQuoteRequest.builder("X") - .from(LocalDate.of(2025, Month.JANUARY, 31)) - .to(LocalDate.of(2025, Month.JANUARY, 1)) - .build()) + assertThatThrownBy(() -> + OptionsQuoteRequest.builder("X") + .from(LocalDate.of(2025, Month.JANUARY, 31)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("from must not be after to"); } @@ -627,11 +623,10 @@ void columnsRequestedButOmittedByApiThrowsParseError() { okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); OptionsResource options = resourceWith(client); - assertThatThrownBy( - () -> - options - .columns("optionSymbol", "strike", "bid") - .quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) + assertThatThrownBy(() -> + options + .columns("optionSymbol", "strike", "bid") + .quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) .isInstanceOf(ParseError.class) .hasMessageContaining("bid"); } @@ -975,6 +970,76 @@ void chainExpirationFilterDteTranslatesToDteParam() { .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); } + // ---------- chain: dte multi-form filter (dte query param: integer -> string) ---------- + + @Test + void chainExpirationFilterDteListTranslatesToCommaSeparatedDte() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteList(15, 30, 45)) + .build()) + .join(); + + // The comma is URL-encoded on the wire, so decode before asserting (same convention as the + // columns= assertions elsewhere in this file). + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url).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 chainExpirationFilterDteComparisonGteRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, 30)) + .build()) + .join(); + + // "%3E%3D" is the URL-encoded ">=" — matches the strike comparison wire test's convention. + 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(StrikeFilter.Operator.LT, 45)) + .build()) + .join(); + + // "%3C" is the URL-encoded "<". + assertThat(client.captured.get(0).uri().toString()).contains("dte=%3C45"); + } + @Test void chainExpirationFilterBetweenTranslatesToFromTo() { CapturingClient client = okWith(CANNED_CHAIN_BODY); @@ -1205,10 +1270,9 @@ void expirationFilterDteRejectsNegative() { @Test void expirationFilterBetweenRejectsReversedDates() { - assertThatThrownBy( - () -> - ExpirationFilter.between( - LocalDate.of(2025, Month.MARCH, 1), LocalDate.of(2025, Month.JANUARY, 1))) + assertThatThrownBy(() -> + ExpirationFilter.between( + LocalDate.of(2025, Month.MARCH, 1), LocalDate.of(2025, Month.JANUARY, 1))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("from must be on or before to"); } @@ -1227,6 +1291,75 @@ void strikeFilterRangeRejectsMinGreaterThanMax() { .hasMessageContaining("min must be <= max"); } + // ---------- dte multi-form filter: validation (new factories added for the integer->string + // widening of the chain endpoint's dte query parameter) ---------- + + @Test + void expirationFilterDteListRejectsEmpty() { + assertThatThrownBy(() -> ExpirationFilter.dteList()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be empty"); + } + + @Test + void expirationFilterDteListRejectsNegativeValue() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, -1, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-negative"); + } + + @Test + void expirationFilterDteListRejectsValueAboveMax() { + assertThatThrownBy(() -> ExpirationFilter.dteList(30, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void expirationFilterDteListRecordConstructorRejectsNullElement() { + // The dteList(int...) factory can never produce a null element (int is primitive), so this + // guard is only reachable by constructing the record directly — validation on a new type must + // hold on every public path, not only through the factory. + assertThatThrownBy(() -> new ExpirationFilter.DteList(java.util.Arrays.asList(30, null))) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("days element"); + } + + @Test + void expirationFilterDteRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(45, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + @Test + void expirationFilterDteRangeRejectsNegativeMin() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(-1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-negative"); + } + + @Test + void expirationFilterDteRangeRejectsMaxAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteRange(0, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + + @Test + void expirationFilterDteComparisonRejectsNegative() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.GTE, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-negative"); + } + + @Test + void expirationFilterDteComparisonRejectsValueAboveLimit() { + assertThatThrownBy(() -> ExpirationFilter.dteComparison(StrikeFilter.Operator.LT, 36501)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("36500"); + } + // ---------- helpers ---------- private static CapturingClient okWith(String body) { diff --git a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java index 2256c89..5f76572 100644 --- a/src/test/java/com/marketdata/sdk/ParallelArraysTest.java +++ b/src/test/java/com/marketdata/sdk/ParallelArraysTest.java @@ -66,23 +66,20 @@ void emptyArraysProduceEmptyListWithoutInvokingBuilder() throws IOException { void serverSideErrorEnvelopeShortCircuitsBeforeFieldValidation() { // s=error means the body intentionally omits the data arrays. The helper must surface the // errmsg instead of complaining about missing fields downstream. - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - parse("{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"), - List.of("symbol"), - row -> null)) + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + parse("{\"s\":\"error\",\"errmsg\":\"database connection refused\"}"), + List.of("symbol"), + row -> null)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("database connection refused"); } @Test void errorEnvelopeWithoutErrmsgYieldsPlaceholderText() { - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, parse("{\"s\":\"error\"}"), List.of("symbol"), row -> null)) + assertThatThrownBy(() -> + ParallelArrays.zip(null, parse("{\"s\":\"error\"}"), List.of("symbol"), row -> null)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("no errmsg field"); } @@ -130,13 +127,12 @@ void noDataEnvelopeIgnoresAdjacentMetadataFields() throws IOException { @Test void missingFieldFailsWithFieldName() { - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\"]}"), - List.of("symbol", "price"), - row -> null)) + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":[\"AAPL\"]}"), + List.of("symbol", "price"), + row -> null)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("missing or non-array") .hasMessageContaining("price"); @@ -144,13 +140,12 @@ void missingFieldFailsWithFieldName() { @Test void nonArrayFieldFailsWithFieldName() { - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - parse("{\"s\":\"ok\",\"symbol\":\"AAPL\",\"price\":[150.0]}"), - List.of("symbol", "price"), - row -> null)) + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"symbol\":\"AAPL\",\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("missing or non-array") .hasMessageContaining("symbol"); @@ -158,16 +153,12 @@ void nonArrayFieldFailsWithFieldName() { @Test void mismatchedLengthsFailWithDetail() { - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - parse( - "{\"s\":\"ok\"," - + "\"symbol\":[\"AAPL\",\"MSFT\"]," - + "\"price\":[150.0]}"), - List.of("symbol", "price"), - row -> null)) + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\"," + "\"symbol\":[\"AAPL\",\"MSFT\"]," + "\"price\":[150.0]}"), + List.of("symbol", "price"), + row -> null)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("mismatched lengths") .hasMessageContaining("price=1") @@ -197,13 +188,12 @@ void rowAccessorRejectsUndeclaredField() throws IOException { // builder lambda — surface it loudly rather than NPEing on a null array. JsonNode root = parse("{\"s\":\"ok\",\"a\":[\"x\"]}"); - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - root, - List.of("a"), - row -> row.text("nonexistent"))) // builder asks for an undeclared column + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + root, + List.of("a"), + row -> row.text("nonexistent"))) // builder asks for an undeclared column .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("nonexistent") .hasMessageContaining("[a]"); @@ -387,14 +377,13 @@ void absentOptionalColumnYieldsNullViaOrNullAccessor() throws IOException { @Test void mismatchedOptionalColumnLengthFails() { - assertThatThrownBy( - () -> - ParallelArrays.zip( - null, - parse("{\"s\":\"ok\",\"a\":[\"x\",\"y\"],\"opt\":[1.0]}"), - List.of("a"), - List.of("opt"), - row -> row.dblOrNull("opt"))) + assertThatThrownBy(() -> + ParallelArrays.zip( + null, + parse("{\"s\":\"ok\",\"a\":[\"x\",\"y\"],\"opt\":[1.0]}"), + List.of("a"), + List.of("opt"), + row -> row.dblOrNull("opt"))) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("mismatched lengths") .hasMessageContaining("opt"); @@ -409,18 +398,16 @@ void orNullAccessorsRejectTypeMismatchedCells() throws IOException { () -> ParallelArrays.zip(null, root, List.of("a"), List.of("d"), r -> r.dblOrNull("d"))) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("number"); - assertThatThrownBy( - () -> - ParallelArrays.zip(null, root, List.of("a"), List.of("t"), r -> r.textOrNull("t"))) + assertThatThrownBy(() -> + ParallelArrays.zip(null, root, List.of("a"), List.of("t"), r -> r.textOrNull("t"))) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("string"); assertThatThrownBy( () -> ParallelArrays.zip(null, root, List.of("a"), List.of("l"), r -> r.lngOrNull("l"))) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("number"); - assertThatThrownBy( - () -> - ParallelArrays.zip(null, root, List.of("a"), List.of("b"), r -> r.boolOrNull("b"))) + assertThatThrownBy(() -> + ParallelArrays.zip(null, root, List.of("a"), List.of("b"), r -> r.boolOrNull("b"))) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("boolean"); } diff --git a/src/test/java/com/marketdata/sdk/ParserAndUtilCoverageTest.java b/src/test/java/com/marketdata/sdk/ParserAndUtilCoverageTest.java index 66e6935..aeb0138 100644 --- a/src/test/java/com/marketdata/sdk/ParserAndUtilCoverageTest.java +++ b/src/test/java/com/marketdata/sdk/ParserAndUtilCoverageTest.java @@ -31,9 +31,8 @@ private static ObjectMapper mapperFor( void stockNewsDeserializerSurfacesErrorEnvelope() { ObjectMapper m = mapperFor(StockNews.class, new StockNewsDeserializer()); - assertThatThrownBy( - () -> - m.readValue("{\"s\":\"error\",\"errmsg\":\"news service down\"}", StockNews.class)) + assertThatThrownBy(() -> + m.readValue("{\"s\":\"error\",\"errmsg\":\"news service down\"}", StockNews.class)) .isInstanceOf(JsonMappingException.class) .hasMessageContaining("news service down"); } diff --git a/src/test/java/com/marketdata/sdk/RequestValidationTest.java b/src/test/java/com/marketdata/sdk/RequestValidationTest.java index d1d8c05..a4d710e 100644 --- a/src/test/java/com/marketdata/sdk/RequestValidationTest.java +++ b/src/test/java/com/marketdata/sdk/RequestValidationTest.java @@ -70,12 +70,11 @@ void stockNewsDateAndCountbackSettersAreCarried() { @Test void stockWindowRejectsDatePlusRange() { - assertThatThrownBy( - () -> - StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") - .date(TODAY) - .from(TODAY) - .build()) + assertThatThrownBy(() -> + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .date(TODAY) + .from(TODAY) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } diff --git a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java index 2e0bff0..da31538 100644 --- a/src/test/java/com/marketdata/sdk/RetryExecutorTest.java +++ b/src/test/java/com/marketdata/sdk/RetryExecutorTest.java @@ -92,11 +92,10 @@ void exhaustsAttemptsAndSurfacesLastCause() { RetryExecutor exec = new RetryExecutor(FAST_RETRY); CompletableFuture f = - exec.execute( - () -> { - calls.incrementAndGet(); - return CompletableFuture.failedFuture(retriable5xx()); - }); + exec.execute(() -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriable5xx()); + }); assertThatThrownBy(f::join) .isInstanceOf(CompletionException.class) @@ -116,11 +115,10 @@ void noRetryPolicyInvokesSupplierExactlyOnceOnFailure() { RetryExecutor exec = new RetryExecutor(RetryPolicy.noRetry()); CompletableFuture f = - exec.execute( - () -> { - calls.incrementAndGet(); - return CompletableFuture.failedFuture(retriableNet()); - }); + exec.execute(() -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); assertThatThrownBy(f::join) .isInstanceOf(CompletionException.class) @@ -137,11 +135,10 @@ void nonRetriableCauseStopsImmediately() { RetryExecutor exec = new RetryExecutor(FAST_RETRY); CompletableFuture f = - exec.execute( - () -> { - calls.incrementAndGet(); - return CompletableFuture.failedFuture(nonRetriable500()); - }); + exec.execute(() -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(nonRetriable500()); + }); assertThatThrownBy(f::join) .isInstanceOf(CompletionException.class) @@ -158,12 +155,11 @@ void cancelOfResultCancelsInFlightAttempt() { RetryExecutor exec = new RetryExecutor(NO_RETRY); CompletableFuture result = - exec.execute( - () -> { - CompletableFuture f = new CompletableFuture<>(); - handle.set(f); - return f; - }); + exec.execute(() -> { + CompletableFuture f = new CompletableFuture<>(); + handle.set(f); + return f; + }); assertThat(handle.get()).isNotNull(); result.cancel(false); @@ -180,11 +176,10 @@ void cancelDuringBackoffPreventsNextAttempt() throws Exception { RetryExecutor exec = new RetryExecutor(slow); CompletableFuture result = - exec.execute( - () -> { - calls.incrementAndGet(); - return CompletableFuture.failedFuture(retriableNet()); - }); + exec.execute(() -> { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(retriableNet()); + }); // Wait until the first attempt has fired and we're presumably in the backoff window. Thread.sleep(10); diff --git a/src/test/java/com/marketdata/sdk/StocksResourceTest.java b/src/test/java/com/marketdata/sdk/StocksResourceTest.java index ff8f111..90c0125 100644 --- a/src/test/java/com/marketdata/sdk/StocksResourceTest.java +++ b/src/test/java/com/marketdata/sdk/StocksResourceTest.java @@ -807,36 +807,33 @@ void responseRateLimitIsNullWhenHeadersAbsent() { @Test void candlesRequestRejectsDateWithRange() { - assertThatThrownBy( - () -> - StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") - .date(LocalDate.of(2025, Month.JANUARY, 1)) - .from(LocalDate.of(2024, Month.DECEMBER, 1)) - .build()) + assertThatThrownBy(() -> + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mutually exclusive"); } @Test void earningsRequestRejectsCountbackWithFrom() { - assertThatThrownBy( - () -> - StockEarningsRequest.builder("AAPL") - .from(LocalDate.of(2024, Month.JANUARY, 1)) - .countback(3) - .build()) + assertThatThrownBy(() -> + StockEarningsRequest.builder("AAPL") + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .countback(3) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("countback and from"); } @Test void candlesRequestRejectsFromAfterTo() { - assertThatThrownBy( - () -> - StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") - .from(LocalDate.of(2025, Month.JANUARY, 31)) - .to(LocalDate.of(2025, Month.JANUARY, 1)) - .build()) + assertThatThrownBy(() -> + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.of(2025, Month.JANUARY, 31)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("from must not be after to"); }