diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4145c..1e7af26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,14 +26,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 down the call stack. ### Added +- **Markets resource** (`client.markets()`) — the single markets endpoint, + `status`, in sync + async form: the exchange open/closed calendar ("was/is + the market open on these days?"), distinct from `utilities().status()` (the + API's own service health). Takes a Builder-based `MarketStatusRequest` where + *every* parameter is optional — a bare `of()` returns today's status, US + calendar; window is `date` xor `from`/`to` xor `to`+`countback`, plus + `country` (two-digit ISO 3166; the backend serves US today and answers + `no_data` for others). Rows are `MarketStatus(date, status)` with derived + `isOpen()`/`isClosed()` predicates; a `status` cell comes back null for days + outside the backend's holiday-calendar coverage and decodes to null (the + Option A column guarantee still applies to the column itself). Universal + params (`dateFormat`/`mode`/`limit`/`offset`/`columns`) as configured + copies, CSV facet via `asCsv()` with the `human`/`headers` shaping params, + and the same nullable-fields + `columns` + Option A decoding contract as + stocks/options. - **Funds resource** (`client.funds()`) — the single funds endpoint, `candles`, in sync + async form, taking a Builder-based `FundCandlesRequest` (window: - `date` xor `from`/`to`/`countback`, plus `exchange`/`country`/`adjustsplits`/ - `adjustdividends`). Fund candles are NAV series: OHLC only (no volume column), - daily-and-up resolutions only — `FundResolution` models `DAILY`/`WEEKLY`/ - `MONTHLY`/`YEARLY` and `days/weeks/months/years(n)`, with no intraday - factories (the API rejects intraday tokens for funds) and therefore no §12 - auto-chunking. Universal params (`dateFormat`/`mode`/`limit`/`offset`/ + `date` xor `from`/`to`/`countback`). Fund candles are NAV series: OHLC only + (no volume column), daily-and-up resolutions only — `FundResolution` models + `DAILY`/`WEEKLY`/`MONTHLY`/`YEARLY` and `days/weeks/months/years(n)`, with no + intraday factories (the API rejects intraday tokens for funds) and therefore + no §12 auto-chunking. Universal params (`dateFormat`/`mode`/`limit`/`offset`/ `columns`) as configured copies, CSV facet via `asCsv()` with the `human`/`headers` shaping params, and the same nullable-fields + `columns` + Option A decoding contract as stocks/options. diff --git a/CLAUDE.md b/CLAUDE.md index acdff2f..fe9f10d 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; the `utilities`, `options`, `stocks`, and `funds` resource façades are implemented today — the markets resource is 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`, `stocks`, `markets`, and `funds` resource façades are all implemented today — the per-resource series for v1 is complete. 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,8 +83,8 @@ 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 — `client.utilities()`, `client.options()`, `client.stocks()`, and `client.funds()` are wired today; `client.markets` still to come. -- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for the remaining resource (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. +- ~~§1.2 resource groupings~~ **DONE** — `client.utilities()`, `client.options()`, `client.stocks()`, `client.markets()`, and `client.funds()` are all wired today; the per-resource series for v1 is complete. +- ~~§2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding~~ **DONE** for all resources. 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. diff --git a/Makefile b/Makefile index 31a9daa..ca4eabf 100644 --- a/Makefile +++ b/Makefile @@ -95,10 +95,14 @@ demo-options: ## Full options surface: every endpoint + all params, CSV facet, c demo-stocks: ## Full stocks surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runStocks +.PHONY: demo-markets +demo-markets: ## Full markets surface: status + all params (open/closed calendar, null cells), CSV facet, columns, Option A (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runMarkets + .PHONY: demo-funds demo-funds: ## Full funds surface: candles + all params (no volume/intraday/chunking), CSV facet, columns, Option A (needs mock-server) cd $(CONSUMER_DIR) && ./gradlew runFunds .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 runStocks runFunds + cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency runOptions runStocks runMarkets runFunds diff --git a/docs/MARKETS_REVIEW_GUIDE.md b/docs/MARKETS_REVIEW_GUIDE.md new file mode 100644 index 0000000..2c298e2 --- /dev/null +++ b/docs/MARKETS_REVIEW_GUIDE.md @@ -0,0 +1,135 @@ +# Markets Review Guide — `13_market_resource` + +This guide walks a reviewer through the `markets` resource added on the `13_market_resource` branch. It is organized by **flow**, not by file. + +This PR closes out the resource series: it **adopts the conventions the [`options`](OPTIONS_REVIEW_GUIDE.md) and [`stocks`](STOCKS_REVIEW_GUIDE.md) PRs established** — `MarketDataResponse` + named responses, the Builder-based per-endpoint request, nullable fields + `columns` + Option A, the CSV/HTML facets — and applies them to the single markets endpoint, `GET /v1/markets/status/`. **No shared-layer changes at all**: transport, retry, rate-limit parsing, `ParallelArrays`, `JsonResponseParser`, `MarketDataDates`, and `RequestConfig` are reused untouched. + +If you reviewed the stocks PR, the only genuinely new content is the markets-specific semantics (§3) and the all-optional parameter surface (§4). ~10 minutes. + +Suggested reading order: §1 (what's here) → §3 (markets-specific semantics) → §4 (request + query translation) → §5 (deserializer). `file:line` citations target `HEAD` on this branch. + +## 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. [Markets-specific semantics](#3-markets-specific-semantics) +4. [Request → query translation](#4-request--query-translation) +5. [The row deserializer: nullable + columns + Option A](#5-the-row-deserializer-nullable--columns--option-a) +6. [Universal parameters + the CSV/HTML facets](#6-universal-parameters--the-csvhtml-facets) +- [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 markets demo against the mock server (all params, null status cells, CSV facet, +# columns projection, Option A). Needs the mock server up: +make publish && make mock-server # (in one terminal) +make demo-markets # (in another) — or: ./gradlew -p examples/consumer-test runMarkets +``` + +`MarketsIntegrationTest` (shape assertions over a one-week window) runs against `api.marketdata.app`. `MarketsApp` (`examples/consumer-test`) scripts the mock server's responses to demonstrate every scenario — it was run green end-to-end (`make demo-markets`). + +--- + +## 1. What this PR adds + +### 1.1 Public API surface (new) + +``` +com.marketdata.sdk.MarketsResource (returned from client.markets()) +com.marketdata.sdk.MarketsCsvResource (returned from .asCsv()) +com.marketdata.sdk.MarketStatusResponse (named response) + +com.marketdata.sdk.markets.MarketStatusRequest (Builder-based request — every param optional) +com.marketdata.sdk.markets.MarketStatus (row record: date/status + isOpen()/isClosed()) +``` + +Same packaging rules as options/stocks (ADR-007): façades `public final` with package-private constructors in the **root** package; request/row records in the public `com.marketdata.sdk.markets` subpackage (`@NullMarked` via `package-info.java`); `MarketsHtmlResource` built but package-private (`asHtml()` stays hidden until the backend serves HTML). ADR-007 named `MarketsResource` as its canonical example — this PR makes that example real. + +### 1.2 Files to review, by role + +| Area | Files | What to check | +|---|---|---| +| Resource façade | `MarketsResource.java` | universal-param config, `statusSpec`, the row deserializer + Option A, `asCsv()`/`asHtml()` | +| CSV/HTML facets | `MarketsCsvResource.java`, `MarketsHtmlResource.java` | reuse of the static `statusSpec`, `format=csv/html` | +| Response | `MarketStatusResponse.java` | thin subclass of `AbstractMarketDataResponse>` | +| Requests | `markets/MarketStatusRequest.java`, `MarketRequests.java` | Builder validation, window rules, the no-required-args `of()` | +| Row record | `markets/MarketStatus.java` | `@Nullable` fields; `isOpen()`/`isClosed()` predicates | +| Wiring | `MarketDataClient.java` | `client.markets()` | +| Demos | `examples/.../MarketsApp.java`, `QuickstartApp.java` | mock-server walk-through; quickstart section enabled | + +--- + +## 2. The response model (reused) + +No new model concepts. `MarketStatusResponse` is a thin subclass of `AbstractMarketDataResponse`; `values()` is `List` — one row per calendar day. Per-response `rateLimit()` (§8.2) and the full `MarketDataResponse` surface come from the base for free. + +--- + +## 3. Markets-specific semantics + +The load-bearing review points — each is a *contract* fact, verified against the backend (`api/marketDataApi/markets/` + `common/util/markets_helper.py`) and the Python SDK: + +1. **This is the exchange calendar, not API health.** `markets.status` answers "was/is the market open on these days?" from the `MarketHoliday` table; `utilities().status()` reports the API's *own* per-service health from the unversioned `/status/` route. The class javadocs call the distinction out on both `MarketsResource` and `MarketDataClient.markets()`. (No interaction with the §9.5 `StatusCache` either — that cache keys on the unversioned `/status/` path, which `/v1/markets/status/` is not.) +2. **Every parameter is optional.** A bare `MarketStatusRequest.of()` returns today's status for the US calendar — this is the only request type in the SDK with a no-args `of()`. Window shapes: `date` (single day) XOR `from`/`to` (inclusive range) XOR `to`+`countback`. +3. **`status` cells can be null.** The backend's holiday data is bounded; days outside its coverage come back as a **null cell in a present column** — so Option A is satisfied and the row decodes with `status() == null` (`isOpen()`/`isClosed()` both false). A null status means "the calendar has no answer", never a decode failure. Test: `statusNullCellsOutsideCalendarCoverageDecodeToNull`. +4. **`country` is pass-through.** Two-digit ISO 3166; the backend currently serves US only and answers `no_data` (404 + `{"s":"no_data"}`, which the SDK surfaces as a successful empty response) for anything else — we don't second-guess that server-side rule client-side. +5. **`isOpen()`/`isClosed()` are derived predicates** on the row record (`"open".equals(status)` / `"closed".equals(status)`), not stored fields — the wire value stays exposed verbatim via `status()`. + +**Python-SDK parity:** `sdk-py` exposes `country/date/from_date/to_date/countback` — this PR exposes exactly the same five, same window validation (`from > to` rejected; here additionally `date` XOR range and `countback`-pairs-with-`to`, the same client-side rules stocks/funds apply). + +--- + +## 4. Request → query translation + +One spec builder, `MarketsResource.statusSpec` (package-private static, reused by both facets): + +| Endpoint | Path | Params | +|---|---|---| +| `statusSpec` | `markets/status` | `country`, `date`/`from`/`to`/`countback` | + +What to verify: +- No path parameters — everything is a query param; dates ISO-formatted (`2025-01-17`). +- Window rules in `MarketRequests.validateWindow` (same as stocks/funds): `date` mutually exclusive with `from`/`to`/`countback`; `countback` positive, pairs with `to` not `from` (the backend silently *ignores* countback when `from` is present — we reject the combination instead of silently dropping one side). +- The bare request produces `/v1/markets/status/` with no query string. + +--- + +## 5. The row deserializer: nullable + columns + Option A + +Identical mechanics to stocks/funds (see [Stocks guide §5](STOCKS_REVIEW_GUIDE.md#5-the-row-deserializers-nullable--columns--option-a)): the `rowsDeserializer`/`validateRequestedColumns` pair is **copied per resource** (the agreed pre-v1 dedup refactor folds these together with the universal-param setters). + +- `STATUS_FIELDS = [date, status]` — both **required** (either may be projected away via `columns`). Note the asymmetry with §3.3: a missing `status` *column* is an Option A anomaly; a null `status` *cell* is data. +- `date` decodes through the tolerant `MarketDataDates.parseDateOrTimestampField` — unix seconds by default, date-only strings (`"2025-01-17"`) under `dateformat=timestamp`, lifted to market-zone midnight (`America/New_York`). +- Envelope handling via `ParallelArrays.zip`: `"s":"error"` → `ParseError` carrying `errmsg`; `"s":"no_data"` → empty `values()`. + +The wire module registers under the name `marketdata-markets` in `MarketsResource`'s client-facing constructor — same once-per-client pattern as the other resources. + +--- + +## 6. Universal parameters + the CSV/HTML facets + +Same shape as stocks/options/funds: `dateFormat`/`mode`/`limit`/`offset`/`columns` return configured copies of `MarketsResource` ("configure once, call many"; the config carries into `asCsv()`); the CSV facet adds the output-shaping `human`/`headers`. The known copy-paste of these setters across resources is tracked tech debt for the pre-v1 self-typed-base refactor — do not review it as accidental duplication. + +--- + +## Reviewer checklist + +- [ ] `client.markets().status(...)` hits `GET /v1/markets/status/` with every param translated (unit: `statusAttachesAllParams`, `statusAttachesDateAndCountbackWindows`) and a bare `of()` produces no query params (`statusHitsVersionedEndpointWithNoRequiredParams`) +- [ ] `date` + `status` are both required columns under Option A; a null `status` **cell** decodes to null (`statusNullCellsOutsideCalendarCoverageDecodeToNull`) +- [ ] `isOpen()`/`isClosed()` are derived from the verbatim wire value, both false on a null cell +- [ ] Sync + async parity (`status` / `statusAsync`) via `transport.joinSync` (ADR-006) +- [ ] CSV facet sends `format=csv` + shaping params; HTML facet stays package-private +- [ ] `no_data` → empty list; error envelope → `ParseError` with `errmsg` +- [ ] Per-response `rateLimit()` populated from the four `x-api-ratelimit-*` headers +- [ ] `MarketDataClient` wires `markets()` like the other resources (constructed before `StatusCache`, inside the partial-construction guard); javadoc disambiguates vs `utilities().status()` +- [ ] Demos: `make demo-markets` green; `QuickstartApp` markets section enabled +- [ ] Integration: `MarketsIntegrationTest` (one-week window: both statuses present; countback window) passes with a token diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index e699e4c..1efc131 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -40,6 +40,8 @@ val demoApps = mapOf( "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."), + "runMarkets" to ("com.marketdata.consumer.MarketsApp" to + "Full markets surface: status + all params (open/closed calendar, null cells), CSV facet, columns projection, Option A. Needs mock server."), "runFunds" to ("com.marketdata.consumer.FundsApp" to "Full funds surface: candles + all params (no volume / no intraday / no chunking), CSV facet, columns projection, Option A. Needs mock server.") ) diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java new file mode 100644 index 0000000..99f5d75 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java @@ -0,0 +1,264 @@ +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.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +import java.time.LocalDate; +import java.util.List; + +/** + * Exhaustive {@code markets} resource demo against the mock server. Covers: + * + *
    + *
  • the single endpoint ({@code status}) with its full parameter surface — universal params + * (dateFormat/mode/limit/offset) + the three window shapes ({@code from}/{@code to}, {@code + * date}, {@code to}+{@code countback}) and {@code country}, including the bare no-args + * request (today's status); + *
  • the null-status-cell case: days outside the backend's holiday-calendar coverage come back + * with a {@code null} status cell — decoded to {@code null}, not an error; + *
  • 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}; + *
  • §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 runMarkets} (needs the mock server up). + */ +public final class MarketsApp { + + private MarketsApp() {} + + // Fri open, Sat/Sun closed — the market open/closed calendar, NOT the API health endpoint. + private static final String STATUS = + "{\"s\":\"ok\"," + + "\"date\":[1705035600,1705122000,1705208400]," + + "\"status\":[\"open\",\"closed\",\"closed\"]}"; + + public static void main(String[] args) { + MockServerControl mock = new MockServerControl(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { + statusWithParams(mock, client); + nullStatusOutsideCalendarCoverage(mock, client); + perResponseRateLimit(mock, client); + csvFacet(mock, client); + columnsProjectionDoesNotFail(mock, client); + optionARequestedColumnMissingFails(mock, client); + strictByDefaultMissingColumnFails(mock, client); + } + } + + // ---------- the status endpoint, all parameter shapes ---------- + + private static void statusWithParams(MockServerControl mock, MarketDataClient client) { + Console.header("markets.status — the parameter surface"); + + // Bare request: every parameter is optional — today's status, US calendar. + Console.step("status(MarketStatusRequest.of()) — no params: today's status"); + mock.reset(); + mock.script(Step.of(200, STATUS)); + var today = client.markets().status(MarketStatusRequest.of()); + Console.ok("status.values() → " + today.values().size() + " day(s); iterating:"); + for (MarketStatus day : today.values()) { + Console.info( + " " + day.date().toLocalDate() + " status=" + day.status() + " isOpen=" + + day.isOpen()); + } + + // from/to range + country + universal params, set fluently. + Console.step("status(from/to + country) + universal params (dateFormat/mode/limit)"); + mock.reset(); + mock.script(Step.of(200, STATUS)); + var range = + client + .markets() + .dateFormat(DateFormat.UNIX) // universal param (type-preserving) + .mode(Mode.DELAYED) // universal param + .limit(500) // universal param + .status( + MarketStatusRequest.builder() + .country("US") + .from(LocalDate.of(2024, 1, 12)) + .to(LocalDate.of(2024, 1, 14)) + .build()); + List days = range.values(); // List + long open = days.stream().filter(MarketStatus::isOpen).count(); + Console.ok("status.values() → " + days.size() + " days, " + open + " open"); + + // Single-day lookup: date is mutually exclusive with from/to/countback. + Console.step("status(date=...) — was the market open on a specific day?"); + mock.reset(); + mock.script(Step.of(200, "{\"s\":\"ok\",\"date\":[1705035600],\"status\":[\"open\"]}")); + var single = + client + .markets() + .status(MarketStatusRequest.builder().date(LocalDate.of(2024, 1, 12)).build()); + Console.ok("→ " + (single.values().get(0).isOpen() ? "open" : "closed")); + + // to + countback: "the last N days ending at `to`" — no left edge needed. + Console.step("status(to=..., countback=30) — the last 30 days"); + mock.reset(); + mock.script(Step.of(200, STATUS)); + var counted = + client + .markets() + .status( + MarketStatusRequest.builder() + .to(LocalDate.of(2024, 1, 14)) + .countback(30) + .build()); + Console.ok("status.values() → " + counted.values().size() + " days"); + } + + // ---------- markets-specific: null status cells outside calendar coverage ---------- + + private static void nullStatusOutsideCalendarCoverage( + MockServerControl mock, MarketDataClient client) { + Console.header("Null status cells — days outside the holiday-calendar coverage"); + mock.reset(); + // The backend's holiday data is bounded; days beyond it get a null status CELL (the column is + // present, so Option A is satisfied — this is data, not an anomaly). + mock.script( + Step.of(200, "{\"s\":\"ok\",\"date\":[1705035600,4102462800],\"status\":[\"open\",null]}")); + Console.step("status(...) — second day beyond the calendar bounds"); + var resp = client.markets().status(MarketStatusRequest.of()); + MarketStatus known = resp.values().get(0); + MarketStatus unknown = resp.values().get(1); + Console.ok("inside coverage → status=" + known.status() + " isOpen=" + known.isOpen()); + Console.ok( + "outside coverage → status=" + + unknown.status() + + " (null cell decoded cleanly; isOpen=" + + unknown.isOpen() + + ", isClosed=" + + unknown.isClosed() + + ")"); + Console.info("A null status means \"the calendar has no answer\", never a decode failure."); + } + + // ---------- §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, STATUS) + .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("status(...).rateLimit() — parsed from THIS response's headers"); + var resp = client.markets().status(MarketStatusRequest.of()); + 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)."); + } + + // ---------- CSV facet ---------- + + private static void csvFacet(MockServerControl mock, MarketDataClient client) { + Console.header("CSV facet — client.markets().asCsv()"); + + Console.step("asCsv().status(...) — plain CSV"); + mock.reset(); + mock.script(Step.of(200, "date,status\n1705035600,open\n1705122000,closed")); + var csv = client.markets().asCsv().status(MarketStatusRequest.of()); + 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, "Date,Status\n2024-01-12,open\n2024-01-13,closed")); + var shaped = + client + .markets() + .asCsv() + .columns("date", "status") + .human(true) + .headers(true) + .status(MarketStatusRequest.of()); + 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\",\"status\":[\"open\"]}")); + + MarketStatus day = + client.markets().columns("status").status(MarketStatusRequest.of()).values().get(0); + Console.ok("requested → status=" + day.status()); + Console.ok("NOT requested (null, decoded cleanly) → date=" + day.date()); + } + + // ---------- 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 date, but the body omits it → anomaly, not a projection. + mock.script(Step.of(200, "{\"s\":\"ok\",\"status\":[\"open\"]}")); + + try { + client.markets().columns("date", "status").status(MarketStatusRequest.of()); + Console.fail("expected a ParseError — 'date' 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\",\"date\":[1705035600]}")); + + try { + // No .columns(...) → every required column is implicitly requested, so the missing `status` + // fails. + client.markets().status(MarketStatusRequest.of()); + Console.fail( + "expected a ParseError — required columns are missing and none were projected away"); + } catch (ParseError e) { + Console.ok( + "ParseError as expected (nullable fields did NOT weaken the strict default): " + + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java index 5f0f3e5..4ece153 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 @@ -6,6 +6,8 @@ import com.marketdata.sdk.exception.MarketDataException; import com.marketdata.sdk.funds.FundCandlesRequest; import com.marketdata.sdk.funds.FundResolution; +import com.marketdata.sdk.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; import com.marketdata.sdk.options.ExpirationFilter; import com.marketdata.sdk.options.ExpirationStrikes; import com.marketdata.sdk.options.OptionQuote; @@ -82,8 +84,8 @@ public static void main(String[] args) { utilitiesExamples(client); optionsExamples(client); stocksExamples(client); + marketsExamples(client); fundsExamples(client); - // marketsExamples(client); // ← add when client.markets() lands } } @@ -396,6 +398,40 @@ private static void stocksExamples(MarketDataClient client) { } } + // ---------- markets ---------- + + /** + * Markets expose a single endpoint: {@code status} — the exchange open/closed calendar (was/is + * the market open on these days?). Distinct from {@code utilities().status()}, which reports the + * API's own per-service health. Entry point is {@code client.markets()}; every parameter is + * optional (a bare request returns today's status, US calendar). + */ + private static void marketsExamples(MarketDataClient client) { + Console.header("markets — status (the exchange open/closed calendar)"); + + Console.step("client.markets().status(...) — open/closed for the last week"); + try { + var r = + client + .markets() + .status( + MarketStatusRequest.builder() + .from(LocalDate.now().minusDays(7)) + .to(LocalDate.now()) + .build()); + long open = r.values().stream().filter(MarketStatus::isOpen).count(); + Console.ok(r.values().size() + " days fetched; " + open + " open"); + if (!r.values().isEmpty()) { + MarketStatus today = r.values().get(r.values().size() - 1); + Console.ok("latest: " + today.date().toLocalDate() + " → " + today.status()); + } + } catch (AuthenticationError e) { + Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the markets endpoint."); + } catch (MarketDataException e) { + Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); + } + } + // ---------- funds ---------- /** diff --git a/src/integrationTest/java/com/marketdata/sdk/MarketsIntegrationTest.java b/src/integrationTest/java/com/marketdata/sdk/MarketsIntegrationTest.java new file mode 100644 index 0000000..5ec12d4 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/MarketsIntegrationTest.java @@ -0,0 +1,76 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +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 markets} 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. + * Status is asserted as {@code 200 || 203} (203 = cached/delayed data, which the SDK surfaces as + * success). + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MarketsIntegrationTest { + + private MarketDataClient client; + + @BeforeAll + void setUp() { + client = new MarketDataClient(); + } + + @AfterAll + void tearDown() { + if (client != null) { + client.close(); + } + } + + @Test + void statusReturnsOneRowPerDayInRange() { + MarketStatusResponse resp = + client + .markets() + .status( + MarketStatusRequest.builder() + .from(LocalDate.now().minusDays(7)) + .to(LocalDate.now()) + .build()); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).as("an 8-day window always contains rows").isNotEmpty(); + // Any 8-day window contains a weekend, so both statuses must show up. + assertThat(resp.values().stream().anyMatch(MarketStatus::isOpen)).isTrue(); + assertThat(resp.values().stream().anyMatch(MarketStatus::isClosed)).isTrue(); + MarketStatus first = resp.values().get(0); + assertThat(first.date()).isNotNull(); + assertThat(first.date().getZone().getId()).isEqualTo("America/New_York"); + } + + @Test + void statusCountbackWindowDecodes() { + MarketStatusResponse resp = + client + .markets() + .status(MarketStatusRequest.builder().to(LocalDate.now()).countback(5).build()); + + assertThat(resp.statusCode()).isIn(200, 203); + assertThat(resp.values()).isNotEmpty(); + for (MarketStatus day : resp.values()) { + // Every row inside the calendar's coverage carries open/closed; none should be blank. + if (day.status() != null) { + assertThat(day.isOpen() || day.isClosed()).isTrue(); + } + } + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index 964780f..3249416 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -23,6 +23,7 @@ public final class MarketDataClient implements AutoCloseable { private final UtilitiesResource utilities; private final OptionsResource options; private final StocksResource stocks; + private final MarketsResource markets; private final FundsResource funds; public MarketDataClient() { @@ -111,6 +112,7 @@ public MarketDataClient( this.utilities = new UtilitiesResource(transport, parser); this.options = new OptionsResource(transport, parser); this.stocks = new StocksResource(transport, parser); + this.markets = new MarketsResource(transport, parser); this.funds = new FundsResource(transport, parser); cacheRef.set( new StatusCache( @@ -171,6 +173,14 @@ public FundsResource funds() { return funds; } + /** + * Markets endpoints: {@code status} (the exchange open/closed calendar — distinct from {@code + * utilities().status()}, the API's own service health). + */ + public MarketsResource markets() { + return markets; + } + /** * 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/MarketStatusResponse.java b/src/main/java/com/marketdata/sdk/MarketStatusResponse.java new file mode 100644 index 0000000..862f6d8 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketStatusResponse.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.markets.MarketStatus; +import java.util.List; + +/** + * Response for {@code markets.status}: {@link #values()} is one row per calendar day. Construct + * only through the resource façade. + */ +public final class MarketStatusResponse extends AbstractMarketDataResponse> { + + MarketStatusResponse(List values, HttpResponseEnvelope envelope, Format format) { + super(values, envelope, format); + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketsCsvResource.java b/src/main/java/com/marketdata/sdk/MarketsCsvResource.java new file mode 100644 index 0000000..82b9920 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketsCsvResource.java @@ -0,0 +1,77 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.markets.MarketStatusRequest; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +/** + * CSV facet of {@code markets} — reached through {@code client.markets().asCsv()}. Every endpoint + * here returns a {@link CsvResponse} (opaque CSV text). + * + *

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 MarketsCsvResource { + + private final HttpTransport transport; + private final RequestConfig config; + + MarketsCsvResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + // ---------- universal + output-shaping params ---------- + + public MarketsCsvResource dateFormat(DateFormat v) { + return new MarketsCsvResource(transport, config.withDateFormat(v)); + } + + public MarketsCsvResource mode(Mode v) { + return new MarketsCsvResource(transport, config.withMode(v)); + } + + public MarketsCsvResource limit(int v) { + return new MarketsCsvResource(transport, config.withLimit(v)); + } + + public MarketsCsvResource offset(int v) { + return new MarketsCsvResource(transport, config.withOffset(v)); + } + + public MarketsCsvResource columns(String... v) { + return new MarketsCsvResource(transport, config.withColumns(java.util.List.of(v))); + } + + public MarketsCsvResource human(boolean v) { + return new MarketsCsvResource(transport, config.withHuman(v)); + } + + public MarketsCsvResource headers(boolean v) { + return new MarketsCsvResource(transport, config.withHeaders(v)); + } + + // ---------- endpoints ---------- + + public CompletableFuture statusAsync(MarketStatusRequest request) { + return executeCsv(MarketsResource.statusSpec(request)); + } + + public CsvResponse status(MarketStatusRequest request) { + return transport.joinSync(statusAsync(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/MarketsHtmlResource.java b/src/main/java/com/marketdata/sdk/MarketsHtmlResource.java new file mode 100644 index 0000000..2d8f65f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketsHtmlResource.java @@ -0,0 +1,43 @@ +package com.marketdata.sdk; + +import com.marketdata.sdk.markets.MarketStatusRequest; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +/** + * HTML facet of {@code markets}. Mirrors {@link MarketsCsvResource} 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 + * MarketsResource} is package-private. Kept built and ready so enabling HTML later is a one-line + * change. + */ +public final class MarketsHtmlResource { + + private final HttpTransport transport; + private final RequestConfig config; + + MarketsHtmlResource(HttpTransport transport, RequestConfig config) { + this.transport = transport; + this.config = config; + } + + public CompletableFuture statusAsync(MarketStatusRequest request) { + return executeHtml(MarketsResource.statusSpec(request)); + } + + public HtmlResponse status(MarketStatusRequest request) { + return transport.joinSync(statusAsync(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/MarketsResource.java b/src/main/java/com/marketdata/sdk/MarketsResource.java new file mode 100644 index 0000000..837a8b4 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/MarketsResource.java @@ -0,0 +1,239 @@ +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.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +import com.marketdata.sdk.markets.MarketStatuses; +import java.io.IOException; +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; + +/** + * Markets endpoints ({@code /v1/markets/...}). Reached through {@code client.markets()}. + * + *

The single endpoint, {@code status}, answers "was/is the market open on these days?" from the + * exchange holiday calendar — distinct from {@code client.utilities().status()}, which reports the + * API's own per-service health from the unversioned {@code /status/} route. + * + *

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 MarketsResource { + + 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. */ + MarketsResource(HttpTransport transport, JsonResponseParser parser) { + this(transport, parser, RequestConfig.empty()); + parser.registerModule(wireFormatModule()); + } + + private MarketsResource( + 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 MarketsResource dateFormat(DateFormat dateFormat) { + return new MarketsResource(transport, parser, config.withDateFormat(dateFormat)); + } + + /** Returns a copy with the data-freshness {@code mode}. */ + public MarketsResource mode(Mode mode) { + return new MarketsResource(transport, parser, config.withMode(mode)); + } + + /** Returns a copy with the pagination {@code limit}. */ + public MarketsResource limit(int limit) { + return new MarketsResource(transport, parser, config.withLimit(limit)); + } + + /** Returns a copy with the pagination {@code offset}. */ + public MarketsResource offset(int offset) { + return new MarketsResource(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 MarketsResource columns(String... columns) { + return new MarketsResource(transport, parser, config.withColumns(List.of(columns))); + } + + // ---------- format facet ---------- + + /** A CSV-flavored view of this resource (carrying the same universal-param config). */ + public MarketsCsvResource asCsv() { + return new MarketsCsvResource(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}. + */ + MarketsHtmlResource asHtml() { + return new MarketsHtmlResource(transport, config); + } + + // ---------- endpoints (typed) ---------- + + /** Async: fetch the open/closed status of one day or a range of days. */ + public java.util.concurrent.CompletableFuture statusAsync( + MarketStatusRequest request) { + RequestSpec.Builder b = statusSpec(request); + config.applyTo(b); + return execute( + b.build(), + MarketStatuses.class, + (d, env, fmt) -> new MarketStatusResponse(d.statuses(), env, fmt)); + } + + /** Sync wrapper for {@link #statusAsync(MarketStatusRequest)}. */ + public MarketStatusResponse status(MarketStatusRequest request) { + return transport.joinSync(statusAsync(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 statusSpec(MarketStatusRequest r) { + RequestSpec.Builder b = RequestSpec.get("markets/status"); + if (r.country() != null) { + b.query("country", r.country()); + } + if (r.date() != null) { + b.query("date", DateTimeFormatter.ISO_LOCAL_DATE.format(r.date())); + } + if (r.from() != null) { + b.query("from", DateTimeFormatter.ISO_LOCAL_DATE.format(r.from())); + } + if (r.to() != null) { + b.query("to", DateTimeFormatter.ISO_LOCAL_DATE.format(r.to())); + } + if (r.countback() != null) { + b.query("countback", r.countback()); + } + return b; + } + + // ---------- wire-format module ---------- + + static SimpleModule wireFormatModule() { + SimpleModule m = new SimpleModule("marketdata-markets"); + m.addDeserializer( + MarketStatuses.class, + rowsDeserializer( + STATUS_FIELDS, STATUS_FIELDS, MarketsResource::buildStatusRow, MarketStatuses::new)); + return m; + } + + // status columns: both required (either may be projected away via `columns`). A `status` CELL + // can still be null — the backend emits null for days outside its holiday-calendar coverage. + private static final List STATUS_FIELDS = List.of("date", "status"); + + private static MarketStatus buildStatusRow(ParallelArrays.Row row) throws IOException { + return new MarketStatus(dateOrTimestampOrNull(row, "date"), row.textOrNull("status")); + } + + 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); + } + + /** + * 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" (or, for {@code status} cells, "outside calendar coverage") — never "the backend + * silently dropped the column". + */ + 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/markets/MarketRequests.java b/src/main/java/com/marketdata/sdk/markets/MarketRequests.java new file mode 100644 index 0000000..cb34690 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketRequests.java @@ -0,0 +1,39 @@ +package com.marketdata.sdk.markets; + +import java.time.LocalDate; +import org.jspecify.annotations.Nullable; + +/** Shared request-builder validation for the market endpoints. */ +final class MarketRequests { + + private MarketRequests() {} + + /** + * Validates the historical-window parameters: {@code date} is a single-point lookup incompatible + * with any ranging parameter; {@code countback} is an alternative to {@code from} for the left + * edge (the backend ignores countback when from is present — we reject the combination instead of + * silently dropping one side); {@code countback} must be positive; and {@code from} must not be + * after {@code to}. + */ + 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 (from != null && to != null && from.isAfter(to)) { + throw new IllegalArgumentException("from must not be after to"); + } + if (countback != null) { + if (countback <= 0) { + throw new IllegalArgumentException("countback must be positive"); + } + if (from != null) { + throw new IllegalArgumentException( + "countback and from are mutually exclusive; pair countback with to"); + } + } + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketStatus.java b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java new file mode 100644 index 0000000..11c5fd0 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java @@ -0,0 +1,34 @@ +package com.marketdata.sdk.markets; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; + +/** + * The market status of a single calendar day — one row of {@link MarketStatuses}. {@code date} is + * midnight market-time ({@code America/New_York}); {@code status} is {@code "open"} or {@code + * "closed"}. + * + *

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}). Additionally, the + * backend itself emits a {@code null} status cell for days outside its holiday-calendar + * coverage — so a {@code null} {@code status} on a row whose column you requested means "calendar + * has no answer for this day", not a decode failure. 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 date the calendar day ({@code date} on the wire). + * @param status {@code "open"} / {@code "closed"} ({@code status} on the wire), or {@code null} + * outside the calendar's coverage. + */ +public record MarketStatus(@Nullable ZonedDateTime date, @Nullable String status) { + + /** Whether this day is an open market day ({@code status == "open"}). */ + public boolean isOpen() { + return "open".equals(status); + } + + /** Whether this day is a closed market day ({@code status == "closed"}). */ + public boolean isClosed() { + return "closed".equals(status); + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketStatusRequest.java b/src/main/java/com/marketdata/sdk/markets/MarketStatusRequest.java new file mode 100644 index 0000000..407d329 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketStatusRequest.java @@ -0,0 +1,107 @@ +package com.marketdata.sdk.markets; + +import java.time.LocalDate; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Parameters for {@code GET /v1/markets/status/}. Every parameter is optional — a bare + * request returns today's status for US exchanges; the window parameters select a single day + * ({@code date}) or a range ({@code from}/{@code to}, or {@code to}+{@code countback}); {@code + * country} switches the exchange calendar (two-digit ISO 3166; the backend currently serves US only + * and answers {@code no_data} for others). + * + *

Window rules (enforced in {@link Builder#build()}): {@code date} 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 MarketStatusRequest { + + private final @Nullable String country; + private final @Nullable LocalDate date; + private final @Nullable LocalDate from; + private final @Nullable LocalDate to; + private final @Nullable Integer countback; + + private MarketStatusRequest(Builder b) { + this.country = b.country; + this.date = b.date; + this.from = b.from; + this.to = b.to; + this.countback = b.countback; + } + + /** Shortcut for {@code builder().build()} — today's status, US calendar. */ + public static MarketStatusRequest of() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public @Nullable String country() { + return country; + } + + 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 @Nullable String country; + private @Nullable LocalDate date; + private @Nullable LocalDate from; + private @Nullable LocalDate to; + private @Nullable Integer countback; + + private Builder() {} + + /** Exchange-calendar country (two-digit ISO 3166 code). Backend default: {@code US}. */ + public Builder country(String country) { + this.country = Objects.requireNonNull(country, "country"); + return this; + } + + /** Look up the status of a single day. Exclusive with {@code from}/{@code to}/countback. */ + public Builder date(LocalDate date) { + this.date = Objects.requireNonNull(date, "date"); + return this; + } + + /** The first day of the range (inclusive). */ + public Builder from(LocalDate from) { + this.from = Objects.requireNonNull(from, "from"); + return this; + } + + /** The last day of the range (inclusive). */ + public Builder to(LocalDate to) { + this.to = Objects.requireNonNull(to, "to"); + return this; + } + + /** Fetch {@code countback} days before {@code to}. Positive; pair with {@code to}. */ + public Builder countback(int countback) { + this.countback = countback; + return this; + } + + public MarketStatusRequest build() { + MarketRequests.validateWindow(date, from, to, countback); + return new MarketStatusRequest(this); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketStatuses.java b/src/main/java/com/marketdata/sdk/markets/MarketStatuses.java new file mode 100644 index 0000000..0a21818 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketStatuses.java @@ -0,0 +1,18 @@ +package com.marketdata.sdk.markets; + +import java.util.List; +import java.util.Objects; + +/** + * Decoded body of {@code GET /v1/markets/status/} — one {@link MarketStatus} per calendar day in + * the order the API delivered them. + * + * @param statuses the rows; immutable, never {@code null}, empty for a {@code "s":"no_data"} body. + */ +public record MarketStatuses(List statuses) { + + public MarketStatuses { + Objects.requireNonNull(statuses, "statuses"); + statuses = List.copyOf(statuses); + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/package-info.java b/src/main/java/com/marketdata/sdk/markets/package-info.java new file mode 100644 index 0000000..b01ea91 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/package-info.java @@ -0,0 +1,7 @@ +/** + * Response records and request types for the {@code markets} resource — the market open/closed + * status calendar. The {@link com.marketdata.sdk.MarketsResource} 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.markets; diff --git a/src/test/java/com/marketdata/sdk/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java new file mode 100644 index 0000000..b0c59e6 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketsResourceTest.java @@ -0,0 +1,401 @@ +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.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +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 MarketsResourceTest { + + 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 MarketsResource 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 MarketsResource(transport, new JsonResponseParser()); + } + + // ---------- canned bodies ---------- + + // Fri (open), Sat (closed), Sun (closed) — 1705039200 = 2024-01-12 (mock uses midnight ET). + private static final String STATUS_BODY = + "{\"s\":\"ok\"," + + "\"date\":[1705035600,1705122000,1705208400]," + + "\"status\":[\"open\",\"closed\",\"closed\"]}"; + + // ---------- status ---------- + + @Test + void statusHitsVersionedEndpointWithNoRequiredParams() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + + markets.statusAsync(MarketStatusRequest.of()).join(); + + assertThat(client.captured.get(0).uri().toString()) + .isEqualTo("http://localhost/v1/markets/status/"); + assertThat(client.captured.get(0).method()).isEqualTo("GET"); + } + + @Test + void statusAttachesAllParams() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + + markets + .statusAsync( + MarketStatusRequest.builder() + .country("US") + .from(LocalDate.of(2025, Month.JANUARY, 1)) + .to(LocalDate.of(2025, Month.JANUARY, 31)) + .build()) + .join(); + + String url = client.captured.get(0).uri().toString(); + assertThat(url) + .contains("/v1/markets/status/") + .contains("country=US") + .contains("from=2025-01-01") + .contains("to=2025-01-31"); + } + + @Test + void statusAttachesDateAndCountbackWindows() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + + markets.status( + MarketStatusRequest.builder().date(LocalDate.of(2025, Month.JANUARY, 17)).build()); + markets.status( + MarketStatusRequest.builder() + .to(LocalDate.of(2025, Month.JANUARY, 31)) + .countback(30) + .build()); + + assertThat(client.captured.get(0).uri().toString()).contains("date=2025-01-17"); + assertThat(client.captured.get(1).uri().toString()) + .contains("to=2025-01-31") + .contains("countback=30"); + } + + @Test + void statusDecodesRowsWithOpenClosedPredicates() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + + List days = markets.status(MarketStatusRequest.of()).values(); + + assertThat(days).hasSize(3); + MarketStatus first = days.get(0); + assertThat(first.status()).isEqualTo("open"); + assertThat(first.isOpen()).isTrue(); + assertThat(first.isClosed()).isFalse(); + assertThat(first.date()).isNotNull(); + assertThat(first.date().getZone().getId()).isEqualTo("America/New_York"); + assertThat(days.get(1).isClosed()).isTrue(); + assertThat(days.get(1).isOpen()).isFalse(); + } + + @Test + void statusAcceptsDateOnlyTimestampStrings() { + // Under dateformat=timestamp the `date` column 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\",\"date\":[\"2025-01-17\"],\"status\":[\"open\"]}"); + MarketsResource markets = resourceWith(client); + + MarketStatus day = + markets.dateFormat(DateFormat.TIMESTAMP).status(MarketStatusRequest.of()).values().get(0); + + assertThat(day.date().toLocalDate()).isEqualTo(LocalDate.of(2025, Month.JANUARY, 17)); + assertThat(day.date().getHour()).isZero(); + } + + @Test + void statusNullCellsOutsideCalendarCoverageDecodeToNull() { + // The backend emits a null status CELL for days outside its holiday-calendar coverage. The + // column is present, so Option A is satisfied — the cell decodes to null, not a ParseError. + CapturingClient client = + okWith("{\"s\":\"ok\",\"date\":[1705035600,1705122000],\"status\":[\"open\",null]}"); + MarketsResource markets = resourceWith(client); + + List days = markets.status(MarketStatusRequest.of()).values(); + + assertThat(days.get(0).isOpen()).isTrue(); + assertThat(days.get(1).status()).isNull(); + assertThat(days.get(1).isOpen()).isFalse(); + assertThat(days.get(1).isClosed()).isFalse(); + } + + @Test + void statusNoDataEnvelopeYieldsEmptyList() { + // The backend answers non-US countries with 404 + {"s":"no_data"} — the SDK surfaces that as a + // successful empty response (isNoData() == true), not an exception. + CapturingClient client = notFoundWith("{\"s\":\"no_data\"}"); + MarketsResource markets = resourceWith(client); + + var response = markets.status(MarketStatusRequest.builder().country("XX").build()); + assertThat(response.values()).isEmpty(); + assertThat(response.isNoData()).isTrue(); + } + + @Test + void statusErrorEnvelopeSurfacesAsParseError() { + CapturingClient client = okWith("{\"s\":\"error\",\"errmsg\":\"Invalid date\"}"); + MarketsResource markets = resourceWith(client); + + assertThatThrownBy(() -> markets.status(MarketStatusRequest.of())) + .isInstanceOf(ParseError.class) + .hasMessageContaining("Invalid date"); + } + + @Test + void statusSyncMirrorsAsync() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + assertThat(markets.status(MarketStatusRequest.of()).values()).hasSize(3); + } + + // ---------- columns projection + Option A ---------- + + @Test + void columnsProjectionDecodesRequestedAndNullsTheRest() { + CapturingClient client = okWith("{\"s\":\"ok\",\"status\":[\"open\"]}"); + MarketsResource markets = resourceWith(client); + + MarketStatus day = markets.columns("status").status(MarketStatusRequest.of()).values().get(0); + + assertThat(day.status()).isEqualTo("open"); + assertThat(day.date()).isNull(); + String url = URLDecoder.decode(client.captured.get(0).uri().toString(), StandardCharsets.UTF_8); + assertThat(url).contains("columns=status"); + } + + @Test + void columnsRequestedButOmittedByApiThrowsParseError() { + CapturingClient client = okWith("{\"s\":\"ok\",\"status\":[\"open\"]}"); + MarketsResource markets = resourceWith(client); + + assertThatThrownBy(() -> markets.columns("date", "status").status(MarketStatusRequest.of())) + .isInstanceOf(ParseError.class) + .hasMessageContaining("date"); + } + + @Test + void noColumnsFilterStillRequiresAllStructuralColumns() { + // Body is missing `status` with no projection requested — must throw, never silently null. + CapturingClient client = okWith("{\"s\":\"ok\",\"date\":[1705035600]}"); + MarketsResource markets = resourceWith(client); + + assertThatThrownBy(() -> markets.status(MarketStatusRequest.of())) + .isInstanceOf(ParseError.class); + } + + @Test + void universalParamsReachTheWire() { + CapturingClient client = okWith(STATUS_BODY); + MarketsResource markets = resourceWith(client); + + markets + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .status(MarketStatusRequest.of()); + + 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("date,status\n1705035600,open\n1705122000,closed"); + MarketsResource markets = resourceWith(client); + + CsvResponse csv = markets.asCsv().status(MarketStatusRequest.of()); + + assertThat(csv.csv()).contains("date,status"); + 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"); + MarketsResource markets = resourceWith(client); + + markets + .asCsv() + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .limit(50) + .offset(10) + .columns("date", "status") + .human(true) + .headers(true) + .status(MarketStatusRequest.of()); + + 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("limit=50") + .contains("offset=10") + .contains("columns=date,status") + .contains("human=true") + .contains("headers=true"); + } + + @Test + void htmlFacetSendsFormatHtml() { + CapturingClient client = okWith("x"); + MarketsHtmlResource html = resourceWith(client).asHtml(); + + assertThat(html.status(MarketStatusRequest.of()).html()).contains(""); + assertThat(client.captured.get(0).uri().toString()).contains("format=html"); + } + + // ---------- §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, STATUS_BODY.getBytes(StandardCharsets.UTF_8), rl); + MarketsResource markets = resourceWith(client); + + RateLimitSnapshot snap = markets.status(MarketStatusRequest.of()).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(STATUS_BODY); + MarketsResource markets = resourceWith(client); + assertThat(markets.status(MarketStatusRequest.of()).rateLimit()).isNull(); + } + + // ---------- request validation ---------- + + @Test + void statusRequestRejectsDateWithRange() { + assertThatThrownBy( + () -> + MarketStatusRequest.builder() + .date(LocalDate.of(2025, Month.JANUARY, 1)) + .from(LocalDate.of(2024, Month.DECEMBER, 1)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + + @Test + void statusRequestRejectsCountbackWithFrom() { + assertThatThrownBy( + () -> + MarketStatusRequest.builder() + .from(LocalDate.of(2024, Month.JANUARY, 1)) + .countback(3) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("countback and from"); + } + + @Test + void statusRequestRejectsNonPositiveCountback() { + assertThatThrownBy(() -> MarketStatusRequest.builder().countback(0).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive"); + } + + @Test + void statusRequestRejectsFromAfterTo() { + assertThatThrownBy( + () -> + MarketStatusRequest.builder() + .from(LocalDate.of(2025, Month.JANUARY, 31)) + .to(LocalDate.of(2025, Month.JANUARY, 1)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("from must not be after to"); + } + + // ---------- helpers ---------- + + private static CapturingClient okWith(String body) { + return new CapturingClient(200, body.getBytes(StandardCharsets.UTF_8), EMPTY_HEADERS); + } + + private static CapturingClient notFoundWith(String body) { + return new CapturingClient(404, 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); + } + } +}