diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3ea8a..6ef8153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,17 +26,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 down the call stack. ### Added +- **Stocks resource** (`client.stocks()`) — six endpoints, each in sync + async + form: `candles`, `quote` (single symbol), `quotes` and `prices` (multi-symbol, + batched into one request — one row per symbol, not a fan-out map like + `options.quotes`), `news`, and `earnings`. Every endpoint takes a Builder-based + per-endpoint request object. Candle resolution is a `StockResolution` value + type (`DAILY`, `minutes(15)`, `hours(1)`, …) rather than an enum, since the API + accepts an open-ended family of resolutions. Quote/price numeric fields are + nullable (the backend nulls them for a closed/illiquid market); the OHLC and + 52-week columns are opt-in via `candle` / `week52`. `news` exposes the feed's + scalar `updated()` off the response (distinct from each article's + `publicationDate`); `earnings` tolerates the nullable fundamentals/report fields + on synthesized forward-quarter rows. Mixed date/timestamp wire shapes (a daily + candle's date-only `t` vs. an intraday full timestamp) decode uniformly. Carries + the same universal-parameter setters, `columns` projection (with the Option A + strict guarantee), and `asCsv()` facet as `options`. Intraday candle requests + spanning more than ~one year are **auto-split** into year-sized sub-requests, + fetched concurrently through the 50-permit pool and merged into one response + (SDK requirements §12), on both the typed and CSV paths. +- **Per-response rate limits** — every `MarketDataResponse` now exposes + `rateLimit()` returning a `RateLimitSnapshot` parsed from that response's own + `x-api-ratelimit-*` headers (request-scoped, SDK requirements §8.2), distinct + from the client-level `MarketDataClient.getRateLimits()`. Applies to every + resource (options/utilities/stocks) and the CSV/HTML responses. - **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`). + `quotes` (multi-contract fan-out returning a per-symbol + `Map`), and `chain`. Every endpoint takes a + Builder-based per-endpoint request object (no `String` convenience overloads) + and returns a named typed response (`OptionsChainResponse`, + `OptionsLookupResponse`, …) implementing `MarketDataResponse` — the payload + is reached via `values()`. 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) plus the + `Greek` enum with `presentGreeks()` / `greek(Greek)` accessors, 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`). Universal parameters + (`dateFormat`/`mode`/`limit`/`offset`) and `columns` projection are set fluently + on the resource; a non-requested column decodes to `null`, while a required + column you *did* request that the API omits raises a `ParseError` (Option A). + The `asCsv()` facet returns CSV (`CsvResponse`) for every endpoint and adds the + output-shaping `human` / `headers` params. - 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 @@ -53,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ServerError`, `NetworkError`, `ParseError`), each carrying support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`) and a `getSupportInfo()` helper. -- `RateLimits` record exposed via `MarketDataClient.getRateLimits()`. +- `RateLimitSnapshot` record exposed via `MarketDataClient.getRateLimits()`. - JSpecify `@NullMarked` on every public package; JSpecify on `compileOnlyApi` so consumers get the annotations at compile time without a runtime dep. - Token redaction utility (`Tokens`, package-private in the SDK root) for diff --git a/CLAUDE.md b/CLAUDE.md index 4aff09b..8939a48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository state -This repo contains a working Java SDK in active development on branch `clean-architecture-restart`. Gradle 9.0 (Kotlin DSL) build per ADR-003; ~48 main + ~28 test source files; full CI matrix per ADR-002. The transport, retry, rate-limit, status-cache, and exception layers are wired; only the `utilities` resource façade is implemented today — stocks/options/funds/markets resources are still to come (see "Deliberately deferred" below). +This repo contains a working Java SDK in active development on branch `clean-architecture-restart`. Gradle 9.0 (Kotlin DSL) build per ADR-003; ~48 main + ~28 test source files; full CI matrix per ADR-002. The transport, retry, rate-limit, status-cache, and exception layers are wired; the `utilities`, `options`, and `stocks` resource façades are implemented today — funds/markets resources are still to come (see "Deliberately deferred" below). Sibling repo: `../api/` is the backend (Python/Django). The Python SDK lives at `../../sdk-py/` (referenced from ADRs). The cross-language `sdk-requirements.md` is referenced as `../sdk-requirements.md` from inside `docs/`; it is canonical but not committed in this repo. @@ -83,9 +83,9 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - Coverage ratchet lives in `codecov.yml`: project status with `target: auto, threshold: 5%` (cannot drop >5 pp vs base branch) plus a patch-coverage requirement of 70 % on new code. Requires a `CODECOV_TOKEN` repo secret — without it the upload step fails because workflows pass `fail_ci_if_error: true`. **Deliberately deferred (require the per-resource layer to land first):** -- §1.2 resource groupings — only `client.utilities()` is wired today; `client.stocks`, `client.options`, `client.funds`, `client.markets` still to come. -- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for resources beyond utilities. The plumbing (`ParallelArrays.zip` helper for the parallel-arrays shape, `JsonResponseParser`, `Response` wrapper) is in place — each new endpoint just declares its fields and row builder. -- §8 request-scoped rate-limit attachment — today the snapshot is client-level (via `client.getRateLimits()`); attaching a per-response snapshot to `Response` is a small follow-up when a consumer needs it. +- §1.2 resource groupings — `client.utilities()`, `client.options()`, and `client.stocks()` are wired today; `client.funds`, `client.markets` still to come. +- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for the remaining resources (funds, markets). The plumbing (`ParallelArrays.zip` helper for the parallel-arrays shape, `JsonResponseParser`, the `MarketDataResponse` named-response types) is in place — each new endpoint just declares its fields and row builder. +- ~~§8 request-scoped rate-limit attachment~~ **DONE** — every `MarketDataResponse` now exposes `rateLimit()`, parsed from that response's own `x-api-ratelimit-*` headers (request-scoped), alongside the client-level `client.getRateLimits()`. - §13 100% coverage threshold via JaCoCo `violationRules`; deferred until the resource layer lands so the threshold meaningfully ratchets functional code, not just scaffolding. When picking up new work, check this list before reaching for the SDK requirements doc — most foundational rules are already encoded in code; missing pieces are deferred deliberately, not by accident. diff --git a/Makefile b/Makefile index a09f14d..633ff8c 100644 --- a/Makefile +++ b/Makefile @@ -91,6 +91,10 @@ demo-concurrency: ## 50-permit semaphore (needs mock-server) demo-options: ## Full options surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runOptions +.PHONY: demo-stocks +demo-stocks: ## Full stocks surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runStocks + .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 runOptions + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency runOptions runStocks diff --git a/README.md b/README.md index 5da8ee6..8e20afe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Market Data Java SDK Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release** -— the `utilities` and `options` resources are implemented; `stocks`, `funds`, +— the `utilities`, `options`, and `stocks` resources are implemented; `funds` and `markets` are forthcoming. The build, package layout, configuration cascade, exception taxonomy, and Kotlin-interop foundations are in place. @@ -48,8 +48,10 @@ MarketDataClient().use { client -> 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. +(`statusCode()`, `isNoData()`, `requestId()`, `rateLimit()`, `json()`, `saveToFile(path)`) are +available on every response, on every resource. `rateLimit()` returns the rate-limit snapshot +parsed from *that* response's headers (request-scoped), distinct from the client-level +`client.getRateLimits()`. ## Options @@ -149,6 +151,92 @@ With `columns`, a field you didn't request decodes to `null` (no error); a **req you *did* request (or didn't project away) that the API omits raises a `ParseError` — so a `null` never silently hides a dropped field. +## Stocks + +Reached via `client.stocks()`. Same conventions as `options`: every endpoint has a +synchronous method and an `…Async` variant, takes a Builder-based request object, and returns +a typed `MarketDataResponse` (payload via `values()`). The universal-parameter setters +(`dateFormat`/`mode`/`limit`/`offset`/`columns`) and the `asCsv()` facet work identically. + +| Method | Purpose | +|--------|---------| +| `candles` | Historical OHLCV candles for a symbol at a given `StockResolution` | +| `quote` | Real-time quote for a single symbol | +| `quotes` | Quotes for many symbols — batched in **one** request, one row per symbol | +| `prices` | Lightweight price snapshot (mid/change) for many symbols | +| `news` | Recent news articles for a symbol | +| `earnings` | EPS history and the forward earnings calendar | + +> Unlike `options.quotes` (which fans out one request per contract and returns a per-symbol +> map), the stocks backend accepts a comma list in a single request — so `stocks.quotes` and +> `stocks.prices` return a single response with one row per symbol. + +### Candles + +`StockResolution` is a value type, not an enum — the API accepts an open-ended family of +resolutions, so use the factories (`DAILY`, `minutes(15)`, `hours(1)`, `days(2)`, …). An +**intraday** request spanning more than ~one year is automatically split into year-sized +sub-requests, fetched concurrently and merged into one response — transparent to the caller: + +#### Java + +```java +try (var client = new MarketDataClient()) { + var resp = client.stocks().candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.now().minusMonths(1)) + .to(LocalDate.now()) + .build()); + + for (StockCandle c : resp.values()) { // values() is a List + System.out.printf("%s O=%s H=%s L=%s C=%s V=%s%n", + c.time(), c.open(), c.high(), c.low(), c.close(), c.volume()); + } +} +``` + +#### Kotlin + +```kotlin +MarketDataClient().use { client -> + val resp = client.stocks().candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.now().minusMonths(1)) + .to(LocalDate.now()) + .build() + ) + resp.values().forEach { c -> + println("${c.time} O=${c.open} H=${c.high} L=${c.low} C=${c.close} V=${c.volume}") + } +} +``` + +### Quotes, prices, news, earnings + +```java +// Multi-symbol quote — one batched request, one row per symbol: +StockQuotesResponse q = client.stocks().quotes( + StockQuotesRequest.builder("AAPL", "MSFT") + .candle(true) // opt-in OHLC columns + .week52(true) // opt-in 52-week high/low columns + .build()); + +// News — articles plus the feed's latest-update time as a scalar off the response: +StockNewsResponse news = client.stocks().news(StockNewsRequest.of("AAPL")); +news.values().forEach(a -> System.out.println(a.publicationDate() + " " + a.headline())); +System.out.println("feed updated: " + news.updated()); + +// Earnings — fundamentals and report fields are nullable on forward-quarter rows: +client.stocks().earnings(StockEarningsRequest.of("AAPL")) + .values() + .forEach(e -> System.out.println("FY" + e.fiscalYear() + " Q" + e.fiscalQuarter() + + " reportedEPS=" + e.reportedEPS())); +``` + +Stock quote/price numeric fields are `@Nullable` (the backend nulls them for a closed or +illiquid market), and the OHLC / 52-week columns appear only when opted in via `candle` / +`week52`. + ## Configuration Values are resolved through this cascade (highest priority first): @@ -177,7 +265,7 @@ Values are resolved through this cascade (highest priority first): 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 +and `stocks` 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. @@ -244,14 +332,18 @@ isn't an exact match is rejected before any request is made. ``` 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 + # (UtilitiesResource, OptionsResource, StocksResource, + # OptionsCsvResource, StocksCsvResource), and + # MarketDataResponse + the named response types + # (OptionsChainResponse, StockCandlesResponse, 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.stocks # Stocks request builders + row records + # (StockCandlesRequest, StockCandle, StockQuote, + # StockEarning, StockResolution, …) com.marketdata.sdk.exception # Sealed MarketDataException hierarchy + ErrorContext ``` diff --git a/docs/STOCKS_REVIEW_GUIDE.md b/docs/STOCKS_REVIEW_GUIDE.md new file mode 100644 index 0000000..2d2f62c --- /dev/null +++ b/docs/STOCKS_REVIEW_GUIDE.md @@ -0,0 +1,232 @@ +# Stocks Review Guide — `11_stocks_resource` + +This guide walks a reviewer through the `stocks` resource added on the `11_stocks_resource` branch. It is organized by **flow**, not by file. + +This PR **adopts the conventions the [`options` PR](OPTIONS_REVIEW_GUIDE.md) established** — the `MarketDataResponse` + named-response model, the Builder-based per-endpoint request, nullable fields + `columns` + Option A, and the CSV/HTML facets — and applies them to stocks. The shared layers (transport, retry, rate-limit parsing, `ParallelArrays`, `JsonResponseParser`) are reused unchanged; there are **two additive shared-layer changes** — a tolerant date parser (`MarketDataDates.parseDateOrTimestampField`) and a per-response rate-limit accessor (`MarketDataResponse.rateLimit()`, §8.2). If you reviewed the options PR, the load-bearing shape will be familiar — focus your time on §4 (per-endpoint query translation), §5 (the per-endpoint required-column sets + the `news` deserializer), §7–§8 (batch vs. fan-out, news/earnings specifics), and the two stocks-/SDK-specific additions: **§12 candle auto-chunking** (§9.9) and **§8.2 per-response rate limits** (§9.10). + +Suggested reading order: §1 (what's here) → §5 (deserializers: nullable + columns + Option A) → §3/§4 (requests + query translation) → §7 (batch) → §8 (news/earnings) → §9 (subtle corners). ~30 minutes. + +`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 (reused)](#2-the-response-model-reused) +3. [Requests + the `StockResolution` value type](#3-requests--the-stockresolution-value-type) +4. [Request → query translation, per endpoint](#4-request--query-translation-per-endpoint) +5. [The row deserializers: nullable + columns + Option A](#5-the-row-deserializers-nullable--columns--option-a) +6. [Universal parameters + the CSV/HTML facets](#6-universal-parameters--the-csvhtml-facets) +7. [`quotes` / `prices`: batch, not fan-out](#7-quotes--prices-batch-not-fan-out) +8. [news + earnings specifics](#8-news--earnings-specifics) +9. [Subtle corners (wire-format-driven)](#9-subtle-corners-wire-format-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 stocks 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-stocks # (in another) — or: ./gradlew -p examples/consumer-test runStocks +``` + +`StocksIntegrationTest` (shape assertions, AAPL/MSFT) runs against `api.marketdata.app`. `StocksApp` (`examples/consumer-test`) scripts the mock server's responses to demonstrate the columns/Option-A scenarios — it was run green end-to-end (`make demo-stocks`). + +--- + +## 1. What this PR adds + +### 1.1 Public API surface (new) + +``` +com.marketdata.sdk.StocksResource (returned from client.stocks()) +com.marketdata.sdk.StocksCsvResource (returned from .asCsv()) +com.marketdata.sdk.Stock{Candles,Quotes,Prices,News,Earnings}Response (named per-endpoint responses) + +com.marketdata.sdk.stocks.Stock{Candles,Quote,Quotes,Prices,News,Earnings}Request +com.marketdata.sdk.stocks.{StockCandle, StockQuote, StockPrice, StockNewsArticle, StockEarning} (row records) +com.marketdata.sdk.stocks.StockResolution (candle-resolution value type) +``` + +`StocksResource` (and `StocksCsvResource`) are `public final` with **package-private constructors** (ADR-007) — reached through `client.stocks()` / `.asCsv()`. Response wrapper types live in the **root** package (so façades construct them via package-private constructors); request/row records live in the public `com.marketdata.sdk.stocks` subpackage. `StockQuotesResponse` is shared by `quote` and `quotes` (same row shape). + +### 1.2 Files to review, by role + +| Area | Files | What to check | +|---|---|---| +| Resource façade | `StocksResource.java` | universal-param config, per-endpoint `*Spec` builders, the generic row deserializer + Option A, `asCsv()`/`asHtml()` | +| CSV/HTML facets | `StocksCsvResource.java`, `StocksHtmlResource.java` | reuse of the static `*Spec` builders, `format=csv/html` | +| Responses | `Stock*Response.java`, `MarketDataResponse.java`, `AbstractMarketDataResponse.java` | `values()` payload typing; `StockNewsResponse.updated()` scalar accessor; the new `rateLimit()` (§8.2) on the interface + base | +| Candle chunking | `StocksResource.candleChunks`/`candlesAsync`, `StockResolution.isIntraday`, `StocksCsvResource.mergeCsvBodies` | §12 intraday split → concurrent fetch → merge | +| Requests | `stocks/Stock*Request.java`, `StockRequests.java`, `StockResolution.java` | Builder validation, shared `validateWindow`, the value-type resolution | +| Row records | `stocks/StockCandle/StockQuote/StockPrice/StockNewsArticle/StockEarning.java` | `@Nullable` fields; `StockNewsArticle` non-null (always emitted) | +| News deserializer | `StockNewsDeserializer.java` | per-row arrays + scalar `updated`; envelope handling | +| Reused infra (changed) | `MarketDataDates.java`, `MarketDataResponse.java`, `AbstractMarketDataResponse.java` | additive only: `parseDateOrTimestampField`; `rateLimit()` | +| Wiring | `MarketDataClient.java` | `client.stocks()` | + +### 1.3 What changed in shared layers (additive only) + +- **`MarketDataDates`** gained `parseDateOrTimestampField` — the existing `parseDateField` / `parseTimestampField` are untouched. Confirm no existing parser was modified. +- **`MarketDataResponse`** gained `rateLimit()` (§8.2), implemented once in `AbstractMarketDataResponse` (it parses `RateLimitHeaders.parse(envelope.headers())` in the constructor). Since that base is the **only** implementor of the interface, every existing response type — options, utilities, `CsvResponse`, `HtmlResponse` — gets it with no per-type change. Confirm the interface addition didn't miss an implementor and that options/utilities tests still pass. +- Everything else (`ParallelArrays`, `JsonResponseParser`, `RequestConfig`, `RateLimitHeaders`, transport/retry/status-cache/exceptions) is reused exactly as the options PR left it. + +--- + +## 2. The response model (reused) + +No new model concepts — `Stock*Response` are thin subclasses of `AbstractMarketDataResponse` (see [Options guide §2](OPTIONS_REVIEW_GUIDE.md#2-the-response-model) for the full shape). `values()` is the flat payload per endpoint (`List`, `List`, …). The one endpoint extra: `StockNewsResponse.updated()` exposes the feed's scalar update time (it sits at the response root, not on each row). + +**New SDK-wide accessor:** `MarketDataResponse.rateLimit()` returns the `RateLimitSnapshot` parsed from this response's own `x-api-ratelimit-*` headers (§8.2) — request-scoped, distinct from the client-level `client.getRateLimits()`. `null` when the four headers weren't all present. §9.10. + +What to check: `values()` return types match the wire shape per endpoint; `updated()` is only on `StockNewsResponse`; `rateLimit()` is populated from the per-response headers (the merged-chunk candle response reflects the final slice's headers, §9.9). + +--- + +## 3. Requests + the `StockResolution` value type + +Same convention as options: **one Builder-based request per endpoint, no `String` overloads.** `of(...)` for the no-optionals path, `builder(...)…build()` otherwise. + +- **`StockResolution` is a value type, not an enum** (`StockResolution.java`). The API accepts an open-ended family of resolutions (any minute count, any multiple of hours/days/…), so an enum can't enumerate them. Constants `DAILY`/`WEEKLY`/`MONTHLY`/`YEARLY` + factories `minutes/hours/days/weeks/months/years` (all reject non-positive) + `of(String)` for an arbitrary token. `wireValue()` is the path segment. `equals`/`hashCode` by wire value. +- **Shared window validation** (`StockRequests.validateWindow`, used by candles/news/earnings): `date` is single-point and mutually exclusive with `from`/`to`/`countback`; `countback` is positive and pairs with `to` (not `from`). Same rule the options requests use. +- The quote requests carry the boolean flags `extended` / `candle` / `week52`; `candle`/`week52` opt the OHLC / 52-week columns into the response. + +--- + +## 4. Request → query translation, per endpoint + +All in `StocksResource.java` as package-private static `*Spec` builders (reused by the CSV/HTML facets): + +| Endpoint | Path | Params | +|---|---|---| +| `candlesSpec` | `stocks/candles/{resolution}/{symbol}` | `date`/`from`/`to`/`countback`, `exchange`, `extended`, `country`, `adjustsplits`, `adjustdividends` | +| `quoteSpec` | `stocks/quotes/{symbol}` | `extended`, `candle`, `52week` | +| `quotesSpec` | `stocks/quotes` | `symbols=A,B,C` (comma-joined), then the quote flags | +| `pricesSpec` | `stocks/prices` | `symbols=A,B,C` | +| `newsSpec` | `stocks/news/{symbol}` | window (`date`/`from`/`to`/`countback`) | +| `earningsSpec` | `stocks/earnings/{symbol}` | window + `report` (e.g. `2024-Q3`) | + +What to verify: +- Path segments encoded via `PathSegments.encode` (the resolution token too). +- The wire param name for the 52-week flag is **`52week`** (not `week52` — that's the Java builder name); OHLC opt-in is **`candle`**. +- `symbols` is comma-joined then URL-encoded by the transport (`,` → `%2C`); the backend splits after decode. Batched endpoints get a trailing slash before `?` (`/v1/stocks/quotes/?symbols=…`). +- Dates are ISO via `DateTimeFormatter.ISO_LOCAL_DATE`. + +--- + +## 5. The row deserializers: nullable + columns + Option A + +**The correctness-critical section.** candles/quotes/prices/earnings share one generic factory; news is hand-written. + +### 5.1 The generic parallel-arrays deserializer + +`rowsDeserializer(allFields, requiredFields, rowBuilder, wrapper)` (`StocksResource.java`) is the stocks analog of options' `optionRowsDeserializer`: + +- Calls `ParallelArrays.zip(p, root, List.of(), allFields, rowBuilder)` — **empty required list**, every column optional at the wire level, so a `columns` projection never throws on an absent column. +- The row builders (`buildCandleRow`/`buildQuoteRow`/`buildPriceRow`/`buildEarningRow`) read **every** column through an `OrNull` accessor; dates go through `timestampOrNull` (→ `parseTimestampField`) or `dateOrTimestampOrNull` (→ `parseDateOrTimestampField`, §9). +- Then `validateRequestedColumns(p, root, rowCount, ctxt, requiredFields)` restores strictness (Option A): for each **required** field, if it was requested (explicitly via `columns`, or implicitly because no `columns` filter was applied) and `!root.has(field)` → `ParseError`. + +### 5.2 The per-endpoint required-column sets + +This is the stocks-specific judgement call to review — which columns are "required" (Option A guards them) vs. legitimately nullable: + +| Endpoint | `*_FIELDS` (all, optional at wire) | Required (Option-A-guarded) | Legitimately nullable | +|---|---|---|---| +| candles | `t,o,h,l,c,v` | **all** | — | +| quotes | the 11 standard + `o,h,l,c,52weekHigh,52weekLow` | the **11 standard** (`symbol,ask,askSize,bid,bidSize,mid,last,change,changepct,volume,updated`) | OHLC + 52-week (opt-in via `candle`/`week52`) | +| prices | `symbol,mid,change,changepct,updated` | **all** | — | +| earnings | 12 columns | `symbol,date,updated` | `fiscalYear`/`fiscalQuarter`/`reportDate`/`reportTime`/EPS figures (null on forward-quarter / fundamentals-missing rows) | + +> Reviewer note: the quote standard columns are always emitted by the backend (it null-fills them when the market is closed), so they're "required" structurally even though their **values** are nullable — `NaN→null` decodes to `null` without tripping Option A (the column key is present). Confirm the earnings required-set is minimal enough that a forward-quarter row doesn't `ParseError` (regression test `earningsToleratesNullFutureQuarterFields`). + +### 5.3 The `news` deserializer + +`StockNewsDeserializer` is hand-written because the shape is mixed: per-row article arrays **plus a scalar `updated` at the root** (which `ParallelArrays.listDeserializer` can't express). Article columns are read **strictly** (`row.text(...)`) — the backend always emits them for a real article row; `publicationDate` via `parseDateOrTimestampField`. `updated` is read only when present (omitted for date-bounded historical queries → `null`). Envelope handling (`error`/`no_data`) mirrors `ParallelArrays`. + +### 5.4 Tests that document this + +`StocksResourceTest`: `columnsProjectionDecodesRequestedAndNullsTheRest`, `columnsRequestedButOmittedByApiThrowsParseError`, `noColumnsFilterStillRequiresAllStructuralColumns`, `quoteNullNumericCellsDecodeToNull`, `earningsToleratesNullFutureQuarterFields`, `newsHistoricalQueryOmitsUpdated`. + +--- + +## 6. Universal parameters + the CSV/HTML facets + +Identical mechanics to options: + +- `StocksResource` is an **immutable configured value**: `dateFormat()/mode()/limit()/offset()/columns()` each return a configured copy carrying a `RequestConfig`, applied to every call. `universalParamsReachTheWire` asserts they land on the query string. +- `asCsv()` → `StocksCsvResource` → `CsvResponse`; reuses the static `*Spec` builders, sets `format=csv`, adds the output-shaping `human`/`headers` (CSV-only). Every endpoint is covered (no scalar to omit, unlike options' `lookup`). +- `asHtml()` → `StocksHtmlResource`/`HtmlResponse` is **package-private (built, not exposed)** — the backend serves no HTML. + +> Note: these per-resource setters are intentional copy-paste of the options ones; SDK-wide de-duplication into a self-typed base is a tracked pre-v1 refactor, not part of this PR. + +--- + +## 7. `quotes` / `prices`: batch, not fan-out + +**The key contrast with options.** `options.quotes` fans out one request per contract (the backend path is single-symbol) and returns `Map`. The stocks backend accepts a **comma list in a single request**, so: + +- `stocks.quotes` / `stocks.prices` send **one** request (`?symbols=A,B,C`) and return a **single** `StockQuotesResponse` / `StockPricesResponse` with one row per symbol. +- No fan-out, no map, no `AsyncSemaphore` fan-out logic — simpler and one round-trip. + +Verify: `quotesBatchesSymbolsInOneRequest` / `pricesBatchesSymbolsInOneRequest` assert exactly one captured request and the multi-row response. + +--- + +## 8. news + earnings specifics + +- **news** — `StockNewsResponse.values()` is the articles; `updated()` is the feed-level scalar (null for date-bounded queries). §5.3. +- **earnings** — both historical reports and the forward calendar share `StockEarning`. The forward-quarter rows carry null fundamentals (`fiscalYear`/`fiscalQuarter`) and null report fields (`reportDate`/`reportTime`/EPS) — modeled `@Nullable` and Option-A-excluded (§5.2). `fiscalYear`/`fiscalQuarter` are `@Nullable Integer` (read via `lngOrNull` → `intValue`). + +--- + +## 9. Subtle corners (wire-format-driven) + +| # | Corner | What to know | +|---|---|---| +| 9.1 | **`parseDateOrTimestampField`** | A **daily** candle's `t` (and earnings `date`/`reportDate`, news `publicationDate`) come back **date-only** (`"2025-01-17"`) under `dateformat=timestamp`, while an **intraday** candle's `t` is a full timestamp. The new parser tries the full-timestamp format, falls back to date-only (→ market-zone midnight), then numeric (unix/spreadsheet). `updated` fields use the plain `parseTimestampField` (always a full timestamp). | +| 9.2 | **`NaN→null` over numerics** | The backend nulls quote/price numeric columns for a closed/illiquid market — so those fields are `@Nullable` and decode to `null` (the column key is still present, so Option A doesn't fire). | +| 9.3 | **opt-in columns** | Quote OHLC (`o/h/l/c`) appears only with `candle=true`; 52-week with `52week=true`. Absent otherwise → `null`, not an error (they're in the all-fields list but not the required set). | +| 9.4 | **batch ≠ fan-out** | `stocks.quotes`/`prices` are a single comma-list request (§7). Do not expect a per-symbol map. | +| 9.5 | **HTTP 203 is success** | Cached/delayed data returns `203`; IT assert `200 || 203` so they don't flap with market hours. | +| 9.6 | **`StockResolution` is a value type** | Not an enum — the API's resolution family is open-ended. Factories validate positivity; `of(String)` passes an arbitrary token through. | +| 9.7 | **wire vs. Java names** | Builder `week52(...)` → wire `52week`; builder `candle(...)` → wire `candle`; `adjustSplits`/`adjustDividends` → wire `adjustsplits`/`adjustdividends`. | +| 9.8 | **`mode=cached` is quote-only** | The backend rejects `cached` on list endpoints (candles/news/earnings) — a consumer concern, not enforced by the SDK. | +| 9.9 | **Candle auto-chunking (§12)** | An intraday request with a `from` bound spanning > ~1 year is split into year-sized sub-requests (`candleChunks`), fanned out through the 50-permit pool and merged (`candlesAsync` typed; `StocksCsvResource.mergeCsvBodies` for CSV). `StockResolution.isIntraday()` is the trigger. When split, the merged response's metadata (status/json/`rateLimit`) reflects the final slice. | +| 9.10 | **Per-response rate limit (§8.2)** | `MarketDataResponse.rateLimit()` is parsed from each response's own `x-api-ratelimit-*` headers (in `AbstractMarketDataResponse`, SDK-wide) — request-scoped, distinct from client-level `getRateLimits()`. | + +--- + +## 10. Out of scope for this review + +Do **not** flag as missing — deferred, documented in [`PR.md`](../PR.md): + +- **ADR-008 accept + ADR-009**, and the `docs/java-sdk-requirements.md` per-resource section that depends on an accepted source ADR. +- **HTML facet exposure** — package-private until the backend serves `format=html`. +- **SDK-wide setter de-duplication** — the universal-param setters are copy-pasted per resource; a self-typed-base refactor is tracked for before v1. +- **§13 JaCoCo 100% threshold** — unchanged from the options PR. +- **`funds`/`markets`** — adopt this convention next. + +--- + +## Reviewer checklist + +- [ ] Shared layers changed only additively: `MarketDataDates.parseDateOrTimestampField` and `MarketDataResponse.rateLimit()` (implemented once in `AbstractMarketDataResponse`); no existing parser modified; options/utilities responses inherit `rateLimit()` with no per-type change. +- [ ] Requests: Builder per endpoint, no `String` overloads; `StockResolution` value-type factories validate; shared `validateWindow` (date XOR range, countback pairs with `to`). +- [ ] Query translation: every `*Spec` reads every getter; wire names correct (`52week`, `candle`, `adjustsplits`, …); `symbols` comma-joined; paths encoded. +- [ ] Nullable + Option A: every row field `@Nullable`; row builders lenient; per-endpoint **required-column sets** are right (quote 11-standard required, OHLC/52-week optional; earnings only symbol/date/updated required); strict-by-default preserved. +- [ ] `news` deserializer: per-row arrays + scalar `updated` (null on date-bounded); article fields strict; envelope handling. +- [ ] `quotes`/`prices` are a single batched request (one response, N rows) — not a fan-out map. +- [ ] Facets: `asCsv()` covers every endpoint, returns `CsvResponse`, adds `human`/`headers`; `asHtml()` package-private. +- [ ] Date handling: daily date-only vs. intraday full timestamp both decode; `updated` uses the plain timestamp parser. +- [ ] §12 candle chunking: `isIntraday()` classifier correct; only intraday + `from`-bounded ranges split; slices contiguous/non-overlapping (`to` exclusive); concurrent via the 50-permit pool; merged in chronological order; non-intraday / no-`from` stay single-request; CSV path merges with header dedup. +- [ ] §8.2 per-response rate limit: `rateLimit()` parsed from each response's headers; `null` when absent; request-scoped (distinct from `getRateLimits()`). +- [ ] Unit (`./gradlew build`, 728 tests) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) green; `make demo-stocks` runs against the mock server. +- [ ] Deferred items (§10) understood and not blocking. diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index 17ba788..ed590af 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -37,7 +37,9 @@ val demoApps = mapOf( "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to "§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.") + "Full options surface: every endpoint + all params, CSV facet, columns projection, Option A. Needs mock server."), + "runStocks" to ("com.marketdata.consumer.StocksApp" to + "Full stocks surface: candles/quote/quotes/prices/news/earnings + 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/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java index ecb0fc8..db031aa 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 @@ -21,6 +21,15 @@ import com.marketdata.sdk.options.OptionsStrikesRequest; import com.marketdata.sdk.options.StrikeFilter; import com.marketdata.sdk.options.StrikeRange; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarning; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; import com.marketdata.sdk.utilities.ApiStatus; import com.marketdata.sdk.utilities.RequestHeaders; import com.marketdata.sdk.utilities.ServiceStatus; @@ -70,7 +79,7 @@ public static void main(String[] args) { } utilitiesExamples(client); optionsExamples(client); - // stocksExamples(client); // ← add when client.stocks() lands + stocksExamples(client); // fundsExamples(client); // ← add when client.funds() lands // marketsExamples(client); // ← add when client.markets() lands } @@ -275,19 +284,115 @@ private static void optionsExamples(MarketDataClient client) { } } - // ---------- stocks (TODO: enable when client.stocks() lands) ---------- - // - // private static void stocksExamples(MarketDataClient client) { - // Console.header("stocks — quotes, candles, news"); - // - // Console.step("client.stocks().quote(\"AAPL\") — latest quote"); - // var q = client.stocks().quote("AAPL"); - // 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.values().rows().size() + " daily candles fetched"); - // } + // ---------- stocks ---------- + + /** + * One short snippet per stocks endpoint, in the order a consumer typically reaches for them: + * candles → quote/quotes → prices → news → earnings. Entry point is {@code client.stocks()}; every + * endpoint takes a Builder-based request and returns a typed {@code MarketDataResponse} (payload + * via {@code .values()}). Stock data needs entitlements, so each step catches {@link + * AuthenticationError} separately and prints a hint — the tour stays runnable in demo mode. + */ + private static void stocksExamples(MarketDataClient client) { + Console.header("stocks — candles, quote, quotes, prices, news, earnings"); + + // 1) candles — historical OHLCV. Resolution is a value type (StockResolution.DAILY, .hours(1), + // .minutes(15), ...); the window is from/to (or date, or to+countback). + Console.step("client.stocks().candles(...) — daily OHLCV for the last month"); + try { + var r = + client + .stocks() + .candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.now().minusMonths(1)) + .to(LocalDate.now()) + .build()); + Console.ok(r.values().size() + " daily candles fetched"); + // §8.2: each response carries its own rate-limit snapshot (request-scoped). + if (r.rateLimit() != null) { + Console.info( + "rate limit (from this response): " + + r.rateLimit().remaining() + + "/" + + r.rateLimit().limit() + + " remaining"); + } + } catch (AuthenticationError e) { + Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the stocks endpoints."); + } catch (MarketDataException e) { + Console.fail("candles() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 2) quote — single symbol. candle(true)/week52(true) add the opt-in OHLC / 52-week columns. + Console.step("client.stocks().quote(\"AAPL\") — latest quote"); + try { + var r = client.stocks().quote(StockQuoteRequest.of("AAPL")); + if (r.values().isEmpty()) { + Console.ok("no quote returned"); + } else { + StockQuote q = r.values().get(0); + Console.ok(q.symbol() + " last=" + q.last() + " bid/ask=" + q.bid() + "/" + q.ask()); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("quote() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 3) quotes — multiple symbols in ONE request (the stocks backend batches a comma list), so the + // result is a single response with one row per symbol (NOT a per-symbol map like options). + Console.step("client.stocks().quotes(\"AAPL\", \"MSFT\") — multi-symbol batch (single request)"); + try { + var r = client.stocks().quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + Console.ok(r.values().size() + " quote rows in one response"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("quotes() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 4) prices — a lighter snapshot (mid/change/updated) for several symbols, also batched. + Console.step("client.stocks().prices(\"AAPL\", \"MSFT\") — light price snapshot"); + try { + var r = client.stocks().prices(StockPricesRequest.of("AAPL", "MSFT")); + Console.ok(r.values().size() + " prices fetched"); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("prices() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 5) news — recent articles. The feed's latest-update time is a scalar off the response + // (response.updated()), distinct from each article's publicationDate. + Console.step("client.stocks().news(\"AAPL\") — recent articles + scalar updated()"); + try { + var r = client.stocks().news(StockNewsRequest.of("AAPL")); + Console.ok(r.values().size() + " articles; feed updated " + r.updated()); + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("news() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + + // 6) earnings — history + forward calendar. Fundamentals/report fields are nullable on + // synthesized forward-quarter rows; they decode to null without error. + Console.step("client.stocks().earnings(\"AAPL\") — EPS history (nullable forward fields)"); + try { + var r = client.stocks().earnings(StockEarningsRequest.of("AAPL")); + Console.ok(r.values().size() + " earnings rows"); + if (!r.values().isEmpty()) { + StockEarning e = r.values().get(r.values().size() - 1); + Console.ok( + "latest: FY" + e.fiscalYear() + " Q" + e.fiscalQuarter() + " reportedEPS=" + + e.reportedEPS()); + } + } catch (AuthenticationError e) { + Console.info("401 — needs a token."); + } catch (MarketDataException e) { + Console.fail("earnings() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + } // ---------- helpers ---------- diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java new file mode 100644 index 0000000..156ae48 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java @@ -0,0 +1,358 @@ +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.stocks.StockCandle; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarning; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsArticle; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPrice; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; +import java.time.LocalDate; +import java.util.List; + +/** + * Exhaustive {@code stocks} resource demo against the mock server. Covers: + * + *
    + *
  • every endpoint (candles, quote, quotes, prices, news, earnings) with its parameter surface — + * universal params (dateFormat/mode/limit/offset) + the candle window, the quote opt-in + * columns ({@code candle}/{@code 52week}), and the news/earnings date windows; + *
  • the CSV facet ({@code asCsv()}) including the output-shaping {@code columns}/{@code + * human}/{@code headers} params; + *
  • {@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}; + *
  • §12 candle auto-chunking: an intraday range over a year splits into concurrent sub-requests + * and merges into one response; + *
  • §8.2 per-response rate limits: {@code rateLimit()} parsed from each response's headers. + *
