Skip to content

Commit aa82825

Browse files
make stocks compliance
1 parent 02635c5 commit aa82825

12 files changed

Lines changed: 444 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4040
on synthesized forward-quarter rows. Mixed date/timestamp wire shapes (a daily
4141
candle's date-only `t` vs. an intraday full timestamp) decode uniformly. Carries
4242
the same universal-parameter setters, `columns` projection (with the Option A
43-
strict guarantee), and `asCsv()` facet as `options`.
43+
strict guarantee), and `asCsv()` facet as `options`. Intraday candle requests
44+
spanning more than ~one year are **auto-split** into year-sized sub-requests,
45+
fetched concurrently through the 50-permit pool and merged into one response
46+
(SDK requirements §12), on both the typed and CSV paths.
47+
- **Per-response rate limits** — every `MarketDataResponse` now exposes
48+
`rateLimit()` returning a `RateLimitSnapshot` parsed from that response's own
49+
`x-api-ratelimit-*` headers (request-scoped, SDK requirements §8.2), distinct
50+
from the client-level `MarketDataClient.getRateLimits()`. Applies to every
51+
resource (options/utilities/stocks) and the CSV/HTML responses.
4452
- **Options resource** (`client.options()`) — all six endpoints, each in sync +
4553
async form: `lookup`, `expirations`, `strikes`, `quote` (single contract),
4654
`quotes` (multi-contract fan-out returning a per-symbol

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
8585
**Deliberately deferred (require the per-resource layer to land first):**
8686
- §1.2 resource groupings — `client.utilities()`, `client.options()`, and `client.stocks()` are wired today; `client.funds`, `client.markets` still to come.
8787
- §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<T>` named-response types) is in place — each new endpoint just declares its fields and row builder.
88-
- §8 request-scoped rate-limit attachment — today the snapshot is client-level (via `client.getRateLimits()`); attaching a per-response snapshot to `Response<T>` is a small follow-up when a consumer needs it.
88+
- ~~§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()`.
8989
- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until the resource layer lands so the threshold meaningfully ratchets functional code, not just scaffolding.
9090

9191
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.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ MarketDataClient().use { client ->
4848

4949
Every response implements `MarketDataResponse<T>`: `values()` returns the typed payload
5050
(typed per endpoint — a `List`, a scalar `String`, …), and the same metadata accessors
51-
(`statusCode()`, `isNoData()`, `requestId()`, `json()`, `saveToFile(path)`) are available on
52-
every response, on every resource.
51+
(`statusCode()`, `isNoData()`, `requestId()`, `rateLimit()`, `json()`, `saveToFile(path)`) are
52+
available on every response, on every resource. `rateLimit()` returns the rate-limit snapshot
53+
parsed from *that* response's headers (request-scoped), distinct from the client-level
54+
`client.getRateLimits()`.
5355

5456
## Options
5557

@@ -172,7 +174,9 @@ a typed `MarketDataResponse` (payload via `values()`). The universal-parameter s
172174
### Candles
173175

174176
`StockResolution` is a value type, not an enum — the API accepts an open-ended family of
175-
resolutions, so use the factories (`DAILY`, `minutes(15)`, `hours(1)`, `days(2)`, …):
177+
resolutions, so use the factories (`DAILY`, `minutes(15)`, `hours(1)`, `days(2)`, …). An
178+
**intraday** request spanning more than ~one year is automatically split into year-sized
179+
sub-requests, fetched concurrently and merged into one response — transparent to the caller:
176180

177181
#### Java
178182

docs/STOCKS_REVIEW_GUIDE.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This guide walks a reviewer through the `stocks` resource added on the `11_stocks_resource` branch. It is organized by **flow**, not by file.
44

5-
This PR **adopts the conventions the [`options` PR](OPTIONS_REVIEW_GUIDE.md) established** — the `MarketDataResponse<T>` + 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, response model, `ParallelArrays`, `JsonResponseParser`) are reused **unchanged**; the only shared-layer addition is one tolerant date parser (`MarketDataDates.parseDateOrTimestampField`). 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) and §7–§8 (batch vs. fan-out, news/earnings specifics).
5+
This PR **adopts the conventions the [`options` PR](OPTIONS_REVIEW_GUIDE.md) established** — the `MarketDataResponse<T>` + 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).
66

