diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f49db..ef3ea8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 down the call stack. ### Added +- **Options resource** (`client.options()`) — all six endpoints, each in sync + + async form: `lookup`, `expirations`, `strikes`, `quote` (single contract), + `quotes` (multi-contract fan-out returning + `Map>`), and `chain`. Every endpoint takes a + Builder-based per-endpoint request object (no `String` convenience overloads). + The `chain` request models its mutually-exclusive expiration and strike groups + as sealed types (`ExpirationFilter`, `StrikeFilter`) so the exclusivity is + compiler-enforced. Covers the `rho` greek (decoded as an optional, nullable + column — absent on some feeds), the `expiration=all` filter (the full chain + vs. the default front-month), and the `countback` historical-window parameter + (validated: positive, and mutually exclusive with `date`/`from`). - Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain, `integrationTest` source set, Spotless + JaCoCo, Vanniktech Maven Publish. - `MarketDataClient` skeleton with two public constructors — a no-arg one diff --git a/Makefile b/Makefile index 0a2151b..a09f14d 100644 --- a/Makefile +++ b/Makefile @@ -80,13 +80,17 @@ demo-retry: ## Retry, Retry-After, preflight (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runRetry .PHONY: demo-response -demo-response: ## Response surface features (needs mock-server) +demo-response: ## MarketDataResponse surface features (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runResponse .PHONY: demo-concurrency demo-concurrency: ## 50-permit semaphore (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runConcurrency +.PHONY: demo-options +demo-options: ## Full options surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runOptions + .PHONY: demos-all demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency runOptions diff --git a/README.md b/README.md index ee2f4c0..5da8ee6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Market Data Java SDK -Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release -scaffold** — endpoints are not yet implemented; this iteration sets up the -build, package layout, configuration cascade, exception taxonomy, and -Kotlin-interop foundations. +Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release** +— the `utilities` and `options` resources are implemented; `stocks`, `funds`, +and `markets` are forthcoming. The build, package layout, configuration cascade, +exception taxonomy, and Kotlin-interop foundations are in place. ## Requirements @@ -32,7 +32,8 @@ common path is two lines: ```java try (var client = new MarketDataClient()) { - // endpoint methods land in subsequent iterations + var resp = client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + System.out.println(resp.values()); // values() is the typed payload (a List) } ``` @@ -40,10 +41,114 @@ try (var client = new MarketDataClient()) { ```kotlin MarketDataClient().use { client -> - // endpoint methods land in subsequent iterations + val resp = client.options().expirations(OptionsExpirationsRequest.of("AAPL")) + println(resp.values()) // List } ``` +Every response implements `MarketDataResponse`: `values()` returns the typed payload +(typed per endpoint — a `List`, a scalar `String`, …), and the same metadata accessors +(`statusCode()`, `isNoData()`, `requestId()`, `json()`, `saveToFile(path)`) are available on +every response, on every resource. + +## Options + +Reached via `client.options()`. Every endpoint has a synchronous method and an +`…Async` variant returning `CompletableFuture`, and takes a Builder-based +request object — there are no `String` convenience overloads, so the call shape +is uniform across the SDK regardless of how many parameters an endpoint has. + +| Method | Purpose | +|--------|---------| +| `lookup` | Resolve a human description (`"AAPL 1/16/2026 $200 Call"`) to an OCC symbol | +| `expirations` | Expiration dates for an underlying | +| `strikes` | Strike ladder per expiration | +| `quote` | Quote for a single OCC option symbol | +| `quotes` | Quotes for many symbols — fans out concurrently, returns a per-symbol map | +| `chain` | Full option chain with the rich filter surface | + +### Chain with filters + +The `chain` request exposes the API's full filter set. Mutually-exclusive groups +(expiration, strike) are modeled as sealed types, so the compiler lets you pick +only one variant per group: + +#### Java + +```java +try (var client = new MarketDataClient()) { + var resp = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) // every expiration, not just front-month + .strikeFilter(StrikeFilter.range(150, 200)) // 150 <= strike <= 200 + .side(OptionSide.CALL) + .strikeLimit(5) + .build()); + + for (OptionQuote q : resp.values()) { // values() is a List + System.out.printf("%s delta=%s rho=%s%n", + q.optionSymbol(), q.delta(), q.rho()); // delta/rho are @Nullable Double + } +} +``` + +#### Kotlin + +```kotlin +MarketDataClient().use { client -> + val resp = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) + .strikeFilter(StrikeFilter.range(150.0, 200.0)) + .side(OptionSide.CALL) + .strikeLimit(5) + .build() + ) + resp.values().forEach { q -> + println("${q.optionSymbol} delta=${q.delta} rho=${q.rho}") // delta/rho are nullable + } +} +``` + +### Multiple quotes + +`quotes` fans out one request per symbol concurrently and returns a +`Map` keyed by the input symbol, so per-symbol +status and errors stay observable. `countback` caps the historical series to the +N most recent rows before `to`: + +```java +Map bySymbol = client.options().quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000") + .to(LocalDate.now()) + .countback(5) // at most 5 EOD rows per symbol, before `to` + .build()); + +bySymbol.forEach((sym, resp) -> System.out.println(sym + " → " + resp.values().size() + " rows")); +``` + +### Universal parameters & CSV + +Universal parameters are set fluently on the resource (an immutable configured value, so you +can "configure once, call many"); `columns` projects the response to a subset of fields, and +`asCsv()` selects a CSV view of any endpoint: + +```java +// type-preserving universal params + column projection (typed): +var chain = client.options() + .dateFormat(DateFormat.TIMESTAMP).mode(Mode.DELAYED).limit(50) + .columns("optionSymbol", "strike", "delta") // fields you don't request come back null + .chain(OptionsChainRequest.of("AAPL")); + +// CSV facet (adds human/headers, which only make sense for CSV): +CsvResponse csv = client.options().asCsv().columns("optionSymbol", "strike").chain(req); +csv.saveToFile(Path.of("aapl-chain.csv")); +``` + +With `columns`, a field you didn't request decodes to `null` (no error); a **required** field +you *did* request (or didn't project away) that the API omits raises a `ParseError` — so a +`null` never silently hides a dropped field. + ## Configuration Values are resolved through this cascade (highest priority first): @@ -70,9 +175,11 @@ Values are resolved through this cascade (highest priority first): | `MARKETDATA_USE_HUMAN_READABLE` | Human-readable field names | `false` | | `MARKETDATA_MODE` | Data mode (live/cached/delayed) | `live` | -Endpoint-shape variables (`OUTPUT_FORMAT`, `DATE_FORMAT`, `COLUMNS`, -`ADD_HEADERS`, `USE_HUMAN_READABLE`, `MODE`) are reserved here and will be -honored when the request layer lands. +The corresponding per-call setters — `dateFormat`/`limit`/`offset`/`mode`/`columns` on the +resource, plus `human`/`headers` and `asCsv()` on the CSV facet — are exposed on `options` +today (and on every resource as it lands). Auto-applying these env-var values as request +*defaults* (`DATE_FORMAT`, `COLUMNS`, `ADD_HEADERS`, `USE_HUMAN_READABLE`, `MODE`, +`OUTPUT_FORMAT`) is still reserved. ### Demo mode @@ -136,9 +243,15 @@ isn't an exact match is rejected before any request is made. ## Package layout ``` -com.marketdata.sdk # MarketDataClient + RateLimits (public); - # Configuration, EnvVars, Tokens, Version - # are package-private and not part of the API +com.marketdata.sdk # MarketDataClient, RateLimits, the resource façades + # (UtilitiesResource, OptionsResource, OptionsCsvResource), + # and MarketDataResponse + the named response types + # (OptionsChainResponse, CsvResponse, …) — public; + # Configuration, EnvVars, Tokens, Version are + # package-private and not part of the API +com.marketdata.sdk.options # Options request builders + row records + # (OptionsChainRequest, OptionQuote, sealed + # ExpirationFilter / StrikeFilter, Greek, …) com.marketdata.sdk.exception # Sealed MarketDataException hierarchy + ErrorContext ``` diff --git a/docs/OPTIONS_REVIEW_GUIDE.md b/docs/OPTIONS_REVIEW_GUIDE.md new file mode 100644 index 0000000..6fe7aaa --- /dev/null +++ b/docs/OPTIONS_REVIEW_GUIDE.md @@ -0,0 +1,267 @@ +# Options Review Guide — `10_options_resource` + +This guide walks a reviewer through the `options` resource added on the `10_options_resource` branch, **and** the response-model + parameter conventions it establishes SDK-wide. Like the [Refactor Review Guide](REFACTOR_REVIEW_GUIDE.md), it is organized by **flow**, not by file. + +This PR builds on the transport/retry/rate-limit foundation that guide describes (reused unchanged), but it **does** change the response layer: the old generic `Response` is replaced by `MarketDataResponse` + named per-endpoint types, and `ParallelArrays` gains lenient accessors. `utilities` is migrated onto the new model too. + +Suggested reading order: §1 (what's here) → §2 (the response model) → §5 (the deserializer: nullable + columns + Option A — the correctness-critical part) → §3 (request convention) / §4 (chain filters) → §6 (universal params + facets) → §9 (subtle corners). ~40 minutes for the load-bearing shape. + +`file:line` citations target `HEAD` on this branch; line numbers drift — if one looks off, search for the symbol it names. + +## Table of contents + +- [Running it locally](#running-it-locally) +1. [What this PR adds](#1-what-this-pr-adds) +2. [The response model](#2-the-response-model) +3. [The Request-class convention](#3-the-request-class-convention) +4. [Chain request → query translation](#4-chain-request--query-translation) +5. [The option-row deserializer: nullable fields + columns + Option A](#5-the-option-row-deserializer-nullable-fields--columns--option-a) +6. [Universal parameters + the CSV/HTML facets](#6-universal-parameters--the-csvhtml-facets) +7. [The `quotes` multi-symbol fan-out](#7-the-quotes-multi-symbol-fan-out) +8. [lookup / expirations / strikes](#8-lookup--expirations--strikes) +9. [Subtle corners (finding-driven)](#9-subtle-corners-finding-driven) +10. [Out of scope for this review](#10-out-of-scope-for-this-review) +- [Reviewer checklist](#reviewer-checklist) + +--- + +## Running it locally + +```bash +make build # unit tests + Spotless + JaCoCo (JDK 17) + +# Integration tests hit the live API (gated). A token in .env or the env is required: +MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest + +# Full options demo against the mock server (every endpoint + all params, CSV facet, +# columns projection, Option A). Needs the mock server up: +make publish && make mock-server # (in one terminal) +make demo-options # (in another) — or: ./gradlew -p examples/consumer-test runOptions +``` + +`OptionsIntegrationTest` (10 tests, shape assertions) was run green against `api.marketdata.app`. `OptionsApp` (`examples/consumer-test`) is the "what consumer code looks like" surface — it scripts the mock server's responses to demonstrate the columns/Option-A scenarios. + +--- + +## 1. What this PR adds + +### 1.1 Public API surface (new) + +``` +com.marketdata.sdk.MarketDataResponse (interface every response implements; T values() + metadata) +com.marketdata.sdk.OptionsResource (returned from client.options()) +com.marketdata.sdk.OptionsCsvResource (returned from .asCsv()) +com.marketdata.sdk.CsvResponse / HtmlResponse (raw-text responses) +com.marketdata.sdk.Options{Chain,Quotes,Strikes,Expirations,Lookup}Response (named per-endpoint responses) + +com.marketdata.sdk.options.Options{Lookup,Expirations,Strikes,Quote,Quotes,Chain}Request +com.marketdata.sdk.options.{OptionQuote, ExpirationStrikes} (row records — every field @Nullable) +com.marketdata.sdk.options.ExpirationFilter (sealed) / StrikeFilter (sealed) +com.marketdata.sdk.options.OptionSide / StrikeRange / Greek (enums) +``` + +`OptionsResource` (and `OptionsCsvResource`) are `public final` with **package-private constructors** (ADR-007) — reached through `client.options()` / `.asCsv()`, never instantiated directly. The response wrapper types live in the **root** package (like the old `Response` did, so resource façades can construct them via package-private constructors); the request/row records live in the public `com.marketdata.sdk.options` subpackage. + +### 1.2 Files to review, by role + +| Area | Files | What to check | +|---|---|---| +| Response model | `MarketDataResponse.java`, `AbstractMarketDataResponse.java`, `Options*Response.java`, `CsvResponse`/`HtmlResponse` | `values()` payload typing, metadata, the package-private-constructor wiring | +| Resource façade | `OptionsResource.java` | universal-param config, URL/param translation, deserializer + Option A, fan-out, `asCsv()`/`asHtml()` | +| CSV/HTML facets | `OptionsCsvResource.java`, `OptionsHtmlResource.java` | reuse of the static `*Spec` builders, `format=csv/html`, fan-out mirroring | +| Requests | `options/Options*Request.java`, `ExpirationFilter`, `StrikeFilter`, `OptionSide`, `StrikeRange` | Builder validation, sealed-type modeling | +| Row records | `options/OptionQuote.java`, `ExpirationStrikes` | **all fields `@Nullable`**, greeks helpers (`presentGreeks`/`greek`) | +| Reused infra (changed) | `ParallelArrays.java`, `JsonResponseParser.java`, `RequestConfig.java` | OrNull accessors; the `requestedColumns` attribute threading | +| Wiring | `MarketDataClient.java` | `client.options()`; the `StatusCache` supplier adapted to `r.values()` | + +### 1.3 What changed in shared layers (was "unchanged") + +- **`Response` is deleted.** Both `options` and `utilities` use named `MarketDataResponse` types now. Confirm nothing still imports `Response`. +- **`ParallelArrays`** gained lenient `textOrNull`/`lngOrNull`/`boolOrNull`/`nodeOrNull` (alongside the pre-existing `dblOrNull`). The strict `text`/`dbl`/`lng`/`bool`/`node` accessors are unchanged — `utilities`' `ApiStatus` deserializer still uses them, so confirm no `utilities` test regressed. +- **`JsonResponseParser`** gained a `parse(env, type, requestedColumns)` overload that sets a Jackson context attribute (`REQUESTED_COLUMNS_ATTR`). The no-columns `parse(env, type)` delegates with an empty list. +- Transport, retry, rate-limit, status-cache, and exception layers are untouched. + +--- + +## 2. The response model + +Every endpoint returns a **named** type implementing `MarketDataResponse`: + +```java +interface MarketDataResponse { + T values(); // the flat payload, typed per endpoint + int statusCode(); boolean isNoData(); String requestId(); java.net.URI requestUrl(); + String json(); boolean isJson(); boolean isCsv(); boolean isHtml(); + void saveToFile(Path path); +} +``` + +- `values()` is the **one** uniform data accessor. `OptionsChainResponse implements MarketDataResponse>` → `values()` is the rows; `OptionsLookupResponse implements MarketDataResponse` → `values()` is the scalar OCC symbol. `OptionsStrikesResponse`/`OptionsExpirationsResponse` additionally expose `updated()`. +- **One hop, no double-unwrap:** `T` is the flat payload (a `List`, a `String`), not a sub-container. +- `AbstractMarketDataResponse` (package-private, root) holds the payload + metadata and implements every accessor; the named types are thin subclasses with a package-private constructor. + +What to check: +- `values()` return types match the wire shape per endpoint (rows / list / scalar). +- The metadata surface matches §11.5 detectors + the SDK's §13.5 additions (`requestId`, `requestUrl`, `json`). `rawBody()` (byte[]) was dropped — `json()` (the body as text) replaces it. +- `MarketDataClient`'s `StatusCache` supplier was adapted: `statusAsync().thenApply(r -> new ApiStatus(r.values()))`. + +--- + +## 3. The Request-class convention + +Unchanged from the original design: **one Builder-based request class per endpoint, no `String` overloads.** + +```java +OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call"); // no-optionals: of(...) +OptionsExpirationsRequest.builder("AAPL").strike(150.0).build(); // optionals: builder(...)…build() +``` + +- Required args are factory params (can't be omitted); optionals are fluent setters; cross-field validation in `build()` (e.g. the shared `validateWindow(...)` rejects `date` + `from`/`to`, and `countback` with `date`/`from`). +- Getters are plain reads; the resource translates them into a `RequestSpec`. + +### 3.1 Sealed types for mutually-exclusive `chain` groups + +`ExpirationFilter` (`OnDate`/`Dte`/`Between`/`MonthYear`/`All`) and `StrikeFilter` (`Exact`/`Range`/`Comparison` + `Operator`) are sealed, so "pick one variant" is compiler-enforced. Factory methods validate at creation. Pair constraints the type system can't express (`minBid ≤ maxBid`) stay as runtime checks in `OptionsChainRequest.build()`. + +> Reviewer note: this is the pattern future resources copy. This is the PR to push back on it. + +--- + +## 4. Chain request → query translation + +`chain` is the densest mapping: ~25 optional params → query string, via `applyChainParams` (`OptionsResource.java`), called from `chainSpec` / `chainAsync`. + +- Flat `if (r.foo() != null) b.query("foo", …)` for independent params, plus two sealed-type delegations: + - `applyExpirationFilter` → `OnDate`→`?expiration=YYYY-MM-DD`, `Dte`→`?dte=N`, `Between`→`?from=…&to=…`, `MonthYear`→`?month=M&year=YYYY`, `All`→`?expiration=all`. **Check the trailing `else { throw IllegalStateException }`** — the `instanceof` chain isn't compiler-exhaustive; the guard fails loudly if a future variant is added without a branch (unreachable today, won't show in coverage — by design). + - `strikeFilterWireValue` → `?strike=` value: `150` / `140-160` / `>150` (no trailing zeros via `formatStrike`). + +What to verify: +- Every `OptionsChainRequest` getter has a line in `applyChainParams` (a param read nowhere silently does nothing). +- `minBid`/`maxBid`/`minAsk`/`maxAsk` are real backend params (verified against the handler). + +The `chainExpirationFilter*` / `chainStrikeFilter*` / per-param URL unit tests assert the exact query string per branch. + +--- + +## 5. The option-row deserializer: nullable fields + columns + Option A + +**This is the correctness-critical section.** `quotes` and `chain` emit the same per-contract parallel-arrays row, so they share `optionRowsDeserializer(wrapper)` (`OptionsResource.java`), registered for both `OptionsQuotes` and `OptionsChain` in `wireFormatModule()`. + +### 5.1 Every field is nullable + +`OptionQuote` is now **all `@Nullable` boxed types** — including the structural ones (`strike`, `bid`, `side`, …), not just the greeks. This is what lets `columns` project the response to a subset: a field the consumer didn't request comes back `null`. + +### 5.2 Lenient decode + +`buildOptionRow` reads **every** column through an `OrNull` accessor (`textOrNull`/`dblOrNull`/`lngOrNull`/`boolOrNull`, dates via `nodeOrNull`). The deserializer calls `ParallelArrays.zip(p, root, List.of(), OPTION_ALL_FIELDS, …)` — note the **empty required-fields list**: every column is optional at the wire level, so `zip` never throws on an absent column. + +`ParallelArrays.Row.cellOrNull(field)` is the primitive: `null` when the column isn't in the response map **or** the cell is null/missing; the `OrNull` accessors still throw `typeMismatch` on a present-but-wrong-type cell (leniency covers **absence, not corruption**). + +### 5.3 Option A restores strictness — no silent failures + +If every column is optional, how is "the backend dropped a field you asked for" detected? `validateRequestedColumns(p, root, rows, ctxt)`, run after decode: + +- Reads the requested columns from the Jackson context attribute (`JsonResponseParser.REQUESTED_COLUMNS_ATTR`), which the resource sets to `config.columns()` via `parse(env, type, requestedColumns)`. +- For each field in `OPTION_REQUIRED_FIELDS` (the 20 structural columns — **not** `iv`/greeks): if it was requested (explicitly in `columns`, or implicitly because no `columns` filter was applied) and `!root.has(field)` → throw `JsonMappingException` → `ParseError`. + +The resulting contract (the one to verify): + +| Field | Requested? | In response? | Result | +|---|---|---|---| +| structural (`strike`, …) | yes (or no `columns`) | yes | value | +| structural | yes (or no `columns`) | **no** | 💥 `ParseError` (Option A) | +| structural | no (projected away via `columns`) | no | ✅ `null` | +| `iv` / greeks | — | no | ✅ `null` (legitimately optional) | + +So a `null` a consumer sees means *only* "I projected it away" or "legitimately-optional model value" — never "a required field was silently dropped." **Strict-by-default is preserved** (no `columns` ⇒ all required columns implicitly requested ⇒ a missing one still `ParseError`s). + +### 5.4 Greeks helpers + +`OptionQuote.presentGreeks()` (`Set`) and `greek(Greek)` (`@Nullable Double`) give a typed way to inspect which model values are present. `Greek` = `DELTA`/`GAMMA`/`THETA`/`VEGA`/`RHO`; **IV is not a greek** (read via `iv()`). + +### 5.5 Tests that document this + +`OptionsResourceTest`: `columnsProjectionDecodesRequestedAndNullsTheRest`, `columnsRequestedButOmittedByApiThrowsParseError`, `noColumnsFilterStillRequiresAllStructuralColumns`, `presentGreeksReportsNonNullGreeks`, plus the original `quoteDecodesNullModelValuesAsNull` (greeks null → null, not `ParseError`). + +--- + +## 6. Universal parameters + the CSV/HTML facets + +### 6.1 Universal params on the resource + +`OptionsResource` is an **immutable configured value**: `dateFormat()/mode()/limit()/offset()/columns()` each return a configured copy carrying a `RequestConfig`, applied to every subsequent call (`config.applyTo(builder)`). So "configure once, call many" works, and the config carries into `.asCsv()`. The `universalParamsReachTheWire` test asserts they land on the query string. + +The split matters: type-preserving params (`dateFormat`/`mode`/`limit`/`offset`) live on the typed resource; **`human`/`headers` live only on the CSV facet** (they reshape the output, so they don't cohere with the typed decode). `columns` is on both. + +### 6.2 The facets + +- `asCsv()` → `OptionsCsvResource` → `CsvResponse` (`values()`/`csv()` = raw CSV text). Reuses the package-private static `*Spec` builders on `OptionsResource`, sets `format=csv`, and skips the typed decode. **Omits `lookup`** (a scalar — no CSV). Fan-out mirrors the container: `quotes(...)` → `Map`. +- `asHtml()` → `OptionsHtmlResource`/`HtmlResponse` is **package-private (built, not exposed)** — the backend serves no HTML. `CsvResponse ≠ HtmlResponse` (distinct types). Verify `asHtmlFacetSendsFormatHtml` exercises it from the same package. + +--- + +## 7. The `quotes` multi-symbol fan-out + +The backend `quotes` endpoint takes a single `optionSymbol`. The SDK's `quotes(...)` accepts N symbols and fans out one request each. + +- One `quoteSpec(symbol, date, from, to, countback)` per symbol, each dispatched through the normal transport (50-permit `AsyncSemaphore`, retry, preflight). +- Results into a `LinkedHashMap` keyed by the **input symbol** (insertion order preserved), each value a full `OptionsQuotesResponse` (so per-symbol `statusCode`/`isNoData`/`requestId`/`json` stay observable). +- **Fail-fast:** `allOf(...)` ⇒ the map's future completes exceptionally if any single request fails. Deliberate (a partial map would hide failures). The CSV facet mirrors the shape: `Map`. + +Verify: `quotesFansOutToMultipleContracts` (integration) and the unit fan-out test. + +--- + +## 8. lookup / expirations / strikes + +Three simpler endpoints, each with a hand-written deserializer (their wire shapes don't fit the parallel-arrays row): + +- **lookup** — flat `{"s":"ok","optionSymbol":"…"}` → `OptionsLookupResponse.values()` is the `String`. Path URL-encoded per-segment via `PathSegments.encode` (`/` preserved, space → `%20`, `$` encoded). +- **expirations** — parallel `expirations[]` + scalar `updated` → `values()` is `List`, `updated()` the timestamp (null on `no_data`). +- **strikes** — one top-level key **per expiration date** + `s`/`updated` → `values()` is `List`. The deserializer is strict (unrecognized non-date key / non-numeric strike throws). Note: `columns`/Option A do not apply to these (they're not the option-row shape). + +`MarketDataDates` handles all three `dateformat` variants (unix / ISO-string / spreadsheet) uniformly. + +--- + +## 9. Subtle corners (finding-driven) + +| # | Corner | What to know | +|---|---|---| +| 9.1 | **No silent failures (Option A)** | All fields nullable for `columns`, but `validateRequestedColumns` still `ParseError`s on a requested-but-missing **required** column. A `null` only means "projected away" or "optional model value." §5.3. | +| 9.2 | **HTTP 203 is success** | API returns `203 Non-Authoritative Information` for cached/delayed data; IT assert `200 || 203` so they don't flap with market hours. | +| 9.3 | **`expiration=all` ≠ no filter** | Omitting the expiration filter returns only the **front-month**; `all()` returns every expiration. Verified against the backend. | +| 9.4 | **`countback` validation** | Positive; mutually exclusive with `date` and `from` (pair with `to`). | +| 9.5 | **Exhaustiveness guard** | `applyExpirationFilter` `else throw` — sealed `instanceof` chains aren't compiler-exhaustive; the guard fails loudly on an unmatched future variant. | +| 9.6 | **`quotes` is fan-out, not bulk** | The backend path takes one symbol; the SDK fans out N concurrent calls. | +| 9.7 | **`human`/`headers` are CSV-only** | They reshape the output, which breaks the typed decode — so they live on the CSV facet, not the typed resource. The facet boundary coincides with the params' type-safety boundary. | +| 9.8 | **HTML built, not exposed** | `asHtml()` is package-private; the server serves no HTML yet. Enabling later is a one-line change. | +| 9.9 | **`source` intentionally absent** | Internal provider param, not in the public schema/requirements/Python SDK. | + +--- + +## 10. Out of scope for this review + +Do **not** flag as missing — deferred decisions, documented in [`PR.md`](../PR.md): + +- **HTML facet exposure** — built and tested; `asHtml()` stays package-private until the backend serves `format=html`. +- **§13 JaCoCo 100% threshold** — deferred until the full resource layer lands. +- **§8 per-response rate-limit snapshot** — still client-level via `client.getRateLimits()`. +- **`stocks`/`funds`/`markets`** — adopt this convention next. + +(§3 universal parameters are **no longer** deferred — they're implemented here, §6.) + +--- + +## Reviewer checklist + +- [ ] Response model: every endpoint returns a named `MarketDataResponse`; `values()` is the flat payload; metadata surface complete; `Response` fully removed; `utilities` migrated with no regression. +- [ ] Request convention: Builder request per endpoint, no `String` overloads; required args non-optional; cross-field validation in `build()`. +- [ ] Sealed `ExpirationFilter`/`StrikeFilter` translate every variant in `applyChainParams`, with the `else throw` guard; `applyChainParams` reads **every** getter. +- [ ] Nullable + Option A: all `OptionQuote` fields `@Nullable`; `buildOptionRow` lenient; `validateRequestedColumns` fails on requested-but-missing required columns; strict-by-default preserved. +- [ ] `ParallelArrays` OrNull accessors are additive; strict path unchanged; an absent optional column can't throw. +- [ ] Universal params reach the wire; `human`/`headers` only on the CSV facet; `columns` projection round-trips. +- [ ] Facets: `asCsv()` returns `CsvResponse`, omits `lookup`, fan-out → `Map`; `asHtml()` package-private; `CsvResponse ≠ HtmlResponse`. +- [ ] `quotes` fan-out: per-symbol map, insertion order, fail-fast acceptable. +- [ ] Unit (`./gradlew build`) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) both green; `make demo-options` runs against the mock server. +- [ ] Deferred items (§10) understood and not blocking. diff --git a/docs/REFACTOR_REVIEW_GUIDE.md b/docs/REFACTOR_REVIEW_GUIDE.md index 996e4b7..6b15825 100644 --- a/docs/REFACTOR_REVIEW_GUIDE.md +++ b/docs/REFACTOR_REVIEW_GUIDE.md @@ -6,6 +6,8 @@ If you've never read this codebase, start with §1 (topology) → §2 (sync requ All file:line citations target `HEAD` on this branch. Line numbers drift; if a citation looks off, search for the symbol it names. +> **Superseded since this branch:** the generic `Response` wrapper this guide describes (`data()`, `rawBody()`, §7) was **replaced** in the `options` PR (`10_options_resource`) by `MarketDataResponse` + named per-endpoint response types, with a uniform `values()` accessor (and `json()` in place of `rawBody()`). The mechanics here — transport, retry, rate-limit, the parser / `ParallelArrays` decode path — are unchanged; only the response *carrier* changed. For the current model see [`OPTIONS_REVIEW_GUIDE.md`](OPTIONS_REVIEW_GUIDE.md). This guide is left as the historical record of the foundation PR. + ## Table of contents - [Running it locally](#running-it-locally) @@ -1114,6 +1116,12 @@ make demo-concurrency # terminal 3 ## 7. `Response` + JSON parsing +> **Note:** `Response` was replaced after this branch by `MarketDataResponse` + named +> per-endpoint types (`values()` instead of `data()`, `json()` instead of `rawBody()`). The +> **JSON parsing** half of this section (`JsonResponseParser`, `ParallelArrays`, the +> `s:"no_data"`/envelope handling) is still current — only the *carrier* changed. See +> [`OPTIONS_REVIEW_GUIDE.md`](OPTIONS_REVIEW_GUIDE.md) §2 / §5. + ### 7.1 `Response` surface `Response` at `Response.java:26` is the carrier consumers see for every successful call. diff --git a/docs/adr/ADR-008-endpoint-parameter-convention.md b/docs/adr/ADR-008-endpoint-parameter-convention.md new file mode 100644 index 0000000..40e5cdd --- /dev/null +++ b/docs/adr/ADR-008-endpoint-parameter-convention.md @@ -0,0 +1,309 @@ +# ADR-008: Endpoint Parameter Convention (Request Objects) + +## Status + +**Accepted** (2026-06-08). Implemented on branch `10_options_resource`. + +Decided as **Option A** (request object + builder) — the `Consumer` overload (Option +B) was *not* adopted. Two pieces the original Context flagged but didn't resolve were settled +here in the Decision below: the **§3 universal parameters** are a fluent resource-level config +(not a request-object overload), and the **output/format axis** is a typed **resource facet** +(not a parameter). The Context and Options-Considered sections are preserved as the decision +rationale; the Decision/Consequences reflect what shipped. + +## Context + +ADR-006 fixed *how many* surfaces each endpoint exposes (sync + async +parity). It did **not** fix *how an endpoint accepts its parameters* — +the call shape a consumer actually types. The `options` resource (the +second resource after `utilities`, and the template the remaining +`stocks` / `funds` / `markets` resources will copy) is the first place +this decision becomes load-bearing, because `chain` carries the richest +filter surface in the API (~25 query parameters, plus two groups of +mutually-exclusive filters). + +The cross-language sibling SDKs have already answered this question, and +they answered it the same way: + +```python +# sdk-py — kwargs bag +client.options.chain("AAPL", side="call", strike_limit=10, min_open_interest=100) +``` + +```typescript +// sdk-js — options-object bag +client.options.chain("AAPL", { side: "call", strikeLimit: 10, minOpenInterest: 100 }); +``` + +Both use *positional required argument + one flat optional bag*. Both +validate at runtime (Pydantic / Zod). Neither models the +mutually-exclusive filter groups — in both, you can pass `dte` **and** +`expiration` together and the backend arbitrates. `sdk-js` even allows +unknown keys through (`.passthrough()`). + +Java cannot copy that shape directly: it has no keyword arguments and no +object literals. The idiomatic Java substitutes are a request object +with a builder, a `Consumer` lambda, a transport-bound fluent +builder, or a flat "parameters" object mirroring the siblings. The +choice matters because: + +- It is a **public-API decision**, hard to reverse without breaking + changes, and it will be **replicated across every future resource** — + so it should be decided once, here, not endpoint-by-endpoint. +- Java is the **only** SDK in the family whose type system can make the + mutually-exclusive filter groups *unrepresentable* rather than merely + *validated*. `chain` models them as sealed types (`ExpirationFilter` → + `OnDate` / `Dte` / `Between` / `MonthYear` / `All`; `StrikeFilter` → + `Exact` / `Range` / `Comparison`) reached through a single setter, so + "pick one variant" is enforced by `javac`. Whatever convention we pick + must preserve that, because it is the one dimension where the Java SDK + is strictly safer than its siblings. +- ADR-006 parity and the **deferred §3 universal parameters** + (`format`, `dateformat`, `columns`, `mode`, …, landing with `stocks`) + both interact with the convention: a request object adds universal + params as a second overload; a fluent terminal folds them in as more + setters. + +Filter set held constant across the options below: **calls only, strike +limit 10, minimum open interest 100.** + +## Options Considered + +### Option A — Request object + builder (the PR as written) + +Each endpoint takes one immutable request object, constructed via a +static `builder(required…)` (or `of(required…)` when there are no +optionals). One signature per endpoint, both surfaces. + +```java +client.options().chain( + OptionsChainRequest.builder("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).build()); + +client.options().chain(OptionsChainRequest.of("AAPL")); // no filters +``` + +**Pros** + +- The request object is **inert and decoupled** — no client reference. + Reusable across calls and clients, inspectable, loggable, and + unit-testable (assert the query translation without a transport). +- One object feeds both `chain(req)` and `chainAsync(req)` with **zero + duplicated parameter surface** — ADR-006 parity is trivial. +- **Uniform call shape** across all six endpoints regardless of + parameter richness; the future universal-params overload retrofits + cleanly as `chain(req, universalParams)`. +- Immutable, matching the records/immutability ethos (ADR-005/007). +- Sealed filters live naturally on the builder. + +**Cons** + +- `.build()` ceremony on every inline call. +- Naming stutter: `options().chain(…)` then `OptionsChainRequest`. +- Loses to a kwargs bag / object literal on raw terseness — Java will + never win that contest. + +### Option B — Option A plus a `Consumer` overload + +Keep everything in Option A; add an additive overload that hides the +builder naming and `.build()` for the inline case. + +```java +client.options().chain("AAPL", b -> b.side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100)); +client.options().chain("AAPL"); // bare overload for the no-filter case +``` + +**Pros** + +- Closest spiritual match to how `sdk-py` / `sdk-js` actually solved it + (required positional + optional configuration), so cross-SDK muscle + memory transfers. +- Keeps **all** of Option A's properties — the request object stays + inert and decoupled; `chain(req)` survives for reuse and for + conditional/dynamic filter construction (which reads badly inside a + lambda). +- Purely additive over Option A: ~6 lines per endpoint delegating to the + existing request-object method; no change to the request classes. + +**Cons** + +- Reopens the explicit "no `String` convenience overloads, uniform call + shape" decision the current PR made on purpose. +- Overload count: `chain(req)` + `chain(String, Consumer)` + + `chain(String)` = 3 signatures × sync/async = 6 per endpoint, and the + deferred universal-params overload can multiply that again unless + universal params fold into the builder. +- Per call site you can avoid the lambda **or** the builder naming, but + not both — the lambda exists precisely to hide the builder, so the + lambda-free form (`chain(req)`) necessarily names `builder()`/`build()`. + +### Option C — Transport-bound fluent builder + terminal verb + +`chain("AAPL")` returns a builder wired to the transport; filters are +fluent setters; a mandatory terminal (`fetch()` / `fetchAsync()`) +executes. This is the only shape that drops the lambda **and** the +builder naming **and** `.build()` simultaneously. + +```java +client.options().chain("AAPL").side(OptionSide.CALL).strikeLimit(10).minOpenInterest(100).fetch(); +``` + +**Pros** + +- Shortest possible call site; closest to the siblings' brevity. +- Universal params fold in as more setters before the terminal — **no + overload growth ever**. +- Uniform if applied to all six endpoints. + +**Cons** + +- The builder is **fused to the transport** — it *is* a live request, + not data. Loses the free-standing, reusable, unit-testable spec unless + a parallel inert `OptionsChainRequest` + `chain(req)` surface is kept + too (two ways to do everything). +- **Dangling-terminal footgun:** `chain("AAPL").side(CALL);` (no + terminal) compiles and silently does nothing. Java's type system + cannot force termination; only an ErrorProne `@CheckReturnValue` + backstop catches it. +- Moves the ADR-006 sync/async pair off the resource method onto the + terminal verb — an **ADR-006 amendment**, not a free refactor. +- Makes Java the **call-shape outlier** among the three SDKs (neither + sibling has a terminal verb). + +### Option D — Flat parameters object (mirror the siblings) + +A single `OptionsChainParams` object (or a long overloaded signature) +with every filter as an independent optional field, mirroring the +Python/JS bag exactly — no sealed types. + +```java +var p = new OptionsChainParams(); +p.setSide(OptionSide.CALL); p.setStrikeLimit(10); p.setMinOpenInterest(100); +client.options().chain("AAPL", p); +``` + +**Pros** + +- Maximum cross-SDK structural consistency. +- Familiar to consumers coming from `sdk-py` / `sdk-js`. + +**Cons** + +- **Throws away the one Java advantage:** mutually-exclusive filters + become independent fields again, so `dte` + `expiration` is back to a + runtime/server error instead of a compile error. Re-implements the + siblings' weakest property in the one language that doesn't have to. +- Mutable params object cuts against the immutability ethos. +- Validation regresses from "unrepresentable" to "checked in `build()`/ + on the wire." + +## Claude's Recommendation + +**Option A as the canonical, ADR-blessed form, plus Option B's +`Consumer` overload as the ergonomic front door.** + +The deciding factors: + +1. Terseness is unwinnable for Java against an object literal or kwargs — + so it should not be the optimization target. The Java SDK's + differentiator is the **compile-time sealed filters** (Options A/B/C + keep them; Option D discards them). Protect that; it is the only + column where Java leads its siblings. +2. The **inert, decoupled request object** (A/B) buys reuse, + testability, trivial ADR-006 parity, and freedom from the + dangling-terminal footgun. Option C trades all of that for a cosmetic + win and forces an ADR-006 amendment plus cross-SDK divergence. +3. Option B's overload is **purely additive** and recovers the siblings' + call ergonomics (`chain("AAPL", b -> …)` / `chain("AAPL")`) without + sacrificing anything in A. Its only real cost — overload count — is a + conscious, bounded trade, not the open-ended growth Option C's + detractors and the original PR feared. +4. Adopting B is an **all-or-nothing convention**: if `chain` gets the + overload, every endpoint should, so the SDK has one front door, not a + per-endpoint coin flip. That uniformity is exactly what an ADR is for. + +The strongest counter-recommendation is **Option A alone** (the PR as +written): one signature per endpoint is the simplest possible surface +and the cleanest universal-params retrofit. The case against it is +purely ergonomic — `OptionsChainRequest.builder(…).build()` at every +call site — and Option B answers that without giving anything up. If the +team values minimal surface over call-site ergonomics, shipping A in the +options PR and adding B in a follow-up is a reasonable middle path (the +overload is additive and non-breaking, so deferring it costs nothing +structurally). + +## Decision + +**Option A — one immutable request object per endpoint** (`builder(required…)` / +`of(required…)`), feeding both the sync and async surfaces. **No `String` convenience +overloads and no `Consumer` overload (Option B):** the team chose the minimal, +uniform surface — the "counter-recommendation" the analysis above weighed — over the lambda +front door. B stays purely additive, so it can be added later if call-site ceremony bites; it +was not worth the overload-count cost up front. The sealed mutually-exclusive filter groups +(`ExpirationFilter`, `StrikeFilter`) are retained unchanged. + +Two things resolved **differently from the original recommendation**, because a request-object +overload turned out to be the wrong axis for them: + +- **Universal parameters are not a second request-object overload.** They are set fluently on + the **resource** — `client.options().dateFormat(…).mode(…).limit(…).offset(…).columns(…)`, + an immutable configured value carried across endpoints via `RequestConfig`. Type-preserving + params (`dateFormat`/`mode`/`limit`/`offset`/`columns`) sit on the typed resource; the + output-shaping `human`/`headers` live only on the CSV facet (they reshape the payload, so they + don't cohere with the typed decode). +- **The output/format axis** — which a request-object parameter *cannot* express in a + statically-typed language (the return type would have to vary at runtime) — is selected via a + **typed resource facet** (`.asCsv()` → `CsvResponse`; `.asHtml()` built but not yet exposed), + not a parameter. + +Options C (transport-bound fluent terminal) and D (flat params object) were considered and +rejected — C because it sacrifices the decoupled request object, introduces an un-enforceable +dangling-terminal footgun, amends ADR-006, and makes Java the cross-SDK call-shape outlier; D +because it discards the compile-time mutual-exclusivity guarantee that is the Java SDK's one +advantage over its siblings. + +### Nullable response fields + Option A (the cost of `columns`) + +Exposing `columns` on the **typed** path forced a response-side decision. A projected response +omits the columns you didn't ask for, so the typed model can't keep "always present" fields: +**every response-row field is `@Nullable`** (boxed) — including structural ones like `strike` +and `bid`, not just the legitimately-optional values (the option greeks / `iv`). + +To stop that from degrading into *silent* failures, the deserializer reads every column +leniently but then enforces **Option A**: it cross-references the columns the consumer actually +requested (threaded in via a parser attribute) and raises a `ParseError` if a **required** +column that was requested — or wasn't projected away (no `columns` filter ⇒ all required +columns are implicitly requested) — is missing from the response. The contract that buys: + +- a `null` a consumer sees means **only** "I projected it away with `columns`" or "a + legitimately-optional model value (greek/IV)"; +- it **never** means "the backend silently dropped a required field" — that is always a + `ParseError`. + +So the nullable fields do **not** weaken the strict-by-default decode; they just make +projection representable. (Greek presence is additionally inspectable via +`OptionQuote.presentGreeks()` / `greek(Greek)`.) + +## Consequences + +What actually shipped, and what was left out. + +- **A (shipped):** One signature per endpoint; call sites always name + `FooRequest.builder(…).build()` (or `of(…)`); no lambda overloads. Shipped for `options` and + is the template every future resource (`stocks`/`funds`/`markets`) copies. +- **B (not adopted):** the `foo(String, Consumer)` / `foo(String)` overloads were + considered and deliberately left out — purely additive, so revisitable without a breaking + change if call-site ceremony proves annoying in practice. +- **Universal parameters:** fluent setters on the **resource** (`RequestConfig`), applied + across endpoints — *not* the "second request-object overload" the recommendation originally + envisioned. Implemented for `options`; `human`/`headers` and the format facet land on the CSV + view. +- **C (fluent terminal) / D (flat params object):** not adopted (rejected as above). + +## References + +- [Market Data SDK Requirements §3 Universal Parameters, §Language-Idiomatic Design](../sdk-requirements.md) +- [Java SDK Requirements §2 — Kotlin interop](../java-sdk-requirements.md) +- [ADR-005 — JSON Library](./ADR-005-json-library.md) — records / immutability ethos +- [ADR-006 — Async API Surface](./ADR-006-async-api-surface.md) — sync/async parity that the terminal-verb option (C) would amend +- [ADR-007 — Internal API Encapsulation](./ADR-007-internal-api-encapsulation.md) — package-private constructors on resource façades and request types +- Sibling SDKs (not committed in this repo): `sdk-py` `client.options.chain(...)` (kwargs bag); `sdk-js` `client.options.chain(...)` (options-object bag, Zod `.passthrough()`) diff --git a/examples/consumer-test/README.md b/examples/consumer-test/README.md index ccf9915..d2e3627 100644 --- a/examples/consumer-test/README.md +++ b/examples/consumer-test/README.md @@ -59,7 +59,7 @@ Without it the demo fails fast with a clear "server not reachable" message. | App | Scenario | What you should see | |---|---|---| -| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers). | +| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers) and `options` (lookup / expirations / strikes / chain incl. sealed filters + `expiration=all` + `rho` / quote / quotes with `countback`). | | **LiveSmokeApp** | The happy path against the real API | client.toString redacted; sync + async parity on status/user/headers; parallel calls completing in ≈ slowest-single-call wall-time; final rate-limit snapshot populated | | **DemoAndConfigApp** | Construction-time behavior | demo-mode skip; §16 token redaction (short ≤8 → full; >8 → ***…***ABCD); cascade (explicit wins); IAE on invalid baseUrl / CRLF API key; validateOnStartup 200 vs 401 paths | | **ExceptionsApp** | Every sealed exception subtype | 401 → AuthenticationError; 400 → BadRequestError; 429 → RateLimitError (+ Retry-After); 500 → ServerError (no retry); 503×4 → ServerError after ≈7s; malformed JSON → ParseError; empty body → ParseError (§29 fix message); connection refused → NetworkError after retries; ADR-002 sealed routing via instanceof | diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index 8de5ec1..17ba788 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -33,9 +33,11 @@ val demoApps = mapOf( "runRetry" to ("com.marketdata.consumer.RetryBehaviorApp" to "Retry policy, Retry-After header, preflight gate. Needs mock server."), "runResponse" to ("com.marketdata.consumer.ResponseFeaturesApp" to - "Response surface: predicates, isNoData, rawBody, saveToFile, toString. Needs mock server."), + "MarketDataResponse surface: predicates, isNoData, json, saveToFile, toString. Needs mock server."), "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to - "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server.") + "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server."), + "runOptions" to ("com.marketdata.consumer.OptionsApp" to + "Full options surface: every endpoint + all params, CSV facet, columns projection, Option A. Needs mock server.") ) demoApps.forEach { (taskName, app) -> diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java index 77a4abf..e9494be 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java @@ -4,7 +4,7 @@ import com.marketdata.consumer.shared.MockServerControl; import com.marketdata.consumer.shared.MockServerControl.Step; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; +import com.marketdata.sdk.UtilitiesStatusResponse; import com.marketdata.sdk.utilities.ApiStatus; import java.util.ArrayList; import java.util.List; @@ -51,7 +51,7 @@ public static void main(String[] args) { try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { long t0 = System.nanoTime(); - List>> futures = new ArrayList<>(fanout); + List> futures = new ArrayList<>(fanout); for (int i = 0; i < fanout; i++) { futures.add(client.utilities().statusAsync()); } diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java index 03a4b11..460e992 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java @@ -80,7 +80,7 @@ private static void notFound404WithRealError(MockServerControl mock, MarketDataC Console.info( "Spec §11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response. The SDK returns a"); Console.info( - "Response with isNoData() = true. To see NotFoundError, we'd need a 404 that"); + "a MarketDataResponse with isNoData() = true. To see NotFoundError, we'd need a 404 that"); Console.info( "ISN'T the no-data envelope — but the current routing maps all 404s to a successful"); Console.info( diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java index 5dad3b4..6b5fc85 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java @@ -2,7 +2,11 @@ import com.marketdata.consumer.shared.Console; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; +import com.marketdata.sdk.MarketDataResponse; +import com.marketdata.sdk.UtilitiesHeadersResponse; +import com.marketdata.sdk.UtilitiesStatusResponse; +import com.marketdata.sdk.UtilitiesUserResponse; +import java.util.Map; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.ServiceStatus; @@ -38,18 +42,18 @@ public static void main(String[] args) { Console.header("/status/ (sync) — unversioned, no token required"); Console.run( () -> client.utilities().status(), - r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + r -> "data() has " + r.values().size() + " services; " + describe(r)); Console.header("/status/ (async) — same call via the async surface"); Console.run( () -> joinResponse(client.utilities().statusAsync()), - r -> "data() has " + r.data().services().size() + " services; " + describe(r)); + r -> "data() has " + r.values().size() + " services; " + describe(r)); Console.header("/user/ (sync) — needs a token"); Console.run( () -> client.utilities().user(), r -> { - User u = r.data(); + User u = r.values(); return "requestsRemaining=" + u.requestsRemaining() + ", requestsLimit=" @@ -64,10 +68,10 @@ public static void main(String[] args) { Console.run( () -> client.utilities().headers(), r -> { - RequestHeaders rh = r.data(); - String auth = rh.headers().getOrDefault("authorization", "(absent)"); + Map rh = r.values(); + String auth = rh.getOrDefault("authorization", "(absent)"); return "headers=" - + rh.headers().size() + + rh.size() + " entries (authorization echoed back: " + auth + "); " @@ -76,9 +80,9 @@ public static void main(String[] args) { Console.header("Parallel async — fan out 3 calls, await all"); long t0 = System.nanoTime(); - CompletableFuture> a = client.utilities().statusAsync(); - CompletableFuture> b = client.utilities().userAsync(); - CompletableFuture> c = client.utilities().headersAsync(); + CompletableFuture a = client.utilities().statusAsync(); + CompletableFuture b = client.utilities().userAsync(); + CompletableFuture c = client.utilities().headersAsync(); // exceptionally() turns a failure into a null sentinel so allOf doesn't short-circuit on // the first failing call — we still want to see whether the others succeeded. CompletableFuture aSafe = a.thenApply(r -> (Object) r).exceptionally(t -> t); @@ -91,11 +95,11 @@ public static void main(String[] args) { + elapsedMs + " ms (≈ slowest single call, not sum — proves true parallelism)"); describeResult("status", aSafe.join(), r -> { - List services = ((Response) r).data().services(); + List services = ((UtilitiesStatusResponse) r).values(); return services.size() + " services; first: " + services.get(0).service(); }); - describeResult("user", bSafe.join(), r -> "remaining=" + ((Response) r).data().requestsRemaining()); - describeResult("headers", cSafe.join(), r -> ((Response) r).data().headers().size() + " entries"); + describeResult("user", bSafe.join(), r -> "remaining=" + ((UtilitiesUserResponse) r).values().requestsRemaining()); + describeResult("headers", cSafe.join(), r -> ((UtilitiesHeadersResponse) r).values().size() + " entries"); Console.header("Final rate-limit snapshot"); Console.info("rateLimits after the calls: " + client.getRateLimits()); @@ -112,11 +116,11 @@ private static void describeResult(String label, Object resultOrThrowable, java. } } - private static String describe(Response r) { + private static String describe(MarketDataResponse r) { return "status=" + r.statusCode() + ", requestId=" + r.requestId() + ", url=" + r.requestUrl(); } - private static Response joinResponse(CompletableFuture> f) { + private static R joinResponse(CompletableFuture f) { // CompletableFuture.join wraps the cause in CompletionException, but the SDK's joinSync // contract is to surface MarketDataException directly. We mimic that here so the demo's // exception output matches what a sync caller would see. diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java new file mode 100644 index 0000000..fa18bcc --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java @@ -0,0 +1,353 @@ +package com.marketdata.consumer; + +import com.marketdata.consumer.shared.Console; +import com.marketdata.consumer.shared.MockServerControl; +import com.marketdata.consumer.shared.MockServerControl.Step; +import com.marketdata.sdk.DateFormat; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Mode; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; +import java.time.LocalDate; +import java.util.List; + +/** + * Exhaustive {@code options} resource demo against the mock server. Covers: + * + *
    + *
  • every endpoint (lookup, expirations, strikes, quote, quotes, chain) with the full + * parameter surface — universal params (dateFormat/mode/limit/offset) + the rich chain + * filters (sealed expiration/strike groups, side, liquidity/price filters, …); + *
  • the CSV facet ({@code asCsv()}); + *
  • {@code columns} projection: requested fields populate, fields you did not ask for + * come back {@code null} with no error; + *
  • Option A failures: a required column you did request (or didn't project away) that + * the API omits raises a {@link ParseError}. + *
+ * + *

Each scenario scripts the mock server's response with {@link MockServerControl#script}, so the + * "API" returns exactly the body the scenario needs. + * + *

Run: {@code ./gradlew runOptions} (needs the mock server up). + */ +public final class OptionsApp { + + private OptionsApp() {} + + // A full option-quote row (every column present) — used by chain/quote. + private static final String FULL_ROW = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\"],\"underlying\":[\"AAPL\"]," + + "\"expiration\":[1737136800],\"side\":[\"call\"],\"strike\":[150]," + + "\"firstTraded\":[1663118400],\"dte\":[45],\"updated\":[1705449600]," + + "\"bid\":[12.55],\"bidSize\":[10],\"mid\":[12.7],\"ask\":[12.85],\"askSize\":[8]," + + "\"last\":[12.8],\"openInterest\":[15234],\"volume\":[289],\"inTheMoney\":[true]," + + "\"intrinsicValue\":[3.38],\"extrinsicValue\":[9.32],\"underlyingPrice\":[153.38]," + + "\"iv\":[0.2432],\"delta\":[0.5862],\"gamma\":[0.015],\"theta\":[-0.1347]," + + "\"vega\":[0.4152],\"rho\":[0.0891]}"; + + // A chain with three contracts — so iterating values() shows more than one row. + private static final String THREE_ROWS = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\",\"AAPL250117C00155000\",\"AAPL250117C00160000\"]," + + "\"underlying\":[\"AAPL\",\"AAPL\",\"AAPL\"]," + + "\"expiration\":[1737136800,1737136800,1737136800]," + + "\"side\":[\"call\",\"call\",\"call\"]," + + "\"strike\":[150,155,160]," + + "\"firstTraded\":[1663118400,1663118400,1663118400]," + + "\"dte\":[45,45,45],\"updated\":[1705449600,1705449600,1705449600]," + + "\"bid\":[12.55,8.90,6.10],\"bidSize\":[10,12,8],\"mid\":[12.7,9.0,6.2]," + + "\"ask\":[12.85,9.10,6.30],\"askSize\":[8,9,7],\"last\":[12.8,9.0,6.2]," + + "\"openInterest\":[15234,9921,7044],\"volume\":[289,144,98]," + + "\"inTheMoney\":[true,false,false],\"intrinsicValue\":[3.38,0,0]," + + "\"extrinsicValue\":[9.32,9.0,6.2],\"underlyingPrice\":[153.38,153.38,153.38]," + + "\"iv\":[0.2432,0.2401,0.2380],\"delta\":[0.5862,0.5096,0.4401]," + + "\"gamma\":[0.015,0.0155,0.0150],\"theta\":[-0.1347,-0.1343,-0.1320]," + + "\"vega\":[0.4152,0.4251,0.4180],\"rho\":[0.0891,0.0810,0.0732]}"; + + private static final String EXPIRATIONS = + "{\"s\":\"ok\",\"expirations\":[1737072000,1739491200],\"updated\":1705449600}"; + private static final String STRIKES = + "{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[145.0,150.0,155.0]}"; + private static final String LOOKUP = "{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"; + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + everyEndpointWithAllParams(mock, client); + csvFacet(mock, client); + columnsProjectionDoesNotFail(mock, client); + optionARequestedColumnMissingFails(mock, client); + strictByDefaultMissingColumnFails(mock, client); + } + } + + // ---------- every endpoint, all params ---------- + + private static void everyEndpointWithAllParams(MockServerControl mock, MarketDataClient client) { + Console.header("Every options endpoint with the full parameter surface"); + + // chain — the richest filter surface, plus universal params set fluently on the resource. + Console.step("chain(...) — sealed filters + side/liquidity/price filters + universal params"); + mock.reset(); + mock.script(Step.of(200, THREE_ROWS)); + var chain = + client + .options() + .dateFormat(DateFormat.TIMESTAMP) // universal param (type-preserving) + .mode(Mode.DELAYED) // universal param + .limit(50) // universal param + .offset(0) // universal param + .chain( + OptionsChainRequest.builder("AAPL") // required: underlying + .expirationFilter(ExpirationFilter.dte(45)) // sealed mutex: pick ONE + .strikeFilter(StrikeFilter.range(150, 250)) // sealed mutex: pick ONE + .strikeRange(StrikeRange.ITM) + .side(OptionSide.CALL) + .strikeLimit(10) // (strikeLimit/delta are alternative strike selectors) + .delta(0.5) + .weekly(true) + .monthly(true) + .quarterly(true) + .am(true) + .pm(true) + .nonstandard(true) + .minBid(0.1) + .maxBid(100.0) + .minAsk(0.1) + .maxAsk(100.0) + .maxBidAskSpread(5.0) + .maxBidAskSpreadPct(50.0) + .minOpenInterest(100) + .minVolume(10) + .date(LocalDate.of(2025, 1, 2)) // historical snapshot + .build()); + // .values() returns a List — iterate it row by row. + List rows = chain.values(); + Console.ok("chain.values() → List with " + rows.size() + " contracts; iterating:"); + for (OptionQuote row : rows) { + Console.info( + " " + + row.optionSymbol() + + " strike=" + + row.strike() + + " bid/ask=" + + row.bid() + + "/" + + row.ask() + + " inTheMoney=" + + row.inTheMoney() + + " delta=" + + row.delta() + + " greeks=" + + row.presentGreeks()); + } + Console.info("statusCode=" + chain.statusCode() + " isNoData=" + chain.isNoData()); + + // quote — single OCC symbol, date-window params. + Console.step("quote(...) — single OCC symbol + date window"); + mock.reset(); + mock.script(Step.of(200, FULL_ROW)); + var quote = + client + .options() + .dateFormat(DateFormat.UNIX) + .quote( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .from(LocalDate.of(2025, 1, 1)) // from+to = a date range (date window is mutex) + .to(LocalDate.of(2025, 1, 10)) + .build()); + List quoteRows = quote.values(); // also a List + Console.ok("quote.values() → List with " + quoteRows.size() + " row(s); iterating:"); + for (OptionQuote row : quoteRows) { + Console.info(" " + row.optionSymbol() + " last=" + row.last() + " iv=" + row.iv()); + } + + // quotes — fan-out over several OCC symbols → one request per symbol → per-symbol map. + Console.step("quotes(...) — multi OCC symbol fan-out (Map)"); + mock.reset(); + mock.script(List.of(Step.of(200, FULL_ROW), Step.of(200, FULL_ROW))); + var quotes = + client + .options() + .quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + quotes.forEach((sym, resp) -> Console.info(" " + sym + " → " + resp.values().size() + " row(s)")); + + // strikes — underlying + optional expiration/date; response is per-expiration strike lists. + Console.step("strikes(...) — strike ladder per expiration"); + mock.reset(); + mock.script(Step.of(200, STRIKES)); + var strikes = + client + .options() + .strikes( + OptionsStrikesRequest.builder("AAPL") + .expiration(LocalDate.of(2025, 1, 17)) + .date(LocalDate.of(2025, 1, 2)) + .build()); + Console.ok( + "strikes → " + + strikes.values().size() + + " expiration(s), updated=" + + strikes.updated()); + + // expirations — underlying + optional strike/date; response is a list of dates. + Console.step("expirations(...) — available expiration dates"); + mock.reset(); + mock.script(Step.of(200, EXPIRATIONS)); + var exps = + client + .options() + .expirations( + OptionsExpirationsRequest.builder("AAPL") + .strike(150.0) + .date(LocalDate.of(2025, 1, 2)) + .build()); + Console.ok("expirations → " + exps.values().size() + " date(s), updated=" + exps.updated()); + + // lookup — free-text → OCC symbol (scalar; no universal params, no facet). + Console.step("lookup(...) — human description → OCC symbol"); + mock.reset(); + mock.script(Step.of(200, LOOKUP)); + var sym = client.options().lookup(OptionsLookupRequest.of("AAPL 1/17/25 $150 call")); + Console.ok("lookup → " + sym.values()); + } + + // ---------- CSV facet ---------- + + private static void csvFacet(MockServerControl mock, MarketDataClient client) { + Console.header("CSV facet — client.options().asCsv()"); + + Console.step("asCsv().chain(...) — plain CSV"); + mock.reset(); + mock.script(Step.of(200, "optionSymbol,strike,bid\nAAPL250117C00150000,150,12.55")); + var csv = client + .options() + .asCsv() + .chain( + OptionsChainRequest.of("AAPL") + ); + Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); + Console.info(csv.csv()); + + // columns / human / headers reshape the output, so they live ONLY on the CSV facet. + Console.step("asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); + mock.reset(); + mock.script(Step.of(200, "Option Symbol,Strike Price,Bid\nAAPL250117C00150000,150,12.55")); + var shaped = + client + .options() + .asCsv() + .columns("optionSymbol", "strike", "bid") // project to a subset + .human(true) // human-readable column names + .headers(true) // include the header row + .chain(OptionsChainRequest.of("AAPL")); + Console.ok("→ CSV with human headers + projected columns:"); + Console.info(shaped.csv()); + + // Fan-out in CSV mirrors the typed map: one CsvResponse per symbol. + Console.step("asCsv().quotes(...) — fan-out → Map"); + mock.reset(); + mock.script( + List.of( + Step.of(200, "optionSymbol,bid\nAAPL250117C00150000,12.55"), + Step.of(200, "optionSymbol,bid\nAAPL250117P00150000,3.10"))); + var csvMap = + client + .options() + .asCsv() + .quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + csvMap.forEach( + (sym, resp) -> Console.info(" " + sym + " → " + resp.csv().replace("\n", " ⏎ "))); + } + + // ---------- columns projection: no failure when a non-requested field is absent ---------- + + private static void columnsProjectionDoesNotFail(MockServerControl mock, MarketDataClient client) { + Console.header("columns projection — non-requested fields come back null, NO error"); + mock.reset(); + // The mock returns ONLY the projected columns (as the real API would for ?columns=...). + mock.script( + Step.of( + 200, + "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]," + + "\"delta\":[0.5862]}")); + + var proj = + client + .options() + .columns("optionSymbol", "strike", "delta") + .chain(OptionsChainRequest.of("AAPL")); + + List rows = proj.values(); // still a List, just with most fields null + for (OptionQuote row : rows) { + Console.ok( + "requested → optionSymbol=" + + row.optionSymbol() + + " strike=" + + row.strike() + + " delta=" + + row.delta()); + Console.ok( + "NOT requested (null, decoded cleanly) → bid=" + + row.bid() + + " volume=" + + row.volume() + + " iv=" + + row.iv()); + } + } + + // ---------- Option A: requested column missing → ParseError ---------- + + private static void optionARequestedColumnMissingFails( + MockServerControl mock, MarketDataClient client) { + Console.header("Option A — requested a column the API omitted → ParseError"); + mock.reset(); + // Consumer asks for bid, but the body omits it → anomaly, not a projection. + mock.script( + Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); + + try { + client + .options() + .columns("optionSymbol", "strike", "bid") + .chain(OptionsChainRequest.of("AAPL")); + Console.fail("expected a ParseError — 'bid' was requested but the API did not return it"); + } catch (ParseError e) { + Console.ok("ParseError as expected: " + e.getMessage()); + } + } + + // ---------- strict by default: no columns filter still requires all structural columns ---------- + + private static void strictByDefaultMissingColumnFails( + MockServerControl mock, MarketDataClient client) { + Console.header("Strict by default — no columns filter, but a required column is missing"); + mock.reset(); + mock.script( + Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); + + try { + // No .columns(...) → every required column is implicitly requested, so a missing one fails. + client.options().chain(OptionsChainRequest.of("AAPL")); + Console.fail("expected a ParseError — required columns are missing and none were projected away"); + } catch (ParseError e) { + Console.ok("ParseError as expected (nullable fields did NOT weaken the strict default): " + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java index 695005b..ecb0fc8 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java @@ -2,13 +2,32 @@ import com.marketdata.consumer.shared.Console; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; import com.marketdata.sdk.exception.AuthenticationError; import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; /** * Idiomatic consumer-style examples — one short snippet per SDK resource showing @@ -50,8 +69,8 @@ public static void main(String[] args) { return; } utilitiesExamples(client); + optionsExamples(client); // stocksExamples(client); // ← add when client.stocks() lands - // optionsExamples(client); // ← add when client.options() lands // fundsExamples(client); // ← add when client.funds() lands // marketsExamples(client); // ← add when client.markets() lands } @@ -65,9 +84,9 @@ private static void utilitiesExamples(MarketDataClient client) { // 1) Public endpoint: no token required. Useful as a liveness check. Console.step("client.utilities().status() — per-service health snapshot"); try { - Response health = client.utilities().status(); - long online = health.data().services().stream().filter(ServiceStatus::online).count(); - Console.ok(online + " of " + health.data().services().size() + " services online"); + var health = client.utilities().status(); + long online = health.values().stream().filter(ServiceStatus::online).count(); + Console.ok(online + " of " + health.values().size() + " services online"); } catch (MarketDataException e) { Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); } @@ -77,8 +96,8 @@ private static void utilitiesExamples(MarketDataClient client) { // invalid" — surface a hint to the user rather than crashing. Console.step("client.utilities().user() — your quota & permissions"); try { - Response me = client.utilities().user(); - User u = me.data(); + var me = client.utilities().user(); + User u = me.values(); Console.ok( u.requestsRemaining() + " requests remaining of " + u.requestsLimit() + " (today)"); } catch (AuthenticationError e) { @@ -93,10 +112,10 @@ private static void utilitiesExamples(MarketDataClient client) { // when debugging "is my Authorization header actually getting through?". Console.step("client.utilities().headers() — what the server saw on this call"); try { - Response echo = client.utilities().headers(); + var echo = client.utilities().headers(); Console.ok( "server received " - + echo.data().headers().size() + + echo.values().size() + " request headers (Authorization echoed back redacted)"); } catch (AuthenticationError e) { Console.info("401 — needs a token (same reason as utilities().user())."); @@ -105,6 +124,157 @@ private static void utilitiesExamples(MarketDataClient client) { } } + // ---------- options ---------- + + /** + * One short snippet per options endpoint, in the order a consumer typically discovers them: + * lookup → expirations → strikes → chain → quote/quotes. The {@code chain} examples show the two + * sealed filter groups ({@link ExpirationFilter}, {@link StrikeFilter}), the {@code + * expiration=all} span, the optional nullable {@code rho} greek, and the {@code countback} + * window. Options data needs entitlements, so each step catches {@link AuthenticationError} + * separately and prints a hint — the tour stays runnable in demo mode. + */ + private static void optionsExamples(MarketDataClient client) { + Console.header("options — lookup, expirations, strikes, chain, quote, quotes"); + Console.info( + "Entry point is client.options(); every endpoint takes a Builder-based request object" + + " (no String overloads) and returns a typed MarketDataResponse (access the payload via .values())."); + + // 1) lookup — turn a human description into a well-formed OCC symbol. + Console.step("client.options().lookup(...) — human description → OCC symbol"); + try { + var r = + client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); + Console.ok("resolved to " + r.values()); + } catch (AuthenticationError e) { + Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the options endpoints."); + } catch (MarketDataException e) { + Console.fail("lookup() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 2) expirations — the expiration calendar for an underlying. + Console.step("client.options().expirations(\"AAPL\") — expiration dates"); + try { + var r = + client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + Console.ok( + r.values().size() + " expirations; updated " + r.updated()); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("expirations() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 3) strikes — the strike ladder, grouped per expiration. + Console.step("client.options().strikes(\"AAPL\") — strike ladder per expiration"); + try { + var r = client.options().strikes(OptionsStrikesRequest.of("AAPL")); + if (r.values().isEmpty()) { + Console.ok("no strikes returned"); + } else { + ExpirationStrikes first = r.values().get(0); + Console.ok( + first.expiration().toLocalDate() + " has " + first.strikes().size() + " strikes"); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("strikes() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 4) chain — the rich filter surface. The mutually-exclusive groups are sealed types: you pick + // one ExpirationFilter and one StrikeFilter variant, enforced by the compiler. Here: ITM-ish + // calls within 45 DTE, strikes 150–250, the 5 nearest the money. + Console.step( + "client.options().chain(...) — filtered chain via sealed ExpirationFilter / StrikeFilter"); + try { + var r = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(45)) + .strikeFilter(StrikeFilter.range(150, 250)) + .side(OptionSide.CALL) + .strikeLimit(5) + .build()); + Console.ok(r.values().size() + " contracts"); + if (!r.values().isEmpty()) { + OptionQuote q = r.values().get(0); + // rho is an optional column — may be null when the feed omits it. + Console.ok(q.optionSymbol() + " delta=" + q.delta() + " rho=" + q.rho()); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("chain() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 5) chain with ExpirationFilter.all() — the whole chain across every expiration, distinct from + // omitting the filter (which the API narrows to the front-month). strikeLimit(1) keeps it small. + Console.step("client.options().chain(... ExpirationFilter.all()) — every expiration at once"); + try { + var chain = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + long distinct = chain.stream().map(OptionQuote::expiration).distinct().count(); + Console.ok("spans " + distinct + " distinct expirations (front-month-only would be 1)"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("chain(all) failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 6) quote / quotes — single contract, then concurrent multi-contract fan-out. Real symbols are + // pulled from a tiny chain query so the contracts are guaranteed to exist. quotes returns a + // Map keyed by symbol; countback caps each per-symbol series to the N most recent rows. + Console.step( + "client.options().quote(...) / quotes(...) — single + concurrent multi-contract (countback)"); + try { + List sample = + client + .options() + .chain( + OptionsChainRequest.builder("AAPL") + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(2) + .build()) + .values(); + if (sample.size() < 2) { + Console.info("not enough contracts returned to demo quote/quotes — skipping"); + return; + } + String s1 = sample.get(0).optionSymbol(); + String s2 = sample.get(1).optionSymbol(); + + var one = client.options().quote(OptionsQuoteRequest.of(s1)); + Console.ok("quote(" + s1 + ") → " + one.values().size() + " row"); + + var many = + client + .options() + .quotes( + OptionsQuotesRequest.builder(s1, s2) + .to(LocalDate.now()) + .countback(5) + .build()); + Console.ok( + "quotes(" + s1 + ", " + s2 + ") → " + many.size() + " symbols, <=5 rows each (countback)"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("quote/quotes failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + } + // ---------- stocks (TODO: enable when client.stocks() lands) ---------- // // private static void stocksExamples(MarketDataClient client) { @@ -112,11 +282,11 @@ private static void utilitiesExamples(MarketDataClient client) { // // Console.step("client.stocks().quote(\"AAPL\") — latest quote"); // var q = client.stocks().quote("AAPL"); - // Console.ok("AAPL last=" + q.data().last() + " (asOf " + q.data().asOf() + ")"); + // Console.ok("AAPL last=" + q.values().last() + " (asOf " + q.values().asOf() + ")"); // // Console.step("client.stocks().candles(\"AAPL\", Resolution.D, from, to) — historical OHLCV"); // var c = client.stocks().candles("AAPL", Resolution.D, ...); - // Console.ok(c.data().rows().size() + " daily candles fetched"); + // Console.ok(c.values().rows().size() + " daily candles fetched"); // } // ---------- helpers ---------- diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java index 3a8c5a8..98d1e09 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java @@ -4,16 +4,15 @@ import com.marketdata.consumer.shared.MockServerControl; import com.marketdata.consumer.shared.MockServerControl.Step; import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Response; import com.marketdata.sdk.utilities.ApiStatus; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; /** - * §13.5 {@code Response} surface: format predicates ({@code isJson}, + * §13.5 {@code MarketDataResponse} surface: format predicates ({@code isJson}, * {@code isCsv}, {@code isHtml}), the no-data envelope ({@code isNoData} - * from a 404 + {@code s:no_data}), defensive copies on {@code rawBody()}, the + * from a 404 + {@code s:no_data}), the raw body via {@code json()}, the * {@code saveToFile} helper, and the redacted {@code toString} shape. * *

Run: {@code ./gradlew runResponse} @@ -29,7 +28,7 @@ public static void main(String[] args) throws Exception { new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { formatPredicates(mock, client); noDataEnvelope(mock, client); - rawBodyIsDefensiveCopy(mock, client); + jsonReturnsRawBody(mock, client); saveToFileWritesVerbatim(mock, client); toStringIsLogSafe(mock, client); } @@ -42,7 +41,7 @@ private static void formatPredicates(MockServerControl mock, MarketDataClient cl mock.reset(); mock.script(Step.of(200, "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}")); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); Console.info("isJson(): " + resp.isJson()); Console.info("isCsv(): " + resp.isCsv()); Console.info("isHtml(): " + resp.isHtml()); @@ -65,13 +64,13 @@ private static void noDataEnvelope(MockServerControl mock, MarketDataClient clie mock.script(Step.of(404, "{\"s\":\"no_data\"}")); try { - Response resp = client.utilities().status(); + var resp = client.utilities().status(); Console.ok( "no exception thrown; statusCode=" + resp.statusCode() + ", isNoData=" + resp.isNoData()); - Console.info("data().services() = " + resp.data().services() + " (empty list as designed)"); + Console.info("data().services() = " + resp.values() + " (empty list as designed)"); } catch (Exception e) { Console.fail("404+no_data became an exception: " + e.getClass().getSimpleName()); } @@ -79,22 +78,18 @@ private static void noDataEnvelope(MockServerControl mock, MarketDataClient clie // ---------- defensive rawBody copy ---------- - private static void rawBodyIsDefensiveCopy(MockServerControl mock, MarketDataClient client) { - Console.header("rawBody() returns a defensive copy (mutations don't leak)"); + private static void jsonReturnsRawBody(MockServerControl mock, MarketDataClient client) { + Console.header("json() returns the raw response body verbatim"); mock.reset(); String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); - byte[] first = resp.rawBody(); - Console.info("first rawBody() length: " + first.length); - first[0] = 'X'; // mutate the returned array — must not affect internal state - - byte[] second = resp.rawBody(); - if (Arrays.equals(second, payload.getBytes())) { - Console.ok("second rawBody() matches the original payload — defensive copy honored"); + var resp = client.utilities().user(); + String body = resp.json(); + if (body.equals(payload)) { + Console.ok("json() matches the original payload (" + body.length() + " chars)"); } else { - Console.fail("internal body state was mutated by the consumer"); + Console.fail("json() differs from the original payload"); } } @@ -107,7 +102,7 @@ private static void saveToFileWritesVerbatim(MockServerControl mock, MarketDataC String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); Path tmp = Files.createTempFile("sdk-consumer-", ".json"); try { resp.saveToFile(tmp); @@ -130,7 +125,7 @@ private static void toStringIsLogSafe(MockServerControl mock, MarketDataClient c String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; mock.script(Step.of(200, payload)); - Response resp = client.utilities().user(); + var resp = client.utilities().user(); String repr = resp.toString(); Console.info(repr); if (repr.contains("requestsRemaining")) { diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java index 5dd465e..d7c0a6c 100644 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java @@ -54,7 +54,7 @@ private static void retryRecovers503Then200(MockServerControl mock, MarketDataCl long elapsed = (System.nanoTime() - t0) / 1_000_000; Console.ok( "succeeded after retries; data.services()=" - + resp.data().services().size() + + resp.values().size() + ", wall-time=" + elapsed + " ms"); diff --git a/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java new file mode 100644 index 0000000..e08ed82 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/OptionsIntegrationTest.java @@ -0,0 +1,239 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeRange; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Integration tests for the {@code options} resource against the live Market Data API. Gated by the + * {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} environment variable in {@code build.gradle.kts}; a + * valid {@code MARKETDATA_TOKEN} is also required (the {@link MarketDataClient} constructor's + * startup validation will surface a clear error if missing). + * + *

Tests assert shape ("AAPL has at least one expiration", "every quote row + * carries finite greeks") rather than specific values, since the live data drifts daily. AAPL is + * used as the underlying everywhere — it is the largest options market by volume so the response is + * always non-empty during market hours and historical queries are well-populated outside them. + * + *

Status is asserted as {@code 200 || 203}: the API returns 203 Non-Authoritative + * Information when it serves cached/delayed data (outside market hours, or on a + * cached/delayed plan), which the SDK surfaces as a normal success. Pinning to 200 would make these + * tests flap with market hours. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class OptionsIntegrationTest { + + private static final String UNDERLYING = "AAPL"; + + private MarketDataClient client; + + @BeforeAll + void setUp() { + client = new MarketDataClient(); + } + + @AfterAll + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void lookupConvertsHumanDescriptionToOccSymbol() { + // A far-future date keeps the test stable against expiration drift — the endpoint converts + // the description regardless of whether such a contract actually exists today. + OptionsLookupResponse resp = + client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()) + .as("OCC symbol shape: 4-6 letter root + YYMMDD + C/P + 8-digit strike") + .matches("[A-Z]{1,6}\\d{6}[CP]\\d{8}"); + } + + @Test + void expirationsReturnsAtLeastOneFutureDate() { + OptionsExpirationsResponse resp = + client.options().expirations(OptionsExpirationsRequest.of(UNDERLYING)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).as("AAPL has options expirations year-round").isNotEmpty(); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void strikesReturnsStrikesPerExpiration() { + OptionsStrikesResponse resp = client.options().strikes(OptionsStrikesRequest.of(UNDERLYING)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).isNotEmpty(); + ExpirationStrikes first = resp.values().get(0); + assertThat(first.strikes()).as("first expiration's strike ladder is non-empty").isNotEmpty(); + assertThat(first.expiration().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void chainReturnsFilteredContracts() { + // Light filter: a narrow strike-limit window keeps the response small without depending on + // a specific dte that might fall on a non-trading day. + OptionsChainResponse resp = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(5) + .strikeRange(StrikeRange.ITM) + .build()); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).isNotEmpty(); + OptionQuote first = resp.values().get(0); + assertThat(first.optionSymbol()).startsWith(UNDERLYING); + assertThat(first.side()).isEqualTo("call"); + assertThat(first.strike()).isGreaterThan(0.0); + } + + @Test + void chainExpirationAllSpansMultipleExpirations() { + // expiration=all is the distinguishing case: omitting the filter returns only the front-month + // expiration, whereas all() returns the full chain. strikeLimit(1) keeps it to ~one contract + // per expiration so the payload stays small while still proving the span. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values(); + + long distinctExpirations = chain.stream().map(OptionQuote::expiration).distinct().count(); + assertThat(distinctExpirations) + .as("expiration=all returns every expiration, not just the front-month") + .isGreaterThan(1); + } + + @Test + void chainDecodesOptionalRhoColumn() { + // rho is an optional column: the live feed may or may not populate it. Assert the SDK decodes + // whatever comes back without error — every row's rho is either null (omitted) or a finite + // double, never a ParseError from a missing required column. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(3) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + + assertThat(chain).isNotEmpty(); + for (OptionQuote q : chain) { + Double rho = q.rho(); + if (rho != null) { + assertThat(rho.doubleValue()).isFinite(); + } + } + } + + @Test + void quoteCountbackBoundsHistoricalSeries() { + // countback=N with to caps the EOD series to at most N rows. A front-month ITM AAPL call has + // ample recent history, so we expect between 1 and 5 rows — the upper bound is the real + // assertion that countback reached the wire. + String optionSymbol = sampleOptionSymbol(); + + OptionsQuotesResponse resp = + client + .options() + .quote( + OptionsQuoteRequest.builder(optionSymbol).to(LocalDate.now()).countback(5).build()); + + assertThat(resp.values()) + .as("countback caps the series to at most 5 rows") + .hasSizeBetween(1, 5); + } + + @Test + void quoteFetchesSingleContract() { + // Derive a real option symbol from the chain so the quote endpoint has a live contract to + // resolve — lookup gives a well-formed symbol but doesn't guarantee one exists in the API's + // data set. + String optionSymbol = sampleOptionSymbol(); + + OptionsQuotesResponse resp = client.options().quote(OptionsQuoteRequest.of(optionSymbol)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).hasSize(1); + OptionQuote q = resp.values().get(0); + assertThat(q.optionSymbol()).isEqualTo(optionSymbol); + assertThat(q.underlying()).isEqualTo(UNDERLYING); + } + + @Test + void quotesFansOutToMultipleContracts() { + // Use the first two contracts from the chain so both are guaranteed to exist. + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(2) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + assertThat(chain).hasSizeGreaterThanOrEqualTo(2); + // optionSymbol is @Nullable on the row (every field is, to support columns projection); on a + // live chain it's always present, so assert that and keep the symbols non-null for the fan-out. + String first = java.util.Objects.requireNonNull(chain.get(0).optionSymbol()); + String second = java.util.Objects.requireNonNull(chain.get(1).optionSymbol()); + + Map resp = + client.options().quotes(OptionsQuotesRequest.builder(first, second).build()); + + assertThat(resp.keySet()).containsExactly(first, second); + assertThat(resp.get(first).values().get(0).optionSymbol()).isEqualTo(first); + assertThat(resp.get(second).values().get(0).optionSymbol()).isEqualTo(second); + } + + /** Fetch one real option symbol — used by {@link #quoteFetchesSingleContract()}. */ + private String sampleOptionSymbol() { + List chain = + client + .options() + .chain( + OptionsChainRequest.builder(UNDERLYING) + .side(OptionSide.CALL) + .strikeLimit(1) + .strikeRange(StrikeRange.ITM) + .build()) + .values(); + assertThat(chain).isNotEmpty(); + return chain.get(0).optionSymbol(); + } +} diff --git a/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java new file mode 100644 index 0000000..0e65a25 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java @@ -0,0 +1,109 @@ +package com.marketdata.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Shared implementation of {@link MarketDataResponse}: holds the flat payload plus the response + * metadata (raw body, format, status, request id, url) and implements every accessor. Named + * per-endpoint response types extend this with nothing more than their payload type binding and a + * package-private constructor, so consumers cannot instantiate them (ADR-007). + * + *

Lives in the root package so the resource façades (also root) can construct the subtypes via + * their package-private constructors, mirroring how the resource façades construct their responses. + * + * @param the flat payload type. + */ +abstract class AbstractMarketDataResponse implements MarketDataResponse { + + private final T values; + private final byte[] rawBody; + private final Format format; + private final int statusCode; + private final @Nullable String requestId; + private final URI requestUrl; + + AbstractMarketDataResponse(T values, HttpResponseEnvelope envelope, Format format) { + this.values = Objects.requireNonNull(values, "values"); + this.rawBody = Objects.requireNonNull(envelope, "envelope").body().clone(); + this.format = Objects.requireNonNull(format, "format"); + this.statusCode = envelope.statusCode(); + this.requestId = envelope.requestId(); + this.requestUrl = envelope.url(); + } + + @Override + public T values() { + return values; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public boolean isNoData() { + return statusCode == 404; + } + + @Override + public @Nullable String requestId() { + return requestId; + } + + @Override + public URI requestUrl() { + return requestUrl; + } + + @Override + public String json() { + return new String(rawBody, StandardCharsets.UTF_8); + } + + @Override + public boolean isJson() { + return format == Format.JSON; + } + + @Override + public boolean isCsv() { + return format == Format.CSV; + } + + @Override + public boolean isHtml() { + return format == Format.HTML; + } + + @Override + public void saveToFile(Path path) { + try { + Files.write(path, rawBody); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write response body to " + path, e); + } + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "[status=" + + statusCode + + ", format=" + + format.name().toLowerCase(Locale.ROOT) + + ", bytes=" + + rawBody.length + + ", url=" + + HttpDispatcher.safeUri(requestUrl) + + "]"; + } +} diff --git a/src/main/java/com/marketdata/sdk/CsvResponse.java b/src/main/java/com/marketdata/sdk/CsvResponse.java new file mode 100644 index 0000000..51dba5b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/CsvResponse.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk; + +/** + * Response from a CSV facet (e.g. {@code client.options().asCsv().chain(req)}): {@link #values()} + * is the raw CSV text. Shared across every endpoint that supports CSV — the body is opaque text + * with no per-endpoint structure. Distinct from {@link HtmlResponse} so the two never cross-assign. + */ +public final class CsvResponse extends AbstractMarketDataResponse { + + CsvResponse(String csv, HttpResponseEnvelope envelope, Format format) { + super(csv, envelope, format); + } + + /** The raw CSV text (same as {@link #values()}; named for readability at call sites). */ + public String csv() { + return values(); + } +} diff --git a/src/main/java/com/marketdata/sdk/HtmlResponse.java b/src/main/java/com/marketdata/sdk/HtmlResponse.java new file mode 100644 index 0000000..268b734 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/HtmlResponse.java @@ -0,0 +1,20 @@ +package com.marketdata.sdk; + +/** + * Response from an HTML facet: {@link #values()} is the raw HTML text. The type and the underlying + * facet plumbing exist so enabling HTML is a trivial release once the backend supports {@code + * format=html}; the {@code asHtml()} entry point is intentionally not exposed on the + * resource today (the server returns no HTML for any data endpoint). Distinct from {@link + * CsvResponse} so the two never cross-assign. + */ +public final class HtmlResponse extends AbstractMarketDataResponse { + + HtmlResponse(String html, HttpResponseEnvelope envelope, Format format) { + super(html, envelope, format); + } + + /** The raw HTML text (same as {@link #values()}). */ + public String html() { + return values(); + } +} diff --git a/src/main/java/com/marketdata/sdk/JsonResponseParser.java b/src/main/java/com/marketdata/sdk/JsonResponseParser.java index 76bc8ed..06ec954 100644 --- a/src/main/java/com/marketdata/sdk/JsonResponseParser.java +++ b/src/main/java/com/marketdata/sdk/JsonResponseParser.java @@ -6,6 +6,7 @@ import com.marketdata.sdk.exception.ParseError; import java.io.IOException; import java.time.Clock; +import java.util.List; /** * Decodes {@link HttpResponseEnvelope} bodies into typed records. @@ -52,7 +53,20 @@ void registerModule(Module module) { * cannot read the body — the error context carries the envelope's url, status, and request id for * the consumer's diagnostics. */ + /** Attribute key under which the requested {@code columns} (§3) travel into deserializers. */ + static final String REQUESTED_COLUMNS_ATTR = "marketdata.requestedColumns"; + T parse(HttpResponseEnvelope env, Class type) { + return parse(env, type, List.of()); + } + + /** + * Decode like {@link #parse(HttpResponseEnvelope, Class)} but additionally make the consumer's + * requested {@code columns} available to deserializers (via a Jackson context attribute) so they + * can enforce Option A: a required column that was requested but the API omitted surfaces as a + * {@link ParseError}, never a silent null. {@code requestedColumns} empty means "all columns". + */ + T parse(HttpResponseEnvelope env, Class type, List requestedColumns) { // Issue #29: a zero-length body surfaces from Jackson as a generic "No content to map" // MismatchedInputException — diagnostically thin, often confusing in the presence of a // body-stripping proxy. Pre-check so the failure carries a precise, actionable message that @@ -69,7 +83,10 @@ T parse(HttpResponseEnvelope env, Class type) { context); } try { - return mapper.readValue(env.body(), type); + return mapper + .readerFor(type) + .withAttribute(REQUESTED_COLUMNS_ATTR, requestedColumns) + .readValue(env.body()); } catch (IOException e) { ErrorContext context = ErrorContext.forResponse( diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 986c7c4..83b0be8 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -1,5 +1,6 @@ package com.marketdata.sdk; +import com.marketdata.sdk.utilities.ApiStatus; import java.nio.file.Path; import java.time.Clock; import java.util.ArrayList; @@ -20,6 +21,7 @@ public final class MarketDataClient implements AutoCloseable { private final Configuration config; private final HttpTransport transport; private final UtilitiesResource utilities; + private final OptionsResource options; public MarketDataClient() { this(null, null, null, true); @@ -105,9 +107,11 @@ public MarketDataClient( try { JsonResponseParser parser = new JsonResponseParser(); this.utilities = new UtilitiesResource(transport, parser); + this.options = new OptionsResource(transport, parser); cacheRef.set( new StatusCache( - () -> utilities.statusAsync().thenApply(Response::data), Clock.systemUTC())); + () -> utilities.statusAsync().thenApply(r -> new ApiStatus(r.values())), + Clock.systemUTC())); } catch (Throwable t) { try { transport.close(); @@ -142,6 +146,14 @@ public UtilitiesResource utilities() { return utilities; } + /** + * Options endpoints: {@code lookup}, {@code expirations}, {@code strikes}, {@code quotes}, {@code + * chain}. + */ + public OptionsResource options() { + return options; + } + /** * Fire a single call to {@code GET /user/} to confirm the token is accepted and a billing plan is * attached (SDK requirements §5). A 401 surfaces as {@link diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java index 8627eb5..5ecd9de 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataDates.java +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -1,8 +1,15 @@ package com.marketdata.sdk; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; /** * Conventions for date/time fields surfaced to SDK consumers (§13.4). @@ -34,4 +41,88 @@ private MarketDataDates() {} static ZonedDateTime marketTimeFromEpochSecond(long epochSecond) { return Instant.ofEpochSecond(epochSecond).atZone(MARKET_ZONE); } + + // Wire-format helpers for the three values of §3's universal `dateformat` parameter + // (`unix`, `timestamp`, `spreadsheet`). The deserializer cannot ask the request "which one did + // we send?", so it detects by JSON node shape. The numeric ranges are far enough apart that a + // single threshold disambiguates safely: + // - spreadsheet serials for years 1900–2100 fit in roughly [1, 73000]; + // - unix epochs for the same period are in [≈ -2.2e9, ≈ 4.1e9]. + // A threshold at 1_000_000 (~year 1970 + 11 days as a serial, ~Jan 12 1970 as an epoch) has no + // realistic collision and we never need to refine it. + private static final long UNIX_VS_SPREADSHEET_THRESHOLD = 1_000_000L; + private static final LocalDate SPREADSHEET_EPOCH = LocalDate.of(1899, 12, 30); + + /** + * Pattern for the timestamp-format datetime emitted by the API: {@code "2025-01-17 16:00:00 + * -05:00"} — space-separated, offset with explicit colon. Matches {@code + * common/util/date_helper.py:format_date} in the backend. + */ + private static final DateTimeFormatter ZONED_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX"); + + /** + * Parse a date-field cell ({@code timestamp}, {@code unix}, or {@code spreadsheet}) to a calendar + * {@link LocalDate}. {@code timestamp} strings are date-only ({@code "yyyy-MM-dd"}); numeric + * values are either epoch seconds (interpreted in {@link #MARKET_ZONE}) or Excel serials + * (whole-day part of days since 1899-12-30). + */ + static LocalDate parseDateField( + @org.jspecify.annotations.Nullable JsonParser p, JsonNode node, String fieldName) + throws JsonMappingException { + if (node == null || node.isNull()) { + throw new JsonMappingException(p, "missing field: " + fieldName); + } + if (node.isTextual()) { + try { + return LocalDate.parse(node.asText()); + } catch (DateTimeParseException e) { + throw new JsonMappingException( + p, "non-ISO date string for field " + fieldName + ": " + node.asText()); + } + } + if (!node.isNumber()) { + throw new JsonMappingException(p, "non-string, non-numeric date field: " + fieldName); + } + long whole = node.asLong(); + if (Math.abs(whole) >= UNIX_VS_SPREADSHEET_THRESHOLD) { + return marketTimeFromEpochSecond(whole).toLocalDate(); + } + return SPREADSHEET_EPOCH.plusDays(whole); + } + + /** + * Parse a timestamp-field cell ({@code timestamp}, {@code unix}, or {@code spreadsheet}) to a + * {@link ZonedDateTime} in {@link #MARKET_ZONE}. {@code timestamp} strings include time-of-day + * and offset ({@code "yyyy-MM-dd HH:mm:ss XXX"}); numeric values are epoch seconds or fractional + * Excel serials. + */ + static ZonedDateTime parseTimestampField( + @org.jspecify.annotations.Nullable JsonParser p, JsonNode node, String fieldName) + throws JsonMappingException { + if (node == null || node.isNull()) { + throw new JsonMappingException(p, "missing field: " + fieldName); + } + if (node.isTextual()) { + try { + return ZonedDateTime.parse(node.asText(), ZONED_TIMESTAMP_FORMAT) + .withZoneSameInstant(MARKET_ZONE); + } catch (DateTimeParseException e) { + throw new JsonMappingException( + p, "non-conforming timestamp string for field " + fieldName + ": " + node.asText()); + } + } + if (!node.isNumber()) { + throw new JsonMappingException(p, "non-string, non-numeric timestamp field: " + fieldName); + } + double v = node.asDouble(); + if (Math.abs(v) >= UNIX_VS_SPREADSHEET_THRESHOLD) { + return marketTimeFromEpochSecond((long) v); + } + // Spreadsheet serial: fractional days since 1899-12-30 UTC (the backend constructs it from a + // UTC datetime, so the epoch reference is UTC, not Eastern). + long millis = Math.round(v * 86_400_000d); + Instant base = SPREADSHEET_EPOCH.atStartOfDay(ZoneOffset.UTC).toInstant(); + return base.plusMillis(millis).atZone(MARKET_ZONE); + } } diff --git a/src/main/java/com/marketdata/sdk/MarketDataResponse.java b/src/main/java/com/marketdata/sdk/MarketDataResponse.java new file mode 100644 index 0000000..53323db --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketDataResponse.java @@ -0,0 +1,55 @@ +package com.marketdata.sdk; + +import java.net.URI; +import java.nio.file.Path; +import org.jspecify.annotations.Nullable; + +/** + * Uniform surface every endpoint response implements. {@code T} is the flat payload the + * endpoint produces — a {@code List} for tabular endpoints, a {@code String} for the scalar + * {@code options.lookup}, and so on — reached through the single {@link #values()} accessor. + * + *

The point is that a consumer learns one shape and reuses it across every resource: {@code + * values()} for the data (typed per endpoint), and the same metadata accessors everywhere. Concrete + * responses are named per endpoint (e.g. {@code OptionsChainResponse implements + * MarketDataResponse>}) so signatures read well and the compiler enforces the + * accessor. + * + * @param the flat payload type for this endpoint. + */ +public interface MarketDataResponse { + + /** The typed payload — {@code List<...>}, a scalar, etc. Never {@code null}. */ + T values(); + + /** HTTP status code (one of 200, 203, 404). */ + int statusCode(); + + /** + * Whether the API signalled {@code {"s":"no_data"}} (HTTP 404 with that envelope) — a successful + * "nothing for that query", distinct from an error. + */ + boolean isNoData(); + + /** Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when absent. */ + @Nullable String requestId(); + + /** Absolute URL the response came from. */ + URI requestUrl(); + + /** The raw response body as text (the original payload the API returned). */ + String json(); + + boolean isJson(); + + boolean isCsv(); + + /** + * Whether the body is HTML — typically a misrouted request that hit the web tier (marketing/error + * page) rather than the API tier. + */ + boolean isHtml(); + + /** Write the raw body verbatim to {@code path}, creating or overwriting it. */ + void saveToFile(Path path); +} diff --git a/src/main/java/com/marketdata/sdk/OptionsChainResponse.java b/src/main/java/com/marketdata/sdk/OptionsChainResponse.java new file mode 100644 index 0000000..da42941 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsChainResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionQuote; +import java.util.List; + +/** + * Response for {@code options.chain}: {@link #values()} is the chain's option rows. Construct only + * through the resource façade (package-private constructor, ADR-007). + */ +public final class OptionsChainResponse extends AbstractMarketDataResponse> { + + OptionsChainResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsCsvResource.java b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java new file mode 100644 index 0000000..e0d0f33 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsCsvResource.java @@ -0,0 +1,144 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * CSV facet of {@code options} — reached through {@code client.options().asCsv()}. Every endpoint + * here returns a {@link CsvResponse} (opaque CSV text). {@code lookup} is intentionally absent: it + * is a scalar with no CSV representation (resource-architecture §2.4). + * + *

Carries the universal-param config from the typed resource and additionally exposes the + * output-shaping {@code columns}/{@code human}/{@code headers} params, which only cohere with CSV. + */ +public final class OptionsCsvResource { + + private final HttpTransport transport; + private final RequestConfig config; + + OptionsCsvResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + // ---------- universal + output-shaping params ---------- + + public OptionsCsvResource dateFormat(DateFormat v) { + return new OptionsCsvResource(transport, config.withDateFormat(v)); + } + + public OptionsCsvResource mode(Mode v) { + return new OptionsCsvResource(transport, config.withMode(v)); + } + + public OptionsCsvResource limit(int v) { + return new OptionsCsvResource(transport, config.withLimit(v)); + } + + public OptionsCsvResource offset(int v) { + return new OptionsCsvResource(transport, config.withOffset(v)); + } + + public OptionsCsvResource columns(String... v) { + return new OptionsCsvResource(transport, config.withColumns(List.of(v))); + } + + public OptionsCsvResource human(boolean v) { + return new OptionsCsvResource(transport, config.withHuman(v)); + } + + public OptionsCsvResource headers(boolean v) { + return new OptionsCsvResource(transport, config.withHeaders(v)); + } + + // ---------- endpoints ---------- + + public CompletableFuture chainAsync(OptionsChainRequest request) { + return executeCsv(OptionsResource.chainSpec(request)); + } + + public CsvResponse chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + return executeCsv( + OptionsResource.quoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback())); + } + + public CsvResponse quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + /** Fan-out: one CSV per symbol, mirroring the typed map (resource-architecture §2.5). */ + public CompletableFuture> quotesAsync(OptionsQuotesRequest request) { + List symbols = request.optionSymbols(); + List>> futures = + new ArrayList<>(symbols.size()); + for (String symbol : symbols) { + futures.add( + executeCsv( + OptionsResource.quoteSpec( + symbol, request.date(), request.from(), request.to(), request.countback())) + .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; + }); + } + + public Map quotes(OptionsQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + return executeCsv(OptionsResource.strikesSpec(request)); + } + + public CsvResponse strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + public CompletableFuture expirationsAsync(OptionsExpirationsRequest request) { + return executeCsv(OptionsResource.expirationsSpec(request)); + } + + public CsvResponse expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + // ---------- execute ---------- + + private CompletableFuture executeCsv(RequestSpec.Builder b) { + config.applyTo(b); + b.format(Format.CSV); + RequestSpec spec = b.build(); + return transport + .executeAsync(spec) + .thenApply( + env -> + new CsvResponse( + new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java new file mode 100644 index 0000000..4ea1b0c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java @@ -0,0 +1,73 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.OptionsExpirations; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Wire-format deserializer for {@link OptionsExpirations}. The endpoint's body mixes a parallel + * array ({@code expirations}) with a scalar metadata field ({@code updated}) at the top level, + * which is the shape {@link ParallelArrays#listDeserializer} cannot express (the wrapper there only + * receives the row list). Envelope handling mirrors {@link ParallelArrays}: + * + *

    + *
  • {@code "s":"error"} → {@link JsonMappingException} carrying {@code errmsg}. + *
  • {@code "s":"no_data"} → empty list, {@code updated} left null — the API omits the data + * fields in that envelope. + *
  • otherwise → strict field validation; missing or wrong-typed {@code expirations} / {@code + * updated} raises {@link JsonMappingException}. + *
+ * + *

Per §3 the deserializer accepts whichever {@code dateformat} the consumer asked the API for — + * {@code unix}, {@code timestamp}, or {@code spreadsheet} — and converts to native types through + * {@link MarketDataDates#parseDateField} / {@link MarketDataDates#parseTimestampField}. That way + * the typed {@link OptionsExpirations#data} surface is uniform regardless of wire format. + */ +final class OptionsExpirationsDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String EXPIRATIONS_KEY = "expirations"; + private static final String UPDATED_KEY = "updated"; + + @Override + public OptionsExpirations deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + JsonNode root = p.readValueAsTree(); + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if (ENVELOPE_ERROR.equals(envelopeStatus)) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return new OptionsExpirations(List.of(), null); + } + + JsonNode expsNode = root.get(EXPIRATIONS_KEY); + if (expsNode == null || !expsNode.isArray()) { + throw new JsonMappingException(p, "missing or non-array field: " + EXPIRATIONS_KEY); + } + List dates = new ArrayList<>(expsNode.size()); + for (int i = 0; i < expsNode.size(); i++) { + // Expirations are calendar dates on the wire; lift each to a midnight market-zone moment so + // the consumer always sees a ZonedDateTime — the SDK's canonical market-timestamp type. + LocalDate cellDate = + MarketDataDates.parseDateField(p, expsNode.get(i), EXPIRATIONS_KEY + "[" + i + "]"); + dates.add(cellDate.atStartOfDay(MarketDataDates.MARKET_ZONE)); + } + + ZonedDateTime updated = + MarketDataDates.parseTimestampField(p, root.get(UPDATED_KEY), UPDATED_KEY); + return new OptionsExpirations(dates, updated); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java b/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java new file mode 100644 index 0000000..8f12274 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsExpirationsResponse.java @@ -0,0 +1,30 @@ +package com.marketdata.sdk; + +import java.time.ZonedDateTime; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Response for {@code options.expirations}: {@link #values()} is the available expiration dates. + * The endpoint also carries an {@code updated} timestamp, exposed via {@link #updated()} ({@code + * null} on the {@code no_data} envelope). Construct only through the resource façade. + */ +public final class OptionsExpirationsResponse + extends AbstractMarketDataResponse> { + + private final @Nullable ZonedDateTime updated; + + OptionsExpirationsResponse( + List values, + @Nullable ZonedDateTime updated, + HttpResponseEnvelope envelope, + Format format) { + super(values, envelope, format); + this.updated = updated; + } + + /** When the expiration list was last refreshed, or {@code null} when the API omitted it. */ + public @Nullable ZonedDateTime updated() { + return updated; + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java b/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java new file mode 100644 index 0000000..f68aff5 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsHtmlResource.java @@ -0,0 +1,76 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +/** + * HTML facet of {@code options}. Mirrors {@link OptionsCsvResource} but returns {@link + * HtmlResponse} and requests {@code format=html}. Not exposed to consumers — the + * backend serves no HTML for data endpoints today, so the {@code asHtml()} entry point on {@link + * OptionsResource} is package-private. Kept built and ready so enabling HTML later is a one-line + * change (flip the entry point to {@code public}). + */ +public final class OptionsHtmlResource { + + private final HttpTransport transport; + private final RequestConfig config; + + OptionsHtmlResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + public CompletableFuture chainAsync(OptionsChainRequest request) { + return executeHtml(OptionsResource.chainSpec(request)); + } + + public HtmlResponse chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + return executeHtml( + OptionsResource.quoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback())); + } + + public HtmlResponse quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + return executeHtml(OptionsResource.strikesSpec(request)); + } + + public HtmlResponse strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + public CompletableFuture expirationsAsync(OptionsExpirationsRequest request) { + return executeHtml(OptionsResource.expirationsSpec(request)); + } + + public HtmlResponse expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + private CompletableFuture executeHtml(RequestSpec.Builder b) { + config.applyTo(b); + b.format(Format.HTML); + RequestSpec spec = b.build(); + return transport + .executeAsync(spec) + .thenApply( + env -> + new HtmlResponse( + new String(env.body(), StandardCharsets.UTF_8), env, spec.format())); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java new file mode 100644 index 0000000..9b909cb --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java @@ -0,0 +1,43 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.OptionsLookup; +import java.io.IOException; + +/** + * Wire-format deserializer for {@link OptionsLookup}. The {@code /options/lookup/} endpoint is the + * one options endpoint that does not use the parallel-arrays envelope — the body is a flat object + * ({@code {"s":"ok","optionSymbol":"AAPL250117C00150000"}}). Envelope handling mirrors {@link + * ParallelArrays} so consumers see consistent behavior across resources: + * + *

    + *
  • {@code "s":"error"} → {@link JsonMappingException} carrying the server's {@code errmsg}. + *
  • missing or non-textual {@code optionSymbol} → {@link JsonMappingException} (strict by + * default, same reasoning as {@link UserDeserializer}). + *
+ */ +final class OptionsLookupDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String OPTION_SYMBOL_KEY = "optionSymbol"; + + @Override + public OptionsLookup deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + if (ENVELOPE_ERROR.equals(root.path(ENVELOPE_STATUS).asText(""))) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + JsonNode node = root.get(OPTION_SYMBOL_KEY); + if (node == null || !node.isTextual()) { + throw new JsonMappingException(p, "missing or non-string field: " + OPTION_SYMBOL_KEY); + } + return new OptionsLookup(node.asText()); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java b/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java new file mode 100644 index 0000000..15a522d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsLookupResponse.java @@ -0,0 +1,12 @@ +package com.marketdata.sdk; + +/** + * Response for {@code options.lookup}: {@link #values()} is the resolved OCC option symbol (a + * scalar). Construct only through the resource façade. + */ +public final class OptionsLookupResponse extends AbstractMarketDataResponse { + + OptionsLookupResponse(String values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java b/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java new file mode 100644 index 0000000..8c37a0f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsQuotesResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.OptionQuote; +import java.util.List; + +/** + * Response for {@code options.quote} (and each per-symbol entry of {@code options.quotes}): {@link + * #values()} is the option-quote rows. Construct only through the resource façade. + */ +public final class OptionsQuotesResponse extends AbstractMarketDataResponse> { + + OptionsQuotesResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsResource.java b/src/main/java/com/marketdata/sdk/OptionsResource.java new file mode 100644 index 0000000..fcf7282 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsResource.java @@ -0,0 +1,576 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionsChain; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirations; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookup; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotes; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikes; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +/** + * Options endpoints ({@code /v1/options/...}). Reached through {@code client.options()}. + * + *

The resource is an immutable configured value (ADR / resource-architecture §1.3): the + * universal-parameter setters ({@link #dateFormat}, {@link #mode}, {@link #limit}, {@link #offset}, + * {@link #columns}) each return a configured copy, so "configure once, call many" works and the + * config carries into the {@link #asCsv()} facet. Every endpoint returns a named {@link + * MarketDataResponse} whose {@link MarketDataResponse#values()} is the flat payload. + * + *

Constructor is package-private (ADR-007) — consumers cannot instantiate. + */ +public final class OptionsResource { + + private final HttpTransport transport; + private final JsonResponseParser parser; + private final RequestConfig config; + + /** Client-facing constructor: registers the wire-format module once, starts with empty config. */ + OptionsResource(HttpTransport transport, JsonResponseParser parser) { + this(transport, parser, RequestConfig.empty()); + parser.registerModule(wireFormatModule()); + } + + private OptionsResource( + HttpTransport transport, JsonResponseParser parser, RequestConfig config) { + this.transport = transport; + this.parser = parser; + this.config = config; + } + + // ---------- universal parameters (type-preserving + columns) ---------- + + /** Returns a copy that requests {@code dateformat} on every subsequent call. */ + public OptionsResource dateFormat(DateFormat dateFormat) { + return new OptionsResource(transport, parser, config.withDateFormat(dateFormat)); + } + + /** + * Returns a copy with the data-freshness {@code mode} (cached honored only by quote endpoints). + */ + public OptionsResource mode(Mode mode) { + return new OptionsResource(transport, parser, config.withMode(mode)); + } + + /** Returns a copy with the pagination {@code limit}. */ + public OptionsResource limit(int limit) { + return new OptionsResource(transport, parser, config.withLimit(limit)); + } + + /** Returns a copy with the pagination {@code offset}. */ + public OptionsResource offset(int offset) { + return new OptionsResource(transport, parser, config.withOffset(offset)); + } + + /** + * Returns a copy that projects the response to the given columns (wire field names). Fields not + * requested decode to {@code null}; a requested column the API fails to return surfaces as a + * {@link com.marketdata.sdk.exception.ParseError} rather than a silent null. + */ + public OptionsResource columns(String... columns) { + return new OptionsResource(transport, parser, config.withColumns(List.of(columns))); + } + + // ---------- format facet ---------- + + /** A CSV-flavored view of this resource (carrying the same universal-param config). */ + public OptionsCsvResource asCsv() { + return new OptionsCsvResource(transport, config); + } + + /** + * HTML facet — built but not exposed to consumers (the backend returns no HTML for any data + * endpoint today). Package-private so it can be exercised by tests; flip to {@code public} when + * the server supports {@code format=html}. + */ + OptionsHtmlResource asHtml() { + return new OptionsHtmlResource(transport, config); + } + + // ---------- endpoints (typed) ---------- + + /** Async: resolve a human-readable option description into an OCC symbol. */ + public CompletableFuture lookupAsync(OptionsLookupRequest request) { + // lookup carries no universal params (no GLOBAL_PARAMS on the backend). + RequestSpec spec = lookupSpec(request).build(); + return execute( + spec, + OptionsLookup.class, + (d, env, fmt) -> new OptionsLookupResponse(d.optionSymbol(), env, fmt)); + } + + /** Sync wrapper for {@link #lookupAsync(OptionsLookupRequest)}. */ + public OptionsLookupResponse lookup(OptionsLookupRequest request) { + return transport.joinSync(lookupAsync(request)); + } + + /** Async: fetch the available option-expiration dates for the request's underlying. */ + public CompletableFuture expirationsAsync( + OptionsExpirationsRequest request) { + RequestSpec.Builder b = expirationsSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsExpirations.class, + (d, env, fmt) -> new OptionsExpirationsResponse(d.expirations(), d.updated(), env, fmt)); + } + + /** Sync wrapper for {@link #expirationsAsync(OptionsExpirationsRequest)}. */ + public OptionsExpirationsResponse expirations(OptionsExpirationsRequest request) { + return transport.joinSync(expirationsAsync(request)); + } + + /** Async: fetch the strike prices available for each expiration on the request's underlying. */ + public CompletableFuture strikesAsync(OptionsStrikesRequest request) { + RequestSpec.Builder b = strikesSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsStrikes.class, + (d, env, fmt) -> new OptionsStrikesResponse(d.expirations(), d.updated(), env, fmt)); + } + + /** Sync wrapper for {@link #strikesAsync(OptionsStrikesRequest)}. */ + public OptionsStrikesResponse strikes(OptionsStrikesRequest request) { + return transport.joinSync(strikesAsync(request)); + } + + /** Async: fetch the current (or historical) quote for a single OCC option symbol. */ + public CompletableFuture quoteAsync(OptionsQuoteRequest request) { + RequestSpec.Builder b = + quoteSpec( + request.optionSymbol(), + request.date(), + request.from(), + request.to(), + request.countback()); + config.applyTo(b); + return execute( + b.build(), + OptionsQuotes.class, + (d, env, fmt) -> new OptionsQuotesResponse(d.quotes(), env, fmt)); + } + + /** Sync wrapper for {@link #quoteAsync(OptionsQuoteRequest)}. */ + public OptionsQuotesResponse quote(OptionsQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + /** + * Async: fetch quotes for multiple OCC option symbols concurrently (one request per symbol — the + * backend path is single-symbol). Returns a per-symbol map (insertion order preserved); the + * future completes exceptionally if any single request fails (fail-fast). + */ + public CompletableFuture> quotesAsync( + OptionsQuotesRequest request) { + List symbols = request.optionSymbols(); + List>> futures = + new ArrayList<>(symbols.size()); + for (String symbol : symbols) { + RequestSpec.Builder b = + quoteSpec(symbol, request.date(), request.from(), request.to(), request.countback()); + config.applyTo(b); + futures.add( + execute( + b.build(), + OptionsQuotes.class, + (d, env, fmt) -> new OptionsQuotesResponse(d.quotes(), env, fmt)) + .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; + }); + } + + /** Sync wrapper for {@link #quotesAsync(OptionsQuotesRequest)}. */ + public Map quotes(OptionsQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + /** Async: fetch the full option chain for the request's underlying. */ + public CompletableFuture chainAsync(OptionsChainRequest request) { + RequestSpec.Builder b = chainSpec(request); + config.applyTo(b); + return execute( + b.build(), + OptionsChain.class, + (d, env, fmt) -> new OptionsChainResponse(d.chain(), env, fmt)); + } + + /** Sync wrapper for {@link #chainAsync(OptionsChainRequest)}. */ + public OptionsChainResponse chain(OptionsChainRequest request) { + return transport.joinSync(chainAsync(request)); + } + + // ---------- execute ---------- + + private CompletableFuture execute( + RequestSpec spec, Class decodeType, ResponseFactory factory) { + return transport + .executeAsync(spec) + .thenApply( + env -> + factory.create( + parser.parse(env, decodeType, config.columns()), env, spec.format())); + } + + @FunctionalInterface + interface ResponseFactory { + R create(D decoded, HttpResponseEnvelope envelope, Format format); + } + + // ---------- request spec builders (package-private static — reused by the facets) ---------- + + static RequestSpec.Builder lookupSpec(OptionsLookupRequest request) { + return RequestSpec.get("options/lookup/" + PathSegments.encode(request.userInput())); + } + + static RequestSpec.Builder expirationsSpec(OptionsExpirationsRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/expirations/" + PathSegments.encode(request.symbol())); + if (request.strike() != null) { + b.query("strike", request.strike()); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return b; + } + + static RequestSpec.Builder strikesSpec(OptionsStrikesRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/strikes/" + PathSegments.encode(request.symbol())); + if (request.expiration() != null) { + b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(request.expiration())); + } + if (request.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(request.date())); + } + return b; + } + + static RequestSpec.Builder quoteSpec( + String optionSymbol, + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to, + @Nullable Integer countback) { + RequestSpec.Builder b = RequestSpec.get("options/quotes/" + PathSegments.encode(optionSymbol)); + if (date != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(date)); + } + if (from != null) { + b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(from)); + } + if (to != null) { + b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(to)); + } + if (countback != null) { + b.query("countback", countback); + } + return b; + } + + static RequestSpec.Builder chainSpec(OptionsChainRequest request) { + RequestSpec.Builder b = + RequestSpec.get("options/chain/" + PathSegments.encode(request.symbol())); + applyChainParams(b, request); + return b; + } + + private static void applyChainParams(RequestSpec.Builder b, OptionsChainRequest r) { + if (r.expirationFilter() != null) { + applyExpirationFilter(b, r.expirationFilter()); + } + if (r.weekly() != null) { + b.query("weekly", r.weekly()); + } + if (r.monthly() != null) { + b.query("monthly", r.monthly()); + } + if (r.quarterly() != null) { + b.query("quarterly", r.quarterly()); + } + if (r.am() != null) { + b.query("am", r.am()); + } + if (r.pm() != null) { + b.query("pm", r.pm()); + } + if (r.nonstandard() != null) { + b.query("nonstandard", r.nonstandard()); + } + if (r.strikeFilter() != null) { + b.query("strike", strikeFilterWireValue(r.strikeFilter())); + } + if (r.delta() != null) { + b.query("delta", r.delta()); + } + if (r.strikeLimit() != null) { + b.query("strikeLimit", r.strikeLimit()); + } + if (r.strikeRange() != null) { + b.query("range", r.strikeRange().wireValue()); + } + if (r.minBid() != null) { + b.query("minBid", r.minBid()); + } + if (r.maxBid() != null) { + b.query("maxBid", r.maxBid()); + } + if (r.minAsk() != null) { + b.query("minAsk", r.minAsk()); + } + if (r.maxAsk() != null) { + b.query("maxAsk", r.maxAsk()); + } + if (r.maxBidAskSpread() != null) { + b.query("maxBidAskSpread", r.maxBidAskSpread()); + } + if (r.maxBidAskSpreadPct() != null) { + b.query("maxBidAskSpreadPct", r.maxBidAskSpreadPct()); + } + if (r.minOpenInterest() != null) { + b.query("minOpenInterest", r.minOpenInterest()); + } + if (r.minVolume() != null) { + b.query("minVolume", r.minVolume()); + } + if (r.side() != null) { + b.query("side", r.side().wireValue()); + } + if (r.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(r.date())); + } + } + + private static void applyExpirationFilter(RequestSpec.Builder b, ExpirationFilter f) { + if (f instanceof ExpirationFilter.OnDate v) { + b.query("expiration", DateTimeFormatter.ISO_LOCAL_DATE.format(v.date())); + } else if (f instanceof ExpirationFilter.Dte v) { + b.query("dte", v.days()); + } 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())); + } else if (f instanceof ExpirationFilter.MonthYear v) { + b.query("month", v.month()); + b.query("year", v.year()); + } else if (f instanceof ExpirationFilter.All) { + b.query("expiration", "all"); + } else { + throw new IllegalStateException("unhandled ExpirationFilter variant: " + f); + } + } + + private static String strikeFilterWireValue(StrikeFilter f) { + if (f instanceof StrikeFilter.Exact v) { + return formatStrike(v.price()); + } else if (f instanceof StrikeFilter.Range v) { + return formatStrike(v.min()) + "-" + formatStrike(v.max()); + } else if (f instanceof StrikeFilter.Comparison v) { + return v.operator().wireValue() + formatStrike(v.price()); + } + throw new IllegalStateException("unhandled StrikeFilter variant: " + f); + } + + private static String formatStrike(double v) { + if (v == Math.floor(v) && !Double.isInfinite(v)) { + return Long.toString((long) v); + } + return Double.toString(v); + } + + // ---------- wire-format module ---------- + + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-options"); + m.addDeserializer(OptionsLookup.class, new OptionsLookupDeserializer()); + m.addDeserializer(OptionsExpirations.class, new OptionsExpirationsDeserializer()); + m.addDeserializer(OptionsStrikes.class, new OptionsStrikesDeserializer()); + m.addDeserializer(OptionsQuotes.class, optionRowsDeserializer(OptionsQuotes::new)); + m.addDeserializer(OptionsChain.class, optionRowsDeserializer(OptionsChain::new)); + return m; + } + + /** + * Structural columns that must be present whenever they're requested. A {@code columns} + * projection may legitimately drop any of these (→ {@code null}); but if the consumer asked for + * one (or used no {@code columns} filter at all) and the API omitted it, that's an anomaly, not a + * projection — Option A turns it into a {@link com.marketdata.sdk.exception.ParseError}. The + * model-derived values ({@code iv} + the greeks) are excluded: they may legitimately be null even + * when present. + */ + private static final List OPTION_REQUIRED_FIELDS = + List.of( + "optionSymbol", + "underlying", + "expiration", + "side", + "strike", + "firstTraded", + "dte", + "updated", + "bid", + "bidSize", + "mid", + "ask", + "askSize", + "last", + "openInterest", + "volume", + "inTheMoney", + "intrinsicValue", + "extrinsicValue", + "underlyingPrice"); + + /** Every column on the option row — all optional at the wire level (projection-friendly). */ + private static final List OPTION_ALL_FIELDS = + List.of( + "optionSymbol", + "underlying", + "expiration", + "side", + "strike", + "firstTraded", + "dte", + "updated", + "bid", + "bidSize", + "mid", + "ask", + "askSize", + "last", + "openInterest", + "volume", + "inTheMoney", + "intrinsicValue", + "extrinsicValue", + "underlyingPrice", + "iv", + "delta", + "gamma", + "theta", + "vega", + "rho"); + + /** + * Deserializer for the option-quote rows. Every column is optional at the wire level so a {@code + * columns} projection decodes cleanly to nulls; the strict guarantee is restored by {@link + * #validateRequestedColumns} (Option A), which fails loudly if a requested required + * column was omitted. + */ + private static JsonDeserializer optionRowsDeserializer( + Function, T> wrapper) { + return new JsonDeserializer<>() { + @Override + public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + List rows = + ParallelArrays.zip( + p, root, List.of(), OPTION_ALL_FIELDS, OptionsResource::buildOptionRow); + validateRequestedColumns(p, root, rows, ctxt); + return wrapper.apply(rows); + } + }; + } + + /** Builds one {@link OptionQuote} from a row, reading every column leniently (absent → null). */ + private static OptionQuote buildOptionRow(ParallelArrays.Row row) throws IOException { + return new OptionQuote( + row.textOrNull("optionSymbol"), + row.textOrNull("underlying"), + dateOrNull(row, "expiration"), + row.textOrNull("side"), + row.dblOrNull("strike"), + dateOrNull(row, "firstTraded"), + intOrNull(row.lngOrNull("dte")), + dateOrNull(row, "updated"), + row.dblOrNull("bid"), + row.lngOrNull("bidSize"), + row.dblOrNull("mid"), + row.dblOrNull("ask"), + row.lngOrNull("askSize"), + row.dblOrNull("last"), + row.lngOrNull("openInterest"), + row.lngOrNull("volume"), + row.boolOrNull("inTheMoney"), + row.dblOrNull("intrinsicValue"), + row.dblOrNull("extrinsicValue"), + row.dblOrNull("underlyingPrice"), + row.dblOrNull("iv"), + row.dblOrNull("delta"), + row.dblOrNull("gamma"), + row.dblOrNull("theta"), + row.dblOrNull("vega"), + row.dblOrNull("rho")); + } + + private static @Nullable ZonedDateTime dateOrNull(ParallelArrays.Row row, String field) + throws IOException { + JsonNode n = row.nodeOrNull(field); + return n == null ? null : MarketDataDates.parseTimestampField(null, n, field); + } + + private static @Nullable Integer intOrNull(@Nullable Long v) { + return v == null ? null : v.intValue(); + } + + /** + * Option A: for every required column that 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 the {@code null} a consumer sees only ever + * means "I projected it away" — never "the backend silently dropped it". + */ + private static void validateRequestedColumns( + JsonParser p, JsonNode root, List rows, DeserializationContext ctxt) + throws JsonMappingException { + if (rows.isEmpty()) { + return; // no_data / empty response — no projection to validate + } + Object attr = ctxt.getAttribute(JsonResponseParser.REQUESTED_COLUMNS_ATTR); + List requested = + attr instanceof List list ? list.stream().map(String::valueOf).toList() : List.of(); + for (String field : OPTION_REQUIRED_FIELDS) { + boolean asked = requested.isEmpty() || requested.contains(field); + if (asked && !root.has(field)) { + throw new JsonMappingException( + p, + "Response is missing requested required column '" + + field + + "' — it was requested (or no columns filter was applied) but the API did not" + + " return it"); + } + } + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java new file mode 100644 index 0000000..47bc73d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsStrikesDeserializer.java @@ -0,0 +1,94 @@ +package com.marketdata.sdk; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.OptionsStrikes; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Wire-format deserializer for {@link OptionsStrikes}. Unlike every other endpoint, the strikes + * body uses dynamic top-level keys — one per expiration date — alongside the fixed {@code + * s} envelope and {@code updated} timestamp. {@link ParallelArrays} cannot express this shape; the + * deserializer iterates the root's fields and treats any key that parses as an ISO date ({@code + * "yyyy-MM-dd"}) as an expiration entry. Envelope handling mirrors the rest of the SDK: + * + *

    + *
  • {@code "s":"error"} → {@link JsonMappingException} with {@code errmsg}. + *
  • {@code "s":"no_data"} → empty list, {@code updated} left null (the API typically attaches + * {@code nextTime} / {@code prevTime} hints in that envelope; this deserializer ignores them + * because they aren't part of the typed surface). + *
  • otherwise → strict validation: missing {@code updated}, missing/wrong-typed strike values, + * or unrecognized non-date keys all raise {@link JsonMappingException}. + *
+ */ +final class OptionsStrikesDeserializer extends JsonDeserializer { + + private static final String ENVELOPE_STATUS = "s"; + private static final String ENVELOPE_ERRMSG = "errmsg"; + private static final String ENVELOPE_ERROR = "error"; + private static final String ENVELOPE_NO_DATA = "no_data"; + private static final String UPDATED_KEY = "updated"; + + @Override + public OptionsStrikes deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); + if (ENVELOPE_ERROR.equals(envelopeStatus)) { + throw new JsonMappingException( + p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")); + } + if (ENVELOPE_NO_DATA.equals(envelopeStatus)) { + return new OptionsStrikes(List.of(), null); + } + + ZonedDateTime updated = + MarketDataDates.parseTimestampField(p, root.get(UPDATED_KEY), UPDATED_KEY); + + List rows = new ArrayList<>(); + Iterator> it = root.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String key = entry.getKey(); + if (ENVELOPE_STATUS.equals(key) || UPDATED_KEY.equals(key)) { + continue; + } + LocalDate expirationDate; + try { + expirationDate = LocalDate.parse(key); + } catch (DateTimeParseException e) { + // The endpoint is documented as `s` + `updated` + dynamic ISO-date keys; an unrecognized + // key signals either a server bug or a forward-compatible extension. Strict-by-default + // surfaces it. + throw new JsonMappingException(p, "unrecognized top-level key: " + key); + } + JsonNode arr = entry.getValue(); + if (!arr.isArray()) { + throw new JsonMappingException(p, "non-array value for expiration " + key); + } + List strikes = new ArrayList<>(arr.size()); + for (int i = 0; i < arr.size(); i++) { + JsonNode cell = arr.get(i); + if (!cell.isNumber()) { + throw new JsonMappingException( + p, "non-numeric strike at " + key + "[" + i + "]: " + cell.asText()); + } + strikes.add(cell.asDouble()); + } + rows.add( + new ExpirationStrikes(expirationDate.atStartOfDay(MarketDataDates.MARKET_ZONE), strikes)); + } + + return new OptionsStrikes(rows, updated); + } +} diff --git a/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java b/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java new file mode 100644 index 0000000..6f91a6b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/OptionsStrikesResponse.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.options.ExpirationStrikes; +import java.time.ZonedDateTime; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Response for {@code options.strikes}: {@link #values()} is the per-expiration strike lists. The + * endpoint also carries an {@code updated} timestamp, exposed via {@link #updated()} ({@code null} + * on the {@code no_data} envelope). Construct only through the resource façade. + */ +public final class OptionsStrikesResponse + extends AbstractMarketDataResponse> { + + private final @Nullable ZonedDateTime updated; + + OptionsStrikesResponse( + List values, + @Nullable ZonedDateTime updated, + HttpResponseEnvelope envelope, + Format format) { + super(values, envelope, format); + this.updated = updated; + } + + /** When the strike table was last refreshed, or {@code null} when the API omitted it. */ + public @Nullable ZonedDateTime updated() { + return updated; + } +} diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index 7ffabcc..5159908 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import org.jspecify.annotations.Nullable; /** * Helper for deserializing the API's parallel-arrays wire format. Almost every endpoint that @@ -58,8 +59,9 @@ private ParallelArrays() {} * see {@code HttpTransport.routeAndEnvelope}) for "the query has no results"; the data * arrays are deliberately omitted in that case. Returning an empty list lets the resource * wrap it in its container type ({@code new ApiStatus(emptyList)}, etc.) so the consumer - * reaches {@link Response#isNoData()} and {@link Response#data()} normally instead of - * hitting a spurious {@code "missing field"} error from the field-validation loop. + * reaches {@link MarketDataResponse#isNoData()} and {@link MarketDataResponse#values()} + * normally instead of hitting a spurious {@code "missing field"} error from the + * field-validation loop. *
  • Any other status (typically {@code "ok"}) → normal field validation. * * @@ -68,6 +70,28 @@ private ParallelArrays() {} */ static List zip(JsonParser p, JsonNode root, List fields, RowBuilder rowBuilder) throws IOException { + return zip(p, root, fields, List.of(), rowBuilder); + } + + /** + * Same as {@link #zip(JsonParser, JsonNode, List, RowBuilder)} but with a set of + * optional columns layered on top of the required {@code fields}. An optional column + * that is absent, null, or non-array is simply skipped (no error); when present it is + * length-checked against the required columns like any other. The row builder reads optional + * cells through {@link Row#dblOrNull} (and future {@code …OrNull} accessors), which return {@code + * null} for an absent column or null cell instead of throwing — the strict-by-default accessors + * stay strict for required columns. + * + *

    This is the escape hatch the class doc anticipates for "legitimately-nullable columns": e.g. + * the options {@code rho} greek, which several feeds omit entirely. + */ + static List zip( + JsonParser p, + JsonNode root, + List fields, + List optionalFields, + RowBuilder rowBuilder) + throws IOException { String envelopeStatus = root.path(ENVELOPE_STATUS).asText(""); if (ENVELOPE_ERROR.equals(envelopeStatus)) { @@ -103,6 +127,26 @@ static List zip(JsonParser p, JsonNode root, List fields, RowBuil arrays.put(field, node); } + for (String field : optionalFields) { + JsonNode node = root.get(field); + if (node == null || node.isNull() || !node.isArray()) { + continue; // absent optional column — Row.dblOrNull will yield null for every row + } + if (expected == -1) { + expected = node.size(); + } else if (node.size() != expected) { + throw new JsonMappingException( + p, + "mismatched lengths: optional " + + field + + "=" + + node.size() + + " vs expected=" + + expected); + } + arrays.put(field, node); + } + int rowCount = Math.max(expected, 0); List rows = new ArrayList<>(rowCount); for (int i = 0; i < rowCount; i++) { @@ -142,11 +186,25 @@ interface RowBuilder { */ static JsonDeserializer listDeserializer( List fields, RowBuilder rowBuilder, Function, T> wrapper) { - return new JsonDeserializer() { + return listDeserializer(fields, List.of(), rowBuilder, wrapper); + } + + /** + * Same as {@link #listDeserializer(List, RowBuilder, Function)} but with a set of optional + * columns (see {@link #zip(JsonParser, JsonNode, List, List, RowBuilder)}). Use when the wire + * schema has a column that some feeds omit — the row builder reads it through {@link + * Row#dblOrNull}. + */ + static JsonDeserializer listDeserializer( + List fields, + List optionalFields, + RowBuilder rowBuilder, + Function, T> wrapper) { + return new JsonDeserializer<>() { @Override public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { JsonNode root = p.readValueAsTree(); - List rows = zip(p, root, fields, rowBuilder); + List rows = zip(p, root, fields, optionalFields, rowBuilder); return wrapper.apply(rows); } }; @@ -178,11 +236,31 @@ interface Row { */ long lng(String field) throws JsonMappingException; + /** + * Lenient numeric accessor for optional columns: returns {@code null} when the column + * was absent from the response (i.e. declared via the optional-fields list and not present) or + * the cell itself is null/missing. A present-but-wrong-type cell still raises {@link + * JsonMappingException} — leniency covers absence, not corruption. + */ + @Nullable Double dblOrNull(String field) throws JsonMappingException; + + /** Lenient string accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable String textOrNull(String field) throws JsonMappingException; + + /** Lenient long accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable Long lngOrNull(String field) throws JsonMappingException; + + /** Lenient boolean accessor: {@code null} when the column is absent or the cell is null. */ + @Nullable Boolean boolOrNull(String field) throws JsonMappingException; + /** * Raw access for custom conversions (e.g. nested objects or non-trivial date parsing). Returns * the node verbatim — the caller decides how to validate. */ JsonNode node(String field); + + /** Like {@link #node} but {@code null} when the column is absent or the cell is null. */ + @Nullable JsonNode nodeOrNull(String field); } private static final class IndexedRow implements Row { @@ -230,11 +308,81 @@ public long lng(String field) throws JsonMappingException { return cell.asLong(); } + @Override + public @Nullable Double dblOrNull(String field) throws JsonMappingException { + if (!arrays.containsKey(field)) { + return null; // optional column absent from the response entirely + } + JsonNode cell = arrays.get(field).get(index); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + return null; + } + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asDouble(); + } + + @Override + public @Nullable String textOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isTextual()) { + throw typeMismatch(field, "string", cell); + } + return cell.asText(); + } + + @Override + public @Nullable Long lngOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isNumber()) { + throw typeMismatch(field, "number", cell); + } + return cell.asLong(); + } + + @Override + public @Nullable Boolean boolOrNull(String field) throws JsonMappingException { + JsonNode cell = cellOrNull(field); + if (cell == null) { + return null; + } + if (!cell.isBoolean()) { + throw typeMismatch(field, "boolean", cell); + } + return cell.asBoolean(); + } + @Override public JsonNode node(String field) { return cell(field); } + @Override + public @Nullable JsonNode nodeOrNull(String field) { + return cellOrNull(field); + } + + /** + * The cell for {@code field}, or {@code null} when the column is absent or the cell is null. + */ + private @Nullable JsonNode cellOrNull(String field) { + if (!arrays.containsKey(field)) { + return null; + } + JsonNode cell = arrays.get(field).get(index); + if (cell == null || cell.isNull() || cell.isMissingNode()) { + return null; + } + return cell; + } + private JsonNode cell(String field) { JsonNode array = arrays.get(field); if (array == null) { diff --git a/src/main/java/com/marketdata/sdk/PathSegments.java b/src/main/java/com/marketdata/sdk/PathSegments.java new file mode 100644 index 0000000..f7eab67 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/PathSegments.java @@ -0,0 +1,41 @@ +package com.marketdata.sdk; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Percent-encode user-provided segments before they are appended to a {@link RequestSpec} path. + * {@link HttpTransport#buildUri} writes the path verbatim (it only encodes query components, where + * {@code application/x-www-form-urlencoded} is the right dialect); when a resource places an + * unsanitized value in the path — symbol names with spaces, OCC option descriptions with {@code $}, + * etc. — it must encode beforehand or the resulting URL goes through {@code URI.create} as an + * illegal-character failure. + * + *

    Slashes are preserved verbatim because some endpoints accept multi-segment user input that + * naturally contains them (e.g. {@code /options/lookup/(?P.*)} matches across slashes, + * and dates like {@code "7/26/23"} look natural). Spaces become {@code %20} (not {@code +}, which + * is the {@code form-urlencoded} dialect and would be read literally by strict path parsers). + */ +final class PathSegments { + + private PathSegments() {} + + /** Percent-encode {@code raw} for path-context use, preserving {@code /} as a separator. */ + static String encode(String raw) { + StringBuilder out = new StringBuilder(raw.length()); + int start = 0; + for (int i = 0; i < raw.length(); i++) { + if (raw.charAt(i) == '/') { + if (i > start) { + out.append(URLEncoder.encode(raw.substring(start, i), StandardCharsets.UTF_8)); + } + out.append('/'); + start = i + 1; + } + } + if (start < raw.length()) { + out.append(URLEncoder.encode(raw.substring(start), StandardCharsets.UTF_8)); + } + return out.toString().replace("+", "%20"); + } +} diff --git a/src/main/java/com/marketdata/sdk/RequestConfig.java b/src/main/java/com/marketdata/sdk/RequestConfig.java new file mode 100644 index 0000000..e07f741 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/RequestConfig.java @@ -0,0 +1,85 @@ +package com.marketdata.sdk; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Immutable bundle of the universal query parameters (§3) a resource carries across its endpoints. + * A resource is a configured value: each {@code with*} method returns a copy, and {@link + * #applyTo(RequestSpec.Builder)} writes the accumulated params onto a request being built. + * + *

    Type-preserving params ({@code dateFormat}, {@code mode}, {@code limit}, {@code offset}) and + * {@code columns} are valid on the typed path; {@code human}/{@code headers} only cohere with the + * CSV facet (they reshape the output). The holder carries them all; each facet exposes the setters + * that make sense there. + */ +record RequestConfig( + @Nullable DateFormat dateFormat, + @Nullable Mode mode, + @Nullable Integer limit, + @Nullable Integer offset, + List columns, + @Nullable Boolean human, + @Nullable Boolean headers) { + + RequestConfig { + columns = List.copyOf(columns); + } + + static RequestConfig empty() { + return new RequestConfig(null, null, null, null, List.of(), null, null); + } + + RequestConfig withDateFormat(DateFormat v) { + return new RequestConfig(v, mode, limit, offset, columns, human, headers); + } + + RequestConfig withMode(Mode v) { + return new RequestConfig(dateFormat, v, limit, offset, columns, human, headers); + } + + RequestConfig withLimit(int v) { + return new RequestConfig(dateFormat, mode, v, offset, columns, human, headers); + } + + RequestConfig withOffset(int v) { + return new RequestConfig(dateFormat, mode, limit, v, columns, human, headers); + } + + RequestConfig withColumns(List v) { + return new RequestConfig(dateFormat, mode, limit, offset, v, human, headers); + } + + RequestConfig withHuman(boolean v) { + return new RequestConfig(dateFormat, mode, limit, offset, columns, v, headers); + } + + RequestConfig withHeaders(boolean v) { + return new RequestConfig(dateFormat, mode, limit, offset, columns, human, v); + } + + /** Writes every set universal param onto a request builder. */ + void applyTo(RequestSpec.Builder b) { + if (dateFormat != null) { + b.dateformat(dateFormat); + } + if (mode != null) { + b.mode(mode); + } + if (limit != null) { + b.limit(limit); + } + if (offset != null) { + b.offset(offset); + } + if (!columns.isEmpty()) { + b.columns(columns); + } + if (human != null) { + b.human(human); + } + if (headers != null) { + b.headers(headers); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/Response.java b/src/main/java/com/marketdata/sdk/Response.java deleted file mode 100644 index 3d254dc..0000000 --- a/src/main/java/com/marketdata/sdk/Response.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.marketdata.sdk; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Objects; -import org.jspecify.annotations.Nullable; - -/** - * Carrier for an API response: typed model + raw body + metadata. Per SDK requirements §13.5, - * exposes format-detection accessors ({@link #isJson()}, {@link #isCsv()}, {@link #isHtml()}), - * no-data detection ({@link #isNoData()}, matching the API's 404-with-{@code "s":"no_data"} - * envelope convention), and {@link #saveToFile(Path)} for writing the raw body verbatim. - * - *

    The {@link Format} enum is package-private — consumers query format via the boolean accessors - * rather than importing the enum. That keeps {@code Format} free to grow new values without - * breaking compiled consumer code (a {@code switch (response.format())} would otherwise be a - * source-compatibility hazard). - * - *

    Immutable. {@link #rawBody()} returns a defensive copy on every call. - * - * @param the typed deserialization of {@link #rawBody()}. - */ -public final class Response { - - private final T data; - private final byte[] rawBody; - private final Format format; - private final int statusCode; - private final @Nullable String requestId; - private final URI requestUrl; - - private Response( - T data, - byte[] rawBody, - Format format, - int statusCode, - @Nullable String requestId, - URI requestUrl) { - this.data = Objects.requireNonNull(data, "data"); - this.rawBody = Objects.requireNonNull(rawBody, "rawBody").clone(); - this.format = Objects.requireNonNull(format, "format"); - this.statusCode = statusCode; - this.requestId = requestId; - this.requestUrl = Objects.requireNonNull(requestUrl, "requestUrl"); - } - - /** - * Package-private factory used by resource façades. Resources parse the envelope's body to a - * typed {@code T}, then wrap. - */ - static Response wrap(T data, HttpResponseEnvelope envelope, Format format) { - return new Response<>( - data, envelope.body(), format, envelope.statusCode(), envelope.requestId(), envelope.url()); - } - - /** The typed deserialization. Never {@code null}. */ - public T data() { - return data; - } - - /** - * Defensive copy of the raw response bytes. Mutating the result does not affect this response. - */ - public byte[] rawBody() { - return rawBody.clone(); - } - - /** HTTP status code (currently one of 200, 203, 404). */ - public int statusCode() { - return statusCode; - } - - /** Absolute URL the response came from. */ - public URI requestUrl() { - return requestUrl; - } - - /** - * Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when the response did - * not carry one — useful when correlating with the support team. Matches the nullability shape of - * {@link com.marketdata.sdk.exception.MarketDataException#getRequestId()} so consumers can branch - * the same way regardless of which surface carries the id. - */ - public @Nullable String requestId() { - return requestId; - } - - public boolean isJson() { - return format == Format.JSON; - } - - public boolean isCsv() { - return format == Format.CSV; - } - - /** - * Whether the response body is HTML — typically a misrouted request that landed on the API's - * web-server tier (a marketing or error page) rather than the API tier. Consumers can use this to - * short-circuit JSON-shaped parsing and log the {@link #rawBody()} for diagnosis. - */ - public boolean isHtml() { - return format == Format.HTML; - } - - /** - * Whether the API signalled {@code {"s":"no_data"}} for this response. The backend uses HTTP 404 - * for that envelope (it is a successful "we have nothing for that query", not an error), so we - * gate on the status code rather than parsing the body. - */ - public boolean isNoData() { - return statusCode == 404; - } - - /** - * Write the raw body verbatim to {@code path}, creating or overwriting it. The on-disk content - * matches what the server sent — if you requested {@code ?format=csv}, you get CSV. Errors are - * rewrapped as {@link UncheckedIOException} so {@code saveToFile} fits naturally inside a fluent - * call chain. - */ - public void saveToFile(Path path) { - try { - Files.write(path, rawBody); - } catch (IOException e) { - throw new UncheckedIOException("Failed to write response body to " + path, e); - } - } - - /** - * Log-safe representation: status, format, byte count, and the request URL with the query string - * redacted (§16 — token, account_id, symbol queries must not persist through {@code toString}). - * {@code data} is intentionally omitted: consumers that need the payload have {@link #data()}; - * embedding it here would let a routine {@code log.info(response)} leak a {@code RequestHeaders} - * map (Authorization, client IP) or whatever else the future resource models carry. - */ - @Override - public String toString() { - return "Response[status=" - + statusCode - + ", format=" - + format.name().toLowerCase(java.util.Locale.ROOT) - + ", bytes=" - + rawBody.length - + ", url=" - + HttpDispatcher.safeUri(requestUrl) - + "]"; - } -} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java new file mode 100644 index 0000000..6682b3c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesHeadersResponse.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk; + +import java.util.Map; + +/** + * Response for {@code utilities.headers}: {@link #values()} is the request headers the server + * received (sensitive values redacted server-side), as a map. Construct only through the resource + * façade. + */ +public final class UtilitiesHeadersResponse + extends AbstractMarketDataResponse> { + + UtilitiesHeadersResponse( + Map values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/UtilitiesResource.java index 2716871..3f5997d 100644 --- a/src/main/java/com/marketdata/sdk/UtilitiesResource.java +++ b/src/main/java/com/marketdata/sdk/UtilitiesResource.java @@ -15,9 +15,10 @@ *

    Constructed once per {@link MarketDataClient}; the consumer reaches it through {@code * client.utilities()}. Constructor is package-private (ADR-007) — consumers cannot instantiate. * - *

    Every endpoint returns a {@link Response} carrying both the typed model and the raw body so - * consumers can access §13.5 response features ({@code isCsv()}, {@code saveToFile()}, …) without - * the resource caring about format choice. + *

    Every endpoint returns a named {@link MarketDataResponse} whose {@link + * MarketDataResponse#values()} is the flat payload (the service list for {@code status}, the header + * map for {@code headers}, the {@link User} for {@code user}). These diagnostic endpoints take no + * universal parameters and have no CSV/HTML facet. */ public final class UtilitiesResource { @@ -69,13 +70,16 @@ static SimpleModule wireFormatModule() { * {@code Authorization}) redacted server-side. Useful for diagnosing auth issues from a deployed * consumer. */ - public CompletableFuture> headersAsync() { + public CompletableFuture headersAsync() { RequestSpec spec = RequestSpec.get("headers").unversioned().build(); - return executeAndWrap(spec, RequestHeaders.class); + return execute( + spec, + RequestHeaders.class, + (d, env, fmt) -> new UtilitiesHeadersResponse(d.headers(), env, fmt)); } /** Sync wrapper for {@link #headersAsync()}; see {@link HttpTransport#joinSync} for semantics. */ - public Response headers() { + public UtilitiesHeadersResponse headers() { return transport.joinSync(headersAsync()); } @@ -88,12 +92,13 @@ public Response headers() { * prefix), same as {@code /status/} and {@code /headers/}. Hitting {@code /v1/user/} falls * through to the global 404 handler. */ - public CompletableFuture> userAsync() { - return executeAndWrap(RequestSpec.get("user").unversioned().build(), User.class); + public CompletableFuture userAsync() { + return execute( + RequestSpec.get("user").unversioned().build(), User.class, UtilitiesUserResponse::new); } /** Sync wrapper for {@link #userAsync()}. */ - public Response user() { + public UtilitiesUserResponse user() { return transport.joinSync(userAsync()); } @@ -113,8 +118,11 @@ public Response user() { */ void validateAuth() { transport.joinSync( - executeAndWrap( - RequestSpec.get("user").unversioned().build(), RetryPolicy.noRetry(), User.class)); + execute( + RequestSpec.get("user").unversioned().build(), + RetryPolicy.noRetry(), + User.class, + UtilitiesUserResponse::new)); } /** @@ -122,28 +130,37 @@ void validateAuth() { * the API root) and public — works without a token. The server refreshes the snapshot every five * minutes; polling more often than that is wasted work. */ - public CompletableFuture> statusAsync() { + public CompletableFuture statusAsync() { RequestSpec spec = RequestSpec.get("status").unversioned().build(); - return executeAndWrap(spec, ApiStatus.class); + return execute( + spec, + ApiStatus.class, + (d, env, fmt) -> new UtilitiesStatusResponse(d.services(), env, fmt)); } /** Sync wrapper for {@link #statusAsync()}. */ - public Response status() { + public UtilitiesStatusResponse status() { return transport.joinSync(statusAsync()); } // ---------- internal helpers ---------- - private CompletableFuture> executeAndWrap(RequestSpec spec, Class type) { + private CompletableFuture execute( + RequestSpec spec, Class decodeType, ResponseFactory factory) { return transport .executeAsync(spec) - .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + .thenApply(env -> factory.create(parser.parse(env, decodeType), env, spec.format())); } - private CompletableFuture> executeAndWrap( - RequestSpec spec, RetryPolicy policy, Class type) { + private CompletableFuture execute( + RequestSpec spec, RetryPolicy policy, Class decodeType, ResponseFactory factory) { return transport .executeAsync(spec, policy) - .thenApply(env -> Response.wrap(parser.parse(env, type), env, spec.format())); + .thenApply(env -> factory.create(parser.parse(env, decodeType), env, spec.format())); + } + + @FunctionalInterface + interface ResponseFactory { + R create(D decoded, HttpResponseEnvelope envelope, Format format); } } diff --git a/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java new file mode 100644 index 0000000..b14cf24 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesStatusResponse.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.ServiceStatus; +import java.util.List; + +/** + * Response for {@code utilities.status}: {@link #values()} is the per-service health snapshot. + * Construct only through the resource façade (package-private constructor, ADR-007). + */ +public final class UtilitiesStatusResponse extends AbstractMarketDataResponse> { + + UtilitiesStatusResponse( + List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java b/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java new file mode 100644 index 0000000..b16bafd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/UtilitiesUserResponse.java @@ -0,0 +1,14 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.utilities.User; + +/** + * Response for {@code utilities.user}: {@link #values()} is the caller's quota / permissions + * snapshot. Construct only through the resource façade. + */ +public final class UtilitiesUserResponse extends AbstractMarketDataResponse { + + UtilitiesUserResponse(User values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java new file mode 100644 index 0000000..3b05379 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/ExpirationFilter.java @@ -0,0 +1,88 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; + +/** + * Mutually-exclusive expiration filter for {@code /v1/options/chain/}. The chain endpoint's + * expiration-side parameters ({@code expiration}, {@code dte}, {@code from}/{@code to}, {@code + * month}/{@code year}) cover overlapping selection axes; combining them produces undefined behavior + * server-side. Modeling them as variants of a sealed interface with a single {@code + * expirationFilter(...)} setter on the request builder makes that exclusivity compiler-enforced: + * there is no way to assign two variants at once. + * + *

    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. + */ +public sealed interface ExpirationFilter + permits ExpirationFilter.OnDate, + ExpirationFilter.Dte, + ExpirationFilter.Between, + ExpirationFilter.MonthYear, + ExpirationFilter.All { + + /** A specific expiration date — wire form {@code ?expiration=YYYY-MM-DD}. */ + static OnDate onDate(LocalDate date) { + return new OnDate(date); + } + + /** + * Every available expiration — wire form {@code ?expiration=all}. + * + *

    This is not equivalent to leaving the expiration filter unset: with no filter the + * chain endpoint returns only the front-month (nearest) expiration, whereas {@code all()} returns + * the full chain across every expiration. The additive {@code weekly}/{@code monthly}/{@code + * quarterly} predicates still narrow the result on top of it. + */ + static All all() { + return new All(); + } + + /** Days-to-expiration filter — wire form {@code ?dte=N}. */ + static Dte dte(int days) { + if (days < 0) { + throw new IllegalArgumentException("dte must be non-negative"); + } + return new Dte(days); + } + + /** + * Inclusive date range — wire form {@code ?from=YYYY-MM-DD&to=YYYY-MM-DD}. {@code from} must not + * be strictly after {@code to}. + */ + static Between between(LocalDate from, LocalDate to) { + Objects.requireNonNull(from, "from"); + Objects.requireNonNull(to, "to"); + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must be on or before to"); + } + return new Between(from, to); + } + + /** + * Calendar month-of-year filter — wire form {@code ?month=M&year=YYYY}. {@code month} is the + * 1-based calendar month (January = 1). + */ + static MonthYear monthYear(int year, int month) { + if (month < 1 || month > 12) { + throw new IllegalArgumentException("month must be in 1..12"); + } + return new MonthYear(year, month); + } + + record OnDate(LocalDate date) implements ExpirationFilter { + public OnDate { + Objects.requireNonNull(date, "date"); + } + } + + record Dte(int days) implements ExpirationFilter {} + + record Between(LocalDate from, LocalDate to) implements ExpirationFilter {} + + record MonthYear(int year, int month) implements ExpirationFilter {} + + /** The whole chain across every expiration — see {@link #all()}. Carries no data of its own. */ + record All() implements ExpirationFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java b/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java new file mode 100644 index 0000000..45819ad --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/ExpirationStrikes.java @@ -0,0 +1,24 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Strikes available for a single expiration in an option chain — one row of {@link OptionsStrikes}. + * + * @param expiration the expiration date as a midnight {@code America/New_York} moment. Wire-format + * comes through as an ISO date-only string ({@code "2025-01-17"}) regardless of the §3 {@code + * dateformat} parameter — the strikes endpoint uses the key itself as the expiration label, so + * it cannot vary by format. + * @param strikes strike prices for {@code expiration}, in ascending order as the API delivers them. + * Immutable; never {@code null}. + */ +public record ExpirationStrikes(ZonedDateTime expiration, List strikes) { + + public ExpirationStrikes { + Objects.requireNonNull(expiration, "expiration"); + Objects.requireNonNull(strikes, "strikes"); + strikes = List.copyOf(strikes); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/Greek.java b/src/main/java/com/marketdata/sdk/options/Greek.java new file mode 100644 index 0000000..41d93b0 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/Greek.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.options; + +/** + * The Black-Scholes greeks an option quote can carry. Used by {@link OptionQuote#presentGreeks()} + * and {@link OptionQuote#greek(Greek)} to inspect which model-derived sensitivities are present on + * a given row (they may be absent on illiquid contracts, or — for {@code rho} — omitted by the + * feed). + * + *

    Implied volatility ({@code iv}) is deliberately not here: it is a volatility, not a + * sensitivity/greek. Read it via {@link OptionQuote#iv()}. + */ +public enum Greek { + DELTA, + GAMMA, + THETA, + VEGA, + RHO +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionQuote.java b/src/main/java/com/marketdata/sdk/options/OptionQuote.java new file mode 100644 index 0000000..a64a6e7 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionQuote.java @@ -0,0 +1,90 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * A single end-of-day option quote — one row of {@link OptionsQuotes}. Carries the contract's + * identification, market data (bid/ask/last/volume/open interest), in-the-money flag, intrinsic / + * extrinsic decomposition, the underlying price the quote was struck against, and the standard set + * of Black-Scholes greeks (delta, gamma, theta, vega, rho) plus implied volatility. + * + *

    Numeric size/count fields use {@code long} so a single record can carry post-Wall-Street-2.0 + * volume figures without silent truncation. Timestamps are {@link ZonedDateTime} in {@code + * America/New_York}; their wire-format may be unix, ISO-string, or spreadsheet serial per the §3 + * {@code dateformat} parameter, all of which are decoded uniformly by the deserializer. + * + *

    Every field is a nullable boxed type. This is what lets the {@code columns} universal + * parameter project the response to a subset of fields: a column the consumer did not request comes + * back {@code null}. The deserializer is still strict about requested fields — a required + * field that was asked for but is missing surfaces as a {@code ParseError} (Option A), never a + * silent null — so a {@code null} here means either "not requested (projected away)" or, for the + * model-derived values ({@code iv} and the greeks), "legitimately not provided for this row". + */ +public record OptionQuote( + @Nullable String optionSymbol, + @Nullable String underlying, + @Nullable ZonedDateTime expiration, + @Nullable String side, + @Nullable Double strike, + @Nullable ZonedDateTime firstTraded, + @Nullable Integer dte, + @Nullable ZonedDateTime updated, + @Nullable Double bid, + @Nullable Long bidSize, + @Nullable Double mid, + @Nullable Double ask, + @Nullable Long askSize, + @Nullable Double last, + @Nullable Long openInterest, + @Nullable Long volume, + @Nullable Boolean inTheMoney, + @Nullable Double intrinsicValue, + @Nullable Double extrinsicValue, + @Nullable Double underlyingPrice, + @Nullable Double iv, + @Nullable Double delta, + @Nullable Double gamma, + @Nullable Double theta, + @Nullable Double vega, + @Nullable Double rho) { + + /** + * The greeks present (non-null) on this row, as an immutable set. Empty when none were computed + * (e.g. an illiquid contract whose implied volatility couldn't be solved). Note {@code rho} is + * often absent even when the rest are present. + */ + public Set presentGreeks() { + EnumSet s = EnumSet.noneOf(Greek.class); + if (delta != null) { + s.add(Greek.DELTA); + } + if (gamma != null) { + s.add(Greek.GAMMA); + } + if (theta != null) { + s.add(Greek.THETA); + } + if (vega != null) { + s.add(Greek.VEGA); + } + if (rho != null) { + s.add(Greek.RHO); + } + return Collections.unmodifiableSet(s); + } + + /** The value of a given greek on this row, or {@code null} when that greek is absent. */ + public @Nullable Double greek(Greek g) { + return switch (g) { + case DELTA -> delta; + case GAMMA -> gamma; + case THETA -> theta; + case VEGA -> vega; + case RHO -> rho; + }; + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionSide.java b/src/main/java/com/marketdata/sdk/options/OptionSide.java new file mode 100644 index 0000000..670d648 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionSide.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.options; + +/** + * Option side — call or put. Used both as the typed enum form of the chain endpoint's {@code + * ?side=} filter and as the typed surface for {@code OptionQuote.side()} (today still a plain + * {@code String} — pending an SDK-wide migration to the enum). + */ +public enum OptionSide { + CALL("call"), + PUT("put"); + + private final String wireValue; + + OptionSide(String wireValue) { + this.wireValue = wireValue; + } + + /** The lowercase token the API uses on the wire. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsChain.java b/src/main/java/com/marketdata/sdk/options/OptionsChain.java new file mode 100644 index 0000000..11e7cd2 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsChain.java @@ -0,0 +1,22 @@ +package com.marketdata.sdk.options; + +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/chain/{underlying}/} — every option contract on the + * underlying that matches the request's filter set. The wire-format and per-row schema match the + * {@code quotes} endpoint exactly, so each row is decoded into the shared {@link OptionQuote} + * record; what differs is the volume of rows (typically many) and the filter parameters available + * via {@link OptionsChainRequest}. + * + * @param chain matching contracts in the order the API delivered them. Immutable; never {@code + * null}. Empty when the response is the {@code "s":"no_data"} envelope. + */ +public record OptionsChain(List chain) { + + public OptionsChain { + Objects.requireNonNull(chain, "chain"); + chain = List.copyOf(chain); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java new file mode 100644 index 0000000..3268966 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsChainRequest.java @@ -0,0 +1,344 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/chain/{underlying}/}. The chain endpoint exposes the + * richest filter surface in the API — ~25 query parameters covering expiration selection, strike + * selection, liquidity floors, side, and structural metadata (weekly / monthly / quarterly / + * AM-settled / PM-settled / nonstandard). + * + *

    The mutually-exclusive groups are modeled as sealed types ({@link ExpirationFilter}, {@link + * StrikeFilter}) accessed through a single setter so the compiler enforces "pick one variant". Pair + * constraints that can't be expressed in the type system ({@code minBid <= maxBid}, {@code minAsk + * <= maxAsk}) are checked at {@link Builder#build} time — runtime, but pre-HTTP. + * + *

    Filters not validated server-side at construction (the combinatoric of expiration sealed-type + * variants with weekly / monthly / quarterly booleans is unconstrained) — the SDK trusts the + * backend to intersect them. Forward-compat: future API parameters drop in as additional builder + * setters without breaking any existing call. + */ +public final class OptionsChainRequest { + + private final String symbol; + + private final @Nullable ExpirationFilter expirationFilter; + private final @Nullable Boolean weekly; + private final @Nullable Boolean monthly; + private final @Nullable Boolean quarterly; + private final @Nullable Boolean am; + private final @Nullable Boolean pm; + private final @Nullable Boolean nonstandard; + + private final @Nullable StrikeFilter strikeFilter; + private final @Nullable Double delta; + private final @Nullable Integer strikeLimit; + private final @Nullable StrikeRange strikeRange; + + private final @Nullable Double minBid; + private final @Nullable Double maxBid; + private final @Nullable Double minAsk; + private final @Nullable Double maxAsk; + private final @Nullable Double maxBidAskSpread; + private final @Nullable Double maxBidAskSpreadPct; + private final @Nullable Long minOpenInterest; + private final @Nullable Long minVolume; + + private final @Nullable OptionSide side; + + private final @Nullable LocalDate date; + + private OptionsChainRequest(Builder b) { + this.symbol = b.symbol; + this.expirationFilter = b.expirationFilter; + this.weekly = b.weekly; + this.monthly = b.monthly; + this.quarterly = b.quarterly; + this.am = b.am; + this.pm = b.pm; + this.nonstandard = b.nonstandard; + this.strikeFilter = b.strikeFilter; + this.delta = b.delta; + this.strikeLimit = b.strikeLimit; + this.strikeRange = b.strikeRange; + this.minBid = b.minBid; + this.maxBid = b.maxBid; + this.minAsk = b.minAsk; + this.maxAsk = b.maxAsk; + this.maxBidAskSpread = b.maxBidAskSpread; + this.maxBidAskSpreadPct = b.maxBidAskSpreadPct; + this.minOpenInterest = b.minOpenInterest; + this.minVolume = b.minVolume; + this.side = b.side; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static OptionsChainRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + // ---------- accessors ---------- + + public String symbol() { + return symbol; + } + + public @Nullable ExpirationFilter expirationFilter() { + return expirationFilter; + } + + public @Nullable Boolean weekly() { + return weekly; + } + + public @Nullable Boolean monthly() { + return monthly; + } + + public @Nullable Boolean quarterly() { + return quarterly; + } + + public @Nullable Boolean am() { + return am; + } + + public @Nullable Boolean pm() { + return pm; + } + + public @Nullable Boolean nonstandard() { + return nonstandard; + } + + public @Nullable StrikeFilter strikeFilter() { + return strikeFilter; + } + + public @Nullable Double delta() { + return delta; + } + + public @Nullable Integer strikeLimit() { + return strikeLimit; + } + + public @Nullable StrikeRange strikeRange() { + return strikeRange; + } + + public @Nullable Double minBid() { + return minBid; + } + + public @Nullable Double maxBid() { + return maxBid; + } + + public @Nullable Double minAsk() { + return minAsk; + } + + public @Nullable Double maxAsk() { + return maxAsk; + } + + public @Nullable Double maxBidAskSpread() { + return maxBidAskSpread; + } + + public @Nullable Double maxBidAskSpreadPct() { + return maxBidAskSpreadPct; + } + + public @Nullable Long minOpenInterest() { + return minOpenInterest; + } + + public @Nullable Long minVolume() { + return minVolume; + } + + public @Nullable OptionSide side() { + return side; + } + + public @Nullable LocalDate date() { + return date; + } + + // ---------- builder ---------- + + public static final class Builder { + private final String symbol; + private @Nullable ExpirationFilter expirationFilter; + private @Nullable Boolean weekly; + private @Nullable Boolean monthly; + private @Nullable Boolean quarterly; + private @Nullable Boolean am; + private @Nullable Boolean pm; + private @Nullable Boolean nonstandard; + private @Nullable StrikeFilter strikeFilter; + private @Nullable Double delta; + private @Nullable Integer strikeLimit; + private @Nullable StrikeRange strikeRange; + private @Nullable Double minBid; + private @Nullable Double maxBid; + private @Nullable Double minAsk; + private @Nullable Double maxAsk; + private @Nullable Double maxBidAskSpread; + private @Nullable Double maxBidAskSpreadPct; + private @Nullable Long minOpenInterest; + private @Nullable Long minVolume; + private @Nullable OptionSide side; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Set the mutually-exclusive expiration-side filter. */ + public Builder expirationFilter(ExpirationFilter filter) { + this.expirationFilter = Objects.requireNonNull(filter, "filter"); + return this; + } + + public Builder weekly(boolean value) { + this.weekly = value; + return this; + } + + public Builder monthly(boolean value) { + this.monthly = value; + return this; + } + + public Builder quarterly(boolean value) { + this.quarterly = value; + return this; + } + + public Builder am(boolean value) { + this.am = value; + return this; + } + + public Builder pm(boolean value) { + this.pm = value; + return this; + } + + /** Whether to include non-standard contracts (mini-options, adjusted options, …). */ + public Builder nonstandard(boolean value) { + this.nonstandard = value; + return this; + } + + /** Set the strike-syntax filter ({@code exact}, {@code range}, {@code comparison}). */ + public Builder strikeFilter(StrikeFilter filter) { + this.strikeFilter = Objects.requireNonNull(filter, "filter"); + return this; + } + + /** Filter by Black-Scholes delta. Independent of {@link #strikeFilter}. */ + public Builder delta(double value) { + this.delta = value; + return this; + } + + /** + * Limit the response to {@code n} strikes around the at-the-money line, partitioned by {@link + * #strikeRange}. Pair semantics: setting one without the other is accepted by the server but + * may behave unexpectedly — the SDK does not enforce the pair to keep forward-compat. + */ + public Builder strikeLimit(int n) { + if (n <= 0) { + throw new IllegalArgumentException("strikeLimit must be positive"); + } + this.strikeLimit = n; + return this; + } + + public Builder strikeRange(StrikeRange range) { + this.strikeRange = Objects.requireNonNull(range, "range"); + return this; + } + + public Builder minBid(double value) { + this.minBid = value; + return this; + } + + public Builder maxBid(double value) { + this.maxBid = value; + return this; + } + + public Builder minAsk(double value) { + this.minAsk = value; + return this; + } + + public Builder maxAsk(double value) { + this.maxAsk = value; + return this; + } + + public Builder maxBidAskSpread(double value) { + this.maxBidAskSpread = value; + return this; + } + + public Builder maxBidAskSpreadPct(double value) { + this.maxBidAskSpreadPct = value; + return this; + } + + public Builder minOpenInterest(long value) { + if (value < 0) { + throw new IllegalArgumentException("minOpenInterest must be non-negative"); + } + this.minOpenInterest = value; + return this; + } + + public Builder minVolume(long value) { + if (value < 0) { + throw new IllegalArgumentException("minVolume must be non-negative"); + } + this.minVolume = value; + return this; + } + + public Builder side(OptionSide value) { + this.side = Objects.requireNonNull(value, "side"); + return this; + } + + /** Historical chain on a specific trading day. */ + public Builder date(LocalDate value) { + this.date = Objects.requireNonNull(value, "date"); + return this; + } + + public OptionsChainRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + if (minBid != null && maxBid != null && minBid > maxBid) { + throw new IllegalArgumentException("minBid must be <= maxBid"); + } + if (minAsk != null && maxAsk != null && minAsk > maxAsk) { + throw new IllegalArgumentException("minAsk must be <= maxAsk"); + } + return new OptionsChainRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java b/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java new file mode 100644 index 0000000..907feb3 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsExpirations.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/expirations/{underlying}/} — the available expiration + * dates for the underlying's option chain. + * + *

    Both fields are {@link ZonedDateTime} in {@code America/New_York} (§13.4 — the canonical + * market zone). For expirations the time-of-day is always {@code 00:00} since the wire value is a + * calendar date; the {@code ZonedDateTime} type is kept for consistency with the rest of the SDK + * ({@code ServiceStatus.updated}, future {@code OptionQuote.expiration}, etc.) so consumers reach + * for the same APIs regardless of which field they touch. Convert to {@link java.time.LocalDate} + * via {@code .toLocalDate()} when the time-of-day is irrelevant. + * + * @param expirations the expiration dates (as midnight market-zone moments) in chronological order. + * Immutable; never {@code null}. Empty when the {@code "s":"no_data"} envelope is received. + * @param updated when the server last refreshed this expirations list, in {@code America/New_York}. + * {@code null} only when the no-data envelope omits the {@code updated} field; the deserializer + * rejects any other absence with a {@link com.marketdata.sdk.exception.ParseError}. + */ +public record OptionsExpirations( + List expirations, @org.jspecify.annotations.Nullable ZonedDateTime updated) { + + public OptionsExpirations { + Objects.requireNonNull(expirations, "expirations"); + expirations = List.copyOf(expirations); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java new file mode 100644 index 0000000..7e5356f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsExpirationsRequest.java @@ -0,0 +1,82 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/expirations/{symbol}/} — the list of expiration dates for + * an underlying's option chain. Two optional filters narrow the result: {@code strike} restricts to + * expirations that include the given strike, and {@code date} fetches the historical list as it + * stood on a previous trading day. + * + *

    Constructed via {@link #builder(String)}; the {@code symbol} is required and seeds the + * builder, mutable optional fields are set with {@code strike(...)} / {@code date(...)}. The §3 + * universal {@code dateformat}/{@code format}/etc. parameters are not part of this class — they + * land on a separate universal-parameters overload when added. + */ +public final class OptionsExpirationsRequest { + + private final String symbol; + private final @Nullable Double strike; + private final @Nullable LocalDate date; + + private OptionsExpirationsRequest(Builder b) { + this.symbol = b.symbol; + this.strike = b.strike; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()} when no filters are needed. */ + public static OptionsExpirationsRequest of(String symbol) { + return builder(symbol).build(); + } + + /** Start a builder seeded with the required underlying symbol. */ + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + /** Strike-price filter, or {@code null} when unset. */ + public @Nullable Double strike() { + return strike; + } + + /** Historical query date, or {@code null} for the current/last-trading-day list. */ + public @Nullable LocalDate date() { + return date; + } + + public static final class Builder { + private final String symbol; + private @Nullable Double strike; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Restrict the result to expirations whose chain contains {@code strike}. */ + public Builder strike(double strike) { + this.strike = strike; + return this; + } + + /** Fetch the expirations list as it stood at end-of-day on {@code date}. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public OptionsExpirationsRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + return new OptionsExpirationsRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsLookup.java b/src/main/java/com/marketdata/sdk/options/OptionsLookup.java new file mode 100644 index 0000000..9efef59 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsLookup.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk.options; + +/** + * Response shape for {@code GET /v1/options/lookup/{userInput}/} — a single OCC-formatted option + * symbol resolved from a human-readable description (e.g. {@code "AAPL 7/26/23 $200 Call"} → {@code + * "AAPL230726C00200000"}). + * + *

    The wire shape is a flat single-value object ({@code {"s":"ok","optionSymbol":"..."}}), not + * the parallel-arrays envelope used by chain/quotes/expirations — that is why this record is + * decoded by a hand-written {@link com.marketdata.sdk.OptionsLookupDeserializer} instead of the + * {@code ParallelArrays.listDeserializer} factory. + * + * @param optionSymbol the OCC option symbol the user input resolves to. Always non-empty when + * present; the deserializer rejects a missing or non-textual {@code optionSymbol} as a {@link + * com.marketdata.sdk.exception.ParseError}. + */ +public record OptionsLookup(String optionSymbol) {} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java new file mode 100644 index 0000000..43e822b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsLookupRequest.java @@ -0,0 +1,52 @@ +package com.marketdata.sdk.options; + +import java.util.Objects; + +/** + * Parameters for {@code GET /v1/options/lookup/{userInput}/}. The endpoint takes a single + * path-positional value (the human-readable description) and no query parameters beyond the §3 + * universal set; the request class exists for SDK-wide consistency so every endpoint is reached the + * same way ({@code options.lookup(request)}). + * + *

    Constructed via {@link #of(String)} when only the required {@code userInput} is needed, or + * {@link #builder(String)} when forward-compatible with future optional fields. + */ +public final class OptionsLookupRequest { + + private final String userInput; + + private OptionsLookupRequest(Builder b) { + this.userInput = b.userInput; + } + + /** Shortcut for {@code builder(userInput).build()}. */ + public static OptionsLookupRequest of(String userInput) { + return builder(userInput).build(); + } + + /** Start a builder seeded with the required path field. */ + public static Builder builder(String userInput) { + return new Builder(userInput); + } + + /** The human-readable option description, e.g. {@code "AAPL 7/26/23 $200 Call"}. */ + public String userInput() { + return userInput; + } + + /** Mutable builder; each chain produces a new {@link OptionsLookupRequest} via {@link #build}. */ + public static final class Builder { + private final String userInput; + + private Builder(String userInput) { + this.userInput = Objects.requireNonNull(userInput, "userInput"); + } + + public OptionsLookupRequest build() { + if (userInput.isEmpty()) { + throw new IllegalArgumentException("userInput must be non-empty"); + } + return new OptionsLookupRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java new file mode 100644 index 0000000..0dafd3a --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuoteRequest.java @@ -0,0 +1,136 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the single-contract form of {@code GET /v1/options/quotes/{optionSymbol}/}. One + * OCC-formatted option symbol plus the optional historical-window filters ({@code date}, {@code + * from}/{@code to}, or {@code to}/{@code countback}). + * + *

    For multiple contracts use {@link OptionsQuotesRequest} — the multi-symbol API fans out one + * request per symbol concurrently and returns a {@code Map}. + */ +public final class OptionsQuoteRequest { + + private final String optionSymbol; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + + private OptionsQuoteRequest(Builder b) { + this.optionSymbol = b.optionSymbol; + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + } + + /** Shortcut for {@code builder(optionSymbol).build()}. */ + public static OptionsQuoteRequest of(String optionSymbol) { + return builder(optionSymbol).build(); + } + + public static Builder builder(String optionSymbol) { + return new Builder(optionSymbol); + } + + public String optionSymbol() { + return optionSymbol; + } + + /** End-of-day historical quote on a specific date, or {@code null} for the current quote. */ + public @Nullable LocalDate date() { + return date; + } + + /** Start of a date range (inclusive), or {@code null} when unset. */ + public @Nullable LocalDate from() { + return from; + } + + /** End of a date range (exclusive), or {@code null} when unset. */ + public @Nullable LocalDate to() { + return to; + } + + /** + * Number of quotes to fetch before {@code to} (to its left), or {@code null} when unset. An + * alternative to {@code from} for bounding the left edge of the window. + */ + public @Nullable Integer countback() { + return countback; + } + + public static final class Builder { + private final String optionSymbol; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + + private Builder(String optionSymbol) { + this.optionSymbol = Objects.requireNonNull(optionSymbol, "optionSymbol"); + } + + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** + * Fetch {@code countback} quotes before {@code to}. Must be positive; mutually exclusive with + * {@code date} and with {@code from} (per the API: "if you use from, countback is not + * required"). Pair it with {@code to} to anchor the window. + */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + public OptionsQuoteRequest build() { + if (optionSymbol.isEmpty()) { + throw new IllegalArgumentException("optionSymbol must be non-empty"); + } + validateWindow(date, from, to, countback); + return new OptionsQuoteRequest(this); + } + } + + /** + * Shared validation for the historical-window parameters across both quote request forms: {@code + * date} is a single snapshot incompatible with any ranging parameter; {@code countback} is an + * alternative to {@code from} for the left edge, so the two cannot be combined; and {@code + * countback} must be positive. + */ + static void validateWindow( + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to, + @Nullable Integer countback) { + if (date != null && (from != null || to != null || countback != null)) { + throw new IllegalArgumentException("date and from/to/countback are mutually exclusive"); + } + if (countback != null) { + if (countback <= 0) { + throw new IllegalArgumentException("countback must be positive"); + } + if (from != null) { + throw new IllegalArgumentException( + "countback and from are mutually exclusive; pair countback with to"); + } + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java new file mode 100644 index 0000000..ec996fa --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotes.java @@ -0,0 +1,24 @@ +package com.marketdata.sdk.options; + +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/quotes/{optionSymbol}/} — the end-of-day option quote + * (or historical series) for a single contract. The wire-format is the standard parallel-arrays + * envelope; the SDK lifts each row into an {@link OptionQuote}. + * + *

    This wrapper is also the per-symbol response type produced by the multi-symbol {@code + * options.quotes(...)} convenience: each symbol's parallel-arrays body becomes one {@code + * OptionsQuotes} (with typically one row) under its symbol key in the returned {@code Map}. + * + * @param quotes the decoded rows in the order the API delivered them. Immutable; never {@code + * null}. Empty when the response is the {@code "s":"no_data"} envelope. + */ +public record OptionsQuotes(List quotes) { + + public OptionsQuotes { + Objects.requireNonNull(quotes, "quotes"); + quotes = List.copyOf(quotes); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java new file mode 100644 index 0000000..36e46c2 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsQuotesRequest.java @@ -0,0 +1,124 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the multi-contract form of {@code /v1/options/quotes/}. Carries a list of one or + * more OCC option symbols plus the optional historical-window filters shared across them. The + * resource fans out one HTTP request per symbol concurrently (via the SDK's 50-permit {@code + * AsyncSemaphore}) and returns a {@code Map} so per-symbol status, + * raw body, and error envelopes stay observable. + * + *

    For a single contract, prefer {@link OptionsQuoteRequest} — clearer intent and one fewer map + * lookup at the call site. + */ +public final class OptionsQuotesRequest { + + private final List optionSymbols; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + + private OptionsQuotesRequest(Builder b) { + this.optionSymbols = List.copyOf(b.optionSymbols); + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + } + + /** + * Start a builder with one or more option symbols. At least one symbol is required; duplicates + * are kept (each one results in its own HTTP call). + */ + public static Builder builder(String first, String... rest) { + Builder b = new Builder(); + b.addSymbol(first); + for (String s : rest) { + b.addSymbol(s); + } + return b; + } + + public List optionSymbols() { + return optionSymbols; + } + + public @Nullable LocalDate date() { + return date; + } + + public @Nullable LocalDate from() { + return from; + } + + public @Nullable LocalDate to() { + return to; + } + + /** + * Number of quotes to fetch before {@code to} (to its left), or {@code null} when unset. An + * alternative to {@code from} for bounding the left edge of the window; applied identically to + * every symbol in the fan-out. + */ + public @Nullable Integer countback() { + return countback; + } + + public static final class Builder { + private final List optionSymbols = new ArrayList<>(); + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + + private Builder() {} + + public Builder addSymbol(String optionSymbol) { + Objects.requireNonNull(optionSymbol, "optionSymbol"); + if (optionSymbol.isEmpty()) { + throw new IllegalArgumentException("optionSymbol must be non-empty"); + } + this.optionSymbols.add(optionSymbol); + return this; + } + + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** + * Fetch {@code countback} quotes before {@code to} for each symbol. Must be positive; mutually + * exclusive with {@code date} and with {@code from} (per the API: "if you use from, countback + * is not required"). Pair it with {@code to} to anchor the window. + */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + public OptionsQuotesRequest build() { + if (optionSymbols.isEmpty()) { + throw new IllegalArgumentException("at least one optionSymbol is required"); + } + OptionsQuoteRequest.validateWindow(date, from, to, countback); + return new OptionsQuotesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java b/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java new file mode 100644 index 0000000..56ae18f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsStrikes.java @@ -0,0 +1,29 @@ +package com.marketdata.sdk.options; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Response shape for {@code GET /v1/options/strikes/{underlying}/} — the strike prices available + * for each expiration in the underlying's option chain. + * + *

    The wire-format is unique in the API: there is no parallel-arrays envelope, and the response + * carries one top-level key per expiration date (e.g. {@code "2025-01-17"} → {@code [140, 145, + * 150]}) plus the universal {@code "s"} and {@code "updated"} metadata. The SDK lifts those into a + * positional list of {@link ExpirationStrikes} rows so consumers iterate naturally; ordering + * follows what the API returns (the backend sorts expirations ascending). + * + * @param expirations one entry per expiration in chronological order; never {@code null}. Empty + * when the {@code "s":"no_data"} envelope is received. + * @param updated when the server last refreshed this strikes table, in {@code America/New_York}. + * {@code null} only when the no-data envelope omits the {@code updated} field. + */ +public record OptionsStrikes( + List expirations, @org.jspecify.annotations.Nullable ZonedDateTime updated) { + + public OptionsStrikes { + Objects.requireNonNull(expirations, "expirations"); + expirations = List.copyOf(expirations); + } +} diff --git a/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java b/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java new file mode 100644 index 0000000..46ae072 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/OptionsStrikesRequest.java @@ -0,0 +1,75 @@ +package com.marketdata.sdk.options; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/options/strikes/{symbol}/}. Two optional filters narrow the result: + * {@code expiration} returns strikes only for that expiration date, {@code date} fetches the + * historical table as it stood on a previous trading day. + */ +public final class OptionsStrikesRequest { + + private final String symbol; + private final @Nullable LocalDate expiration; + private final @Nullable LocalDate date; + + private OptionsStrikesRequest(Builder b) { + this.symbol = b.symbol; + this.expiration = b.expiration; + this.date = b.date; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static OptionsStrikesRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + /** Single-expiration filter, or {@code null} when unset. */ + public @Nullable LocalDate expiration() { + return expiration; + } + + /** Historical query date, or {@code null} for the current/last-trading-day table. */ + public @Nullable LocalDate date() { + return date; + } + + public static final class Builder { + private final String symbol; + private @Nullable LocalDate expiration; + private @Nullable LocalDate date; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Restrict the result to the single given expiration date. */ + public Builder expiration(LocalDate expiration) { + this.expiration = Objects.requireNonNull(expiration, "expiration"); + return this; + } + + /** Fetch the strikes table as it stood at end-of-day on {@code date}. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + public OptionsStrikesRequest build() { + if (symbol.isEmpty()) { + throw new IllegalArgumentException("symbol must be non-empty"); + } + return new OptionsStrikesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/options/StrikeFilter.java b/src/main/java/com/marketdata/sdk/options/StrikeFilter.java new file mode 100644 index 0000000..3c1f541 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/StrikeFilter.java @@ -0,0 +1,61 @@ +package com.marketdata.sdk.options; + +/** + * The chain endpoint's {@code ?strike=} parameter accepts three syntactic forms — exact value, + * range, and comparison ({@code >150}, {@code <=160}, …). Modeling them as a sealed type with + * factory entry-points gives the consumer compile-time autocomplete for valid shapes and prevents + * typos like {@code "140--160"} that a raw-string passthrough would silently ship to the server. + * + *

    Other strike-related chain parameters ({@code delta}, {@code strikeLimit} + {@code range}, + * {@code minBid}/{@code maxBid}, …) are independent filters server-side — they intersect with this + * one rather than overriding it, so they stay as separate setters on the request builder. + */ +public sealed interface StrikeFilter + permits StrikeFilter.Exact, StrikeFilter.Range, StrikeFilter.Comparison { + + /** Match options whose strike equals {@code price}. */ + static Exact exact(double price) { + return new Exact(price); + } + + /** Match strikes in {@code [min, max]} inclusive. {@code min} must not exceed {@code max}. */ + static Range range(double min, double max) { + if (min > max) { + throw new IllegalArgumentException("min must be <= max"); + } + return new Range(min, max); + } + + /** Match strikes satisfying {@code operator price} (e.g. {@code > 150}). */ + static Comparison comparison(Operator operator, double price) { + if (operator == null) { + throw new IllegalArgumentException("operator must not be null"); + } + return new Comparison(operator, price); + } + + /** Comparison operators accepted by the API. */ + 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; + } + } + + record Exact(double price) implements StrikeFilter {} + + record Range(double min, double max) implements StrikeFilter {} + + record Comparison(Operator operator, double price) implements StrikeFilter {} +} diff --git a/src/main/java/com/marketdata/sdk/options/StrikeRange.java b/src/main/java/com/marketdata/sdk/options/StrikeRange.java new file mode 100644 index 0000000..3de72b6 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/StrikeRange.java @@ -0,0 +1,23 @@ +package com.marketdata.sdk.options; + +/** + * Coarse-grained strike-range filter for the chain endpoint's {@code ?range=} parameter. Used + * together with {@code strikeLimit} on {@link OptionsChainRequest} to ask for "the N strikes around + * the in-the-money / out-of-the-money boundary". + */ +public enum StrikeRange { + ITM("itm"), + OTM("otm"), + ALL("all"); + + private final String wireValue; + + StrikeRange(String wireValue) { + this.wireValue = wireValue; + } + + /** The lowercase token the API uses on the wire. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/marketdata/sdk/options/package-info.java b/src/main/java/com/marketdata/sdk/options/package-info.java new file mode 100644 index 0000000..45975ca --- /dev/null +++ b/src/main/java/com/marketdata/sdk/options/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records and request types for the {@code options} resource — quotes, chain, expirations, + * strikes, and OCC-symbol lookup. The {@link com.marketdata.sdk.OptionsResource} façade lives in + * the SDK root package (ADR-007); only the public consumer-facing types live here. + */ +@org.jspecify.annotations.NullMarked +package com.marketdata.sdk.options; diff --git a/src/test/java/com/marketdata/sdk/OptionsResourceTest.java b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java new file mode 100644 index 0000000..03fe3d1 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/OptionsResourceTest.java @@ -0,0 +1,1388 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.ExpirationStrikes; +import com.marketdata.sdk.options.Greek; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.OptionsStrikesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class OptionsResourceTest { + + private static final RetryPolicy NO_RETRY = + new RetryPolicy(1, Duration.ofMillis(1), Duration.ofMillis(1)); + private static final HttpHeaders EMPTY_HEADERS = HttpHeaders.of(Map.of(), (a, b) -> true); + + /** Mints a fresh transport + resource pair against the given canned HTTP client. */ + private static OptionsResource resourceWith(HttpClient client) { + HttpTransport transport = + new HttpTransport( + "http://localhost", + "v1", + "test/0.0", + "secret-token", + new HttpDispatcher(client, HttpTransport.CONCURRENCY_LIMIT), + new RetryExecutor(NO_RETRY), + () -> null, + Clock.systemUTC()); + return new OptionsResource(transport, new JsonResponseParser()); + } + + // ---------- lookup: URL & verb ---------- + + @Test + void lookupHitsVersionedEndpoint() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL230726C00200000\"}"); + OptionsResource options = resourceWith(client); + + options.lookupAsync(OptionsLookupRequest.of("AAPL230726C00200000")).join(); + + HttpRequest sent = client.captured.get(0); + assertThat(sent.uri().toString()) + .isEqualTo("http://localhost/v1/options/lookup/AAPL230726C00200000/"); + assertThat(sent.method()).isEqualTo("GET"); + } + + @Test + void lookupUrlEncodesSpacesAndReservedChars() { + // Spaces → %20 (not +, which is the application/x-www-form-urlencoded dialect and would be + // taken literally by a strict path parser). The "$" is reserved in URLs and must be encoded. + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL230726C00200000\"}"); + OptionsResource options = resourceWith(client); + + options.lookupAsync(OptionsLookupRequest.of("AAPL 7/26/23 $200 Call")).join(); + + String url = client.captured.get(0).uri().toString(); + // Slashes survive verbatim — the backend regex (?P.*) matches across them, and dates + // like "7/26/23" are natural in the input. Mirrors Python's urllib.parse.quote() default + // (safe="/"). + assertThat(url).isEqualTo("http://localhost/v1/options/lookup/AAPL%207/26/23%20%24200%20Call/"); + } + + // ---------- lookup: response decoding ---------- + + @Test + void lookupAsyncReturnsDecodedSymbol() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + String lookup = options.lookupAsync(OptionsLookupRequest.of("anything")).join().values(); + + assertThat(lookup).isEqualTo("AAPL250117C00150000"); + } + + @Test + void lookupSyncMirrorsAsync() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + String viaSync = options.lookup(OptionsLookupRequest.of("x")).values(); + assertThat(viaSync).isEqualTo("AAPL250117C00150000"); + } + + @Test + void lookupResponseExposesIsJsonAndRequestUrl() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"); + OptionsResource options = resourceWith(client); + + OptionsLookupResponse resp = options.lookup(OptionsLookupRequest.of("x")); + assertThat(resp.isJson()).isTrue(); + assertThat(resp.isCsv()).isFalse(); + assertThat(resp.isHtml()).isFalse(); + assertThat(resp.statusCode()).isEqualTo(200); + } + + // ---------- lookup: envelope handling ---------- + + @Test + void lookupErrorEnvelopeSurfacesAsParseError() { + // Backend returns {"s":"error","errmsg":"..."} when the input can't be parsed. The + // deserializer turns that into a JsonMappingException which JsonResponseParser wraps as a + // ParseError — same shape as the parallel-arrays envelope path so consumers see consistent + // error semantics across endpoints. + CapturingClient client = + okWith("{\"s\":\"error\",\"errmsg\":\"Unable to parse option description\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("bogus"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Unable to parse option description"); + } + + @Test + void lookupMissingOptionSymbolFieldThrowsParseError() { + // Strict-by-default: a server bug that drops the optionSymbol field must surface, not silently + // produce a record with an empty string. Same reasoning as UserDeserializer / ParallelArrays. + CapturingClient client = okWith("{\"s\":\"ok\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("x"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("optionSymbol"); + } + + @Test + void lookupNonStringOptionSymbolThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"optionSymbol\":12345}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.lookup(OptionsLookupRequest.of("x"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("optionSymbol"); + } + + // ---------- expirations: URL & params ---------- + + @Test + void expirationsHitsVersionedEndpointWithNoExtraParams() { + // No forced ?dateformat= — §3 says the universal parameter is consumer-controlled. The typed + // deserializer adapts to whichever format the API returns. + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options.expirationsAsync(OptionsExpirationsRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/expirations/AAPL/"); + } + + @Test + void expirationsAttachesStrikeAndDateFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options + .expirationsAsync( + OptionsExpirationsRequest.builder("AAPL") + .strike(150.0) + .date(LocalDate.of(2024, Month.JANUARY, 17)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo("http://localhost/v1/options/expirations/AAPL/?strike=150.0&date=2024-01-17"); + } + + @Test + void expirationsSkipsNullFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + options.expirationsAsync(OptionsExpirationsRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/expirations/AAPL/"); + } + + // ---------- expirations: response decoding ---------- + + @Test + void expirationsDecodesEpochsToMarketMidnights() { + // The API serializes expiration dates as the epoch for midnight America/New_York of that day + // (confirmed against the Python SDK fixtures + DateHelper.format_date in the backend). Derive + // the epochs from LocalDate so the test is robust to DST shifts and never carries a "what + // does 1737090000 mean" magic-number question. + ZoneId et = ZoneId.of("America/New_York"); + long e1 = LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et).toEpochSecond(); + long e2 = LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et).toEpochSecond(); + long e3 = LocalDate.of(2025, Month.MARCH, 21).atStartOfDay(et).toEpochSecond(); // after DST + long updated = + LocalDate.of(2025, Month.JANUARY, 16).atStartOfDay(et).toEpochSecond() + 19 * 3600; + + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"expirations\":[" + + e1 + + "," + + e2 + + "," + + e3 + + "],\"updated\":" + + updated + + "}"); + OptionsResource options = resourceWith(client); + + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + + assertThat(exps) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et), + LocalDate.of(2025, Month.MARCH, 21).atStartOfDay(et)); + assertThat(exps.get(0).getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); + assertThat(expResp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(expResp.updated().getHour()).isEqualTo(19); + } + + @Test + void expirationsSyncMirrorsAsync() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[1737072000],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps).hasSize(1); + } + + // ---------- expirations: envelope handling ---------- + + @Test + void expirationsNoDataEnvelopeYieldsEmptyList() { + CapturingClient client = okWith("{\"s\":\"no_data\"}"); + OptionsResource options = resourceWith(client); + + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("NOPE")); + List exps = expResp.values(); + assertThat(exps).isEmpty(); + assertThat(expResp.updated()).isNull(); + } + + @Test + void expirationsErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Underlying not found\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Underlying not found"); + } + + @Test + void expirationsMissingUpdatedThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"expirations\":[1737072000]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("updated"); + } + + @Test + void expirationsAcceptsTimestampStringFormat() { + // dateformat=timestamp returns ISO date strings for expirations and "yyyy-MM-dd HH:mm:ss XXX" + // for updated — the deserializer detects via JSON node type and converts to native types. + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"expirations\":[\"2025-01-17\",\"2025-02-21\"]," + + "\"updated\":\"2025-01-16 19:00:00 -05:00\"}"); + OptionsResource options = resourceWith(client); + + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + assertThat(expResp.updated().getHour()).isEqualTo(19); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void expirationsAcceptsSpreadsheetSerialFormat() { + // Spreadsheet serials are days since 1899-12-30 UTC. 2025-01-17 = 45674. + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[45674,45709],\"updated\":45673.79166667}"); + OptionsResource options = resourceWith(client); + + OptionsExpirationsResponse expResp = options.expirations(OptionsExpirationsRequest.of("AAPL")); + List exps = expResp.values(); + assertThat(exps) + .containsExactly( + LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et), + LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(expResp.updated()).isNotNull(); + assertThat(expResp.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void expirationsInvalidDateCellThrowsParseError() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"expirations\":[\"not-a-date\"],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-ISO date string"); + } + + @Test + void expirationsBooleanCellThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"expirations\":[true],\"updated\":1705449600}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.expirations(OptionsExpirationsRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-string, non-numeric"); + } + + // ---------- strikes: URL & params ---------- + + @Test + void strikesHitsVersionedEndpoint() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[140,145,150]}"); + OptionsResource options = resourceWith(client); + + options.strikesAsync(OptionsStrikesRequest.of("AAPL")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/strikes/AAPL/"); + } + + @Test + void strikesAttachesExpirationAndDateFilters() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[140,145]}"); + OptionsResource options = resourceWith(client); + + options + .strikesAsync( + OptionsStrikesRequest.builder("AAPL") + .expiration(LocalDate.of(2025, Month.JANUARY, 17)) + .date(LocalDate.of(2024, Month.DECEMBER, 16)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/strikes/AAPL/?expiration=2025-01-17&date=2024-12-16"); + } + + // ---------- strikes: response decoding ---------- + + @Test + void strikesDecodesMultipleExpirations() { + ZoneId et = ZoneId.of("America/New_York"); + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"updated\":1705449600," + + "\"2025-01-17\":[140.0,145.0,150.0]," + + "\"2025-02-21\":[135.0,140.0,145.0,150.0]}"); + OptionsResource options = resourceWith(client); + + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("AAPL")); + List strikes = resp.values(); + + assertThat(strikes).hasSize(2); + ExpirationStrikes first = strikes.get(0); + assertThat(first.expiration()) + .isEqualTo(LocalDate.of(2025, Month.JANUARY, 17).atStartOfDay(et)); + assertThat(first.strikes()).containsExactly(140.0, 145.0, 150.0); + ExpirationStrikes second = strikes.get(1); + assertThat(second.expiration()) + .isEqualTo(LocalDate.of(2025, Month.FEBRUARY, 21).atStartOfDay(et)); + assertThat(second.strikes()).containsExactly(135.0, 140.0, 145.0, 150.0); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void strikesAcceptsTimestampStringFormatForUpdated() { + // The expiration keys are ALWAYS literal ISO dates regardless of dateformat (the backend + // emits str(date) for the key, ignoring the format param). Only `updated` honors dateformat. + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":\"2025-01-16 19:00:00 -05:00\",\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("AAPL")); + List strikes = resp.values(); + assertThat(strikes).hasSize(1); + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 16)); + } + + // ---------- strikes: envelope handling ---------- + + @Test + void strikesNoDataEnvelopeYieldsEmptyList() { + // The strikes endpoint also attaches nextTime/prevTime hints in no_data envelopes; the + // deserializer ignores them (they aren't part of the typed surface). + CapturingClient client = okWith("{\"s\":\"no_data\",\"nextTime\":null,\"prevTime\":null}"); + OptionsResource options = resourceWith(client); + + OptionsStrikesResponse resp = options.strikes(OptionsStrikesRequest.of("BOGUS")); + List strikes = resp.values(); + assertThat(strikes).isEmpty(); + assertThat(resp.updated()).isNull(); + } + + @Test + void strikesErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Symbol not found\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Symbol not found"); + } + + @Test + void strikesMissingUpdatedThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("updated"); + } + + @Test + void strikesUnrecognizedTopLevelKeyThrowsParseError() { + // Strict-by-default — a non-date, non-{s,updated} key signals server change. Surfacing it + // gives us a diagnostic breadcrumb instead of silently dropping data. + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"surprise\":[1.0]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("unrecognized top-level key: surprise"); + } + + @Test + void strikesNonNumericStrikeThrowsParseError() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[\"oops\"]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-numeric strike"); + } + + @Test + void strikesNonArrayExpirationValueThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":150.0}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.strikes(OptionsStrikesRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("non-array value for expiration"); + } + + @Test + void strikesSyncMirrorsAsync() { + CapturingClient client = okWith("{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[150.0]}"); + OptionsResource options = resourceWith(client); + + List strikes = options.strikes(OptionsStrikesRequest.of("AAPL")).values(); + assertThat(strikes).hasSize(1); + } + + // ---------- quote (singular): URL & params ---------- + + private static final String CANNED_QUOTE_BODY = + "{\"s\":\"ok\"," + + "\"optionSymbol\":[\"AAPL250117C00150000\"]," + + "\"underlying\":[\"AAPL\"]," + + "\"expiration\":[1737136800]," + + "\"side\":[\"call\"]," + + "\"strike\":[150]," + + "\"firstTraded\":[1663118400]," + + "\"dte\":[45]," + + "\"updated\":[1705449600]," + + "\"bid\":[52.1],\"bidSize\":[10],\"mid\":[52.35],\"ask\":[52.6],\"askSize\":[15]," + + "\"last\":[52.3],\"openInterest\":[5000],\"volume\":[1500]," + + "\"inTheMoney\":[true],\"intrinsicValue\":[50.22],\"extrinsicValue\":[2.13]," + + "\"underlyingPrice\":[200.22]," + + "\"iv\":[0.3012],\"delta\":[0.89],\"gamma\":[0.012],\"theta\":[-0.05],\"vega\":[0.15]}"; + + @Test + void quoteHitsVersionedEndpointWithEncodedSymbol() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options.quoteAsync(OptionsQuoteRequest.of("AAPL250117C00150000")).join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).isEqualTo("http://localhost/v1/options/quotes/AAPL250117C00150000/"); + } + + @Test + void quoteAttachesDateFilter() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .date(LocalDate.of(2025, Month.JANUARY, 15)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo("http://localhost/v1/options/quotes/AAPL250117C00150000/?date=2025-01-15"); + } + + @Test + void quoteAttachesFromToFilters() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/quotes/AAPL250117C00150000/" + + "?from=2024-12-01&to=2025-01-01"); + } + + @Test + void quoteRequestRejectsDateAndFromToTogether() { + 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"); + } + + @Test + void quoteAttachesCountbackWithTo() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .quoteAsync( + OptionsQuoteRequest.builder("AAPL250117C00150000") + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(5) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .isEqualTo( + "http://localhost/v1/options/quotes/AAPL250117C00150000/?to=2025-01-01&countback=5"); + } + + @Test + void quoteRequestRejectsCountbackWithDate() { + 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()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback and from"); + } + + @Test + void quoteRequestRejectsNonPositiveCountback() { + assertThatThrownBy(() -> OptionsQuoteRequest.builder("X").countback(0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback must be positive"); + } + + // ---------- quote: response decoding ---------- + + @Test + void quoteDecodesAllFields() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + List response = + options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values(); + + assertThat(response).hasSize(1); + OptionQuote q = response.get(0); + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.underlying()).isEqualTo("AAPL"); + assertThat(q.side()).isEqualTo("call"); + assertThat(q.strike()).isEqualTo(150.0); + assertThat(q.dte()).isEqualTo(45); + assertThat(q.bid()).isEqualTo(52.1); + assertThat(q.bidSize()).isEqualTo(10L); + assertThat(q.mid()).isEqualTo(52.35); + assertThat(q.ask()).isEqualTo(52.6); + assertThat(q.askSize()).isEqualTo(15L); + assertThat(q.last()).isEqualTo(52.3); + assertThat(q.openInterest()).isEqualTo(5000L); + assertThat(q.volume()).isEqualTo(1500L); + assertThat(q.inTheMoney()).isTrue(); + assertThat(q.intrinsicValue()).isEqualTo(50.22); + assertThat(q.extrinsicValue()).isEqualTo(2.13); + assertThat(q.underlyingPrice()).isEqualTo(200.22); + assertThat(q.iv()).isEqualTo(0.3012); + assertThat(q.delta()).isEqualTo(0.89); + assertThat(q.gamma()).isEqualTo(0.012); + assertThat(q.theta()).isEqualTo(-0.05); + assertThat(q.vega()).isEqualTo(0.15); + // rho is an optional column absent from CANNED_QUOTE_BODY — it must decode to null, not 0.0, + // and the missing column must not trip the strict parallel-arrays parser. + assertThat(q.rho()).isNull(); + assertThat(java.util.Objects.requireNonNull(q.expiration()).getZone().getId()) + .isEqualTo("America/New_York"); + assertThat(java.util.Objects.requireNonNull(q.firstTraded()).getZone().getId()) + .isEqualTo("America/New_York"); + assertThat(java.util.Objects.requireNonNull(q.updated()).getZone().getId()) + .isEqualTo("America/New_York"); + } + + @Test + void quoteDecodesRhoWhenPresent() { + String bodyWithRho = + CANNED_QUOTE_BODY.replace("\"vega\":[0.15]}", "\"vega\":[0.15],\"rho\":[0.0456]}"); + CapturingClient client = okWith(bodyWithRho); + OptionsResource options = resourceWith(client); + + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); + + assertThat(q.rho()).isEqualTo(0.0456); + } + + @Test + void quoteDecodesNullModelValuesAsNull() { + // Historical / illiquid rows legitimately carry null iv + greeks (no model output that day). + // The columns are present, only the cell values are null — they must decode to null rather than + // tripping the strict parallel-arrays parser. Reproduces the live-API ParseError seen on a + // countback query for a thinly-traded contract. + String body = + CANNED_QUOTE_BODY.replace( + "\"iv\":[0.3012],\"delta\":[0.89],\"gamma\":[0.012],\"theta\":[-0.05],\"vega\":[0.15]}", + "\"iv\":[null],\"delta\":[null],\"gamma\":[null],\"theta\":[null],\"vega\":[null]}"); + CapturingClient client = okWith(body); + OptionsResource options = resourceWith(client); + + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); + + assertThat(q.iv()).isNull(); + assertThat(q.delta()).isNull(); + assertThat(q.gamma()).isNull(); + assertThat(q.theta()).isNull(); + assertThat(q.vega()).isNull(); + assertThat(q.rho()).isNull(); + // Market-data fields on the same row stay populated and primitive. + assertThat(q.bid()).isEqualTo(52.1); + } + + @Test + void quoteSyncMirrorsAsync() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + List data = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values(); + assertThat(data).hasSize(1); + } + + @Test + void quoteErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Unknown contract\"}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.quote(OptionsQuoteRequest.of("BOGUS"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Unknown contract"); + } + + // ---------- columns projection + Option A + greeks ---------- + + @Test + void columnsProjectionDecodesRequestedAndNullsTheRest() { + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + OptionQuote q = + options + .columns("optionSymbol", "strike") + .quote(OptionsQuoteRequest.of("AAPL250117C00150000")) + .values() + .get(0); + + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.strike()).isEqualTo(150.0); + // Projected away → null, no error. + assertThat(q.bid()).isNull(); + assertThat(q.volume()).isNull(); + // The columns param reached the wire (comma is URL-encoded, so decode before asserting). + String decodedUri = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedUri).contains("columns=optionSymbol,strike"); + } + + @Test + void columnsRequestedButOmittedByApiThrowsParseError() { + // Option A: asked for bid via columns, but the API didn't return it → loud failure, not a null. + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy( + () -> + options + .columns("optionSymbol", "strike", "bid") + .quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("bid"); + } + + @Test + void noColumnsFilterStillRequiresAllStructuralColumns() { + // With no columns projection every required column is implicitly requested, so a missing one is + // still a ParseError (the strict-by-default guarantee survives the nullable fields). + CapturingClient client = + okWith("{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}"); + OptionsResource options = resourceWith(client); + + assertThatThrownBy(() -> options.quote(OptionsQuoteRequest.of("AAPL250117C00150000"))) + .isInstanceOf(ParseError.class); + } + + @Test + void presentGreeksReportsNonNullGreeks() { + // CANNED_QUOTE_BODY carries delta/gamma/theta/vega but not rho. + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionQuote q = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).values().get(0); + + assertThat(q.presentGreeks()) + .containsExactlyInAnyOrder(Greek.DELTA, Greek.GAMMA, Greek.THETA, Greek.VEGA); + assertThat(q.greek(Greek.DELTA)).isEqualTo(0.89); + assertThat(q.greek(Greek.RHO)).isNull(); + } + + // ---------- CSV / HTML facets ---------- + + @Test + void asCsvSendsFormatCsvAndReturnsRawText() { + CapturingClient client = okWith("optionSymbol,strike\nAAPL250117C00150000,150"); + OptionsResource options = resourceWith(client); + + CsvResponse csv = options.asCsv().chain(OptionsChainRequest.of("AAPL")); + + assertThat(csv.csv()).contains("optionSymbol,strike"); + assertThat(csv.values()).isEqualTo(csv.csv()); + assertThat(csv.isCsv()).isTrue(); + assertThat(client.captured.get(0).uri().toString()).contains("format=csv"); + } + + @Test + void asHtmlFacetSendsFormatHtml() { + // asHtml() is package-private (built, not exposed) — exercised here from the same package. + CapturingClient client = okWith("chain"); + OptionsResource options = resourceWith(client); + + HtmlResponse html = options.asHtml().chain(OptionsChainRequest.of("AAPL")); + + assertThat(html.html()).contains(""); + assertThat(client.captured.get(0).uri().toString()).contains("format=html"); + } + + @Test + void csvFacetUniversalAndShapingParamsReachTheWire() { + CapturingClient client = okWith("optionSymbol,strike\nAAPL250117C00150000,150"); + OptionsResource options = resourceWith(client); + + options + .asCsv() + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .columns("optionSymbol", "strike") + .human(true) // output-shaping — CSV-only + .headers(true) // output-shaping — CSV-only + .chain(OptionsChainRequest.of("AAPL")); + + String url = + java.net.URLDecoder.decode( + client.captured.get(0).uri().toString(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(url) + .contains("format=csv") + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("limit=50") + .contains("offset=10") + .contains("columns=optionSymbol,strike") + .contains("human=true") + .contains("headers=true"); + } + + @Test + void csvFacetCoversEveryEndpoint() { + CapturingClient client = okWith("a,b\n1,2"); + OptionsCsvResource csv = resourceWith(client).asCsv(); + + assertThat(csv.chain(OptionsChainRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).csv()).contains("a,b"); + assertThat(csv.strikes(OptionsStrikesRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.expirations(OptionsExpirationsRequest.of("AAPL")).csv()).contains("a,b"); + + Map fanout = + csv.quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + assertThat(fanout).hasSize(2); + assertThat(fanout.values()).allSatisfy(r -> assertThat(r.csv()).contains("a,b")); + } + + @Test + void htmlFacetCoversEveryEndpoint() { + CapturingClient client = okWith("x"); + OptionsHtmlResource html = resourceWith(client).asHtml(); + + assertThat(html.chain(OptionsChainRequest.of("AAPL")).html()).contains(""); + assertThat(html.quote(OptionsQuoteRequest.of("AAPL250117C00150000")).html()).contains(""); + assertThat(html.strikes(OptionsStrikesRequest.of("AAPL")).html()).contains(""); + assertThat(html.expirations(OptionsExpirationsRequest.of("AAPL")).html()).contains(""); + } + + // ---------- response metadata (§13.5 / §16) ---------- + + @Test + void responseToStringRedactsQueryString() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = + options.quote(OptionsQuoteRequest.builder("AAPL250117C00150000").countback(5).build()); + + // §16: the query string is redacted in toString (logged as /path?…) so params never persist. + assertThat(resp.toString()).contains("?…").doesNotContain("countback"); + } + + @Test + void responseSaveToFileWritesRawBodyVerbatim( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws java.io.IOException { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + java.nio.file.Path out = dir.resolve("quote.json"); + resp.saveToFile(out); + + assertThat(java.nio.file.Files.readString(out)) + .isEqualTo(CANNED_QUOTE_BODY) + .isEqualTo(resp.json()); + } + + @Test + void universalParamsReachTheWire() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + options + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("limit=50") + .contains("offset=10"); + } + + @Test + void responseExposesRequestIdAndUrlMetadata() { + CapturingClient client = okWith(CANNED_QUOTE_BODY); + OptionsResource options = resourceWith(client); + + OptionsQuotesResponse resp = options.quote(OptionsQuoteRequest.of("AAPL250117C00150000")); + assertThat(resp.requestId()).isNull(); // the mock sends no cf-ray header + assertThat(resp.requestUrl().toString()).contains("/v1/options/quotes/"); + } + + // ---------- quotes (plural, multi-symbol) ---------- + + @Test + void quotesFansOutOnePerSymbolAndPreservesOrder() { + // Cancellable-but-canned client that responds based on the requested URL — lets us route each + // fan-out per symbol to a distinct body. Without this, all fan-outs would get the same canned + // bytes and the test wouldn't observe per-symbol responses. + SymbolRoutingClient client = + new SymbolRoutingClient( + Map.of( + "AAPL250117C00150000", + CANNED_QUOTE_BODY, + "AAPL250117P00150000", + CANNED_QUOTE_BODY + .replace("AAPL250117C00150000", "AAPL250117P00150000") + .replace("\"side\":[\"call\"]", "\"side\":[\"put\"]"))); + + OptionsResource options = resourceWith(client); + + Map result = + options.quotes( + OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); + + // Insertion order preserved — first symbol in the builder is first in the iteration. + assertThat(result.keySet()).containsExactly("AAPL250117C00150000", "AAPL250117P00150000"); + assertThat(result.get("AAPL250117C00150000").values().get(0).side()).isEqualTo("call"); + assertThat(result.get("AAPL250117P00150000").values().get(0).side()).isEqualTo("put"); + + // Two HTTP requests were sent. + assertThat(client.captured).hasSize(2); + } + + @Test + void quotesAttachesDateFilterToEachFanOut() { + SymbolRoutingClient client = + new SymbolRoutingClient(Map.of("X", CANNED_QUOTE_BODY, "Y", CANNED_QUOTE_BODY)); + OptionsResource options = resourceWith(client); + + options.quotes( + OptionsQuotesRequest.builder("X", "Y").date(LocalDate.of(2025, Month.JANUARY, 1)).build()); + + assertThat(client.captured).hasSize(2); + for (HttpRequest req : client.captured) { + assertThat(req.uri().toString()).endsWith("?date=2025-01-01"); + } + } + + @Test + void quotesAttachesCountbackToEachFanOut() { + SymbolRoutingClient client = + new SymbolRoutingClient(Map.of("X", CANNED_QUOTE_BODY, "Y", CANNED_QUOTE_BODY)); + OptionsResource options = resourceWith(client); + + options.quotes( + OptionsQuotesRequest.builder("X", "Y") + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(3) + .build()); + + assertThat(client.captured).hasSize(2); + for (HttpRequest req : client.captured) { + assertThat(req.uri().toString()).endsWith("?to=2025-01-01&countback=3"); + } + } + + @Test + void quotesRequestRequiresAtLeastOneSymbol() { + // The static factory takes the first symbol non-optionally, so there is no way to construct + // an empty Builder from public API. The internal Builder constructor is private; this test + // documents the public-API contract via the static factory. + assertThatThrownBy(() -> OptionsQuotesRequest.builder("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + // ---------- chain: URL & filters ---------- + + /** Reuses the canned single-row body from quotes — same wire schema. */ + private static final String CANNED_CHAIN_BODY = CANNED_QUOTE_BODY; + + @Test + void chainHitsVersionedEndpointWithNoFilters() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chainAsync(OptionsChainRequest.of("AAPL")).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/"); + } + + @Test + void chainExpirationFilterOnDateTranslatesToExpirationParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.onDate(LocalDate.of(2025, Month.JANUARY, 17))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?expiration=2025-01-17"); + } + + @Test + void chainExpirationFilterDteTranslatesToDteParam() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.dte(30)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?dte=30"); + } + + @Test + void chainExpirationFilterBetweenTranslatesToFromTo() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter( + ExpirationFilter.between( + LocalDate.of(2025, Month.JANUARY, 1), LocalDate.of(2025, Month.MARCH, 31))) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?from=2025-01-01&to=2025-03-31"); + } + + @Test + void chainExpirationFilterAllTranslatesToExpirationAll() { + // expiration=all returns the full chain — distinct from omitting the filter, which the API + // narrows to the front-month expiration. + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").expirationFilter(ExpirationFilter.all()).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?expiration=all"); + } + + @Test + void chainExpirationFilterMonthYearTranslatesToMonthYear() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.monthYear(2025, 3)) + .build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?month=3&year=2025"); + } + + @Test + void chainStrikeFilterExactRendersAsBareNumber() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeFilter(StrikeFilter.exact(150)).build()) + .join(); + + // Integer-valued strikes render without decimal noise so the wire matches the API docs + // verbatim. + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?strike=150"); + } + + @Test + void chainStrikeFilterRangeRendersAsDashed() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeFilter(StrikeFilter.range(140, 160)).build()) + .join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/options/chain/AAPL/?strike=140-160"); + } + + @Test + void chainStrikeFilterComparisonRendersWithOperatorPrefix() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .strikeFilter(StrikeFilter.comparison(StrikeFilter.Operator.GTE, 150)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + // "%3E%3D" is the URL-encoded ">=" — the query encoder pushes reserved characters through. + assertThat(url).contains("strike=%3E%3D150"); + } + + @Test + void chainBooleanAdditiveFiltersStack() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .weekly(true) + .monthly(false) + .quarterly(true) + .nonstandard(false) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("weekly=true"); + assertThat(url).contains("monthly=false"); + assertThat(url).contains("quarterly=true"); + assertThat(url).contains("nonstandard=false"); + } + + @Test + void chainSideEnumTranslatesToWireValue() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options.chainAsync(OptionsChainRequest.builder("AAPL").side(OptionSide.PUT).build()).join(); + + assertThat(client.captured.get(0).uri().toString()).endsWith("?side=put"); + } + + @Test + void chainStrikeLimitAndRangeTranslate() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL").strikeLimit(4).strikeRange(StrikeRange.OTM).build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("strikeLimit=4"); + assertThat(url).contains("range=otm"); + } + + @Test + void chainLiquidityFiltersTranslate() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + options + .chainAsync( + OptionsChainRequest.builder("AAPL") + .minOpenInterest(500) + .minVolume(100) + .maxBidAskSpread(0.5) + .maxBidAskSpreadPct(0.1) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("minOpenInterest=500"); + assertThat(url).contains("minVolume=100"); + assertThat(url).contains("maxBidAskSpread=0.5"); + assertThat(url).contains("maxBidAskSpreadPct=0.1"); + } + + // ---------- chain: response decoding ---------- + + @Test + void chainDecodesSingleRowResponse() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + List chain = options.chain(OptionsChainRequest.of("AAPL")).values(); + assertThat(chain).hasSize(1); + OptionQuote q = chain.get(0); + assertThat(q.optionSymbol()).isEqualTo("AAPL250117C00150000"); + assertThat(q.delta()).isEqualTo(0.89); + } + + @Test + void chainSyncMirrorsAsync() { + CapturingClient client = okWith(CANNED_CHAIN_BODY); + OptionsResource options = resourceWith(client); + + List chain = options.chain(OptionsChainRequest.of("AAPL")).values(); + assertThat(chain).hasSize(1); + } + + // ---------- chain: builder validation ---------- + + @Test + void chainRequestRejectsMinBidGreaterThanMaxBid() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minBid(10.0).maxBid(5.0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minBid must be <= maxBid"); + } + + @Test + void chainRequestRejectsMinAskGreaterThanMaxAsk() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minAsk(10.0).maxAsk(5.0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minAsk must be <= maxAsk"); + } + + @Test + void chainRequestRejectsNegativeStrikeLimit() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").strikeLimit(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strikeLimit must be positive"); + } + + @Test + void chainRequestRejectsNegativeMinOpenInterest() { + assertThatThrownBy(() -> OptionsChainRequest.builder("AAPL").minOpenInterest(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minOpenInterest must be non-negative"); + } + + // ---------- sealed type factory validation ---------- + + @Test + void expirationFilterDteRejectsNegative() { + assertThatThrownBy(() -> ExpirationFilter.dte(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dte must be non-negative"); + } + + @Test + void expirationFilterBetweenRejectsReversedDates() { + 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"); + } + + @Test + void expirationFilterMonthYearRejectsInvalidMonth() { + assertThatThrownBy(() -> ExpirationFilter.monthYear(2025, 13)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("month must be in 1..12"); + } + + @Test + void strikeFilterRangeRejectsMinGreaterThanMax() { + assertThatThrownBy(() -> StrikeFilter.range(160, 140)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("min must be <= max"); + } + + // ---------- helpers ---------- + + private static CapturingClient okWith(String body) { + return new CapturingClient(200, body.getBytes(), EMPTY_HEADERS); + } + + /** + * Test double that routes the request body based on the last non-empty path segment of the URL — + * lets a single test exercise multi-symbol fan-out where each fan-out should observe a distinct + * canned response. Falls back to a 200 OK with empty {@code "{}"} for an unmapped path. + */ + private static final class SymbolRoutingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final Map bodies; + + SymbolRoutingClient(Map bodies) { + this.bodies = bodies; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + String[] parts = request.uri().getPath().split("/"); + String tail = ""; + for (int i = parts.length - 1; i >= 0; i--) { + if (!parts[i].isEmpty()) { + tail = parts[i]; + break; + } + } + String body = bodies.getOrDefault(tail, "{\"s\":\"no_data\"}"); + HttpResponse resp = + TestHttpClients.response( + 200, body.getBytes(), EMPTY_HEADERS, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } + + private static final class CapturingClient extends TestHttpClients.StubHttpClient { + final List captured = new ArrayList<>(); + final int status; + final byte[] body; + final HttpHeaders headers; + + CapturingClient(int status, byte[] body, HttpHeaders headers) { + this.status = status; + this.body = body; + this.headers = headers; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler bh) { + captured.add(request); + HttpResponse resp = + TestHttpClients.response(status, body, headers, URI.create("http://localhost")); + return (CompletableFuture) CompletableFuture.completedFuture(resp); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/PathSegmentsTest.java b/src/test/java/com/marketdata/sdk/PathSegmentsTest.java new file mode 100644 index 0000000..f8b1f0f --- /dev/null +++ b/src/test/java/com/marketdata/sdk/PathSegmentsTest.java @@ -0,0 +1,47 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PathSegmentsTest { + + @Test + void leavesAsciiSymbolUntouched() { + assertThat(PathSegments.encode("AAPL")).isEqualTo("AAPL"); + assertThat(PathSegments.encode("AAPL230726C00200000")).isEqualTo("AAPL230726C00200000"); + } + + @Test + void encodesSpacesAsPercent20NotPlus() { + // %20 is the path-context encoding; "+" is the application/x-www-form-urlencoded dialect that + // a strict path parser would treat literally. + assertThat(PathSegments.encode("BRK A")).isEqualTo("BRK%20A"); + } + + @Test + void encodesReservedAndUnicode() { + assertThat(PathSegments.encode("$200")).isEqualTo("%24200"); + assertThat(PathSegments.encode("café")).isEqualTo("caf%C3%A9"); + } + + @Test + void preservesSlashesAsSegmentSeparators() { + // Slashes are kept because some endpoints accept multi-segment user input and the backend's + // catch-all regex matches across them. + assertThat(PathSegments.encode("AAPL 7/26/23 $200 Call")) + .isEqualTo("AAPL%207/26/23%20%24200%20Call"); + } + + @Test + void handlesLeadingTrailingAndDoubleSlashes() { + assertThat(PathSegments.encode("/leading")).isEqualTo("/leading"); + assertThat(PathSegments.encode("trailing/")).isEqualTo("trailing/"); + assertThat(PathSegments.encode("a//b")).isEqualTo("a//b"); + } + + @Test + void emptyStringReturnsEmpty() { + assertThat(PathSegments.encode("")).isEqualTo(""); + } +} diff --git a/src/test/java/com/marketdata/sdk/ResponseTest.java b/src/test/java/com/marketdata/sdk/ResponseTest.java deleted file mode 100644 index 8f46b87..0000000 --- a/src/test/java/com/marketdata/sdk/ResponseTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.marketdata.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URI; -import java.net.http.HttpHeaders; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class ResponseTest { - - private static HttpResponseEnvelope env(byte[] body, int status, String url) { - return new HttpResponseEnvelope( - body, status, "req-id-123", HttpHeaders.of(Map.of(), (a, b) -> true), URI.create(url)); - } - - // ---------- typed accessors ---------- - - @Test - void exposesDataStatusAndUrl() { - Response r = - Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); - - assertThat(r.data()).isEqualTo("payload"); - assertThat(r.statusCode()).isEqualTo(200); - assertThat(r.requestUrl()).isEqualTo(URI.create("http://x/y")); - assertThat(r.requestId()).isEqualTo("req-id-123"); - } - - @Test - void requestIdNullWhenServerOmitsIt() { - HttpResponseEnvelope e = - new HttpResponseEnvelope( - "x".getBytes(), - 200, - null, - HttpHeaders.of(Map.of(), (a, b) -> true), - URI.create("http://x")); - Response r = Response.wrap("data", e, Format.JSON); - - assertThat(r.requestId()).isNull(); - } - - // ---------- format detection ---------- - - @Test - void formatDetectionExposesBooleansOnly() { - // The Format enum itself is package-private; consumers only see the booleans. - Response json = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.JSON); - Response csv = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.CSV); - Response html = Response.wrap("a", env("a".getBytes(), 200, "http://x"), Format.HTML); - - assertThat(json.isJson()).isTrue(); - assertThat(json.isCsv()).isFalse(); - assertThat(json.isHtml()).isFalse(); - - assertThat(csv.isCsv()).isTrue(); - assertThat(csv.isJson()).isFalse(); - assertThat(csv.isHtml()).isFalse(); - - // §13.5: HTML detection — typically a misrouted request that landed on the web-server tier. - assertThat(html.isHtml()).isTrue(); - assertThat(html.isJson()).isFalse(); - assertThat(html.isCsv()).isFalse(); - } - - // ---------- no-data ---------- - - @Test - void isNoDataReflects404Convention() { - Response ok = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); - Response noData = Response.wrap("d", env("d".getBytes(), 404, "http://x"), Format.JSON); - - assertThat(ok.isNoData()).isFalse(); - assertThat(noData.isNoData()).isTrue(); - } - - // ---------- raw body immutability ---------- - - @Test - void rawBodyReturnsDefensiveCopy() { - byte[] source = "hello".getBytes(); - Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); - - byte[] firstCopy = r.rawBody(); - firstCopy[0] = 'X'; // mutate the returned array - byte[] secondCopy = r.rawBody(); - - assertThat(secondCopy[0]) - .as("internal state must not be affected by mutation") - .isEqualTo((byte) 'h'); - } - - @Test - void constructorCopiesIncomingRawBody() { - // Symmetric: the constructor must clone the input so mutations to the source after - // construction don't bleed into the Response. - byte[] source = "hello".getBytes(); - Response r = Response.wrap("ignored", env(source, 200, "http://x"), Format.JSON); - source[0] = 'X'; - - assertThat(r.rawBody()[0]).isEqualTo((byte) 'h'); - } - - // ---------- saveToFile ---------- - - @Test - void saveToFileWritesRawBodyVerbatim(@TempDir Path tmp) throws IOException { - byte[] body = "alpha,beta\n1,2\n".getBytes(); - Response r = Response.wrap("ignored", env(body, 200, "http://x"), Format.CSV); - - Path target = tmp.resolve("out.csv"); - r.saveToFile(target); - - assertThat(Files.readAllBytes(target)).isEqualTo(body); - } - - @Test - void saveToFileWrapsIoFailuresInUncheckedIoException(@TempDir Path tmp) { - Response r = Response.wrap("d", env("d".getBytes(), 200, "http://x"), Format.JSON); - - // A non-existent parent directory triggers NoSuchFileException — the wrapper turns it into - // UncheckedIOException so the call fits in a fluent chain without checked-exception noise. - Path inaccessible = tmp.resolve("does-not-exist").resolve("out.txt"); - - assertThatThrownBy(() -> r.saveToFile(inaccessible)) - .isInstanceOf(UncheckedIOException.class) - .hasMessageContaining(inaccessible.toString()); - } - - // ---------- toString ---------- - - @Test - void toStringIncludesStatusFormatBytesAndUrl() { - Response r = - Response.wrap("payload", env("body".getBytes(), 200, "http://x/y"), Format.JSON); - - String repr = r.toString(); - - // safeUri emits the path only (its contract): host/scheme are uninteresting in a log line and - // omitting them mirrors what HttpDispatcher already does for ambient request logs. - assertThat(repr) - .contains("status=200") - .contains("format=json") - .contains("bytes=4") - .contains("url=/y"); - } - - /** - * Issue #38: {@code toString} is a routine logging surface. The typed payload may carry sensitive - * content (e.g. a {@code RequestHeaders} map with {@code authorization} or client IPs), so it - * must not be embedded. Consumers that want the payload have {@link Response#data()}. - */ - @Test - void toStringDoesNotIncludeDataPayload() { - Response r = - Response.wrap( - "sensitive-payload-do-not-leak", - env("body".getBytes(), 200, "http://x/y"), - Format.JSON); - - assertThat(r.toString()).doesNotContain("sensitive-payload-do-not-leak"); - } - - /** - * Issue #38 + §16: query strings (tokens, account_ids, symbols) must not survive through {@code - * toString}. The full URI is still available via {@link Response#requestUrl()}. - */ - @Test - void toStringRedactsQueryStringInUrl() { - Response r = - Response.wrap( - "data", - env("body".getBytes(), 200, "http://x/quotes/?token=secret&symbol=AAPL"), - Format.JSON); - - String repr = r.toString(); - - assertThat(repr).doesNotContain("secret").doesNotContain("AAPL"); - assertThat(repr).contains("?…"); - } -} diff --git a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java index 6a9601c..28dbd84 100644 --- a/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java +++ b/src/test/java/com/marketdata/sdk/UtilitiesResourceTest.java @@ -4,8 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; +import com.marketdata.sdk.utilities.ServiceStatus; import com.marketdata.sdk.utilities.User; import java.net.URI; import java.net.http.HttpClient; @@ -67,9 +67,9 @@ void headersAsyncReturnsDecodedRecord() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headersAsync().join().data(); + Map rh = utilities.headersAsync().join().values(); - assertThat(rh.headers()) + assertThat(rh) .containsEntry("accept", "*/*") .containsEntry("authorization", "Bearer ***REDACTED***") .containsEntry("cf-ray", "abc-123-xyz"); @@ -82,9 +82,9 @@ void headersSyncMirrorsHeadersAsync() { 200, "{\"x\":\"1\"}".getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - RequestHeaders rh = utilities.headers().data(); + Map rh = utilities.headers().values(); - assertThat(rh.headers()).containsEntry("x", "1"); + assertThat(rh).containsEntry("x", "1"); } // ---------- /user/ endpoint ---------- @@ -119,7 +119,7 @@ void userAsyncReturnsDecodedRecord() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.userAsync().join().data(); + User u = utilities.userAsync().join().values(); assertThat(u.requestsRemaining()).isEqualTo(42); assertThat(u.requestsLimit()).isEqualTo(100); @@ -137,7 +137,7 @@ void userSyncMirrorsAsync() { HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - User u = utilities.user().data(); + User u = utilities.user().values(); assertThat(u.requestsRemaining()).isEqualTo(7); } @@ -187,13 +187,13 @@ void statusAsyncReturnsZippedServiceList() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.statusAsync().join().data(); + List status = utilities.statusAsync().join().values(); - assertThat(status.services()).hasSize(2); - assertThat(status.services().get(0).service()).isEqualTo("/v1/a/"); - assertThat(status.services().get(0).online()).isTrue(); - assertThat(status.services().get(1).service()).isEqualTo("/v1/b/"); - assertThat(status.services().get(1).online()).isFalse(); + assertThat(status).hasSize(2); + assertThat(status.get(0).service()).isEqualTo("/v1/a/"); + assertThat(status.get(0).online()).isTrue(); + assertThat(status.get(1).service()).isEqualTo("/v1/b/"); + assertThat(status.get(1).online()).isFalse(); } @Test @@ -205,18 +205,18 @@ void statusSyncMirrorsAsync() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - ApiStatus status = utilities.status().data(); + List status = utilities.status().values(); - assertThat(status.services()).hasSize(1); + assertThat(status).hasSize(1); } // ---------- Response wrapper composition ---------- /** * The resource layer is responsible for composing typed model + raw body + format into a {@link - * Response}. This verifies the wiring end-to-end: the bytes from the wire reach {@code rawBody}, - * the request URL is preserved for support, and the format from the spec is reflected in the - * format accessors. + * MarketDataResponse}. This verifies the wiring end-to-end: the raw body is reachable via {@code + * json()}, the request URL is preserved for support, and the format from the spec is reflected in + * the format accessors. */ @Test void resourceWrapsTypedDataWithRawBodyAndMetadata() { @@ -225,10 +225,10 @@ void resourceWrapsTypedDataWithRawBodyAndMetadata() { new CapturingClient(200, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - Response r = utilities.headers(); + UtilitiesHeadersResponse r = utilities.headers(); - assertThat(r.data().headers()).containsEntry("x", "1"); - assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.values()).containsEntry("x", "1"); + assertThat(r.json()).isEqualTo(body); assertThat(r.statusCode()).isEqualTo(200); assertThat(r.isJson()).isTrue(); assertThat(r.isNoData()).isFalse(); @@ -239,7 +239,7 @@ void resourceWrapsTypedDataWithRawBodyAndMetadata() { /** * When the backend returns the no_data envelope with HTTP 404, the consumer must reach {@link - * Response#isNoData()} and {@link Response#data()} normally — no {@link + * MarketDataResponse#isNoData()} and {@link MarketDataResponse#values()} normally — no {@link * com.marketdata.sdk.exception.ParseError} from the parallel-array field validation. The data * payload is the typed model with an empty collection. */ @@ -250,12 +250,12 @@ void statusEndpointSurfaces404NoDataAsEmptyResponseInsteadOfParseError() { new CapturingClient(404, body.getBytes(), HttpHeaders.of(Map.of(), (a, b) -> true)); UtilitiesResource utilities = resourceWith(client); - Response r = utilities.status(); + UtilitiesStatusResponse r = utilities.status(); assertThat(r.statusCode()).isEqualTo(404); assertThat(r.isNoData()).isTrue(); - assertThat(r.data().services()).isEmpty(); - assertThat(new String(r.rawBody())).isEqualTo(body); + assertThat(r.values()).isEmpty(); + assertThat(r.json()).isEqualTo(body); } // ---------- error surfacing through sync ----------