SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

web: socle du site v2 — thème sombre/clair persisté, layout (nav, tiroir mobile, recherche ⌘K, pied de page), composants partagés (Code Prism + copie, Tabs, Callout, Table, Badge, LangSelector, Markdown sûr, SearchDialog) et moteur de docs 3 colonnes généré depuis /openapi.json (référence par opération, erreurs, guides MDX, changelog)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent 7ee2ef9

41 changed files +4,355 −58

added hfmarketdata/web/content/changelog.mdx +43 −0
@@ -0,0 +1,43 @@
1 +export const meta = { title: 'Changelog', description: 'Dated release notes for the HF Market Data API and website.' }
2 +
3 +# Changelog
4 +
5 +All notable changes to the API and the website. Dates are ISO 8601 (UTC). The API follows the [versioning & deprecation policy](/docs/versioning): everything below is **additive** unless flagged otherwise.
6 +
7 +## 2.0.0 — 2026-09-04
8 +
9 +The v2 platform release. Every `/v1/*` endpoint that existed before keeps its path, parameters and response shape.
10 +
11 +### Added
12 +
13 +- **Futures individual contracts** — `GET /v1/futures/roots`, `GET /v1/futures/{root}/contracts`, `GET /v1/futures/contract/{symbol}/bars`, `GET /v1/futures/contract/{symbol}/coverage`, `GET /v1/futures/{root}/chain?as_of=`, `GET /v1/futures/{root}/continuous?roll=&adjust=&depth=`, `GET /v1/futures/{root}/term-structure?as_of=`. Symbols accept `ESZ25` and `ESZ2025`. See the [futures guide](/docs/futures-contracts).
14 +- **Fundamentals (SEC EDGAR, point-in-time, since 2010)** — statements, facts, ratios (periodic and daily), filings, coverage, screener, frames, plus `wss://…/v1/stream` for filings and `GET /v1/bulk/fundamentals/{year}.parquet`. See the [fundamentals section](/docs/fundamentals/balance-sheet).
15 +- **Accounts & API keys** — sign-up with e-mail verification, `Authorization: Bearer hfmd_live_…` keys (hashed at rest, shown once), rotation and revocation, usage dashboard. See [Authentication](/docs/authentication).
16 +- **Rate limiting with three tiers** — keyless (per IP, hourly), free account, high usage on request — with `X-RateLimit-*` headers on every response and a `Retry-After` on 429. See [Rate limits](/docs/rate-limits).
17 +- **Uniform error envelope** — `{"error": {"code", "message", "docs", "type?", "details?"}}` on every error; the legacy `detail` field is preserved. All codes are listed on the [Errors](/docs/errors) page.
18 +- **Response formats** — `format=parquet` on tabular v2 endpoints (counts half against the rows quota), `X-Row-Count` header, ETag / `If-None-Match` (304 responses are free).
19 +- **OpenAPI 3.1** at `/openapi.json` with tags, markdown descriptions, examples and per-operation error codes. This documentation is generated from it.
20 +- **Website** — new documentation (3-column reference generated from the spec, guides, instant search with ⌘K), playground with deep links (`/playground?ep=…`), pricing/limits page, live status page, dashboard, light theme, MCP server and skills pack under Integrations.
21 +
22 +### Changed
23 +
24 +- Error responses now carry `error.code` and `error.docs` in addition to `detail` (additive).
25 +- Successful responses carry `X-RateLimit-*` and `X-Row-Count` headers (additive).
26 +
27 +### Unchanged (guaranteed)
28 +
29 +- `GET /v1/bars/{asset}/{ticker}`, `GET /v1/bars/{asset}`, `GET /v1/snapshot/{asset}`, `GET /v1/{asset}/tickers`, `GET /v1/status`, `GET /health` and the five `/v1/options/*` endpoints: same paths, parameters, `{"count", "data"}` shape and status codes. Intraday `datetime` values for these endpoints remain naive US/Eastern strings, as before; v2 endpoints emit UTC ISO 8601 (see [Time zones](/docs/time-zones)).
30 +
31 +## 1.0.1 — 2026-08-10
32 +
33 +### Fixed
34 +
35 +- Options history: `NaN` / `±inf` Greeks are serialised as `null` instead of failing the request with a 500.
36 +
37 +### Added
38 +
39 +- Interactive playground on every endpoint of the (v1) documentation site.
40 +
41 +## 1.0.0 — 2026-08-10
42 +
43 +Initial public release: stocks, ETFs, continuous futures, individual futures contract files, crypto, indices and FX bars from 1-minute to daily (FirstRate Data, 26.5 billion rows, 558,814 files, 350 GB), 66 quarterly options archives (2010 Q1 → 2026 Q2) with full Greeks, `format=csv` bulk extraction up to 2,000,000 rows, multi-ticker bars and point-in-time snapshots.
added hfmarketdata/web/content/openapi.snapshot.json +1852 −0
@@ -0,0 +1,1852 @@
1 +{
2 + "openapi": "3.1.0",
3 + "info": {
4 + "title": "HF Market Data API",
5 + "description": "Open high-frequency market data API \u2014 intraday & daily bars for stocks, ETFs, futures, crypto, indices and FX, plus full historical options chains (quotes, implied volatility, Greeks) since 2010. Built by Simon-Pierre Boucher (contact@spboucher.ai).",
6 + "contact": {
7 + "name": "Simon-Pierre Boucher",
8 + "url": "https://www.hfmarketdata.io/",
9 + "email": "contact@spboucher.ai"
10 + },
11 + "license": {
12 + "name": "Data: FirstRate Data & SEC EDGAR \u2014 see terms",
13 + "url": "https://www.hfmarketdata.io/docs/terms"
14 + },
15 + "version": "2.0.0",
16 + "x-logo": {
17 + "url": "https://www.hfmarketdata.io/logo.svg"
18 + },
19 + "x-tiers": {
20 + "keyless": {
21 + "window": "1h",
22 + "requests": 30,
23 + "rows": 100000,
24 + "max_rows_per_request": 5000
25 + },
26 + "free": {
27 + "window": "1m",
28 + "requests": 120,
29 + "rows": 1000000,
30 + "max_rows_per_request": 50000
31 + },
32 + "high_usage": {
33 + "window": "1m",
34 + "requests": 600,
35 + "rows": 10000000,
36 + "max_rows_per_request": 200000,
37 + "how": "e-mail contact@spboucher.ai"
38 + }
39 + }
40 + },
41 + "servers": [
42 + {
43 + "url": "https://www.hfmarketdata.io",
44 + "description": "Production"
45 + }
46 + ],
47 + "paths": {
48 + "/health": {
49 + "get": {
50 + "tags": [
51 + "meta"
52 + ],
53 + "summary": "Health",
54 + "operationId": "health_health_get",
55 + "responses": {
56 + "200": {
57 + "description": "Successful Response",
58 + "content": {
59 + "application/json": {
60 + "schema": {}
61 + }
62 + },
63 + "headers": {
64 + "X-RateLimit-Limit-Requests": {
65 + "schema": {
66 + "type": "integer"
67 + },
68 + "description": "Requests allowed in the current window."
69 + },
70 + "X-RateLimit-Remaining-Requests": {
71 + "schema": {
72 + "type": "integer"
73 + },
74 + "description": "Requests left in the current window."
75 + },
76 + "X-RateLimit-Limit-Rows": {
77 + "schema": {
78 + "type": "integer"
79 + },
80 + "description": "Data rows allowed in the current window."
81 + },
82 + "X-RateLimit-Remaining-Rows": {
83 + "schema": {
84 + "type": "integer"
85 + },
86 + "description": "Data rows left in the current window."
87 + },
88 + "X-RateLimit-Reset": {
89 + "schema": {
90 + "type": "integer"
91 + },
92 + "description": "Unix timestamp (seconds) when the window resets."
93 + },
94 + "X-Row-Count": {
95 + "schema": {
96 + "type": "integer"
97 + },
98 + "description": "Rows returned by this response."
99 + }
100 + }
101 + },
102 + "500": {
103 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
104 + "content": {
105 + "application/json": {
106 + "schema": {
107 + "$ref": "#/components/schemas/Error"
108 + }
109 + }
110 + }
111 + },
112 + "429": {
113 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
114 + "content": {
115 + "application/json": {
116 + "schema": {
117 + "$ref": "#/components/schemas/Error"
118 + }
119 + }
120 + }
121 + },
122 + "422": {
123 + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
124 + "content": {
125 + "application/json": {
126 + "schema": {
127 + "$ref": "#/components/schemas/Error"
128 + }
129 + }
130 + }
131 + }
132 + }
133 + }
134 + },
135 + "/v1/status": {
136 + "get": {
137 + "tags": [
138 + "meta"
139 + ],
140 + "summary": "Status",
141 + "description": "Dataset inventory: every asset type, timeframe and adjustment with\nthe number of instruments currently available.",
142 + "operationId": "status_v1_status_get",
143 + "responses": {
144 + "200": {
145 + "description": "Successful Response",
146 + "content": {
147 + "application/json": {
148 + "schema": {}
149 + }
150 + },
151 + "headers": {
152 + "X-RateLimit-Limit-Requests": {
153 + "schema": {
154 + "type": "integer"
155 + },
156 + "description": "Requests allowed in the current window."
157 + },
158 + "X-RateLimit-Remaining-Requests": {
159 + "schema": {
160 + "type": "integer"
161 + },
162 + "description": "Requests left in the current window."
163 + },
164 + "X-RateLimit-Limit-Rows": {
165 + "schema": {
166 + "type": "integer"
167 + },
168 + "description": "Data rows allowed in the current window."
169 + },
170 + "X-RateLimit-Remaining-Rows": {
171 + "schema": {
172 + "type": "integer"
173 + },
174 + "description": "Data rows left in the current window."
175 + },
176 + "X-RateLimit-Reset": {
177 + "schema": {
178 + "type": "integer"
179 + },
180 + "description": "Unix timestamp (seconds) when the window resets."
181 + },
182 + "X-Row-Count": {
183 + "schema": {
184 + "type": "integer"
185 + },
186 + "description": "Rows returned by this response."
187 + }
188 + }
189 + },
190 + "500": {
191 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
192 + "content": {
193 + "application/json": {
194 + "schema": {
195 + "$ref": "#/components/schemas/Error"
196 + }
197 + }
198 + }
199 + },
200 + "429": {
201 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
202 + "content": {
203 + "application/json": {
204 + "schema": {
205 + "$ref": "#/components/schemas/Error"
206 + }
207 + }
208 + }
209 + },
210 + "422": {
211 + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
212 + "content": {
213 + "application/json": {
214 + "schema": {
215 + "$ref": "#/components/schemas/Error"
216 + }
217 + }
218 + }
219 + }
220 + }
221 + }
222 + },
223 + "/v1/{asset}/tickers": {
224 + "get": {
225 + "tags": [
226 + "bars"
227 + ],
228 + "summary": "Tickers",
229 + "description": "List available tickers for an asset type / timeframe / adjustment.\nFor `options`, this delegates to the options underlyings listing.",
230 + "operationId": "tickers_v1__asset__tickers_get",
231 + "parameters": [
232 + {
233 + "name": "asset",
234 + "in": "path",
235 + "required": true,
236 + "schema": {
237 + "type": "string",
238 + "title": "Asset"
239 + }
240 + },
241 + {
242 + "name": "timeframe",
243 + "in": "query",
244 + "required": false,
245 + "schema": {
246 + "type": "string",
247 + "default": "1day",
248 + "title": "Timeframe"
249 + }
250 + },
251 + {
252 + "name": "adjustment",
253 + "in": "query",
254 + "required": false,
255 + "schema": {
256 + "anyOf": [
257 + {
258 + "type": "string"
259 + },
260 + {
261 + "type": "null"
262 + }
263 + ],
264 + "title": "Adjustment"
265 + }
266 + },
267 + {
268 + "name": "search",
269 + "in": "query",
270 + "required": false,
271 + "schema": {
272 + "anyOf": [
273 + {
274 + "type": "string"
275 + },
276 + {
277 + "type": "null"
278 + }
279 + ],
280 + "description": "Substring filter",
281 + "title": "Search"
282 + },
283 + "description": "Substring filter"
284 + },
285 + {
286 + "name": "limit",
287 + "in": "query",
288 + "required": false,
289 + "schema": {
290 + "type": "integer",
291 + "minimum": 1,
292 + "default": 10000,
293 + "title": "Limit"
294 + }
295 + }
296 + ],
297 + "responses": {
298 + "200": {
299 + "description": "Successful Response",
300 + "content": {
301 + "application/json": {
302 + "schema": {}
303 + }
304 + },
305 + "headers": {
306 + "X-RateLimit-Limit-Requests": {
307 + "schema": {
308 + "type": "integer"
309 + },
310 + "description": "Requests allowed in the current window."
311 + },
312 + "X-RateLimit-Remaining-Requests": {
313 + "schema": {
314 + "type": "integer"
315 + },
316 + "description": "Requests left in the current window."
317 + },
318 + "X-RateLimit-Limit-Rows": {
319 + "schema": {
320 + "type": "integer"
321 + },
322 + "description": "Data rows allowed in the current window."
323 + },
324 + "X-RateLimit-Remaining-Rows": {
325 + "schema": {
326 + "type": "integer"
327 + },
328 + "description": "Data rows left in the current window."
329 + },
330 + "X-RateLimit-Reset": {
331 + "schema": {
332 + "type": "integer"
333 + },
334 + "description": "Unix timestamp (seconds) when the window resets."
335 + },
336 + "X-Row-Count": {
337 + "schema": {
338 + "type": "integer"
339 + },
340 + "description": "Rows returned by this response."
341 + }
342 + }
343 + },
344 + "422": {
345 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
346 + "content": {
347 + "application/json": {
348 + "schema": {
349 + "$ref": "#/components/schemas/Error"
350 + }
351 + }
352 + }
353 + },
354 + "500": {
355 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
356 + "content": {
357 + "application/json": {
358 + "schema": {
359 + "$ref": "#/components/schemas/Error"
360 + }
361 + }
362 + }
363 + },
364 + "429": {
365 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
366 + "content": {
367 + "application/json": {
368 + "schema": {
369 + "$ref": "#/components/schemas/Error"
370 + }
371 + }
372 + }
373 + }
374 + }
375 + }
376 + },
377 + "/v1/bars/{asset}/{ticker}": {
378 + "get": {
379 + "tags": [
380 + "bars"
381 + ],
382 + "summary": "Bars",
383 + "description": "OHLCV bars for one instrument. Daily futures bars also carry open\ninterest. `format=csv` allows bulk extraction (up to 2M rows).",
384 + "operationId": "bars_v1_bars__asset___ticker__get",
385 + "parameters": [
386 + {
387 + "name": "asset",
388 + "in": "path",
389 + "required": true,
390 + "schema": {
391 + "type": "string",
392 + "title": "Asset"
393 + }
394 + },
395 + {
396 + "name": "ticker",
397 + "in": "path",
398 + "required": true,
399 + "schema": {
400 + "type": "string",
401 + "title": "Ticker"
402 + }
403 + },
404 + {
405 + "name": "timeframe",
406 + "in": "query",
407 + "required": false,
408 + "schema": {
409 + "type": "string",
410 + "description": "1min|5min|30min|1hour|1day",
411 + "default": "1day",
412 + "title": "Timeframe"
413 + },
414 + "description": "1min|5min|30min|1hour|1day"
415 + },
416 + {
417 + "name": "adjustment",
418 + "in": "query",
419 + "required": false,
420 + "schema": {
421 + "anyOf": [
422 + {
423 + "type": "string"
424 + },
425 + {
426 + "type": "null"
427 + }
428 + ],
429 + "description": "Default depends on asset type",
430 + "title": "Adjustment"
431 + },
432 + "description": "Default depends on asset type"
433 + },
434 + {
435 + "name": "start",
436 + "in": "query",
437 + "required": false,
438 + "schema": {
439 + "anyOf": [
440 + {
441 + "type": "string"
442 + },
443 + {
444 + "type": "null"
445 + }
446 + ],
447 + "description": "ISO date/datetime lower bound",
448 + "title": "Start"
449 + },
450 + "description": "ISO date/datetime lower bound"
451 + },
452 + {
453 + "name": "end",
454 + "in": "query",
455 + "required": false,
456 + "schema": {
457 + "anyOf": [
458 + {
459 + "type": "string"
460 + },
461 + {
462 + "type": "null"
463 + }
464 + ],
465 + "description": "ISO date/datetime upper bound",
466 + "title": "End"
467 + },
468 + "description": "ISO date/datetime upper bound"
469 + },
470 + {
471 + "name": "order",
472 + "in": "query",
473 + "required": false,
474 + "schema": {
475 + "type": "string",
476 + "pattern": "^(asc|desc)$",
477 + "default": "asc",
478 + "title": "Order"
479 + }
480 + },
481 + {
482 + "name": "limit",
483 + "in": "query",
484 + "required": false,
485 + "schema": {
486 + "type": "integer",
487 + "minimum": 1,
488 + "default": 5000,
489 + "title": "Limit"
490 + }
491 + },
492 + {
493 + "name": "format",
494 + "in": "query",
495 + "required": false,
496 + "schema": {
497 + "type": "string",
498 + "pattern": "^(json|csv)$",
499 + "default": "json",
500 + "title": "Format"
501 + }
502 + }
503 + ],
504 + "responses": {
505 + "200": {
506 + "description": "Successful Response",
507 + "content": {
508 + "application/json": {
509 + "schema": {}
510 + }
511 + },
512 + "headers": {
513 + "X-RateLimit-Limit-Requests": {
514 + "schema": {
515 + "type": "integer"
516 + },
517 + "description": "Requests allowed in the current window."
518 + },
519 + "X-RateLimit-Remaining-Requests": {
520 + "schema": {
521 + "type": "integer"
522 + },
523 + "description": "Requests left in the current window."
524 + },
525 + "X-RateLimit-Limit-Rows": {
526 + "schema": {
527 + "type": "integer"
528 + },
529 + "description": "Data rows allowed in the current window."
530 + },
531 + "X-RateLimit-Remaining-Rows": {
532 + "schema": {
533 + "type": "integer"
534 + },
535 + "description": "Data rows left in the current window."
536 + },
537 + "X-RateLimit-Reset": {
538 + "schema": {
539 + "type": "integer"
540 + },
541 + "description": "Unix timestamp (seconds) when the window resets."
542 + },
543 + "X-Row-Count": {
544 + "schema": {
545 + "type": "integer"
546 + },
547 + "description": "Rows returned by this response."
548 + }
549 + }
550 + },
551 + "422": {
552 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
553 + "content": {
554 + "application/json": {
555 + "schema": {
556 + "$ref": "#/components/schemas/Error"
557 + }
558 + }
559 + }
560 + },
561 + "500": {
562 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
563 + "content": {
564 + "application/json": {
565 + "schema": {
566 + "$ref": "#/components/schemas/Error"
567 + }
568 + }
569 + }
570 + },
571 + "429": {
572 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
573 + "content": {
574 + "application/json": {
575 + "schema": {
576 + "$ref": "#/components/schemas/Error"
577 + }
578 + }
579 + }
580 + }
581 + }
582 + }
583 + },
584 + "/v1/bars/{asset}": {
585 + "get": {
586 + "tags": [
587 + "bars"
588 + ],
589 + "summary": "Bars Multi",
590 + "description": "Bars for SEVERAL instruments in one call. Combine with `start`/`end`\nto slice any precise window \u2014 e.g. 4 hours of 1-minute bars across a\nwhole watchlist. The row limit applies per ticker.",
591 + "operationId": "bars_multi_v1_bars__asset__get",
592 + "parameters": [
593 + {
594 + "name": "asset",
595 + "in": "path",
596 + "required": true,
597 + "schema": {
598 + "type": "string",
599 + "title": "Asset"
600 + }
601 + },
602 + {
603 + "name": "tickers",
604 + "in": "query",
605 + "required": true,
606 + "schema": {
607 + "type": "string",
608 + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)",
609 + "title": "Tickers"
610 + },
611 + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)"
612 + },
613 + {
614 + "name": "timeframe",
615 + "in": "query",
616 + "required": false,
617 + "schema": {
618 + "type": "string",
619 + "default": "1day",
620 + "title": "Timeframe"
621 + }
622 + },
623 + {
624 + "name": "adjustment",
625 + "in": "query",
626 + "required": false,
627 + "schema": {
628 + "anyOf": [
629 + {
630 + "type": "string"
631 + },
632 + {
633 + "type": "null"
634 + }
635 + ],
636 + "title": "Adjustment"
637 + }
638 + },
639 + {
640 + "name": "start",
641 + "in": "query",
642 + "required": false,
643 + "schema": {
644 + "anyOf": [
645 + {
646 + "type": "string"
647 + },
648 + {
649 + "type": "null"
650 + }
651 + ],
652 + "description": "ISO date/datetime lower bound",
653 + "title": "Start"
654 + },
655 + "description": "ISO date/datetime lower bound"
656 + },
657 + {
658 + "name": "end",
659 + "in": "query",
660 + "required": false,
661 + "schema": {
662 + "anyOf": [
663 + {
664 + "type": "string"
665 + },
666 + {
667 + "type": "null"
668 + }
669 + ],
670 + "description": "ISO date/datetime upper bound",
671 + "title": "End"
672 + },
673 + "description": "ISO date/datetime upper bound"
674 + },
675 + {
676 + "name": "order",
677 + "in": "query",
678 + "required": false,
679 + "schema": {
680 + "type": "string",
681 + "pattern": "^(asc|desc)$",
682 + "default": "asc",
683 + "title": "Order"
684 + }
685 + },
686 + {
687 + "name": "limit",
688 + "in": "query",
689 + "required": false,
690 + "schema": {
691 + "type": "integer",
692 + "minimum": 1,
693 + "description": "Max rows PER TICKER",
694 + "default": 5000,
695 + "title": "Limit"
696 + },
697 + "description": "Max rows PER TICKER"
698 + },
699 + {
700 + "name": "format",
701 + "in": "query",
702 + "required": false,
703 + "schema": {
704 + "type": "string",
705 + "pattern": "^(json|csv)$",
706 + "default": "json",
707 + "title": "Format"
708 + }
709 + }
710 + ],
711 + "responses": {
712 + "200": {
713 + "description": "Successful Response",
714 + "content": {
715 + "application/json": {
716 + "schema": {}
717 + }
718 + },
719 + "headers": {
720 + "X-RateLimit-Limit-Requests": {
721 + "schema": {
722 + "type": "integer"
723 + },
724 + "description": "Requests allowed in the current window."
725 + },
726 + "X-RateLimit-Remaining-Requests": {
727 + "schema": {
728 + "type": "integer"
729 + },
730 + "description": "Requests left in the current window."
731 + },
732 + "X-RateLimit-Limit-Rows": {
733 + "schema": {
734 + "type": "integer"
735 + },
736 + "description": "Data rows allowed in the current window."
737 + },
738 + "X-RateLimit-Remaining-Rows": {
739 + "schema": {
740 + "type": "integer"
741 + },
742 + "description": "Data rows left in the current window."
743 + },
744 + "X-RateLimit-Reset": {
745 + "schema": {
746 + "type": "integer"
747 + },
748 + "description": "Unix timestamp (seconds) when the window resets."
749 + },
750 + "X-Row-Count": {
751 + "schema": {
752 + "type": "integer"
753 + },
754 + "description": "Rows returned by this response."
755 + }
756 + }
757 + },
758 + "422": {
759 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
760 + "content": {
761 + "application/json": {
762 + "schema": {
763 + "$ref": "#/components/schemas/Error"
764 + }
765 + }
766 + }
767 + },
768 + "500": {
769 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
770 + "content": {
771 + "application/json": {
772 + "schema": {
773 + "$ref": "#/components/schemas/Error"
774 + }
775 + }
776 + }
777 + },
778 + "429": {
779 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
780 + "content": {
781 + "application/json": {
782 + "schema": {
783 + "$ref": "#/components/schemas/Error"
784 + }
785 + }
786 + }
787 + }
788 + }
789 + }
790 + },
791 + "/v1/snapshot/{asset}": {
792 + "get": {
793 + "tags": [
794 + "bars"
795 + ],
796 + "summary": "Snapshot",
797 + "description": "Cross-sectional snapshot: for each requested instrument, the last bar\nat or before the given moment \u2014 the state of a whole watchlist at one\nprecise point in time.",
798 + "operationId": "snapshot_v1_snapshot__asset__get",
799 + "parameters": [
800 + {
801 + "name": "asset",
802 + "in": "path",
803 + "required": true,
804 + "schema": {
805 + "type": "string",
806 + "title": "Asset"
807 + }
808 + },
809 + {
810 + "name": "tickers",
811 + "in": "query",
812 + "required": true,
813 + "schema": {
814 + "type": "string",
815 + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)",
816 + "title": "Tickers"
817 + },
818 + "description": "Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)"
819 + },
820 + {
821 + "name": "at",
822 + "in": "query",
823 + "required": true,
824 + "schema": {
825 + "type": "string",
826 + "description": "Precise moment, e.g. 2024-06-03 10:35:00",
827 + "title": "At"
828 + },
829 + "description": "Precise moment, e.g. 2024-06-03 10:35:00"
830 + },
831 + {
832 + "name": "timeframe",
833 + "in": "query",
834 + "required": false,
835 + "schema": {
836 + "type": "string",
837 + "default": "1min",
838 + "title": "Timeframe"
839 + }
840 + },
841 + {
842 + "name": "adjustment",
843 + "in": "query",
844 + "required": false,
845 + "schema": {
846 + "anyOf": [
847 + {
848 + "type": "string"
849 + },
850 + {
851 + "type": "null"
852 + }
853 + ],
854 + "title": "Adjustment"
855 + }
856 + },
857 + {
858 + "name": "format",
859 + "in": "query",
860 + "required": false,
861 + "schema": {
862 + "type": "string",
863 + "pattern": "^(json|csv)$",
864 + "default": "json",
865 + "title": "Format"
866 + }
867 + }
868 + ],
869 + "responses": {
870 + "200": {
871 + "description": "Successful Response",
872 + "content": {
873 + "application/json": {
874 + "schema": {}
875 + }
876 + },
877 + "headers": {
878 + "X-RateLimit-Limit-Requests": {
879 + "schema": {
880 + "type": "integer"
881 + },
882 + "description": "Requests allowed in the current window."
883 + },
884 + "X-RateLimit-Remaining-Requests": {
885 + "schema": {
886 + "type": "integer"
887 + },
888 + "description": "Requests left in the current window."
889 + },
890 + "X-RateLimit-Limit-Rows": {
891 + "schema": {
892 + "type": "integer"
893 + },
894 + "description": "Data rows allowed in the current window."
895 + },
896 + "X-RateLimit-Remaining-Rows": {
897 + "schema": {
898 + "type": "integer"
899 + },
900 + "description": "Data rows left in the current window."
901 + },
902 + "X-RateLimit-Reset": {
903 + "schema": {
904 + "type": "integer"
905 + },
906 + "description": "Unix timestamp (seconds) when the window resets."
907 + },
908 + "X-Row-Count": {
909 + "schema": {
910 + "type": "integer"
911 + },
912 + "description": "Rows returned by this response."
913 + }
914 + }
915 + },
916 + "422": {
917 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
918 + "content": {
919 + "application/json": {
920 + "schema": {
921 + "$ref": "#/components/schemas/Error"
922 + }
923 + }
924 + }
925 + },
926 + "500": {
927 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
928 + "content": {
929 + "application/json": {
930 + "schema": {
931 + "$ref": "#/components/schemas/Error"
932 + }
933 + }
934 + }
935 + },
936 + "429": {
937 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
938 + "content": {
939 + "application/json": {
940 + "schema": {
941 + "$ref": "#/components/schemas/Error"
942 + }
943 + }
944 + }
945 + }
946 + }
947 + }
948 + },
949 + "/v1/options/quarters": {
950 + "get": {
951 + "tags": [
952 + "options"
953 + ],
954 + "summary": "Opt Quarters",
955 + "description": "Available options archive quarters (e.g. 2010_q1 \u2026 current).",
956 + "operationId": "opt_quarters_v1_options_quarters_get",
957 + "responses": {
958 + "200": {
959 + "description": "Successful Response",
960 + "content": {
961 + "application/json": {
962 + "schema": {}
963 + }
964 + },
965 + "headers": {
966 + "X-RateLimit-Limit-Requests": {
967 + "schema": {
968 + "type": "integer"
969 + },
970 + "description": "Requests allowed in the current window."
971 + },
972 + "X-RateLimit-Remaining-Requests": {
973 + "schema": {
974 + "type": "integer"
975 + },
976 + "description": "Requests left in the current window."
977 + },
978 + "X-RateLimit-Limit-Rows": {
979 + "schema": {
980 + "type": "integer"
981 + },
982 + "description": "Data rows allowed in the current window."
983 + },
984 + "X-RateLimit-Remaining-Rows": {
985 + "schema": {
986 + "type": "integer"
987 + },
988 + "description": "Data rows left in the current window."
989 + },
990 + "X-RateLimit-Reset": {
991 + "schema": {
992 + "type": "integer"
993 + },
994 + "description": "Unix timestamp (seconds) when the window resets."
995 + },
996 + "X-Row-Count": {
997 + "schema": {
998 + "type": "integer"
999 + },
1000 + "description": "Rows returned by this response."
1001 + }
1002 + }
1003 + },
1004 + "500": {
1005 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
1006 + "content": {
1007 + "application/json": {
1008 + "schema": {
1009 + "$ref": "#/components/schemas/Error"
1010 + }
1011 + }
1012 + }
1013 + },
1014 + "429": {
1015 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
1016 + "content": {
1017 + "application/json": {
1018 + "schema": {
1019 + "$ref": "#/components/schemas/Error"
1020 + }
1021 + }
1022 + }
1023 + },
1024 + "422": {
1025 + "description": "`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
1026 + "content": {
1027 + "application/json": {
1028 + "schema": {
1029 + "$ref": "#/components/schemas/Error"
1030 + }
1031 + }
1032 + }
1033 + }
1034 + }
1035 + }
1036 + },
1037 + "/v1/options/tickers": {
1038 + "get": {
1039 + "tags": [
1040 + "options"
1041 + ],
1042 + "summary": "Opt Tickers",
1043 + "description": "Underlyings available in a given options quarter.",
1044 + "operationId": "opt_tickers_v1_options_tickers_get",
1045 + "parameters": [
1046 + {
1047 + "name": "quarter",
1048 + "in": "query",
1049 + "required": false,
1050 + "schema": {
1051 + "anyOf": [
1052 + {
1053 + "type": "string"
1054 + },
1055 + {
1056 + "type": "null"
1057 + }
1058 + ],
1059 + "description": "e.g. 2024_q4; default latest",
1060 + "title": "Quarter"
1061 + },
1062 + "description": "e.g. 2024_q4; default latest"
1063 + },
1064 + {
1065 + "name": "search",
1066 + "in": "query",
1067 + "required": false,
1068 + "schema": {
1069 + "anyOf": [
1070 + {
1071 + "type": "string"
1072 + },
1073 + {
1074 + "type": "null"
1075 + }
1076 + ],
1077 + "title": "Search"
1078 + }
1079 + },
1080 + {
1081 + "name": "limit",
1082 + "in": "query",
1083 + "required": false,
1084 + "schema": {
1085 + "type": "integer",
1086 + "minimum": 1,
1087 + "default": 10000,
1088 + "title": "Limit"
1089 + }
1090 + }
1091 + ],
1092 + "responses": {
1093 + "200": {
1094 + "description": "Successful Response",
1095 + "content": {
1096 + "application/json": {
1097 + "schema": {}
1098 + }
1099 + },
1100 + "headers": {
1101 + "X-RateLimit-Limit-Requests": {
1102 + "schema": {
1103 + "type": "integer"
1104 + },
1105 + "description": "Requests allowed in the current window."
1106 + },
1107 + "X-RateLimit-Remaining-Requests": {
1108 + "schema": {
1109 + "type": "integer"
1110 + },
1111 + "description": "Requests left in the current window."
1112 + },
1113 + "X-RateLimit-Limit-Rows": {
1114 + "schema": {
1115 + "type": "integer"
1116 + },
1117 + "description": "Data rows allowed in the current window."
1118 + },
1119 + "X-RateLimit-Remaining-Rows": {
1120 + "schema": {
1121 + "type": "integer"
1122 + },
1123 + "description": "Data rows left in the current window."
1124 + },
1125 + "X-RateLimit-Reset": {
1126 + "schema": {
1127 + "type": "integer"
1128 + },
1129 + "description": "Unix timestamp (seconds) when the window resets."
1130 + },
1131 + "X-Row-Count": {
1132 + "schema": {
1133 + "type": "integer"
1134 + },
1135 + "description": "Rows returned by this response."
1136 + }
1137 + }
1138 + },
1139 + "422": {
1140 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
1141 + "content": {
1142 + "application/json": {
1143 + "schema": {
1144 + "$ref": "#/components/schemas/Error"
1145 + }
1146 + }
1147 + }
1148 + },
1149 + "500": {
1150 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
1151 + "content": {
1152 + "application/json": {
1153 + "schema": {
1154 + "$ref": "#/components/schemas/Error"
1155 + }
1156 + }
1157 + }
1158 + },
1159 + "429": {
1160 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
1161 + "content": {
1162 + "application/json": {
1163 + "schema": {
1164 + "$ref": "#/components/schemas/Error"
1165 + }
1166 + }
1167 + }
1168 + }
1169 + }
1170 + }
1171 + },
1172 + "/v1/options/chain/{ticker}": {
1173 + "get": {
1174 + "tags": [
1175 + "options"
1176 + ],
1177 + "summary": "Opt Chain",
1178 + "description": "Full end-of-day option chain for an underlying: quotes, bid/ask implied\nvolatility, open interest, volume and Greeks (delta, gamma, vega, theta, rho).",
1179 + "operationId": "opt_chain_v1_options_chain__ticker__get",
1180 + "parameters": [
1181 + {
1182 + "name": "ticker",
1183 + "in": "path",
1184 + "required": true,
1185 + "schema": {
1186 + "type": "string",
1187 + "title": "Ticker"
1188 + }
1189 + },
1190 + {
1191 + "name": "trade_date",
1192 + "in": "query",
1193 + "required": false,
1194 + "schema": {
1195 + "anyOf": [
1196 + {
1197 + "type": "string"
1198 + },
1199 + {
1200 + "type": "null"
1201 + }
1202 + ],
1203 + "description": "yyyy-mm-dd; default latest available",
1204 + "title": "Trade Date"
1205 + },
1206 + "description": "yyyy-mm-dd; default latest available"
1207 + },
1208 + {
1209 + "name": "expiry",
1210 + "in": "query",
1211 + "required": false,
1212 + "schema": {
1213 + "anyOf": [
1214 + {
1215 + "type": "string"
1216 + },
1217 + {
1218 + "type": "null"
1219 + }
1220 + ],
1221 + "description": "Filter on expiry date",
1222 + "title": "Expiry"
1223 + },
1224 + "description": "Filter on expiry date"
1225 + },
1226 + {
1227 + "name": "call_put",
1228 + "in": "query",
1229 + "required": false,
1230 + "schema": {
1231 + "anyOf": [
1232 + {
1233 + "type": "string",
1234 + "pattern": "^(c|p)$"
1235 + },
1236 + {
1237 + "type": "null"
1238 + }
1239 + ],
1240 + "title": "Call Put"
1241 + }
1242 + },
1243 + {
1244 + "name": "strike_min",
1245 + "in": "query",
1246 + "required": false,
1247 + "schema": {
1248 + "anyOf": [
1249 + {
1250 + "type": "number"
1251 + },
1252 + {
1253 + "type": "null"
1254 + }
1255 + ],
1256 + "title": "Strike Min"
1257 + }
1258 + },
1259 + {
1260 + "name": "strike_max",
1261 + "in": "query",
1262 + "required": false,
1263 + "schema": {
1264 + "anyOf": [
1265 + {
1266 + "type": "number"
1267 + },
1268 + {
1269 + "type": "null"
1270 + }
1271 + ],
1272 + "title": "Strike Max"
1273 + }
1274 + },
1275 + {
1276 + "name": "min_volume",
1277 + "in": "query",
1278 + "required": false,
1279 + "schema": {
1280 + "anyOf": [
1281 + {
1282 + "type": "number"
1283 + },
1284 + {
1285 + "type": "null"
1286 + }
1287 + ],
1288 + "title": "Min Volume"
1289 + }
1290 + },
1291 + {
1292 + "name": "limit",
1293 + "in": "query",
1294 + "required": false,
1295 + "schema": {
1296 + "type": "integer",
1297 + "minimum": 1,
1298 + "default": 20000,
1299 + "title": "Limit"
1300 + }
1301 + },
1302 + {
1303 + "name": "format",
1304 + "in": "query",
1305 + "required": false,
1306 + "schema": {
1307 + "type": "string",
1308 + "pattern": "^(json|csv)$",
1309 + "default": "json",
1310 + "title": "Format"
1311 + }
1312 + }
1313 + ],
1314 + "responses": {
1315 + "200": {
1316 + "description": "Successful Response",
1317 + "content": {
1318 + "application/json": {
1319 + "schema": {}
1320 + }
1321 + },
1322 + "headers": {
1323 + "X-RateLimit-Limit-Requests": {
1324 + "schema": {
1325 + "type": "integer"
1326 + },
1327 + "description": "Requests allowed in the current window."
1328 + },
1329 + "X-RateLimit-Remaining-Requests": {
1330 + "schema": {
1331 + "type": "integer"
1332 + },
1333 + "description": "Requests left in the current window."
1334 + },
1335 + "X-RateLimit-Limit-Rows": {
1336 + "schema": {
1337 + "type": "integer"
1338 + },
1339 + "description": "Data rows allowed in the current window."
1340 + },
1341 + "X-RateLimit-Remaining-Rows": {
1342 + "schema": {
1343 + "type": "integer"
1344 + },
1345 + "description": "Data rows left in the current window."
1346 + },
1347 + "X-RateLimit-Reset": {
1348 + "schema": {
1349 + "type": "integer"
1350 + },
1351 + "description": "Unix timestamp (seconds) when the window resets."
1352 + },
1353 + "X-Row-Count": {
1354 + "schema": {
1355 + "type": "integer"
1356 + },
1357 + "description": "Rows returned by this response."
1358 + }
1359 + }
1360 + },
1361 + "422": {
1362 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
1363 + "content": {
1364 + "application/json": {
1365 + "schema": {
1366 + "$ref": "#/components/schemas/Error"
1367 + }
1368 + }
1369 + }
1370 + },
1371 + "500": {
1372 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
1373 + "content": {
1374 + "application/json": {
1375 + "schema": {
1376 + "$ref": "#/components/schemas/Error"
1377 + }
1378 + }
1379 + }
1380 + },
1381 + "429": {
1382 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
1383 + "content": {
1384 + "application/json": {
1385 + "schema": {
1386 + "$ref": "#/components/schemas/Error"
1387 + }
1388 + }
1389 + }
1390 + }
1391 + }
1392 + }
1393 + },
1394 + "/v1/options/expirations/{ticker}": {
1395 + "get": {
1396 + "tags": [
1397 + "options"
1398 + ],
1399 + "summary": "Opt Expirations",
1400 + "description": "Available expiry dates (optionally as of one trade date).",
1401 + "operationId": "opt_expirations_v1_options_expirations__ticker__get",
1402 + "parameters": [
1403 + {
1404 + "name": "ticker",
1405 + "in": "path",
1406 + "required": true,
1407 + "schema": {
1408 + "type": "string",
1409 + "title": "Ticker"
1410 + }
1411 + },
1412 + {
1413 + "name": "trade_date",
1414 + "in": "query",
1415 + "required": false,
1416 + "schema": {
1417 + "anyOf": [
1418 + {
1419 + "type": "string"
1420 + },
1421 + {
1422 + "type": "null"
1423 + }
1424 + ],
1425 + "description": "yyyy-mm-dd",
1426 + "title": "Trade Date"
1427 + },
1428 + "description": "yyyy-mm-dd"
1429 + }
1430 + ],
1431 + "responses": {
1432 + "200": {
1433 + "description": "Successful Response",
1434 + "content": {
1435 + "application/json": {
1436 + "schema": {}
1437 + }
1438 + },
1439 + "headers": {
1440 + "X-RateLimit-Limit-Requests": {
1441 + "schema": {
1442 + "type": "integer"
1443 + },
1444 + "description": "Requests allowed in the current window."
1445 + },
1446 + "X-RateLimit-Remaining-Requests": {
1447 + "schema": {
1448 + "type": "integer"
1449 + },
1450 + "description": "Requests left in the current window."
1451 + },
1452 + "X-RateLimit-Limit-Rows": {
1453 + "schema": {
1454 + "type": "integer"
1455 + },
1456 + "description": "Data rows allowed in the current window."
1457 + },
1458 + "X-RateLimit-Remaining-Rows": {
1459 + "schema": {
1460 + "type": "integer"
1461 + },
1462 + "description": "Data rows left in the current window."
1463 + },
1464 + "X-RateLimit-Reset": {
1465 + "schema": {
1466 + "type": "integer"
1467 + },
1468 + "description": "Unix timestamp (seconds) when the window resets."
1469 + },
1470 + "X-Row-Count": {
1471 + "schema": {
1472 + "type": "integer"
1473 + },
1474 + "description": "Rows returned by this response."
1475 + }
1476 + }
1477 + },
1478 + "422": {
1479 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
1480 + "content": {
1481 + "application/json": {
1482 + "schema": {
1483 + "$ref": "#/components/schemas/Error"
1484 + }
1485 + }
1486 + }
1487 + },
1488 + "500": {
1489 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
1490 + "content": {
1491 + "application/json": {
1492 + "schema": {
1493 + "$ref": "#/components/schemas/Error"
1494 + }
1495 + }
1496 + }
1497 + },
1498 + "429": {
1499 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
1500 + "content": {
1501 + "application/json": {
1502 + "schema": {
1503 + "$ref": "#/components/schemas/Error"
1504 + }
1505 + }
1506 + }
1507 + }
1508 + }
1509 + }
1510 + },
1511 + "/v1/options/history/{ticker}": {
1512 + "get": {
1513 + "tags": [
1514 + "options"
1515 + ],
1516 + "summary": "Opt History",
1517 + "description": "Daily time series for one specific contract across its whole life.",
1518 + "operationId": "opt_history_v1_options_history__ticker__get",
1519 + "parameters": [
1520 + {
1521 + "name": "ticker",
1522 + "in": "path",
1523 + "required": true,
1524 + "schema": {
1525 + "type": "string",
1526 + "title": "Ticker"
1527 + }
1528 + },
1529 + {
1530 + "name": "strike",
1531 + "in": "query",
1532 + "required": true,
1533 + "schema": {
1534 + "type": "number",
1535 + "title": "Strike"
1536 + }
1537 + },
1538 + {
1539 + "name": "expiry",
1540 + "in": "query",
1541 + "required": true,
1542 + "schema": {
1543 + "type": "string",
1544 + "description": "yyyy-mm-dd",
1545 + "title": "Expiry"
1546 + },
1547 + "description": "yyyy-mm-dd"
1548 + },
1549 + {
1550 + "name": "call_put",
1551 + "in": "query",
1552 + "required": true,
1553 + "schema": {
1554 + "type": "string",
1555 + "pattern": "^(c|p)$",
1556 + "title": "Call Put"
1557 + }
1558 + },
1559 + {
1560 + "name": "limit",
1561 + "in": "query",
1562 + "required": false,
1563 + "schema": {
1564 + "type": "integer",
1565 + "minimum": 1,
1566 + "default": 5000,
1567 + "title": "Limit"
1568 + }
1569 + },
1570 + {
1571 + "name": "format",
1572 + "in": "query",
1573 + "required": false,
1574 + "schema": {
1575 + "type": "string",
1576 + "pattern": "^(json|csv)$",
1577 + "default": "json",
1578 + "title": "Format"
1579 + }
1580 + }
1581 + ],
1582 + "responses": {
1583 + "200": {
1584 + "description": "Successful Response",
1585 + "content": {
1586 + "application/json": {
1587 + "schema": {}
1588 + }
1589 + },
1590 + "headers": {
1591 + "X-RateLimit-Limit-Requests": {
1592 + "schema": {
1593 + "type": "integer"
1594 + },
1595 + "description": "Requests allowed in the current window."
1596 + },
1597 + "X-RateLimit-Remaining-Requests": {
1598 + "schema": {
1599 + "type": "integer"
1600 + },
1601 + "description": "Requests left in the current window."
1602 + },
1603 + "X-RateLimit-Limit-Rows": {
1604 + "schema": {
1605 + "type": "integer"
1606 + },
1607 + "description": "Data rows allowed in the current window."
1608 + },
1609 + "X-RateLimit-Remaining-Rows": {
1610 + "schema": {
1611 + "type": "integer"
1612 + },
1613 + "description": "Data rows left in the current window."
1614 + },
1615 + "X-RateLimit-Reset": {
1616 + "schema": {
1617 + "type": "integer"
1618 + },
1619 + "description": "Unix timestamp (seconds) when the window resets."
1620 + },
1621 + "X-Row-Count": {
1622 + "schema": {
1623 + "type": "integer"
1624 + },
1625 + "description": "Rows returned by this response."
1626 + }
1627 + }
1628 + },
1629 + "422": {
1630 + "description": "Validation Error\n\n`VALIDATION_ERROR` \u2014 The request did not match the endpoint schema.",
1631 + "content": {
1632 + "application/json": {
1633 + "schema": {
1634 + "$ref": "#/components/schemas/Error"
1635 + }
1636 + }
1637 + }
1638 + },
1639 + "500": {
1640 + "description": "`INTERNAL_ERROR` \u2014 Unexpected server error.",
1641 + "content": {
1642 + "application/json": {
1643 + "schema": {
1644 + "$ref": "#/components/schemas/Error"
1645 + }
1646 + }
1647 + }
1648 + },
1649 + "429": {
1650 + "description": "`RATE_LIMIT_EXCEEDED` \u2014 Request or row quota exhausted for the current window.",
1651 + "content": {
1652 + "application/json": {
1653 + "schema": {
1654 + "$ref": "#/components/schemas/Error"
1655 + }
1656 + }
1657 + }
1658 + }
1659 + }
1660 + }
1661 + }
1662 + },
1663 + "components": {
1664 + "schemas": {
1665 + "HTTPValidationError": {
1666 + "properties": {
1667 + "detail": {
1668 + "items": {
1669 + "$ref": "#/components/schemas/ValidationError"
1670 + },
1671 + "type": "array",
1672 + "title": "Detail"
1673 + }
1674 + },
1675 + "type": "object",
1676 + "title": "HTTPValidationError"
1677 + },
1678 + "ValidationError": {
1679 + "properties": {
1680 + "loc": {
1681 + "items": {
1682 + "anyOf": [
1683 + {
1684 + "type": "string"
1685 + },
1686 + {
1687 + "type": "integer"
1688 + }
1689 + ]
1690 + },
1691 + "type": "array",
1692 + "title": "Location"
1693 + },
1694 + "msg": {
1695 + "type": "string",
1696 + "title": "Message"
1697 + },
1698 + "type": {
1699 + "type": "string",
1700 + "title": "Error Type"
1701 + },
1702 + "input": {
1703 + "title": "Input"
1704 + },
1705 + "ctx": {
1706 + "type": "object",
1707 + "title": "Context"
1708 + }
1709 + },
1710 + "type": "object",
1711 + "required": [
1712 + "loc",
1713 + "msg",
1714 + "type"
1715 + ],
1716 + "title": "ValidationError"
1717 + },
1718 + "Error": {
1719 + "type": "object",
1720 + "required": [
1721 + "error",
1722 + "detail"
1723 + ],
1724 + "properties": {
1725 + "error": {
1726 + "type": "object",
1727 + "required": [
1728 + "code",
1729 + "message",
1730 + "docs"
1731 + ],
1732 + "properties": {
1733 + "code": {
1734 + "type": "string",
1735 + "enum": [
1736 + "ASSET_NOT_FOUND",
1737 + "AUTH_REQUIRED",
1738 + "CONFLICT",
1739 + "CONTRACT_NOT_FOUND",
1740 + "FORBIDDEN",
1741 + "INTERNAL_ERROR",
1742 + "INVALID_API_KEY",
1743 + "INVALID_CONTRACT_SYMBOL",
1744 + "INVALID_PARAMETER",
1745 + "NOT_FOUND",
1746 + "OPTIONS_UNAVAILABLE",
1747 + "RATE_LIMIT_EXCEEDED",
1748 + "ROOT_NOT_FOUND",
1749 + "ROW_LIMIT_EXCEEDED",
1750 + "SERVICE_UNAVAILABLE",
1751 + "TICKER_NOT_FOUND",
1752 + "VALIDATION_ERROR"
1753 + ]
1754 + },
1755 + "message": {
1756 + "type": "string"
1757 + },
1758 + "docs": {
1759 + "type": "string",
1760 + "format": "uri"
1761 + },
1762 + "type": {
1763 + "type": "string",
1764 + "description": "Sub-type, e.g. requests_per_hour / rows_per_minute for 429."
1765 + },
1766 + "details": {
1767 + "type": "object",
1768 + "additionalProperties": true
1769 + }
1770 + }
1771 + },
1772 + "detail": {
1773 + "type": "string",
1774 + "description": "Legacy field (same as error.message)."
1775 + }
1776 + },
1777 + "example": {
1778 + "error": {
1779 + "code": "CONTRACT_NOT_FOUND",
1780 + "message": "No data for contract ESZ19",
1781 + "docs": "https://www.hfmarketdata.io/docs/errors#contract_not_found"
1782 + },
1783 + "detail": "No data for contract ESZ19"
1784 + }
1785 + }
1786 + },
1787 + "securitySchemes": {
1788 + "ApiKey": {
1789 + "type": "http",
1790 + "scheme": "bearer",
1791 + "bearerFormat": "hfmd_live_\u2026",
1792 + "description": "Optional. `Authorization: Bearer <key>` or `?api_key=<key>`. Without a key you get the keyless (per-IP, hourly) limits; a free account raises them a lot. High usage: contact@spboucher.ai."
1793 + },
1794 + "ApiKeyQuery": {
1795 + "type": "apiKey",
1796 + "in": "query",
1797 + "name": "api_key"
1798 + }
1799 + }
1800 + },
1801 + "tags": [
1802 + {
1803 + "name": "meta",
1804 + "description": "Health, live dataset inventory and your current limits."
1805 + },
1806 + {
1807 + "name": "bars",
1808 + "description": "Intraday & daily OHLCV bars for stocks, ETFs, crypto, indices, FX and continuous futures."
1809 + },
1810 + {
1811 + "name": "futures",
1812 + "description": "Individual futures contracts, expiry chains, continuous series, term structure."
1813 + },
1814 + {
1815 + "name": "options",
1816 + "description": "End-of-day options chains with quotes, implied volatility and Greeks (since 2010)."
1817 + },
1818 + {
1819 + "name": "fundamentals",
1820 + "description": "SEC EDGAR financial statements, ratios, screener \u2014 point-in-time, since 2010."
1821 + },
1822 + {
1823 + "name": "stream",
1824 + "description": "WebSocket streams (filings)."
1825 + },
1826 + {
1827 + "name": "bulk",
1828 + "description": "Whole-universe Parquet extracts, outside the rows quota."
1829 + },
1830 + {
1831 + "name": "auth",
1832 + "description": "Sign-up, e-mail verification, login, password reset."
1833 + },
1834 + {
1835 + "name": "me",
1836 + "description": "Your account: API keys, usage, tier."
1837 + },
1838 + {
1839 + "name": "admin",
1840 + "description": "Administration (admin role)."
1841 + }
1842 + ],
1843 + "security": [
1844 + {},
1845 + {
1846 + "ApiKey": []
1847 + },
1848 + {
1849 + "ApiKeyQuery": []
1850 + }
1851 + ]
1852 +}
\ No newline at end of file
modified hfmarketdata/web/index.html +21 −5
@@ -1,13 +1,29 @@
1 1 <!doctype html>
2 −<html lang="en">
2 +<html lang="en" data-theme="dark">
3 3 <head>
4 4 <meta charset="UTF-8" />
5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 − <meta name="description" content="HF Market Data — free open API for high-frequency historical market data: stocks, ETFs, futures, crypto, indices, FX (1-minute to daily) and full options chains with Greeks since 2010." />
6 + <meta name="color-scheme" content="dark light" />
7 + <meta name="theme-color" content="#0a0c10" media="(prefers-color-scheme: dark)" />
8 + <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
9 + <meta name="description" content="HF Market Data — free open API for high-frequency historical market data: stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX from 1-minute to daily, full options chains with Greeks, and SEC EDGAR fundamentals — since 2010." />
10 + <meta property="og:type" content="website" />
11 + <meta property="og:site_name" content="HF Market Data" />
12 + <meta property="og:title" content="HF Market Data — Open High-Frequency Market Data API" />
13 + <meta property="og:description" content="1-minute to daily bars, futures contracts, options chains with Greeks and point-in-time fundamentals. No signup needed to start." />
14 + <meta property="og:image" content="https://www.hfmarketdata.io/og.svg" />
15 + <meta name="twitter:card" content="summary_large_image" />
16 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
17 + <link rel="canonical" href="https://www.hfmarketdata.io/" />
7 18 <title>HF Market Data — Open High-Frequency Market Data API</title>
8 − <link rel="preconnect" href="https://fonts.googleapis.com" />
9 − <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10 − <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
19 + <script>
20 + // Theme is resolved before first paint (no flash): localStorage → system preference → dark.
21 + try {
22 + var t = localStorage.getItem('hfmd.theme');
23 + if (t !== 'light' && t !== 'dark') t = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
24 + document.documentElement.setAttribute('data-theme', t);
25 + } catch (e) {}
26 + </script>
11 27 </head>
12 28 <body>
13 29 <div id="root"></div>
modified hfmarketdata/web/package-lock.json +35 −1
@@ -8,6 +8,7 @@
8 8 "name": "hfmarketdata-web",
9 9 "version": "2.0.0",
10 10 "dependencies": {
11 + "@mdx-js/react": "^3.1.1",
11 12 "lightweight-charts": "^4.2.0",
12 13 "prism-react-renderer": "^2.4.0",
13 14 "react": "^18.3.1",
@@ -833,6 +834,23 @@
833 834 "url": "https://opencollective.com/unified"
834 835 }
835 836 },
837 + "node_modules/@mdx-js/react": {
838 + "version": "3.1.1",
839 + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
840 + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
841 + "license": "MIT",
842 + "dependencies": {
843 + "@types/mdx": "^2.0.0"
844 + },
845 + "funding": {
846 + "type": "opencollective",
847 + "url": "https://opencollective.com/unified"
848 + },
849 + "peerDependencies": {
850 + "@types/react": ">=16",
851 + "react": ">=16"
852 + }
853 + },
836 854 "node_modules/@mdx-js/rollup": {
837 855 "version": "3.1.1",
838 856 "resolved": "https://registry.npmjs.org/@mdx-js/rollup/-/rollup-3.1.1.tgz",
@@ -1420,7 +1438,6 @@
1420 1438 "version": "2.0.14",
1421 1439 "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
1422 1440 "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==",
1423 − "dev": true,
1424 1441 "license": "MIT"
1425 1442 },
1426 1443 "node_modules/@types/ms": {
@@ -1436,6 +1453,16 @@
1436 1453 "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
1437 1454 "license": "MIT"
1438 1455 },
1456 + "node_modules/@types/react": {
1457 + "version": "19.2.18",
1458 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
1459 + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
1460 + "license": "MIT",
1461 + "peer": true,
1462 + "dependencies": {
1463 + "csstype": "^3.2.2"
1464 + }
1465 + },
1439 1466 "node_modules/@types/unist": {
1440 1467 "version": "3.0.3",
1441 1468 "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1676,6 +1703,13 @@
1676 1703 "dev": true,
1677 1704 "license": "MIT"
1678 1705 },
1706 + "node_modules/csstype": {
1707 + "version": "3.2.3",
1708 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1709 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1710 + "license": "MIT",
1711 + "peer": true
1712 + },
1679 1713 "node_modules/debug": {
1680 1714 "version": "4.4.3",
1681 1715 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
modified hfmarketdata/web/package.json +5 −4
@@ -12,16 +12,17 @@
12 12 "test:e2e": "playwright test"
13 13 },
14 14 "dependencies": {
15 + "@mdx-js/react": "^3.1.1",
16 + "lightweight-charts": "^4.2.0",
17 + "prism-react-renderer": "^2.4.0",
15 18 "react": "^18.3.1",
16 19 "react-dom": "^18.3.1",
17 − "react-router-dom": "^6.28.0",
18 − "lightweight-charts": "^4.2.0",
19 − "prism-react-renderer": "^2.4.0"
20 + "react-router-dom": "^6.28.0"
20 21 },
21 22 "devDependencies": {
22 − "@vitejs/plugin-react": "^4.3.4",
23 23 "@mdx-js/rollup": "^3.1.0",
24 24 "@playwright/test": "^1.49.0",
25 + "@vitejs/plugin-react": "^4.3.4",
25 26 "vite": "^6.0.0"
26 27 }
27 28 }
added hfmarketdata/web/public/favicon.svg +6 −0
@@ -0,0 +1,6 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs>
3 + <rect width="64" height="64" rx="14" fill="#0a0c10"/>
4 + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
5 + <rect x="10" y="50" width="44" height="4" rx="2" fill="#5ee7a5" opacity=".5"/>
6 +</svg>
added hfmarketdata/web/public/logo.svg +6 −0
@@ -0,0 +1,6 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 64" width="320" height="64" role="img" aria-label="HF Market Data">
2 + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient></defs>
3 + <rect width="64" height="64" rx="14" fill="#0a0c10"/>
4 + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
5 + <text x="80" y="41" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="28" font-weight="650" fill="#e8ebf1" letter-spacing="-0.5">HF Market Data</text>
6 +</svg>
added hfmarketdata/web/public/og.svg +16 −0
@@ -0,0 +1,16 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-label="HF Market Data — Open High-Frequency Market Data API">
2 + <defs>
3 + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5ee7a5"/><stop offset="1" stop-color="#4db8ff"/></linearGradient>
4 + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0a0c10"/><stop offset="1" stop-color="#141a24"/></linearGradient>
5 + </defs>
6 + <rect width="1200" height="630" fill="url(#bg)"/>
7 + <g stroke="#232833" stroke-width="1">
8 + <path d="M0 105H1200M0 210H1200M0 315H1200M0 420H1200M0 525H1200"/>
9 + </g>
10 + <path d="M80 470 L200 380 L300 420 L420 300 L540 340 L660 220 L780 280 L900 160 L1020 200 L1120 110" fill="none" stroke="url(#g)" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" opacity=".85"/>
11 + <rect x="80" y="80" width="72" height="72" rx="16" fill="#0a0c10" stroke="#313847"/>
12 + <path d="M92 130 L104 112 L114 120 L126 100 L136 108 L142 92" fill="none" stroke="url(#g)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
13 + <text x="172" y="132" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="44" font-weight="700" fill="#e8ebf1" letter-spacing="-1">HF Market Data</text>
14 + <text x="80" y="560" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="34" fill="#aeb6c4">Open high-frequency market data API · 1-minute to daily since 2010</text>
15 + <text x="80" y="600" font-family="ui-monospace, Menlo, Consolas, monospace" font-size="24" fill="#5ee7a5">curl https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1min</text>
16 +</svg>
added hfmarketdata/web/public/robots.txt +5 −0
@@ -0,0 +1,5 @@
1 +User-agent: *
2 +Allow: /
3 +Disallow: /dashboard
4 +Disallow: /admin
5 +Sitemap: https://www.hfmarketdata.io/sitemap.xml
modified hfmarketdata/web/src/app/Layout.jsx +106 −10
@@ -1,20 +1,64 @@
1 −import React from 'react'
2 −import { Link, NavLink } from 'react-router-dom'
1 +// App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core.
2 +import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react'
3 +import { Link, NavLink, useLocation } from 'react-router-dom'
3 4 import { useAuth } from './auth.jsx'
4 −import { CONTACT_EMAIL } from './api.js'
5 +import { CONTACT_EMAIL, PUBLIC_BASE } from './api.js'
6 +import { useTheme } from './theme.js'
7 +import { CloseIcon, MenuIcon, MoonIcon, SearchIcon, SunIcon } from '../components/Icons.jsx'
5 8
6 −const NAV = [
9 +const SearchDialog = lazy(() => import('../components/SearchDialog.jsx'))
10 +
11 +/** Global shortcuts: Cmd/Ctrl+K opens search, `/` opens it too (outside form fields). */
12 +function useSearchShortcut(open) {
13 + useEffect(() => {
14 + const on = e => {
15 + const tag = e.target?.tagName
16 + const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable
17 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open() }
18 + else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); open() }
19 + }
20 + window.addEventListener('keydown', on)
21 + return () => window.removeEventListener('keydown', on)
22 + }, [open])
23 +}
24 +
25 +export const NAV = [
7 26 ['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'],
8 27 ['/pricing', 'Pricing / Limits'], ['/status', 'Status'],
9 28 ]
10 29
30 +export const REPO_URL = 'https://git.spboucher.ai/hfmarketdata.git'
31 +
32 +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
33 +
11 34 export default function Layout({ children }) {
12 35 const { user } = useAuth()
36 + const { theme, toggle } = useTheme()
37 + const [drawer, setDrawer] = useState(false)
38 + const [searchOpen, setSearchOpen] = useState(false)
39 + const location = useLocation()
40 + const openSearch = useCallback(() => setSearchOpen(true), [])
41 + useSearchShortcut(openSearch)
42 + useEffect(() => { setDrawer(false) }, [location.pathname])
43 + useEffect(() => {
44 + if (!drawer) return
45 + const onKey = e => { if (e.key === 'Escape') setDrawer(false) }
46 + window.addEventListener('keydown', onKey)
47 + return () => window.removeEventListener('keydown', onKey)
48 + }, [drawer])
49 + // Scroll to top on route change (hash links handled by the docs pages).
50 + useEffect(() => { if (!location.hash) window.scrollTo(0, 0) }, [location.pathname, location.hash])
51 +
52 + const session = user
53 + ? <Link to="/dashboard" className="btn btn-ghost">Dashboard</Link>
54 + : <Link to="/signin" className="btn btn-ghost btn-signin">Sign in</Link>
55 +
13 56 return (
14 57 <div className="shell">
58 + <a href="#main" className="skip-link">Skip to content</a>
15 59 <header className="topbar">
16 60 <Link to="/" className="brand" aria-label="HF Market Data home">
17 − <span className="brand-mark">HF</span><span className="brand-name">Market Data</span>
61 + <span className="brand-mark" aria-hidden="true">HF</span><span className="brand-name">Market Data</span>
18 62 </Link>
19 63 <nav className="topnav" aria-label="Main">
20 64 {NAV.map(([to, label]) => (
@@ -22,15 +66,67 @@ export default function Layout({ children }) {
22 66 ))}
23 67 </nav>
24 68 <div className="topbar-right">
25 − {user ? <Link to="/dashboard" className="btn btn-ghost">Dashboard</Link>
26 − : <Link to="/signin" className="btn btn-ghost">Sign in</Link>}
69 + <button type="button" className="search-trigger" onClick={openSearch} aria-label="Search documentation" data-testid="search-trigger">
70 + <SearchIcon /><span>Search docs…</span><kbd>{isMac ? '⌘' : 'Ctrl'} K</kbd>
71 + </button>
72 + <button type="button" className="icon-btn" onClick={toggle} aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} data-testid="theme-toggle" title="Toggle theme">
73 + {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
74 + </button>
75 + {session}
76 + <button type="button" className="icon-btn menu-btn" onClick={() => setDrawer(true)} aria-label="Open menu" aria-expanded={drawer} aria-controls="mobile-drawer"><MenuIcon /></button>
27 77 </div>
28 78 </header>
29 − {children}
79 +
80 + {drawer && (
81 + <>
82 + <div className="drawer-backdrop" onClick={() => setDrawer(false)} />
83 + <nav className="drawer" id="mobile-drawer" aria-label="Mobile">
84 + <div className="drawer-head">
85 + <span className="brand"><span className="brand-mark" aria-hidden="true">HF</span>Market Data</span>
86 + <button type="button" className="icon-btn" onClick={() => setDrawer(false)} aria-label="Close menu"><CloseIcon /></button>
87 + </div>
88 + <NavLink to="/" end className="navlink">Home</NavLink>
89 + {NAV.map(([to, label]) => <NavLink key={to} to={to} className="navlink">{label}</NavLink>)}
90 + <div className="drawer-foot">
91 + {user ? <Link to="/dashboard" className="btn">Dashboard</Link> : <><Link to="/signin" className="btn">Sign in</Link><Link to="/signup" className="btn btn-primary">Create free account</Link></>}
92 + </div>
93 + </nav>
94 + </>
95 + )}
96 +
97 + <div id="main" style={{ display: 'contents' }}>{children}</div>
98 +
30 99 <footer className="footer">
31 − <div>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher · <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a></div>
32 − <div className="muted">Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</div>
100 + <div className="footer-inner">
101 + <div>
102 + <div className="brand" style={{ marginBottom: 10 }}><span className="brand-mark" aria-hidden="true">HF</span>Market Data</div>
103 + <p className="muted" style={{ maxWidth: 320 }}>Open high-frequency market data API. 1-minute to daily bars since 2010, futures contracts, options chains with Greeks, SEC EDGAR fundamentals.</p>
104 + <p className="muted small">Base URL <code>{PUBLIC_BASE}</code></p>
105 + </div>
106 + <div>
107 + <h4>Product</h4>
108 + <ul><li><Link to="/docs">Documentation</Link></li><li><Link to="/docs/reference">API reference</Link></li><li><Link to="/playground">Playground</Link></li><li><Link to="/integrations">Integrations</Link></li><li><Link to="/pricing">Pricing / Limits</Link></li></ul>
109 + </div>
110 + <div>
111 + <h4>Resources</h4>
112 + <ul><li><Link to="/docs/quickstart">Quickstart</Link></li><li><Link to="/docs/changelog">Changelog</Link></li><li><Link to="/docs/errors">Error codes</Link></li><li><Link to="/status">Status</Link></li><li><a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json</a></li></ul>
113 + </div>
114 + <div>
115 + <h4>Contact</h4>
116 + <ul><li><a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a></li><li><a href={REPO_URL} rel="noopener noreferrer">Source repository</a></li><li><Link to="/signup">Create free account</Link></li></ul>
117 + </div>
118 + </div>
119 + <div className="footer-bottom">
120 + <span>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher</span>
121 + <span>Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</span>
122 + </div>
33 123 </footer>
124 +
125 + {searchOpen && (
126 + <Suspense fallback={null}>
127 + <SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
128 + </Suspense>
129 + )}
34 130 </div>
35 131 )
36 132 }
modified hfmarketdata/web/src/app/theme.css +135 −30
@@ -1,41 +1,146 @@
1 −/* HF Market Data design tokens — dark by default, one accent, mono for code. Owner: web-core. */
1 +/* HF Market Data design tokens + shell. Dark by default, light via [data-theme="light"] (persisted in localStorage).
2 + System font stacks only — no external font requests (LCP budget < 1 s). Owner: web-core. */
2 3 :root {
3 4 color-scheme: dark;
4 − --bg: #0b0d10; --bg-1: #12151a; --bg-2: #181c23; --line: #262b34; --line-2: #333a46;
5 − --fg: #e6e9ef; --fg-1: #aeb6c2; --fg-2: #7c8594;
6 − --accent: #5ee7a5; --accent-2: #37b3ff; --danger: #ff6b6b; --warn: #ffc857;
7 − --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
8 − --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9 − --radius: 8px; --max: 1240px;
5 + --bg: #0a0c10; --bg-1: #10131a; --bg-2: #161a22; --bg-3: #1d222c;
6 + --line: #232833; --line-2: #313847; --line-3: #414a5c;
7 + --fg: #e8ebf1; --fg-1: #aeb6c4; --fg-2: #7d8697; --fg-3: #5a6373;
8 + --accent: #5ee7a5; --accent-ink: #04150c; --accent-soft: rgba(94, 231, 165, 0.12);
9 + --accent-2: #4db8ff; --accent-2-soft: rgba(77, 184, 255, 0.12);
10 + --danger: #ff6b6b; --danger-soft: rgba(255, 107, 107, 0.12);
11 + --warn: #ffc857; --warn-soft: rgba(255, 200, 87, 0.12);
12 + --get: #4db8ff; --post: #5ee7a5; --delete: #ff6b6b; --put: #ffc857; --patch: #c792ea;
13 + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
14 + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15 + --radius: 8px; --radius-lg: 14px; --max: 1240px;
16 + --shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 40px -12px rgba(0, 0, 0, .55);
17 + --topbar-h: 56px;
18 + --code-bg: #0c0f15; --code-fg: #d9dee8; --code-comment: #6b7688; --code-key: #7fd1ff; --code-string: #9fe7c0; --code-number: #ffcf7a; --code-keyword: #d6a6ff; --code-punct: #8d97a8; --code-func: #9fd0ff;
10 19 }
11 −[data-theme="light"] { color-scheme: light; --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --line: #dfe3ea; --line-2: #c9cfd9; --fg: #14181f; --fg-1: #3d4654; --fg-2: #6b7482; --accent: #0f8f5a; --accent-2: #0b6fb8; }
12 −* { box-sizing: border-box; }
13 −html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: 15px; }
14 −body { margin: 0; }
20 +[data-theme="light"] {
21 + color-scheme: light;
22 + --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --bg-3: #e4e7ed;
23 + --line: #e2e5eb; --line-2: #cfd4dd; --line-3: #b4bcc9;
24 + --fg: #12161d; --fg-1: #3b4452; --fg-2: #66707f; --fg-3: #8b94a3;
25 + --accent: #0d8a55; --accent-ink: #ffffff; --accent-soft: rgba(13, 138, 85, 0.10);
26 + --accent-2: #0b6fb8; --accent-2-soft: rgba(11, 111, 184, 0.10);
27 + --danger: #d23b3b; --danger-soft: rgba(210, 59, 59, 0.10);
28 + --warn: #b7791f; --warn-soft: rgba(183, 121, 31, 0.12);
29 + --get: #0b6fb8; --post: #0d8a55; --delete: #d23b3b; --put: #b7791f; --patch: #7c3aed;
30 + --shadow: 0 1px 2px rgba(20, 24, 31, .06), 0 12px 40px -16px rgba(20, 24, 31, .22);
31 + --code-bg: #f4f6f9; --code-fg: #1d2430; --code-comment: #7a8494; --code-key: #0b5ea8; --code-string: #0d7a4a; --code-number: #a35a00; --code-keyword: #7c3aed; --code-punct: #5d6675; --code-func: #0b5ea8;
32 +}
33 +
34 +/* ---- base ---- */
35 +*, *::before, *::after { box-sizing: border-box; }
36 +html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: 15px; line-height: 1.55; text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--topbar-h) + 16px); }
37 +body { margin: 0; min-height: 100dvh; }
38 +h1, h2, h3, h4 { line-height: 1.2; letter-spacing: -0.015em; margin: 0 0 0.5em; font-weight: 650; }
39 +h1 { font-size: clamp(28px, 4vw, 40px); }
40 +h2 { font-size: clamp(22px, 2.6vw, 28px); }
41 +h3 { font-size: 18px; }
42 +h4 { font-size: 15px; }
43 +p { margin: 0 0 1em; }
15 44 a { color: var(--accent-2); text-decoration: none; }
16 −a:hover { text-decoration: underline; }
17 −code, pre, kbd, .mono { font-family: var(--mono); font-size: 0.92em; }
18 −pre { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; overflow: auto; }
45 +a:hover { text-decoration: underline; text-underline-offset: 3px; }
46 +a:focus-visible, button:focus-visible, [tabindex]:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
47 +code, pre, kbd, .mono { font-family: var(--mono); font-size: 0.9em; }
48 +code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; }
49 +pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; overflow: auto; line-height: 1.5; }
50 +pre code { background: none; border: 0; padding: 0; font-size: 13px; }
51 +kbd { background: var(--bg-2); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px; font-size: 12px; color: var(--fg-1); }
52 +hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; }
53 +img, svg { max-width: 100%; }
54 +button { font: inherit; }
19 55 .muted { color: var(--fg-2); }
56 +.small { font-size: 13px; }
57 +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
58 +.skip-link { position: absolute; left: 12px; top: -48px; z-index: 100; background: var(--accent); color: var(--accent-ink); padding: 8px 12px; border-radius: 6px; font-weight: 600; }
59 +.skip-link:focus { top: 12px; text-decoration: none; }
60 +
61 +/* ---- shell ---- */
20 62 .shell { min-height: 100dvh; display: flex; flex-direction: column; }
21 −.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 24px; padding: 0 20px; height: 56px; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
22 −.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--fg); font-weight: 600; }
23 −.brand-mark { font-family: var(--mono); background: var(--accent); color: #05140c; border-radius: 6px; padding: 2px 6px; font-size: 13px; }
24 −.topnav { display: flex; gap: 4px; flex: 1; }
25 −.topnav a { color: var(--fg-1); padding: 6px 10px; border-radius: 6px; }
63 +.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 20px; padding: 0 20px; height: var(--topbar-h); background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
64 +.brand { display: inline-flex; align-items: center; gap: 9px; color: var(--fg); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; }
65 +.brand:hover { text-decoration: none; }
66 +.brand-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, var(--accent-2))); color: var(--accent-ink); font-family: var(--mono); font-size: 12px; font-weight: 700; }
67 +.topnav { display: flex; gap: 2px; flex: 1; }
68 +.topnav a { color: var(--fg-1); padding: 6px 10px; border-radius: 6px; font-weight: 500; font-size: 14px; }
26 69 .topnav a.active, .topnav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
27 −.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; }
28 −.btn:hover { border-color: var(--fg-2); text-decoration: none; }
29 −.btn-primary { background: var(--accent); color: #05140c; border-color: var(--accent); font-weight: 600; }
30 −.btn-ghost { background: transparent; }
70 +.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
71 +.search-trigger { display: inline-flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px 0 12px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); cursor: pointer; font-size: 13px; min-width: 190px; }
72 +.search-trigger:hover { border-color: var(--line-3); color: var(--fg-1); }
73 +.search-trigger kbd { margin-left: auto; }
74 +.icon-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--fg-1); cursor: pointer; }
75 +.icon-btn:hover { background: var(--bg-2); color: var(--fg); }
76 +.icon-btn svg { width: 18px; height: 18px; }
77 +.menu-btn { display: none; }
78 +
79 +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; font-weight: 500; font-size: 14px; line-height: 1.2; white-space: nowrap; transition: border-color .12s, background .12s, transform .06s; }
80 +.btn:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); }
81 +.btn:active { transform: translateY(1px); }
82 +.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 650; }
83 +.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, white); border-color: color-mix(in srgb, var(--accent) 88%, white); }
84 +.btn-ghost { background: transparent; border-color: transparent; color: var(--fg-1); }
85 +.btn-ghost:hover { background: var(--bg-2); color: var(--fg); border-color: transparent; }
86 +.btn-lg { padding: 12px 20px; font-size: 15px; border-radius: 10px; }
87 +.btn-sm { padding: 5px 10px; font-size: 13px; border-radius: 6px; }
88 +.btn[disabled] { opacity: .55; cursor: not-allowed; }
89 +
31 90 .page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; }
32 −.page.narrow { max-width: 760px; }
91 +.page.narrow { max-width: 780px; }
33 92 .page-loading { flex: 1; min-height: 40vh; }
34 −.footer { border-top: 1px solid var(--line); padding: 24px 20px; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--fg-1); font-size: 13px; }
35 −.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; }
36 −table { border-collapse: collapse; width: 100%; }
37 −th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
38 −th { color: var(--fg-1); font-weight: 600; font-size: 13px; }
93 +.footer { border-top: 1px solid var(--line); padding: 28px 20px 36px; color: var(--fg-1); font-size: 13px; }
94 +.footer-inner { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: 24px; }
95 +.footer h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin-bottom: 10px; }
96 +.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
97 +.footer a { color: var(--fg-1); }
98 +.footer a:hover { color: var(--fg); }
99 +.footer-bottom { max-width: var(--max); margin: 24px auto 0; padding-top: 16px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--fg-2); font-size: 12.5px; }
100 +
101 +.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 20px; }
102 +.grid { display: grid; gap: 16px; }
103 +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
104 +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
105 +.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
106 +.eyebrow { font-size: 12px; font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; }
107 +.lead { font-size: 17px; color: var(--fg-1); max-width: 62ch; }
108 +
109 +table { border-collapse: collapse; width: 100%; font-size: 14px; }
110 +th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
111 +th { color: var(--fg-1); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; }
112 +tbody tr:hover { background: color-mix(in srgb, var(--bg-2) 60%, transparent); }
113 +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
114 +.table-wrap table { min-width: 520px; }
115 +.table-wrap th { background: var(--bg-1); position: sticky; top: 0; }
116 +.table-wrap tr:last-child td { border-bottom: 0; }
117 +.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 13px; }
118 +
39 119 input, select, textarea { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 6px; padding: 8px 10px; }
40 120 input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); border-color: var(--accent); }
41 −@media (max-width: 800px) { .topnav { display: none; } .topbar { gap: 12px; } }
121 +::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
122 +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
123 +
124 +/* ---- mobile drawer ---- */
125 +.drawer-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, .55); }
126 +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 61; width: min(320px, 86vw); background: var(--bg-1); border-left: 1px solid var(--line); padding: 16px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow); }
127 +.drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
128 +.drawer a.navlink { display: block; padding: 10px 12px; border-radius: 8px; color: var(--fg-1); font-weight: 500; }
129 +.drawer a.navlink.active, .drawer a.navlink:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; }
130 +.drawer-foot { margin-top: auto; display: grid; gap: 8px; }
131 +
132 +@media (max-width: 900px) {
133 + .topnav { display: none; }
134 + .menu-btn { display: inline-grid; }
135 + .search-trigger { min-width: 0; padding: 0 9px; }
136 + .search-trigger span, .search-trigger kbd { display: none; }
137 + .topbar { gap: 12px; padding: 0 14px; }
138 + .footer-inner { grid-template-columns: 1fr 1fr; }
139 + .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; }
140 + .page { padding: 28px 16px 64px; }
141 +}
142 +@media (max-width: 600px) {
143 + .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
144 + .footer-inner { grid-template-columns: 1fr; }
145 + .topbar-right .btn-signin { display: none; }
146 +}
added hfmarketdata/web/src/app/theme.js +41 −0
@@ -0,0 +1,41 @@
1 +// Theme (dark | light) persisted in localStorage under `hfmd.theme`. index.html applies it before first paint.
2 +import { useCallback, useEffect, useState } from 'react'
3 +
4 +const KEY = 'hfmd.theme'
5 +
6 +export function getTheme() {
7 + if (typeof document === 'undefined') return 'dark'
8 + return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
9 +}
10 +
11 +export function applyTheme(theme) {
12 + document.documentElement.setAttribute('data-theme', theme)
13 + try { localStorage.setItem(KEY, theme) } catch { /* private mode */ }
14 + window.dispatchEvent(new CustomEvent('hfmd:theme', { detail: theme }))
15 +}
16 +
17 +export function useTheme() {
18 + const [theme, setTheme] = useState(getTheme)
19 + useEffect(() => {
20 + const on = e => setTheme(e.detail)
21 + window.addEventListener('hfmd:theme', on)
22 + return () => window.removeEventListener('hfmd:theme', on)
23 + }, [])
24 + const toggle = useCallback(() => applyTheme(getTheme() === 'dark' ? 'light' : 'dark'), [])
25 + return { theme, toggle, set: applyTheme }
26 +}
27 +
28 +// Small persisted-state helper reused by LangSelector, docs panel, etc.
29 +export function usePersisted(key, initial) {
30 + const [v, setV] = useState(() => {
31 + try { const raw = localStorage.getItem(key); return raw == null ? initial : JSON.parse(raw) } catch { return initial }
32 + })
33 + const set = useCallback(next => {
34 + setV(prev => {
35 + const val = typeof next === 'function' ? next(prev) : next
36 + try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
37 + return val
38 + })
39 + }, [key])
40 + return [v, set]
41 +}
added hfmarketdata/web/src/components/Badge.jsx +17 −0
@@ -0,0 +1,17 @@
1 +// <Badge> generic pill · <MethodBadge method="GET"> coloured HTTP verb · <StatusBadge code={404}>.
2 +import React from 'react'
3 +
4 +export default function Badge({ tone = 'neutral', children, className = '', title }) {
5 + return <span className={`badge badge-${tone} ${className}`} title={title}>{children}</span>
6 +}
7 +
8 +export function MethodBadge({ method = 'GET', className = '' }) {
9 + const m = method.toUpperCase()
10 + return <span className={`method method-${m.toLowerCase()} ${className}`}>{m}</span>
11 +}
12 +
13 +export function StatusBadge({ code }) {
14 + const n = Number(code)
15 + const tone = n < 300 ? 'success' : n < 400 ? 'info' : n < 500 ? 'warn' : 'danger'
16 + return <Badge tone={tone} className="mono">{code}</Badge>
17 +}
added hfmarketdata/web/src/components/Callout.jsx +18 −0
@@ -0,0 +1,18 @@
1 +// <Callout type="info|tip|warning|danger" title?> — highlighted aside.
2 +import React from 'react'
3 +import { BoltIcon, InfoIcon, WarnIcon } from './Icons.jsx'
4 +
5 +const ICONS = { info: InfoIcon, tip: BoltIcon, warning: WarnIcon, danger: WarnIcon }
6 +
7 +export default function Callout({ type = 'info', title, children, className = '' }) {
8 + const Icon = ICONS[type] || InfoIcon
9 + return (
10 + <aside className={`callout callout-${type} ${className}`} role={type === 'danger' || type === 'warning' ? 'alert' : 'note'}>
11 + <span className="callout-icon"><Icon /></span>
12 + <div className="callout-body">
13 + {title && <div className="callout-title">{title}</div>}
14 + <div>{children}</div>
15 + </div>
16 + </aside>
17 + )
18 +}
added hfmarketdata/web/src/components/Code.jsx +110 −0
@@ -0,0 +1,110 @@
1 +// <Code> — syntax-highlighted block (prism-react-renderer, bundled grammars + tiny bash/R grammars) with a copy button.
2 +// Colors come from CSS variables so it follows the dark/light theme without a second stylesheet.
3 +import React, { useCallback, useEffect, useState } from 'react'
4 +import { Highlight, Prism } from 'prism-react-renderer'
5 +import { CheckIcon, CopyIcon } from './Icons.jsx'
6 +
7 +// prism-react-renderer ships no bash/R grammar — minimal ones, enough for API snippets.
8 +if (!Prism.languages.bash) {
9 + Prism.languages.bash = {
10 + comment: /#.*/,
11 + string: [{ pattern: /"(?:\\[\s\S]|\$\([^)]*\)|[^"\\])*"/, greedy: true, inside: { variable: /\$\{?[\w.]+\}?/ } }, { pattern: /'[^']*'/, greedy: true }],
12 + variable: /\$\{?[\w.]+\}?/,
13 + flag: { pattern: /(^|\s)--?[\w-]+/, lookbehind: true, alias: 'attr-name' },
14 + function: /\b(?:curl|jq|python3?|pip|npm|npx|node|export|echo|cat|head|wget|Rscript)\b/,
15 + operator: /\\$|\||&&|>/m,
16 + punctuation: /[{}()]/,
17 + }
18 + Prism.languages.sh = Prism.languages.shell = Prism.languages.bash
19 +}
20 +if (!Prism.languages.r) {
21 + Prism.languages.r = {
22 + comment: /#.*/,
23 + string: { pattern: /(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/, greedy: true },
24 + 'percent-operator': { pattern: /%[^%\s]*%/, alias: 'operator' },
25 + boolean: /\b(?:TRUE|FALSE|T|F)\b/,
26 + keyword: /\b(?:NA|NULL|Inf|NaN|if|else|for|while|repeat|function|return|in|next|break|library|require)\b/,
27 + number: /\b(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:e[+-]?\d+)?)[iL]?\b/i,
28 + function: /\b[a-zA-Z_.][\w.]*(?=\s*\()/,
29 + operator: /->?>?|<(?:=|<?-)?|[>=!]=?|::?|&&?|\|\|?|[+*\/^$@~]/,
30 + punctuation: /[(){}\[\],;]/,
31 + }
32 +}
33 +
34 +const THEME = {
35 + plain: { color: 'var(--code-fg)', backgroundColor: 'transparent' },
36 + styles: [
37 + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--code-comment)', fontStyle: 'italic' } },
38 + { types: ['punctuation', 'operator'], style: { color: 'var(--code-punct)' } },
39 + { types: ['property', 'attr-name', 'variable', 'flag'], style: { color: 'var(--code-key)' } },
40 + { types: ['string', 'char', 'url', 'inserted'], style: { color: 'var(--code-string)' } },
41 + { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--code-number)' } },
42 + { types: ['keyword', 'builtin', 'important', 'atrule', 'tag'], style: { color: 'var(--code-keyword)' } },
43 + { types: ['function', 'class-name', 'percent-operator'], style: { color: 'var(--code-func)' } },
44 + { types: ['deleted'], style: { color: 'var(--danger)' } },
45 + ],
46 +}
47 +
48 +export function useCopy(timeout = 1600) {
49 + const [copied, setCopied] = useState(false)
50 + const copy = useCallback(async text => {
51 + try {
52 + await navigator.clipboard.writeText(text)
53 + } catch {
54 + const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '')
55 + ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select()
56 + try { document.execCommand('copy') } finally { document.body.removeChild(ta) }
57 + }
58 + setCopied(true)
59 + }, [])
60 + useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), timeout); return () => clearTimeout(t) }, [copied, timeout])
61 + return { copied, copy }
62 +}
63 +
64 +export function CopyButton({ text, label = 'Copy to clipboard', className = '' }) {
65 + const { copied, copy } = useCopy()
66 + return (
67 + <button type="button" className={`copy-btn ${copied ? 'copied' : ''} ${className}`} onClick={() => copy(text)} aria-label={copied ? 'Copied' : label} title={label}>
68 + {copied ? <CheckIcon /> : <CopyIcon />}<span>{copied ? 'Copied' : 'Copy'}</span>
69 + </button>
70 + )
71 +}
72 +
73 +/**
74 + * Code block.
75 + * @param {string} code source text
76 + * @param {string} language bash | python | javascript | r | json | http | text …
77 + * @param {string} [title] optional header label (e.g. file name / "Response")
78 + * @param {boolean} [copy=true]
79 + * @param {number} [maxHeight] px — scrolls inside when longer
80 + * @param {boolean} [lineNumbers=false]
81 + */
82 +export default function Code({ code = '', language = 'text', title, copy = true, maxHeight, lineNumbers = false, className = '', children }) {
83 + const src = (code || children || '').toString().replace(/\n$/, '')
84 + const lang = Prism.languages[language] ? language : 'text'
85 + return (
86 + <div className={`code ${className}`} data-language={language}>
87 + {(title || copy) && (
88 + <div className="code-head">
89 + <span className="code-title">{title || language}</span>
90 + {copy && <CopyButton text={src} />}
91 + </div>
92 + )}
93 + <Highlight code={src} language={lang} theme={THEME}>
94 + {({ className: cls, tokens, getLineProps, getTokenProps }) => (
95 + <pre className={`${cls} code-pre`} style={maxHeight ? { maxHeight } : undefined} tabIndex={0}>
96 + <code>
97 + {tokens.map((line, i) => (
98 + <span key={i} {...getLineProps({ line })} className="code-line">
99 + {lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>}
100 + {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)}
101 + {'\n'}
102 + </span>
103 + ))}
104 + </code>
105 + </pre>
106 + )}
107 + </Highlight>
108 + </div>
109 + )
110 +}
added hfmarketdata/web/src/components/CodeTabs.jsx +26 −0
@@ -0,0 +1,26 @@
1 +// <CodeTabs> — one code block per language, driven by the global persisted language (LangSelector).
2 +// snippets: { curl, python, javascript, r } (any subset) or an array [{ id, label, language, code }].
3 +import React from 'react'
4 +import Code from './Code.jsx'
5 +import LangSelector, { LANGS, useLang } from './LangSelector.jsx'
6 +
7 +export default function CodeTabs({ snippets, title, maxHeight, className = '' }) {
8 + const [lang, setLang] = useLang()
9 + const items = Array.isArray(snippets)
10 + ? snippets
11 + : LANGS.filter(l => snippets?.[l.id] != null).map(l => ({ id: l.id, label: l.label, language: l.prism, code: snippets[l.id] }))
12 + if (!items.length) return null
13 + const active = items.find(s => s.id === lang) || items[0]
14 + const value = items.some(s => s.id === lang) ? lang : active.id
15 + return (
16 + <div className={`codetabs ${className}`}>
17 + <div className="codetabs-head">
18 + {title && <span className="code-title">{title}</span>}
19 + {Array.isArray(snippets)
20 + ? <div className="lang-selector lang-sm" role="radiogroup" aria-label="Code sample">{items.map(s => <button key={s.id} type="button" role="radio" aria-checked={s.id === value} className={`lang-btn ${s.id === value ? 'active' : ''}`} onClick={() => setLang(s.id)}>{s.label}</button>)}</div>
21 + : <LangSelector value={value} onChange={setLang} />}
22 + </div>
23 + <Code code={active.code} language={active.language} maxHeight={maxHeight} title={null} />
24 + </div>
25 + )
26 +}
added hfmarketdata/web/src/components/Icons.jsx +30 −0
@@ -0,0 +1,30 @@
1 +// Inline SVG icons (no icon font, no external request). All 24×24, stroke-based, currentColor.
2 +import React from 'react'
3 +
4 +const I = ({ children, ...p }) => (
5 + <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>{children}</svg>
6 +)
7 +
8 +export const SunIcon = p => <I {...p}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></I>
9 +export const MoonIcon = p => <I {...p}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></I>
10 +export const SearchIcon = p => <I {...p}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></I>
11 +export const MenuIcon = p => <I {...p}><path d="M4 7h16M4 12h16M4 17h16" /></I>
12 +export const CloseIcon = p => <I {...p}><path d="M6 6l12 12M18 6 6 18" /></I>
13 +export const CopyIcon = p => <I {...p}><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V6a2 2 0 0 1 2-2h9" /></I>
14 +export const CheckIcon = p => <I {...p}><path d="m5 12 5 5L20 7" /></I>
15 +export const ArrowRightIcon = p => <I {...p}><path d="M5 12h14M13 6l6 6-6 6" /></I>
16 +export const ExternalIcon = p => <I {...p}><path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></I>
17 +export const PlayIcon = p => <I {...p}><path d="M7 5v14l11-7z" fill="currentColor" stroke="none" /></I>
18 +export const InfoIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></I>
19 +export const WarnIcon = p => <I {...p}><path d="M12 3 2.5 20h19L12 3zM12 10v4M12 17.5h.01" /></I>
20 +export const BoltIcon = p => <I {...p}><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" /></I>
21 +export const ChevronIcon = p => <I {...p}><path d="m9 6 6 6-6 6" /></I>
22 +export const ChevronDownIcon = p => <I {...p}><path d="m6 9 6 6 6-6" /></I>
23 +export const KeyIcon = p => <I {...p}><circle cx="8" cy="15" r="4" /><path d="m10.9 12.1 8.6-8.6M15 6l3 3M18 3l3 3" /></I>
24 +export const MailIcon = p => <I {...p}><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" /></I>
25 +export const DatabaseIcon = p => <I {...p}><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" /></I>
26 +export const BookIcon = p => <I {...p}><path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5zM4 19a2 2 0 0 0 2 2h13" /></I>
27 +export const TerminalIcon = p => <I {...p}><path d="m5 7 5 5-5 5M12 17h7" /></I>
28 +export const EditIcon = p => <I {...p}><path d="M4 20h4l10.5-10.5a2 2 0 0 0 0-2.8l-1.2-1.2a2 2 0 0 0-2.8 0L4 16v4z" /></I>
29 +export const GitIcon = p => <I {...p}><circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="8" r="2.5" /><path d="M6 8.5v7M18 10.5c0 3-3 4-6 4.5s-6 1-6 1" /></I>
30 +export const PulseIcon = p => <I {...p}><path d="M3 12h4l3-7 4 14 3-7h4" /></I>
added hfmarketdata/web/src/components/LangSelector.jsx +32 −0
@@ -0,0 +1,32 @@
1 +// Language choice for code samples — one global, persisted value (`hfmd.lang`) shared by docs, guides and playground.
2 +import React, { createContext, useContext } from 'react'
3 +import { usePersisted } from '../app/theme.js'
4 +
5 +export const LANGS = [
6 + { id: 'curl', label: 'curl', prism: 'bash' },
7 + { id: 'python', label: 'Python', prism: 'python' },
8 + { id: 'javascript', label: 'JavaScript', prism: 'javascript' },
9 + { id: 'r', label: 'R', prism: 'r' },
10 +]
11 +
12 +const Ctx = createContext(['curl', () => {}])
13 +
14 +export function LangProvider({ children }) {
15 + const state = usePersisted('hfmd.lang', 'curl')
16 + return <Ctx.Provider value={state}>{children}</Ctx.Provider>
17 +}
18 +
19 +export const useLang = () => useContext(Ctx)
20 +
21 +export default function LangSelector({ value, onChange, size = 'sm', className = '', ariaLabel = 'Code language' }) {
22 + const [ctxLang, setCtxLang] = useLang()
23 + const lang = value ?? ctxLang
24 + const set = onChange ?? setCtxLang
25 + return (
26 + <div className={`lang-selector lang-${size} ${className}`} role="radiogroup" aria-label={ariaLabel}>
27 + {LANGS.map(l => (
28 + <button key={l.id} type="button" role="radio" aria-checked={lang === l.id} className={`lang-btn ${lang === l.id ? 'active' : ''}`} onClick={() => set(l.id)}>{l.label}</button>
29 + ))}
30 + </div>
31 + )
32 +}
added hfmarketdata/web/src/components/Markdown.jsx +136 −0
@@ -0,0 +1,136 @@
1 +// <Markdown> — small, safe Markdown → React renderer for OpenAPI descriptions (no innerHTML, no raw HTML passthrough).
2 +// Supports: ATX headings, paragraphs, bullet/numbered lists (one nesting level), fenced code, blockquotes,
3 +// GFM tables, horizontal rules; inline code, **bold**, *italic*, [links](href), autolinks. Unsafe URL schemes are dropped.
4 +import React from 'react'
5 +import { Link } from 'react-router-dom'
6 +import Code from './Code.jsx'
7 +
8 +const SAFE_HREF = /^(https?:|mailto:|\/|#|\.\.?\/)/i
9 +
10 +export function InlineMd({ text }) {
11 + return <>{renderInline(text || '')}</>
12 +}
13 +
14 +function renderInline(text, keyBase = 'i') {
15 + const out = []
16 + let rest = text
17 + let k = 0
18 + // token regex: code, bold, italic, link, autolink
19 + const re = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*?)\*|_([^_\s][^_]*?)_|\[([^\]]+)\]\(([^)\s]+)\)|<?(https?:\/\/[^\s<>)]+)>?/
20 + while (rest.length) {
21 + const m = re.exec(rest)
22 + if (!m) { out.push(rest); break }
23 + if (m.index > 0) out.push(rest.slice(0, m.index))
24 + const key = `${keyBase}${k++}`
25 + if (m[2] !== undefined) out.push(<code key={key}>{m[2]}</code>)
26 + else if (m[3] !== undefined || m[4] !== undefined) out.push(<strong key={key}>{renderInline(m[3] ?? m[4], key)}</strong>)
27 + else if (m[5] !== undefined || m[6] !== undefined) out.push(<em key={key}>{renderInline(m[5] ?? m[6], key)}</em>)
28 + else if (m[7] !== undefined) out.push(<MdLink key={key} href={m[8]}>{renderInline(m[7], key)}</MdLink>)
29 + else if (m[9] !== undefined) out.push(<MdLink key={key} href={m[9]}>{m[9]}</MdLink>)
30 + rest = rest.slice(m.index + m[0].length)
31 + }
32 + return out
33 +}
34 +
35 +export function MdLink({ href, children }) {
36 + if (!SAFE_HREF.test(href)) return <span>{children}</span>
37 + const site = /^https?:\/\/(www\.)?hfmarketdata\.io/i
38 + let h = href
39 + if (site.test(h)) h = h.replace(site, '') || '/'
40 + if (h.startsWith('/') && !h.startsWith('//')) return <Link to={h}>{children}</Link>
41 + const external = /^https?:/i.test(h)
42 + return <a href={h} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined}>{children}</a>
43 +}
44 +
45 +function slug(s) { return s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') }
46 +
47 +export default function Markdown({ children, text, className = '', headingOffset = 0 }) {
48 + const src = (text ?? children ?? '').toString().replace(/\r\n/g, '\n')
49 + const lines = src.split('\n')
50 + const blocks = []
51 + let i = 0
52 + const para = []
53 + const flushPara = () => { if (para.length) { blocks.push(<p key={blocks.length}>{renderInline(para.join(' '), `p${blocks.length}`)}</p>); para.length = 0 } }
54 + while (i < lines.length) {
55 + const line = lines[i]
56 + if (/^\s*$/.test(line)) { flushPara(); i++; continue }
57 + const fence = /^\s*(```|~~~)\s*(\w+)?/.exec(line)
58 + if (fence) {
59 + flushPara()
60 + const body = []; i++
61 + while (i < lines.length && !lines[i].startsWith(fence[1])) body.push(lines[i++])
62 + i++
63 + blocks.push(<Code key={blocks.length} code={body.join('\n')} language={fence[2] || 'text'} copy={body.length > 1} />)
64 + continue
65 + }
66 + const h = /^(#{1,6})\s+(.*)$/.exec(line)
67 + if (h) {
68 + flushPara()
69 + const level = Math.min(6, h[1].length + headingOffset)
70 + const Tag = `h${level}`
71 + blocks.push(<Tag key={blocks.length} id={slug(h[2])}>{renderInline(h[2], `h${blocks.length}`)}</Tag>)
72 + i++; continue
73 + }
74 + if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flushPara(); blocks.push(<hr key={blocks.length} />); i++; continue }
75 + if (/^\s*>/.test(line)) {
76 + flushPara()
77 + const q = []
78 + while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ''))
79 + blocks.push(<blockquote key={blocks.length}><Markdown text={q.join('\n')} /></blockquote>)
80 + continue
81 + }
82 + if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
83 + flushPara()
84 + const cells = l => l.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim())
85 + const head = cells(line)
86 + const aligns = cells(lines[i + 1]).map(c => (c.endsWith(':') && c.startsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
87 + i += 2
88 + const rows = []
89 + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
90 + blocks.push(
91 + <div className="table-wrap" key={blocks.length}>
92 + <table>
93 + <thead><tr>{head.map((c, j) => <th key={j} style={{ textAlign: aligns[j] }}>{renderInline(c, `th${j}`)}</th>)}</tr></thead>
94 + <tbody>{rows.map((r, ri) => <tr key={ri}>{r.map((c, j) => <td key={j} style={{ textAlign: aligns[j] }}>{renderInline(c, `td${ri}-${j}`)}</td>)}</tr>)}</tbody>
95 + </table>
96 + </div>,
97 + )
98 + continue
99 + }
100 + const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
101 + if (li) {
102 + flushPara()
103 + const ordered = /\d/.test(li[2])
104 + const items = []
105 + while (i < lines.length) {
106 + const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i])
107 + if (!m) break
108 + const indent = m[1].length
109 + if (indent > li[1].length && items.length) {
110 + const sub = []
111 + while (i < lines.length) {
112 + const s = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i])
113 + if (!s || s[1].length <= li[1].length) break
114 + sub.push(s[3]); i++
115 + }
116 + items[items.length - 1].sub = sub
117 + continue
118 + }
119 + items.push({ text: m[3] }); i++
120 + while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i])) items[items.length - 1].text += ' ' + lines[i++].trim()
121 + }
122 + const Tag = ordered ? 'ol' : 'ul'
123 + blocks.push(
124 + <Tag key={blocks.length}>
125 + {items.map((it, j) => (
126 + <li key={j}>{renderInline(it.text, `li${j}`)}{it.sub && <ul>{it.sub.map((s, k) => <li key={k}>{renderInline(s, `sub${j}-${k}`)}</li>)}</ul>}</li>
127 + ))}
128 + </Tag>,
129 + )
130 + continue
131 + }
132 + para.push(line.trim()); i++
133 + }
134 + flushPara()
135 + return <div className={`md ${className}`}>{blocks}</div>
136 +}
added hfmarketdata/web/src/components/SearchDialog.jsx +98 −0
@@ -0,0 +1,98 @@
1 +// <SearchDialog> — Cmd/Ctrl+K instant search over operations, guides and headings. Keyboard: ↑ ↓ Enter Esc.
2 +// Index is built lazily from the (cached) OpenAPI spec + guides registry. Focus is trapped while open.
3 +import React, { useEffect, useMemo, useRef, useState } from 'react'
4 +import { useNavigate } from 'react-router-dom'
5 +import { buildIndex, groupResults, search } from '../docs/search.js'
6 +import { loadSpec } from '../docs/spec.js'
7 +import { MethodBadge } from './Badge.jsx'
8 +import { BookIcon, ChevronIcon, SearchIcon } from './Icons.jsx'
9 +
10 +let indexMemo = null
11 +async function getIndex() {
12 + if (indexMemo) return indexMemo
13 + const { spec } = await loadSpec()
14 + indexMemo = buildIndex(spec)
15 + return indexMemo
16 +}
17 +
18 +export default function SearchDialog({ open, onClose }) {
19 + const [q, setQ] = useState('')
20 + const [items, setItems] = useState(indexMemo || [])
21 + const [sel, setSel] = useState(0)
22 + const inputRef = useRef(null)
23 + const listRef = useRef(null)
24 + const navigate = useNavigate()
25 +
26 + useEffect(() => {
27 + if (!open) return
28 + setQ(''); setSel(0)
29 + getIndex().then(setItems)
30 + const t = setTimeout(() => inputRef.current?.focus(), 10)
31 + const prev = document.body.style.overflow
32 + document.body.style.overflow = 'hidden'
33 + return () => { clearTimeout(t); document.body.style.overflow = prev }
34 + }, [open])
35 +
36 + const results = useMemo(() => search(items, q), [items, q])
37 + const groups = useMemo(() => groupResults(results), [results])
38 + useEffect(() => { setSel(0) }, [q])
39 + useEffect(() => {
40 + const el = listRef.current?.querySelector('[aria-selected="true"]')
41 + el?.scrollIntoView({ block: 'nearest' })
42 + }, [sel])
43 +
44 + const go = item => { if (!item) return; onClose(); navigate(item.href) }
45 +
46 + const onKey = e => {
47 + if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(results.length - 1, s + 1)) }
48 + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)) }
49 + else if (e.key === 'Enter') { e.preventDefault(); go(results[sel]) }
50 + else if (e.key === 'Escape') { e.preventDefault(); onClose() }
51 + else if (e.key === 'Tab') { e.preventDefault() } // keep focus in the input
52 + }
53 +
54 + if (!open) return null
55 + let flat = -1
56 + return (
57 + <div className="search-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
58 + <div className="search-dialog" role="dialog" aria-modal="true" aria-label="Search documentation" data-testid="search-dialog">
59 + <div className="search-input-row">
60 + <SearchIcon />
61 + <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} placeholder="Search endpoints, guides, error codes…" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="search-results" aria-activedescendant={results[sel] ? `sr-${sel}` : undefined} autoComplete="off" spellCheck={false} />
62 + <kbd>esc</kbd>
63 + </div>
64 + <div className="search-results" id="search-results" role="listbox" ref={listRef}>
65 + {results.length === 0 && <div className="search-empty">No results for “{q}”. Try an endpoint path, a ticker concept or an error code.</div>}
66 + {groups.map(g => (
67 + <div key={g.name}>
68 + <div className="search-group">{g.name}</div>
69 + {g.items.map(it => {
70 + flat += 1
71 + const idx = flat
72 + return (
73 + <div key={it.href + it.title} id={`sr-${idx}`} role="option" aria-selected={idx === sel} className="search-item" onMouseEnter={() => setSel(idx)} onMouseDown={e => e.preventDefault()} onClick={() => go(it)}>
74 + {it.method ? <MethodBadge method={it.method} /> : <span className="search-item-tag"><BookIcon /></span>}
75 + <div className="search-item-text">
76 + <div className="search-item-title">{highlight(it.title, q)}</div>
77 + {it.sub && <div className="search-item-sub">{it.sub}</div>}
78 + </div>
79 + <span className="search-item-tag"><ChevronIcon /></span>
80 + </div>
81 + )
82 + })}
83 + </div>
84 + ))}
85 + </div>
86 + <div className="search-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></div>
87 + </div>
88 + </div>
89 + )
90 +}
91 +
92 +function highlight(text, q) {
93 + const t = q.trim().split(/\s+/)[0]
94 + if (!t) return text
95 + const i = text.toLowerCase().indexOf(t.toLowerCase())
96 + if (i < 0) return text
97 + return <>{text.slice(0, i)}<mark>{text.slice(i, i + t.length)}</mark>{text.slice(i + t.length)}</>
98 +}
added hfmarketdata/web/src/components/Table.jsx +23 −0
@@ -0,0 +1,23 @@
1 +// <Table columns rows> — responsive data table. columns: [{ key, label, align?: 'right', render?(row) }].
2 +import React from 'react'
3 +
4 +export default function Table({ columns, rows, caption, keyField, dense = false, className = '', emptyText = 'No rows' }) {
5 + return (
6 + <div className={`table-wrap ${dense ? 'dense' : ''} ${className}`}>
7 + <table>
8 + {caption && <caption className="sr-only">{caption}</caption>}
9 + <thead>
10 + <tr>{columns.map(c => <th key={c.key} scope="col" className={c.align === 'right' ? 'num' : ''} style={c.width ? { width: c.width } : undefined}>{c.label}</th>)}</tr>
11 + </thead>
12 + <tbody>
13 + {rows.length === 0 && <tr><td colSpan={columns.length} className="muted">{emptyText}</td></tr>}
14 + {rows.map((r, i) => (
15 + <tr key={keyField ? r[keyField] : i}>
16 + {columns.map(c => <td key={c.key} className={c.align === 'right' ? 'num' : ''}>{c.render ? c.render(r) : r[c.key]}</td>)}
17 + </tr>
18 + ))}
19 + </tbody>
20 + </table>
21 + </div>
22 + )
23 +}
added hfmarketdata/web/src/components/Tabs.jsx +40 −0
@@ -0,0 +1,40 @@
1 +// <Tabs> — accessible tab list (roving focus, arrow keys). Controlled or uncontrolled.
2 +// items: [{ id, label, content?: ReactNode }] · value/onChange optional · renders children(activeId) when no content.
3 +import React, { useId, useRef, useState } from 'react'
4 +
5 +export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, ariaLabel = 'Tabs' }) {
6 + const [inner, setInner] = useState(defaultValue ?? items[0]?.id)
7 + const active = value ?? inner
8 + const set = id => { setInner(id); onChange?.(id) }
9 + const base = useId()
10 + const refs = useRef([])
11 + const onKey = (e, i) => {
12 + const n = items.length
13 + let j = null
14 + if (e.key === 'ArrowRight') j = (i + 1) % n
15 + else if (e.key === 'ArrowLeft') j = (i - 1 + n) % n
16 + else if (e.key === 'Home') j = 0
17 + else if (e.key === 'End') j = n - 1
18 + if (j == null) return
19 + e.preventDefault(); set(items[j].id); refs.current[j]?.focus()
20 + }
21 + const current = items.find(t => t.id === active) || items[0]
22 + return (
23 + <div className={`tabs tabs-${size} ${className}`}>
24 + <div role="tablist" aria-label={ariaLabel} className="tablist">
25 + {items.map((t, i) => (
26 + <button key={t.id} ref={el => (refs.current[i] = el)} role="tab" type="button" id={`${base}-tab-${t.id}`}
27 + aria-selected={t.id === active} aria-controls={`${base}-panel-${t.id}`} tabIndex={t.id === active ? 0 : -1}
28 + className={`tab ${t.id === active ? 'active' : ''}`} onClick={() => set(t.id)} onKeyDown={e => onKey(e, i)}>
29 + {t.icon}{t.label}
30 + </button>
31 + ))}
32 + </div>
33 + {current && (
34 + <div role="tabpanel" id={`${base}-panel-${current.id}`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel">
35 + {current.content ?? (typeof children === 'function' ? children(current.id) : children)}
36 + </div>
37 + )}
38 + </div>
39 + )
40 +}
added hfmarketdata/web/src/components/components.css +80 −0
@@ -0,0 +1,80 @@
1 +/* Shared components: Code, Tabs, Callout, Badge, LangSelector, SearchDialog, Markdown prose. Owner: web-core. */
2 +
3 +/* Code */
4 +.code { border: 1px solid var(--line); border-radius: var(--radius); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; }
5 +.code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 34px; }
6 +.code-title { font-family: var(--mono); font-size: 12px; color: var(--fg-2); letter-spacing: .02em; }
7 +.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: 13px; line-height: 1.55; tab-size: 2; }
8 +.code-line { display: block; white-space: pre; }
9 +.code-ln { display: inline-block; width: 2.4em; color: var(--fg-3); user-select: none; text-align: right; padding-right: 1.2em; }
10 +.copy-btn { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 6px; border: 1px solid transparent; background: transparent; color: var(--fg-2); cursor: pointer; font-size: 12px; }
11 +.copy-btn:hover { background: var(--bg-2); color: var(--fg); border-color: var(--line-2); }
12 +.copy-btn.copied { color: var(--accent); }
13 +.copy-btn svg { width: 14px; height: 14px; }
14 +
15 +/* Tabs */
16 +.tablist { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
17 +.tablist::-webkit-scrollbar { display: none; }
18 +.tab { position: relative; padding: 9px 12px; border: 0; background: transparent; color: var(--fg-2); cursor: pointer; font-weight: 500; font-size: 14px; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
19 +.tab:hover { color: var(--fg); }
20 +.tab.active { color: var(--fg); }
21 +.tab.active::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; }
22 +.tabs-sm .tab { padding: 6px 10px; font-size: 13px; }
23 +.tabpanel { padding-top: 14px; }
24 +
25 +/* Language selector */
26 +.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: 7px; background: var(--bg-2); border: 1px solid var(--line); }
27 +.lang-btn { border: 0; background: transparent; color: var(--fg-2); font-size: 12.5px; padding: 3px 9px; border-radius: 5px; cursor: pointer; font-weight: 500; }
28 +.lang-btn:hover { color: var(--fg); }
29 +.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .25); }
30 +.codetabs { margin: 0 0 16px; }
31 +.codetabs-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
32 +.codetabs .code { margin-bottom: 0; }
33 +
34 +/* Callout */
35 +.callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg-1); margin: 0 0 18px; border-left-width: 3px; }
36 +.callout-icon { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; margin-top: 1px; }
37 +.callout-icon svg { width: 18px; height: 18px; }
38 +.callout-title { font-weight: 650; margin-bottom: 3px; }
39 +.callout-body p:last-child { margin-bottom: 0; }
40 +.callout-info { border-left-color: var(--accent-2); } .callout-info .callout-icon { color: var(--accent-2); }
41 +.callout-tip { border-left-color: var(--accent); } .callout-tip .callout-icon { color: var(--accent); }
42 +.callout-warning { border-left-color: var(--warn); background: var(--warn-soft); } .callout-warning .callout-icon { color: var(--warn); }
43 +.callout-danger { border-left-color: var(--danger); background: var(--danger-soft); } .callout-danger .callout-icon { color: var(--danger); }
44 +
45 +/* Badges */
46 +.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-2); line-height: 1.6; white-space: nowrap; }
47 +.badge-success { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); }
48 +.badge-info { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: var(--accent-2-soft); }
49 +.badge-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); }
50 +.badge-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: var(--danger-soft); }
51 +.method { display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: .04em; padding: 2px 7px; border-radius: 5px; color: #fff; line-height: 1.4; min-width: 44px; text-align: center; }
52 +.method-get { background: var(--get); } .method-post { background: var(--post); color: var(--accent-ink); } .method-delete { background: var(--delete); } .method-put { background: var(--put); color: #1a1200; } .method-patch { background: var(--patch); }
53 +[data-theme="light"] .method-post { color: #fff; }
54 +
55 +/* Search dialog */
56 +.search-backdrop { position: fixed; inset: 0; z-index: 80; background: rgba(0, 0, 0, .55); display: flex; justify-content: center; align-items: flex-start; padding: 12vh 16px 16px; }
57 +.search-dialog { width: min(640px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
58 +.search-input-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); }
59 +.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; }
60 +.search-input-row input:focus { outline: none; }
61 +.search-results { max-height: 52vh; overflow-y: auto; padding: 6px; }
62 +.search-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; }
63 +.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; color: var(--fg); cursor: pointer; }
64 +.search-item[aria-selected="true"] { background: var(--bg-2); }
65 +.search-item .method { flex: none; }
66 +.search-item-text { min-width: 0; flex: 1; }
67 +.search-item-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68 +.search-item-sub { color: var(--fg-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
69 +.search-item-tag { color: var(--fg-3); font-size: 12px; flex: none; }
70 +.search-empty { padding: 28px 12px; text-align: center; color: var(--fg-2); }
71 +.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: 12px; }
72 +mark { background: var(--accent-soft); color: inherit; border-radius: 2px; }
73 +
74 +/* Markdown prose */
75 +.md > :first-child { margin-top: 0; }
76 +.md p, .md ul, .md ol { margin: 0 0 12px; }
77 +.md li + li { margin-top: 4px; }
78 +.md blockquote { margin: 0 0 14px; padding: 8px 14px; border-left: 3px solid var(--line-3); color: var(--fg-1); background: var(--bg-1); border-radius: 0 6px 6px 0; }
79 +.md .table-wrap { margin-bottom: 16px; }
80 +.md h1, .md h2, .md h3, .md h4 { margin-top: 1.6em; }
added hfmarketdata/web/src/components/index.js +10 −0
@@ -0,0 +1,10 @@
1 +// Shared component barrel — see hfmarketdata/web/README.md for the API of each component.
2 +export { default as Code, CopyButton, useCopy } from './Code.jsx'
3 +export { default as CodeTabs } from './CodeTabs.jsx'
4 +export { default as Tabs } from './Tabs.jsx'
5 +export { default as Callout } from './Callout.jsx'
6 +export { default as Table } from './Table.jsx'
7 +export { default as Badge, MethodBadge, StatusBadge } from './Badge.jsx'
8 +export { default as LangSelector, LangProvider, useLang, LANGS } from './LangSelector.jsx'
9 +export { default as Markdown, InlineMd } from './Markdown.jsx'
10 +export * from './Icons.jsx'
added hfmarketdata/web/src/docs/enrich.js +94 −0
@@ -0,0 +1,94 @@
1 +// Realistic example values for any operation, derived from the spec (defaults, enums, examples) plus a
2 +// name-based dictionary so v2 endpoints get sensible values before the backend ships `example`s.
3 +// Never an API key here — public examples use env variables ($HFMD_API_KEY).
4 +import { PUBLIC_BASE } from '../app/api.js'
5 +
6 +// Per-parameter-name defaults (checked after the spec's own example/default when the param is required).
7 +export const NAME_DEFAULTS = {
8 + asset: 'stock', ticker: 'AAPL', tickers: 'AAPL,MSFT,NVDA', root: 'ES', symbol: 'ESZ25', concept: 'Assets',
9 + timeframe: '1day', interval: '1d', adjustment: '', start: '2024-06-03', end: '2024-06-07', from: '2024-01-01', to: '2024-06-30',
10 + as_of: '2024-06-03', at: '2024-06-03 10:35:00', trade_date: '2024-06-21', expiry: '2024-12-20', strike: '200', call_put: 'c',
11 + quarter: '2024_q4', year: '2024', roll: 'volume', adjust: 'back_adjusted', depth: '1', session: 'all', format: 'json',
12 + limit: '5', order: 'asc', search: 'AAP', period: 'quarterly', fiscal_year: '2024', fiscal_period: 'Q2', statement: 'balance_sheet',
13 + cik: '320193', form: '10-Q', frequency: 'quarterly', unit: 'USD', range: '24h', min_volume: '', strike_min: '', strike_max: '',
14 + cursor: '', api_key: '', email: 'you@example.com', name: 'default',
15 +}
16 +
17 +// Endpoint-specific overrides keyed by "METHOD /path" (path as written in the spec).
18 +export const PATH_OVERRIDES = {
19 + 'GET /v1/bars/{asset}/{ticker}': { asset: 'stock', ticker: 'AAPL', timeframe: '1day', start: '2024-06-03', limit: '5' },
20 + 'GET /v1/bars/{asset}': { asset: 'stock', tickers: 'AAPL,MSFT,NVDA', timeframe: '1min', start: '2024-06-03 09:30:00', end: '2024-06-03 09:35:00', limit: '3' },
21 + 'GET /v1/snapshot/{asset}': { asset: 'stock', tickers: 'AAPL,MSFT,NVDA', at: '2024-06-03 10:35:00', timeframe: '1min' },
22 + 'GET /v1/{asset}/tickers': { asset: 'stock', timeframe: '1day', search: 'AAP', limit: '10' },
23 + 'GET /v1/options/chain/{ticker}': { ticker: 'AAPL', trade_date: '2024-06-21', call_put: 'c', strike_min: '200', strike_max: '210', limit: '5' },
24 + 'GET /v1/options/expirations/{ticker}': { ticker: 'AAPL', trade_date: '2024-06-21' },
25 + 'GET /v1/options/history/{ticker}': { ticker: 'AAPL', strike: '200', expiry: '2024-12-20', call_put: 'c', limit: '5' },
26 + 'GET /v1/options/tickers': { search: 'TSL', limit: '10' },
27 + 'GET /v1/futures/{root}/contracts': { root: 'ES' },
28 + 'GET /v1/futures/contract/{symbol}/bars': { symbol: 'ESZ25', interval: '1d', from: '2025-06-01', to: '2025-06-30', limit: '5' },
29 + 'GET /v1/futures/contract/{symbol}/coverage': { symbol: 'ESZ25' },
30 + 'GET /v1/futures/{root}/chain': { root: 'CL', as_of: '2024-06-03' },
31 + 'GET /v1/futures/{root}/continuous': { root: 'ES', roll: 'volume', adjust: 'back_adjusted', depth: '1', interval: '1d', from: '2024-01-01', to: '2024-03-31', limit: '5' },
32 + 'GET /v1/futures/{root}/term-structure': { root: 'CL', as_of: '2024-06-03' },
33 + 'GET /v1/fundamentals/{ticker}/statements': { ticker: 'AAPL', statement: 'balance_sheet', period: 'quarterly', from: '2024-01-01', to: '2024-06-30' },
34 + 'GET /v1/fundamentals/{ticker}/facts/{concept}': { ticker: 'AAPL', concept: 'Assets', from: '2023-01-01' },
35 + 'GET /v1/fundamentals/{ticker}/ratios': { ticker: 'AAPL', period: 'quarterly', from: '2024-01-01' },
36 + 'GET /v1/fundamentals/{ticker}/ratios/daily': { ticker: 'AAPL', from: '2024-01-01', to: '2024-06-30', limit: '5' },
37 + 'GET /v1/fundamentals/{ticker}/filings': { ticker: 'AAPL', form: '10-Q', from: '2024-01-01' },
38 + 'GET /v1/fundamentals/{ticker}/coverage': { ticker: 'AAPL' },
39 + 'GET /v1/fundamentals/screener': { limit: '10' },
40 + 'GET /v1/fundamentals/frames/{concept}': { concept: 'Assets', fiscal_year: '2024', fiscal_period: 'Q2' },
41 + 'GET /v1/bulk/fundamentals/{year}.parquet': { year: '2024' },
42 +}
43 +
44 +// Params we always show when present (even if optional) so examples look like real calls.
45 +const SHOW_OPTIONAL = new Set(['timeframe', 'interval', 'start', 'end', 'from', 'to', 'limit', 'as_of', 'roll', 'adjust', 'trade_date', 'search', 'period', 'statement', 'form', 'fiscal_year', 'fiscal_period', 'call_put', 'strike_min', 'strike_max', 'at', 'tickers', 'depth'])
46 +
47 +export function exampleParams(op) {
48 + const over = PATH_OVERRIDES[`${op.method} ${op.path}`] || {}
49 + const values = {}
50 + for (const p of op.parameters) {
51 + if (p.in === 'header' || p.in === 'cookie') continue
52 + if (p.name === 'api_key') continue
53 + let v = over[p.name]
54 + if (v === undefined && p.example !== undefined) v = p.example
55 + if (v === undefined && (p.required || SHOW_OPTIONAL.has(p.name))) {
56 + if (p.enum && p.enum.length && !(p.name in NAME_DEFAULTS)) v = p.default ?? p.enum[0]
57 + else v = NAME_DEFAULTS[p.name] ?? p.default ?? (p.enum ? p.enum[0] : p.type === 'integer' || p.type === 'number' ? 1 : `<${p.name}>`)
58 + }
59 + if (v === undefined || v === '' || v === null) continue
60 + values[p.name] = String(v)
61 + }
62 + return values
63 +}
64 +
65 +/** Build the request URL from an operation and a values map. Path params are substituted, the rest become query. */
66 +export function buildRequest(op, values, base = PUBLIC_BASE) {
67 + let path = op.path
68 + const query = []
69 + const pathParams = new Set(op.parameters.filter(p => p.in === 'path').map(p => p.name))
70 + for (const name of pathParams) path = path.replace(`{${name}}`, encodeURIComponent(values[name] ?? `{${name}}`))
71 + for (const [k, v] of Object.entries(values)) {
72 + if (pathParams.has(k) || v === undefined || v === '' || v === null) continue
73 + query.push(`${encodeURIComponent(k)}=${encodeURIComponent(v).replace(/%2C/g, ',').replace(/%3A/g, ':').replace(/%20/g, '%20')}`)
74 + }
75 + const rel = path + (query.length ? `?${query.join('&')}` : '')
76 + return { path, query, rel, url: base.replace(/\/$/, '') + rel, format: values.format || 'json' }
77 +}
78 +
79 +/** Playground deep link: /playground?ep=<operationId>&<param>=<value>… (contract documented in README). */
80 +export function playgroundLink(op, values) {
81 + const q = new URLSearchParams({ ep: op.id })
82 + for (const [k, v] of Object.entries(values || {})) if (v !== '' && v != null) q.set(k, v)
83 + return `/playground?${q.toString()}`
84 +}
85 +
86 +// Lazy per-operation example files captured at build time by scripts/fetch-examples.mjs.
87 +const exampleFiles = import.meta.glob('../../content/examples/*.json')
88 +export async function loadExampleFile(opId) {
89 + const key = `../../content/examples/${opId}.json`
90 + if (!exampleFiles[key]) return null
91 + const mod = await exampleFiles[key]()
92 + return mod.default || mod
93 +}
94 +export const hasExampleFile = opId => !!exampleFiles[`../../content/examples/${opId}.json`]
added hfmarketdata/web/src/docs/guides.js +67 −0
@@ -0,0 +1,67 @@
1 +// Guides registry — the docs navigation tree. Each guide is an MDX file in content/guides (lazy chunk).
2 +// To add a guide: drop the .mdx file, add one entry here (slug, title, section, file). Headings are indexed
3 +// automatically for search by the `virtual:guides-index` Vite plugin (see vite.config.js).
4 +import guidesIndex from 'virtual:guides-index'
5 +
6 +const mdx = import.meta.glob('../../content/guides/*.mdx')
7 +const changelog = () => import('../../content/changelog.mdx')
8 +
9 +export const SECTIONS = [
10 + { id: 'start', title: 'Getting started' },
11 + { id: 'guides', title: 'Guides' },
12 + { id: 'recipes', title: 'Recipes' },
13 + { id: 'fundamentals', title: 'Fundamentals' },
14 + { id: 'reference', title: 'Reference' },
15 + { id: 'more', title: 'More' },
16 +]
17 +
18 +export const GUIDES = [
19 + { slug: 'quickstart', title: 'Quickstart', section: 'start', file: 'quickstart', summary: 'Your first request in 30 seconds — no signup.' },
20 + { slug: 'authentication', title: 'Authentication & API keys', section: 'start', file: 'authentication', summary: 'Keyless, free account, high usage — how keys work.' },
21 + { slug: 'rate-limits', title: 'Rate limits', section: 'start', file: 'rate-limits', summary: 'Tiers, headers, handling 429, best practices.' },
22 + { slug: 'data-formats', title: 'Data formats', section: 'start', file: 'data-formats', summary: 'JSON vs CSV vs Parquet, with a size benchmark.' },
23 + { slug: 'futures-contracts', title: 'Futures: individual contracts', section: 'guides', file: 'futures-contracts', summary: 'Month codes, ESZ25 vs continuous, roll & adjustment methods.' },
24 + { slug: 'options', title: 'Options chains & Greeks', section: 'guides', file: 'options', summary: 'End-of-day chains, IV, Greeks, contract histories.' },
25 + { slug: 'time-zones', title: 'Time zones & sessions', section: 'guides', file: 'time-zones', summary: 'RTH / ETH, US/Eastern intraday stamps, UTC output in v2.' },
26 + { slug: 'bulk-downloads', title: 'Bulk downloads', section: 'guides', file: 'bulk-downloads', summary: 'Whole-universe Parquet extracts outside the rows quota.' },
27 + { slug: 'recipes/pandas-backtest', title: 'Backtest a strategy in pandas', section: 'recipes', file: 'recipe-pandas-backtest', summary: 'Moving-average crossover on ES continuous, end to end.' },
28 + { slug: 'recipes/custom-continuous', title: 'Build a custom continuous contract', section: 'recipes', file: 'recipe-custom-continuous', summary: 'Stitch individual contracts with your own roll rule.' },
29 + { slug: 'recipes/crude-term-structure', title: 'Analyse crude oil term structure', section: 'recipes', file: 'recipe-crude-term-structure', summary: 'Contango / backwardation from the CL curve.' },
30 + { slug: 'fundamentals/balance-sheet', title: 'Reading a balance sheet through the API', section: 'fundamentals', file: 'fundamentals-balance-sheet', summary: 'Apple Q2 FY2024, line by line.' },
31 + { slug: 'fundamentals/fiscal-vs-calendar', title: 'Fiscal vs calendar periods', section: 'fundamentals', file: 'fundamentals-fiscal-vs-calendar', summary: 'Why Apple\'s Q2 ends in March.' },
32 + { slug: 'fundamentals/point-in-time', title: 'Point-in-time & look-ahead bias', section: 'fundamentals', file: 'fundamentals-point-in-time', summary: 'Backtests that only know what was public.' },
33 + { slug: 'fundamentals/ratios', title: 'All ratio formulas', section: 'fundamentals', file: 'fundamentals-ratios', summary: 'Every ratio the API computes, with its exact formula.' },
34 + { slug: 'fundamentals/screener', title: 'Screener', section: 'fundamentals', file: 'fundamentals-screener', summary: 'Filter the universe by fundamentals.' },
35 + { slug: 'fundamentals/stream', title: 'WebSocket filings stream', section: 'fundamentals', file: 'fundamentals-stream', summary: 'Live SEC filings over WebSocket.' },
36 + { slug: 'changelog', title: 'Changelog', section: 'more', file: '__changelog', summary: 'Dated release notes.' },
37 + { slug: 'versioning', title: 'Versioning & deprecation policy', section: 'more', file: 'versioning', summary: 'What can change, and how we tell you.' },
38 +]
39 +
40 +// Non-MDX entries rendered by dedicated components (kept here so nav + search see them).
41 +export const STATIC_PAGES = [
42 + { slug: 'errors', title: 'Errors', section: 'more', summary: 'Every error code, with meaning and fix.' },
43 +]
44 +
45 +export const EXTERNAL_LINKS = [
46 + { href: '/pricing', title: 'Limits & pricing', section: 'more' },
47 + { href: '/integrations', title: 'Integrations (MCP, skills)', section: 'more' },
48 + { href: '/status', title: 'Status', section: 'more' },
49 +]
50 +
51 +export function guideBySlug(slug) {
52 + return GUIDES.find(g => g.slug === slug) || null
53 +}
54 +
55 +export function loadGuide(guide) {
56 + if (guide.file === '__changelog') return changelog()
57 + const loader = mdx[`../../content/guides/${guide.file}.mdx`]
58 + if (!loader) return Promise.reject(new Error(`Missing guide file: ${guide.file}.mdx`))
59 + return loader()
60 +}
61 +
62 +/** Headings extracted at build time: { [file]: [{ depth, text, id }] } */
63 +export function guideHeadings(guide) {
64 + return guidesIndex[guide.file === '__changelog' ? 'changelog' : guide.file] || []
65 +}
66 +
67 +export const guideHref = g => `/docs/${g.slug}`
added hfmarketdata/web/src/docs/mdx-components.jsx +71 −0
@@ -0,0 +1,71 @@
1 +// Components available inside every MDX guide (via MDXProvider) — no imports needed in the .mdx files.
2 +// Headings get stable ids (same slug rule as the build-time index → search anchors always resolve).
3 +import React from 'react'
4 +import { Link } from 'react-router-dom'
5 +import Badge, { MethodBadge } from '../components/Badge.jsx'
6 +import Callout from '../components/Callout.jsx'
7 +import Code from '../components/Code.jsx'
8 +import CodeTabs from '../components/CodeTabs.jsx'
9 +import Table from '../components/Table.jsx'
10 +import Tabs from '../components/Tabs.jsx'
11 +import { MdLink } from '../components/Markdown.jsx'
12 +import { PUBLIC_BASE } from '../app/api.js'
13 +
14 +export const slugify = s => s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
15 +
16 +function textOf(node) {
17 + if (node == null || typeof node === 'boolean') return ''
18 + if (typeof node === 'string' || typeof node === 'number') return String(node)
19 + if (Array.isArray(node)) return node.map(textOf).join('')
20 + if (node.props?.children) return textOf(node.props.children)
21 + return ''
22 +}
23 +
24 +const Heading = level => function H({ children, id, ...rest }) {
25 + const Tag = `h${level}`
26 + const slug = id || slugify(textOf(children))
27 + return (
28 + <Tag id={slug} className="anchor-heading" {...rest}>
29 + <a href={`#${slug}`} className="anchor" aria-label="Link to this section">#</a>{children}
30 + </Tag>
31 + )
32 +}
33 +
34 +function Pre({ children }) {
35 + // MDX: <pre><code className="language-python">…</code></pre>
36 + const child = React.Children.only(children)
37 + const lang = (child.props.className || '').replace('language-', '') || 'text'
38 + const meta = child.props.metastring || child.props.title
39 + return <Code code={textOf(child.props.children)} language={lang} title={meta || null} />
40 +}
41 +
42 +/** <Endpoint method="GET" path="/v1/bars/{asset}/{ticker}" id="operationId?"> — links to the reference page. */
43 +export function Endpoint({ method = 'GET', path, id, children }) {
44 + const href = id ? `/docs/reference/${id}` : `/docs/reference?find=${encodeURIComponent(`${method} ${path}`)}`
45 + return (
46 + <Link to={href} className="endpoint-chip">
47 + <MethodBadge method={method} /><code>{path}</code>{children}
48 + </Link>
49 + )
50 +}
51 +
52 +/** <TryIt ep="operationId" params={{ ticker: 'AAPL' }}>Try it</TryIt> — deep link to the playground. */
53 +export function TryIt({ ep, params = {}, children = 'Try it in the playground' }) {
54 + const q = new URLSearchParams({ ep, ...params })
55 + return <Link to={`/playground?${q}`} className="btn btn-sm btn-primary">{children}</Link>
56 +}
57 +
58 +/** <Url path="/v1/…"> renders the absolute production URL as code. */
59 +export const Url = ({ path }) => <code>{PUBLIC_BASE}{path}</code>
60 +
61 +export function Figure({ children, caption }) {
62 + return <figure className="figure">{children}{caption && <figcaption>{caption}</figcaption>}</figure>
63 +}
64 +
65 +export const mdxComponents = {
66 + h1: Heading(1), h2: Heading(2), h3: Heading(3), h4: Heading(4),
67 + pre: Pre,
68 + a: ({ href, children }) => <MdLink href={href}>{children}</MdLink>,
69 + table: ({ children }) => <div className="table-wrap"><table>{children}</table></div>,
70 + Callout, CodeTabs, Tabs, Table, Badge, MethodBadge, Code, Endpoint, TryIt, Url, Figure, Link,
71 +}
added hfmarketdata/web/src/docs/search.js +61 −0
@@ -0,0 +1,61 @@
1 +// Instant search index over operations, guides and headings. Pure functions; the dialog owns the UI.
2 +import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHeadings, guideHref } from './guides.js'
3 +import { listOperations } from './spec.js'
4 +
5 +export function buildIndex(spec) {
6 + const items = []
7 + const sectionTitle = id => SECTIONS.find(s => s.id === id)?.title || ''
8 + for (const g of GUIDES) {
9 + items.push({ type: 'guide', title: g.title, sub: g.summary, href: guideHref(g), group: sectionTitle(g.section), keywords: g.slug })
10 + for (const h of guideHeadings(g)) {
11 + items.push({ type: 'heading', title: h.text, sub: g.title, href: `${guideHref(g)}#${h.id}`, group: sectionTitle(g.section), keywords: '' })
12 + }
13 + }
14 + for (const p of STATIC_PAGES) items.push({ type: 'guide', title: p.title, sub: p.summary, href: `/docs/${p.slug}`, group: sectionTitle(p.section), keywords: p.slug })
15 + for (const l of EXTERNAL_LINKS) items.push({ type: 'page', title: l.title, sub: l.href, href: l.href, group: sectionTitle(l.section), keywords: '' })
16 + if (spec) {
17 + for (const op of listOperations(spec)) {
18 + items.push({ type: 'operation', title: op.summary, sub: `${op.method} ${op.path}`, href: `/docs/reference/${op.id}`, group: `Reference · ${op.tag}`, method: op.method, keywords: `${op.tag} ${op.id} ${op.description.slice(0, 200)}` })
19 + }
20 + const codes = spec?.components?.schemas?.Error?.properties?.error?.properties?.code?.enum || []
21 + for (const c of codes) items.push({ type: 'heading', title: c, sub: 'Error code', href: `/docs/errors#${c.toLowerCase()}`, group: 'Errors', keywords: 'error' })
22 + }
23 + return items
24 +}
25 +
26 +const norm = s => (s || '').toLowerCase()
27 +
28 +export function search(items, query, limit = 24) {
29 + const q = norm(query).trim()
30 + if (!q) return items.filter(i => i.type !== 'heading').slice(0, 12)
31 + const terms = q.split(/\s+/).filter(Boolean)
32 + const scored = []
33 + for (const it of items) {
34 + const title = norm(it.title), sub = norm(it.sub), kw = norm(it.keywords)
35 + let score = 0
36 + for (const t of terms) {
37 + if (title === t) score += 40
38 + else if (title.startsWith(t)) score += 22
39 + else if (title.includes(t)) score += 14
40 + else if (sub.includes(t)) score += 9
41 + else if (kw.includes(t)) score += 4
42 + else { score = -1; break }
43 + }
44 + if (score < 0) continue
45 + if (it.type === 'operation') score += 3
46 + if (it.type === 'heading') score -= 2
47 + scored.push({ it, score })
48 + }
49 + scored.sort((a, b) => b.score - a.score || a.it.title.length - b.it.title.length)
50 + return scored.slice(0, limit).map(s => s.it)
51 +}
52 +
53 +export function groupResults(results) {
54 + const groups = []
55 + for (const r of results) {
56 + let g = groups.find(x => x.name === r.group)
57 + if (!g) { g = { name: r.group, items: [] }; groups.push(g) }
58 + g.items.push(r)
59 + }
60 + return groups
61 +}
added hfmarketdata/web/src/docs/snippets.js +85 −0
@@ -0,0 +1,85 @@
1 +// Code sample generation — curl / Python (requests + pandas) / JavaScript (fetch) / R (httr2) from a request.
2 +// Keys are never inlined: samples read HFMD_API_KEY from the environment when `auth` is requested.
3 +import { LANGS } from '../components/LangSelector.jsx'
4 +
5 +const shellQuote = s => `"${s.replace(/"/g, '\\"')}"`
6 +
7 +/**
8 + * @param {object} req { method, url, format, body?, auth? }
9 + * @param {string} req.format json | csv | parquet — steers the pandas/R parsing line
10 + * @param {boolean} [req.auth] add the Authorization header (env var)
11 + * @param {boolean} [req.stream] WebSocket endpoint
12 + */
13 +export function snippetsFor({ method = 'GET', url, format = 'json', body, auth = false, stream = false }) {
14 + if (stream) return wsSnippets(url)
15 + const m = method.toUpperCase()
16 + const bodyJson = body ? JSON.stringify(body, null, 2) : null
17 + return { curl: curl(m, url, bodyJson, auth), python: python(m, url, format, bodyJson, auth), javascript: javascript(m, url, format, bodyJson, auth), r: rlang(m, url, format, bodyJson, auth) }
18 +}
19 +
20 +function curl(m, url, bodyJson, auth) {
21 + const lines = [`curl ${m !== 'GET' ? `-X ${m} ` : ''}${shellQuote(url)}`]
22 + if (auth) lines.push(` -H "Authorization: Bearer $HFMD_API_KEY"`)
23 + if (bodyJson) { lines.push(` -H "Content-Type: application/json"`); lines.push(` -d '${bodyJson.replace(/\n\s*/g, ' ')}'`) }
24 + return lines.join(' \\\n')
25 +}
26 +
27 +function python(m, url, format, bodyJson, auth) {
28 + const imp = ['import os', 'import requests', 'import pandas as pd']
29 + if (format === 'parquet') imp.push('from io import BytesIO')
30 + if (format === 'csv') imp.push('from io import StringIO')
31 + const hdr = auth ? `headers = {"Authorization": f"Bearer {os.environ['HFMD_API_KEY']}"}\n` : ''
32 + const call = m === 'GET'
33 + ? `r = requests.get(\n "${url}",\n${auth ? ' headers=headers,\n' : ''} timeout=60,\n)`
34 + : `r = requests.request(\n "${m}", "${url}",\n${auth ? ' headers=headers,\n' : ''}${bodyJson ? ` json=${pyLiteral(bodyJson)},\n` : ''} timeout=60,\n)`
35 + let parse
36 + if (format === 'csv') parse = `df = pd.read_csv(StringIO(r.text))`
37 + else if (format === 'parquet') parse = `df = pd.read_parquet(BytesIO(r.content)) # Parquet counts half against the rows quota`
38 + else parse = `payload = r.json()\ndf = pd.DataFrame(payload["data"]) if isinstance(payload, dict) and "data" in payload else pd.json_normalize(payload)`
39 + return `${imp.join('\n')}\n\n${hdr}${call}\nr.raise_for_status()\nprint(r.headers.get("X-RateLimit-Remaining-Requests"), "requests left")\n${parse}\nprint(df.head())`
40 +}
41 +
42 +function pyLiteral(json) { return json.replace(/\btrue\b/g, 'True').replace(/\bfalse\b/g, 'False').replace(/\bnull\b/g, 'None') }
43 +
44 +function javascript(m, url, format, bodyJson, auth) {
45 + const opts = []
46 + if (m !== 'GET') opts.push(`method: "${m}"`)
47 + const headers = []
48 + if (auth) headers.push(`Authorization: \`Bearer \${process.env.HFMD_API_KEY}\``)
49 + if (bodyJson) headers.push(`"Content-Type": "application/json"`)
50 + if (headers.length) opts.push(`headers: { ${headers.join(', ')} }`)
51 + if (bodyJson) opts.push(`body: JSON.stringify(${bodyJson.replace(/\n\s*/g, ' ')})`)
52 + const optStr = opts.length ? `, {\n ${opts.join(',\n ')},\n}` : ''
53 + let parse
54 + if (format === 'csv') parse = `const csv = await res.text();\nconsole.log(csv.split("\\n").slice(0, 3).join("\\n"));`
55 + else if (format === 'parquet') parse = `const bytes = new Uint8Array(await res.arrayBuffer()); // feed to parquet-wasm / duckdb-wasm\nconsole.log(bytes.byteLength, "bytes");`
56 + else parse = `const payload = await res.json();\nconsole.table((payload.data ?? payload).slice?.(0, 5) ?? payload);`
57 + return `const res = await fetch("${url}"${optStr});\nif (!res.ok) {\n const { error } = await res.json();\n throw new Error(\`\${error.code}: \${error.message} (\${error.docs})\`);\n}\nconsole.log(res.headers.get("X-RateLimit-Remaining-Requests"), "requests left");\n${parse}`
58 +}
59 +
60 +function rlang(m, url, format, bodyJson, auth) {
61 + const lines = ['library(httr2)']
62 + if (format === 'parquet') lines.push('library(arrow)')
63 + lines.push('', `req <- request("${url}")`)
64 + if (m !== 'GET') lines.push(`req <- req |> req_method("${m}")`)
65 + if (auth) lines.push(`req <- req |> req_auth_bearer_token(Sys.getenv("HFMD_API_KEY"))`)
66 + if (bodyJson) lines.push(`req <- req |> req_body_json(jsonlite::fromJSON('${bodyJson.replace(/\n\s*/g, ' ')}'))`)
67 + lines.push(`resp <- req_perform(req)`, `cat(resp_header(resp, "X-RateLimit-Remaining-Requests"), "requests left\\n")`)
68 + if (format === 'csv') lines.push(`df <- read.csv(text = resp_body_string(resp))`)
69 + else if (format === 'parquet') lines.push(`df <- as.data.frame(arrow::read_parquet(resp_body_raw(resp)))`)
70 + else lines.push(`payload <- resp_body_json(resp, simplifyVector = TRUE)`, `df <- if (is.list(payload) && !is.null(payload$data)) as.data.frame(payload$data) else payload`)
71 + lines.push(`head(df)`)
72 + return lines.join('\n')
73 +}
74 +
75 +function wsSnippets(url) {
76 + const ws = url.replace(/^http/, 'ws')
77 + return {
78 + curl: `# websocat: https://github.com/vi/websocat\nwebsocat "${ws}"`,
79 + python: `import asyncio, json, os\nimport websockets # pip install websockets\n\nasync def main():\n headers = {"Authorization": f"Bearer {os.environ.get('HFMD_API_KEY', '')}"}\n async with websockets.connect("${ws}", additional_headers=headers) as ws:\n async for msg in ws:\n event = json.loads(msg)\n print(event.get("type"), event.get("data"))\n\nasyncio.run(main())`,
80 + javascript: `const ws = new WebSocket("${ws}");\nws.onmessage = (ev) => {\n const event = JSON.parse(ev.data);\n console.log(event.type, event.data);\n};\nws.onclose = (ev) => console.log("closed", ev.code, ev.reason);`,
81 + r: `# R has no mainstream WebSocket client for long-lived streams; the 'websocket' package works:\nlibrary(websocket)\nws <- WebSocket$new("${ws}")\nws$onMessage(function(event) print(jsonlite::fromJSON(event$data)))`,
82 + }
83 +}
84 +
85 +export const langMeta = id => LANGS.find(l => l.id === id) || LANGS[0]
added hfmarketdata/web/src/docs/spec.js +199 −0
@@ -0,0 +1,199 @@
1 +// OpenAPI loader + helpers. Fetches /openapi.json once per session (ETag revalidation, localStorage cache),
2 +// falls back to the bundled snapshot (content/openapi.snapshot.json) when the API is unreachable (e.g. `vite preview`).
3 +import { useEffect, useState } from 'react'
4 +import { BASE_URL } from '../app/api.js'
5 +
6 +const LS_KEY = 'hfmd.openapi.v1'
7 +let memo = null // { spec, source }
8 +let inflight = null
9 +
10 +function readCache() {
11 + try { const raw = localStorage.getItem(LS_KEY); return raw ? JSON.parse(raw) : null } catch { return null }
12 +}
13 +function writeCache(etag, spec) {
14 + try { localStorage.setItem(LS_KEY, JSON.stringify({ etag, spec, at: Date.now() })) } catch { /* quota */ }
15 +}
16 +
17 +async function snapshot() {
18 + const mod = await import('../../content/openapi.snapshot.json')
19 + return mod.default || mod
20 +}
21 +
22 +export async function loadSpec({ force = false } = {}) {
23 + if (memo && !force) return memo
24 + if (inflight) return inflight
25 + inflight = (async () => {
26 + const cached = readCache()
27 + try {
28 + const headers = { Accept: 'application/json' }
29 + if (cached?.etag) headers['If-None-Match'] = cached.etag
30 + const res = await fetch(`${BASE_URL}/openapi.json`, { headers })
31 + if (res.status === 304 && cached?.spec) { memo = { spec: cached.spec, source: 'cache' }; return memo }
32 + const ct = res.headers.get('content-type') || ''
33 + if (res.ok && ct.includes('json')) {
34 + const spec = await res.json()
35 + if (spec && spec.paths) { writeCache(res.headers.get('etag'), spec); memo = { spec, source: 'live' }; return memo }
36 + }
37 + throw new Error(`openapi.json unavailable (${res.status})`)
38 + } catch (e) {
39 + if (cached?.spec) { memo = { spec: cached.spec, source: 'cache' }; return memo }
40 + memo = { spec: await snapshot(), source: 'snapshot' }
41 + return memo
42 + } finally {
43 + inflight = null
44 + }
45 + })()
46 + return inflight
47 +}
48 +
49 +export function useSpec() {
50 + const [state, setState] = useState(() => (memo ? { ...memo, loading: false, error: null } : { spec: null, source: null, loading: true, error: null }))
51 + useEffect(() => {
52 + if (memo) return
53 + let alive = true
54 + loadSpec().then(r => alive && setState({ ...r, loading: false, error: null }), e => alive && setState({ spec: null, source: null, loading: false, error: e }))
55 + return () => { alive = false }
56 + }, [])
57 + return state
58 +}
59 +
60 +// ---- helpers -------------------------------------------------------------------------------------
61 +
62 +export function resolveRef(spec, obj, depth = 0) {
63 + if (!obj || typeof obj !== 'object' || depth > 12) return obj
64 + if (obj.$ref && typeof obj.$ref === 'string' && obj.$ref.startsWith('#/')) {
65 + const target = obj.$ref.slice(2).split('/').reduce((o, k) => (o == null ? undefined : o[decodeURIComponent(k).replace(/~1/g, '/').replace(/~0/g, '~')]), spec)
66 + return resolveRef(spec, target, depth + 1)
67 + }
68 + return obj
69 +}
70 +
71 +export const METHODS = ['get', 'post', 'put', 'patch', 'delete']
72 +
73 +/** Flatten every operation of the spec. */
74 +export function listOperations(spec) {
75 + if (!spec?.paths) return []
76 + const ops = []
77 + for (const [path, item] of Object.entries(spec.paths)) {
78 + const shared = item.parameters || []
79 + for (const method of METHODS) {
80 + const op = item[method]
81 + if (!op) continue
82 + const id = op.operationId || `${method}_${path.replace(/[^a-z0-9]+/gi, '_')}`
83 + ops.push({
84 + id, method: method.toUpperCase(), path,
85 + tag: (op.tags && op.tags[0]) || 'other',
86 + summary: op.summary || humanize(id),
87 + description: op.description || '',
88 + deprecated: !!op.deprecated,
89 + parameters: [...shared, ...(op.parameters || [])].map(p => paramInfo(spec, resolveRef(spec, p))),
90 + requestBody: op.requestBody ? resolveRef(spec, op.requestBody) : null,
91 + responses: op.responses || {},
92 + security: op.security,
93 + raw: op,
94 + })
95 + }
96 + }
97 + return ops
98 +}
99 +
100 +function humanize(id) {
101 + return id.replace(/_v1_.*$/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
102 +}
103 +
104 +export function schemaType(schema) {
105 + if (!schema) return 'string'
106 + if (schema.type) return Array.isArray(schema.type) ? schema.type.filter(t => t !== 'null').join(' | ') : schema.type
107 + if (schema.anyOf || schema.oneOf) {
108 + const types = (schema.anyOf || schema.oneOf).map(s => schemaType(s)).filter(t => t !== 'null')
109 + return [...new Set(types)].join(' | ') || 'string'
110 + }
111 + if (schema.enum) return typeof schema.enum[0]
112 + return 'string'
113 +}
114 +
115 +function pick(schema, key) {
116 + if (!schema) return undefined
117 + if (schema[key] !== undefined) return schema[key]
118 + for (const s of schema.anyOf || schema.oneOf || []) if (s && s[key] !== undefined) return s[key]
119 + return undefined
120 +}
121 +
122 +export function paramInfo(spec, p) {
123 + const schema = resolveRef(spec, p.schema) || {}
124 + const constraints = []
125 + const min = pick(schema, 'minimum'), max = pick(schema, 'maximum'), pattern = pick(schema, 'pattern')
126 + const minLen = pick(schema, 'minLength'), maxLen = pick(schema, 'maxLength'), format = pick(schema, 'format')
127 + if (min !== undefined) constraints.push(`≥ ${min}`)
128 + if (max !== undefined) constraints.push(`≤ ${max}`)
129 + if (minLen !== undefined) constraints.push(`min length ${minLen}`)
130 + if (maxLen !== undefined) constraints.push(`max length ${maxLen}`)
131 + if (pattern) constraints.push(`pattern ${pattern}`)
132 + if (format) constraints.push(format)
133 + const en = pick(schema, 'enum') || enumFromPattern(pattern)
134 + return {
135 + name: p.name, in: p.in || 'query', required: !!p.required,
136 + type: schemaType(schema), default: pick(schema, 'default'), enum: en || null,
137 + constraints, description: p.description || schema.description || '',
138 + example: p.example ?? pick(schema, 'example') ?? (p.examples ? Object.values(p.examples)[0]?.value : undefined),
139 + deprecated: !!p.deprecated,
140 + }
141 +}
142 +
143 +// FastAPI often expresses small enums as `^(a|b|c)$` patterns — surface them as choices.
144 +function enumFromPattern(pattern) {
145 + const m = pattern && /^\^\(([\w|.-]+)\)\$$/.exec(pattern)
146 + return m ? m[1].split('|') : null
147 +}
148 +
149 +/** Ordered tag list with descriptions and their operations. Unknown tags appended alphabetically. */
150 +export function groupByTag(spec, ops) {
151 + const declared = spec?.tags || []
152 + const byName = new Map()
153 + for (const t of declared) byName.set(t.name, { name: t.name, description: t.description || '', ops: [] })
154 + for (const op of ops) {
155 + if (!byName.has(op.tag)) byName.set(op.tag, { name: op.tag, description: '', ops: [] })
156 + byName.get(op.tag).ops.push(op)
157 + }
158 + return [...byName.values()].filter(t => t.ops.length)
159 +}
160 +
161 +/** First JSON example found on the 2xx response of an operation (spec-declared), else null. */
162 +export function responseExample(spec, op) {
163 + for (const [code, r0] of Object.entries(op.responses || {})) {
164 + if (!code.startsWith('2')) continue
165 + const r = resolveRef(spec, r0)
166 + const content = r?.content || {}
167 + const media = content['application/json'] || Object.values(content)[0]
168 + if (!media) continue
169 + if (media.example !== undefined) return { code, value: media.example, mediaType: mediaTypeOf(content, media) }
170 + if (media.examples) { const first = resolveRef(spec, Object.values(media.examples)[0]); if (first?.value !== undefined) return { code, value: first.value, mediaType: mediaTypeOf(content, media) } }
171 + const schema = resolveRef(spec, media.schema)
172 + if (schema?.example !== undefined) return { code, value: schema.example, mediaType: mediaTypeOf(content, media) }
173 + if (schema?.examples?.length) return { code, value: schema.examples[0], mediaType: mediaTypeOf(content, media) }
174 + }
175 + return null
176 +}
177 +function mediaTypeOf(content, media) { return Object.keys(content).find(k => content[k] === media) || 'application/json' }
178 +
179 +/** Error responses declared on the operation: [{ status, codes: [...], description }]. */
180 +export function errorResponses(spec, op) {
181 + const out = []
182 + for (const [status, r0] of Object.entries(op.responses || {})) {
183 + if (!/^[45]/.test(status)) continue
184 + const r = resolveRef(spec, r0)
185 + const desc = r?.description || ''
186 + const codes = [...desc.matchAll(/`([A-Z_]{4,})`/g)].map(m => m[1])
187 + out.push({ status, codes: [...new Set(codes)], description: desc })
188 + }
189 + return out.sort((a, b) => Number(a.status) - Number(b.status))
190 +}
191 +
192 +export function errorCodes(spec) {
193 + const en = spec?.components?.schemas?.Error?.properties?.error?.properties?.code?.enum
194 + return Array.isArray(en) ? en : []
195 +}
196 +
197 +export function tiers(spec) { return spec?.info?.['x-tiers'] || null }
198 +
199 +export const specVersion = spec => spec?.info?.version || ''
added hfmarketdata/web/src/docs/useTitle.js +16 −0
@@ -0,0 +1,16 @@
1 +// Document title + meta description for client-side routes (prerendered shells carry their own tags).
2 +import { useEffect } from 'react'
3 +
4 +export default function useTitle(title, description) {
5 + useEffect(() => {
6 + const prev = document.title
7 + document.title = title ? `${title} · HF Market Data` : 'HF Market Data — Open High-Frequency Market Data API'
8 + let meta = document.querySelector('meta[name="description"]')
9 + const prevDesc = meta?.getAttribute('content')
10 + if (description) {
11 + if (!meta) { meta = document.createElement('meta'); meta.name = 'description'; document.head.appendChild(meta) }
12 + meta.setAttribute('content', description)
13 + }
14 + return () => { document.title = prev; if (description && meta && prevDesc != null) meta.setAttribute('content', prevDesc) }
15 + }, [title, description])
16 +}
modified hfmarketdata/web/src/main.jsx +5 −1
@@ -3,13 +3,17 @@ import { createRoot } from 'react-dom/client'
3 3 import { BrowserRouter } from 'react-router-dom'
4 4 import App from './App.jsx'
5 5 import { AuthProvider } from './app/auth.jsx'
6 +import { LangProvider } from './components/LangSelector.jsx'
6 7 import './app/theme.css'
8 +import './components/components.css'
7 9
8 10 createRoot(document.getElementById('root')).render(
9 11 <React.StrictMode>
10 12 <BrowserRouter>
11 13 <AuthProvider>
12 − <App />
14 + <LangProvider>
15 + <App />
16 + </LangProvider>
13 17 </AuthProvider>
14 18 </BrowserRouter>
15 19 </React.StrictMode>,
modified hfmarketdata/web/src/pages/docs/Docs.jsx +27 −2
@@ -1,5 +1,30 @@
1 +// /docs/* router. Reference pages are generated from /openapi.json; guides are MDX (content/guides).
1 2 import React from 'react'
2 −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name.
3 +import { Route, Routes, useLocation } from 'react-router-dom'
4 +import DocsHome from './DocsHome.jsx'
5 +import DocsLayout from './DocsLayout.jsx'
6 +import ErrorsPage from './ErrorsPage.jsx'
7 +import GuidePage from './GuidePage.jsx'
8 +import OperationPage from './OperationPage.jsx'
9 +import ReferenceIndex from './ReferenceIndex.jsx'
10 +
11 +function GuideRoute() {
12 + const { pathname } = useLocation()
13 + const slug = pathname.replace(/^\/docs\/?/, '').replace(/\/$/, '')
14 + return <GuidePage slug={slug} />
15 +}
16 +
3 17 export default function Docs() {
4 − return <main className="page"><h1>Docs</h1><p className="muted">Coming soon.</p></main>
18 + return (
19 + <DocsLayout>
20 + <Routes>
21 + <Route index element={<DocsHome />} />
22 + <Route path="reference" element={<ReferenceIndex />} />
23 + <Route path="reference/tag/:tag" element={<ReferenceIndex />} />
24 + <Route path="reference/:opId" element={<OperationPage />} />
25 + <Route path="errors" element={<ErrorsPage />} />
26 + <Route path="*" element={<GuideRoute />} />
27 + </Routes>
28 + </DocsLayout>
29 + )
5 30 }
added hfmarketdata/web/src/pages/docs/DocsHome.jsx +55 −0
@@ -0,0 +1,55 @@
1 +// /docs landing: quick start snippet, section cards, reference tags.
2 +import React, { useMemo } from 'react'
3 +import { Link } from 'react-router-dom'
4 +import { PUBLIC_BASE } from '../../app/api.js'
5 +import CodeTabs from '../../components/CodeTabs.jsx'
6 +import { GUIDES, SECTIONS, guideHref } from '../../docs/guides.js'
7 +import { snippetsFor } from '../../docs/snippets.js'
8 +import { groupByTag, listOperations, useSpec } from '../../docs/spec.js'
9 +import useTitle from '../../docs/useTitle.js'
10 +import { DocPage, OnThisPage } from './DocsLayout.jsx'
11 +
12 +const FIRST = `${PUBLIC_BASE}/v1/bars/stock/AAPL?timeframe=1min&start=2024-06-03%2009:30:00&end=2024-06-03%2010:00:00`
13 +
14 +export default function DocsHome() {
15 + useTitle('Documentation', 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference.')
16 + const { spec } = useSpec()
17 + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec])
18 + const snippets = useMemo(() => snippetsFor({ url: FIRST }), [])
19 + const headings = SECTIONS.filter(s => s.id !== 'more').map(s => ({ depth: 2, text: s.title, id: s.id }))
20 + const panel = (
21 + <>
22 + <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div>
23 + <OnThisPage headings={headings} />
24 + </>
25 + )
26 + return (
27 + <DocPage panel={panel} testId="docs-home">
28 + <header>
29 + <h1>Documentation</h1>
30 + <p className="lead">Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.</p>
31 + <p><code>{PUBLIC_BASE}/v1</code></p>
32 + </header>
33 + {SECTIONS.filter(s => s.id !== 'more' && s.id !== 'reference').map(s => (
34 + <section key={s.id}>
35 + <h2 id={s.id}>{s.title}</h2>
36 + <div className="docs-home-grid">
37 + {GUIDES.filter(g => g.section === s.id).map(g => (
38 + <Link key={g.slug} to={guideHref(g)} className="docs-home-card"><h3>{g.title}</h3><p>{g.summary}</p></Link>
39 + ))}
40 + </div>
41 + </section>
42 + ))}
43 + <section>
44 + <h2 id="reference">Reference</h2>
45 + <p className="muted">Generated from the live OpenAPI 3.1 specification. Every page has request samples in four languages, a real response and a <em>Try it</em> button.</p>
46 + <div className="docs-home-grid">
47 + {tags.map(t => (
48 + <Link key={t.name} to={`/docs/reference/tag/${t.name}`} className="docs-home-card"><h3 style={{ textTransform: 'capitalize' }}>{t.name} <span className="muted small">· {t.ops.length}</span></h3><p>{t.description}</p></Link>
49 + ))}
50 + <Link to="/docs/errors" className="docs-home-card"><h3>Errors</h3><p>Every error code, meaning and fix.</p></Link>
51 + </div>
52 + </section>
53 + </DocPage>
54 + )
55 +}
added hfmarketdata/web/src/pages/docs/DocsLayout.jsx +148 −0
@@ -0,0 +1,148 @@
1 +// Docs shell: left nav (guides + reference generated from the spec), DocPage (content + sticky code panel),
2 +// OnThisPage outline, footer with Edit / Report links. DOM order = visual order on every breakpoint.
3 +import React, { useEffect, useMemo, useState } from 'react'
4 +import { Link, NavLink, useLocation } from 'react-router-dom'
5 +import { CONTACT_EMAIL, PUBLIC_BASE } from '../../app/api.js'
6 +import { REPO_URL } from '../../app/Layout.jsx'
7 +import { MethodBadge } from '../../components/Badge.jsx'
8 +import { ChevronIcon, EditIcon, GitIcon, MenuIcon, SearchIcon } from '../../components/Icons.jsx'
9 +import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHref } from '../../docs/guides.js'
10 +import { groupByTag, listOperations, useSpec } from '../../docs/spec.js'
11 +import './docs.css'
12 +
13 +const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
14 +
15 +export default function DocsLayout({ children }) {
16 + const [open, setOpen] = useState(false)
17 + const location = useLocation()
18 + useEffect(() => { setOpen(false) }, [location.pathname])
19 + return (
20 + <div className="docs">
21 + <div className="docs-nav-toggle">
22 + <button type="button" className="btn btn-sm" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-controls="docs-nav"><MenuIcon /> Docs menu</button>
23 + <span className="muted">{currentLabel(location.pathname)}</span>
24 + </div>
25 + <DocsNav open={open} />
26 + <div className="docs-main">{children}</div>
27 + </div>
28 + )
29 +}
30 +
31 +function currentLabel(path) {
32 + const g = GUIDES.find(g => guideHref(g) === path)
33 + if (g) return g.title
34 + if (path.startsWith('/docs/reference')) return 'Reference'
35 + if (path === '/docs/errors') return 'Errors'
36 + return 'Overview'
37 +}
38 +
39 +function openSearch() { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true })) }
40 +
41 +export function DocsNav({ open }) {
42 + const { spec } = useSpec()
43 + const location = useLocation()
44 + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec])
45 + const activeTag = tags.find(t => t.ops.some(op => location.pathname === `/docs/reference/${op.id}`) || location.pathname === `/docs/reference/tag/${t.name}`)?.name
46 + return (
47 + <nav className={`docs-nav ${open ? 'open' : ''}`} id="docs-nav" aria-label="Documentation">
48 + <button type="button" className="docs-nav-search" onClick={openSearch}><SearchIcon /> Search<kbd>{isMac ? '⌘' : 'Ctrl'} K</kbd></button>
49 + <div className="docs-nav-section">
50 + <NavLink to="/docs" end>Overview</NavLink>
51 + </div>
52 + {SECTIONS.filter(s => s.id !== 'reference' && s.id !== 'more').map(s => (
53 + <div className="docs-nav-section" key={s.id}>
54 + <div className="docs-nav-title">{s.title}</div>
55 + {GUIDES.filter(g => g.section === s.id).map(g => <NavLink key={g.slug} to={guideHref(g)}>{g.title}</NavLink>)}
56 + </div>
57 + ))}
58 + <div className="docs-nav-section">
59 + <div className="docs-nav-title">Reference</div>
60 + <NavLink to="/docs/reference" end>All endpoints</NavLink>
61 + {tags.map(t => (
62 + <details key={t.name} open={activeTag === t.name || undefined}>
63 + <summary>{t.name.charAt(0).toUpperCase() + t.name.slice(1)} <ChevronIcon /></summary>
64 + <div>
65 + {t.ops.map(op => (
66 + <NavLink key={op.id} to={`/docs/reference/${op.id}`} className="nav-op" title={`${op.method} ${op.path}`}><MethodBadge method={op.method} />{op.summary}</NavLink>
67 + ))}
68 + </div>
69 + </details>
70 + ))}
71 + <NavLink to="/docs/errors">Errors</NavLink>
72 + </div>
73 + <div className="docs-nav-section">
74 + <div className="docs-nav-title">More</div>
75 + {GUIDES.filter(g => g.section === 'more').map(g => <NavLink key={g.slug} to={guideHref(g)}>{g.title}</NavLink>)}
76 + {STATIC_PAGES.filter(p => p.slug !== 'errors').map(p => <NavLink key={p.slug} to={`/docs/${p.slug}`}>{p.title}</NavLink>)}
77 + {EXTERNAL_LINKS.map(l => <NavLink key={l.href} to={l.href}>{l.title}</NavLink>)}
78 + <a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json ↗</a>
79 + </div>
80 + </nav>
81 + )
82 +}
83 +
84 +/** Centre column + sticky right panel. `panel` null → single wide column. */
85 +export function DocPage({ children, panel, crumbs, footer = true, testId }) {
86 + const location = useLocation()
87 + return (
88 + <div className={`docpage ${panel ? '' : 'no-panel'}`} data-testid={testId}>
89 + <article className="docs-content">
90 + {crumbs && (
91 + <div className="docs-crumbs" aria-label="Breadcrumb">
92 + <Link to="/docs">Docs</Link>
93 + {crumbs.map((c, i) => <React.Fragment key={i}><ChevronIcon />{c.href ? <Link to={c.href}>{c.label}</Link> : <span>{c.label}</span>}</React.Fragment>)}
94 + </div>
95 + )}
96 + {children}
97 + {footer && <DocsFooter path={location.pathname} />}
98 + </article>
99 + {panel && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>}
100 + </div>
101 + )
102 +}
103 +
104 +export function DocsFooter({ path }) {
105 + const url = `${PUBLIC_BASE}${path}`
106 + const mail = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(`[hfmarketdata docs] Issue on ${path}`)}&body=${encodeURIComponent(`Page: ${url}\n\nWhat is wrong / missing:\n`)}`
107 + return (
108 + <div className="docs-footer">
109 + <a href={mail}><EditIcon /> Edit / Report an issue</a>
110 + <a href={REPO_URL} rel="noopener noreferrer"><GitIcon /> Source repository</a>
111 + </div>
112 + )
113 +}
114 +
115 +/** "On this page" outline; tracks the heading currently in view. headings: [{ depth, text, id }] */
116 +export function OnThisPage({ headings }) {
117 + const [active, setActive] = useState(null)
118 + useEffect(() => {
119 + if (!headings?.length || typeof IntersectionObserver === 'undefined') return
120 + const els = headings.map(h => document.getElementById(h.id)).filter(Boolean)
121 + if (!els.length) return
122 + const io = new IntersectionObserver(entries => {
123 + const visible = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
124 + if (visible[0]) setActive(visible[0].target.id)
125 + }, { rootMargin: '-64px 0px -70% 0px', threshold: [0, 1] })
126 + els.forEach(el => io.observe(el))
127 + return () => io.disconnect()
128 + }, [headings])
129 + if (!headings?.length) return null
130 + return (
131 + <div className="panel-block">
132 + <h4>On this page</h4>
133 + <nav className="toc" aria-label="On this page">
134 + {headings.map(h => <a key={h.id} href={`#${h.id}`} className={`depth-${h.depth} ${active === h.id ? 'active' : ''}`}>{h.text}</a>)}
135 + </nav>
136 + </div>
137 + )
138 +}
139 +
140 +export function PrevNext({ prev, next }) {
141 + if (!prev && !next) return null
142 + return (
143 + <nav className="docs-prevnext" aria-label="Previous / next">
144 + <div>{prev && <Link to={prev.href}><span className="lbl">← Previous</span>{prev.title}</Link>}</div>
145 + <div>{next && <Link to={next.href} className="next"><span className="lbl">Next →</span>{next.title}</Link>}</div>
146 + </nav>
147 + )
148 +}
added hfmarketdata/web/src/pages/docs/ErrorsPage.jsx +89 −0
@@ -0,0 +1,89 @@
1 +// Every error code from components.schemas.Error.properties.code.enum, with anchors #<code lower>
2 +// (the API's error.docs links point here). Meanings/fixes are curated; unknown codes still get an entry.
3 +import React, { useEffect, useMemo } from 'react'
4 +import { Link, useLocation } from 'react-router-dom'
5 +import { StatusBadge } from '../../components/Badge.jsx'
6 +import Code from '../../components/Code.jsx'
7 +import Callout from '../../components/Callout.jsx'
8 +import { errorCodes, useSpec } from '../../docs/spec.js'
9 +import useTitle from '../../docs/useTitle.js'
10 +import { DocPage, OnThisPage } from './DocsLayout.jsx'
11 +
12 +export const ERROR_INFO = {
13 + INVALID_PARAMETER: { status: 400, meaning: 'A query or path parameter is malformed or out of range.', fix: 'Read `error.message` — it names the parameter and lists valid values (e.g. unknown `timeframe`, `adjustment` not available for this asset). Fix the value and retry; no need to back off.' },
14 + VALIDATION_ERROR: { status: 422, meaning: 'The request did not match the endpoint schema (wrong type, missing required parameter).', fix: '`error.details.errors` lists each failing field with its location (`loc`) and message. Compare with the parameters table of the endpoint.' },
15 + NOT_FOUND: { status: 404, meaning: 'The requested resource does not exist.', fix: 'Check the path. For data lookups prefer the specific codes below (`TICKER_NOT_FOUND`, `CONTRACT_NOT_FOUND`…).' },
16 + TICKER_NOT_FOUND: { status: 404, meaning: 'Unknown ticker for this asset type / timeframe / adjustment.', fix: 'List what exists with `GET /v1/{asset}/tickers?search=` for the same `timeframe` and `adjustment`. Coverage differs per timeframe (e.g. `UNADJUSTED` exists for 1min and 1day only).' },
17 + ASSET_NOT_FOUND: { status: 404, meaning: 'Unknown asset type.', fix: 'Valid values: `stock`, `etf`, `futures`, `futures_contracts`, `crypto`, `index`, `fx` (see `GET /v1/status`).' },
18 + INVALID_CONTRACT_SYMBOL: { status: 400, meaning: 'Futures contract symbol must look like `ESZ25` or `ESZ2025`.', fix: 'Root (1–3 letters) + month code (F G H J K M N Q U V X Z) + 2- or 4-digit year. See the futures guide for the month code table.' },
19 + CONTRACT_NOT_FOUND: { status: 404, meaning: 'No data for this futures contract.', fix: 'The symbol parses but no file exists — either the expiry was never listed, or it is outside the covered range. `GET /v1/futures/{root}/contracts` returns the full list with first/last data dates.' },
20 + ROOT_NOT_FOUND: { status: 404, meaning: 'Unknown futures root.', fix: '`GET /v1/futures/roots` lists the 140+ roots with names and exchanges. Some FirstRate roots have aliases (e.g. `E6` → `6E`).' },
21 + OPTIONS_UNAVAILABLE: { status: 503, meaning: 'Options dataset not available.', fix: 'The quarter or underlying is not ingested (yet). `GET /v1/options/quarters` and `GET /v1/options/tickers?quarter=` show what exists.' },
22 + INVALID_API_KEY: { status: 401, meaning: 'The API key is missing, malformed, revoked or unknown.', fix: 'Send `Authorization: Bearer hfmd_live_…` (or `?api_key=`). Keys are shown once at creation — rotate from the dashboard if lost. Without a key, requests still work under keyless limits.' },
23 + AUTH_REQUIRED: { status: 401, meaning: 'Authentication is required for this endpoint.', fix: 'Account endpoints (`/v1/me/*`, `/v1/admin/*`) need a signed-in session or an API key.' },
24 + FORBIDDEN: { status: 403, meaning: 'Your tier or role does not allow this action.', fix: 'Admin endpoints require the admin role. Some features may be limited to the high-usage tier — e-mail contact@spboucher.ai.' },
25 + RATE_LIMIT_EXCEEDED: { status: 429, meaning: 'Request or row quota exhausted for the current window.', fix: 'Wait `Retry-After` seconds (also `X-RateLimit-Reset`). `error.type` says which bucket: `requests_per_hour`, `rows_per_minute`… Create a free account to move from 30 req/h to 120 req/min; use Parquet (½ cost), bulk endpoints (free) and ETags (304 = free). See the rate limits guide.' },
26 + ROW_LIMIT_EXCEEDED: { status: 400, meaning: '`limit` is above the maximum rows per request for your tier.', fix: 'Keyless: 5 000 rows/request · free: 50 000 · high usage: 200 000. Lower `limit` and paginate with `cursor`, or switch to `format=csv|parquet` / bulk downloads.' },
27 + CONFLICT: { status: 409, meaning: 'The resource already exists or is in a conflicting state.', fix: 'Typically an e-mail already registered or a key name already used. Pick another value or fetch the existing resource.' },
28 + INTERNAL_ERROR: { status: 500, meaning: 'Unexpected server error.', fix: 'Retry once; if it persists, e-mail contact@spboucher.ai with the full URL and timestamp (UTC).' },
29 + SERVICE_UNAVAILABLE: { status: 503, meaning: 'A dependency (Redis, data lake) is unavailable.', fix: 'Transient. Retry with exponential backoff (1 s, 2 s, 4 s…). The status page reflects incidents.' },
30 +}
31 +
32 +const EXAMPLE = {
33 + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Request quota exhausted: 30 requests per hour for keyless access. Create a free account for 120 requests per minute.', docs: 'https://www.hfmarketdata.io/docs/errors#rate_limit_exceeded', type: 'requests_per_hour', details: { limit: 30, window: '1h', reset: 1757030400 } },
34 + detail: 'Request quota exhausted: 30 requests per hour for keyless access. Create a free account for 120 requests per minute.',
35 +}
36 +
37 +export default function ErrorsPage() {
38 + const { spec, loading } = useSpec()
39 + const location = useLocation()
40 + useTitle('Error codes', 'Every error code returned by the HF Market Data API, its HTTP status, meaning and how to fix it.')
41 + const codes = useMemo(() => {
42 + const fromSpec = spec ? errorCodes(spec) : []
43 + const all = new Set([...fromSpec, ...Object.keys(ERROR_INFO)])
44 + return [...all].sort((a, b) => (ERROR_INFO[a]?.status || 400) - (ERROR_INFO[b]?.status || 400) || a.localeCompare(b))
45 + }, [spec])
46 + useEffect(() => {
47 + if (!location.hash || loading) return
48 + const el = document.getElementById(location.hash.slice(1))
49 + if (el) el.scrollIntoView({ block: 'start' })
50 + }, [location.hash, loading, codes])
51 + const headings = [{ depth: 2, text: 'Envelope', id: 'envelope' }, { depth: 2, text: 'Codes', id: 'codes' }, ...codes.map(c => ({ depth: 3, text: c, id: c.toLowerCase() }))]
52 + return (
53 + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: 'Errors' }]} panel={<OnThisPage headings={headings} />} testId="errors-page">
54 + <header>
55 + <h1>Errors</h1>
56 + <p className="lead">Every error response — on every endpoint, v1 and v2 — has the same JSON envelope. The <code>error.docs</code> URL points to the matching anchor on this page.</p>
57 + </header>
58 + <section className="prose">
59 + <h2 id="envelope">Envelope</h2>
60 + <Code language="json" title="429 Too Many Requests" code={JSON.stringify(EXAMPLE, null, 2)} />
61 + <ul>
62 + <li><code>error.code</code> — stable machine-readable code (this page). Match on it, never on <code>message</code>.</li>
63 + <li><code>error.message</code> — human explanation, often with the valid values.</li>
64 + <li><code>error.docs</code> — link to the code below.</li>
65 + <li><code>error.type</code> — optional sub-type (429: <code>requests_per_hour</code>, <code>requests_per_minute</code>, <code>rows_per_hour</code>, <code>rows_per_minute</code>).</li>
66 + <li><code>error.details</code> — optional structured context (validation errors, limits).</li>
67 + <li><code>detail</code> — legacy field kept for v1 clients; same text as <code>error.message</code>.</li>
68 + </ul>
69 + <Callout type="tip" title="Handling strategy">4xx other than 429 are <strong>your</strong> bug — fix and retry immediately. 429 → wait <code>Retry-After</code>. 5xx → exponential backoff. See <Link to="/docs/rate-limits">Rate limits</Link> for code in four languages.</Callout>
70 + <h2 id="codes">Codes</h2>
71 + {codes.map(c => {
72 + const info = ERROR_INFO[c] || { status: 400, meaning: 'See the endpoint description.', fix: '' }
73 + return (
74 + <div className="error-code-block" key={c} id={c.toLowerCase()}>
75 + <h3><StatusBadge code={info.status} /> {c} <a href={`#${c.toLowerCase()}`} className="anchor" aria-label="Link to this code">#</a></h3>
76 + <p><Md text={info.meaning} /></p>
77 + {info.fix && <p className="muted"><strong>Fix:</strong> <Md text={info.fix} /></p>}
78 + </div>
79 + )
80 + })}
81 + </section>
82 + </DocPage>
83 + )
84 +}
85 +
86 +function Md({ text }) {
87 + const parts = text.split(/(`[^`]+`)/g)
88 + return <>{parts.map((p, i) => (p.startsWith('`') ? <code key={i}>{p.slice(1, -1)}</code> : p))}</>
89 +}
added hfmarketdata/web/src/pages/docs/GuidePage.jsx +65 −0
@@ -0,0 +1,65 @@
1 +// MDX guide page. The module may export `snippets` ([{ title, curl, python, javascript, r }]) → right panel,
2 +// and `meta` ({ title, description }). Headings come from the build-time index (virtual:guides-index).
3 +import React, { useEffect, useState } from 'react'
4 +import { MDXProvider } from '@mdx-js/react'
5 +import { useLocation } from 'react-router-dom'
6 +import CodeTabs from '../../components/CodeTabs.jsx'
7 +import { GUIDES, guideBySlug, guideHeadings, guideHref, loadGuide } from '../../docs/guides.js'
8 +import { mdxComponents } from '../../docs/mdx-components.jsx'
9 +import useTitle from '../../docs/useTitle.js'
10 +import { DocPage, OnThisPage, PrevNext } from './DocsLayout.jsx'
11 +
12 +const cache = new Map()
13 +
14 +export default function GuidePage({ slug }) {
15 + const guide = guideBySlug(slug)
16 + const [mod, setMod] = useState(() => cache.get(slug) || null)
17 + const [error, setError] = useState(null)
18 + const location = useLocation()
19 + useTitle(mod?.meta?.title || guide?.title, mod?.meta?.description || guide?.summary)
20 +
21 + useEffect(() => {
22 + if (!guide) return
23 + if (cache.has(slug)) { setMod(cache.get(slug)); return }
24 + let alive = true
25 + setMod(null); setError(null)
26 + loadGuide(guide).then(m => { cache.set(slug, m); alive && setMod(m) }, e => alive && setError(e))
27 + return () => { alive = false }
28 + }, [slug, guide])
29 +
30 + // Scroll to hash once content is in the DOM.
31 + useEffect(() => {
32 + if (!mod || !location.hash) return
33 + const el = document.getElementById(decodeURIComponent(location.hash.slice(1)))
34 + if (el) el.scrollIntoView({ block: 'start' })
35 + }, [mod, location.hash])
36 +
37 + if (!guide) return <DocPage><h1>Not found</h1><p className="muted">There is no guide at <code>{location.pathname}</code>.</p></DocPage>
38 + const idx = GUIDES.indexOf(guide)
39 + const prev = GUIDES[idx - 1], next = GUIDES[idx + 1]
40 + const headings = guideHeadings(guide)
41 + const snippets = mod?.snippets || []
42 + const panel = (
43 + <>
44 + <OnThisPage headings={headings} />
45 + {snippets.map((s, i) => <div className="panel-block" key={i}><CodeTabs title={s.title} snippets={s} maxHeight={460} /></div>)}
46 + </>
47 + )
48 + const Content = mod?.default
49 + return (
50 + <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page">
51 + {error && <p role="alert" className="muted">Could not load this guide ({String(error.message || error)}).</p>}
52 + {!mod && !error && <p className="muted" aria-busy="true">Loading…</p>}
53 + {Content && (
54 + <div className="prose">
55 + <MDXProvider components={mdxComponents}><Content /></MDXProvider>
56 + </div>
57 + )}
58 + <PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} />
59 + </DocPage>
60 + )
61 +}
62 +
63 +function sectionTitle(id) {
64 + return { start: 'Getting started', guides: 'Guides', recipes: 'Recipes', fundamentals: 'Fundamentals', more: 'More' }[id] || 'Docs'
65 +}
added hfmarketdata/web/src/pages/docs/OperationPage.jsx +233 −0
@@ -0,0 +1,233 @@
1 +// One page per OpenAPI operation: method + path, markdown description, parameters, request samples
2 +// (curl / Python / JS / R), response example (spec → captured file → live fetch for public GETs), errors, Try it.
3 +import React, { useEffect, useMemo, useState } from 'react'
4 +import { Link, useParams } from 'react-router-dom'
5 +import { BASE_URL, PUBLIC_BASE } from '../../app/api.js'
6 +import Badge, { MethodBadge, StatusBadge } from '../../components/Badge.jsx'
7 +import Callout from '../../components/Callout.jsx'
8 +import Code from '../../components/Code.jsx'
9 +import CodeTabs from '../../components/CodeTabs.jsx'
10 +import { PlayIcon } from '../../components/Icons.jsx'
11 +import Markdown, { InlineMd } from '../../components/Markdown.jsx'
12 +import { buildRequest, exampleParams, hasExampleFile, loadExampleFile, playgroundLink } from '../../docs/enrich.js'
13 +import { snippetsFor } from '../../docs/snippets.js'
14 +import { errorResponses, listOperations, resolveRef, responseExample, useSpec } from '../../docs/spec.js'
15 +import useTitle from '../../docs/useTitle.js'
16 +import { DocPage, OnThisPage, PrevNext } from './DocsLayout.jsx'
17 +
18 +const PUBLIC_TAGS = new Set(['meta', 'bars', 'futures', 'options', 'fundamentals'])
19 +const liveCache = new Map()
20 +
21 +export default function OperationPage() {
22 + const { opId } = useParams()
23 + const { spec, loading, source } = useSpec()
24 + const ops = useMemo(() => (spec ? listOperations(spec) : []), [spec])
25 + const idx = ops.findIndex(o => o.id === opId)
26 + const op = ops[idx]
27 + useTitle(op ? `${op.summary} — ${op.method} ${op.path}` : 'Reference', op?.description?.split('\n')[0])
28 +
29 + if (loading) return <DocPage footer={false}><p className="muted" aria-busy="true">Loading reference…</p></DocPage>
30 + if (!op) return <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }]}><h1>Unknown operation</h1><p className="muted">No operation <code>{opId}</code> in the current specification. <Link to="/docs/reference">Browse the reference</Link>.</p></DocPage>
31 + return <Operation key={op.id} op={op} spec={spec} source={source} prev={ops[idx - 1]} next={ops[idx + 1]} />
32 +}
33 +
34 +function Operation({ op, spec, source, prev, next }) {
35 + const values = useMemo(() => exampleParams(op), [op])
36 + const req = useMemo(() => buildRequest(op, values), [op, values])
37 + const needsAuth = ['auth', 'me', 'admin'].includes(op.tag) || (op.security && op.security.length && !op.security.some(s => Object.keys(s).length === 0))
38 + const isStream = op.tag === 'stream' || /\/stream$/.test(op.path)
39 + const snippets = useMemo(() => snippetsFor({ method: op.method, url: req.url, format: req.format, auth: !!needsAuth, stream: isStream }), [op.method, req.url, req.format, needsAuth, isStream])
40 + const specExample = useMemo(() => responseExample(spec, op), [spec, op])
41 + const errors = useMemo(() => errorResponses(spec, op), [spec, op])
42 + const [example, setExample] = useState(specExample ? { ...specExample, origin: 'spec' } : null)
43 +
44 + useEffect(() => {
45 + if (specExample) return
46 + let alive = true
47 + ;(async () => {
48 + const file = await loadExampleFile(op.id)
49 + if (file && alive) { setExample({ code: file.status || 200, value: file.body ?? file, origin: 'captured', capturedAt: file.captured_at, url: file.url }); return }
50 + if (op.method !== 'GET' || !PUBLIC_TAGS.has(op.tag) || isStream) return
51 + if (liveCache.has(op.id)) { alive && setExample(liveCache.get(op.id)); return }
52 + try {
53 + const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000)
54 + const res = await fetch(BASE_URL + req.rel, { headers: { Accept: 'application/json' }, signal: ctl.signal })
55 + clearTimeout(t)
56 + const ct = res.headers.get('content-type') || ''
57 + if (!ct.includes('json')) return
58 + const body = await res.json()
59 + const ex = { code: res.status, value: trimExample(body), origin: 'live', url: PUBLIC_BASE + req.rel }
60 + liveCache.set(op.id, ex)
61 + alive && setExample(ex)
62 + } catch { /* offline — no example */ }
63 + })()
64 + return () => { alive = false }
65 + }, [op.id, op.method, op.tag, req.rel, specExample, isStream])
66 +
67 + const successCodes = Object.keys(op.responses).filter(c => c.startsWith('2'))
68 + const success = successCodes.map(c => resolveRef(spec, op.responses[c]))
69 + const headers = success[0]?.headers || {}
70 + const params = op.parameters.filter(p => p.in !== 'header' && p.in !== 'cookie')
71 + const body = op.requestBody?.content?.['application/json']
72 + const bodySchema = body ? resolveRef(spec, body.schema) : null
73 + const headings = [
74 + { depth: 2, text: 'Parameters', id: 'parameters' }, ...(bodySchema ? [{ depth: 2, text: 'Request body', id: 'request-body' }] : []),
75 + { depth: 2, text: 'Response', id: 'response' }, { depth: 2, text: 'Errors', id: 'errors' },
76 + ]
77 +
78 + const panel = (
79 + <>
80 + <div className="panel-block">
81 + <CodeTabs title={`${op.method} request`} snippets={snippets} maxHeight={420} />
82 + </div>
83 + <div className="panel-block">
84 + <Code title={example ? `Response · ${example.code}${example.origin === 'live' ? ' · live' : example.origin === 'captured' ? ' · captured' : ''}` : 'Response'} language="json" maxHeight={520}
85 + code={example ? JSON.stringify(example.value, null, 2) : `// No example in the spec yet${op.method === 'GET' && PUBLIC_TAGS.has(op.tag) ? ' — fetching a live one…' : ''}`} />
86 + {example?.origin === 'live' && <p className="source-note">Fetched live from <code>{example.url}</code>.</p>}
87 + {example?.origin === 'captured' && <p className="source-note">Captured from production{example.capturedAt ? ` on ${example.capturedAt.slice(0, 10)}` : ''} by <code>scripts/fetch-examples.mjs</code>.</p>}
88 + </div>
89 + <OnThisPage headings={headings} />
90 + </>
91 + )
92 +
93 + return (
94 + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: op.tag, href: `/docs/reference/tag/${op.tag}` }]} panel={panel} testId="operation-page">
95 + <header>
96 + <h1>{op.summary}</h1>
97 + <div className="op-head">
98 + <MethodBadge method={op.method} />
99 + <span className="op-path">{PUBLIC_BASE}{highlightPath(op.path)}</span>
100 + {op.deprecated && <Badge tone="warn">Deprecated</Badge>}
101 + {needsAuth ? <Badge tone="info">Requires API key</Badge> : <Badge tone="success">Public · keyless OK</Badge>}
102 + </div>
103 + {op.description ? <Markdown className="prose" text={op.description} headingOffset={2} /> : <p className="muted">No description in the specification yet.</p>}
104 + <div className="op-actions">
105 + {!isStream && <Link to={playgroundLink(op, values)} className="btn btn-primary" data-testid="try-it"><PlayIcon /> Try it in the playground</Link>}
106 + <a className="btn" href={`${PUBLIC_BASE}/openapi.json`}>OpenAPI JSON</a>
107 + </div>
108 + </header>
109 +
110 + <section>
111 + <h2 id="parameters">Parameters</h2>
112 + {params.length === 0 && <p className="muted">This endpoint takes no parameters.</p>}
113 + {params.length > 0 && (
114 + <div className="table-wrap">
115 + <table>
116 + <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
117 + <tbody>
118 + {params.map(p => (
119 + <tr key={`${p.in}-${p.name}`} id={`param-${p.name}`}>
120 + <td><span className="param-name">{p.name}</span><span className="param-in">{p.in}</span>{p.required && <span className="param-req">required</span>}</td>
121 + <td><span className="param-type">{p.type}</span>{p.constraints.length > 0 && <div className="param-constraints">{p.constraints.join(' · ')}</div>}</td>
122 + <td>{p.default !== undefined ? <span className="param-default">{String(p.default)}</span> : <span className="muted">—</span>}</td>
123 + <td>
124 + <div className="param-desc"><InlineMd text={p.description} /></div>
125 + {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>}
126 + {values[p.name] !== undefined && <div className="param-constraints">Example: <code>{values[p.name]}</code></div>}
127 + </td>
128 + </tr>
129 + ))}
130 + </tbody>
131 + </table>
132 + </div>
133 + )}
134 + </section>
135 +
136 + {bodySchema && (
137 + <section>
138 + <h2 id="request-body">Request body</h2>
139 + <p className="muted small">application/json{op.requestBody.required ? ' · required' : ''}</p>
140 + <SchemaTable spec={spec} schema={bodySchema} />
141 + {body.example !== undefined && <Code title="Example body" language="json" code={JSON.stringify(body.example, null, 2)} />}
142 + </section>
143 + )}
144 +
145 + <section>
146 + <h2 id="response">Response</h2>
147 + {success.map((r, i) => (
148 + <div key={successCodes[i]} style={{ marginBottom: 12 }}>
149 + <p><StatusBadge code={successCodes[i]} /> <InlineMd text={r.description || 'Success'} /></p>
150 + {r.content && <p className="muted small">Content types: {Object.keys(r.content).map(ct => <code key={ct} style={{ marginRight: 6 }}>{ct}</code>)}</p>}
151 + {r.content?.['application/json']?.schema && <SchemaTable spec={spec} schema={resolveRef(spec, r.content['application/json'].schema)} />}
152 + </div>
153 + ))}
154 + {Object.keys(headers).length > 0 && (
155 + <details>
156 + <summary>Response headers ({Object.keys(headers).length})</summary>
157 + <dl className="headers-list">
158 + {Object.entries(headers).map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd>{v.description}</dd></React.Fragment>)}
159 + </dl>
160 + </details>
161 + )}
162 + {source === 'snapshot' && <Callout type="info" title="Offline specification">The live <code>/openapi.json</code> was not reachable; this page is rendered from the bundled snapshot.</Callout>}
163 + </section>
164 +
165 + <section>
166 + <h2 id="errors">Errors</h2>
167 + <p className="muted small">Every error uses the <Link to="/docs/errors">uniform envelope</Link> <code>{'{"error": {"code", "message", "docs"}}'}</code>.</p>
168 + <div className="errors-list">
169 + {errors.map(e => (
170 + <div className="error-row" key={e.status}>
171 + <StatusBadge code={e.status} />
172 + <div>
173 + {e.codes.length ? e.codes.map(c => <Link key={c} to={`/docs/errors#${c.toLowerCase()}`} style={{ marginRight: 8 }}><code>{c}</code></Link>) : <InlineMd text={e.description} />}
174 + {e.codes.length > 0 && <div className="muted small"><InlineMd text={e.description.replace(/`[A-Z_]+`\s*—\s*/g, '').split('\n\n')[0]} /></div>}
175 + </div>
176 + </div>
177 + ))}
178 + </div>
179 + </section>
180 +
181 + <PrevNext prev={prev && { href: `/docs/reference/${prev.id}`, title: prev.summary }} next={next && { href: `/docs/reference/${next.id}`, title: next.summary }} />
182 + </DocPage>
183 + )
184 +}
185 +
186 +function highlightPath(path) {
187 + return path.split(/(\{[^}]+\})/g).map((part, i) => (part.startsWith('{') ? <span className="var" key={i}>{part}</span> : part))
188 +}
189 +
190 +function trimExample(body, maxRows = 5) {
191 + if (body && typeof body === 'object' && Array.isArray(body.data) && body.data.length > maxRows) {
192 + return { ...body, data: [...body.data.slice(0, maxRows), `… ${body.data.length - maxRows} more rows`] }
193 + }
194 + return body
195 +}
196 +
197 +/** Flat property table for an object schema (one level, arrays of objects expanded once). */
198 +export function SchemaTable({ spec, schema, depth = 0 }) {
199 + const s = resolveRef(spec, schema)
200 + if (!s || typeof s !== 'object') return null
201 + const props = s.properties || (s.items && resolveRef(spec, s.items)?.properties)
202 + if (!props) {
203 + if (s.type || s.description) return <p className="muted small">{s.type || 'object'}{s.description ? ` — ${s.description}` : ''}</p>
204 + return null
205 + }
206 + const required = new Set(s.required || resolveRef(spec, s.items)?.required || [])
207 + return (
208 + <div className="table-wrap" style={{ marginBottom: 12 }}>
209 + <table>
210 + <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
211 + <tbody>
212 + {Object.entries(props).map(([name, p0]) => {
213 + const p = resolveRef(spec, p0) || {}
214 + const type = p.type ? (Array.isArray(p.type) ? p.type.join(' | ') : p.type) : p.anyOf ? p.anyOf.map(x => resolveRef(spec, x)?.type).filter(Boolean).join(' | ') : p.$ref ? 'object' : 'any'
215 + const inner = (p.type === 'object' || p.type === 'array') && depth < 1 && (p.properties || resolveRef(spec, p.items)?.properties)
216 + return (
217 + <tr key={name}>
218 + <td><span className="param-name">{name}</span>{required.has(name) && <span className="param-req">required</span>}</td>
219 + <td><span className="param-type">{type}{p.type === 'array' && p.items ? `<${resolveRef(spec, p.items)?.type || 'object'}>` : ''}{p.format ? ` (${p.format})` : ''}</span></td>
220 + <td>
221 + <div className="param-desc"><InlineMd text={p.description || ''} /></div>
222 + {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>}
223 + {p.example !== undefined && <div className="param-constraints">Example: <code>{JSON.stringify(p.example)}</code></div>}
224 + {inner && <SchemaTable spec={spec} schema={p} depth={depth + 1} />}
225 + </td>
226 + </tr>
227 + )
228 + })}
229 + </tbody>
230 + </table>
231 + </div>
232 + )
233 +}
added hfmarketdata/web/src/pages/docs/ReferenceIndex.jsx +55 −0
@@ -0,0 +1,55 @@
1 +// Reference index (all tags) and per-tag page, generated from the spec.
2 +import React, { useMemo } from 'react'
3 +import { Link, useParams, useSearchParams } from 'react-router-dom'
4 +import { MethodBadge } from '../../components/Badge.jsx'
5 +import Callout from '../../components/Callout.jsx'
6 +import Markdown from '../../components/Markdown.jsx'
7 +import { groupByTag, listOperations, specVersion, useSpec } from '../../docs/spec.js'
8 +import useTitle from '../../docs/useTitle.js'
9 +import { DocPage, OnThisPage } from './DocsLayout.jsx'
10 +
11 +export default function ReferenceIndex() {
12 + const { tag } = useParams()
13 + const [sp] = useSearchParams()
14 + const { spec, loading, source } = useSpec()
15 + const tags = useMemo(() => (spec ? groupByTag(spec, listOperations(spec)) : []), [spec])
16 + const shown = tag ? tags.filter(t => t.name === tag) : tags
17 + useTitle(tag ? `${cap(tag)} — API reference` : 'API reference', 'Every endpoint of the HF Market Data API, generated from the OpenAPI 3.1 specification.')
18 +
19 + // <Endpoint> chips from guides may link with ?find=METHOD /path when the operationId is unknown.
20 + const find = sp.get('find')
21 + const found = find && spec ? listOperations(spec).find(o => `${o.method} ${o.path}` === find) : null
22 +
23 + if (loading) return <DocPage footer={false}><p className="muted" aria-busy="true">Loading specification…</p></DocPage>
24 + const headings = shown.map(t => ({ depth: 2, text: cap(t.name), id: `tag-${t.name}` }))
25 + return (
26 + <DocPage crumbs={tag ? [{ label: 'Reference', href: '/docs/reference' }, { label: tag }] : [{ label: 'Reference' }]} panel={<OnThisPage headings={headings} />} testId="reference-index">
27 + <header>
28 + <h1>{tag ? cap(tag) : 'API reference'}</h1>
29 + {!tag && <p className="lead">Every endpoint, generated from <a href="/openapi.json">openapi.json</a> (OpenAPI 3.1{specVersion(spec) ? `, API v${specVersion(spec)}` : ''}). Each page shows parameters, live examples, error codes and a one-click <em>Try it</em>.</p>}
30 + {found && <Callout type="tip" title="You were looking for">{found.method} <code>{found.path}</code> → <Link to={`/docs/reference/${found.id}`}>{found.summary}</Link></Callout>}
31 + {source === 'snapshot' && <Callout type="info">Rendered from the bundled specification snapshot — the live API was not reachable.</Callout>}
32 + </header>
33 + {shown.length === 0 && <p className="muted">No endpoints for this tag.</p>}
34 + {shown.map(t => (
35 + <section className="ref-tag" key={t.name}>
36 + <h2 id={`tag-${t.name}`}>{cap(t.name)} <span className="muted small">{t.ops.length} endpoint{t.ops.length > 1 ? 's' : ''}</span></h2>
37 + {t.description && <Markdown text={t.description} />}
38 + <div className="ref-op-list">
39 + {t.ops.map(op => (
40 + <Link key={op.id} to={`/docs/reference/${op.id}`} className="ref-op">
41 + <MethodBadge method={op.method} />
42 + <div>
43 + <div className="op-path">{op.path}</div>
44 + <div className="sum">{op.summary}</div>
45 + </div>
46 + </Link>
47 + ))}
48 + </div>
49 + </section>
50 + ))}
51 + </DocPage>
52 + )
53 +}
54 +
55 +const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s)
added hfmarketdata/web/src/pages/docs/docs.css +137 −0
@@ -0,0 +1,137 @@
1 +/* Docs: 3 columns (nav · content · code panel). DOM order = visual order on every breakpoint. */
2 +.docs { display: grid; grid-template-columns: 250px minmax(0, 1fr); flex: 1; width: 100%; max-width: 1480px; margin: 0 auto; }
3 +.docs-nav { position: sticky; top: var(--topbar-h); align-self: start; height: calc(100dvh - var(--topbar-h)); overflow-y: auto; padding: 22px 12px 40px 20px; border-right: 1px solid var(--line); scrollbar-width: thin; }
4 +.docs-nav-section { margin-bottom: 18px; }
5 +.docs-nav-title { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--fg-3); padding: 0 8px; margin-bottom: 6px; }
6 +.docs-nav a { display: block; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; line-height: 1.35; }
7 +.docs-nav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
8 +.docs-nav a.active { color: var(--fg); background: var(--accent-soft); font-weight: 550; }
9 +.docs-nav .nav-op { display: flex; align-items: center; gap: 7px; padding-left: 8px; font-size: 13px; }
10 +.docs-nav .nav-op .method { min-width: 0; font-size: 9.5px; padding: 1px 4px; }
11 +.docs-nav details { margin-bottom: 2px; }
12 +.docs-nav summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-radius: 6px; color: var(--fg-1); font-size: 13.5px; font-weight: 500; }
13 +.docs-nav summary::-webkit-details-marker { display: none; }
14 +.docs-nav summary:hover { background: var(--bg-2); color: var(--fg); }
15 +.docs-nav summary svg { width: 14px; height: 14px; transition: transform .15s; color: var(--fg-3); }
16 +.docs-nav details[open] summary svg { transform: rotate(90deg); }
17 +.docs-nav details > div { padding-left: 6px; border-left: 1px solid var(--line); margin-left: 12px; }
18 +.docs-nav-search { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 16px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); font-size: 13px; cursor: text; text-align: left; }
19 +.docs-nav-search kbd { margin-left: auto; }
20 +.docs-main { min-width: 0; }
21 +.docpage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 0; min-height: calc(100dvh - var(--topbar-h)); }
22 +.docpage.no-panel { grid-template-columns: minmax(0, 1fr); }
23 +.docs-content { min-width: 0; padding: 34px 40px 80px; max-width: 820px; }
24 +.docs-content > header { margin-bottom: 26px; }
25 +.docs-content h1 { font-size: clamp(26px, 3vw, 34px); margin-bottom: 8px; }
26 +.docs-crumbs { display: flex; gap: 6px; align-items: center; color: var(--fg-2); font-size: 13px; margin-bottom: 12px; flex-wrap: wrap; }
27 +.docs-crumbs a { color: var(--fg-2); }
28 +.docs-crumbs svg { width: 12px; height: 12px; }
29 +.docs-panel { border-left: 1px solid var(--line); background: var(--bg-1); padding: 26px 22px 60px; }
30 +.docs-panel-inner { position: sticky; top: calc(var(--topbar-h) + 18px); display: grid; gap: 18px; max-height: calc(100dvh - var(--topbar-h) - 36px); overflow-y: auto; scrollbar-width: thin; padding-right: 2px; }
31 +.docs-panel .code { background: var(--code-bg); }
32 +.docs-panel h4 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 8px; }
33 +.panel-block { display: grid; gap: 6px; }
34 +.docs-footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; color: var(--fg-2); font-size: 13px; }
35 +.docs-footer a { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-1); }
36 +.docs-footer svg { width: 14px; height: 14px; }
37 +.docs-prevnext { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 36px; }
38 +.docs-prevnext a { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); }
39 +.docs-prevnext a:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-1); }
40 +.docs-prevnext .lbl { display: block; font-size: 12px; color: var(--fg-2); margin-bottom: 4px; }
41 +.docs-prevnext .next { text-align: right; }
42 +
43 +/* prose (MDX + markdown descriptions) */
44 +.prose { font-size: 15.5px; line-height: 1.7; color: var(--fg); }
45 +.prose > p:first-of-type { color: var(--fg-1); }
46 +.prose h2 { margin-top: 2.2em; padding-top: .3em; font-size: 22px; }
47 +.prose h3 { margin-top: 1.8em; font-size: 17px; }
48 +.prose h4 { margin-top: 1.4em; font-size: 15px; }
49 +.prose ul, .prose ol { padding-left: 1.4em; margin: 0 0 1em; }
50 +.prose li + li { margin-top: 5px; }
51 +.prose li > p { margin: 0; }
52 +.prose img { border-radius: var(--radius); border: 1px solid var(--line); }
53 +.prose .table-wrap { margin: 0 0 20px; }
54 +.prose table { font-size: 13.5px; }
55 +.prose th, .prose td { padding: 8px 11px; }
56 +.prose blockquote { margin: 0 0 16px; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-1); border-radius: 0 var(--radius) var(--radius) 0; color: var(--fg-1); }
57 +.prose hr { margin: 36px 0; }
58 +.prose .code, .prose .codetabs, .prose .callout { margin: 16px 0 22px; }
59 +.anchor-heading { position: relative; }
60 +.anchor-heading .anchor { position: absolute; left: -1.1em; color: var(--fg-3); opacity: 0; font-weight: 400; padding-right: .3em; }
61 +.anchor-heading:hover .anchor, .anchor-heading .anchor:focus { opacity: 1; text-decoration: none; }
62 +.figure { margin: 18px 0 26px; }
63 +.figure svg { display: block; width: 100%; height: auto; }
64 +.figure figcaption { font-size: 13px; color: var(--fg-2); margin-top: 8px; text-align: center; }
65 +.endpoint-chip { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px 4px 5px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-1); color: var(--fg); margin: 2px 4px 6px 0; font-size: 13px; }
66 +.endpoint-chip:hover { border-color: var(--line-3); text-decoration: none; }
67 +.endpoint-chip code { background: none; border: 0; padding: 0; }
68 +
69 +/* on this page */
70 +.toc a { display: block; padding: 3px 0 3px 10px; border-left: 2px solid var(--line); color: var(--fg-2); font-size: 13px; line-height: 1.4; }
71 +.toc a.depth-3 { padding-left: 22px; }
72 +.toc a:hover { color: var(--fg); text-decoration: none; }
73 +.toc a.active { color: var(--fg); border-left-color: var(--accent); }
74 +
75 +/* operation page */
76 +.op-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 14px; }
77 +.op-path { font-family: var(--mono); font-size: 15px; word-break: break-all; }
78 +.op-path .var { color: var(--accent-2); }
79 +.op-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 26px; }
80 +.param-name { font-family: var(--mono); font-size: 13px; font-weight: 600; }
81 +.param-in { font-size: 11px; color: var(--fg-3); margin-left: 6px; text-transform: uppercase; letter-spacing: .04em; }
82 +.param-type { font-family: var(--mono); font-size: 12px; color: var(--fg-2); }
83 +.param-req { color: var(--danger); font-size: 11px; font-weight: 600; margin-left: 6px; }
84 +.param-default { font-family: var(--mono); font-size: 12px; color: var(--fg-2); }
85 +.param-enum { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
86 +.param-enum code { font-size: 11.5px; }
87 +.param-desc { color: var(--fg-1); font-size: 13.5px; }
88 +.param-desc p { margin: 0; }
89 +.param-constraints { font-size: 12px; color: var(--fg-3); margin-top: 3px; }
90 +.errors-list { display: grid; gap: 6px; }
91 +.error-row { display: grid; grid-template-columns: 52px 1fr; gap: 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
92 +.error-row code { font-size: 12px; }
93 +.headers-list dt { font-family: var(--mono); font-size: 12.5px; font-weight: 600; margin-top: 8px; }
94 +.headers-list dd { margin: 2px 0 0; color: var(--fg-1); font-size: 13px; }
95 +.source-note { font-size: 12px; color: var(--fg-3); }
96 +
97 +/* reference index */
98 +.ref-tag { margin-bottom: 36px; }
99 +.ref-tag h2 { text-transform: capitalize; display: flex; align-items: baseline; gap: 10px; }
100 +.ref-op-list { display: grid; gap: 6px; }
101 +.ref-op { display: grid; grid-template-columns: 56px 1fr; gap: 12px; align-items: baseline; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--fg); }
102 +.ref-op:hover { border-color: var(--line-3); background: var(--bg-1); text-decoration: none; }
103 +.ref-op .op-path { font-size: 13.5px; }
104 +.ref-op .sum { color: var(--fg-2); font-size: 13px; margin-top: 2px; }
105 +
106 +/* docs home */
107 +.docs-home-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0 30px; }
108 +.docs-home-card { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--fg); background: var(--bg-1); }
109 +.docs-home-card:hover { border-color: var(--line-3); text-decoration: none; }
110 +.docs-home-card h3 { font-size: 15px; margin-bottom: 4px; }
111 +.docs-home-card p { margin: 0; color: var(--fg-2); font-size: 13.5px; }
112 +
113 +/* error code page */
114 +.error-code-block { padding: 16px 0 22px; border-bottom: 1px solid var(--line); }
115 +.error-code-block h3 { font-family: var(--mono); font-size: 15px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
116 +
117 +/* mobile nav toggle */
118 +.docs-nav-toggle { display: none; }
119 +
120 +@media (max-width: 1180px) {
121 + .docpage { grid-template-columns: minmax(0, 1fr); }
122 + .docs-panel { border-left: 0; border-top: 1px solid var(--line); padding: 22px 40px 50px; }
123 + .docs-panel-inner { position: static; max-height: none; overflow: visible; }
124 + .docs-content { max-width: 900px; }
125 +}
126 +@media (max-width: 900px) {
127 + .docs { grid-template-columns: 1fr; }
128 + .docs-nav { display: none; }
129 + .docs-nav.open { display: block; position: fixed; inset: var(--topbar-h) 0 0 0; z-index: 45; height: auto; background: var(--bg); border-right: 0; padding: 16px; }
130 + .docs-nav-toggle { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--bg-1); position: sticky; top: var(--topbar-h); z-index: 40; font-size: 13.5px; color: var(--fg-1); }
131 + .docs-nav-toggle button { display: inline-flex; align-items: center; gap: 6px; }
132 + .docs-content { padding: 24px 16px 60px; }
133 + .docs-panel { padding: 18px 16px 40px; }
134 + .docs-prevnext { grid-template-columns: 1fr; }
135 + .docs-home-grid { grid-template-columns: 1fr; }
136 + .anchor-heading .anchor { display: none; }
137 +}
modified hfmarketdata/web/vite.config.js +57 −5
@@ -1,18 +1,70 @@
1 +import { readdirSync, readFileSync } from 'node:fs'
2 +import { join, resolve } from 'node:path'
1 3 import { defineConfig } from 'vite'
2 4 import react from '@vitejs/plugin-react'
3 5 import mdx from '@mdx-js/rollup'
4 6
7 +const CONTENT = resolve(__dirname, 'content')
8 +
9 +// Same slug rule as the MDX heading components (src/docs/mdx-components.jsx) — anchors must match search results.
10 +export const slugify = s => s.toLowerCase().replace(/[`*_]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
11 +
12 +function extractHeadings(src) {
13 + const out = []
14 + let inFence = false
15 + for (const line of src.split('\n')) {
16 + if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue }
17 + if (inFence) continue
18 + const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line)
19 + if (m) out.push({ depth: m[1].length, text: m[2].replace(/[`*_]/g, ''), id: slugify(m[2]) })
20 + }
21 + return out
22 +}
23 +
24 +// virtual:guides-index → { [file]: [{ depth, text, id }] } for every content/guides/*.mdx + content/changelog.mdx.
25 +// Lets search and the "On this page" outline work without loading every guide chunk.
26 +function guidesIndexPlugin() {
27 + const id = 'virtual:guides-index'
28 + const resolved = '\0' + id
29 + const build = () => {
30 + const index = {}
31 + for (const f of readdirSync(join(CONTENT, 'guides')).filter(f => f.endsWith('.mdx'))) {
32 + index[f.replace(/\.mdx$/, '')] = extractHeadings(readFileSync(join(CONTENT, 'guides', f), 'utf8'))
33 + }
34 + try { index.changelog = extractHeadings(readFileSync(join(CONTENT, 'changelog.mdx'), 'utf8')) } catch { index.changelog = [] }
35 + return index
36 + }
37 + return {
38 + name: 'hfmd-guides-index',
39 + resolveId(source) { return source === id ? resolved : null },
40 + load(source) {
41 + if (source !== resolved) return null
42 + this.addWatchFile(join(CONTENT, 'guides'))
43 + return `export default ${JSON.stringify(build())}`
44 + },
45 + handleHotUpdate({ file, server }) {
46 + if (file.startsWith(CONTENT)) {
47 + const mod = server.moduleGraph.getModuleById(resolved)
48 + if (mod) server.moduleGraph.invalidateModule(mod)
49 + }
50 + },
51 + }
52 +}
53 +
5 54 // Dev proxy → local API (uvicorn on :8090). In production the API serves dist/ itself (SPA fallback in main.py).
6 55 export default defineConfig({
7 − plugins: [{ enforce: 'pre', ...mdx({ providerImportSource: '@mdx-js/react' }) }, react()],
56 + plugins: [guidesIndexPlugin(), { enforce: 'pre', ...mdx({ providerImportSource: '@mdx-js/react' }) }, react()],
8 57 build: {
9 58 target: 'es2020',
59 + cssCodeSplit: true,
10 60 rollupOptions: {
11 61 output: {
12 − manualChunks: {
13 − vendor: ['react', 'react-dom', 'react-router-dom'],
14 − charts: ['lightweight-charts'],
15 − prism: ['prism-react-renderer'],
62 + manualChunks(id) {
63 + if (id.includes('node_modules/lightweight-charts')) return 'charts'
64 + if (id.includes('node_modules/prism-react-renderer')) return 'prism'
65 + if (id.includes('node_modules/@mdx-js')) return 'mdx'
66 + if (/node_modules\/(react|react-dom|react-router|react-router-dom|scheduler|@remix-run)\//.test(id)) return 'vendor'
67 + return undefined
16 68 },
17 69 },
18 70 },
19 71