77
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.
88

@@ -65,24 +65,29 @@ com.marketdata.sdk.stocks.StockResolution (candle-resolution value type)
6565
|---|---|---|
6666
| Resource façade | `StocksResource.java` | universal-param config, per-endpoint `*Spec` builders, the generic row deserializer + Option A, `asCsv()`/`asHtml()` |
6767
| CSV/HTML facets | `StocksCsvResource.java`, `StocksHtmlResource.java` | reuse of the static `*Spec` builders, `format=csv/html` |
68-
| Responses | `Stock*Response.java` | `values()` payload typing; `StockNewsResponse.updated()` scalar accessor |
68+
| Responses | `Stock*Response.java`, `MarketDataResponse.java`, `AbstractMarketDataResponse.java` | `values()` payload typing; `StockNewsResponse.updated()` scalar accessor; the new `rateLimit()` (§8.2) on the interface + base |
69+
| Candle chunking | `StocksResource.candleChunks`/`candlesAsync`, `StockResolution.isIntraday`, `StocksCsvResource.mergeCsvBodies` | §12 intraday split → concurrent fetch → merge |
6970
| Requests | `stocks/Stock*Request.java`, `StockRequests.java`, `StockResolution.java` | Builder validation, shared `validateWindow`, the value-type resolution |
7071
| Row records | `stocks/StockCandle/StockQuote/StockPrice/StockNewsArticle/StockEarning.java` | `@Nullable` fields; `StockNewsArticle` non-null (always emitted) |
7172
| News deserializer | `StockNewsDeserializer.java` | per-row arrays + scalar `updated`; envelope handling |
72-
| Reused infra (changed) | `MarketDataDates.java` | the new `parseDateOrTimestampField` only |
73+
| Reused infra (changed) | `MarketDataDates.java`, `MarketDataResponse.java`, `AbstractMarketDataResponse.java` | additive only: `parseDateOrTimestampField`; `rateLimit()` |
7374
| Wiring | `MarketDataClient.java` | `client.stocks()` |
7475

75-
### 1.3 What changed in shared layers (vs. "unchanged")
76+
### 1.3 What changed in shared layers (additive only)
7677

77-
- **`MarketDataDates`** gained `parseDateOrTimestampField` — additive; the existing `parseDateField` / `parseTimestampField` are untouched. Everything else (`ParallelArrays`, `JsonResponseParser`, `RequestConfig`, `MarketDataResponse`, transport/retry/rate-limit/status-cache/exceptions) is reused exactly as the options PR left it. Confirm no existing parser was modified.
78+
- **`MarketDataDates`** gained `parseDateOrTimestampField` — the existing `parseDateField` / `parseTimestampField` are untouched. Confirm no existing parser was modified.
79+
- **`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.
80+
- Everything else (`ParallelArrays`, `JsonResponseParser`, `RequestConfig`, `RateLimitHeaders`, transport/retry/status-cache/exceptions) is reused exactly as the options PR left it.
7881

7982
---
8083

8184
## 2. The response model (reused)
8285

83-
No new model concepts — `Stock*Response` are thin subclasses of `AbstractMarketDataResponse<T>` (see [Options guide §2](OPTIONS_REVIEW_GUIDE.md#2-the-response-model) for the full shape). `values()` is the flat payload per endpoint (`List<StockCandle>`, `List<StockQuote>`, …). The one extra: `StockNewsResponse.updated()` exposes the feed's scalar update time (it sits at the response root, not on each row).
86+
No new model concepts — `Stock*Response` are thin subclasses of `AbstractMarketDataResponse<T>` (see [Options guide §2](OPTIONS_REVIEW_GUIDE.md#2-the-response-model) for the full shape). `values()` is the flat payload per endpoint (`List<StockCandle>`, `List<StockQuote>`, …). The one endpoint extra: `StockNewsResponse.updated()` exposes the feed's scalar update time (it sits at the response root, not on each row).
8487

85-
What to check: `values()` return types match the wire shape per endpoint; `updated()` is only on `StockNewsResponse`.
88+
**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.
89+
90+
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).
8691

8792
---
8893

@@ -194,6 +199,8 @@ Verify: `quotesBatchesSymbolsInOneRequest` / `pricesBatchesSymbolsInOneRequest`
194199
| 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. |
195200
| 9.7 | **wire vs. Java names** | Builder `week52(...)` → wire `52week`; builder `candle(...)` → wire `candle`; `adjustSplits`/`adjustDividends` → wire `adjustsplits`/`adjustdividends`. |
196201
| 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. |
202+
| 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. |
203+
| 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()`. |
197204