+ * + *

Each scenario scripts the mock server's response with {@link MockServerControl#script}. + * + *

Run: {@code ./gradlew runStocks} (needs the mock server up). + */ +public final class StocksApp { + + private StocksApp() {} + + private static final String CANDLES = + "{\"s\":\"ok\",\"t\":[1705276800,1705363200]," + + "\"o\":[216.5,218.0],\"h\":[218.55,220.12],\"l\":[215.78,217.32]," + + "\"c\":[217.83,219.68],\"v\":[62130000,58240000]}"; + + // A single-symbol quote WITH the opt-in candle (o/h/l/c) and 52-week columns present. + private static final String QUOTE_FULL = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\"]," + + "\"ask\":[221.55],\"askSize\":[200],\"bid\":[221.5],\"bidSize\":[300]," + + "\"mid\":[221.525],\"last\":[221.52],\"change\":[1.38],\"changepct\":[0.0063]," + + "\"volume\":[58240000],\"updated\":[1705449600]," + + "\"o\":[219.0],\"h\":[222.3],\"l\":[218.5],\"c\":[221.52]," + + "\"52weekHigh\":[260.1],\"52weekLow\":[164.08]}"; + + private static final String QUOTES = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"ask\":[221.55,415.2],\"askSize\":[200,100],\"bid\":[221.5,415.05]," + + "\"bidSize\":[300,150],\"mid\":[221.525,415.125],\"last\":[221.52,415.1]," + + "\"change\":[1.38,-2.4],\"changepct\":[0.0063,-0.0057]," + + "\"volume\":[58240000,22150000],\"updated\":[1705449600,1705449600]}"; + + private static final String PRICES = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"mid\":[221.525,415.125],\"change\":[1.38,-2.4]," + + "\"changepct\":[0.0063,-0.0057],\"updated\":[1705449600,1705449600]}"; + + private static final String NEWS = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"AAPL\"]," + + "\"headline\":[\"Apple Reports Record Q4\",\"Apple Announces New Product Line\"]," + + "\"content\":[\"Apple reported record revenue...\",\"Apple unveiled new products...\"]," + + "\"source\":[\"https://example.com/a\",\"https://example.com/b\"]," + + "\"publicationDate\":[1705449600,1705363200],\"updated\":1705449600}"; + + // Two rows: a settled historical report, then a synthesized forward quarter whose fundamentals + // and report fields are null — showing the nullable earnings columns decode cleanly. + private static final String EARNINGS = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"AAPL\"]," + + "\"fiscalYear\":[2024,null],\"fiscalQuarter\":[3,null]," + + "\"date\":[1706659200,1714521600],\"reportDate\":[1706832000,null]," + + "\"reportTime\":[\"after close\",null],\"currency\":[\"USD\",\"USD\"]," + + "\"reportedEPS\":[2.18,null],\"estimatedEPS\":[2.1,2.3]," + + "\"surpriseEPS\":[0.08,null],\"surpriseEPSpct\":[3.81,null]," + + "\"updated\":[1706832000,1706832000]}"; + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + everyEndpointWithParams(mock, client); + candleAutoChunking(mock, client); + perResponseRateLimit(mock, client); + csvFacet(mock, client); + columnsProjectionDoesNotFail(mock, client); + optionARequestedColumnMissingFails(mock, client); + strictByDefaultMissingColumnFails(mock, client); + } + } + + // ---------- §12 candle auto-chunking ---------- + + private static void candleAutoChunking(MockServerControl mock, MarketDataClient client) { + Console.header("§12 candle auto-chunking — intraday range > 1 year splits + merges"); + mock.reset(); + // A 3-year HOURLY (intraday) range splits into 4 year-sized sub-requests, fetched concurrently + // and merged. Script one candle body per slice; each returns 2 rows → 8 merged. + mock.script( + List.of( + Step.of(200, CANDLES), Step.of(200, CANDLES), Step.of(200, CANDLES), + Step.of(200, CANDLES))); + Console.step("candles(hours(1), from=2020-01-01, to=2023-01-01) — auto-split"); + var resp = + client + .stocks() + .candles( + StockCandlesRequest.builder(StockResolution.hours(1), "AAPL") + .from(LocalDate.of(2020, 1, 1)) + .to(LocalDate.of(2023, 1, 1)) + .build()); + Console.ok( + "candles.values() → " + + resp.values().size() + + " bars merged from " + + mock.stats().requests() + + " concurrent sub-requests (one continuous series, transparent to the caller)"); + Console.info("Daily/weekly/… resolutions or no `from` bound → a single request, no chunking."); + } + + // ---------- §8.2 per-response rate limit ---------- + + private static void perResponseRateLimit(MockServerControl mock, MarketDataClient client) { + Console.header("§8.2 per-response rate limit — rateLimit() off each response"); + mock.reset(); + // Script the four x-api-ratelimit-* headers on the response; the SDK parses them per response. + mock.script( + Step.of(200, QUOTE_FULL) + .withHeader("x-api-ratelimit-limit", "100000") + .withHeader("x-api-ratelimit-remaining", "99997") + .withHeader("x-api-ratelimit-reset", "1735689600") + .withHeader("x-api-ratelimit-consumed", "3")); + Console.step("quote(\"AAPL\").rateLimit() — parsed from THIS response's headers"); + var resp = client.stocks().quote(StockQuoteRequest.of("AAPL")); + var rl = resp.rateLimit(); + if (rl != null) { + Console.ok( + "rateLimit() → remaining=" + + rl.remaining() + + "/" + + rl.limit() + + " consumed=" + + rl.consumed() + + " reset=" + + rl.reset()); + } else { + Console.fail("expected a rate-limit snapshot from the response headers"); + } + Console.info( + "Request-scoped — distinct from client.getRateLimits() (the client-wide latest snapshot)."); + } + + // ---------- every endpoint ---------- + + private static void everyEndpointWithParams(MockServerControl mock, MarketDataClient client) { + Console.header("Every stocks endpoint with its parameter surface"); + + // candles — OHLCV series. Resolution is a value type; universal params set fluently. + Console.step("candles(...) — daily OHLCV + universal params (dateFormat/mode/limit)"); + mock.reset(); + mock.script(Step.of(200, CANDLES)); + var candles = + client + .stocks() + .dateFormat(DateFormat.UNIX) // universal param (type-preserving) + .mode(Mode.DELAYED) // universal param + .limit(500) // universal param + .candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") // required: resolution + symbol + .from(LocalDate.of(2025, 1, 1)) + .to(LocalDate.of(2025, 1, 31)) + .adjustSplits(true) + .adjustDividends(true) + .build()); + List bars = candles.values(); // List + Console.ok("candles.values() → " + bars.size() + " bars; iterating:"); + for (StockCandle bar : bars) { + Console.info( + " " + bar.time() + " O=" + bar.open() + " H=" + bar.high() + " L=" + bar.low() + + " C=" + bar.close() + " V=" + bar.volume()); + } + + // quote — single symbol, opt-in candle + 52-week columns. + Console.step("quote(...) — single symbol with candle=true & 52week=true opt-in columns"); + mock.reset(); + mock.script(Step.of(200, QUOTE_FULL)); + StockQuote q = + client + .stocks() + .quote(StockQuoteRequest.builder("AAPL").candle(true).week52(true).build()) + .values() + .get(0); + Console.ok( + "quote → " + q.symbol() + " bid/ask=" + q.bid() + "/" + q.ask() + " last=" + q.last()); + Console.info( + " opt-in candle: O=" + q.open() + " H=" + q.high() + " L=" + q.low() + " C=" + q.close()); + Console.info(" opt-in 52week: high=" + q.week52High() + " low=" + q.week52Low()); + + // quotes — multi-symbol BATCH in ONE request (comma list) → one response with N rows. + Console.step("quotes(...) — multi-symbol batch (single request, one response with N rows)"); + mock.reset(); + mock.script(Step.of(200, QUOTES)); + var quotes = client.stocks().quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + Console.ok("quotes.values() → " + quotes.values().size() + " rows (single request):"); + for (StockQuote row : quotes.values()) { + Console.info(" " + row.symbol() + " mid=" + row.mid() + " vol=" + row.volume()); + } + + // prices — lighter than quotes (mid/change/updated), also a single batched request. + Console.step("prices(...) — light multi-symbol price snapshot (mid/change)"); + mock.reset(); + mock.script(Step.of(200, PRICES)); + var prices = client.stocks().prices(StockPricesRequest.of("AAPL", "MSFT")); + for (StockPrice p : prices.values()) { + Console.info(" " + p.symbol() + " mid=" + p.mid() + " change=" + p.change()); + } + + // news — per-row articles + a scalar `updated` exposed off the response (not on each row). + Console.step("news(...) — articles + scalar updated() on the response"); + mock.reset(); + mock.script(Step.of(200, NEWS)); + var news = client.stocks().news(StockNewsRequest.of("AAPL")); + Console.ok("news.values() → " + news.values().size() + " articles; updated=" + news.updated()); + for (StockNewsArticle a : news.values()) { + Console.info(" " + a.publicationDate().toLocalDate() + " " + a.headline()); + } + + // earnings — history + forward calendar; nullable fundamentals/report fields decode to null. + Console.step("earnings(...) — history + forward quarter (nullable fields decode cleanly)"); + mock.reset(); + mock.script(Step.of(200, EARNINGS)); + var earnings = + client + .stocks() + .earnings( + StockEarningsRequest.builder("AAPL") + .to(LocalDate.of(2025, 6, 1)) + .countback(8) + .build()); + for (StockEarning e : earnings.values()) { + Console.info( + " FY" + + e.fiscalYear() + + " Q" + + e.fiscalQuarter() + + " reportedEPS=" + + e.reportedEPS() + + " estimatedEPS=" + + e.estimatedEPS() + + " reportTime=" + + e.reportTime()); + } + } + + // ---------- CSV facet ---------- + + private static void csvFacet(MockServerControl mock, MarketDataClient client) { + Console.header("CSV facet — client.stocks().asCsv()"); + + Console.step("asCsv().candles(...) — plain CSV"); + mock.reset(); + mock.script( + Step.of(200, "t,o,h,l,c,v\n1705276800,216.5,218.55,215.78,217.83,62130000")); + var csv = + client.stocks().asCsv().candles(StockCandlesRequest.of(StockResolution.DAILY, "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, "Symbol,Mid Price\nAAPL,221.525\nMSFT,415.125")); + var shaped = + client + .stocks() + .asCsv() + .columns("symbol", "mid") + .human(true) + .headers(true) + .quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + Console.ok("→ CSV with human headers + projected columns:"); + Console.info(shaped.csv()); + } + + // ---------- 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\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); + + StockQuote row = + client.stocks().columns("symbol", "mid").quote(StockQuoteRequest.of("AAPL")).values().get(0); + Console.ok("requested → symbol=" + row.symbol() + " mid=" + row.mid()); + Console.ok( + "NOT requested (null, decoded cleanly) → bid=" + + row.bid() + + " volume=" + + row.volume() + + " updated=" + + row.updated()); + } + + // ---------- 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\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); + + try { + client.stocks().columns("symbol", "mid", "bid").quote(StockQuoteRequest.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\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); + + try { + // No .columns(...) → every required column is implicitly requested, so a missing one fails. + client.stocks().quote(StockQuoteRequest.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/src/integrationTest/java/com/marketdata/sdk/StocksIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/StocksIntegrationTest.java new file mode 100644 index 0000000..fc01f1e --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/StocksIntegrationTest.java @@ -0,0 +1,150 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.stocks.StockCandle; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarning; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsArticle; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPrice; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; +import java.time.LocalDate; +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 stocks} 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. + * + *

Tests assert shape rather than specific values, since live data drifts daily. + * AAPL/MSFT are used everywhere — large, always-populated tickers. Status is asserted as {@code 200 + * || 203} (203 = cached/delayed data, which the SDK surfaces as success) so the suite doesn't flap + * with market hours. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StocksIntegrationTest { + + private static final String SYMBOL = "AAPL"; + + private MarketDataClient client; + + @BeforeAll + void setUp() { + client = new MarketDataClient(); + } + + @AfterAll + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void candlesReturnsDailyOhlcvSeries() { + StockCandlesResponse resp = + client + .stocks() + .candles( + StockCandlesRequest.builder(StockResolution.DAILY, SYMBOL) + .from(LocalDate.now().minusMonths(1)) + .to(LocalDate.now()) + .build()); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).as("AAPL has daily candles in the last month").isNotEmpty(); + StockCandle first = resp.values().get(0); + assertThat(first.time()).isNotNull(); + assertThat(first.time().getZone().getId()).isEqualTo("America/New_York"); + assertThat(first.close()).isNotNull(); + } + + @Test + void quoteFetchesSingleSymbol() { + StockQuotesResponse resp = client.stocks().quote(StockQuoteRequest.of(SYMBOL)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).hasSize(1); + StockQuote q = resp.values().get(0); + assertThat(q.symbol()).isEqualTo(SYMBOL); + } + + @Test + void quoteCandleAndWeek52OptInColumnsDecode() { + StockQuote q = + client + .stocks() + .quote(StockQuoteRequest.builder(SYMBOL).candle(true).week52(true).build()) + .values() + .get(0); + + // Opt-in columns: present when the market returns them, never a ParseError. Assert any + // populated value is finite. + if (q.close() != null) { + assertThat(q.close()).isFinite(); + } + if (q.week52High() != null) { + assertThat(q.week52High()).isFinite(); + } + } + + @Test + void quotesBatchesMultipleSymbolsInOneResponse() { + StockQuotesResponse resp = + client.stocks().quotes(StockQuotesRequest.builder(SYMBOL, "MSFT").build()); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).hasSizeGreaterThanOrEqualTo(2); + assertThat(resp.values().stream().map(StockQuote::symbol)).contains(SYMBOL, "MSFT"); + } + + @Test + void pricesBatchesMultipleSymbols() { + StockPricesResponse resp = client.stocks().prices(StockPricesRequest.of(SYMBOL, "MSFT")); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).hasSizeGreaterThanOrEqualTo(2); + StockPrice p = resp.values().get(0); + assertThat(p.symbol()).isNotNull(); + if (p.mid() != null) { + assertThat(p.mid()).isFinite(); + } + } + + @Test + void newsReturnsArticlesWithScalarUpdated() { + StockNewsResponse resp = client.stocks().news(StockNewsRequest.of(SYMBOL)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).as("AAPL always has recent news").isNotEmpty(); + StockNewsArticle a = resp.values().get(0); + assertThat(a.headline()).isNotBlank(); + assertThat(a.publicationDate().getZone().getId()).isEqualTo("America/New_York"); + // Live (non-date-bounded) query carries the scalar `updated`. + assertThat(resp.updated()).isNotNull(); + } + + @Test + void earningsReturnsHistoryWithNullableFields() { + StockEarningsResponse resp = client.stocks().earnings(StockEarningsRequest.of(SYMBOL)); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).isNotEmpty(); + // Every row decodes; nullable fundamentals/forward fields never trip a ParseError. + for (StockEarning e : resp.values()) { + assertThat(e.symbol()).isEqualTo(SYMBOL); + if (e.reportedEPS() != null) { + assertThat(e.reportedEPS()).isFinite(); + } + } + } +} diff --git a/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java index 0e65a25..afd0b0b 100644 --- a/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java +++ b/src/main/java/com/marketdata/sdk/AbstractMarketDataResponse.java @@ -29,6 +29,7 @@ abstract class AbstractMarketDataResponse implements MarketDataResponse { private final int statusCode; private final @Nullable String requestId; private final URI requestUrl; + private final @Nullable RateLimitSnapshot rateLimit; AbstractMarketDataResponse(T values, HttpResponseEnvelope envelope, Format format) { this.values = Objects.requireNonNull(values, "values"); @@ -37,6 +38,9 @@ abstract class AbstractMarketDataResponse implements MarketDataResponse { this.statusCode = envelope.statusCode(); this.requestId = envelope.requestId(); this.requestUrl = envelope.url(); + // §8.2: attach this response's own rate-limit snapshot (null when the four headers weren't all + // present, e.g. a CDN-served error). Request-scoped, distinct from the client-level latest. + this.rateLimit = RateLimitHeaders.parse(envelope.headers()); } @Override @@ -59,6 +63,11 @@ public boolean isNoData() { return requestId; } + @Override + public @Nullable RateLimitSnapshot rateLimit() { + return rateLimit; + } + @Override public URI requestUrl() { return requestUrl; diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 83b0be8..0a1349f 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -22,6 +22,7 @@ public final class MarketDataClient implements AutoCloseable { private final HttpTransport transport; private final UtilitiesResource utilities; private final OptionsResource options; + private final StocksResource stocks; public MarketDataClient() { this(null, null, null, true); @@ -108,6 +109,7 @@ public MarketDataClient( JsonResponseParser parser = new JsonResponseParser(); this.utilities = new UtilitiesResource(transport, parser); this.options = new OptionsResource(transport, parser); + this.stocks = new StocksResource(transport, parser); cacheRef.set( new StatusCache( () -> utilities.statusAsync().thenApply(r -> new ApiStatus(r.values())), @@ -154,6 +156,14 @@ public OptionsResource options() { return options; } + /** + * Stocks endpoints: {@code candles}, {@code quote}, {@code quotes}, {@code prices}, {@code news}, + * {@code earnings}. + */ + public StocksResource stocks() { + return stocks; + } + /** * 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 5ecd9de..f9a3148 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataDates.java +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -91,6 +91,39 @@ static LocalDate parseDateField( return SPREADSHEET_EPOCH.plusDays(whole); } + /** + * Parse a cell that may carry either a date-only or a full-timestamp string (plus the + * numeric {@code unix}/{@code spreadsheet} encodings) to a {@link ZonedDateTime} in {@link + * #MARKET_ZONE}. Several stock endpoints flip between the two textual shapes under {@code + * dateformat=timestamp}: a daily candle's {@code t} (and earnings/news date fields) come back + * date-only ({@code "2026-06-03"}), while an intraday candle's {@code t} carries the full {@code + * "yyyy-MM-dd HH:mm:ss XXX"}. This helper tolerates both — date-only strings are lifted to a + * market-zone midnight — so a single model field decodes uniformly regardless of resolution. + */ + static ZonedDateTime parseDateOrTimestampField( + @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()) { + String text = node.asText(); + try { + return ZonedDateTime.parse(text, ZONED_TIMESTAMP_FORMAT).withZoneSameInstant(MARKET_ZONE); + } catch (DateTimeParseException fullTimestamp) { + try { + return LocalDate.parse(text).atStartOfDay(MARKET_ZONE); + } catch (DateTimeParseException dateOnly) { + throw new JsonMappingException( + p, "non-conforming date/timestamp string for field " + fieldName + ": " + text); + } + } + } + // Numeric encodings (unix epoch / spreadsheet serial) are unambiguous — defer to the shared + // numeric path used by the pure-timestamp parser. + return parseTimestampField(p, node, fieldName); + } + /** * 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 diff --git a/src/main/java/com/marketdata/sdk/MarketDataResponse.java b/src/main/java/com/marketdata/sdk/MarketDataResponse.java index 53323db..9b1814a 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataResponse.java +++ b/src/main/java/com/marketdata/sdk/MarketDataResponse.java @@ -34,6 +34,14 @@ public interface MarketDataResponse { /** Server-provided request id (Cloudflare {@code cf-ray}), or {@code null} when absent. */ @Nullable String requestId(); + /** + * The rate-limit snapshot parsed from this response's {@code x-api-ratelimit-*} headers + * (SDK requirements §8.2), or {@code null} when the response did not carry the full header set. + * Request-scoped: distinct from {@link MarketDataClient#getRateLimits()}, which returns the + * latest snapshot seen across all requests on the client. + */ + @Nullable RateLimitSnapshot rateLimit(); + /** Absolute URL the response came from. */ URI requestUrl(); diff --git a/src/main/java/com/marketdata/sdk/StockCandlesResponse.java b/src/main/java/com/marketdata/sdk/StockCandlesResponse.java new file mode 100644 index 0000000..b9a75b9 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockCandlesResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockCandle; +import java.util.List; + +/** + * Response for {@code stocks.candles}: {@link #values()} is the OHLCV rows. Construct only through + * the resource façade. + */ +public final class StockCandlesResponse extends AbstractMarketDataResponse> { + + StockCandlesResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/StockEarningsResponse.java b/src/main/java/com/marketdata/sdk/StockEarningsResponse.java new file mode 100644 index 0000000..e7090dd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockEarningsResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockEarning; +import java.util.List; + +/** + * Response for {@code stocks.earnings}: {@link #values()} is the earnings rows. Construct only + * through the resource façade. + */ +public final class StockEarningsResponse extends AbstractMarketDataResponse> { + + StockEarningsResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java b/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java new file mode 100644 index 0000000..fd53c58 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java @@ -0,0 +1,72 @@ +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.stocks.StockNews; +import com.marketdata.sdk.stocks.StockNewsArticle; +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.List; + +/** + * Wire-format deserializer for {@link StockNews}. The article columns are parallel arrays; {@code + * updated} is a scalar at the response root (omitted for date-bounded queries), which is + * the mixed shape {@link ParallelArrays#listDeserializer} can't express. Envelope handling mirrors + * {@link ParallelArrays}: + * + *

    + *
  • {@code "s":"error"} → {@link JsonMappingException} carrying {@code errmsg}. + *
  • {@code "s":"no_data"} → empty list, {@code updated} null. + *
  • otherwise → strict field validation across the five article columns; {@code updated} is + * read only when present. + *
+ */ +final class StockNewsDeserializer 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"; + + private static final List ARTICLE_FIELDS = + List.of("symbol", "headline", "content", "source", "publicationDate"); + + @Override + public StockNews 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 StockNews(List.of(), null); + } + + List articles = + ParallelArrays.zip( + p, + root, + ARTICLE_FIELDS, + row -> + new StockNewsArticle( + row.text("symbol"), + row.text("headline"), + row.text("content"), + row.text("source"), + MarketDataDates.parseDateOrTimestampField( + p, row.node("publicationDate"), "publicationDate"))); + + // `updated` is the feed's latest-update scalar; the backend omits it for historical queries. + JsonNode updatedNode = root.get(UPDATED_KEY); + ZonedDateTime updated = + updatedNode == null || updatedNode.isNull() + ? null + : MarketDataDates.parseTimestampField(p, updatedNode, UPDATED_KEY); + return new StockNews(articles, updated); + } +} diff --git a/src/main/java/com/marketdata/sdk/StockNewsResponse.java b/src/main/java/com/marketdata/sdk/StockNewsResponse.java new file mode 100644 index 0000000..d8ef847 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockNewsResponse.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockNewsArticle; +import java.time.ZonedDateTime; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Response for {@code stocks.news}: {@link #values()} is the article rows. The feed's scalar {@code + * updated} time is exposed separately via {@link #updated()} (it sits at the response root, not on + * each row, and is absent for historical/date-bounded queries). Construct only through the resource + * façade. + */ +public final class StockNewsResponse extends AbstractMarketDataResponse> { + + private final @Nullable ZonedDateTime updated; + + StockNewsResponse( + List values, + @Nullable ZonedDateTime updated, + HttpResponseEnvelope envelope, + Format format) { + super(values, envelope, format); + this.updated = updated; + } + + /** The feed's latest update time, or {@code null} for historical (date-bounded) queries. */ + public @Nullable ZonedDateTime updated() { + return updated; + } +} diff --git a/src/main/java/com/marketdata/sdk/StockPricesResponse.java b/src/main/java/com/marketdata/sdk/StockPricesResponse.java new file mode 100644 index 0000000..db105ff --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockPricesResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockPrice; +import java.util.List; + +/** + * Response for {@code stocks.prices}: {@link #values()} is one {@link StockPrice} row per requested + * symbol. Construct only through the resource façade. + */ +public final class StockPricesResponse extends AbstractMarketDataResponse> { + + StockPricesResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/StockQuotesResponse.java b/src/main/java/com/marketdata/sdk/StockQuotesResponse.java new file mode 100644 index 0000000..2bea837 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StockQuotesResponse.java @@ -0,0 +1,16 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockQuote; +import java.util.List; + +/** + * Response for {@code stocks.quote} and {@code stocks.quotes}: {@link #values()} is the quote rows + * (one for the single-symbol form, one per symbol for the batch). Construct only through the + * resource façade. + */ +public final class StockQuotesResponse extends AbstractMarketDataResponse> { + + StockQuotesResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/StocksCsvResource.java b/src/main/java/com/marketdata/sdk/StocksCsvResource.java new file mode 100644 index 0000000..6837e91 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StocksCsvResource.java @@ -0,0 +1,188 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * CSV facet of {@code stocks} — reached through {@code client.stocks().asCsv()}. Every endpoint + * here returns a {@link CsvResponse} (opaque CSV text). Because the stock quote/price endpoints + * batch a comma list in a single request, even the multi-symbol forms return one {@code + * CsvResponse} (no fan-out map). + * + *

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 StocksCsvResource { + + private final HttpTransport transport; + private final RequestConfig config; + + StocksCsvResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + // ---------- universal + output-shaping params ---------- + + public StocksCsvResource dateFormat(DateFormat v) { + return new StocksCsvResource(transport, config.withDateFormat(v)); + } + + public StocksCsvResource mode(Mode v) { + return new StocksCsvResource(transport, config.withMode(v)); + } + + public StocksCsvResource limit(int v) { + return new StocksCsvResource(transport, config.withLimit(v)); + } + + public StocksCsvResource offset(int v) { + return new StocksCsvResource(transport, config.withOffset(v)); + } + + public StocksCsvResource columns(String... v) { + return new StocksCsvResource(transport, config.withColumns(java.util.List.of(v))); + } + + public StocksCsvResource human(boolean v) { + return new StocksCsvResource(transport, config.withHuman(v)); + } + + public StocksCsvResource headers(boolean v) { + return new StocksCsvResource(transport, config.withHeaders(v)); + } + + // ---------- endpoints ---------- + + public CompletableFuture candlesAsync(StockCandlesRequest request) { + List chunks = StocksResource.candleChunks(request); + if (chunks.size() == 1) { + StocksResource.DateRange only = chunks.get(0); + return executeCsv(StocksResource.candlesSpec(request, only.from(), only.to())); + } + // §12 auto-chunking on the CSV facet: fan out a CSV sub-request per year-sized slice and merge + // the texts (dropping the repeated header row from every slice after the first when headers are + // on), so the consumer gets one continuous CSV instead of a silently truncated first year. + boolean headersIncluded = !Boolean.FALSE.equals(config.headers()); + List> futures = new ArrayList<>(chunks.size()); + for (StocksResource.DateRange range : chunks) { + RequestSpec.Builder b = StocksResource.candlesSpec(request, range.from(), range.to()); + config.applyTo(b); + b.format(Format.CSV); + RequestSpec spec = b.build(); + futures.add( + transport + .executeAsync(spec) + .thenApply( + env -> + new EnvBody( + new String(env.body(), StandardCharsets.UTF_8), env, spec.format()))); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply( + unused -> { + EnvBody last = futures.get(futures.size() - 1).join(); + List bodies = new ArrayList<>(futures.size()); + for (CompletableFuture f : futures) { + bodies.add(f.join().body()); + } + return new CsvResponse( + mergeCsvBodies(bodies, headersIncluded), last.envelope(), last.format()); + }); + } + + private record EnvBody(String body, HttpResponseEnvelope envelope, Format format) {} + + /** + * Concatenates CSV slice bodies in order. When {@code headersIncluded}, the leading header line + * of every slice after the first is dropped so the merged text has a single header. + */ + static String mergeCsvBodies(List bodies, boolean headersIncluded) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < bodies.size(); i++) { + String body = bodies.get(i); + if (i > 0 && headersIncluded) { + int nl = body.indexOf('\n'); + body = nl >= 0 ? body.substring(nl + 1) : ""; // drop the repeated header row + } + if (body.isEmpty()) { + continue; + } + if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '\n') { + sb.append('\n'); + } + sb.append(body); + } + return sb.toString(); + } + + public CsvResponse candles(StockCandlesRequest request) { + return transport.joinSync(candlesAsync(request)); + } + + public CompletableFuture quoteAsync(StockQuoteRequest request) { + return executeCsv( + StocksResource.quoteSpec( + request.symbol(), request.extended(), request.candle(), request.week52())); + } + + public CsvResponse quote(StockQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + public CompletableFuture quotesAsync(StockQuotesRequest request) { + return executeCsv( + StocksResource.quotesSpec( + request.symbols(), request.extended(), request.candle(), request.week52())); + } + + public CsvResponse quotes(StockQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + public CompletableFuture pricesAsync(StockPricesRequest request) { + return executeCsv(StocksResource.pricesSpec(request.symbols())); + } + + public CsvResponse prices(StockPricesRequest request) { + return transport.joinSync(pricesAsync(request)); + } + + public CompletableFuture newsAsync(StockNewsRequest request) { + return executeCsv(StocksResource.newsSpec(request)); + } + + public CsvResponse news(StockNewsRequest request) { + return transport.joinSync(newsAsync(request)); + } + + public CompletableFuture earningsAsync(StockEarningsRequest request) { + return executeCsv(StocksResource.earningsSpec(request)); + } + + public CsvResponse earnings(StockEarningsRequest request) { + return transport.joinSync(earningsAsync(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/StocksHtmlResource.java b/src/main/java/com/marketdata/sdk/StocksHtmlResource.java new file mode 100644 index 0000000..dbfcba6 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StocksHtmlResource.java @@ -0,0 +1,91 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +/** + * HTML facet of {@code stocks}. Mirrors {@link StocksCsvResource} 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 StocksResource} + * is package-private. Kept built and ready so enabling HTML later is a one-line change. + */ +public final class StocksHtmlResource { + + private final HttpTransport transport; + private final RequestConfig config; + + StocksHtmlResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + public CompletableFuture candlesAsync(StockCandlesRequest request) { + return executeHtml(StocksResource.candlesSpec(request)); + } + + public HtmlResponse candles(StockCandlesRequest request) { + return transport.joinSync(candlesAsync(request)); + } + + public CompletableFuture quoteAsync(StockQuoteRequest request) { + return executeHtml( + StocksResource.quoteSpec( + request.symbol(), request.extended(), request.candle(), request.week52())); + } + + public HtmlResponse quote(StockQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + public CompletableFuture quotesAsync(StockQuotesRequest request) { + return executeHtml( + StocksResource.quotesSpec( + request.symbols(), request.extended(), request.candle(), request.week52())); + } + + public HtmlResponse quotes(StockQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + public CompletableFuture pricesAsync(StockPricesRequest request) { + return executeHtml(StocksResource.pricesSpec(request.symbols())); + } + + public HtmlResponse prices(StockPricesRequest request) { + return transport.joinSync(pricesAsync(request)); + } + + public CompletableFuture newsAsync(StockNewsRequest request) { + return executeHtml(StocksResource.newsSpec(request)); + } + + public HtmlResponse news(StockNewsRequest request) { + return transport.joinSync(newsAsync(request)); + } + + public CompletableFuture earningsAsync(StockEarningsRequest request) { + return executeHtml(StocksResource.earningsSpec(request)); + } + + public HtmlResponse earnings(StockEarningsRequest request) { + return transport.joinSync(earningsAsync(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/StocksResource.java b/src/main/java/com/marketdata/sdk/StocksResource.java new file mode 100644 index 0000000..eab1bde --- /dev/null +++ b/src/main/java/com/marketdata/sdk/StocksResource.java @@ -0,0 +1,675 @@ +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.stocks.StockCandle; +import com.marketdata.sdk.stocks.StockCandles; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarning; +import com.marketdata.sdk.stocks.StockEarnings; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNews; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPrice; +import com.marketdata.sdk.stocks.StockPrices; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotes; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + +/** + * Stocks endpoints ({@code /v1/stocks/...}). Reached through {@code client.stocks()}. + * + *

The resource is an immutable configured value (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 StocksResource { + + 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. */ + StocksResource(HttpTransport transport, JsonResponseParser parser) { + this(transport, parser, RequestConfig.empty()); + parser.registerModule(wireFormatModule()); + } + + private StocksResource(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 StocksResource dateFormat(DateFormat dateFormat) { + return new StocksResource(transport, parser, config.withDateFormat(dateFormat)); + } + + /** + * Returns a copy with the data-freshness {@code mode} (cached honored only by quote endpoints). + */ + public StocksResource mode(Mode mode) { + return new StocksResource(transport, parser, config.withMode(mode)); + } + + /** Returns a copy with the pagination {@code limit}. */ + public StocksResource limit(int limit) { + return new StocksResource(transport, parser, config.withLimit(limit)); + } + + /** Returns a copy with the pagination {@code offset}. */ + public StocksResource offset(int offset) { + return new StocksResource(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 StocksResource columns(String... columns) { + return new StocksResource(transport, parser, config.withColumns(List.of(columns))); + } + + // ---------- format facet ---------- + + /** A CSV-flavored view of this resource (carrying the same universal-param config). */ + public StocksCsvResource asCsv() { + return new StocksCsvResource(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}. + */ + StocksHtmlResource asHtml() { + return new StocksHtmlResource(transport, config); + } + + // ---------- endpoints (typed) ---------- + + /** + * Async: fetch the OHLCV candle series for a single symbol. + * + *

Per SDK requirements §12, an intraday request with a {@code from} bound spanning + * more than ~one year is auto-split into year-sized sub-requests, dispatched concurrently through + * the transport's 50-permit pool and merged: the returned response's {@link + * MarketDataResponse#values()} are every slice's candles concatenated in chronological order. + * When splitting occurs, the response metadata ({@code statusCode}/{@code requestId}/{@code + * json}/{@code rateLimit}) reflects the final sub-request. + */ + public java.util.concurrent.CompletableFuture candlesAsync( + StockCandlesRequest request) { + List chunks = candleChunks(request); + if (chunks.size() == 1) { + DateRange only = chunks.get(0); + RequestSpec.Builder b = candlesSpec(request, only.from(), only.to()); + config.applyTo(b); + return execute( + b.build(), + StockCandles.class, + (d, env, fmt) -> new StockCandlesResponse(d.candles(), env, fmt)); + } + List> futures = + new java.util.ArrayList<>(chunks.size()); + for (DateRange range : chunks) { + RequestSpec.Builder b = candlesSpec(request, range.from(), range.to()); + config.applyTo(b); + RequestSpec spec = b.build(); + futures.add( + transport + .executeAsync(spec) + .thenApply( + env -> + new DecodedChunk( + parser.parse(env, StockCandles.class, config.columns()), + env, + spec.format()))); + } + return java.util.concurrent.CompletableFuture.allOf( + futures.toArray(new java.util.concurrent.CompletableFuture[0])) + .thenApply( + unused -> { + List merged = new java.util.ArrayList<>(); + DecodedChunk last = futures.get(futures.size() - 1).join(); + for (java.util.concurrent.CompletableFuture f : futures) { + merged.addAll(f.join().decoded().candles()); + } + return new StockCandlesResponse(List.copyOf(merged), last.envelope(), last.format()); + }); + } + + /** One decoded candle sub-request — its rows plus the envelope/format for metadata. */ + private record DecodedChunk(StockCandles decoded, HttpResponseEnvelope envelope, Format format) {} + + /** Sync wrapper for {@link #candlesAsync(StockCandlesRequest)}. */ + public StockCandlesResponse candles(StockCandlesRequest request) { + return transport.joinSync(candlesAsync(request)); + } + + /** Async: fetch the real-time quote for a single symbol. */ + public java.util.concurrent.CompletableFuture quoteAsync( + StockQuoteRequest request) { + RequestSpec.Builder b = + quoteSpec(request.symbol(), request.extended(), request.candle(), request.week52()); + config.applyTo(b); + return execute( + b.build(), + StockQuotes.class, + (d, env, fmt) -> new StockQuotesResponse(d.quotes(), env, fmt)); + } + + /** Sync wrapper for {@link #quoteAsync(StockQuoteRequest)}. */ + public StockQuotesResponse quote(StockQuoteRequest request) { + return transport.joinSync(quoteAsync(request)); + } + + /** + * Async: fetch real-time quotes for multiple symbols in a single request (the backend + * accepts a comma list), returning one row per symbol in the response. + */ + public java.util.concurrent.CompletableFuture quotesAsync( + StockQuotesRequest request) { + RequestSpec.Builder b = + quotesSpec(request.symbols(), request.extended(), request.candle(), request.week52()); + config.applyTo(b); + return execute( + b.build(), + StockQuotes.class, + (d, env, fmt) -> new StockQuotesResponse(d.quotes(), env, fmt)); + } + + /** Sync wrapper for {@link #quotesAsync(StockQuotesRequest)}. */ + public StockQuotesResponse quotes(StockQuotesRequest request) { + return transport.joinSync(quotesAsync(request)); + } + + /** Async: fetch the last price for multiple symbols in a single request. */ + public java.util.concurrent.CompletableFuture pricesAsync( + StockPricesRequest request) { + RequestSpec.Builder b = pricesSpec(request.symbols()); + config.applyTo(b); + return execute( + b.build(), + StockPrices.class, + (d, env, fmt) -> new StockPricesResponse(d.prices(), env, fmt)); + } + + /** Sync wrapper for {@link #pricesAsync(StockPricesRequest)}. */ + public StockPricesResponse prices(StockPricesRequest request) { + return transport.joinSync(pricesAsync(request)); + } + + /** + * Async: fetch news articles for a single symbol. + * + *

Unlike the other endpoints, {@code news} does not support a {@code columns} projection on + * the typed path: {@link com.marketdata.sdk.stocks.StockNewsArticle} declares every field + * non-null, so projecting columns away would force the model to lie (return {@code null} where + * the type promises a value). Rather than silently degrade, a configured {@code columns} filter + * is rejected with a clear message. Consumers who genuinely want a projected payload can use the + * CSV facet — {@code client.stocks().asCsv().columns(...).news(...)} — where the output is raw + * text and no typed contract is broken. + * + *

The rejection is surfaced as a failed future, not a synchronous throw, so it + * reaches async callers through their {@code exceptionally}/{@code handle} chain like every other + * failure; the sync {@link #news(StockNewsRequest)} wrapper unwraps it to a direct {@link + * IllegalArgumentException} via {@code join()} (ADR-006). + */ + public java.util.concurrent.CompletableFuture newsAsync( + StockNewsRequest request) { + if (!config.columns().isEmpty()) { + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalArgumentException( + "columns projection is not supported on the news endpoint; news always returns all" + + " fields. For a projected payload use the CSV facet:" + + " client.stocks().asCsv().columns(...).news(...)")); + } + RequestSpec.Builder b = newsSpec(request); + // Only `columns` is unsupported on news; the rest of the universal params (dateFormat/mode/ + // limit/offset) are valid. Past the guard `columns` is empty, so applyTo writes those and + // no-ops the columns clause. + config.applyTo(b); + return execute( + b.build(), + StockNews.class, + (d, env, fmt) -> new StockNewsResponse(d.articles(), d.updated(), env, fmt)); + } + + /** Sync wrapper for {@link #newsAsync(StockNewsRequest)}. */ + public StockNewsResponse news(StockNewsRequest request) { + return transport.joinSync(newsAsync(request)); + } + + /** Async: fetch the earnings history (or forward calendar) for a single symbol. */ + public java.util.concurrent.CompletableFuture earningsAsync( + StockEarningsRequest request) { + RequestSpec.Builder b = earningsSpec(request); + config.applyTo(b); + return execute( + b.build(), + StockEarnings.class, + (d, env, fmt) -> new StockEarningsResponse(d.earnings(), env, fmt)); + } + + /** Sync wrapper for {@link #earningsAsync(StockEarningsRequest)}. */ + public StockEarningsResponse earnings(StockEarningsRequest request) { + return transport.joinSync(earningsAsync(request)); + } + + // ---------- execute ---------- + + private java.util.concurrent.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 candlesSpec(StockCandlesRequest r) { + return candlesSpec(r, r.from(), r.to()); + } + + /** + * Candles spec with the date window overridden — used by the auto-chunking path (§12), where each + * sub-request carries a slice of the original {@code from}/{@code to} range. Non-window params + * ({@code date}/{@code countback}/exchange/etc.) come from {@code r} unchanged. + */ + static RequestSpec.Builder candlesSpec( + StockCandlesRequest r, @Nullable LocalDate from, @Nullable LocalDate to) { + RequestSpec.Builder b = + RequestSpec.get( + "stocks/candles/" + + PathSegments.encode(r.resolution().wireValue()) + + "/" + + PathSegments.encode(r.symbol())); + if (r.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(r.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 (r.countback() != null) { + b.query("countback", r.countback()); + } + if (r.exchange() != null) { + b.query("exchange", r.exchange()); + } + if (r.extended() != null) { + b.query("extended", r.extended()); + } + if (r.country() != null) { + b.query("country", r.country()); + } + if (r.adjustSplits() != null) { + b.query("adjustsplits", r.adjustSplits()); + } + if (r.adjustDividends() != null) { + b.query("adjustdividends", r.adjustDividends()); + } + return b; + } + + /** Inclusive-{@code from} / exclusive-{@code to} date window for one candle sub-request. */ + record DateRange(@Nullable LocalDate from, @Nullable LocalDate to) {} + + /** Year-sized chunk span: the API caps a single intraday candle request to ~one year. */ + private static final int CHUNK_DAYS = 365; + + /** + * Splits a candle request's date window into the sub-ranges to fetch (§12). Returns a single + * range (the request's own window) unless the resolution is intraday and a {@code from} + * bound is set — then the {@code [from, to]} span (with {@code to} defaulting to today when + * open-ended) is cut into consecutive ≤{@value #CHUNK_DAYS}-day ranges. Mirrors the Python SDK's + * {@code split_dates_by_timeframe}: contiguous, non-overlapping ({@code to} of one chunk is the + * {@code from} of the next, and {@code to} is exclusive so the boundary candle isn't duplicated). + */ + static List candleChunks(StockCandlesRequest r) { + LocalDate from = r.from(); + if (from == null || !r.resolution().isIntraday()) { + return List.of(new DateRange(from, r.to())); + } + LocalDate to = r.to() != null ? r.to() : LocalDate.now(); + if (!from.isBefore(to)) { + return List.of(new DateRange(from, r.to())); // degenerate — let the backend handle it + } + List ranges = new java.util.ArrayList<>(); + LocalDate current = from; + while (true) { + LocalDate nextCut = current.plusDays(CHUNK_DAYS); + if (!nextCut.isBefore(to)) { // nextCut >= to + ranges.add(new DateRange(current, to)); + break; + } + ranges.add(new DateRange(current, nextCut)); + current = nextCut; + } + return ranges; + } + + static RequestSpec.Builder quoteSpec( + String symbol, + @Nullable Boolean extended, + @Nullable Boolean candle, + @Nullable Boolean week52) { + RequestSpec.Builder b = RequestSpec.get("stocks/quotes/" + PathSegments.encode(symbol)); + applyQuoteFlags(b, extended, candle, week52); + return b; + } + + static RequestSpec.Builder quotesSpec( + List symbols, + @Nullable Boolean extended, + @Nullable Boolean candle, + @Nullable Boolean week52) { + RequestSpec.Builder b = RequestSpec.get("stocks/quotes"); + b.query("symbols", String.join(",", symbols)); + applyQuoteFlags(b, extended, candle, week52); + return b; + } + + private static void applyQuoteFlags( + RequestSpec.Builder b, + @Nullable Boolean extended, + @Nullable Boolean candle, + @Nullable Boolean week52) { + if (extended != null) { + b.query("extended", extended); + } + if (candle != null) { + b.query("candle", candle); + } + if (week52 != null) { + b.query("52week", week52); + } + } + + static RequestSpec.Builder pricesSpec(List symbols) { + RequestSpec.Builder b = RequestSpec.get("stocks/prices"); + b.query("symbols", String.join(",", symbols)); + return b; + } + + static RequestSpec.Builder newsSpec(StockNewsRequest r) { + RequestSpec.Builder b = RequestSpec.get("stocks/news/" + PathSegments.encode(r.symbol())); + applyWindow(b, r.date(), r.from(), r.to(), r.countback()); + return b; + } + + static RequestSpec.Builder earningsSpec(StockEarningsRequest r) { + RequestSpec.Builder b = RequestSpec.get("stocks/earnings/" + PathSegments.encode(r.symbol())); + applyWindow(b, r.date(), r.from(), r.to(), r.countback()); + if (r.report() != null) { + b.query("report", r.report()); + } + return b; + } + + private static void applyWindow( + RequestSpec.Builder b, + @Nullable LocalDate date, + @Nullable LocalDate from, + @Nullable LocalDate to, + @Nullable Integer countback) { + 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); + } + } + + // ---------- wire-format module ---------- + + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-stocks"); + m.addDeserializer( + StockCandles.class, + rowsDeserializer( + CANDLE_FIELDS, CANDLE_FIELDS, StocksResource::buildCandleRow, StockCandles::new)); + m.addDeserializer( + StockQuotes.class, + rowsDeserializer( + QUOTE_FIELDS, QUOTE_REQUIRED_FIELDS, StocksResource::buildQuoteRow, StockQuotes::new)); + m.addDeserializer( + StockPrices.class, + rowsDeserializer( + PRICE_FIELDS, PRICE_FIELDS, StocksResource::buildPriceRow, StockPrices::new)); + m.addDeserializer( + StockEarnings.class, + rowsDeserializer( + EARNINGS_FIELDS, + EARNINGS_REQUIRED_FIELDS, + StocksResource::buildEarningRow, + StockEarnings::new)); + m.addDeserializer(StockNews.class, new StockNewsDeserializer()); + return m; + } + + // candle columns: every column required (any may be projected away via `columns`). + private static final List CANDLE_FIELDS = List.of("t", "o", "h", "l", "c", "v"); + + // quote columns: the always-emitted set is required; OHLC + 52-week extremes are opt-in. + private static final List QUOTE_REQUIRED_FIELDS = + List.of( + "symbol", + "ask", + "askSize", + "bid", + "bidSize", + "mid", + "last", + "change", + "changepct", + "volume", + "updated"); + private static final List QUOTE_FIELDS = + List.of( + "symbol", + "ask", + "askSize", + "bid", + "bidSize", + "mid", + "last", + "change", + "changepct", + "volume", + "updated", + "o", + "h", + "l", + "c", + "52weekHigh", + "52weekLow"); + + private static final List PRICE_FIELDS = + List.of("symbol", "mid", "change", "changepct", "updated"); + + private static final List EARNINGS_FIELDS = + List.of( + "symbol", + "fiscalYear", + "fiscalQuarter", + "date", + "reportDate", + "reportTime", + "currency", + "reportedEPS", + "estimatedEPS", + "surpriseEPS", + "surpriseEPSpct", + "updated"); + // Identity/timing columns the backend always emits; the rest are legitimately null on + // future-quarter or fundamentals-missing rows. + private static final List EARNINGS_REQUIRED_FIELDS = List.of("symbol", "date", "updated"); + + private static StockCandle buildCandleRow(ParallelArrays.Row row) throws IOException { + return new StockCandle( + dateOrTimestampOrNull(row, "t"), + row.dblOrNull("o"), + row.dblOrNull("h"), + row.dblOrNull("l"), + row.dblOrNull("c"), + row.lngOrNull("v")); + } + + private static StockQuote buildQuoteRow(ParallelArrays.Row row) throws IOException { + return new StockQuote( + row.textOrNull("symbol"), + row.dblOrNull("ask"), + row.lngOrNull("askSize"), + row.dblOrNull("bid"), + row.lngOrNull("bidSize"), + row.dblOrNull("mid"), + row.dblOrNull("last"), + row.dblOrNull("change"), + row.dblOrNull("changepct"), + row.lngOrNull("volume"), + timestampOrNull(row, "updated"), + row.dblOrNull("o"), + row.dblOrNull("h"), + row.dblOrNull("l"), + row.dblOrNull("c"), + row.dblOrNull("52weekHigh"), + row.dblOrNull("52weekLow")); + } + + private static StockPrice buildPriceRow(ParallelArrays.Row row) throws IOException { + return new StockPrice( + row.textOrNull("symbol"), + row.dblOrNull("mid"), + row.dblOrNull("change"), + row.dblOrNull("changepct"), + timestampOrNull(row, "updated")); + } + + private static StockEarning buildEarningRow(ParallelArrays.Row row) throws IOException { + return new StockEarning( + row.textOrNull("symbol"), + intOrNull(row.lngOrNull("fiscalYear")), + intOrNull(row.lngOrNull("fiscalQuarter")), + dateOrTimestampOrNull(row, "date"), + dateOrTimestampOrNull(row, "reportDate"), + row.textOrNull("reportTime"), + row.textOrNull("currency"), + row.dblOrNull("reportedEPS"), + row.dblOrNull("estimatedEPS"), + row.dblOrNull("surpriseEPS"), + row.dblOrNull("surpriseEPSpct"), + timestampOrNull(row, "updated")); + } + + private static @Nullable ZonedDateTime timestampOrNull(ParallelArrays.Row row, String field) + throws IOException { + JsonNode n = row.nodeOrNull(field); + return n == null ? null : MarketDataDates.parseTimestampField(null, n, field); + } + + private static @Nullable ZonedDateTime dateOrTimestampOrNull(ParallelArrays.Row row, String field) + throws IOException { + JsonNode n = row.nodeOrNull(field); + return n == null ? null : MarketDataDates.parseDateOrTimestampField(null, n, field); + } + + private static @Nullable Integer intOrNull(@Nullable Long v) { + return v == null ? null : v.intValue(); + } + + /** + * Builds a parallel-arrays deserializer where every column is optional at the wire level (so a + * {@code columns} projection decodes cleanly to nulls), restoring the strict guarantee via {@link + * #validateRequestedColumns} (Option A): a requested required column the API omitted + * surfaces as a {@code ParseError} instead of a silent null. + */ + private static JsonDeserializer rowsDeserializer( + List allFields, + List requiredFields, + ParallelArrays.RowBuilder rowBuilder, + 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(), allFields, rowBuilder); + validateRequestedColumns(p, root, rows.size(), ctxt, requiredFields); + return wrapper.apply(rows); + } + }; + } + + /** + * Option A: for every required column the consumer asked for (explicitly via {@code columns}, or + * implicitly by not projecting at all), verify the API actually returned it. A requested-but- + * absent required column throws, so a {@code null} a consumer sees only ever means "I projected + * it away" — never "the backend silently dropped it". + */ + private static void validateRequestedColumns( + JsonParser p, + JsonNode root, + int rowCount, + DeserializationContext ctxt, + List requiredFields) + throws JsonMappingException { + if (rowCount == 0) { + 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).collect(Collectors.toList()) + : List.of(); + for (String field : requiredFields) { + 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/stocks/StockCandle.java b/src/main/java/com/marketdata/sdk/stocks/StockCandle.java new file mode 100644 index 0000000..0139fd1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockCandle.java @@ -0,0 +1,29 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; + +/** + * A single OHLCV candle — one row of {@link StockCandles}. {@code time} is the bar's opening moment + * in {@code America/New_York} (a daily bar is midnight market-time; an intraday bar carries the + * time-of-day). + * + *

Every field is a nullable boxed type so the {@code columns} universal parameter can project + * the response to a subset (an unrequested column decodes to {@code null}). The deserializer stays + * strict about requested columns — a required column asked for but omitted by the API + * surfaces as a {@code ParseError} (Option A), never a silent null. + * + * @param time bar timestamp ({@code t} on the wire). + * @param open opening price ({@code o}). + * @param high session high ({@code h}). + * @param low session low ({@code l}). + * @param close closing price ({@code c}). + * @param volume traded volume ({@code v}). + */ +public record StockCandle( + @Nullable ZonedDateTime time, + @Nullable Double open, + @Nullable Double high, + @Nullable Double low, + @Nullable Double close, + @Nullable Long volume) {} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockCandles.java b/src/main/java/com/marketdata/sdk/stocks/StockCandles.java new file mode 100644 index 0000000..2cbf9b4 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockCandles.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.stocks; + +import java.util.List; +import java.util.Objects; + +/** + * Decoded body of {@code GET /v1/stocks/candles/{resolution}/{symbol}/} — the OHLCV series for one + * symbol, one {@link StockCandle} per bar in the order the API delivered them. + * + * @param candles the rows; immutable, never {@code null}, empty for a {@code "s":"no_data"} body. + */ +public record StockCandles(List candles) { + + public StockCandles { + Objects.requireNonNull(candles, "candles"); + candles = List.copyOf(candles); + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockCandlesRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockCandlesRequest.java new file mode 100644 index 0000000..5641532 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockCandlesRequest.java @@ -0,0 +1,177 @@ +package com.marketdata.sdk.stocks; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/stocks/candles/{resolution}/{symbol}/}. The {@link StockResolution} + * and the ticker {@code symbol} are required; the rest bound the window and tune + * adjustment/exchange resolution. + * + *

Window rules (enforced in {@link Builder#build()}): {@code date} selects a single trading day + * and is incompatible with {@code from}/{@code to}/{@code countback}; {@code countback} pairs with + * {@code to} (not {@code from}) and must be positive. + */ +public final class StockCandlesRequest { + + private final StockResolution resolution; + private final String symbol; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + private final @Nullable String exchange; + private final @Nullable Boolean extended; + private final @Nullable String country; + private final @Nullable Boolean adjustSplits; + private final @Nullable Boolean adjustDividends; + + private StockCandlesRequest(Builder b) { + this.resolution = b.resolution; + this.symbol = b.symbol; + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + this.exchange = b.exchange; + this.extended = b.extended; + this.country = b.country; + this.adjustSplits = b.adjustSplits; + this.adjustDividends = b.adjustDividends; + } + + /** Shortcut for {@code builder(resolution, symbol).build()}. */ + public static StockCandlesRequest of(StockResolution resolution, String symbol) { + return builder(resolution, symbol).build(); + } + + public static Builder builder(StockResolution resolution, String symbol) { + return new Builder(resolution, symbol); + } + + public StockResolution resolution() { + return resolution; + } + + public String symbol() { + return symbol; + } + + public @Nullable LocalDate date() { + return date; + } + + public @Nullable LocalDate from() { + return from; + } + + public @Nullable LocalDate to() { + return to; + } + + public @Nullable Integer countback() { + return countback; + } + + public @Nullable String exchange() { + return exchange; + } + + public @Nullable Boolean extended() { + return extended; + } + + public @Nullable String country() { + return country; + } + + public @Nullable Boolean adjustSplits() { + return adjustSplits; + } + + public @Nullable Boolean adjustDividends() { + return adjustDividends; + } + + public static final class Builder { + private final StockResolution resolution; + private final String symbol; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + private @Nullable String exchange; + private @Nullable Boolean extended; + private @Nullable String country; + private @Nullable Boolean adjustSplits; + private @Nullable Boolean adjustDividends; + + private Builder(StockResolution resolution, String symbol) { + this.resolution = Objects.requireNonNull(resolution, "resolution"); + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** + * Look up candles for a single trading day. Exclusive with {@code from}/{@code to}/countback. + */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + /** The leftmost candle (inclusive). */ + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + /** The rightmost candle (exclusive). */ + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** Fetch {@code countback} candles before {@code to}. Positive; pair with {@code to}. */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + /** Disambiguate the exchange (acronym, MIC code, or two-digit Yahoo code). */ + public Builder exchange(String exchange) { + this.exchange = Objects.requireNonNull(exchange, "exchange"); + return this; + } + + /** Include extended-hours sessions on intraday candles (daily never returns extended). */ + public Builder extended(boolean extended) { + this.extended = extended; + return this; + } + + /** Disambiguate the exchange country (two-digit ISO 3166 code). */ + public Builder country(String country) { + this.country = Objects.requireNonNull(country, "country"); + return this; + } + + /** Adjust for splits (daily default true, intraday default false). */ + public Builder adjustSplits(boolean adjustSplits) { + this.adjustSplits = adjustSplits; + return this; + } + + /** Adjust for dividends (daily default true, intraday default false). */ + public Builder adjustDividends(boolean adjustDividends) { + this.adjustDividends = adjustDividends; + return this; + } + + public StockCandlesRequest build() { + StockRequests.requireNonEmpty(symbol, "symbol"); + StockRequests.validateWindow(date, from, to, countback); + return new StockCandlesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockEarning.java b/src/main/java/com/marketdata/sdk/stocks/StockEarning.java new file mode 100644 index 0000000..1f55177 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockEarning.java @@ -0,0 +1,40 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; + +/** + * A single earnings report — one row of {@link StockEarnings}. Both historical reports and the + * forward earnings calendar share this shape. + * + *

Several fields are legitimately {@code null}: {@code fiscalYear}/{@code fiscalQuarter} when + * the provider lacks the fundamentals; {@code reportTime}/{@code reportDate} and the EPS figures on + * a synthesized future-quarter row whose actuals don't exist yet. All fields are nullable boxed + * types (also enabling {@code columns} projection). + * + * @param symbol the ticker. + * @param fiscalYear fiscal year the report covers. + * @param fiscalQuarter fiscal quarter (1–4). + * @param date the fiscal period end date, lifted to a market-zone moment. + * @param reportDate the date the report was (or will be) released. + * @param reportTime relative release time, e.g. {@code "before open"} / {@code "after close"}. + * @param currency reporting currency (ISO code). + * @param reportedEPS actual earnings per share. + * @param estimatedEPS consensus estimate. + * @param surpriseEPS reported minus estimated. + * @param surpriseEPSpct surprise as a fraction of the estimate. + * @param updated row update timestamp in {@code America/New_York}. + */ +public record StockEarning( + @Nullable String symbol, + @Nullable Integer fiscalYear, + @Nullable Integer fiscalQuarter, + @Nullable ZonedDateTime date, + @Nullable ZonedDateTime reportDate, + @Nullable String reportTime, + @Nullable String currency, + @Nullable Double reportedEPS, + @Nullable Double estimatedEPS, + @Nullable Double surpriseEPS, + @Nullable Double surpriseEPSpct, + @Nullable ZonedDateTime updated) {} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockEarnings.java b/src/main/java/com/marketdata/sdk/stocks/StockEarnings.java new file mode 100644 index 0000000..473c6de --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockEarnings.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.stocks; + +import java.util.List; +import java.util.Objects; + +/** + * Decoded body of {@code GET /v1/stocks/earnings/{symbol}/} — one {@link StockEarning} row per + * report, newest last (the order the API delivers them). + * + * @param earnings the rows; immutable, never {@code null}, empty for a {@code "s":"no_data"} body. + */ +public record StockEarnings(List earnings) { + + public StockEarnings { + Objects.requireNonNull(earnings, "earnings"); + earnings = List.copyOf(earnings); + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockEarningsRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockEarningsRequest.java new file mode 100644 index 0000000..e0850dd --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockEarningsRequest.java @@ -0,0 +1,113 @@ +package com.marketdata.sdk.stocks; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/stocks/earnings/{symbol}/}. The ticker is required; the optional + * window follows the shared rules. {@code report} selects a specific fiscal report by {@code + * YYYY-Qn} (e.g. {@code "2023-Q4"}) without needing the company's fiscal calendar. + */ +public final class StockEarningsRequest { + + private final String symbol; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + private final @Nullable String report; + + private StockEarningsRequest(Builder b) { + this.symbol = b.symbol; + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + this.report = b.report; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static StockEarningsRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + public @Nullable LocalDate date() { + return date; + } + + public @Nullable LocalDate from() { + return from; + } + + public @Nullable LocalDate to() { + return to; + } + + public @Nullable Integer countback() { + return countback; + } + + public @Nullable String report() { + return report; + } + + public static final class Builder { + private final String symbol; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + private @Nullable String report; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** + * Retrieve a single report by date. Exclusive with {@code from}/{@code to}/{@code countback}. + */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + /** The earliest report to include. */ + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + /** The latest report to include. */ + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** Fetch {@code countback} reports before {@code to}. Positive; pair with {@code to}. */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + /** Retrieve a specific fiscal report, e.g. {@code "2023-Q4"}. */ + public Builder report(String report) { + this.report = Objects.requireNonNull(report, "report"); + return this; + } + + public StockEarningsRequest build() { + StockRequests.requireNonEmpty(symbol, "symbol"); + StockRequests.validateWindow(date, from, to, countback); + return new StockEarningsRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockNews.java b/src/main/java/com/marketdata/sdk/stocks/StockNews.java new file mode 100644 index 0000000..6f37e43 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockNews.java @@ -0,0 +1,24 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Decoded body of {@code GET /v1/stocks/news/{symbol}/}. The article fields are per-row arrays; + * {@code updated} is a scalar at the response root — the single most-recent update time + * for the live feed. The backend omits {@code updated} for date-bounded (historical) queries, so it + * is {@code @Nullable} here. + * + * @param articles the article rows; immutable, never {@code null}, empty for a {@code + * "s":"no_data"} body. + * @param updated the feed's latest update time, or {@code null} for historical queries. + */ +public record StockNews(List articles, @Nullable ZonedDateTime updated) { + + public StockNews { + Objects.requireNonNull(articles, "articles"); + articles = List.copyOf(articles); + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockNewsArticle.java b/src/main/java/com/marketdata/sdk/stocks/StockNewsArticle.java new file mode 100644 index 0000000..d5146f3 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockNewsArticle.java @@ -0,0 +1,17 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; + +/** + * A single news article — one row of {@link StockNews}. Fields are non-null: the backend always + * emits every column for a normal article row (a {@code "s":"no_data"} body carries no rows at + * all). + * + * @param symbol the ticker the article is about. + * @param headline the article headline. + * @param content the article body/summary. + * @param source the article URL. + * @param publicationDate the publication date, lifted to a market-zone moment. + */ +public record StockNewsArticle( + String symbol, String headline, String content, String source, ZonedDateTime publicationDate) {} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockNewsRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockNewsRequest.java new file mode 100644 index 0000000..75d1d52 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockNewsRequest.java @@ -0,0 +1,98 @@ +package com.marketdata.sdk.stocks; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/stocks/news/{symbol}/}. The ticker is required; the optional window + * follows the shared rules ({@code date} is single-day and exclusive with {@code from}/{@code + * to}/{@code countback}; {@code countback} pairs with {@code to}). + */ +public final class StockNewsRequest { + + private final String symbol; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + + private StockNewsRequest(Builder b) { + this.symbol = b.symbol; + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static StockNewsRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + public @Nullable LocalDate date() { + return date; + } + + public @Nullable LocalDate from() { + return from; + } + + public @Nullable LocalDate to() { + return to; + } + + public @Nullable Integer countback() { + return countback; + } + + public static final class Builder { + private final String symbol; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Retrieve news for a single day. Exclusive with {@code from}/{@code to}/{@code countback}. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + /** The earliest article to include. */ + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + /** The latest article to include. */ + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** Fetch {@code countback} articles before {@code to}. Positive; pair with {@code to}. */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + public StockNewsRequest build() { + StockRequests.requireNonEmpty(symbol, "symbol"); + StockRequests.validateWindow(date, from, to, countback); + return new StockNewsRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockPrice.java b/src/main/java/com/marketdata/sdk/stocks/StockPrice.java new file mode 100644 index 0000000..c4e704f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockPrice.java @@ -0,0 +1,23 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; + +/** + * A single stock price — one row of {@link StockPrices}. A lighter-weight shape than {@link + * StockQuote}: just the midpoint, the day's change, and the timestamp. Every field is a nullable + * boxed type so {@code columns} can project the row and so a {@code NaN} from a closed/illiquid + * market decodes to {@code null}. + * + * @param symbol the ticker. + * @param mid the midpoint price. + * @param change absolute change versus the prior close. + * @param changepct fractional change versus the prior close. + * @param updated price timestamp in {@code America/New_York}. + */ +public record StockPrice( + @Nullable String symbol, + @Nullable Double mid, + @Nullable Double change, + @Nullable Double changepct, + @Nullable ZonedDateTime updated) {} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockPrices.java b/src/main/java/com/marketdata/sdk/stocks/StockPrices.java new file mode 100644 index 0000000..a2a8c2a --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockPrices.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.stocks; + +import java.util.List; +import java.util.Objects; + +/** + * Decoded body of {@code GET /v1/stocks/prices/?symbols=A,B,C} — one {@link StockPrice} row per + * requested symbol in a single response. + * + * @param prices the rows; immutable, never {@code null}, empty for a {@code "s":"no_data"} body. + */ +public record StockPrices(List prices) { + + public StockPrices { + Objects.requireNonNull(prices, "prices"); + prices = List.copyOf(prices); + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockPricesRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockPricesRequest.java new file mode 100644 index 0000000..1377662 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockPricesRequest.java @@ -0,0 +1,57 @@ +package com.marketdata.sdk.stocks; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Parameters for {@code GET /v1/stocks/prices/?symbols=A,B,C} — one or more ticker symbols, batched + * into a single request. The backend returns one {@link StockPrice} row per symbol. + */ +public final class StockPricesRequest { + + private final List symbols; + + private StockPricesRequest(Builder b) { + this.symbols = List.copyOf(b.symbols); + } + + /** Shortcut for {@code builder(first, rest...).build()}. */ + public static StockPricesRequest of(String first, String... rest) { + return builder(first, rest).build(); + } + + /** Start a builder with one or more ticker symbols. At least one is required. */ + 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 symbols() { + return symbols; + } + + public static final class Builder { + private final List symbols = new ArrayList<>(); + + private Builder() {} + + public Builder addSymbol(String symbol) { + Objects.requireNonNull(symbol, "symbol"); + StockRequests.requireNonEmpty(symbol, "symbol"); + this.symbols.add(symbol); + return this; + } + + public StockPricesRequest build() { + if (symbols.isEmpty()) { + throw new IllegalArgumentException("at least one symbol is required"); + } + return new StockPricesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockQuote.java b/src/main/java/com/marketdata/sdk/stocks/StockQuote.java new file mode 100644 index 0000000..c105d8e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockQuote.java @@ -0,0 +1,54 @@ +package com.marketdata.sdk.stocks; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; + +/** + * A single real-time stock quote — one row of {@link StockQuotes}. Carries the NBBO + * (bid/ask/sizes), the mid/last, the day's change, and traded volume. + * + *

Every field is a nullable boxed type. Two reasons: the {@code columns} universal parameter can + * project the row to a subset (unrequested columns decode to {@code null}); and the backend runs + * {@code NaN → null} across the numeric columns, so a closed or illiquid market legitimately yields + * {@code null} bid/ask/last even when those columns were requested. + * + *

The OHLC fields ({@link #open}/{@link #high}/{@link #low}/{@link #close}) are populated only + * when the request opts in via {@code candle=true}; the 52-week extremes only when {@code + * 52week=true}. Absent otherwise. + * + * @param symbol the ticker. + * @param ask best ask price. + * @param askSize best ask size. + * @param bid best bid price. + * @param bidSize best bid size. + * @param mid midpoint of the NBBO. + * @param last last traded price. + * @param change absolute change versus the prior close. + * @param changepct fractional change versus the prior close. + * @param volume cumulative session volume. + * @param updated quote timestamp in {@code America/New_York}. + * @param open session open (opt-in via {@code candle}). + * @param high session high (opt-in via {@code candle}). + * @param low session low (opt-in via {@code candle}). + * @param close session close (opt-in via {@code candle}). + * @param week52High 52-week high (opt-in via {@code 52week}). + * @param week52Low 52-week low (opt-in via {@code 52week}). + */ +public record StockQuote( + @Nullable String symbol, + @Nullable Double ask, + @Nullable Long askSize, + @Nullable Double bid, + @Nullable Long bidSize, + @Nullable Double mid, + @Nullable Double last, + @Nullable Double change, + @Nullable Double changepct, + @Nullable Long volume, + @Nullable ZonedDateTime updated, + @Nullable Double open, + @Nullable Double high, + @Nullable Double low, + @Nullable Double close, + @Nullable Double week52High, + @Nullable Double week52Low) {} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockQuoteRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockQuoteRequest.java new file mode 100644 index 0000000..c93e302 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockQuoteRequest.java @@ -0,0 +1,86 @@ +package com.marketdata.sdk.stocks; + +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the single-symbol form of {@code GET /v1/stocks/quotes/{symbol}/}. For several + * symbols in one call use {@link StockQuotesRequest} (the backend batches a comma list in a single + * request). + * + *

{@code candle} opts the OHLC columns into the response; {@code week52} opts in the 52-week + * extremes; {@code extended} toggles extended-session prices (default on at the API). + */ +public final class StockQuoteRequest { + + private final String symbol; + private final @Nullable Boolean extended; + private final @Nullable Boolean candle; + private final @Nullable Boolean week52; + + private StockQuoteRequest(Builder b) { + this.symbol = b.symbol; + this.extended = b.extended; + this.candle = b.candle; + this.week52 = b.week52; + } + + /** Shortcut for {@code builder(symbol).build()}. */ + public static StockQuoteRequest of(String symbol) { + return builder(symbol).build(); + } + + public static Builder builder(String symbol) { + return new Builder(symbol); + } + + public String symbol() { + return symbol; + } + + public @Nullable Boolean extended() { + return extended; + } + + public @Nullable Boolean candle() { + return candle; + } + + public @Nullable Boolean week52() { + return week52; + } + + public static final class Builder { + private final String symbol; + private @Nullable Boolean extended; + private @Nullable Boolean candle; + private @Nullable Boolean week52; + + private Builder(String symbol) { + this.symbol = Objects.requireNonNull(symbol, "symbol"); + } + + /** Whether to include extended-session prices (API default: true). */ + public Builder extended(boolean extended) { + this.extended = extended; + return this; + } + + /** Add the OHLC columns ({@code open}/{@code high}/{@code low}/{@code close}) to each row. */ + public Builder candle(boolean candle) { + this.candle = candle; + return this; + } + + /** Add the 52-week high/low columns to each row. */ + public Builder week52(boolean week52) { + this.week52 = week52; + return this; + } + + public StockQuoteRequest build() { + StockRequests.requireNonEmpty(symbol, "symbol"); + return new StockQuoteRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockQuotes.java b/src/main/java/com/marketdata/sdk/stocks/StockQuotes.java new file mode 100644 index 0000000..3876185 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockQuotes.java @@ -0,0 +1,20 @@ +package com.marketdata.sdk.stocks; + +import java.util.List; +import java.util.Objects; + +/** + * Decoded body of the stock quote endpoints. The single-symbol form ({@code + * /stocks/quotes/{symbol}/}) and the multi-symbol batch ({@code /stocks/quotes/?symbols=A,B,C}) + * share this shape — the batch returns one {@link StockQuote} row per symbol in a single response + * (the backend accepts a comma list in one request, so no fan-out is needed). + * + * @param quotes the rows; immutable, never {@code null}, empty for a {@code "s":"no_data"} body. + */ +public record StockQuotes(List quotes) { + + public StockQuotes { + Objects.requireNonNull(quotes, "quotes"); + quotes = List.copyOf(quotes); + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockQuotesRequest.java b/src/main/java/com/marketdata/sdk/stocks/StockQuotesRequest.java new file mode 100644 index 0000000..63a5f6f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockQuotesRequest.java @@ -0,0 +1,96 @@ +package com.marketdata.sdk.stocks; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for the multi-symbol form of {@code GET /v1/stocks/quotes/?symbols=A,B,C}. Unlike the + * options multi-quote (which fans out one request per contract), the stocks backend accepts a comma + * list in a single request and returns one row per symbol — so this maps to one {@code + * StockQuotesResponse}, not a per-symbol map. + * + *

For a single symbol prefer {@link StockQuoteRequest}. + */ +public final class StockQuotesRequest { + + private final List symbols; + private final @Nullable Boolean extended; + private final @Nullable Boolean candle; + private final @Nullable Boolean week52; + + private StockQuotesRequest(Builder b) { + this.symbols = List.copyOf(b.symbols); + this.extended = b.extended; + this.candle = b.candle; + this.week52 = b.week52; + } + + /** Start a builder with one or more ticker symbols. At least one is required. */ + 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 symbols() { + return symbols; + } + + public @Nullable Boolean extended() { + return extended; + } + + public @Nullable Boolean candle() { + return candle; + } + + public @Nullable Boolean week52() { + return week52; + } + + public static final class Builder { + private final List symbols = new ArrayList<>(); + private @Nullable Boolean extended; + private @Nullable Boolean candle; + private @Nullable Boolean week52; + + private Builder() {} + + public Builder addSymbol(String symbol) { + Objects.requireNonNull(symbol, "symbol"); + StockRequests.requireNonEmpty(symbol, "symbol"); + this.symbols.add(symbol); + return this; + } + + /** Whether to include extended-session prices (API default: true). */ + public Builder extended(boolean extended) { + this.extended = extended; + return this; + } + + /** Add the OHLC columns to each row. */ + public Builder candle(boolean candle) { + this.candle = candle; + return this; + } + + /** Add the 52-week high/low columns to each row. */ + public Builder week52(boolean week52) { + this.week52 = week52; + return this; + } + + public StockQuotesRequest build() { + if (symbols.isEmpty()) { + throw new IllegalArgumentException("at least one symbol is required"); + } + return new StockQuotesRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockRequests.java b/src/main/java/com/marketdata/sdk/stocks/StockRequests.java new file mode 100644 index 0000000..e520745 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockRequests.java @@ -0,0 +1,42 @@ +package com.marketdata.sdk.stocks; + +import java.time.LocalDate; +import org.jspecify.annotations.Nullable; + +/** Shared request-builder validation for the stock endpoints. */ +final class StockRequests { + + private StockRequests() {} + + /** + * Validates the historical-window parameters shared by candles/news/earnings: {@code date} is a + * single-point lookup incompatible with any ranging parameter; {@code countback} is an + * alternative to {@code from} for the left edge (per the API: "if you use from, countback is not + * required"), 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"); + } + } + } + + static String requireNonEmpty(String value, String name) { + if (value.isEmpty()) { + throw new IllegalArgumentException(name + " must be non-empty"); + } + return value; + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/StockResolution.java b/src/main/java/com/marketdata/sdk/stocks/StockResolution.java new file mode 100644 index 0000000..2601060 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/StockResolution.java @@ -0,0 +1,117 @@ +package com.marketdata.sdk.stocks; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Candle resolution for {@code GET /v1/stocks/candles/{resolution}/{symbol}/}. A value type rather + * than an enum: the API accepts an open-ended family of resolutions (any minute count, any multiple + * of hours/days/weeks/months/years), so the meaningful values cannot be enumerated up front. + * + *

Use the factories for the common shapes — {@link #minutes(int)}, {@link #hours(int)}, {@link + * #days(int)}, {@link #weeks(int)}, {@link #months(int)}, {@link #years(int)} — or the {@link + * #DAILY}/{@link #WEEKLY}/{@link #MONTHLY}/{@link #YEARLY} constants for the bare forms. {@link + * #of(String)} passes an arbitrary wire token through verbatim for resolutions the factories don't + * model. + */ +public final class StockResolution { + + /** Daily candles ({@code D}). */ + public static final StockResolution DAILY = new StockResolution("D"); + + /** Weekly candles ({@code W}). */ + public static final StockResolution WEEKLY = new StockResolution("W"); + + /** Monthly candles ({@code M}). */ + public static final StockResolution MONTHLY = new StockResolution("M"); + + /** Yearly candles ({@code Y}). */ + public static final StockResolution YEARLY = new StockResolution("Y"); + + private final String wireValue; + + private StockResolution(String wireValue) { + this.wireValue = wireValue; + } + + /** Minutely candles: {@code n} minutes per bar (e.g. {@code 1}, {@code 5}, {@code 15}). */ + public static StockResolution minutes(int n) { + return new StockResolution(Integer.toString(requirePositive(n, "minutes"))); + } + + /** Hourly candles: {@code n} hours per bar (e.g. {@code 1H}, {@code 4H}). */ + public static StockResolution hours(int n) { + return new StockResolution(requirePositive(n, "hours") + "H"); + } + + /** Daily candles: {@code n} days per bar (e.g. {@code 1D}, {@code 2D}). */ + public static StockResolution days(int n) { + return new StockResolution(requirePositive(n, "days") + "D"); + } + + /** Weekly candles: {@code n} weeks per bar. */ + public static StockResolution weeks(int n) { + return new StockResolution(requirePositive(n, "weeks") + "W"); + } + + /** Monthly candles: {@code n} months per bar. */ + public static StockResolution months(int n) { + return new StockResolution(requirePositive(n, "months") + "M"); + } + + /** Yearly candles: {@code n} years per bar. */ + public static StockResolution years(int n) { + return new StockResolution(requirePositive(n, "years") + "Y"); + } + + /** An arbitrary resolution token, passed to the API verbatim. Must be non-blank. */ + public static StockResolution of(String wireValue) { + Objects.requireNonNull(wireValue, "wireValue"); + if (wireValue.isBlank()) { + throw new IllegalArgumentException("resolution must be non-blank"); + } + return new StockResolution(wireValue); + } + + private static int requirePositive(int n, String name) { + if (n <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return n; + } + + // Minutely (a bare positive integer) or hourly (optional count + "H", or descriptive). Matches + // the + // Python SDK's is_intraday classifier — the trigger for auto-chunking large candle date ranges. + private static final Pattern INTRADAY = + Pattern.compile("^(?:[1-9]\\d*H?|H|minutely|hourly)$", Pattern.CASE_INSENSITIVE); + + /** The value placed in the {@code {resolution}} path segment. */ + public String wireValue() { + return wireValue; + } + + /** + * Whether this is an intraday resolution (minutely or hourly). Daily/weekly/monthly/yearly are + * not. Intraday resolutions over a multi-year date range are auto-split into year-sized chunks + * and fetched concurrently (SDK requirements §12) — this predicate is the trigger. + */ + public boolean isIntraday() { + return INTRADAY.matcher(wireValue).matches(); + } + + @Override + public boolean equals(Object o) { + return o instanceof StockResolution other && wireValue.equals(other.wireValue); + } + + @Override + public int hashCode() { + return wireValue.hashCode(); + } + + @Override + public String toString() { + return "StockResolution[" + wireValue + "]"; + } +} diff --git a/src/main/java/com/marketdata/sdk/stocks/package-info.java b/src/main/java/com/marketdata/sdk/stocks/package-info.java new file mode 100644 index 0000000..5aafe28 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/stocks/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records and request types for the {@code stocks} resource — candles, quotes, prices, + * news, and earnings. The {@link com.marketdata.sdk.StocksResource} 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.stocks; diff --git a/src/test/java/com/marketdata/sdk/StocksResourceTest.java b/src/test/java/com/marketdata/sdk/StocksResourceTest.java new file mode 100644 index 0000000..651c4f9 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/StocksResourceTest.java @@ -0,0 +1,829 @@ +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.stocks.StockCandle; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockEarning; +import com.marketdata.sdk.stocks.StockEarningsRequest; +import com.marketdata.sdk.stocks.StockNewsArticle; +import com.marketdata.sdk.stocks.StockNewsRequest; +import com.marketdata.sdk.stocks.StockPrice; +import com.marketdata.sdk.stocks.StockPricesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.Month; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class StocksResourceTest { + + 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); + + private static StocksResource 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 StocksResource(transport, new JsonResponseParser()); + } + + // ---------- canned bodies ---------- + + private static final String CANDLES_BODY = + "{\"s\":\"ok\"," + + "\"t\":[1705276800,1705363200]," + + "\"o\":[216.5,218.0]," + + "\"h\":[218.55,220.12]," + + "\"l\":[215.78,217.32]," + + "\"c\":[217.83,219.68]," + + "\"v\":[62130000,58240000]}"; + + private static final String QUOTE_BODY = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\"]," + + "\"ask\":[221.55],\"askSize\":[200],\"bid\":[221.5],\"bidSize\":[300]," + + "\"mid\":[221.525],\"last\":[221.52],\"change\":[1.38],\"changepct\":[0.0063]," + + "\"volume\":[58240000],\"updated\":[1705449600]}"; + + private static final String QUOTES_BODY = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"ask\":[221.55,415.2],\"askSize\":[200,100],\"bid\":[221.5,415.05]," + + "\"bidSize\":[300,150],\"mid\":[221.525,415.125],\"last\":[221.52,415.1]," + + "\"change\":[1.38,-2.4],\"changepct\":[0.0063,-0.0057]," + + "\"volume\":[58240000,22150000],\"updated\":[1705449600,1705449600]}"; + + private static final String PRICES_BODY = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," + + "\"mid\":[221.525,415.125],\"change\":[1.38,-2.4]," + + "\"changepct\":[0.0063,-0.0057],\"updated\":[1705449600,1705449600]}"; + + private static final String NEWS_BODY = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"AAPL\"]," + + "\"headline\":[\"Record Q4\",\"New product\"]," + + "\"content\":[\"body one\",\"body two\"]," + + "\"source\":[\"https://a/1\",\"https://b/2\"]," + + "\"publicationDate\":[1705449600,1705363200]," + + "\"updated\":1705449600}"; + + private static final String EARNINGS_BODY = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\"]," + + "\"fiscalYear\":[2024],\"fiscalQuarter\":[3]," + + "\"date\":[1706659200],\"reportDate\":[1706832000]," + + "\"reportTime\":[\"after close\"],\"currency\":[\"USD\"]," + + "\"reportedEPS\":[2.18],\"estimatedEPS\":[2.1]," + + "\"surpriseEPS\":[0.08],\"surpriseEPSpct\":[3.81]," + + "\"updated\":[1706832000]}"; + + // ---------- candles ---------- + + @Test + void candlesHitsVersionedEndpointWithResolutionAndSymbol() { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + stocks.candlesAsync(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/stocks/candles/D/AAPL/"); + assertThat(client.captured.get(0).method()).isEqualTo("GET"); + } + + @Test + void candlesAttachesAllParams() { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + stocks + .candlesAsync( + StockCandlesRequest.builder(StockResolution.hours(1), "AAPL") + .from(LocalDate.of(2025, Month.JANUARY, 1)) + .to(LocalDate.of(2025, Month.JANUARY, 31)) + .exchange("XNAS") + .extended(true) + .country("US") + .adjustSplits(true) + .adjustDividends(false) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("/v1/stocks/candles/1H/AAPL/") + .contains("from=2025-01-01") + .contains("to=2025-01-31") + .contains("exchange=XNAS") + .contains("extended=true") + .contains("country=US") + .contains("adjustsplits=true") + .contains("adjustdividends=false"); + } + + @Test + void candlesDecodesOhlcvRows() { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + List candles = + stocks.candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")).values(); + + assertThat(candles).hasSize(2); + StockCandle first = candles.get(0); + assertThat(first.open()).isEqualTo(216.5); + assertThat(first.high()).isEqualTo(218.55); + assertThat(first.low()).isEqualTo(215.78); + assertThat(first.close()).isEqualTo(217.83); + assertThat(first.volume()).isEqualTo(62130000L); + assertThat(first.time()).isNotNull(); + assertThat(first.time().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void candlesAcceptsDateOnlyTimestampStringForDailyBars() { + // Under dateformat=timestamp the daily candle `t` comes back date-only ("2025-01-17"); the + // tolerant parser lifts it to a market-zone midnight rather than failing on the missing time. + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"t\":[\"2025-01-17\"],\"o\":[216.5],\"h\":[218.55]," + + "\"l\":[215.78],\"c\":[217.83],\"v\":[62130000]}"); + StocksResource stocks = resourceWith(client); + + StockCandle c = + stocks + .dateFormat(DateFormat.TIMESTAMP) + .candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")) + .values() + .get(0); + + assertThat(c.time().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 17)); + assertThat(c.time().getHour()).isZero(); + } + + @Test + void candlesNoDataEnvelopeYieldsEmptyList() { + CapturingClient client = okWith("{\"s\":\"no_data\"}"); + StocksResource stocks = resourceWith(client); + + assertThat(stocks.candles(StockCandlesRequest.of(StockResolution.DAILY, "NOPE")).values()) + .isEmpty(); + } + + @Test + void candlesErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Invalid resolution\"}"); + StocksResource stocks = resourceWith(client); + + assertThatThrownBy( + () -> stocks.candles(StockCandlesRequest.of(StockResolution.of("bogus"), "AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Invalid resolution"); + } + + // ---------- quote (single) ---------- + + @Test + void quoteHitsVersionedEndpointWithFlags() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + + stocks + .quoteAsync( + StockQuoteRequest.builder("AAPL").extended(false).candle(true).week52(true).build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("/v1/stocks/quotes/AAPL/") + .contains("extended=false") + .contains("candle=true") + .contains("52week=true"); + } + + @Test + void quoteDecodesRow() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + + StockQuote q = stocks.quote(StockQuoteRequest.of("AAPL")).values().get(0); + + assertThat(q.symbol()).isEqualTo("AAPL"); + assertThat(q.ask()).isEqualTo(221.55); + assertThat(q.askSize()).isEqualTo(200L); + assertThat(q.bid()).isEqualTo(221.5); + assertThat(q.mid()).isEqualTo(221.525); + assertThat(q.last()).isEqualTo(221.52); + assertThat(q.change()).isEqualTo(1.38); + assertThat(q.changepct()).isEqualTo(0.0063); + assertThat(q.volume()).isEqualTo(58240000L); + assertThat(q.updated()).isNotNull(); + assertThat(q.updated().getZone().getId()).isEqualTo("America/New_York"); + // Opt-in columns absent from the body decode to null, not a parse failure. + assertThat(q.open()).isNull(); + assertThat(q.week52High()).isNull(); + } + + @Test + void quoteNullNumericCellsDecodeToNull() { + // Closed/illiquid market: the backend runs NaN→null over numeric columns. The cells are present + // but null — they must decode to null rather than tripping the strict parser. + CapturingClient client = + okWith( + "{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"ask\":[null],\"askSize\":[null]," + + "\"bid\":[null],\"bidSize\":[null],\"mid\":[null],\"last\":[null]," + + "\"change\":[null],\"changepct\":[null],\"volume\":[null],\"updated\":[1705449600]}"); + StocksResource stocks = resourceWith(client); + + StockQuote q = stocks.quote(StockQuoteRequest.of("AAPL")).values().get(0); + assertThat(q.ask()).isNull(); + assertThat(q.volume()).isNull(); + assertThat(q.symbol()).isEqualTo("AAPL"); + } + + @Test + void quoteSyncMirrorsAsync() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + assertThat(stocks.quote(StockQuoteRequest.of("AAPL")).values()).hasSize(1); + } + + // ---------- quotes (multi, single request) ---------- + + @Test + void quotesBatchesSymbolsInOneRequest() { + CapturingClient client = okWith(QUOTES_BODY); + StocksResource stocks = resourceWith(client); + + StockQuotesResponse resp = stocks.quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + + assertThat(client.captured).hasSize(1); + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url).contains("/v1/stocks/quotes/?").contains("symbols=AAPL,MSFT"); + assertThat(resp.values()).hasSize(2); + assertThat(resp.values().get(0).symbol()).isEqualTo("AAPL"); + assertThat(resp.values().get(1).symbol()).isEqualTo("MSFT"); + } + + // ---------- prices ---------- + + @Test + void pricesBatchesSymbolsInOneRequest() { + CapturingClient client = okWith(PRICES_BODY); + StocksResource stocks = resourceWith(client); + + List prices = stocks.prices(StockPricesRequest.of("AAPL", "MSFT")).values(); + + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url).contains("/v1/stocks/prices/?").contains("symbols=AAPL,MSFT"); + assertThat(prices).hasSize(2); + assertThat(prices.get(0).symbol()).isEqualTo("AAPL"); + assertThat(prices.get(0).mid()).isEqualTo(221.525); + assertThat(prices.get(0).change()).isEqualTo(1.38); + assertThat(prices.get(0).updated()).isNotNull(); + } + + // ---------- news ---------- + + @Test + void newsDecodesArticlesAndScalarUpdated() { + CapturingClient client = okWith(NEWS_BODY); + StocksResource stocks = resourceWith(client); + + StockNewsResponse resp = stocks.news(StockNewsRequest.of("AAPL")); + List articles = resp.values(); + + assertThat(client.captured.get(0).uri().toString()) + .startsWith("http://localhost/v1/stocks/news/AAPL/"); + assertThat(articles).hasSize(2); + assertThat(articles.get(0).headline()).isEqualTo("Record Q4"); + assertThat(articles.get(0).content()).isEqualTo("body one"); + assertThat(articles.get(0).source()).isEqualTo("https://a/1"); + assertThat(articles.get(0).publicationDate().getZone().getId()).isEqualTo("America/New_York"); + // `updated` is a scalar at the response root. + assertThat(resp.updated()).isNotNull(); + assertThat(resp.updated().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void newsHistoricalQueryOmitsUpdated() { + // Date-bounded queries omit the scalar `updated`; it must surface as null, not a parse error. + String body = NEWS_BODY.replace(",\"updated\":1705449600", ""); + CapturingClient client = okWith(body); + StocksResource stocks = resourceWith(client); + + StockNewsResponse resp = + stocks.news( + StockNewsRequest.builder("AAPL") + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .to(LocalDate.of(2024, Month.FEBRUARY, 1)) + .build()); + + assertThat(resp.values()).hasSize(2); + assertThat(resp.updated()).isNull(); + } + + @Test + void newsNoDataEnvelopeYieldsEmptyList() { + CapturingClient client = okWith("{\"s\":\"no_data\"}"); + StocksResource stocks = resourceWith(client); + + StockNewsResponse resp = stocks.news(StockNewsRequest.of("NOPE")); + assertThat(resp.values()).isEmpty(); + assertThat(resp.updated()).isNull(); + } + + @Test + void newsRejectsColumnsProjectionOnTypedPath() { + // StockNewsArticle is non-null by contract, so a typed columns projection can't be honored + // without lying. It must fail fast and clearly, before any request is dispatched (Option B). + CapturingClient client = okWith(NEWS_BODY); + StocksResource stocks = resourceWith(client); + + assertThatThrownBy(() -> stocks.columns("headline").news(StockNewsRequest.of("AAPL"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("news") + .hasMessageContaining("asCsv"); + // Fail-fast: no request reached the wire. + assertThat(client.captured).isEmpty(); + } + + @Test + void newsColumnsRejectionIsAFailedFutureNotASyncThrow() { + // ADR-006: the async surface signals errors through the future. The guard must NOT throw at the + // call site (which would bypass .exceptionally/.handle) — newsAsync(...) returns normally and + // the returned future completes exceptionally instead. + CapturingClient client = okWith(NEWS_BODY); + StocksResource stocks = resourceWith(client); + + var future = stocks.columns("headline").newsAsync(StockNewsRequest.of("AAPL")); + + assertThat(future).isCompletedExceptionally(); + assertThatThrownBy(future::join) + .isInstanceOf(java.util.concurrent.CompletionException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + assertThat(client.captured).isEmpty(); + } + + @Test + void newsColumnsProjectionStillWorksOnCsvFacet() { + // The CSV facet returns raw text — no typed contract to break — so columns stays supported + // there. + CapturingClient client = okWith("a,b\n1,2"); + StocksCsvResource csv = resourceWith(client).asCsv(); + + assertThat(csv.columns("headline").news(StockNewsRequest.of("AAPL")).csv()).contains("a,b"); + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url).contains("columns=headline"); + } + + // ---------- earnings ---------- + + @Test + void earningsDecodesRowWithNullableFundamentals() { + CapturingClient client = okWith(EARNINGS_BODY); + StocksResource stocks = resourceWith(client); + + StockEarning e = stocks.earnings(StockEarningsRequest.of("AAPL")).values().get(0); + + assertThat(client.captured.get(0).uri().toString()) + .startsWith("http://localhost/v1/stocks/earnings/AAPL/"); + assertThat(e.symbol()).isEqualTo("AAPL"); + assertThat(e.fiscalYear()).isEqualTo(2024); + assertThat(e.fiscalQuarter()).isEqualTo(3); + assertThat(e.reportTime()).isEqualTo("after close"); + assertThat(e.currency()).isEqualTo("USD"); + assertThat(e.reportedEPS()).isEqualTo(2.18); + assertThat(e.surpriseEPSpct()).isEqualTo(3.81); + assertThat(e.date()).isNotNull(); + assertThat(e.reportDate()).isNotNull(); + assertThat(e.updated()).isNotNull(); + } + + @Test + void earningsToleratesNullFutureQuarterFields() { + // Synthesized forward-quarter row: fundamentals/report fields come back null. (Reproduces a + // live-API ParseError that strict accessors would have thrown.) + String body = + "{\"s\":\"ok\",\"symbol\":[\"AAPL\"]," + + "\"fiscalYear\":[null],\"fiscalQuarter\":[null]," + + "\"date\":[1706659200],\"reportDate\":[null]," + + "\"reportTime\":[null],\"currency\":[\"USD\"]," + + "\"reportedEPS\":[null],\"estimatedEPS\":[2.1]," + + "\"surpriseEPS\":[null],\"surpriseEPSpct\":[null]," + + "\"updated\":[1706832000]}"; + CapturingClient client = okWith(body); + StocksResource stocks = resourceWith(client); + + StockEarning e = stocks.earnings(StockEarningsRequest.of("AAPL")).values().get(0); + assertThat(e.fiscalYear()).isNull(); + assertThat(e.fiscalQuarter()).isNull(); + assertThat(e.reportDate()).isNull(); + assertThat(e.reportTime()).isNull(); + assertThat(e.reportedEPS()).isNull(); + assertThat(e.estimatedEPS()).isEqualTo(2.1); + } + + @Test + void earningsAttachesWindowAndReportParams() { + CapturingClient client = okWith(EARNINGS_BODY); + StocksResource stocks = resourceWith(client); + + stocks + .earningsAsync( + StockEarningsRequest.builder("AAPL") + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .countback(4) + .report("2024-Q3") + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url).contains("to=2025-01-01").contains("countback=4").contains("report=2024-Q3"); + } + + // ---------- columns projection + Option A ---------- + + @Test + void columnsProjectionDecodesRequestedAndNullsTheRest() { + CapturingClient client = okWith("{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}"); + StocksResource stocks = resourceWith(client); + + StockQuote q = + stocks.columns("symbol", "mid").quote(StockQuoteRequest.of("AAPL")).values().get(0); + + assertThat(q.symbol()).isEqualTo("AAPL"); + assertThat(q.mid()).isEqualTo(221.525); + assertThat(q.bid()).isNull(); + assertThat(q.volume()).isNull(); + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url).contains("columns=symbol,mid"); + } + + @Test + void columnsRequestedButOmittedByApiThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}"); + StocksResource stocks = resourceWith(client); + + assertThatThrownBy( + () -> stocks.columns("symbol", "mid", "bid").quote(StockQuoteRequest.of("AAPL"))) + .isInstanceOf(ParseError.class) + .hasMessageContaining("bid"); + } + + @Test + void noColumnsFilterStillRequiresAllStructuralColumns() { + CapturingClient client = okWith("{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}"); + StocksResource stocks = resourceWith(client); + + assertThatThrownBy(() -> stocks.quote(StockQuoteRequest.of("AAPL"))) + .isInstanceOf(ParseError.class); + } + + @Test + void universalParamsReachTheWire() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + + stocks + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .quote(StockQuoteRequest.of("AAPL")); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("limit=50") + .contains("offset=10"); + } + + // ---------- CSV / HTML facets ---------- + + @Test + void asCsvSendsFormatCsvAndReturnsRawText() { + CapturingClient client = okWith("t,o,h,l,c,v\n1705276800,216.5,218.55,215.78,217.83,62130000"); + StocksResource stocks = resourceWith(client); + + CsvResponse csv = stocks.asCsv().candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")); + + assertThat(csv.csv()).contains("t,o,h,l,c,v"); + assertThat(csv.values()).isEqualTo(csv.csv()); + assertThat(csv.isCsv()).isTrue(); + assertThat(client.captured.get(0).uri().toString()).contains("format=csv"); + } + + @Test + void csvFacetUniversalAndShapingParamsReachTheWire() { + CapturingClient client = okWith("a,b\n1,2"); + StocksResource stocks = resourceWith(client); + + stocks + .asCsv() + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .columns("symbol", "mid") + .human(true) + .headers(true) + .quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url) + .contains("format=csv") + .contains("dateformat=timestamp") + .contains("mode=delayed") + .contains("columns=symbol,mid") + .contains("human=true") + .contains("headers=true"); + } + + @Test + void csvFacetCoversEveryEndpoint() { + CapturingClient client = okWith("a,b\n1,2"); + StocksCsvResource csv = resourceWith(client).asCsv(); + + assertThat(csv.candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")).csv()) + .contains("a,b"); + assertThat(csv.quote(StockQuoteRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()).csv()) + .contains("a,b"); + assertThat(csv.prices(StockPricesRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.news(StockNewsRequest.of("AAPL")).csv()).contains("a,b"); + assertThat(csv.earnings(StockEarningsRequest.of("AAPL")).csv()).contains("a,b"); + } + + @Test + void htmlFacetCoversEveryEndpoint() { + CapturingClient client = okWith("x"); + StocksHtmlResource html = resourceWith(client).asHtml(); + + assertThat(html.candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")).html()) + .contains(""); + assertThat(html.quote(StockQuoteRequest.of("AAPL")).html()).contains(""); + assertThat(html.prices(StockPricesRequest.of("AAPL")).html()).contains(""); + assertThat(html.news(StockNewsRequest.of("AAPL")).html()).contains(""); + assertThat(html.earnings(StockEarningsRequest.of("AAPL")).html()).contains(""); + assertThat(client.captured.get(0).uri().toString()).contains("format=html"); + } + + // ---------- response metadata (§13.5 / §16) ---------- + + @Test + void responseToStringRedactsQueryString() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + + StockQuotesResponse resp = stocks.quote(StockQuoteRequest.builder("AAPL").candle(true).build()); + + assertThat(resp.toString()).contains("?…").doesNotContain("candle"); + } + + @Test + void responseSaveToFileWritesRawBodyVerbatim( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws java.io.IOException { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + StockCandlesResponse resp = + stocks.candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")); + java.nio.file.Path out = dir.resolve("candles.json"); + resp.saveToFile(out); + + assertThat(java.nio.file.Files.readString(out)).isEqualTo(CANDLES_BODY).isEqualTo(resp.json()); + } + + // ---------- §12 candle auto-chunking ---------- + + @Test + void candlesIntradayLongRangeSplitsIntoYearChunksAndMerges() { + // ~3-year intraday range → split into 4 contiguous ≤365-day requests, dispatched concurrently + // and merged into one response (each canned chunk returns 2 rows → 8 merged). + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + StockCandlesResponse resp = + stocks.candles( + StockCandlesRequest.builder(StockResolution.hours(1), "AAPL") + .from(LocalDate.of(2020, Month.JANUARY, 1)) + .to(LocalDate.of(2023, Month.JANUARY, 1)) + .build()); + + assertThat(client.captured).hasSize(4); + assertThat(resp.values()).hasSize(8); + // first slice starts at the request's `from`; last slice ends at the request's `to`. + assertThat(client.captured.get(0).uri().toString()).contains("from=2020-01-01"); + assertThat(client.captured.get(3).uri().toString()).contains("to=2023-01-01"); + // slices are contiguous and non-overlapping: chunk 1's `to` is chunk 2's `from`. + assertThat(client.captured.get(0).uri().toString()).contains("to=2020-12-31"); + assertThat(client.captured.get(1).uri().toString()).contains("from=2020-12-31"); + } + + @Test + void candlesDailyLongRangeDoesNotSplit() { + // Non-intraday resolutions are never chunked, regardless of span. + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + stocks.candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.of(2016, Month.JANUARY, 1)) + .to(LocalDate.of(2024, Month.JANUARY, 1)) + .build()); + + assertThat(client.captured).hasSize(1); + } + + @Test + void candlesIntradayShortRangeIsASingleRequest() { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + stocks.candles( + StockCandlesRequest.builder(StockResolution.minutes(15), "AAPL") + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .to(LocalDate.of(2024, Month.MARCH, 1)) + .build()); + + assertThat(client.captured).hasSize(1); + } + + @Test + void candlesIntradayWithoutFromIsNotChunked() { + CapturingClient client = okWith(CANDLES_BODY); + StocksResource stocks = resourceWith(client); + + stocks.candles(StockCandlesRequest.of(StockResolution.hours(1), "AAPL")); + + assertThat(client.captured).hasSize(1); + assertThat(client.captured.get(0).uri().toString()).doesNotContain("from="); + } + + @Test + void csvFacetCandlesLongRangeMergesChunksAndDedupesHeader() { + CapturingClient client = okWith("t,o,h,l,c,v\n1705276800,216.5,218.55,215.78,217.83,62130000"); + StocksResource stocks = resourceWith(client); + + CsvResponse csv = + stocks + .asCsv() + .candles( + StockCandlesRequest.builder(StockResolution.hours(1), "AAPL") + .from(LocalDate.of(2020, Month.JANUARY, 1)) + .to(LocalDate.of(2023, Month.JANUARY, 1)) + .build()); + + assertThat(client.captured).hasSize(4); + long headerRows = csv.csv().lines().filter(l -> l.startsWith("t,o,h")).count(); + long dataRows = csv.csv().lines().filter(l -> l.startsWith("1705276800")).count(); + assertThat(headerRows).as("header deduped to one").isEqualTo(1); + assertThat(dataRows).as("one data row per slice").isEqualTo(4); + } + + @Test + void mergeCsvBodiesDropsRepeatedHeaderWhenEnabled() { + List bodies = List.of("h1,h2\n1,2", "h1,h2\n3,4", "h1,h2\n5,6"); + assertThat(StocksCsvResource.mergeCsvBodies(bodies, true)).isEqualTo("h1,h2\n1,2\n3,4\n5,6"); + // headers off → straight concatenation (no line is treated as a header). + assertThat(StocksCsvResource.mergeCsvBodies(bodies, false)) + .isEqualTo("h1,h2\n1,2\nh1,h2\n3,4\nh1,h2\n5,6"); + } + + @Test + void resolutionIsIntradayClassifier() { + assertThat(StockResolution.minutes(15).isIntraday()).isTrue(); + assertThat(StockResolution.hours(1).isIntraday()).isTrue(); + assertThat(StockResolution.of("H").isIntraday()).isTrue(); + assertThat(StockResolution.DAILY.isIntraday()).isFalse(); + assertThat(StockResolution.WEEKLY.isIntraday()).isFalse(); + assertThat(StockResolution.of("1D").isIntraday()).isFalse(); + assertThat(StockResolution.of("daily").isIntraday()).isFalse(); + } + + // ---------- §8.2 per-response rate-limit snapshot ---------- + + @Test + void responseExposesPerResponseRateLimitSnapshot() { + HttpHeaders rl = + TestHttpClients.headersOf( + Map.of( + "x-api-ratelimit-limit", "100", + "x-api-ratelimit-remaining", "95", + "x-api-ratelimit-reset", "1705500000", + "x-api-ratelimit-consumed", "5")); + CapturingClient client = + new CapturingClient(200, QUOTE_BODY.getBytes(StandardCharsets.UTF_8), rl); + StocksResource stocks = resourceWith(client); + + RateLimitSnapshot snap = stocks.quote(StockQuoteRequest.of("AAPL")).rateLimit(); + assertThat(snap).isNotNull(); + assertThat(snap.limit()).isEqualTo(100); + assertThat(snap.remaining()).isEqualTo(95); + assertThat(snap.consumed()).isEqualTo(5); + assertThat(snap.reset()).isNotNull(); + } + + @Test + void responseRateLimitIsNullWhenHeadersAbsent() { + CapturingClient client = okWith(QUOTE_BODY); + StocksResource stocks = resourceWith(client); + assertThat(stocks.quote(StockQuoteRequest.of("AAPL")).rateLimit()).isNull(); + } + + // ---------- request / resolution validation ---------- + + @Test + void candlesRequestRejectsDateWithRange() { + assertThatThrownBy( + () -> + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + + @Test + void earningsRequestRejectsCountbackWithFrom() { + assertThatThrownBy( + () -> + StockEarningsRequest.builder("AAPL") + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .countback(3) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback and from"); + } + + @Test + void quotesRequestRequiresAtLeastOneSymbol() { + assertThatThrownBy(() -> StockQuotesRequest.builder("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty"); + } + + @Test + void resolutionFactoriesRenderWireTokens() { + assertThat(StockResolution.minutes(15).wireValue()).isEqualTo("15"); + assertThat(StockResolution.hours(4).wireValue()).isEqualTo("4H"); + assertThat(StockResolution.days(2).wireValue()).isEqualTo("2D"); + assertThat(StockResolution.WEEKLY.wireValue()).isEqualTo("W"); + assertThat(StockResolution.of("3M").wireValue()).isEqualTo("3M"); + assertThatThrownBy(() -> StockResolution.minutes(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive"); + } + + // ---------- helpers ---------- + + private static CapturingClient okWith(String body) { + return new CapturingClient(200, body.getBytes(StandardCharsets.UTF_8), EMPTY_HEADERS); + } + + 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); + } + } +}