198205
---
199206

@@ -204,20 +211,22 @@ Do **not** flag as missing — deferred, documented in [`PR.md`](../PR.md):
204211
- **ADR-008 accept + ADR-009**, and the `docs/java-sdk-requirements.md` per-resource section that depends on an accepted source ADR.
205212
- **HTML facet exposure** — package-private until the backend serves `format=html`.
206213
- **SDK-wide setter de-duplication** — the universal-param setters are copy-pasted per resource; a self-typed-base refactor is tracked for before v1.
207-
- **§13 JaCoCo 100% threshold**, **§8 per-response rate-limit snapshot** — unchanged from the options PR.
214+
- **§13 JaCoCo 100% threshold** — unchanged from the options PR.
208215
- **`funds`/`markets`** — adopt this convention next.
209216

210217
---
211218

212219
## Reviewer checklist
213220

214-
- [ ] Shared layers untouched except the additive `MarketDataDates.parseDateOrTimestampField`; no existing parser modified.
221+
- [ ] 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.
215222
- [ ] Requests: Builder per endpoint, no `String` overloads; `StockResolution` value-type factories validate; shared `validateWindow` (date XOR range, countback pairs with `to`).
216223
- [ ] Query translation: every `*Spec` reads every getter; wire names correct (`52week`, `candle`, `adjustsplits`, …); `symbols` comma-joined; paths encoded.
217224
- [ ] 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.
218225
- [ ] `news` deserializer: per-row arrays + scalar `updated` (null on date-bounded); article fields strict; envelope handling.
219226
- [ ] `quotes`/`prices` are a single batched request (one response, N rows) — not a fan-out map.
220227
- [ ] Facets: `asCsv()` covers every endpoint, returns `CsvResponse`, adds `human`/`headers`; `asHtml()` package-private.
221228
- [ ] Date handling: daily date-only vs. intraday full timestamp both decode; `updated` uses the plain timestamp parser.
222-
- [ ] Unit (`./gradlew build`, 719 tests) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) green; `make demo-stocks` runs against the mock server.
229+
- [ ] §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.
230+
- [ ] §8.2 per-response rate limit: `rateLimit()` parsed from each response's headers; `null` when absent; request-scoped (distinct from `getRateLimits()`).
231+
- [ ] Unit (`./gradlew build`, 728 tests) and integration (`MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest`) green; `make demo-stocks` runs against the mock server.
223232
- [ ] Deferred items (§10) understood and not blocking.

examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,15 @@ private static void stocksExamples(MarketDataClient client) {
309309
.to(LocalDate.now())
310310
.build());
311311
Console.ok(r.values().size() + " daily candles fetched");
312+
// §8.2: each response carries its own rate-limit snapshot (request-scoped).
313+
if (r.rateLimit() != null) {
314+
Console.info(
315+
"rate limit (from this response): "
316+
+ r.rateLimit().remaining()
317+
+ "/"
318+
+ r.rateLimit().limit()
319+
+ " remaining");
320+
}
312321
} catch (AuthenticationError e) {
313322
Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the stocks endpoints.");
314323
} catch (MarketDataException e) {

examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
* <li>{@code columns} projection: requested fields populate, fields you did <em>not</em> ask for
3535
* come back {@code null} with <strong>no error</strong>;
3636
* <li>Option A failures: a required column you <em>did</em> request (or didn't project away) that
37-
* the API omits raises a {@link ParseError}.
37+
* the API omits raises a {@link ParseError};
38+
* <li>§12 candle auto-chunking: an intraday range over a year splits into concurrent sub-requests
39+
* and merges into one response;
40+
* <li>§8.2 per-response rate limits: {@code rateLimit()} parsed from each response's headers.
3841
* </ul>
3942
*
4043
* <p>Each scenario scripts the mock server's response with {@link MockServerControl#script}.
@@ -95,13 +98,76 @@ public static void main(String[] args) {
9598

9699
try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) {
97100
everyEndpointWithParams(mock, client);
101+
candleAutoChunking(mock, client);
102+
perResponseRateLimit(mock, client);
98103
csvFacet(mock, client);
99104
columnsProjectionDoesNotFail(mock, client);
100105
optionARequestedColumnMissingFails(mock, client);
101106
strictByDefaultMissingColumnFails(mock, client);
102107
}
103108
}
104109

110+
// ---------- §12 candle auto-chunking ----------
111+
112+
private static void candleAutoChunking(MockServerControl mock, MarketDataClient client) {
113+
Console.header("§12 candle auto-chunking — intraday range > 1 year splits + merges");
114+
mock.reset();
115+
// A 3-year HOURLY (intraday) range splits into 4 year-sized sub-requests, fetched concurrently
116+
// and merged. Script one candle body per slice; each returns 2 rows → 8 merged.
117+
mock.script(
118+
List.of(
119+
Step.of(200, CANDLES), Step.of(200, CANDLES), Step.of(200, CANDLES),
120+
Step.of(200, CANDLES)));
121+
Console.step("candles(hours(1), from=2020-01-01, to=2023-01-01) — auto-split");
122+
var resp =
123+
client
124+
.stocks()
125+
.candles(
126+
StockCandlesRequest.builder(StockResolution.hours(1), "AAPL")
127+
.from(LocalDate.of(2020, 1, 1))
128+
.to(LocalDate.of(2023, 1, 1))
129+
.build());
130+
Console.ok(
131+
"candles.values() → "
132+
+ resp.values().size()
133+
+ " bars merged from "
134+
+ mock.stats().requests()
135+
+ " concurrent sub-requests (one continuous series, transparent to the caller)");
136+
Console.info("Daily/weekly/… resolutions or no `from` bound → a single request, no chunking.");
137+
}
138+
139+
// ---------- §8.2 per-response rate limit ----------
140+
141+
private static void perResponseRateLimit(MockServerControl mock, MarketDataClient client) {
142+
Console.header("§8.2 per-response rate limit — rateLimit() off each response");
143+
mock.reset();
144+
// Script the four x-api-ratelimit-* headers on the response; the SDK parses them per response.
145+
mock.script(
146+
Step.of(200, QUOTE_FULL)
147+
.withHeader("x-api-ratelimit-limit", "100000")
148+
.withHeader("x-api-ratelimit-remaining", "99997")
149+
.withHeader("x-api-ratelimit-reset", "1735689600")
150+
.withHeader("x-api-ratelimit-consumed", "3"));
151+
Console.step("quote(\"AAPL\").rateLimit() — parsed from THIS response's headers");
152+
var resp = client.stocks().quote(StockQuoteRequest.of("AAPL"));
153+
var rl = resp.rateLimit();
154+
if (rl != null) {
155+
Console.ok(
156+
"rateLimit() → remaining="
157+
+ rl.remaining()
158+
+ "/"
159+
+ rl.limit()
160+
+ " consumed="
161+
+ rl.consumed()
162+
+ " reset="
163+
+ rl.reset());
164+
} else {
165+
Console.fail("expected a rate-limit snapshot from the response headers");
166+
}
167+
Console.info(
168+
"Request-scoped — distinct from client.getRateLimits() (the client-wide latest snapshot).");
169+
}
170+
105171
// ---------- every endpoint ----------
106172

107173
private static void everyEndpointWithParams(MockServerControl mock, MarketDataClient client) {

0 commit comments

Comments
 (0)