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: refonte design — étape 1 (tokens, composants unifiés, home, docs, playground, status, légal, 404, prerender, e2e)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 02b779a

81 changed files +2,909 −2,351

modified hfmarketdata/web/README.md +124 −73
@@ -1,8 +1,8 @@
1 1 # hfmarketdata/web — site, docs, playground, dashboard
2 2
3 3 Vite 6 + React 18 + react-router 6. Dark by default, light theme persisted. No CSS framework, no external
4 −requests at runtime (system font stacks, inline SVG, all JS bundled). Served by the FastAPI app from `dist/`
5 −(SPA fallback in `api/main.py`), or by `vite preview` for E2E.
4 +requests at runtime (system sans stack, self-hosted JetBrains Mono, inline SVG, all JS bundled). Served by the FastAPI
5 +app from `dist/` (SPA fallback in `api/main.py`), or by `vite preview` for E2E.
6 6
7 7 ```
8 8 npm install
@@ -10,57 +10,103 @@ npm run dev # http://localhost:5173 — proxies /v1, /health, /openapi.
10 10 npm run build # vite build + scripts/prerender.mjs → dist/
11 11 npm run test:e2e # Playwright against `vite preview` (build first)
12 12 npm run examples # refresh content/examples/*.json + openapi snapshot from production
13 +node scripts/make-brand-assets.mjs # regenerate public/og.png + PNG icons (Playwright)
13 14 ```
14 15
16 +## Rule: the site never spends the visitor's quota by itself
17 +
18 +Keyless access is 30 requests / hour per IP. The homepage makes **one** call on load (`GET /v1/status`, quota-exempt)
19 +and falls back silently to `content/examples/status_v1_status_get.json`. Docs pages show the captured example and
20 +only fetch on an explicit **Run live**. The playground's empty state is the captured example of the selected
21 +endpoint; only **Run** (or a valid deep link, once) hits the API. A 429 is always shown as *rate limited — not down*,
22 +with a countdown and a free-account CTA (`ErrorState`).
23 +
15 24 ## Architecture
16 25
17 26 ```
18 −index.html theme applied before first paint (inline script reads localStorage hfmd.theme)
27 +index.html theme applied before first paint (inline script reads localStorage hfmd.theme); og.png, icons, manifest
19 28 src/main.jsx BrowserRouter › AuthProvider › LangProvider › App
20 29 src/App.jsx route table, one lazy chunk per area (owner comments inside)
21 −src/app/ Layout (topbar · drawer · search · footer), theme.css (tokens), theme.js, api.js, auth.jsx
30 +src/app/ Layout (topbar · drawer · search · footer), theme.css (design tokens + utilities), theme.js, api.js, auth.jsx
22 31 src/components/ shared UI (below) + components.css
23 32 src/docs/ docs engine: spec.js (OpenAPI loader), enrich.js (example values), snippets.js (code gen),
24 33 guides.js (registry), search.js (index), mdx-components.jsx, useTitle.js
25 34 src/pages/home|limits|status marketing / limits / live status pages (web-core)
26 −src/pages/docs DocsLayout (3 columns), DocsHome, ReferenceIndex, OperationPage, GuidePage, ErrorsPage
35 +src/pages/legal /terms, /privacy, /data-license (MDX in the same folder)
36 +src/pages/NotFound.jsx 404 with search + links
37 +src/pages/docs DocsLayout (nav · content · panel), DocsHome, ReferenceIndex, OperationPage, GuidePage, ErrorsPage
27 38 src/pages/playground|auth|dashboard|admin web-app agent · src/pages/integrations mcp agent
39 +src/playground/ Playground (app), EndpointPicker (combobox), RequestForm, catalog.js, codegen.js, views/
28 40 content/guides/*.mdx guides (MDX) · content/changelog.mdx
29 41 content/examples/<opId>.json captured real responses (scripts/fetch-examples.mjs) — commit them
42 +content/examples/home-aapl-daily.json one year of daily AAPL bars for the homepage chart (compact rows)
30 43 content/openapi.snapshot.json bundled spec fallback when /openapi.json is unreachable (e.g. vite preview)
31 −content/samples/aapl-pe.json homepage chart fallback (scripts/make-aapl-pe-sample.mjs)
32 −scripts/ prerender.mjs · fetch-examples.mjs · make-aapl-pe-sample.mjs · bench-formats.mjs · bench-parquet.py
33 −e2e/ Playwright specs (site, docs, mobile) · playwright.config.js
34 −public/ favicon.svg, logo.svg, og.svg, robots.txt (copied verbatim to dist/)
44 +content/samples/aapl-pe.json homepage fundamentals chart (scripts/make-aapl-pe-sample.mjs)
45 +public/ favicon.svg, logo.svg, og.png, icon-{32,192,512}.png, apple-touch-icon.png, manifest.webmanifest,
46 + fonts/jetbrains-mono-latin.woff2 (31 KB, variable weight), robots.txt, downloads/
47 +scripts/ prerender.mjs · fetch-examples.mjs · make-aapl-pe-sample.mjs · make-brand-assets.mjs · bench-*
48 +e2e/ Playwright specs (site, docs, mobile, playground, auth, dashboard) · playwright.config.js
35 49 ```
36 50
37 −Chunks (`vite.config.js › manualChunks`): `vendor` (react, router), `prism`, `charts` (lightweight-charts, only
38 −loaded by the homepage chart / playground), `mdx`, one chunk per page and per guide.
51 +Chunks (`vite.config.js › manualChunks`): `vendor` (react, router), `prism` (lazy — `CodeHighlight.jsx` is loaded on
52 +demand by `CodeBlock`, so a page paints its code as plain text first), `charts` (lightweight-charts, lazy: homepage
53 +charts load when scrolled into view, playground Chart tab), `mdx`, one chunk per page, guide and captured example.
54 +
55 +## Design system (`src/app/theme.css`)
39 56
40 −### Theme
57 +Tokens live in `:root` (dark) with `[data-theme="light"]` overrides. Breakpoints: **640 / 960 / 1200 px**.
41 58
42 −Tokens live in `src/app/theme.css` (`:root` dark, `[data-theme="light"]` overrides). `src/app/theme.js` exposes
43 −`useTheme()` → `{ theme, toggle, set }` and `usePersisted(key, initial)` (localStorage-backed `useState`). Colours for
44 −code blocks are CSS variables too (`--code-*`) so Prism follows the theme without a second stylesheet. HTTP verb colours:
45 −`--get --post --put --patch --delete`.
59 +| Group | Tokens |
60 +| --- | --- |
61 +| Surfaces | `--bg --bg-1 --bg-2 --bg-3`, lines `--line --line-2 --line-3` |
62 +| Text | `--fg --fg-1 --fg-2 --fg-3` (all ≥ 4.5:1 on `--bg`/`--bg-1`/`--bg-2`), `--fg-muted-deco` (decorative only, never for text) |
63 +| Accent | `--accent --accent-ink --accent-soft --accent-strong`, `--accent-2 --accent-2-soft` |
64 +| Market | `--up --down --neutral` (candles, deltas — never raw hex in charts) |
65 +| States | `--warn --danger --info --success` + `*-soft` |
66 +| Verbs | `--get --post --put --patch --delete` |
67 +| Type | `--sans`, `--mono` (JetBrains Mono self-hosted), `--fs-0…8` = 12/13/14/15/17/20/24/32/44, `--lh-tight/snug/body/prose` |
68 +| Space / radius / shadow | `--sp-1…10` (4·n), `--r-sm --r-md --r-lg` (6/10/16; `--radius`/`--radius-lg` are legacy aliases), `--shadow-1 --shadow-2` |
69 +| Layers | `--z-sticky 10 · --z-drawer 60 · --z-modal 80` |
70 +| Controls | `--tap` = 36 px desktop, 44 px under 960 px — every button/link/input uses it as min-height |
71 +
72 +Utility classes (usable in any page, including the dashboard): `.btn .btn-primary .btn-secondary .btn-ghost .btn-danger
73 +.btn-sm .btn-lg .btn-block`, `.icon-btn`, `.card .card-sm`, `.grid .grid-2/3/4`, `.stack .row`, `.eyebrow .lead .muted
74 +.small .mono .num .up .down .neutral`, `.table-wrap` (+ `.dense`, `.sticky-first`, `.table-stack` for label/value rows
75 +under 640 px), **`.tabs-nav`** (horizontal scrolling tab bar for links/buttons, `.active` or `aria-current`),
76 +**`.stat .stat-label .stat-value .stat-hint .stat-delta`**, **`.skeleton .skeleton-block .skeleton-text`**,
77 +**`.empty-state`**, **`.error-state`** (+ `.is-429`, `.countdown`), `.sr-only`, `.skip-link`.
78 +
79 +Accessibility: single `:focus-visible` ring (no double ring on inputs), `prefers-reduced-motion`, skip link, roles on
80 +tabs / dialog / combobox / listbox, contrast AA on every text token.
46 81
47 82 ## Shared components (`src/components/`, barrel `index.js`)
48 83
49 84 | Component | Props | Notes |
50 85 | --- | --- | --- |
51 −| `Code` | `code, language='text', title?, copy=true, maxHeight?, lineNumbers=false` | Prism (prism-react-renderer) + bundled `bash`/`r` grammars added in `Code.jsx`; copy button; scrolls inside when `maxHeight` set. `CopyButton({ text })`, `useCopy()` exported. |
52 −| `CodeTabs` | `snippets, title?, maxHeight?` | `snippets` = `{ curl, python, javascript, r }` (any subset) **or** `[{ id, label, language, code }]`. Uses the global persisted language (`hfmd.lang`). |
53 −| `Tabs` | `items:[{id,label,content?,icon?}], value?, onChange?, defaultValue?, size='md'|'sm', children?(activeId)` | Accessible tablist (roving focus, arrows/Home/End). |
54 −| `Callout` | `type='info'|'tip'|'warning'|'danger', title?` | Aside with icon; `role=alert` for warning/danger. |
55 −| `Table` | `columns:[{key,label,align?,render?,width?}], rows, keyField?, caption?, dense?` | Responsive wrapper `.table-wrap`. |
56 −| `Badge` / `MethodBadge` / `StatusBadge` | `tone='neutral'|'success'|'info'|'warn'|'danger'` / `method` / `code` | |
86 +| `CodeBlock` (alias `Code`) | `code, language='text', title?, copy=true, wrapToggle=true, wrap?, maxHeight?, lineNumbers=false, actions?, footer?` | Prism (lazy chunk, theme via `--code-*` vars, bash/R/toml/http grammars added). Header: title, extra `actions`, wrap toggle (persisted `hfmd.codewrap`), copy. `footer` = caption under the code. |
87 +| `CopyButton` / `useCopy()` | `text (string \| () => string), label='Copy', size='sm'\|'md', iconOnly, variant='ghost'\|'secondary'` | The only clipboard helper. |
88 +| `CodeTabs` | `snippets, title?, maxHeight?, footer?, lineNumbers?` | `snippets` = `{ curl, python, javascript, r }` (any subset) **or** `[{ id, label, language, code }]`. Uses the global persisted language (`hfmd.lang`). |
89 +| `Button` / `IconButton` | `variant='primary'\|'secondary'\|'ghost'\|'danger', size='sm'\|'md'\|'lg', to?, href?, block, loading, disabled` / `label (required), to?, href?` | Renders `<Link>` / `<a>` when `to` / `href` is set. |
90 +| `Tabs` | `items:[{id,label,content?,icon?,badge?,disabled?}], value?, onChange?, defaultValue?, size='md'\|'sm', right?, ariaLabel, panel=true, children?(activeId)` | Accessible tablist (roving focus, arrows/Home/End, skips disabled). `right` = slot at the end of the bar. |
91 +| `Callout` | `type='info'\|'tip'\|'success'\|'warning'\|'danger', title?, action?, compact` | `kind` accepted as alias (`warn` → `warning`) — `PgCallout.jsx` re-exports it for the dashboard/auth pages. `role=alert` for warning/danger. |
92 +| `Card` | `as='div', to?, size='md'\|'sm'` | Link card when `to` is set. |
93 +| `Table` | `columns:[{key,label,align?,render?,width?,mono?}], rows, keyField?, caption?, dense, stickyFirst, stack, maxHeight?, emptyText` | Sticky header inside `.table-wrap`; `stack` turns rows into label/value pairs under 640 px (cells carry `data-label`). |
94 +| `Field` / `Input` / `Select` / `Textarea` | `Field: label, hint, error, required, id?, inline, labelExtra` — wires `id`, `aria-describedby`, `aria-invalid` on its single child. `Select` accepts `options` (strings or `{value,label}`). Controls accept `invalid`, `Input` accepts `mono`. |
95 +| `Skeleton` | `width, height, lines?, block` | Shimmer placeholder. |
96 +| `EmptyState` | `icon?, title, children, actions?` | Dashed box, centred. |
97 +| `ErrorState` | `status, code?, message?, title?, retryUntil? (epoch s), authenticated, docs?, onRetry?, actions?` | Network (status 0) / 4xx / **429 with live countdown + free-account CTA** / 5xx. Also exports `useCountdown(epochSec)`, `fmtDuration(s)`. |
98 +| `Stat` | `label, value, hint?, delta?, loading` | Number tile (`delta` coloured with `--up`/`--down`). |
99 +| `Badge` / `MethodBadge` / `StatusBadge` / `TierBadge` | `tone='neutral'\|'success'\|'info'\|'warn'\|'danger', dot` / `method` / `code` / `tier` | |
100 +| `BrandLogo` | `name='claude'\|'cursor'\|'codex'\|'mcp'\|'skills'\|'python'\|'r'\|'javascript', size=40` | One source for the homepage and Integrations logos. |
57 101 | `LangSelector` | `value?, onChange?, size` | Radio group over `LANGS` (`curl, python, javascript, r`); `useLang()` → `[lang, setLang]`; wrap app in `LangProvider`. |
58 −| `Markdown` / `InlineMd` | `text` or children, `headingOffset?` | Safe Markdown → React (no innerHTML): headings, lists, fenced code, tables, blockquotes, inline code/bold/italic/links (unsafe schemes dropped; site links become router `Link`s). |
59 −| `SearchDialog` | `open, onClose` | ⌘K/Ctrl+K and `/` are bound in `Layout`; index built lazily from spec + guides. |
60 −| `Icons` | — | Stroke icons (`SunIcon`, `CopyIcon`, `PlayIcon`, …), 18px, currentColor. |
102 +| `Markdown` / `InlineMd` | `text` or children, `headingOffset?` | Safe Markdown → React (no innerHTML). |
103 +| `SearchDialog` | `open, onClose, initialQuery?` | ⌘K/Ctrl+K and `/` are bound in `Layout`; any page can open it with a query: `window.dispatchEvent(new CustomEvent('hfmd:search', { detail: 'rate limits' }))`. |
104 +| `useMediaQuery(query)` | → boolean | Used by the docs layout to move the code panel inline under 1200 px. |
105 +| `Icons` | — | Stroke icons (`SunIcon`, `CopyIcon`, `PlayIcon`, `WrapIcon`, `CheckCircleIcon`, `InboxIcon`, `WifiOffIcon`, …), 18px, currentColor. |
61 106
62 −CSS classes reusable anywhere: `.btn .btn-primary .btn-ghost .btn-sm .btn-lg`, `.card`, `.grid .grid-2/3/4`, `.eyebrow`,
63 −`.lead`, `.muted`, `.small`, `.mono`, `.num`, `.table-wrap`, `.sr-only`, `.skeleton` (home.css).
107 +Compatibility aliases kept for pages owned by other agents: `components/PgCallout.jsx` (→ `Callout`, `kind` prop) and
108 +`components/PgCopyButton.jsx` (→ `CopyButton`, `{ text, label, small }`). `PgTabs`, `PgCodeBlock`, `IntCopyBlock`,
109 +`Code.jsx`, `src/styles.css` and `src/legacy-spec.js` were removed.
64 110
65 111 ## Docs engine
66 112
@@ -73,38 +119,46 @@ CSS classes reusable anywhere: `.btn .btn-primary .btn-ghost .btn-sm .btn-lg`, `
73 119 (resolved, with type/default/enum/constraints), responses }`; `groupByTag` orders by `spec.tags`.
74 120 FastAPI's `^(a|b)$` patterns are surfaced as enums.
75 121 3. `enrich.js › exampleParams(op)` derives realistic values: spec `example` → `PATH_OVERRIDES["METHOD /path"]` →
76 − `NAME_DEFAULTS[paramName]` (AAPL, ES, ESZ25, CL, 2024-06-03…) → schema default/enum. `buildRequest(op, values)`
77 − → `{ url, rel, query, format }`; `playgroundLink(op, values)`.
78 −4. `snippets.js › snippetsFor({ method, url, format, auth, stream })` → `{ curl, python, javascript, r }`
79 − (requests+pandas, fetch, httr2; Parquet/CSV parsing lines vary with `format`; `$HFMD_API_KEY` env var, never a key).
80 −5. Response example: spec example (`responseExample`) → `content/examples/<operationId>.json` (captured at build by
81 − `npm run examples`) → live fetch for public GET operations (cached in memory). Errors: `errorResponses()` parses the
82 − `\`CODE\`` markers that `api/openapi.py` writes into 4xx/5xx descriptions; every code links to `/docs/errors#<code>`.
83 −6. Routes: `/docs/reference` (index), `/docs/reference/tag/<tag>`, `/docs/reference/<operationId>`, `/docs/errors`.
122 + `NAME_DEFAULTS[paramName]` → schema default/enum. `buildRequest(op, values)` → `{ url, rel, query, format }`;
123 + `playgroundLink(op, values)`.
124 +4. `snippets.js › snippetsFor({ method, url, format, auth, stream })` → `{ curl, python, javascript, r }`.
125 +5. Response example: spec example → `content/examples/<operationId>.json` (captured by `npm run examples`) →
126 + **nothing automatic**. Public GET pages show a *Run live* button in the response header (one request of the
127 + visitor's quota; 429 → "rate limited — not down" + countdown).
128 +6. Layout: `DocPage({ intro, children, after, panel })`. Above 1200 px the panel (On this page → request → response)
129 + is a sticky aside; below, `useMediaQuery` renders it **inside the article right after `intro`** (before
130 + Parameters) — DOM order = visual order, no CSS reordering. Content column ≤ 720 px + 420 px panel.
131 +7. Headings: the `#` anchor is an empty `<a class="anchor">` drawn with `::before`, so `textContent` stays clean.
132 +8. Routes: `/docs/reference` (index), `/docs/reference/tag/<tag>`, `/docs/reference/<operationId>`, `/docs/errors`.
84 133
85 134 **Guides** are MDX in `content/guides/`. The `virtual:guides-index` Vite plugin (in `vite.config.js`) extracts `##`/`###`
86 135 headings of every guide at build time → search index + "On this page" outline without loading the guide chunks.
87 −Heading ids use the same `slugify` in the plugin and in `mdx-components.jsx`.
88 136
89 137 ### Add a guide
90 138
91 −1. Create `content/guides/<file>.mdx`. Optional exports:
92 − ```js
93 − export const meta = { title, description }
94 − export const snippets = [{ title, curl, python, javascript, r }] // → right-hand code panel
95 − ```
96 − Components available without import: `Callout`, `CodeTabs`, `Tabs`, `Table`, `Badge`, `MethodBadge`, `Code`,
97 − `Endpoint method path id?` (chip linking to the reference), `TryIt ep params`, `Url path`, `Figure caption`, `Link`.
98 − MDX caveats: no bare `<` or `{` in prose (use code spans), SVG attributes in camelCase.
99 −2. Register it in `src/docs/guides.js › GUIDES` (`slug`, `title`, `section`, `file`, `summary`). Sections:
100 − `start · guides · recipes · fundamentals · more`. Nav, search, prev/next and the sitemap pick it up automatically.
139 +1. Create `content/guides/<file>.mdx`. Optional exports: `meta = { title, description }`,
140 + `snippets = [{ title, curl, python, javascript, r }]` (→ right-hand code panel). Components available without
141 + import: `Callout`, `CodeTabs`, `Tabs`, `Table`, `Badge`, `MethodBadge`, `Code`, `Endpoint method path id?`,
142 + `TryIt ep params`, `Url path`, `Figure caption`, `Link`. MDX caveats: no bare `<` or `{` in prose, SVG attributes
143 + in camelCase.
144 +2. Register it in `src/docs/guides.js › GUIDES` (`slug`, `title`, `section`, `file`, `summary`). Nav, search,
145 + prev/next, sitemap **and the prerendered shell** pick it up automatically (`scripts/prerender.mjs` parses `GUIDES`).
101 146
102 147 ### Errors page
103 148
104 149 `/docs/errors` lists every code of `components.schemas.Error.properties.code.enum` (plus curated meaning/fix in
105 150 `ErrorsPage.jsx › ERROR_INFO`) with anchors `#<code_lower>` — the API's `error.docs` URLs point there.
106 151
107 −## Playground deep-link contract (`/playground?ep=…`)
152 +## Playground
153 +
154 +Layout: **endpoint picker** (`EndpointPicker.jsx`, a command-palette combobox: search by title/path/group, grouped
155 +listbox, ↑ ↓ Enter Esc) + preset chips → **request card** (parameters grid built from `catalog.js`, URL bar, Run —
156 +sticky at the bottom on mobile) → **results** full width (tabs JSON / Table / Chart / Code / Download) → rate-limit
157 +panel. Empty state = the captured production example of the endpoint (`content/examples/<operationId>.json`) with
158 +"Edit a parameter and run". `catalog.js › normalizeValues()` recomputes dependent enums (e.g. `adjustment` after
159 +`asset` arrives from a deep link) so Run is never blocked by "Not an allowed value".
160 +
161 +### Deep-link contract (`/playground?ep=…`)
108 162
109 163 Every reference page's **Try it** button and the MDX `<TryIt>` component produce:
110 164
@@ -112,44 +166,41 @@ Every reference page's **Try it** button and the MDX `<TryIt>` component produce
112 166 /playground?ep=<operationId>&<paramName>=<value>&<paramName>=<value>…
113 167 ```
114 168
115 −- `ep` — the OpenAPI `operationId` (e.g. `bars_v1_bars__asset___ticker__get`). The playground should resolve it via
116 − `listOperations(spec)` from `src/docs/spec.js` and select that endpoint.
117 −- Every other query key is a **parameter name of that operation** (path or query), URL-encoded, already validated
118 − against the spec's enums/patterns by construction. Unknown keys should be ignored.
119 −- `format` may be present (`json|csv|parquet`); `api_key` is never included.
120 −- Helpers to reuse: `exampleParams(op)`, `buildRequest(op, values)`, `snippetsFor(...)`, `rateHeaders(res)` (`api.js`).
169 +- `ep` — the OpenAPI `operationId` (e.g. `bars_v1_bars__asset___ticker__get`) or a catalog id (`stock-bars`). The
170 + playground resolves it via `catalog.js › findEntry` (fixed params like `asset=futures` pick the matching entry), then
171 + `/openapi.json` for unknown ids (generic form).
172 +- Every other query key is a **parameter name of that operation**, URL-encoded. Unknown keys are ignored. `format` may be
173 + present (`json|csv|parquet`); `api_key` is never included.
174 +- A deep link with `ep` **auto-runs once** when the form validates (one request). `/playground` alone never calls the API.
121 175
122 176 ## Prerender (LCP)
123 177
124 178 `npm run build` runs `vite build` (with `build.manifest`) then `scripts/prerender.mjs`, which writes static shells for
125 −`/`, `/limits`, `/docs`, `/docs/quickstart`, `/status` — as both `dist/<route>/index.html` and `dist/<route>.html` so
126 −every static server finds them (`/` in place) — plus `dist/sitemap.xml`:
127 −
128 −- route-specific `<title>`, description, OpenGraph, canonical;
129 −- `<link rel="modulepreload">` for the route's whole chunk graph and `<link rel="stylesheet">` for its CSS — the
130 − lazy-route waterfall disappears, so the first paint of the real page is one round-trip after HTML;
131 −- above-the-fold markup (header, h1, lead) inside `#root`, styled by the already-linked CSS, so LCP happens before
132 − React mounts. `createRoot().render()` then swaps it for the live tree.
133 −
134 −Trade-off: this is *not* SSR — dynamic content (counters, spec-driven reference) is not in the HTML. Full SSR would
135 −require a second SSR bundle and a Node render per route around lazy routes, MDX and browser-only libraries
136 −(lightweight-charts), for pages whose content is mostly fetched live anyway. The shell approach costs ~60 lines and no
137 −runtime dependency. **Backend note**: the FastAPI SPA fallback serves `dist/index.html` for directory paths; to serve the
138 −shells it should try, in order, `candidate`, `candidate.with_suffix(".html")` and `candidate / "index.html"` before
139 −falling back to `index.html`.
179 +`/`, `/limits`, `/status`, `/playground`, `/integrations`, `/terms`, `/privacy`, `/data-license`, `/docs`,
180 +`/docs/reference`, `/docs/errors` and **every guide in `GUIDES`** — as both `dist/<route>/index.html` and
181 +`dist/<route>.html` — plus `dist/sitemap.xml`. Each shell has route-specific `<title>`, description, OpenGraph,
182 +canonical, `<link rel="modulepreload">` for the route's chunk graph (guide chunk included), its CSS, and the h1 + lead
183 +inside `#root`. Trade-off: not SSR — dynamic content is not in the HTML (see git history for the reasoning).
184 +**Backend note**: the SPA fallback should try `candidate`, `candidate.html` and `candidate/index.html` before `index.html`,
185 +and return 404 for unknown paths (the client renders `NotFound`).
140 186
141 187 ## Scripts
142 188
143 189 | Script | Purpose |
144 190 | --- | --- |
145 −| `scripts/fetch-examples.mjs` | For each public GET operation without a spec example, call production with realistic params and store `content/examples/<operationId>.json` (`{ url, params, status, headers, captured_at, body }`, `data` trimmed to 5 rows). Refreshes `content/openapi.snapshot.json` when the live spec has the `Error` schema. Flags: `--base`, `--only a,b`, `--force`. |
146 −| `scripts/make-aapl-pe-sample.mjs` | Builds `content/samples/aapl-pe.json` (weekly AAPL split-adjusted close ÷ 10-K diluted EPS, labelled as such) — homepage chart fallback until `/v1/fundamentals/AAPL/ratios/daily` is live. |
191 +| `scripts/fetch-examples.mjs` | For each public GET operation without a spec example, call production with realistic params and store `content/examples/<operationId>.json`. Flags: `--base`, `--only a,b`, `--force`. |
192 +| `scripts/make-brand-assets.mjs` | Renders `public/og.png` (1200×630), `apple-touch-icon.png` (180) and `icon-{32,192,512}.png` from inline HTML with Playwright. |
193 +| `scripts/make-aapl-pe-sample.mjs` | Builds `content/samples/aapl-pe.json` (weekly AAPL split-adjusted close ÷ 10-K diluted EPS) — homepage fundamentals chart. |
147 194 | `scripts/bench-formats.mjs` + `bench-parquet.py` | Real JSON/CSV/Parquet size measurements used in the Data formats guide. |
148 195 | `scripts/prerender.mjs` | See above. |
149 196
150 197 ## E2E
151 198
152 199 `playwright.config.js` starts `vite preview` on :4173 (Chromium desktop + Pixel 7 for `mobile.spec.js`). Without the
153 −API, docs render from the bundled snapshot and the homepage/status pages exercise their fallbacks — which is exactly
154 −what the tests assert (docs navigation, ⌘K search with keyboard, Try-it link format, limits content and CTAs, theme
155 −toggle persistence, prerendered shells, mobile drawer/docs nav). Browsers: `npx playwright install chromium`.
200 +API, docs render from the bundled snapshot and the homepage/status pages exercise their fallbacks. Specs cover: home
201 +(single `/v1/status` call, captured hero, footer, no overflow), limits, theme persistence + light-mode code blocks,
202 +status (down vs **429 = rate limited**, last dates), 404 (search seeds the ⌘K dialog), legal pages, prerendered
203 +shells for every guide, tap targets; docs (outline order, Run live explicit, wrap toggle, clean heading text, search,
204 +anchors); playground (endpoint picker, captured empty state, deep link auto-run, dependent enums, 429 countdown, CSV,
205 +WebSocket gating); mobile (drawer, inline docs panel order, playground form + 44 px Run, no overflow on every public
206 +page). Browsers: `npx playwright install chromium`.
added hfmarketdata/web/content/examples/home-aapl-daily.json +263 −0
@@ -0,0 +1,263 @@
1 +{
2 + "note": "Real response captured from production on 2026-09-06 for the homepage chart (GET /v1/bars/stock/AAPL?timeframe=1day&start=2025-09-02&end=2026-09-03, adj_splitdiv), OHLC rounded to 2 decimals, volume dropped. Columns: datetime, open, high, low, close. Refresh with npm run examples.",
3 + "url": "https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&start=2025-09-02&end=2026-09-03",
4 + "captured_at": "2026-09-06T14:20:00Z",
5 + "ticker": "AAPL",
6 + "columns": ["datetime", "open", "high", "low", "close"],
7 + "rows": [
8 + ["2025-09-02",228.41,230.0,226.13,228.87],
9 + ["2025-09-03",236.34,237.97,233.5,237.59],
10 + ["2025-09-04",237.57,239.02,235.87,238.9],
11 + ["2025-09-05",239.11,240.43,237.61,238.81],
12 + ["2025-09-08",238.42,239.27,235.47,237.0],
13 + ["2025-09-09",236.13,237.9,232.5,233.49],
14 + ["2025-09-10",231.33,231.56,225.12,225.96],
15 + ["2025-09-11",226.04,229.6,225.82,229.18],
16 + ["2025-09-12",228.38,233.65,228.18,233.21],
17 + ["2025-09-15",236.13,237.31,234.16,235.83],
18 + ["2025-09-16",236.3,240.33,235.45,237.27],
19 + ["2025-09-17",238.09,239.22,236.86,238.11],
20 + ["2025-09-18",239.09,240.31,235.78,237.0],
21 + ["2025-09-19",240.34,245.39,239.33,244.6],
22 + ["2025-09-22",247.39,255.7,247.21,255.14],
23 + ["2025-09-23",254.93,256.39,252.65,253.49],
24 + ["2025-09-24",254.28,254.8,250.12,251.38],
25 + ["2025-09-25",252.27,256.22,250.79,255.92],
26 + ["2025-09-26",253.16,256.65,252.85,254.52],
27 + ["2025-09-29",253.62,254.06,252.08,253.49],
28 + ["2025-09-30",253.92,254.98,252.18,253.69],
29 + ["2025-10-01",254.1,257.84,253.99,254.51],
30 + ["2025-10-02",255.63,257.23,253.21,256.18],
31 + ["2025-10-03",253.73,258.29,253.02,257.07],
32 + ["2025-10-06",257.04,258.12,254.11,255.75],
33 + ["2025-10-07",255.86,256.45,254.49,255.54],
34 + ["2025-10-08",255.58,257.57,255.17,257.11],
35 + ["2025-10-09",256.86,257.05,252.21,253.1],
36 + ["2025-10-10",254.0,255.44,243.1,244.37],
37 + ["2025-10-13",248.46,248.77,244.66,246.75],
38 + ["2025-10-14",245.69,247.93,243.8,246.86],
39 + ["2025-10-15",248.57,250.89,246.56,248.42],
40 + ["2025-10-16",247.34,248.12,244.23,246.54],
41 + ["2025-10-17",247.11,252.45,246.36,251.36],
42 + ["2025-10-20",254.94,263.4,254.69,261.27],
43 + ["2025-10-21",260.92,264.31,260.87,261.8],
44 + ["2025-10-22",261.68,261.88,254.49,257.5],
45 + ["2025-10-23",258.98,259.66,257.06,258.62],
46 + ["2025-10-24",260.23,263.16,258.23,261.85],
47 + ["2025-10-27",263.91,268.13,263.68,267.82],
48 + ["2025-10-28",268.0,268.9,267.16,268.01],
49 + ["2025-10-29",268.28,270.41,266.13,268.71],
50 + ["2025-10-30",270.99,273.13,267.49,270.4],
51 + ["2025-10-31",275.97,276.3,268.17,269.37],
52 + ["2025-11-03",269.42,269.85,265.27,268.06],
53 + ["2025-11-04",267.34,270.49,266.63,269.05],
54 + ["2025-11-05",267.62,270.7,265.95,269.15],
55 + ["2025-11-06",266.9,272.39,266.9,268.78],
56 + ["2025-11-07",268.8,271.29,265.79,267.48],
57 + ["2025-11-10",268.23,272.99,266.73,268.7],
58 + ["2025-11-11",269.08,275.16,269.07,274.5],
59 + ["2025-11-12",274.25,274.98,270.96,272.73],
60 + ["2025-11-13",273.37,275.95,271.35,272.21],
61 + ["2025-11-14",270.31,275.21,268.87,271.67],
62 + ["2025-11-17",268.09,269.76,265.01,266.73],
63 + ["2025-11-18",269.26,269.98,264.6,266.71],
64 + ["2025-11-19",264.8,271.47,264.78,267.83],
65 + ["2025-11-20",270.09,274.68,265.2,265.53],
66 + ["2025-11-21",265.23,272.59,264.95,270.75],
67 + ["2025-11-24",270.16,276.25,270.16,275.17],
68 + ["2025-11-25",274.52,279.62,274.5,276.22],
69 + ["2025-11-26",276.21,278.77,275.88,276.8],
70 + ["2025-11-28",276.51,278.24,275.24,278.09],
71 + ["2025-12-01",277.26,282.65,275.39,282.33],
72 + ["2025-12-02",282.23,286.62,281.86,285.41],
73 + ["2025-12-03",285.42,287.84,282.53,283.38],
74 + ["2025-12-04",283.32,283.96,277.83,279.94],
75 + ["2025-12-05",279.78,280.38,277.3,278.02],
76 + ["2025-12-08",277.38,278.91,275.4,277.14],
77 + ["2025-12-09",277.4,279.27,276.17,276.43],
78 + ["2025-12-10",277.0,278.99,275.69,278.02],
79 + ["2025-12-11",278.34,278.83,273.07,277.28],
80 + ["2025-12-12",277.15,278.46,276.07,277.52],
81 + ["2025-12-15",279.39,279.39,272.1,273.37],
82 + ["2025-12-16",272.08,274.75,271.05,273.86],
83 + ["2025-12-17",274.26,275.41,270.9,271.1],
84 + ["2025-12-18",272.86,272.89,266.23,271.45],
85 + ["2025-12-19",271.41,273.85,269.17,272.93],
86 + ["2025-12-22",272.12,273.14,269.77,270.23],
87 + ["2025-12-23",270.1,271.76,268.83,271.62],
88 + ["2025-12-24",271.6,274.68,271.46,273.07],
89 + ["2025-12-26",273.42,274.62,272.12,272.66],
90 + ["2025-12-29",271.95,273.62,271.61,273.02],
91 + ["2025-12-30",272.07,273.34,271.54,272.34],
92 + ["2025-12-31",272.32,272.94,271.01,271.12],
93 + ["2026-01-02",271.52,277.09,268.27,270.27],
94 + ["2026-01-05",269.91,270.77,265.42,266.53],
95 + ["2026-01-06",266.28,266.82,261.41,261.65],
96 + ["2026-01-07",262.49,262.96,259.1,259.62],
97 + ["2026-01-08",256.32,258.59,255.01,258.34],
98 + ["2026-01-09",258.37,259.5,255.52,258.67],
99 + ["2026-01-12",258.46,260.59,256.1,259.54],
100 + ["2026-01-13",258.02,261.1,257.69,260.34],
101 + ["2026-01-14",258.79,261.11,256.01,259.25],
102 + ["2026-01-15",259.94,260.33,256.35,257.51],
103 + ["2026-01-16",257.2,258.2,254.24,254.84],
104 + ["2026-01-20",252.04,254.1,242.76,246.03],
105 + ["2026-01-21",248.02,250.88,244.51,246.98],
106 + ["2026-01-22",248.52,250.32,247.48,247.68],
107 + ["2026-01-23",246.65,248.73,244.02,247.37],
108 + ["2026-01-26",250.8,255.86,249.12,254.72],
109 + ["2026-01-27",258.47,261.24,257.51,257.57],
110 + ["2026-01-28",256.95,258.15,253.82,255.74],
111 + ["2026-01-29",257.3,258.95,253.72,257.58],
112 + ["2026-01-30",254.47,261.19,251.5,258.78],
113 + ["2026-02-02",259.32,269.76,258.5,269.28],
114 + ["2026-02-03",268.47,271.14,266.88,268.75],
115 + ["2026-02-04",271.55,278.19,271.55,275.74],
116 + ["2026-02-05",277.38,278.74,272.49,275.16],
117 + ["2026-02-06",276.37,280.14,276.17,277.37],
118 + ["2026-02-09",277.41,277.7,271.22,274.13],
119 + ["2026-02-10",274.4,274.88,272.45,273.19],
120 + ["2026-02-11",274.21,279.68,273.96,275.01],
121 + ["2026-02-12",275.1,275.23,259.72,261.26],
122 + ["2026-02-13",261.54,261.76,254.99,255.32],
123 + ["2026-02-17",257.59,265.82,255.08,263.41],
124 + ["2026-02-18",263.13,266.34,261.98,263.88],
125 + ["2026-02-19",262.13,264.01,259.59,260.12],
126 + ["2026-02-20",258.51,264.28,257.7,264.11],
127 + ["2026-02-23",263.02,268.95,262.91,265.71],
128 + ["2026-02-24",267.38,274.4,267.23,271.66],
129 + ["2026-02-25",271.3,274.45,270.57,273.74],
130 + ["2026-02-26",274.46,275.62,270.31,272.46],
131 + ["2026-02-27",272.32,272.32,262.42,263.71],
132 + ["2026-03-02",261.94,266.06,259.74,264.25],
133 + ["2026-03-03",263.01,265.09,259.67,263.28],
134 + ["2026-03-04",264.18,265.68,260.96,262.05],
135 + ["2026-03-05",260.33,261.09,256.79,259.83],
136 + ["2026-03-06",258.17,258.31,253.92,257.0],
137 + ["2026-03-09",255.23,260.68,253.23,259.42],
138 + ["2026-03-10",257.19,262.01,256.49,260.37],
139 + ["2026-03-11",260.62,261.66,259.09,260.35],
140 + ["2026-03-12",258.2,258.49,253.73,255.3],
141 + ["2026-03-13",255.02,255.87,249.11,249.67],
142 + ["2026-03-16",251.66,253.43,249.43,252.37],
143 + ["2026-03-17",252.5,254.68,251.73,253.78],
144 + ["2026-03-18",252.18,254.49,248.56,249.49],
145 + ["2026-03-19",248.96,251.38,246.86,248.52],
146 + ["2026-03-20",247.53,248.76,245.56,247.55],
147 + ["2026-03-23",253.52,254.15,249.83,251.04],
148 + ["2026-03-24",249.9,254.37,249.11,251.19],
149 + ["2026-03-25",253.64,254.55,251.15,252.17],
150 + ["2026-03-26",251.67,256.54,250.32,252.44],
151 + ["2026-03-27",253.45,255.04,247.63,248.36],
152 + ["2026-03-30",249.62,250.42,245.6,245.87],
153 + ["2026-03-31",247.47,255.02,246.66,253.34],
154 + ["2026-04-01",253.63,255.72,252.88,255.17],
155 + ["2026-04-02",253.75,255.67,250.2,255.46],
156 + ["2026-04-06",256.05,261.69,256.0,258.4],
157 + ["2026-04-07",255.7,255.74,245.26,253.05],
158 + ["2026-04-08",257.99,259.29,256.07,258.44],
159 + ["2026-04-09",258.54,260.65,255.61,260.03],
160 + ["2026-04-10",259.52,261.72,258.56,260.02],
161 + ["2026-04-13",259.27,259.72,256.2,258.74],
162 + ["2026-04-14",258.78,261.46,256.73,258.37],
163 + ["2026-04-15",257.7,266.09,257.35,265.96],
164 + ["2026-04-16",266.32,266.68,260.8,262.93],
165 + ["2026-04-17",266.48,271.81,266.24,269.75],
166 + ["2026-04-20",269.85,273.79,269.81,272.56],
167 + ["2026-04-21",271.02,272.31,264.93,265.7],
168 + ["2026-04-22",267.34,273.25,266.39,272.68],
169 + ["2026-04-23",274.56,275.28,271.17,272.94],
170 + ["2026-04-24",272.27,272.57,269.17,270.58],
171 + ["2026-04-27",265.62,267.88,264.6,267.13],
172 + ["2026-04-28",271.85,272.74,268.18,270.23],
173 + ["2026-04-29",267.07,270.56,266.56,269.69],
174 + ["2026-04-30",270.02,275.51,267.66,270.87],
175 + ["2026-05-01",278.36,286.71,277.87,279.64],
176 + ["2026-05-04",279.16,280.13,274.37,276.34],
177 + ["2026-05-05",276.43,284.06,276.01,283.67],
178 + ["2026-05-06",281.41,287.52,280.57,287.0],
179 + ["2026-05-07",288.75,291.61,285.27,286.93],
180 + ["2026-05-08",289.49,294.23,289.48,292.8],
181 + ["2026-05-11",291.73,293.63,289.98,292.43],
182 + ["2026-05-12",292.31,295.02,292.31,294.55],
183 + ["2026-05-13",293.25,300.66,293.25,298.61],
184 + ["2026-05-14",299.56,300.19,295.13,297.95],
185 + ["2026-05-15",297.64,302.94,296.26,299.97],
186 + ["2026-05-18",299.98,300.4,294.66,297.58],
187 + ["2026-05-19",296.71,300.25,296.09,298.71],
188 + ["2026-05-20",297.92,302.54,297.82,301.99],
189 + ["2026-05-21",300.8,305.28,300.14,304.73],
190 + ["2026-05-22",305.86,311.13,305.58,308.55],
191 + ["2026-05-26",309.29,311.55,307.4,308.06],
192 + ["2026-05-27",308.06,312.99,308.03,310.58],
193 + ["2026-05-28",310.41,312.53,309.3,312.24],
194 + ["2026-05-29",311.51,314.73,309.26,311.79],
195 + ["2026-06-01",309.36,310.67,304.76,306.05],
196 + ["2026-06-02",307.2,315.18,306.42,314.93],
197 + ["2026-06-03",313.9,316.67,308.58,309.99],
198 + ["2026-06-04",312.96,313.27,309.38,310.96],
199 + ["2026-06-05",312.59,314.9,306.89,307.08],
200 + ["2026-06-08",308.47,317.13,300.91,301.28],
201 + ["2026-06-09",300.02,300.49,287.53,290.3],
202 + ["2026-06-10",290.49,294.5,287.13,291.33],
203 + ["2026-06-11",293.47,296.74,289.34,295.38],
204 + ["2026-06-12",295.77,296.88,289.37,290.88],
205 + ["2026-06-15",293.87,297.52,291.45,296.16],
206 + ["2026-06-16",294.99,300.22,293.72,298.98],
207 + ["2026-06-17",300.59,301.81,294.11,295.69],
208 + ["2026-06-18",297.85,300.31,295.37,297.75],
209 + ["2026-06-22",297.05,302.16,296.5,296.75],
210 + ["2026-06-23",297.28,301.38,293.93,294.05],
211 + ["2026-06-24",295.1,299.44,292.69,292.83],
212 + ["2026-06-25",287.15,288.55,273.51,274.91],
213 + ["2026-06-26",274.76,285.7,273.97,283.54],
214 + ["2026-06-29",286.48,288.12,279.61,281.5],
215 + ["2026-06-30",280.93,289.69,280.45,289.11],
216 + ["2026-07-01",293.19,296.33,288.95,294.13],
217 + ["2026-07-02",293.87,309.15,293.43,308.36],
218 + ["2026-07-06",307.1,313.93,306.74,312.39],
219 + ["2026-07-07",315.02,315.21,309.88,310.39],
220 + ["2026-07-08",311.64,314.55,306.79,313.12],
221 + ["2026-07-09",310.24,316.26,307.89,315.95],
222 + ["2026-07-10",314.45,316.64,311.9,315.05],
223 + ["2026-07-13",316.74,323.17,315.51,317.04],
224 + ["2026-07-14",313.49,315.92,311.64,314.59],
225 + ["2026-07-15",317.34,328.45,317.05,327.22],
226 + ["2026-07-16",327.72,334.39,326.51,332.97],
227 + ["2026-07-17",331.69,334.7,328.72,333.45],
228 + ["2026-07-20",333.22,333.42,323.4,326.31],
229 + ["2026-07-21",322.85,329.32,321.94,327.46],
230 + ["2026-07-22",327.59,328.72,323.06,325.61],
231 + ["2026-07-23",321.45,323.02,319.07,321.38],
232 + ["2026-07-24",321.51,334.08,321.34,332.73],
233 + ["2026-07-27",334.25,339.28,333.73,336.62],
234 + ["2026-07-28",339.74,342.59,335.31,339.79],
235 + ["2026-07-29",339.44,344.27,337.06,337.9],
236 + ["2026-07-30",332.81,334.46,329.31,333.14],
237 + ["2026-07-31",304.55,310.42,299.74,308.64],
238 + ["2026-08-03",309.31,311.53,302.3,303.16],
239 + ["2026-08-04",302.46,310.15,301.06,309.11],
240 + ["2026-08-05",309.09,311.44,305.41,310.73],
241 + ["2026-08-06",314.07,316.02,308.96,312.14],
242 + ["2026-08-07",311.18,314.54,310.47,313.06],
243 + ["2026-08-10",306.83,308.26,304.61,308.26],
244 + ["2026-08-11",307.75,309.97,302.79,304.91],
245 + ["2026-08-12",305.1,305.66,300.57,302.25],
246 + ["2026-08-13",304.21,306,302.05,305.26],
247 + ["2026-08-14",306,307.49,304.3,305.93],
248 + ["2026-08-17",306.21,307.66,302.94,305.59],
249 + ["2026-08-18",307.58,311.49,305.74,310.03],
250 + ["2026-08-19",310.14,319.28,309.6,316.83],
251 + ["2026-08-20",317.45,320.28,310.65,311.3],
252 + ["2026-08-21",312.05,312.38,307.01,309.35],
253 + ["2026-08-24",311.47,313.36,309.97,310.34],
254 + ["2026-08-25",310.79,313.59,308.21,309.9],
255 + ["2026-08-26",310.3,315.43,308.8,313.45],
256 + ["2026-08-27",310.55,315.4,309.4,314.58],
257 + ["2026-08-28",316.85,322.37,315.45,319.7],
258 + ["2026-08-31",319.6,321.24,312.8,316.85],
259 + ["2026-09-01",316.98,327.3,314.73,325.13],
260 + ["2026-09-02",326.87,328.4,323.53,324.96],
261 + ["2026-09-03",324.87,330.81,324.11,328.21]
262 + ]
263 +}
modified hfmarketdata/web/content/samples/aapl-pe.json +1 −1
@@ -1 +1 @@
1 −{"ticker":"AAPL","metric":"pe","note":"Illustrative fallback used only when /v1/fundamentals/AAPL/ratios/daily is not live: split-adjusted weekly close (HF Market Data, adj_split) divided by the diluted EPS reported in Apple's latest Form 10-K at that date (10-K EPS stepped at each release; not a trailing-twelve-month series).","source":{"prices":"https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&adjustment=adj_split&start=2015-01-01&limit=50000","eps":"Apple Inc. Form 10-K filings FY2014–FY2025 (diluted EPS, split-adjusted)"},"generated_at":"2026-09-04T23:38:04.919Z","points":[{"date":"2015-01-02","close":27.3325,"eps_ttm":1.61,"pe":16.98},{"date":"2015-01-09","close":28.0025,"eps_ttm":1.61,"pe":17.39},{"date":"2015-01-16","close":26.4975,"eps_ttm":1.61,"pe":16.46},{"date":"2015-01-23","close":28.245,"eps_ttm":1.61,"pe":17.54},{"date":"2015-01-30","close":29.29,"eps_ttm":1.61,"pe":18.19},{"date":"2015-02-06","close":29.7325,"eps_ttm":1.61,"pe":18.47},{"date":"2015-02-13","close":31.77,"eps_ttm":1.61,"pe":19.73},{"date":"2015-02-20","close":32.375,"eps_ttm":1.61,"pe":20.11},{"date":"2015-02-27","close":32.115,"eps_ttm":1.61,"pe":19.95},{"date":"2015-03-06","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-03-13","close":30.8975,"eps_ttm":1.61,"pe":19.19},{"date":"2015-03-20","close":31.475,"eps_ttm":1.61,"pe":19.55},{"date":"2015-03-27","close":30.8125,"eps_ttm":1.61,"pe":19.14},{"date":"2015-04-02","close":31.33,"eps_ttm":1.61,"pe":19.46},{"date":"2015-04-10","close":31.775,"eps_ttm":1.61,"pe":19.74},{"date":"2015-04-17","close":31.1875,"eps_ttm":1.61,"pe":19.37},{"date":"2015-04-24","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-05-01","close":32.2375,"eps_ttm":1.61,"pe":20.02},{"date":"2015-05-08","close":31.905,"eps_ttm":1.61,"pe":19.82},{"date":"2015-05-15","close":32.1925,"eps_ttm":1.61,"pe":20},{"date":"2015-05-22","close":33.135,"eps_ttm":1.61,"pe":20.58},{"date":"2015-05-29","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-06-05","close":32.1625,"eps_ttm":1.61,"pe":19.98},{"date":"2015-06-12","close":31.7925,"eps_ttm":1.61,"pe":19.75},{"date":"2015-06-19","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-06-26","close":31.6875,"eps_ttm":1.61,"pe":19.68},{"date":"2015-07-02","close":31.61,"eps_ttm":1.61,"pe":19.63},{"date":"2015-07-10","close":30.82,"eps_ttm":1.61,"pe":19.14},{"date":"2015-07-17","close":32.405,"eps_ttm":1.61,"pe":20.13},{"date":"2015-07-24","close":31.125,"eps_ttm":1.61,"pe":19.33},{"date":"2015-07-31","close":30.325,"eps_ttm":1.61,"pe":18.84},{"date":"2015-08-07","close":28.88,"eps_ttm":1.61,"pe":17.94},{"date":"2015-08-14","close":28.99,"eps_ttm":1.61,"pe":18.01},{"date":"2015-08-21","close":26.44,"eps_ttm":1.61,"pe":16.42},{"date":"2015-08-28","close":28.3225,"eps_ttm":1.61,"pe":17.59},{"date":"2015-09-04","close":27.3175,"eps_ttm":1.61,"pe":16.97},{"date":"2015-09-11","close":28.5525,"eps_ttm":1.61,"pe":17.73},{"date":"2015-09-18","close":28.3625,"eps_ttm":1.61,"pe":17.62},{"date":"2015-09-25","close":28.6775,"eps_ttm":1.61,"pe":17.81},{"date":"2015-10-02","close":27.595,"eps_ttm":1.61,"pe":17.14},{"date":"2015-10-09","close":28.03,"eps_ttm":1.61,"pe":17.41},{"date":"2015-10-16","close":27.76,"eps_ttm":1.61,"pe":17.24},{"date":"2015-10-23","close":29.77,"eps_ttm":1.61,"pe":18.49},{"date":"2015-10-30","close":29.875,"eps_ttm":2.31,"pe":12.93},{"date":"2015-11-06","close":30.265,"eps_ttm":2.31,"pe":13.1},{"date":"2015-11-13","close":28.085,"eps_ttm":2.31,"pe":12.16},{"date":"2015-11-20","close":29.825,"eps_ttm":2.31,"pe":12.91},{"date":"2015-11-27","close":29.4525,"eps_ttm":2.31,"pe":12.75},{"date":"2015-12-04","close":29.7575,"eps_ttm":2.31,"pe":12.88},{"date":"2015-12-11","close":28.295,"eps_ttm":2.31,"pe":12.25},{"date":"2015-12-18","close":26.5075,"eps_ttm":2.31,"pe":11.48},{"date":"2015-12-24","close":27.0075,"eps_ttm":2.31,"pe":11.69},{"date":"2015-12-31","close":26.315,"eps_ttm":2.31,"pe":11.39},{"date":"2016-01-08","close":24.24,"eps_ttm":2.31,"pe":10.49},{"date":"2016-01-15","close":24.2825,"eps_ttm":2.31,"pe":10.51},{"date":"2016-01-22","close":25.355,"eps_ttm":2.31,"pe":10.98},{"date":"2016-01-29","close":24.335,"eps_ttm":2.31,"pe":10.53},{"date":"2016-02-05","close":23.505,"eps_ttm":2.31,"pe":10.18},{"date":"2016-02-12","close":23.4975,"eps_ttm":2.31,"pe":10.17},{"date":"2016-02-19","close":24.01,"eps_ttm":2.31,"pe":10.39},{"date":"2016-02-26","close":24.2275,"eps_ttm":2.31,"pe":10.49},{"date":"2016-03-04","close":25.7525,"eps_ttm":2.31,"pe":11.15},{"date":"2016-03-11","close":25.565,"eps_ttm":2.31,"pe":11.07},{"date":"2016-03-18","close":26.48,"eps_ttm":2.31,"pe":11.46},{"date":"2016-03-24","close":26.4175,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-01","close":27.4975,"eps_ttm":2.31,"pe":11.9},{"date":"2016-04-08","close":27.165,"eps_ttm":2.31,"pe":11.76},{"date":"2016-04-15","close":27.4625,"eps_ttm":2.31,"pe":11.89},{"date":"2016-04-22","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-29","close":23.435,"eps_ttm":2.31,"pe":10.15},{"date":"2016-05-06","close":23.18,"eps_ttm":2.31,"pe":10.03},{"date":"2016-05-13","close":22.63,"eps_ttm":2.31,"pe":9.8},{"date":"2016-05-20","close":23.805,"eps_ttm":2.31,"pe":10.31},{"date":"2016-05-27","close":25.0875,"eps_ttm":2.31,"pe":10.86},{"date":"2016-06-03","close":24.48,"eps_ttm":2.31,"pe":10.6},{"date":"2016-06-10","close":24.7075,"eps_ttm":2.31,"pe":10.7},{"date":"2016-06-17","close":23.8325,"eps_ttm":2.31,"pe":10.32},{"date":"2016-06-24","close":23.35,"eps_ttm":2.31,"pe":10.11},{"date":"2016-07-01","close":23.9725,"eps_ttm":2.31,"pe":10.38},{"date":"2016-07-08","close":24.17,"eps_ttm":2.31,"pe":10.46},{"date":"2016-07-15","close":24.695,"eps_ttm":2.31,"pe":10.69},{"date":"2016-07-22","close":24.665,"eps_ttm":2.31,"pe":10.68},{"date":"2016-07-29","close":26.0525,"eps_ttm":2.31,"pe":11.28},{"date":"2016-08-05","close":26.87,"eps_ttm":2.31,"pe":11.63},{"date":"2016-08-12","close":27.045,"eps_ttm":2.31,"pe":11.71},{"date":"2016-08-19","close":27.34,"eps_ttm":2.31,"pe":11.84},{"date":"2016-08-26","close":26.735,"eps_ttm":2.31,"pe":11.57},{"date":"2016-09-02","close":26.9325,"eps_ttm":2.31,"pe":11.66},{"date":"2016-09-09","close":25.7825,"eps_ttm":2.31,"pe":11.16},{"date":"2016-09-16","close":28.73,"eps_ttm":2.31,"pe":12.44},{"date":"2016-09-23","close":28.1775,"eps_ttm":2.31,"pe":12.2},{"date":"2016-09-30","close":28.2625,"eps_ttm":2.31,"pe":12.23},{"date":"2016-10-07","close":28.515,"eps_ttm":2.31,"pe":12.34},{"date":"2016-10-14","close":29.4075,"eps_ttm":2.31,"pe":12.73},{"date":"2016-10-21","close":29.15,"eps_ttm":2.31,"pe":12.62},{"date":"2016-10-28","close":28.43,"eps_ttm":2.08,"pe":13.67},{"date":"2016-11-04","close":27.21,"eps_ttm":2.08,"pe":13.08},{"date":"2016-11-11","close":27.1075,"eps_ttm":2.08,"pe":13.03},{"date":"2016-11-18","close":27.515,"eps_ttm":2.08,"pe":13.23},{"date":"2016-11-25","close":27.9475,"eps_ttm":2.08,"pe":13.44},{"date":"2016-12-02","close":27.475,"eps_ttm":2.08,"pe":13.21},{"date":"2016-12-09","close":28.4875,"eps_ttm":2.08,"pe":13.7},{"date":"2016-12-16","close":28.9925,"eps_ttm":2.08,"pe":13.94},{"date":"2016-12-23","close":29.13,"eps_ttm":2.08,"pe":14},{"date":"2016-12-30","close":28.955,"eps_ttm":2.08,"pe":13.92},{"date":"2017-01-06","close":29.4775,"eps_ttm":2.08,"pe":14.17},{"date":"2017-01-13","close":29.76,"eps_ttm":2.08,"pe":14.31},{"date":"2017-01-20","close":30,"eps_ttm":2.08,"pe":14.42},{"date":"2017-01-27","close":30.4875,"eps_ttm":2.08,"pe":14.66},{"date":"2017-02-03","close":32.27,"eps_ttm":2.08,"pe":15.51},{"date":"2017-02-10","close":33.03,"eps_ttm":2.08,"pe":15.88},{"date":"2017-02-17","close":33.93,"eps_ttm":2.08,"pe":16.31},{"date":"2017-02-24","close":34.165,"eps_ttm":2.08,"pe":16.43},{"date":"2017-03-03","close":34.945,"eps_ttm":2.08,"pe":16.8},{"date":"2017-03-10","close":34.785,"eps_ttm":2.08,"pe":16.72},{"date":"2017-03-17","close":34.9975,"eps_ttm":2.08,"pe":16.83},{"date":"2017-03-24","close":35.16,"eps_ttm":2.08,"pe":16.9},{"date":"2017-03-31","close":35.915,"eps_ttm":2.08,"pe":17.27},{"date":"2017-04-07","close":35.835,"eps_ttm":2.08,"pe":17.23},{"date":"2017-04-13","close":35.2625,"eps_ttm":2.08,"pe":16.95},{"date":"2017-04-21","close":35.5675,"eps_ttm":2.08,"pe":17.1},{"date":"2017-04-28","close":35.9125,"eps_ttm":2.08,"pe":17.27},{"date":"2017-05-05","close":37.24,"eps_ttm":2.08,"pe":17.9},{"date":"2017-05-12","close":39.025,"eps_ttm":2.08,"pe":18.76},{"date":"2017-05-19","close":38.265,"eps_ttm":2.08,"pe":18.4},{"date":"2017-05-26","close":38.4025,"eps_ttm":2.08,"pe":18.46},{"date":"2017-06-02","close":38.8625,"eps_ttm":2.08,"pe":18.68},{"date":"2017-06-09","close":37.245,"eps_ttm":2.08,"pe":17.91},{"date":"2017-06-16","close":35.5675,"eps_ttm":2.08,"pe":17.1},{"date":"2017-06-23","close":36.57,"eps_ttm":2.08,"pe":17.58},{"date":"2017-06-30","close":36.005,"eps_ttm":2.08,"pe":17.31},{"date":"2017-07-07","close":36.045,"eps_ttm":2.08,"pe":17.33},{"date":"2017-07-14","close":37.26,"eps_ttm":2.08,"pe":17.91},{"date":"2017-07-21","close":37.5675,"eps_ttm":2.08,"pe":18.06},{"date":"2017-07-28","close":37.375,"eps_ttm":2.08,"pe":17.97},{"date":"2017-08-04","close":39.0975,"eps_ttm":2.08,"pe":18.8},{"date":"2017-08-11","close":39.37,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-18","close":39.375,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-25","close":39.965,"eps_ttm":2.08,"pe":19.21},{"date":"2017-09-01","close":41.0125,"eps_ttm":2.08,"pe":19.72},{"date":"2017-09-08","close":39.6575,"eps_ttm":2.08,"pe":19.07},{"date":"2017-09-15","close":39.97,"eps_ttm":2.08,"pe":19.22},{"date":"2017-09-22","close":37.9725,"eps_ttm":2.08,"pe":18.26},{"date":"2017-09-29","close":38.53,"eps_ttm":2.08,"pe":18.52},{"date":"2017-10-06","close":38.825,"eps_ttm":2.08,"pe":18.67},{"date":"2017-10-13","close":39.2475,"eps_ttm":2.08,"pe":18.87},{"date":"2017-10-20","close":39.0625,"eps_ttm":2.08,"pe":18.78},{"date":"2017-10-27","close":40.7625,"eps_ttm":2.08,"pe":19.6},{"date":"2017-11-03","close":43.125,"eps_ttm":2.3,"pe":18.75},{"date":"2017-11-10","close":43.6675,"eps_ttm":2.3,"pe":18.99},{"date":"2017-11-17","close":42.5375,"eps_ttm":2.3,"pe":18.49},{"date":"2017-11-24","close":43.7425,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-01","close":42.7625,"eps_ttm":2.3,"pe":18.59},{"date":"2017-12-08","close":42.3425,"eps_ttm":2.3,"pe":18.41},{"date":"2017-12-15","close":43.4925,"eps_ttm":2.3,"pe":18.91},{"date":"2017-12-22","close":43.7525,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-29","close":42.3075,"eps_ttm":2.3,"pe":18.39},{"date":"2018-01-05","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2018-01-12","close":44.2725,"eps_ttm":2.3,"pe":19.25},{"date":"2018-01-19","close":44.615,"eps_ttm":2.3,"pe":19.4},{"date":"2018-01-26","close":42.8775,"eps_ttm":2.3,"pe":18.64},{"date":"2018-02-02","close":40.125,"eps_ttm":2.3,"pe":17.45},{"date":"2018-02-09","close":39.1025,"eps_ttm":2.3,"pe":17},{"date":"2018-02-16","close":43.1075,"eps_ttm":2.3,"pe":18.74},{"date":"2018-02-23","close":43.875,"eps_ttm":2.3,"pe":19.08},{"date":"2018-03-02","close":44.0525,"eps_ttm":2.3,"pe":19.15},{"date":"2018-03-09","close":44.995,"eps_ttm":2.3,"pe":19.56},{"date":"2018-03-16","close":44.505,"eps_ttm":2.3,"pe":19.35},{"date":"2018-03-23","close":41.235,"eps_ttm":2.3,"pe":17.93},{"date":"2018-03-29","close":41.945,"eps_ttm":2.3,"pe":18.24},{"date":"2018-04-06","close":42.095,"eps_ttm":2.3,"pe":18.3},{"date":"2018-04-13","close":43.6825,"eps_ttm":2.3,"pe":18.99},{"date":"2018-04-20","close":41.43,"eps_ttm":2.3,"pe":18.01},{"date":"2018-04-27","close":40.58,"eps_ttm":2.3,"pe":17.64},{"date":"2018-05-04","close":45.9575,"eps_ttm":2.3,"pe":19.98},{"date":"2018-05-11","close":47.1475,"eps_ttm":2.3,"pe":20.5},{"date":"2018-05-18","close":46.5775,"eps_ttm":2.3,"pe":20.25},{"date":"2018-05-25","close":47.145,"eps_ttm":2.3,"pe":20.5},{"date":"2018-06-01","close":47.56,"eps_ttm":2.3,"pe":20.68},{"date":"2018-06-08","close":47.925,"eps_ttm":2.3,"pe":20.84},{"date":"2018-06-15","close":47.21,"eps_ttm":2.3,"pe":20.53},{"date":"2018-06-22","close":46.23,"eps_ttm":2.3,"pe":20.1},{"date":"2018-06-29","close":46.2775,"eps_ttm":2.3,"pe":20.12},{"date":"2018-07-06","close":46.9925,"eps_ttm":2.3,"pe":20.43},{"date":"2018-07-13","close":47.8325,"eps_ttm":2.3,"pe":20.8},{"date":"2018-07-20","close":47.86,"eps_ttm":2.3,"pe":20.81},{"date":"2018-07-27","close":47.745,"eps_ttm":2.3,"pe":20.76},{"date":"2018-08-03","close":51.9975,"eps_ttm":2.3,"pe":22.61},{"date":"2018-08-10","close":51.8825,"eps_ttm":2.3,"pe":22.56},{"date":"2018-08-17","close":54.395,"eps_ttm":2.3,"pe":23.65},{"date":"2018-08-24","close":54.04,"eps_ttm":2.3,"pe":23.5},{"date":"2018-08-31","close":56.9075,"eps_ttm":2.3,"pe":24.74},{"date":"2018-09-07","close":55.325,"eps_ttm":2.3,"pe":24.05},{"date":"2018-09-14","close":55.96,"eps_ttm":2.3,"pe":24.33},{"date":"2018-09-21","close":54.415,"eps_ttm":2.3,"pe":23.66},{"date":"2018-09-28","close":56.435,"eps_ttm":2.3,"pe":24.54},{"date":"2018-10-05","close":56.0725,"eps_ttm":2.3,"pe":24.38},{"date":"2018-10-12","close":55.5275,"eps_ttm":2.3,"pe":24.14},{"date":"2018-10-19","close":54.8275,"eps_ttm":2.3,"pe":23.84},{"date":"2018-10-26","close":54.075,"eps_ttm":2.3,"pe":23.51},{"date":"2018-11-02","close":51.87,"eps_ttm":2.98,"pe":17.41},{"date":"2018-11-09","close":51.1175,"eps_ttm":2.98,"pe":17.15},{"date":"2018-11-16","close":48.3825,"eps_ttm":2.98,"pe":16.24},{"date":"2018-11-23","close":43.0725,"eps_ttm":2.98,"pe":14.45},{"date":"2018-11-30","close":44.645,"eps_ttm":2.98,"pe":14.98},{"date":"2018-12-07","close":42.1225,"eps_ttm":2.98,"pe":14.14},{"date":"2018-12-14","close":41.37,"eps_ttm":2.98,"pe":13.88},{"date":"2018-12-21","close":37.6825,"eps_ttm":2.98,"pe":12.65},{"date":"2018-12-28","close":39.0575,"eps_ttm":2.98,"pe":13.11},{"date":"2019-01-04","close":37.065,"eps_ttm":2.98,"pe":12.44},{"date":"2019-01-11","close":38.0725,"eps_ttm":2.98,"pe":12.78},{"date":"2019-01-18","close":39.205,"eps_ttm":2.98,"pe":13.16},{"date":"2019-01-25","close":39.44,"eps_ttm":2.98,"pe":13.23},{"date":"2019-02-01","close":41.63,"eps_ttm":2.98,"pe":13.97},{"date":"2019-02-08","close":42.6025,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-15","close":42.605,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-22","close":43.2425,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-01","close":43.7425,"eps_ttm":2.98,"pe":14.68},{"date":"2019-03-08","close":43.2275,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-15","close":46.53,"eps_ttm":2.98,"pe":15.61},{"date":"2019-03-22","close":47.7625,"eps_ttm":2.98,"pe":16.03},{"date":"2019-03-29","close":47.4875,"eps_ttm":2.98,"pe":15.94},{"date":"2019-04-05","close":49.25,"eps_ttm":2.98,"pe":16.53},{"date":"2019-04-12","close":49.7175,"eps_ttm":2.98,"pe":16.68},{"date":"2019-04-18","close":50.965,"eps_ttm":2.98,"pe":17.1},{"date":"2019-04-26","close":51.075,"eps_ttm":2.98,"pe":17.14},{"date":"2019-05-03","close":52.9375,"eps_ttm":2.98,"pe":17.76},{"date":"2019-05-10","close":49.295,"eps_ttm":2.98,"pe":16.54},{"date":"2019-05-17","close":47.25,"eps_ttm":2.98,"pe":15.86},{"date":"2019-05-24","close":44.7425,"eps_ttm":2.98,"pe":15.01},{"date":"2019-05-31","close":43.7675,"eps_ttm":2.98,"pe":14.69},{"date":"2019-06-07","close":47.5375,"eps_ttm":2.98,"pe":15.95},{"date":"2019-06-14","close":48.185,"eps_ttm":2.98,"pe":16.17},{"date":"2019-06-21","close":49.695,"eps_ttm":2.98,"pe":16.68},{"date":"2019-06-28","close":49.48,"eps_ttm":2.98,"pe":16.6},{"date":"2019-07-05","close":51.0575,"eps_ttm":2.98,"pe":17.13},{"date":"2019-07-12","close":50.825,"eps_ttm":2.98,"pe":17.06},{"date":"2019-07-19","close":50.6475,"eps_ttm":2.98,"pe":17},{"date":"2019-07-26","close":51.935,"eps_ttm":2.98,"pe":17.43},{"date":"2019-08-02","close":51.005,"eps_ttm":2.98,"pe":17.12},{"date":"2019-08-09","close":50.2475,"eps_ttm":2.98,"pe":16.86},{"date":"2019-08-16","close":51.625,"eps_ttm":2.98,"pe":17.32},{"date":"2019-08-23","close":50.66,"eps_ttm":2.98,"pe":17},{"date":"2019-08-30","close":52.185,"eps_ttm":2.98,"pe":17.51},{"date":"2019-09-06","close":53.315,"eps_ttm":2.98,"pe":17.89},{"date":"2019-09-13","close":54.6875,"eps_ttm":2.98,"pe":18.35},{"date":"2019-09-20","close":54.4325,"eps_ttm":2.98,"pe":18.27},{"date":"2019-09-27","close":54.705,"eps_ttm":2.98,"pe":18.36},{"date":"2019-10-04","close":56.7525,"eps_ttm":2.98,"pe":19.04},{"date":"2019-10-11","close":59.0525,"eps_ttm":2.98,"pe":19.82},{"date":"2019-10-18","close":59.1025,"eps_ttm":2.98,"pe":19.83},{"date":"2019-10-25","close":61.645,"eps_ttm":2.98,"pe":20.69},{"date":"2019-11-01","close":63.955,"eps_ttm":2.97,"pe":21.53},{"date":"2019-11-08","close":65.035,"eps_ttm":2.97,"pe":21.9},{"date":"2019-11-15","close":66.44,"eps_ttm":2.97,"pe":22.37},{"date":"2019-11-22","close":65.445,"eps_ttm":2.97,"pe":22.04},{"date":"2019-11-29","close":66.8125,"eps_ttm":2.97,"pe":22.5},{"date":"2019-12-06","close":67.6775,"eps_ttm":2.97,"pe":22.79},{"date":"2019-12-13","close":68.7875,"eps_ttm":2.97,"pe":23.16},{"date":"2019-12-20","close":69.86,"eps_ttm":2.97,"pe":23.52},{"date":"2019-12-27","close":72.45,"eps_ttm":2.97,"pe":24.39},{"date":"2020-01-03","close":74.3575,"eps_ttm":2.97,"pe":25.04},{"date":"2020-01-10","close":77.5825,"eps_ttm":2.97,"pe":26.12},{"date":"2020-01-17","close":79.6825,"eps_ttm":2.97,"pe":26.83},{"date":"2020-01-24","close":79.5775,"eps_ttm":2.97,"pe":26.79},{"date":"2020-01-31","close":77.3775,"eps_ttm":2.97,"pe":26.05},{"date":"2020-02-07","close":80.0075,"eps_ttm":2.97,"pe":26.94},{"date":"2020-02-14","close":81.2375,"eps_ttm":2.97,"pe":27.35},{"date":"2020-02-21","close":78.2625,"eps_ttm":2.97,"pe":26.35},{"date":"2020-02-28","close":68.34,"eps_ttm":2.97,"pe":23.01},{"date":"2020-03-06","close":72.2575,"eps_ttm":2.97,"pe":24.33},{"date":"2020-03-13","close":69.4925,"eps_ttm":2.97,"pe":23.4},{"date":"2020-03-20","close":57.31,"eps_ttm":2.97,"pe":19.3},{"date":"2020-03-27","close":61.935,"eps_ttm":2.97,"pe":20.85},{"date":"2020-04-03","close":60.3525,"eps_ttm":2.97,"pe":20.32},{"date":"2020-04-09","close":66.9975,"eps_ttm":2.97,"pe":22.56},{"date":"2020-04-17","close":70.7,"eps_ttm":2.97,"pe":23.8},{"date":"2020-04-24","close":70.7425,"eps_ttm":2.97,"pe":23.82},{"date":"2020-05-01","close":72.2675,"eps_ttm":2.97,"pe":24.33},{"date":"2020-05-08","close":77.5325,"eps_ttm":2.97,"pe":26.11},{"date":"2020-05-15","close":76.9275,"eps_ttm":2.97,"pe":25.9},{"date":"2020-05-22","close":79.7225,"eps_ttm":2.97,"pe":26.84},{"date":"2020-05-29","close":79.485,"eps_ttm":2.97,"pe":26.76},{"date":"2020-06-05","close":82.875,"eps_ttm":2.97,"pe":27.9},{"date":"2020-06-12","close":84.7,"eps_ttm":2.97,"pe":28.52},{"date":"2020-06-19","close":87.43,"eps_ttm":2.97,"pe":29.44},{"date":"2020-06-26","close":88.4075,"eps_ttm":2.97,"pe":29.77},{"date":"2020-07-02","close":91.0275,"eps_ttm":2.97,"pe":30.65},{"date":"2020-07-10","close":95.92,"eps_ttm":2.97,"pe":32.3},{"date":"2020-07-17","close":96.3275,"eps_ttm":2.97,"pe":32.43},{"date":"2020-07-24","close":92.615,"eps_ttm":2.97,"pe":31.18},{"date":"2020-07-31","close":106.26,"eps_ttm":2.97,"pe":35.78},{"date":"2020-08-07","close":111.1125,"eps_ttm":2.97,"pe":37.41},{"date":"2020-08-14","close":114.9075,"eps_ttm":2.97,"pe":38.69},{"date":"2020-08-21","close":124.37,"eps_ttm":2.97,"pe":41.88},{"date":"2020-08-28","close":124.8075,"eps_ttm":2.97,"pe":42.02},{"date":"2020-09-04","close":120.96,"eps_ttm":2.97,"pe":40.73},{"date":"2020-09-11","close":112,"eps_ttm":2.97,"pe":37.71},{"date":"2020-09-18","close":106.84,"eps_ttm":2.97,"pe":35.97},{"date":"2020-09-25","close":112.28,"eps_ttm":2.97,"pe":37.8},{"date":"2020-10-02","close":113.02,"eps_ttm":2.97,"pe":38.05},{"date":"2020-10-09","close":116.97,"eps_ttm":2.97,"pe":39.38},{"date":"2020-10-16","close":119.02,"eps_ttm":2.97,"pe":40.07},{"date":"2020-10-23","close":115.04,"eps_ttm":2.97,"pe":38.73},{"date":"2020-10-30","close":108.86,"eps_ttm":3.28,"pe":33.19},{"date":"2020-11-06","close":118.69,"eps_ttm":3.28,"pe":36.19},{"date":"2020-11-13","close":119.26,"eps_ttm":3.28,"pe":36.36},{"date":"2020-11-20","close":117.34,"eps_ttm":3.28,"pe":35.77},{"date":"2020-11-27","close":116.59,"eps_ttm":3.28,"pe":35.55},{"date":"2020-12-04","close":122.25,"eps_ttm":3.28,"pe":37.27},{"date":"2020-12-11","close":122.41,"eps_ttm":3.28,"pe":37.32},{"date":"2020-12-18","close":126.66,"eps_ttm":3.28,"pe":38.62},{"date":"2020-12-24","close":131.97,"eps_ttm":3.28,"pe":40.23},{"date":"2020-12-31","close":132.69,"eps_ttm":3.28,"pe":40.45},{"date":"2021-01-08","close":132.05,"eps_ttm":3.28,"pe":40.26},{"date":"2021-01-15","close":127.14,"eps_ttm":3.28,"pe":38.76},{"date":"2021-01-22","close":139.07,"eps_ttm":3.28,"pe":42.4},{"date":"2021-01-29","close":131.96,"eps_ttm":3.28,"pe":40.23},{"date":"2021-02-05","close":136.76,"eps_ttm":3.28,"pe":41.7},{"date":"2021-02-12","close":135.37,"eps_ttm":3.28,"pe":41.27},{"date":"2021-02-19","close":129.87,"eps_ttm":3.28,"pe":39.59},{"date":"2021-02-26","close":121.26,"eps_ttm":3.28,"pe":36.97},{"date":"2021-03-05","close":121.42,"eps_ttm":3.28,"pe":37.02},{"date":"2021-03-12","close":121.03,"eps_ttm":3.28,"pe":36.9},{"date":"2021-03-19","close":119.99,"eps_ttm":3.28,"pe":36.58},{"date":"2021-03-26","close":121.21,"eps_ttm":3.28,"pe":36.95},{"date":"2021-04-01","close":123,"eps_ttm":3.28,"pe":37.5},{"date":"2021-04-09","close":133,"eps_ttm":3.28,"pe":40.55},{"date":"2021-04-16","close":134.16,"eps_ttm":3.28,"pe":40.9},{"date":"2021-04-23","close":134.32,"eps_ttm":3.28,"pe":40.95},{"date":"2021-04-30","close":131.46,"eps_ttm":3.28,"pe":40.08},{"date":"2021-05-07","close":130.21,"eps_ttm":3.28,"pe":39.7},{"date":"2021-05-14","close":127.45,"eps_ttm":3.28,"pe":38.86},{"date":"2021-05-21","close":125.43,"eps_ttm":3.28,"pe":38.24},{"date":"2021-05-28","close":124.61,"eps_ttm":3.28,"pe":37.99},{"date":"2021-06-04","close":125.89,"eps_ttm":3.28,"pe":38.38},{"date":"2021-06-11","close":127.35,"eps_ttm":3.28,"pe":38.83},{"date":"2021-06-18","close":130.46,"eps_ttm":3.28,"pe":39.77},{"date":"2021-06-25","close":133.11,"eps_ttm":3.28,"pe":40.58},{"date":"2021-07-02","close":139.96,"eps_ttm":3.28,"pe":42.67},{"date":"2021-07-09","close":145.11,"eps_ttm":3.28,"pe":44.24},{"date":"2021-07-16","close":146.39,"eps_ttm":3.28,"pe":44.63},{"date":"2021-07-23","close":148.56,"eps_ttm":3.28,"pe":45.29},{"date":"2021-07-30","close":145.86,"eps_ttm":3.28,"pe":44.47},{"date":"2021-08-06","close":146.14,"eps_ttm":3.28,"pe":44.55},{"date":"2021-08-13","close":149.1,"eps_ttm":3.28,"pe":45.46},{"date":"2021-08-20","close":148.19,"eps_ttm":3.28,"pe":45.18},{"date":"2021-08-27","close":148.6,"eps_ttm":3.28,"pe":45.3},{"date":"2021-09-03","close":154.3,"eps_ttm":3.28,"pe":47.04},{"date":"2021-09-10","close":148.97,"eps_ttm":3.28,"pe":45.42},{"date":"2021-09-17","close":146.06,"eps_ttm":3.28,"pe":44.53},{"date":"2021-09-24","close":146.92,"eps_ttm":3.28,"pe":44.79},{"date":"2021-10-01","close":142.65,"eps_ttm":3.28,"pe":43.49},{"date":"2021-10-08","close":142.9,"eps_ttm":3.28,"pe":43.57},{"date":"2021-10-15","close":144.84,"eps_ttm":3.28,"pe":44.16},{"date":"2021-10-22","close":148.69,"eps_ttm":3.28,"pe":45.33},{"date":"2021-10-29","close":149.8,"eps_ttm":5.61,"pe":26.7},{"date":"2021-11-05","close":151.28,"eps_ttm":5.61,"pe":26.97},{"date":"2021-11-12","close":149.99,"eps_ttm":5.61,"pe":26.74},{"date":"2021-11-19","close":160.55,"eps_ttm":5.61,"pe":28.62},{"date":"2021-11-26","close":156.81,"eps_ttm":5.61,"pe":27.95},{"date":"2021-12-03","close":161.84,"eps_ttm":5.61,"pe":28.85},{"date":"2021-12-10","close":179.45,"eps_ttm":5.61,"pe":31.99},{"date":"2021-12-17","close":171.14,"eps_ttm":5.61,"pe":30.51},{"date":"2021-12-23","close":176.28,"eps_ttm":5.61,"pe":31.42},{"date":"2021-12-31","close":177.57,"eps_ttm":5.61,"pe":31.65},{"date":"2022-01-07","close":172.17,"eps_ttm":5.61,"pe":30.69},{"date":"2022-01-14","close":173.07,"eps_ttm":5.61,"pe":30.85},{"date":"2022-01-21","close":162.41,"eps_ttm":5.61,"pe":28.95},{"date":"2022-01-28","close":170.33,"eps_ttm":5.61,"pe":30.36},{"date":"2022-02-04","close":172.39,"eps_ttm":5.61,"pe":30.73},{"date":"2022-02-11","close":168.64,"eps_ttm":5.61,"pe":30.06},{"date":"2022-02-18","close":167.3,"eps_ttm":5.61,"pe":29.82},{"date":"2022-02-25","close":164.85,"eps_ttm":5.61,"pe":29.39},{"date":"2022-03-04","close":163.17,"eps_ttm":5.61,"pe":29.09},{"date":"2022-03-11","close":154.73,"eps_ttm":5.61,"pe":27.58},{"date":"2022-03-18","close":163.98,"eps_ttm":5.61,"pe":29.23},{"date":"2022-03-25","close":174.72,"eps_ttm":5.61,"pe":31.14},{"date":"2022-04-01","close":174.31,"eps_ttm":5.61,"pe":31.07},{"date":"2022-04-08","close":170.09,"eps_ttm":5.61,"pe":30.32},{"date":"2022-04-14","close":165.29,"eps_ttm":5.61,"pe":29.46},{"date":"2022-04-22","close":161.79,"eps_ttm":5.61,"pe":28.84},{"date":"2022-04-29","close":157.65,"eps_ttm":5.61,"pe":28.1},{"date":"2022-05-06","close":157.28,"eps_ttm":5.61,"pe":28.04},{"date":"2022-05-13","close":147.11,"eps_ttm":5.61,"pe":26.22},{"date":"2022-05-20","close":137.59,"eps_ttm":5.61,"pe":24.53},{"date":"2022-05-27","close":149.64,"eps_ttm":5.61,"pe":26.67},{"date":"2022-06-03","close":145.38,"eps_ttm":5.61,"pe":25.91},{"date":"2022-06-10","close":137.13,"eps_ttm":5.61,"pe":24.44},{"date":"2022-06-17","close":131.56,"eps_ttm":5.61,"pe":23.45},{"date":"2022-06-24","close":141.66,"eps_ttm":5.61,"pe":25.25},{"date":"2022-07-01","close":138.93,"eps_ttm":5.61,"pe":24.76},{"date":"2022-07-08","close":147.04,"eps_ttm":5.61,"pe":26.21},{"date":"2022-07-15","close":150.17,"eps_ttm":5.61,"pe":26.77},{"date":"2022-07-22","close":154.09,"eps_ttm":5.61,"pe":27.47},{"date":"2022-07-29","close":162.51,"eps_ttm":5.61,"pe":28.97},{"date":"2022-08-05","close":165.35,"eps_ttm":5.61,"pe":29.47},{"date":"2022-08-12","close":172.1,"eps_ttm":5.61,"pe":30.68},{"date":"2022-08-19","close":171.52,"eps_ttm":5.61,"pe":30.57},{"date":"2022-08-26","close":163.62,"eps_ttm":5.61,"pe":29.17},{"date":"2022-09-02","close":155.81,"eps_ttm":5.61,"pe":27.77},{"date":"2022-09-09","close":157.37,"eps_ttm":5.61,"pe":28.05},{"date":"2022-09-16","close":150.7,"eps_ttm":5.61,"pe":26.86},{"date":"2022-09-23","close":150.43,"eps_ttm":5.61,"pe":26.81},{"date":"2022-09-30","close":138.2,"eps_ttm":5.61,"pe":24.63},{"date":"2022-10-07","close":140.09,"eps_ttm":5.61,"pe":24.97},{"date":"2022-10-14","close":138.38,"eps_ttm":5.61,"pe":24.67},{"date":"2022-10-21","close":147.27,"eps_ttm":5.61,"pe":26.25},{"date":"2022-10-28","close":155.74,"eps_ttm":6.11,"pe":25.49},{"date":"2022-11-04","close":138.38,"eps_ttm":6.11,"pe":22.65},{"date":"2022-11-11","close":149.7,"eps_ttm":6.11,"pe":24.5},{"date":"2022-11-18","close":151.29,"eps_ttm":6.11,"pe":24.76},{"date":"2022-11-25","close":148.11,"eps_ttm":6.11,"pe":24.24},{"date":"2022-12-02","close":147.81,"eps_ttm":6.11,"pe":24.19},{"date":"2022-12-09","close":142.16,"eps_ttm":6.11,"pe":23.27},{"date":"2022-12-16","close":134.51,"eps_ttm":6.11,"pe":22.01},{"date":"2022-12-23","close":131.86,"eps_ttm":6.11,"pe":21.58},{"date":"2022-12-30","close":129.93,"eps_ttm":6.11,"pe":21.27},{"date":"2023-01-06","close":129.62,"eps_ttm":6.11,"pe":21.21},{"date":"2023-01-13","close":134.76,"eps_ttm":6.11,"pe":22.06},{"date":"2023-01-20","close":137.87,"eps_ttm":6.11,"pe":22.56},{"date":"2023-01-27","close":145.93,"eps_ttm":6.11,"pe":23.88},{"date":"2023-02-03","close":154.5,"eps_ttm":6.11,"pe":25.29},{"date":"2023-02-10","close":151.01,"eps_ttm":6.11,"pe":24.72},{"date":"2023-02-17","close":152.55,"eps_ttm":6.11,"pe":24.97},{"date":"2023-02-24","close":146.71,"eps_ttm":6.11,"pe":24.01},{"date":"2023-03-03","close":151.03,"eps_ttm":6.11,"pe":24.72},{"date":"2023-03-10","close":148.5,"eps_ttm":6.11,"pe":24.3},{"date":"2023-03-17","close":155,"eps_ttm":6.11,"pe":25.37},{"date":"2023-03-24","close":160.25,"eps_ttm":6.11,"pe":26.23},{"date":"2023-03-31","close":164.9,"eps_ttm":6.11,"pe":26.99},{"date":"2023-04-06","close":164.66,"eps_ttm":6.11,"pe":26.95},{"date":"2023-04-14","close":165.21,"eps_ttm":6.11,"pe":27.04},{"date":"2023-04-21","close":165.02,"eps_ttm":6.11,"pe":27.01},{"date":"2023-04-28","close":169.68,"eps_ttm":6.11,"pe":27.77},{"date":"2023-05-05","close":173.57,"eps_ttm":6.11,"pe":28.41},{"date":"2023-05-12","close":172.57,"eps_ttm":6.11,"pe":28.24},{"date":"2023-05-19","close":175.16,"eps_ttm":6.11,"pe":28.67},{"date":"2023-05-26","close":175.43,"eps_ttm":6.11,"pe":28.71},{"date":"2023-06-02","close":180.95,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-09","close":180.96,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-16","close":184.92,"eps_ttm":6.11,"pe":30.27},{"date":"2023-06-23","close":186.68,"eps_ttm":6.11,"pe":30.55},{"date":"2023-06-30","close":193.97,"eps_ttm":6.11,"pe":31.75},{"date":"2023-07-07","close":190.68,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-14","close":190.69,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-21","close":191.94,"eps_ttm":6.11,"pe":31.41},{"date":"2023-07-28","close":195.83,"eps_ttm":6.11,"pe":32.05},{"date":"2023-08-04","close":181.99,"eps_ttm":6.11,"pe":29.79},{"date":"2023-08-11","close":177.79,"eps_ttm":6.11,"pe":29.1},{"date":"2023-08-18","close":174.49,"eps_ttm":6.11,"pe":28.56},{"date":"2023-08-25","close":178.61,"eps_ttm":6.11,"pe":29.23},{"date":"2023-09-01","close":189.46,"eps_ttm":6.11,"pe":31.01},{"date":"2023-09-08","close":178.18,"eps_ttm":6.11,"pe":29.16},{"date":"2023-09-15","close":175.01,"eps_ttm":6.11,"pe":28.64},{"date":"2023-09-22","close":174.79,"eps_ttm":6.11,"pe":28.61},{"date":"2023-09-29","close":171.21,"eps_ttm":6.11,"pe":28.02},{"date":"2023-10-06","close":177.49,"eps_ttm":6.11,"pe":29.05},{"date":"2023-10-13","close":178.85,"eps_ttm":6.11,"pe":29.27},{"date":"2023-10-20","close":172.88,"eps_ttm":6.11,"pe":28.29},{"date":"2023-10-27","close":168.22,"eps_ttm":6.11,"pe":27.53},{"date":"2023-11-03","close":176.65,"eps_ttm":6.13,"pe":28.82},{"date":"2023-11-10","close":186.4,"eps_ttm":6.13,"pe":30.41},{"date":"2023-11-17","close":189.69,"eps_ttm":6.13,"pe":30.94},{"date":"2023-11-24","close":189.97,"eps_ttm":6.13,"pe":30.99},{"date":"2023-12-01","close":191.24,"eps_ttm":6.13,"pe":31.2},{"date":"2023-12-08","close":195.71,"eps_ttm":6.13,"pe":31.93},{"date":"2023-12-15","close":197.57,"eps_ttm":6.13,"pe":32.23},{"date":"2023-12-22","close":193.6,"eps_ttm":6.13,"pe":31.58},{"date":"2023-12-29","close":192.53,"eps_ttm":6.13,"pe":31.41},{"date":"2024-01-05","close":181.18,"eps_ttm":6.13,"pe":29.56},{"date":"2024-01-12","close":185.92,"eps_ttm":6.13,"pe":30.33},{"date":"2024-01-19","close":191.56,"eps_ttm":6.13,"pe":31.25},{"date":"2024-01-26","close":192.42,"eps_ttm":6.13,"pe":31.39},{"date":"2024-02-02","close":185.85,"eps_ttm":6.13,"pe":30.32},{"date":"2024-02-09","close":188.85,"eps_ttm":6.13,"pe":30.81},{"date":"2024-02-16","close":182.31,"eps_ttm":6.13,"pe":29.74},{"date":"2024-02-23","close":182.52,"eps_ttm":6.13,"pe":29.77},{"date":"2024-03-01","close":179.66,"eps_ttm":6.13,"pe":29.31},{"date":"2024-03-08","close":170.73,"eps_ttm":6.13,"pe":27.85},{"date":"2024-03-15","close":172.62,"eps_ttm":6.13,"pe":28.16},{"date":"2024-03-22","close":172.28,"eps_ttm":6.13,"pe":28.1},{"date":"2024-03-28","close":171.48,"eps_ttm":6.13,"pe":27.97},{"date":"2024-04-05","close":169.58,"eps_ttm":6.13,"pe":27.66},{"date":"2024-04-12","close":176.55,"eps_ttm":6.13,"pe":28.8},{"date":"2024-04-19","close":165,"eps_ttm":6.13,"pe":26.92},{"date":"2024-04-26","close":169.3,"eps_ttm":6.13,"pe":27.62},{"date":"2024-05-03","close":183.38,"eps_ttm":6.13,"pe":29.92},{"date":"2024-05-10","close":183.05,"eps_ttm":6.13,"pe":29.86},{"date":"2024-05-17","close":189.87,"eps_ttm":6.13,"pe":30.97},{"date":"2024-05-24","close":189.98,"eps_ttm":6.13,"pe":30.99},{"date":"2024-05-31","close":192.25,"eps_ttm":6.13,"pe":31.36},{"date":"2024-06-07","close":196.89,"eps_ttm":6.13,"pe":32.12},{"date":"2024-06-14","close":212.49,"eps_ttm":6.13,"pe":34.66},{"date":"2024-06-21","close":207.49,"eps_ttm":6.13,"pe":33.85},{"date":"2024-06-28","close":210.62,"eps_ttm":6.13,"pe":34.36},{"date":"2024-07-05","close":226.34,"eps_ttm":6.13,"pe":36.92},{"date":"2024-07-12","close":230.54,"eps_ttm":6.13,"pe":37.61},{"date":"2024-07-19","close":224.31,"eps_ttm":6.13,"pe":36.59},{"date":"2024-07-26","close":217.96,"eps_ttm":6.13,"pe":35.56},{"date":"2024-08-02","close":219.86,"eps_ttm":6.13,"pe":35.87},{"date":"2024-08-09","close":216.24,"eps_ttm":6.13,"pe":35.28},{"date":"2024-08-16","close":226.05,"eps_ttm":6.13,"pe":36.88},{"date":"2024-08-23","close":226.84,"eps_ttm":6.13,"pe":37},{"date":"2024-08-30","close":229,"eps_ttm":6.13,"pe":37.36},{"date":"2024-09-06","close":220.82,"eps_ttm":6.13,"pe":36.02},{"date":"2024-09-13","close":222.5,"eps_ttm":6.13,"pe":36.3},{"date":"2024-09-20","close":228.2,"eps_ttm":6.13,"pe":37.23},{"date":"2024-09-27","close":227.79,"eps_ttm":6.13,"pe":37.16},{"date":"2024-10-04","close":226.8,"eps_ttm":6.13,"pe":37},{"date":"2024-10-11","close":227.55,"eps_ttm":6.13,"pe":37.12},{"date":"2024-10-18","close":235,"eps_ttm":6.13,"pe":38.34},{"date":"2024-10-25","close":231.41,"eps_ttm":6.13,"pe":37.75},{"date":"2024-11-01","close":222.91,"eps_ttm":6.08,"pe":36.66},{"date":"2024-11-08","close":226.96,"eps_ttm":6.08,"pe":37.33},{"date":"2024-11-15","close":225,"eps_ttm":6.08,"pe":37.01},{"date":"2024-11-22","close":229.87,"eps_ttm":6.08,"pe":37.81},{"date":"2024-11-29","close":237.33,"eps_ttm":6.08,"pe":39.03},{"date":"2024-12-06","close":242.84,"eps_ttm":6.08,"pe":39.94},{"date":"2024-12-13","close":248.13,"eps_ttm":6.08,"pe":40.81},{"date":"2024-12-20","close":254.49,"eps_ttm":6.08,"pe":41.86},{"date":"2024-12-27","close":255.59,"eps_ttm":6.08,"pe":42.04},{"date":"2025-01-03","close":243.36,"eps_ttm":6.08,"pe":40.03},{"date":"2025-01-10","close":236.85,"eps_ttm":6.08,"pe":38.96},{"date":"2025-01-17","close":229.98,"eps_ttm":6.08,"pe":37.83},{"date":"2025-01-24","close":222.78,"eps_ttm":6.08,"pe":36.64},{"date":"2025-01-31","close":236,"eps_ttm":6.08,"pe":38.82},{"date":"2025-02-07","close":227.63,"eps_ttm":6.08,"pe":37.44},{"date":"2025-02-14","close":244.6,"eps_ttm":6.08,"pe":40.23},{"date":"2025-02-21","close":245.55,"eps_ttm":6.08,"pe":40.39},{"date":"2025-02-28","close":241.84,"eps_ttm":6.08,"pe":39.78},{"date":"2025-03-07","close":239.07,"eps_ttm":6.08,"pe":39.32},{"date":"2025-03-14","close":213.49,"eps_ttm":6.08,"pe":35.11},{"date":"2025-03-21","close":218.27,"eps_ttm":6.08,"pe":35.9},{"date":"2025-03-28","close":217.9,"eps_ttm":6.08,"pe":35.84},{"date":"2025-04-04","close":188.38,"eps_ttm":6.08,"pe":30.98},{"date":"2025-04-11","close":198.15,"eps_ttm":6.08,"pe":32.59},{"date":"2025-04-17","close":196.98,"eps_ttm":6.08,"pe":32.4},{"date":"2025-04-25","close":209.28,"eps_ttm":6.08,"pe":34.42},{"date":"2025-05-02","close":205.35,"eps_ttm":6.08,"pe":33.77},{"date":"2025-05-09","close":198.53,"eps_ttm":6.08,"pe":32.65},{"date":"2025-05-16","close":211.26,"eps_ttm":6.08,"pe":34.75},{"date":"2025-05-23","close":195.27,"eps_ttm":6.08,"pe":32.12},{"date":"2025-05-30","close":200.85,"eps_ttm":6.08,"pe":33.03},{"date":"2025-06-06","close":203.92,"eps_ttm":6.08,"pe":33.54},{"date":"2025-06-13","close":196.45,"eps_ttm":6.08,"pe":32.31},{"date":"2025-06-20","close":201,"eps_ttm":6.08,"pe":33.06},{"date":"2025-06-27","close":201.08,"eps_ttm":6.08,"pe":33.07},{"date":"2025-07-03","close":213.55,"eps_ttm":6.08,"pe":35.12},{"date":"2025-07-11","close":211.16,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-18","close":211.18,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-25","close":213.88,"eps_ttm":6.08,"pe":35.18},{"date":"2025-08-01","close":202.38,"eps_ttm":6.08,"pe":33.29},{"date":"2025-08-08","close":229.35,"eps_ttm":6.08,"pe":37.72},{"date":"2025-08-15","close":231.59,"eps_ttm":6.08,"pe":38.09},{"date":"2025-08-22","close":227.76,"eps_ttm":6.08,"pe":37.46},{"date":"2025-08-29","close":232.14,"eps_ttm":6.08,"pe":38.18},{"date":"2025-09-05","close":239.69,"eps_ttm":6.08,"pe":39.42},{"date":"2025-09-12","close":234.07,"eps_ttm":6.08,"pe":38.5},{"date":"2025-09-19","close":245.5,"eps_ttm":6.08,"pe":40.38},{"date":"2025-09-26","close":255.46,"eps_ttm":6.08,"pe":42.02},{"date":"2025-10-03","close":258.02,"eps_ttm":6.08,"pe":42.44},{"date":"2025-10-10","close":245.27,"eps_ttm":6.08,"pe":40.34},{"date":"2025-10-17","close":252.29,"eps_ttm":6.08,"pe":41.5},{"date":"2025-10-24","close":262.82,"eps_ttm":6.08,"pe":43.23},{"date":"2025-10-31","close":270.37,"eps_ttm":7.46,"pe":36.24},{"date":"2025-11-07","close":268.47,"eps_ttm":7.46,"pe":35.99},{"date":"2025-11-14","close":272.41,"eps_ttm":7.46,"pe":36.52},{"date":"2025-11-21","close":271.49,"eps_ttm":7.46,"pe":36.39},{"date":"2025-11-28","close":278.85,"eps_ttm":7.46,"pe":37.38},{"date":"2025-12-05","close":278.78,"eps_ttm":7.46,"pe":37.37},{"date":"2025-12-12","close":278.28,"eps_ttm":7.46,"pe":37.3},{"date":"2025-12-19","close":273.67,"eps_ttm":7.46,"pe":36.68},{"date":"2025-12-26","close":273.4,"eps_ttm":7.46,"pe":36.65},{"date":"2026-01-02","close":271.01,"eps_ttm":7.46,"pe":36.33},{"date":"2026-01-09","close":259.37,"eps_ttm":7.46,"pe":34.77},{"date":"2026-01-16","close":255.53,"eps_ttm":7.46,"pe":34.25},{"date":"2026-01-23","close":248.04,"eps_ttm":7.46,"pe":33.25},{"date":"2026-01-30","close":259.48,"eps_ttm":7.46,"pe":34.78},{"date":"2026-02-06","close":278.12,"eps_ttm":7.46,"pe":37.28},{"date":"2026-02-13","close":255.78,"eps_ttm":7.46,"pe":34.29},{"date":"2026-02-20","close":264.58,"eps_ttm":7.46,"pe":35.47},{"date":"2026-02-27","close":264.18,"eps_ttm":7.46,"pe":35.41},{"date":"2026-03-06","close":257.46,"eps_ttm":7.46,"pe":34.51},{"date":"2026-03-13","close":250.12,"eps_ttm":7.46,"pe":33.53},{"date":"2026-03-20","close":247.99,"eps_ttm":7.46,"pe":33.24},{"date":"2026-03-27","close":248.8,"eps_ttm":7.46,"pe":33.35},{"date":"2026-04-02","close":255.92,"eps_ttm":7.46,"pe":34.31},{"date":"2026-04-10","close":260.48,"eps_ttm":7.46,"pe":34.92},{"date":"2026-04-17","close":270.23,"eps_ttm":7.46,"pe":36.22},{"date":"2026-04-24","close":271.06,"eps_ttm":7.46,"pe":36.34},{"date":"2026-05-01","close":280.14,"eps_ttm":7.46,"pe":37.55},{"date":"2026-05-08","close":293.32,"eps_ttm":7.46,"pe":39.32},{"date":"2026-05-15","close":300.23,"eps_ttm":7.46,"pe":40.25},{"date":"2026-05-22","close":308.82,"eps_ttm":7.46,"pe":41.4},{"date":"2026-05-29","close":312.06,"eps_ttm":7.46,"pe":41.83},{"date":"2026-06-05","close":307.34,"eps_ttm":7.46,"pe":41.2},{"date":"2026-06-12","close":291.13,"eps_ttm":7.46,"pe":39.03},{"date":"2026-06-18","close":298.01,"eps_ttm":7.46,"pe":39.95},{"date":"2026-06-26","close":283.78,"eps_ttm":7.46,"pe":38.04},{"date":"2026-07-02","close":308.63,"eps_ttm":7.46,"pe":41.37},{"date":"2026-07-10","close":315.32,"eps_ttm":7.46,"pe":42.27},{"date":"2026-07-17","close":333.74,"eps_ttm":7.46,"pe":44.74},{"date":"2026-07-24","close":333.02,"eps_ttm":7.46,"pe":44.64},{"date":"2026-07-31","close":308.91,"eps_ttm":7.46,"pe":41.41},{"date":"2026-08-07","close":313.33,"eps_ttm":7.46,"pe":42}]}
\ No newline at end of file
1 +{"ticker":"AAPL","metric":"pe","note":"Illustrative fallback used only when /v1/fundamentals/AAPL/ratios/daily is not live: split-adjusted weekly close (HF Market Data, adj_split) divided by the diluted EPS reported in Apple's latest Form 10-K at that date (10-K EPS stepped at each release; not a trailing-twelve-month series).","source":{"prices":"https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day&adjustment=adj_split&start=2015-01-01&limit=50000","eps":"Apple Inc. Form 10-K filings FY2014\u2013FY2025 (diluted EPS, split-adjusted)"},"generated_at":"2026-09-04T23:38:04.919Z","points":[{"date":"2015-01-02","close":27.33,"eps_ttm":1.61,"pe":16.98},{"date":"2015-01-09","close":28.0,"eps_ttm":1.61,"pe":17.39},{"date":"2015-01-16","close":26.5,"eps_ttm":1.61,"pe":16.46},{"date":"2015-01-23","close":28.25,"eps_ttm":1.61,"pe":17.54},{"date":"2015-01-30","close":29.29,"eps_ttm":1.61,"pe":18.19},{"date":"2015-02-06","close":29.73,"eps_ttm":1.61,"pe":18.47},{"date":"2015-02-13","close":31.77,"eps_ttm":1.61,"pe":19.73},{"date":"2015-02-20","close":32.38,"eps_ttm":1.61,"pe":20.11},{"date":"2015-02-27","close":32.12,"eps_ttm":1.61,"pe":19.95},{"date":"2015-03-06","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-03-13","close":30.9,"eps_ttm":1.61,"pe":19.19},{"date":"2015-03-20","close":31.48,"eps_ttm":1.61,"pe":19.55},{"date":"2015-03-27","close":30.81,"eps_ttm":1.61,"pe":19.14},{"date":"2015-04-02","close":31.33,"eps_ttm":1.61,"pe":19.46},{"date":"2015-04-10","close":31.77,"eps_ttm":1.61,"pe":19.74},{"date":"2015-04-17","close":31.19,"eps_ttm":1.61,"pe":19.37},{"date":"2015-04-24","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-05-01","close":32.24,"eps_ttm":1.61,"pe":20.02},{"date":"2015-05-08","close":31.91,"eps_ttm":1.61,"pe":19.82},{"date":"2015-05-15","close":32.19,"eps_ttm":1.61,"pe":20},{"date":"2015-05-22","close":33.13,"eps_ttm":1.61,"pe":20.58},{"date":"2015-05-29","close":32.57,"eps_ttm":1.61,"pe":20.23},{"date":"2015-06-05","close":32.16,"eps_ttm":1.61,"pe":19.98},{"date":"2015-06-12","close":31.79,"eps_ttm":1.61,"pe":19.75},{"date":"2015-06-19","close":31.65,"eps_ttm":1.61,"pe":19.66},{"date":"2015-06-26","close":31.69,"eps_ttm":1.61,"pe":19.68},{"date":"2015-07-02","close":31.61,"eps_ttm":1.61,"pe":19.63},{"date":"2015-07-10","close":30.82,"eps_ttm":1.61,"pe":19.14},{"date":"2015-07-17","close":32.41,"eps_ttm":1.61,"pe":20.13},{"date":"2015-07-24","close":31.12,"eps_ttm":1.61,"pe":19.33},{"date":"2015-07-31","close":30.32,"eps_ttm":1.61,"pe":18.84},{"date":"2015-08-07","close":28.88,"eps_ttm":1.61,"pe":17.94},{"date":"2015-08-14","close":28.99,"eps_ttm":1.61,"pe":18.01},{"date":"2015-08-21","close":26.44,"eps_ttm":1.61,"pe":16.42},{"date":"2015-08-28","close":28.32,"eps_ttm":1.61,"pe":17.59},{"date":"2015-09-04","close":27.32,"eps_ttm":1.61,"pe":16.97},{"date":"2015-09-11","close":28.55,"eps_ttm":1.61,"pe":17.73},{"date":"2015-09-18","close":28.36,"eps_ttm":1.61,"pe":17.62},{"date":"2015-09-25","close":28.68,"eps_ttm":1.61,"pe":17.81},{"date":"2015-10-02","close":27.59,"eps_ttm":1.61,"pe":17.14},{"date":"2015-10-09","close":28.03,"eps_ttm":1.61,"pe":17.41},{"date":"2015-10-16","close":27.76,"eps_ttm":1.61,"pe":17.24},{"date":"2015-10-23","close":29.77,"eps_ttm":1.61,"pe":18.49},{"date":"2015-10-30","close":29.88,"eps_ttm":2.31,"pe":12.93},{"date":"2015-11-06","close":30.27,"eps_ttm":2.31,"pe":13.1},{"date":"2015-11-13","close":28.09,"eps_ttm":2.31,"pe":12.16},{"date":"2015-11-20","close":29.82,"eps_ttm":2.31,"pe":12.91},{"date":"2015-11-27","close":29.45,"eps_ttm":2.31,"pe":12.75},{"date":"2015-12-04","close":29.76,"eps_ttm":2.31,"pe":12.88},{"date":"2015-12-11","close":28.3,"eps_ttm":2.31,"pe":12.25},{"date":"2015-12-18","close":26.51,"eps_ttm":2.31,"pe":11.48},{"date":"2015-12-24","close":27.01,"eps_ttm":2.31,"pe":11.69},{"date":"2015-12-31","close":26.32,"eps_ttm":2.31,"pe":11.39},{"date":"2016-01-08","close":24.24,"eps_ttm":2.31,"pe":10.49},{"date":"2016-01-15","close":24.28,"eps_ttm":2.31,"pe":10.51},{"date":"2016-01-22","close":25.36,"eps_ttm":2.31,"pe":10.98},{"date":"2016-01-29","close":24.34,"eps_ttm":2.31,"pe":10.53},{"date":"2016-02-05","close":23.5,"eps_ttm":2.31,"pe":10.18},{"date":"2016-02-12","close":23.5,"eps_ttm":2.31,"pe":10.17},{"date":"2016-02-19","close":24.01,"eps_ttm":2.31,"pe":10.39},{"date":"2016-02-26","close":24.23,"eps_ttm":2.31,"pe":10.49},{"date":"2016-03-04","close":25.75,"eps_ttm":2.31,"pe":11.15},{"date":"2016-03-11","close":25.57,"eps_ttm":2.31,"pe":11.07},{"date":"2016-03-18","close":26.48,"eps_ttm":2.31,"pe":11.46},{"date":"2016-03-24","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-01","close":27.5,"eps_ttm":2.31,"pe":11.9},{"date":"2016-04-08","close":27.16,"eps_ttm":2.31,"pe":11.76},{"date":"2016-04-15","close":27.46,"eps_ttm":2.31,"pe":11.89},{"date":"2016-04-22","close":26.42,"eps_ttm":2.31,"pe":11.44},{"date":"2016-04-29","close":23.43,"eps_ttm":2.31,"pe":10.15},{"date":"2016-05-06","close":23.18,"eps_ttm":2.31,"pe":10.03},{"date":"2016-05-13","close":22.63,"eps_ttm":2.31,"pe":9.8},{"date":"2016-05-20","close":23.8,"eps_ttm":2.31,"pe":10.31},{"date":"2016-05-27","close":25.09,"eps_ttm":2.31,"pe":10.86},{"date":"2016-06-03","close":24.48,"eps_ttm":2.31,"pe":10.6},{"date":"2016-06-10","close":24.71,"eps_ttm":2.31,"pe":10.7},{"date":"2016-06-17","close":23.83,"eps_ttm":2.31,"pe":10.32},{"date":"2016-06-24","close":23.35,"eps_ttm":2.31,"pe":10.11},{"date":"2016-07-01","close":23.97,"eps_ttm":2.31,"pe":10.38},{"date":"2016-07-08","close":24.17,"eps_ttm":2.31,"pe":10.46},{"date":"2016-07-15","close":24.7,"eps_ttm":2.31,"pe":10.69},{"date":"2016-07-22","close":24.66,"eps_ttm":2.31,"pe":10.68},{"date":"2016-07-29","close":26.05,"eps_ttm":2.31,"pe":11.28},{"date":"2016-08-05","close":26.87,"eps_ttm":2.31,"pe":11.63},{"date":"2016-08-12","close":27.05,"eps_ttm":2.31,"pe":11.71},{"date":"2016-08-19","close":27.34,"eps_ttm":2.31,"pe":11.84},{"date":"2016-08-26","close":26.73,"eps_ttm":2.31,"pe":11.57},{"date":"2016-09-02","close":26.93,"eps_ttm":2.31,"pe":11.66},{"date":"2016-09-09","close":25.78,"eps_ttm":2.31,"pe":11.16},{"date":"2016-09-16","close":28.73,"eps_ttm":2.31,"pe":12.44},{"date":"2016-09-23","close":28.18,"eps_ttm":2.31,"pe":12.2},{"date":"2016-09-30","close":28.26,"eps_ttm":2.31,"pe":12.23},{"date":"2016-10-07","close":28.52,"eps_ttm":2.31,"pe":12.34},{"date":"2016-10-14","close":29.41,"eps_ttm":2.31,"pe":12.73},{"date":"2016-10-21","close":29.15,"eps_ttm":2.31,"pe":12.62},{"date":"2016-10-28","close":28.43,"eps_ttm":2.08,"pe":13.67},{"date":"2016-11-04","close":27.21,"eps_ttm":2.08,"pe":13.08},{"date":"2016-11-11","close":27.11,"eps_ttm":2.08,"pe":13.03},{"date":"2016-11-18","close":27.52,"eps_ttm":2.08,"pe":13.23},{"date":"2016-11-25","close":27.95,"eps_ttm":2.08,"pe":13.44},{"date":"2016-12-02","close":27.48,"eps_ttm":2.08,"pe":13.21},{"date":"2016-12-09","close":28.49,"eps_ttm":2.08,"pe":13.7},{"date":"2016-12-16","close":28.99,"eps_ttm":2.08,"pe":13.94},{"date":"2016-12-23","close":29.13,"eps_ttm":2.08,"pe":14},{"date":"2016-12-30","close":28.95,"eps_ttm":2.08,"pe":13.92},{"date":"2017-01-06","close":29.48,"eps_ttm":2.08,"pe":14.17},{"date":"2017-01-13","close":29.76,"eps_ttm":2.08,"pe":14.31},{"date":"2017-01-20","close":30,"eps_ttm":2.08,"pe":14.42},{"date":"2017-01-27","close":30.49,"eps_ttm":2.08,"pe":14.66},{"date":"2017-02-03","close":32.27,"eps_ttm":2.08,"pe":15.51},{"date":"2017-02-10","close":33.03,"eps_ttm":2.08,"pe":15.88},{"date":"2017-02-17","close":33.93,"eps_ttm":2.08,"pe":16.31},{"date":"2017-02-24","close":34.16,"eps_ttm":2.08,"pe":16.43},{"date":"2017-03-03","close":34.95,"eps_ttm":2.08,"pe":16.8},{"date":"2017-03-10","close":34.78,"eps_ttm":2.08,"pe":16.72},{"date":"2017-03-17","close":35.0,"eps_ttm":2.08,"pe":16.83},{"date":"2017-03-24","close":35.16,"eps_ttm":2.08,"pe":16.9},{"date":"2017-03-31","close":35.91,"eps_ttm":2.08,"pe":17.27},{"date":"2017-04-07","close":35.84,"eps_ttm":2.08,"pe":17.23},{"date":"2017-04-13","close":35.26,"eps_ttm":2.08,"pe":16.95},{"date":"2017-04-21","close":35.57,"eps_ttm":2.08,"pe":17.1},{"date":"2017-04-28","close":35.91,"eps_ttm":2.08,"pe":17.27},{"date":"2017-05-05","close":37.24,"eps_ttm":2.08,"pe":17.9},{"date":"2017-05-12","close":39.02,"eps_ttm":2.08,"pe":18.76},{"date":"2017-05-19","close":38.27,"eps_ttm":2.08,"pe":18.4},{"date":"2017-05-26","close":38.4,"eps_ttm":2.08,"pe":18.46},{"date":"2017-06-02","close":38.86,"eps_ttm":2.08,"pe":18.68},{"date":"2017-06-09","close":37.24,"eps_ttm":2.08,"pe":17.91},{"date":"2017-06-16","close":35.57,"eps_ttm":2.08,"pe":17.1},{"date":"2017-06-23","close":36.57,"eps_ttm":2.08,"pe":17.58},{"date":"2017-06-30","close":36.01,"eps_ttm":2.08,"pe":17.31},{"date":"2017-07-07","close":36.05,"eps_ttm":2.08,"pe":17.33},{"date":"2017-07-14","close":37.26,"eps_ttm":2.08,"pe":17.91},{"date":"2017-07-21","close":37.57,"eps_ttm":2.08,"pe":18.06},{"date":"2017-07-28","close":37.38,"eps_ttm":2.08,"pe":17.97},{"date":"2017-08-04","close":39.1,"eps_ttm":2.08,"pe":18.8},{"date":"2017-08-11","close":39.37,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-18","close":39.38,"eps_ttm":2.08,"pe":18.93},{"date":"2017-08-25","close":39.97,"eps_ttm":2.08,"pe":19.21},{"date":"2017-09-01","close":41.01,"eps_ttm":2.08,"pe":19.72},{"date":"2017-09-08","close":39.66,"eps_ttm":2.08,"pe":19.07},{"date":"2017-09-15","close":39.97,"eps_ttm":2.08,"pe":19.22},{"date":"2017-09-22","close":37.97,"eps_ttm":2.08,"pe":18.26},{"date":"2017-09-29","close":38.53,"eps_ttm":2.08,"pe":18.52},{"date":"2017-10-06","close":38.83,"eps_ttm":2.08,"pe":18.67},{"date":"2017-10-13","close":39.25,"eps_ttm":2.08,"pe":18.87},{"date":"2017-10-20","close":39.06,"eps_ttm":2.08,"pe":18.78},{"date":"2017-10-27","close":40.76,"eps_ttm":2.08,"pe":19.6},{"date":"2017-11-03","close":43.12,"eps_ttm":2.3,"pe":18.75},{"date":"2017-11-10","close":43.67,"eps_ttm":2.3,"pe":18.99},{"date":"2017-11-17","close":42.54,"eps_ttm":2.3,"pe":18.49},{"date":"2017-11-24","close":43.74,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-01","close":42.76,"eps_ttm":2.3,"pe":18.59},{"date":"2017-12-08","close":42.34,"eps_ttm":2.3,"pe":18.41},{"date":"2017-12-15","close":43.49,"eps_ttm":2.3,"pe":18.91},{"date":"2017-12-22","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2017-12-29","close":42.31,"eps_ttm":2.3,"pe":18.39},{"date":"2018-01-05","close":43.75,"eps_ttm":2.3,"pe":19.02},{"date":"2018-01-12","close":44.27,"eps_ttm":2.3,"pe":19.25},{"date":"2018-01-19","close":44.62,"eps_ttm":2.3,"pe":19.4},{"date":"2018-01-26","close":42.88,"eps_ttm":2.3,"pe":18.64},{"date":"2018-02-02","close":40.12,"eps_ttm":2.3,"pe":17.45},{"date":"2018-02-09","close":39.1,"eps_ttm":2.3,"pe":17},{"date":"2018-02-16","close":43.11,"eps_ttm":2.3,"pe":18.74},{"date":"2018-02-23","close":43.88,"eps_ttm":2.3,"pe":19.08},{"date":"2018-03-02","close":44.05,"eps_ttm":2.3,"pe":19.15},{"date":"2018-03-09","close":44.99,"eps_ttm":2.3,"pe":19.56},{"date":"2018-03-16","close":44.51,"eps_ttm":2.3,"pe":19.35},{"date":"2018-03-23","close":41.23,"eps_ttm":2.3,"pe":17.93},{"date":"2018-03-29","close":41.95,"eps_ttm":2.3,"pe":18.24},{"date":"2018-04-06","close":42.09,"eps_ttm":2.3,"pe":18.3},{"date":"2018-04-13","close":43.68,"eps_ttm":2.3,"pe":18.99},{"date":"2018-04-20","close":41.43,"eps_ttm":2.3,"pe":18.01},{"date":"2018-04-27","close":40.58,"eps_ttm":2.3,"pe":17.64},{"date":"2018-05-04","close":45.96,"eps_ttm":2.3,"pe":19.98},{"date":"2018-05-11","close":47.15,"eps_ttm":2.3,"pe":20.5},{"date":"2018-05-18","close":46.58,"eps_ttm":2.3,"pe":20.25},{"date":"2018-05-25","close":47.15,"eps_ttm":2.3,"pe":20.5},{"date":"2018-06-01","close":47.56,"eps_ttm":2.3,"pe":20.68},{"date":"2018-06-08","close":47.92,"eps_ttm":2.3,"pe":20.84},{"date":"2018-06-15","close":47.21,"eps_ttm":2.3,"pe":20.53},{"date":"2018-06-22","close":46.23,"eps_ttm":2.3,"pe":20.1},{"date":"2018-06-29","close":46.28,"eps_ttm":2.3,"pe":20.12},{"date":"2018-07-06","close":46.99,"eps_ttm":2.3,"pe":20.43},{"date":"2018-07-13","close":47.83,"eps_ttm":2.3,"pe":20.8},{"date":"2018-07-20","close":47.86,"eps_ttm":2.3,"pe":20.81},{"date":"2018-07-27","close":47.74,"eps_ttm":2.3,"pe":20.76},{"date":"2018-08-03","close":52.0,"eps_ttm":2.3,"pe":22.61},{"date":"2018-08-10","close":51.88,"eps_ttm":2.3,"pe":22.56},{"date":"2018-08-17","close":54.4,"eps_ttm":2.3,"pe":23.65},{"date":"2018-08-24","close":54.04,"eps_ttm":2.3,"pe":23.5},{"date":"2018-08-31","close":56.91,"eps_ttm":2.3,"pe":24.74},{"date":"2018-09-07","close":55.33,"eps_ttm":2.3,"pe":24.05},{"date":"2018-09-14","close":55.96,"eps_ttm":2.3,"pe":24.33},{"date":"2018-09-21","close":54.41,"eps_ttm":2.3,"pe":23.66},{"date":"2018-09-28","close":56.44,"eps_ttm":2.3,"pe":24.54},{"date":"2018-10-05","close":56.07,"eps_ttm":2.3,"pe":24.38},{"date":"2018-10-12","close":55.53,"eps_ttm":2.3,"pe":24.14},{"date":"2018-10-19","close":54.83,"eps_ttm":2.3,"pe":23.84},{"date":"2018-10-26","close":54.08,"eps_ttm":2.3,"pe":23.51},{"date":"2018-11-02","close":51.87,"eps_ttm":2.98,"pe":17.41},{"date":"2018-11-09","close":51.12,"eps_ttm":2.98,"pe":17.15},{"date":"2018-11-16","close":48.38,"eps_ttm":2.98,"pe":16.24},{"date":"2018-11-23","close":43.07,"eps_ttm":2.98,"pe":14.45},{"date":"2018-11-30","close":44.65,"eps_ttm":2.98,"pe":14.98},{"date":"2018-12-07","close":42.12,"eps_ttm":2.98,"pe":14.14},{"date":"2018-12-14","close":41.37,"eps_ttm":2.98,"pe":13.88},{"date":"2018-12-21","close":37.68,"eps_ttm":2.98,"pe":12.65},{"date":"2018-12-28","close":39.06,"eps_ttm":2.98,"pe":13.11},{"date":"2019-01-04","close":37.06,"eps_ttm":2.98,"pe":12.44},{"date":"2019-01-11","close":38.07,"eps_ttm":2.98,"pe":12.78},{"date":"2019-01-18","close":39.2,"eps_ttm":2.98,"pe":13.16},{"date":"2019-01-25","close":39.44,"eps_ttm":2.98,"pe":13.23},{"date":"2019-02-01","close":41.63,"eps_ttm":2.98,"pe":13.97},{"date":"2019-02-08","close":42.6,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-15","close":42.6,"eps_ttm":2.98,"pe":14.3},{"date":"2019-02-22","close":43.24,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-01","close":43.74,"eps_ttm":2.98,"pe":14.68},{"date":"2019-03-08","close":43.23,"eps_ttm":2.98,"pe":14.51},{"date":"2019-03-15","close":46.53,"eps_ttm":2.98,"pe":15.61},{"date":"2019-03-22","close":47.76,"eps_ttm":2.98,"pe":16.03},{"date":"2019-03-29","close":47.49,"eps_ttm":2.98,"pe":15.94},{"date":"2019-04-05","close":49.25,"eps_ttm":2.98,"pe":16.53},{"date":"2019-04-12","close":49.72,"eps_ttm":2.98,"pe":16.68},{"date":"2019-04-18","close":50.97,"eps_ttm":2.98,"pe":17.1},{"date":"2019-04-26","close":51.08,"eps_ttm":2.98,"pe":17.14},{"date":"2019-05-03","close":52.94,"eps_ttm":2.98,"pe":17.76},{"date":"2019-05-10","close":49.3,"eps_ttm":2.98,"pe":16.54},{"date":"2019-05-17","close":47.25,"eps_ttm":2.98,"pe":15.86},{"date":"2019-05-24","close":44.74,"eps_ttm":2.98,"pe":15.01},{"date":"2019-05-31","close":43.77,"eps_ttm":2.98,"pe":14.69},{"date":"2019-06-07","close":47.54,"eps_ttm":2.98,"pe":15.95},{"date":"2019-06-14","close":48.19,"eps_ttm":2.98,"pe":16.17},{"date":"2019-06-21","close":49.7,"eps_ttm":2.98,"pe":16.68},{"date":"2019-06-28","close":49.48,"eps_ttm":2.98,"pe":16.6},{"date":"2019-07-05","close":51.06,"eps_ttm":2.98,"pe":17.13},{"date":"2019-07-12","close":50.83,"eps_ttm":2.98,"pe":17.06},{"date":"2019-07-19","close":50.65,"eps_ttm":2.98,"pe":17},{"date":"2019-07-26","close":51.94,"eps_ttm":2.98,"pe":17.43},{"date":"2019-08-02","close":51.01,"eps_ttm":2.98,"pe":17.12},{"date":"2019-08-09","close":50.25,"eps_ttm":2.98,"pe":16.86},{"date":"2019-08-16","close":51.62,"eps_ttm":2.98,"pe":17.32},{"date":"2019-08-23","close":50.66,"eps_ttm":2.98,"pe":17},{"date":"2019-08-30","close":52.19,"eps_ttm":2.98,"pe":17.51},{"date":"2019-09-06","close":53.31,"eps_ttm":2.98,"pe":17.89},{"date":"2019-09-13","close":54.69,"eps_ttm":2.98,"pe":18.35},{"date":"2019-09-20","close":54.43,"eps_ttm":2.98,"pe":18.27},{"date":"2019-09-27","close":54.7,"eps_ttm":2.98,"pe":18.36},{"date":"2019-10-04","close":56.75,"eps_ttm":2.98,"pe":19.04},{"date":"2019-10-11","close":59.05,"eps_ttm":2.98,"pe":19.82},{"date":"2019-10-18","close":59.1,"eps_ttm":2.98,"pe":19.83},{"date":"2019-10-25","close":61.65,"eps_ttm":2.98,"pe":20.69},{"date":"2019-11-01","close":63.95,"eps_ttm":2.97,"pe":21.53},{"date":"2019-11-08","close":65.03,"eps_ttm":2.97,"pe":21.9},{"date":"2019-11-15","close":66.44,"eps_ttm":2.97,"pe":22.37},{"date":"2019-11-22","close":65.44,"eps_ttm":2.97,"pe":22.04},{"date":"2019-11-29","close":66.81,"eps_ttm":2.97,"pe":22.5},{"date":"2019-12-06","close":67.68,"eps_ttm":2.97,"pe":22.79},{"date":"2019-12-13","close":68.79,"eps_ttm":2.97,"pe":23.16},{"date":"2019-12-20","close":69.86,"eps_ttm":2.97,"pe":23.52},{"date":"2019-12-27","close":72.45,"eps_ttm":2.97,"pe":24.39},{"date":"2020-01-03","close":74.36,"eps_ttm":2.97,"pe":25.04},{"date":"2020-01-10","close":77.58,"eps_ttm":2.97,"pe":26.12},{"date":"2020-01-17","close":79.68,"eps_ttm":2.97,"pe":26.83},{"date":"2020-01-24","close":79.58,"eps_ttm":2.97,"pe":26.79},{"date":"2020-01-31","close":77.38,"eps_ttm":2.97,"pe":26.05},{"date":"2020-02-07","close":80.01,"eps_ttm":2.97,"pe":26.94},{"date":"2020-02-14","close":81.24,"eps_ttm":2.97,"pe":27.35},{"date":"2020-02-21","close":78.26,"eps_ttm":2.97,"pe":26.35},{"date":"2020-02-28","close":68.34,"eps_ttm":2.97,"pe":23.01},{"date":"2020-03-06","close":72.26,"eps_ttm":2.97,"pe":24.33},{"date":"2020-03-13","close":69.49,"eps_ttm":2.97,"pe":23.4},{"date":"2020-03-20","close":57.31,"eps_ttm":2.97,"pe":19.3},{"date":"2020-03-27","close":61.94,"eps_ttm":2.97,"pe":20.85},{"date":"2020-04-03","close":60.35,"eps_ttm":2.97,"pe":20.32},{"date":"2020-04-09","close":67.0,"eps_ttm":2.97,"pe":22.56},{"date":"2020-04-17","close":70.7,"eps_ttm":2.97,"pe":23.8},{"date":"2020-04-24","close":70.74,"eps_ttm":2.97,"pe":23.82},{"date":"2020-05-01","close":72.27,"eps_ttm":2.97,"pe":24.33},{"date":"2020-05-08","close":77.53,"eps_ttm":2.97,"pe":26.11},{"date":"2020-05-15","close":76.93,"eps_ttm":2.97,"pe":25.9},{"date":"2020-05-22","close":79.72,"eps_ttm":2.97,"pe":26.84},{"date":"2020-05-29","close":79.48,"eps_ttm":2.97,"pe":26.76},{"date":"2020-06-05","close":82.88,"eps_ttm":2.97,"pe":27.9},{"date":"2020-06-12","close":84.7,"eps_ttm":2.97,"pe":28.52},{"date":"2020-06-19","close":87.43,"eps_ttm":2.97,"pe":29.44},{"date":"2020-06-26","close":88.41,"eps_ttm":2.97,"pe":29.77},{"date":"2020-07-02","close":91.03,"eps_ttm":2.97,"pe":30.65},{"date":"2020-07-10","close":95.92,"eps_ttm":2.97,"pe":32.3},{"date":"2020-07-17","close":96.33,"eps_ttm":2.97,"pe":32.43},{"date":"2020-07-24","close":92.61,"eps_ttm":2.97,"pe":31.18},{"date":"2020-07-31","close":106.26,"eps_ttm":2.97,"pe":35.78},{"date":"2020-08-07","close":111.11,"eps_ttm":2.97,"pe":37.41},{"date":"2020-08-14","close":114.91,"eps_ttm":2.97,"pe":38.69},{"date":"2020-08-21","close":124.37,"eps_ttm":2.97,"pe":41.88},{"date":"2020-08-28","close":124.81,"eps_ttm":2.97,"pe":42.02},{"date":"2020-09-04","close":120.96,"eps_ttm":2.97,"pe":40.73},{"date":"2020-09-11","close":112,"eps_ttm":2.97,"pe":37.71},{"date":"2020-09-18","close":106.84,"eps_ttm":2.97,"pe":35.97},{"date":"2020-09-25","close":112.28,"eps_ttm":2.97,"pe":37.8},{"date":"2020-10-02","close":113.02,"eps_ttm":2.97,"pe":38.05},{"date":"2020-10-09","close":116.97,"eps_ttm":2.97,"pe":39.38},{"date":"2020-10-16","close":119.02,"eps_ttm":2.97,"pe":40.07},{"date":"2020-10-23","close":115.04,"eps_ttm":2.97,"pe":38.73},{"date":"2020-10-30","close":108.86,"eps_ttm":3.28,"pe":33.19},{"date":"2020-11-06","close":118.69,"eps_ttm":3.28,"pe":36.19},{"date":"2020-11-13","close":119.26,"eps_ttm":3.28,"pe":36.36},{"date":"2020-11-20","close":117.34,"eps_ttm":3.28,"pe":35.77},{"date":"2020-11-27","close":116.59,"eps_ttm":3.28,"pe":35.55},{"date":"2020-12-04","close":122.25,"eps_ttm":3.28,"pe":37.27},{"date":"2020-12-11","close":122.41,"eps_ttm":3.28,"pe":37.32},{"date":"2020-12-18","close":126.66,"eps_ttm":3.28,"pe":38.62},{"date":"2020-12-24","close":131.97,"eps_ttm":3.28,"pe":40.23},{"date":"2020-12-31","close":132.69,"eps_ttm":3.28,"pe":40.45},{"date":"2021-01-08","close":132.05,"eps_ttm":3.28,"pe":40.26},{"date":"2021-01-15","close":127.14,"eps_ttm":3.28,"pe":38.76},{"date":"2021-01-22","close":139.07,"eps_ttm":3.28,"pe":42.4},{"date":"2021-01-29","close":131.96,"eps_ttm":3.28,"pe":40.23},{"date":"2021-02-05","close":136.76,"eps_ttm":3.28,"pe":41.7},{"date":"2021-02-12","close":135.37,"eps_ttm":3.28,"pe":41.27},{"date":"2021-02-19","close":129.87,"eps_ttm":3.28,"pe":39.59},{"date":"2021-02-26","close":121.26,"eps_ttm":3.28,"pe":36.97},{"date":"2021-03-05","close":121.42,"eps_ttm":3.28,"pe":37.02},{"date":"2021-03-12","close":121.03,"eps_ttm":3.28,"pe":36.9},{"date":"2021-03-19","close":119.99,"eps_ttm":3.28,"pe":36.58},{"date":"2021-03-26","close":121.21,"eps_ttm":3.28,"pe":36.95},{"date":"2021-04-01","close":123,"eps_ttm":3.28,"pe":37.5},{"date":"2021-04-09","close":133,"eps_ttm":3.28,"pe":40.55},{"date":"2021-04-16","close":134.16,"eps_ttm":3.28,"pe":40.9},{"date":"2021-04-23","close":134.32,"eps_ttm":3.28,"pe":40.95},{"date":"2021-04-30","close":131.46,"eps_ttm":3.28,"pe":40.08},{"date":"2021-05-07","close":130.21,"eps_ttm":3.28,"pe":39.7},{"date":"2021-05-14","close":127.45,"eps_ttm":3.28,"pe":38.86},{"date":"2021-05-21","close":125.43,"eps_ttm":3.28,"pe":38.24},{"date":"2021-05-28","close":124.61,"eps_ttm":3.28,"pe":37.99},{"date":"2021-06-04","close":125.89,"eps_ttm":3.28,"pe":38.38},{"date":"2021-06-11","close":127.35,"eps_ttm":3.28,"pe":38.83},{"date":"2021-06-18","close":130.46,"eps_ttm":3.28,"pe":39.77},{"date":"2021-06-25","close":133.11,"eps_ttm":3.28,"pe":40.58},{"date":"2021-07-02","close":139.96,"eps_ttm":3.28,"pe":42.67},{"date":"2021-07-09","close":145.11,"eps_ttm":3.28,"pe":44.24},{"date":"2021-07-16","close":146.39,"eps_ttm":3.28,"pe":44.63},{"date":"2021-07-23","close":148.56,"eps_ttm":3.28,"pe":45.29},{"date":"2021-07-30","close":145.86,"eps_ttm":3.28,"pe":44.47},{"date":"2021-08-06","close":146.14,"eps_ttm":3.28,"pe":44.55},{"date":"2021-08-13","close":149.1,"eps_ttm":3.28,"pe":45.46},{"date":"2021-08-20","close":148.19,"eps_ttm":3.28,"pe":45.18},{"date":"2021-08-27","close":148.6,"eps_ttm":3.28,"pe":45.3},{"date":"2021-09-03","close":154.3,"eps_ttm":3.28,"pe":47.04},{"date":"2021-09-10","close":148.97,"eps_ttm":3.28,"pe":45.42},{"date":"2021-09-17","close":146.06,"eps_ttm":3.28,"pe":44.53},{"date":"2021-09-24","close":146.92,"eps_ttm":3.28,"pe":44.79},{"date":"2021-10-01","close":142.65,"eps_ttm":3.28,"pe":43.49},{"date":"2021-10-08","close":142.9,"eps_ttm":3.28,"pe":43.57},{"date":"2021-10-15","close":144.84,"eps_ttm":3.28,"pe":44.16},{"date":"2021-10-22","close":148.69,"eps_ttm":3.28,"pe":45.33},{"date":"2021-10-29","close":149.8,"eps_ttm":5.61,"pe":26.7},{"date":"2021-11-05","close":151.28,"eps_ttm":5.61,"pe":26.97},{"date":"2021-11-12","close":149.99,"eps_ttm":5.61,"pe":26.74},{"date":"2021-11-19","close":160.55,"eps_ttm":5.61,"pe":28.62},{"date":"2021-11-26","close":156.81,"eps_ttm":5.61,"pe":27.95},{"date":"2021-12-03","close":161.84,"eps_ttm":5.61,"pe":28.85},{"date":"2021-12-10","close":179.45,"eps_ttm":5.61,"pe":31.99},{"date":"2021-12-17","close":171.14,"eps_ttm":5.61,"pe":30.51},{"date":"2021-12-23","close":176.28,"eps_ttm":5.61,"pe":31.42},{"date":"2021-12-31","close":177.57,"eps_ttm":5.61,"pe":31.65},{"date":"2022-01-07","close":172.17,"eps_ttm":5.61,"pe":30.69},{"date":"2022-01-14","close":173.07,"eps_ttm":5.61,"pe":30.85},{"date":"2022-01-21","close":162.41,"eps_ttm":5.61,"pe":28.95},{"date":"2022-01-28","close":170.33,"eps_ttm":5.61,"pe":30.36},{"date":"2022-02-04","close":172.39,"eps_ttm":5.61,"pe":30.73},{"date":"2022-02-11","close":168.64,"eps_ttm":5.61,"pe":30.06},{"date":"2022-02-18","close":167.3,"eps_ttm":5.61,"pe":29.82},{"date":"2022-02-25","close":164.85,"eps_ttm":5.61,"pe":29.39},{"date":"2022-03-04","close":163.17,"eps_ttm":5.61,"pe":29.09},{"date":"2022-03-11","close":154.73,"eps_ttm":5.61,"pe":27.58},{"date":"2022-03-18","close":163.98,"eps_ttm":5.61,"pe":29.23},{"date":"2022-03-25","close":174.72,"eps_ttm":5.61,"pe":31.14},{"date":"2022-04-01","close":174.31,"eps_ttm":5.61,"pe":31.07},{"date":"2022-04-08","close":170.09,"eps_ttm":5.61,"pe":30.32},{"date":"2022-04-14","close":165.29,"eps_ttm":5.61,"pe":29.46},{"date":"2022-04-22","close":161.79,"eps_ttm":5.61,"pe":28.84},{"date":"2022-04-29","close":157.65,"eps_ttm":5.61,"pe":28.1},{"date":"2022-05-06","close":157.28,"eps_ttm":5.61,"pe":28.04},{"date":"2022-05-13","close":147.11,"eps_ttm":5.61,"pe":26.22},{"date":"2022-05-20","close":137.59,"eps_ttm":5.61,"pe":24.53},{"date":"2022-05-27","close":149.64,"eps_ttm":5.61,"pe":26.67},{"date":"2022-06-03","close":145.38,"eps_ttm":5.61,"pe":25.91},{"date":"2022-06-10","close":137.13,"eps_ttm":5.61,"pe":24.44},{"date":"2022-06-17","close":131.56,"eps_ttm":5.61,"pe":23.45},{"date":"2022-06-24","close":141.66,"eps_ttm":5.61,"pe":25.25},{"date":"2022-07-01","close":138.93,"eps_ttm":5.61,"pe":24.76},{"date":"2022-07-08","close":147.04,"eps_ttm":5.61,"pe":26.21},{"date":"2022-07-15","close":150.17,"eps_ttm":5.61,"pe":26.77},{"date":"2022-07-22","close":154.09,"eps_ttm":5.61,"pe":27.47},{"date":"2022-07-29","close":162.51,"eps_ttm":5.61,"pe":28.97},{"date":"2022-08-05","close":165.35,"eps_ttm":5.61,"pe":29.47},{"date":"2022-08-12","close":172.1,"eps_ttm":5.61,"pe":30.68},{"date":"2022-08-19","close":171.52,"eps_ttm":5.61,"pe":30.57},{"date":"2022-08-26","close":163.62,"eps_ttm":5.61,"pe":29.17},{"date":"2022-09-02","close":155.81,"eps_ttm":5.61,"pe":27.77},{"date":"2022-09-09","close":157.37,"eps_ttm":5.61,"pe":28.05},{"date":"2022-09-16","close":150.7,"eps_ttm":5.61,"pe":26.86},{"date":"2022-09-23","close":150.43,"eps_ttm":5.61,"pe":26.81},{"date":"2022-09-30","close":138.2,"eps_ttm":5.61,"pe":24.63},{"date":"2022-10-07","close":140.09,"eps_ttm":5.61,"pe":24.97},{"date":"2022-10-14","close":138.38,"eps_ttm":5.61,"pe":24.67},{"date":"2022-10-21","close":147.27,"eps_ttm":5.61,"pe":26.25},{"date":"2022-10-28","close":155.74,"eps_ttm":6.11,"pe":25.49},{"date":"2022-11-04","close":138.38,"eps_ttm":6.11,"pe":22.65},{"date":"2022-11-11","close":149.7,"eps_ttm":6.11,"pe":24.5},{"date":"2022-11-18","close":151.29,"eps_ttm":6.11,"pe":24.76},{"date":"2022-11-25","close":148.11,"eps_ttm":6.11,"pe":24.24},{"date":"2022-12-02","close":147.81,"eps_ttm":6.11,"pe":24.19},{"date":"2022-12-09","close":142.16,"eps_ttm":6.11,"pe":23.27},{"date":"2022-12-16","close":134.51,"eps_ttm":6.11,"pe":22.01},{"date":"2022-12-23","close":131.86,"eps_ttm":6.11,"pe":21.58},{"date":"2022-12-30","close":129.93,"eps_ttm":6.11,"pe":21.27},{"date":"2023-01-06","close":129.62,"eps_ttm":6.11,"pe":21.21},{"date":"2023-01-13","close":134.76,"eps_ttm":6.11,"pe":22.06},{"date":"2023-01-20","close":137.87,"eps_ttm":6.11,"pe":22.56},{"date":"2023-01-27","close":145.93,"eps_ttm":6.11,"pe":23.88},{"date":"2023-02-03","close":154.5,"eps_ttm":6.11,"pe":25.29},{"date":"2023-02-10","close":151.01,"eps_ttm":6.11,"pe":24.72},{"date":"2023-02-17","close":152.55,"eps_ttm":6.11,"pe":24.97},{"date":"2023-02-24","close":146.71,"eps_ttm":6.11,"pe":24.01},{"date":"2023-03-03","close":151.03,"eps_ttm":6.11,"pe":24.72},{"date":"2023-03-10","close":148.5,"eps_ttm":6.11,"pe":24.3},{"date":"2023-03-17","close":155,"eps_ttm":6.11,"pe":25.37},{"date":"2023-03-24","close":160.25,"eps_ttm":6.11,"pe":26.23},{"date":"2023-03-31","close":164.9,"eps_ttm":6.11,"pe":26.99},{"date":"2023-04-06","close":164.66,"eps_ttm":6.11,"pe":26.95},{"date":"2023-04-14","close":165.21,"eps_ttm":6.11,"pe":27.04},{"date":"2023-04-21","close":165.02,"eps_ttm":6.11,"pe":27.01},{"date":"2023-04-28","close":169.68,"eps_ttm":6.11,"pe":27.77},{"date":"2023-05-05","close":173.57,"eps_ttm":6.11,"pe":28.41},{"date":"2023-05-12","close":172.57,"eps_ttm":6.11,"pe":28.24},{"date":"2023-05-19","close":175.16,"eps_ttm":6.11,"pe":28.67},{"date":"2023-05-26","close":175.43,"eps_ttm":6.11,"pe":28.71},{"date":"2023-06-02","close":180.95,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-09","close":180.96,"eps_ttm":6.11,"pe":29.62},{"date":"2023-06-16","close":184.92,"eps_ttm":6.11,"pe":30.27},{"date":"2023-06-23","close":186.68,"eps_ttm":6.11,"pe":30.55},{"date":"2023-06-30","close":193.97,"eps_ttm":6.11,"pe":31.75},{"date":"2023-07-07","close":190.68,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-14","close":190.69,"eps_ttm":6.11,"pe":31.21},{"date":"2023-07-21","close":191.94,"eps_ttm":6.11,"pe":31.41},{"date":"2023-07-28","close":195.83,"eps_ttm":6.11,"pe":32.05},{"date":"2023-08-04","close":181.99,"eps_ttm":6.11,"pe":29.79},{"date":"2023-08-11","close":177.79,"eps_ttm":6.11,"pe":29.1},{"date":"2023-08-18","close":174.49,"eps_ttm":6.11,"pe":28.56},{"date":"2023-08-25","close":178.61,"eps_ttm":6.11,"pe":29.23},{"date":"2023-09-01","close":189.46,"eps_ttm":6.11,"pe":31.01},{"date":"2023-09-08","close":178.18,"eps_ttm":6.11,"pe":29.16},{"date":"2023-09-15","close":175.01,"eps_ttm":6.11,"pe":28.64},{"date":"2023-09-22","close":174.79,"eps_ttm":6.11,"pe":28.61},{"date":"2023-09-29","close":171.21,"eps_ttm":6.11,"pe":28.02},{"date":"2023-10-06","close":177.49,"eps_ttm":6.11,"pe":29.05},{"date":"2023-10-13","close":178.85,"eps_ttm":6.11,"pe":29.27},{"date":"2023-10-20","close":172.88,"eps_ttm":6.11,"pe":28.29},{"date":"2023-10-27","close":168.22,"eps_ttm":6.11,"pe":27.53},{"date":"2023-11-03","close":176.65,"eps_ttm":6.13,"pe":28.82},{"date":"2023-11-10","close":186.4,"eps_ttm":6.13,"pe":30.41},{"date":"2023-11-17","close":189.69,"eps_ttm":6.13,"pe":30.94},{"date":"2023-11-24","close":189.97,"eps_ttm":6.13,"pe":30.99},{"date":"2023-12-01","close":191.24,"eps_ttm":6.13,"pe":31.2},{"date":"2023-12-08","close":195.71,"eps_ttm":6.13,"pe":31.93},{"date":"2023-12-15","close":197.57,"eps_ttm":6.13,"pe":32.23},{"date":"2023-12-22","close":193.6,"eps_ttm":6.13,"pe":31.58},{"date":"2023-12-29","close":192.53,"eps_ttm":6.13,"pe":31.41},{"date":"2024-01-05","close":181.18,"eps_ttm":6.13,"pe":29.56},{"date":"2024-01-12","close":185.92,"eps_ttm":6.13,"pe":30.33},{"date":"2024-01-19","close":191.56,"eps_ttm":6.13,"pe":31.25},{"date":"2024-01-26","close":192.42,"eps_ttm":6.13,"pe":31.39},{"date":"2024-02-02","close":185.85,"eps_ttm":6.13,"pe":30.32},{"date":"2024-02-09","close":188.85,"eps_ttm":6.13,"pe":30.81},{"date":"2024-02-16","close":182.31,"eps_ttm":6.13,"pe":29.74},{"date":"2024-02-23","close":182.52,"eps_ttm":6.13,"pe":29.77},{"date":"2024-03-01","close":179.66,"eps_ttm":6.13,"pe":29.31},{"date":"2024-03-08","close":170.73,"eps_ttm":6.13,"pe":27.85},{"date":"2024-03-15","close":172.62,"eps_ttm":6.13,"pe":28.16},{"date":"2024-03-22","close":172.28,"eps_ttm":6.13,"pe":28.1},{"date":"2024-03-28","close":171.48,"eps_ttm":6.13,"pe":27.97},{"date":"2024-04-05","close":169.58,"eps_ttm":6.13,"pe":27.66},{"date":"2024-04-12","close":176.55,"eps_ttm":6.13,"pe":28.8},{"date":"2024-04-19","close":165,"eps_ttm":6.13,"pe":26.92},{"date":"2024-04-26","close":169.3,"eps_ttm":6.13,"pe":27.62},{"date":"2024-05-03","close":183.38,"eps_ttm":6.13,"pe":29.92},{"date":"2024-05-10","close":183.05,"eps_ttm":6.13,"pe":29.86},{"date":"2024-05-17","close":189.87,"eps_ttm":6.13,"pe":30.97},{"date":"2024-05-24","close":189.98,"eps_ttm":6.13,"pe":30.99},{"date":"2024-05-31","close":192.25,"eps_ttm":6.13,"pe":31.36},{"date":"2024-06-07","close":196.89,"eps_ttm":6.13,"pe":32.12},{"date":"2024-06-14","close":212.49,"eps_ttm":6.13,"pe":34.66},{"date":"2024-06-21","close":207.49,"eps_ttm":6.13,"pe":33.85},{"date":"2024-06-28","close":210.62,"eps_ttm":6.13,"pe":34.36},{"date":"2024-07-05","close":226.34,"eps_ttm":6.13,"pe":36.92},{"date":"2024-07-12","close":230.54,"eps_ttm":6.13,"pe":37.61},{"date":"2024-07-19","close":224.31,"eps_ttm":6.13,"pe":36.59},{"date":"2024-07-26","close":217.96,"eps_ttm":6.13,"pe":35.56},{"date":"2024-08-02","close":219.86,"eps_ttm":6.13,"pe":35.87},{"date":"2024-08-09","close":216.24,"eps_ttm":6.13,"pe":35.28},{"date":"2024-08-16","close":226.05,"eps_ttm":6.13,"pe":36.88},{"date":"2024-08-23","close":226.84,"eps_ttm":6.13,"pe":37},{"date":"2024-08-30","close":229,"eps_ttm":6.13,"pe":37.36},{"date":"2024-09-06","close":220.82,"eps_ttm":6.13,"pe":36.02},{"date":"2024-09-13","close":222.5,"eps_ttm":6.13,"pe":36.3},{"date":"2024-09-20","close":228.2,"eps_ttm":6.13,"pe":37.23},{"date":"2024-09-27","close":227.79,"eps_ttm":6.13,"pe":37.16},{"date":"2024-10-04","close":226.8,"eps_ttm":6.13,"pe":37},{"date":"2024-10-11","close":227.55,"eps_ttm":6.13,"pe":37.12},{"date":"2024-10-18","close":235,"eps_ttm":6.13,"pe":38.34},{"date":"2024-10-25","close":231.41,"eps_ttm":6.13,"pe":37.75},{"date":"2024-11-01","close":222.91,"eps_ttm":6.08,"pe":36.66},{"date":"2024-11-08","close":226.96,"eps_ttm":6.08,"pe":37.33},{"date":"2024-11-15","close":225,"eps_ttm":6.08,"pe":37.01},{"date":"2024-11-22","close":229.87,"eps_ttm":6.08,"pe":37.81},{"date":"2024-11-29","close":237.33,"eps_ttm":6.08,"pe":39.03},{"date":"2024-12-06","close":242.84,"eps_ttm":6.08,"pe":39.94},{"date":"2024-12-13","close":248.13,"eps_ttm":6.08,"pe":40.81},{"date":"2024-12-20","close":254.49,"eps_ttm":6.08,"pe":41.86},{"date":"2024-12-27","close":255.59,"eps_ttm":6.08,"pe":42.04},{"date":"2025-01-03","close":243.36,"eps_ttm":6.08,"pe":40.03},{"date":"2025-01-10","close":236.85,"eps_ttm":6.08,"pe":38.96},{"date":"2025-01-17","close":229.98,"eps_ttm":6.08,"pe":37.83},{"date":"2025-01-24","close":222.78,"eps_ttm":6.08,"pe":36.64},{"date":"2025-01-31","close":236,"eps_ttm":6.08,"pe":38.82},{"date":"2025-02-07","close":227.63,"eps_ttm":6.08,"pe":37.44},{"date":"2025-02-14","close":244.6,"eps_ttm":6.08,"pe":40.23},{"date":"2025-02-21","close":245.55,"eps_ttm":6.08,"pe":40.39},{"date":"2025-02-28","close":241.84,"eps_ttm":6.08,"pe":39.78},{"date":"2025-03-07","close":239.07,"eps_ttm":6.08,"pe":39.32},{"date":"2025-03-14","close":213.49,"eps_ttm":6.08,"pe":35.11},{"date":"2025-03-21","close":218.27,"eps_ttm":6.08,"pe":35.9},{"date":"2025-03-28","close":217.9,"eps_ttm":6.08,"pe":35.84},{"date":"2025-04-04","close":188.38,"eps_ttm":6.08,"pe":30.98},{"date":"2025-04-11","close":198.15,"eps_ttm":6.08,"pe":32.59},{"date":"2025-04-17","close":196.98,"eps_ttm":6.08,"pe":32.4},{"date":"2025-04-25","close":209.28,"eps_ttm":6.08,"pe":34.42},{"date":"2025-05-02","close":205.35,"eps_ttm":6.08,"pe":33.77},{"date":"2025-05-09","close":198.53,"eps_ttm":6.08,"pe":32.65},{"date":"2025-05-16","close":211.26,"eps_ttm":6.08,"pe":34.75},{"date":"2025-05-23","close":195.27,"eps_ttm":6.08,"pe":32.12},{"date":"2025-05-30","close":200.85,"eps_ttm":6.08,"pe":33.03},{"date":"2025-06-06","close":203.92,"eps_ttm":6.08,"pe":33.54},{"date":"2025-06-13","close":196.45,"eps_ttm":6.08,"pe":32.31},{"date":"2025-06-20","close":201,"eps_ttm":6.08,"pe":33.06},{"date":"2025-06-27","close":201.08,"eps_ttm":6.08,"pe":33.07},{"date":"2025-07-03","close":213.55,"eps_ttm":6.08,"pe":35.12},{"date":"2025-07-11","close":211.16,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-18","close":211.18,"eps_ttm":6.08,"pe":34.73},{"date":"2025-07-25","close":213.88,"eps_ttm":6.08,"pe":35.18},{"date":"2025-08-01","close":202.38,"eps_ttm":6.08,"pe":33.29},{"date":"2025-08-08","close":229.35,"eps_ttm":6.08,"pe":37.72},{"date":"2025-08-15","close":231.59,"eps_ttm":6.08,"pe":38.09},{"date":"2025-08-22","close":227.76,"eps_ttm":6.08,"pe":37.46},{"date":"2025-08-29","close":232.14,"eps_ttm":6.08,"pe":38.18},{"date":"2025-09-05","close":239.69,"eps_ttm":6.08,"pe":39.42},{"date":"2025-09-12","close":234.07,"eps_ttm":6.08,"pe":38.5},{"date":"2025-09-19","close":245.5,"eps_ttm":6.08,"pe":40.38},{"date":"2025-09-26","close":255.46,"eps_ttm":6.08,"pe":42.02},{"date":"2025-10-03","close":258.02,"eps_ttm":6.08,"pe":42.44},{"date":"2025-10-10","close":245.27,"eps_ttm":6.08,"pe":40.34},{"date":"2025-10-17","close":252.29,"eps_ttm":6.08,"pe":41.5},{"date":"2025-10-24","close":262.82,"eps_ttm":6.08,"pe":43.23},{"date":"2025-10-31","close":270.37,"eps_ttm":7.46,"pe":36.24},{"date":"2025-11-07","close":268.47,"eps_ttm":7.46,"pe":35.99},{"date":"2025-11-14","close":272.41,"eps_ttm":7.46,"pe":36.52},{"date":"2025-11-21","close":271.49,"eps_ttm":7.46,"pe":36.39},{"date":"2025-11-28","close":278.85,"eps_ttm":7.46,"pe":37.38},{"date":"2025-12-05","close":278.78,"eps_ttm":7.46,"pe":37.37},{"date":"2025-12-12","close":278.28,"eps_ttm":7.46,"pe":37.3},{"date":"2025-12-19","close":273.67,"eps_ttm":7.46,"pe":36.68},{"date":"2025-12-26","close":273.4,"eps_ttm":7.46,"pe":36.65},{"date":"2026-01-02","close":271.01,"eps_ttm":7.46,"pe":36.33},{"date":"2026-01-09","close":259.37,"eps_ttm":7.46,"pe":34.77},{"date":"2026-01-16","close":255.53,"eps_ttm":7.46,"pe":34.25},{"date":"2026-01-23","close":248.04,"eps_ttm":7.46,"pe":33.25},{"date":"2026-01-30","close":259.48,"eps_ttm":7.46,"pe":34.78},{"date":"2026-02-06","close":278.12,"eps_ttm":7.46,"pe":37.28},{"date":"2026-02-13","close":255.78,"eps_ttm":7.46,"pe":34.29},{"date":"2026-02-20","close":264.58,"eps_ttm":7.46,"pe":35.47},{"date":"2026-02-27","close":264.18,"eps_ttm":7.46,"pe":35.41},{"date":"2026-03-06","close":257.46,"eps_ttm":7.46,"pe":34.51},{"date":"2026-03-13","close":250.12,"eps_ttm":7.46,"pe":33.53},{"date":"2026-03-20","close":247.99,"eps_ttm":7.46,"pe":33.24},{"date":"2026-03-27","close":248.8,"eps_ttm":7.46,"pe":33.35},{"date":"2026-04-02","close":255.92,"eps_ttm":7.46,"pe":34.31},{"date":"2026-04-10","close":260.48,"eps_ttm":7.46,"pe":34.92},{"date":"2026-04-17","close":270.23,"eps_ttm":7.46,"pe":36.22},{"date":"2026-04-24","close":271.06,"eps_ttm":7.46,"pe":36.34},{"date":"2026-05-01","close":280.14,"eps_ttm":7.46,"pe":37.55},{"date":"2026-05-08","close":293.32,"eps_ttm":7.46,"pe":39.32},{"date":"2026-05-15","close":300.23,"eps_ttm":7.46,"pe":40.25},{"date":"2026-05-22","close":308.82,"eps_ttm":7.46,"pe":41.4},{"date":"2026-05-29","close":312.06,"eps_ttm":7.46,"pe":41.83},{"date":"2026-06-05","close":307.34,"eps_ttm":7.46,"pe":41.2},{"date":"2026-06-12","close":291.13,"eps_ttm":7.46,"pe":39.03},{"date":"2026-06-18","close":298.01,"eps_ttm":7.46,"pe":39.95},{"date":"2026-06-26","close":283.78,"eps_ttm":7.46,"pe":38.04},{"date":"2026-07-02","close":308.63,"eps_ttm":7.46,"pe":41.37},{"date":"2026-07-10","close":315.32,"eps_ttm":7.46,"pe":42.27},{"date":"2026-07-17","close":333.74,"eps_ttm":7.46,"pe":44.74},{"date":"2026-07-24","close":333.02,"eps_ttm":7.46,"pe":44.64},{"date":"2026-07-31","close":308.91,"eps_ttm":7.46,"pe":41.41},{"date":"2026-08-07","close":313.33,"eps_ttm":7.46,"pe":42}]}
\ No newline at end of file
modified hfmarketdata/web/e2e/docs.spec.js +48 −13
@@ -1,8 +1,10 @@
1 −// Docs: navigation, generated reference, Try-it contract, search dialog, errors anchors, language persistence.
1 +// Docs: navigation, generated reference, Try-it contract, search dialog, errors anchors, language persistence,
2 +// no automatic live calls (Run live is explicit), wrap toggle, heading anchors without "#" in textContent.
2 3 import { expect, test } from '@playwright/test'
4 +import { json, mockAnon } from './mocks.js'
3 5
4 6 test.describe('docs', () => {
5 − test('overview renders nav and quickstart guide loads with code panel', async ({ page }) => {
7 + test('overview renders nav and quickstart guide loads with code panel (outline first)', async ({ page }) => {
6 8 await page.goto('/docs')
7 9 await expect(page.getByTestId('docs-home')).toBeVisible()
8 10 await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeVisible()
@@ -10,10 +12,12 @@ test.describe('docs', () => {
10 12 await expect(page).toHaveURL(/\/docs\/quickstart$/)
11 13 await expect(page.getByRole('heading', { level: 1, name: 'Quickstart' })).toBeVisible()
12 14 await expect(page.getByTestId('guide-page')).toBeVisible()
13 − // right panel: code tabs with the 4 languages
14 15 const panel = page.getByRole('complementary', { name: 'Code and outline' })
15 16 await expect(panel.getByRole('radio', { name: 'Python' }).first()).toBeVisible()
16 17 await expect(panel.getByRole('radio', { name: 'R', exact: true }).first()).toBeVisible()
18 + // "On this page" comes before the code blocks
19 + const [toc, code] = await Promise.all([panel.getByRole('navigation', { name: 'On this page' }).boundingBox(), panel.locator('.code').first().boundingBox()])
20 + expect(toc.y).toBeLessThan(code.y)
17 21 await expect(page).toHaveTitle(/Quickstart/)
18 22 })
19 23
@@ -37,20 +41,29 @@ test.describe('docs', () => {
37 41 await expect(page).toHaveURL(/\/docs\/reference\/bars_v1_bars__asset___ticker__get$/)
38 42 })
39 43
40 − test('operation page: parameters, samples, response example and Try-it deep link', async ({ page }) => {
44 + test('operation page: parameters, samples, captured example, Try-it deep link, no automatic API call', async ({ page }) => {
45 + const calls = []
46 + await mockAnon(page, url => { calls.push(url.pathname); return null })
41 47 await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get')
42 48 await expect(page.getByTestId('operation-page')).toBeVisible()
43 49 await expect(page.getByRole('heading', { level: 1 })).toHaveText(/Bars/)
44 50 await expect(page.locator('.op-path')).toContainText('/v1/bars/{asset}/{ticker}')
45 − // parameters table lists the documented params
46 51 for (const p of ['asset', 'ticker', 'timeframe', 'limit', 'format']) await expect(page.locator(`#param-${p}`)).toBeVisible()
47 − // curl sample uses the production URL with realistic values, no key
48 52 const panel = page.getByRole('complementary', { name: 'Code and outline' })
49 53 await panel.getByRole('radio', { name: 'curl' }).first().click()
50 54 await expect(panel.locator('.code').first()).toContainText('https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day')
51 55 await expect(panel.locator('.code').first()).not.toContainText('hfmd_live_')
52 − // captured response example present (content/examples)
53 − await expect(panel.locator('.code').nth(1)).toContainText('"ticker": "AAPL"')
56 + // captured response example present (content/examples), nothing fetched
57 + await expect(page.getByTestId('response-example')).toContainText('"ticker": "AAPL"')
58 + await expect(page.getByTestId('response-example')).toContainText('captured')
59 + await page.waitForTimeout(400)
60 + expect(calls.filter(p => p !== '/v1/me')).toEqual([])
61 + // Run live is explicit
62 + await page.unroute('**/v1/**')
63 + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' ? json({ count: 1, data: [{ ticker: 'AAPL', datetime: '2024-06-03', close: 1 }] }) : null))
64 + await page.getByTestId('run-live').click()
65 + await expect(page.getByTestId('response-example')).toContainText('live')
66 + await expect(page.getByTestId('response-example')).toContainText('keyless requests left')
54 67 // Try it → /playground?ep=<operationId>&<params>
55 68 const href = await page.getByTestId('try-it').getAttribute('href')
56 69 expect(href).toMatch(/^\/playground\?ep=bars_v1_bars__asset___ticker__get&/)
@@ -58,11 +71,34 @@ test.describe('docs', () => {
58 71 expect(q.get('asset')).toBe('stock')
59 72 expect(q.get('ticker')).toBe('AAPL')
60 73 expect(q.get('timeframe')).toBe('1day')
61 − // errors section links to anchors on the errors page
62 74 await expect(page.locator('#errors')).toBeVisible()
63 75 await expect(page.getByRole('link', { name: 'RATE_LIMIT_EXCEEDED' }).first()).toHaveAttribute('href', '/docs/errors#rate_limit_exceeded')
64 76 })
65 77
78 + test('operation page: Run live on a 429 says rate limited, not down', async ({ page }) => {
79 + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL'
80 + ? json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } }, 429, { 'Retry-After': '90' }) : null))
81 + await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get')
82 + await page.getByTestId('run-live').click()
83 + await expect(page.getByTestId('response-example')).toContainText('Rate limited — not down')
84 + await expect(page.getByTestId('response-example').getByRole('link', { name: /free account/ })).toBeVisible()
85 + await expect(page.getByTestId('run-live')).toBeDisabled()
86 + })
87 +
88 + test('code blocks: wrap toggle persists; heading anchors carry no "#" text', async ({ page }) => {
89 + await page.goto('/docs/quickstart')
90 + const block = page.getByRole('complementary', { name: 'Code and outline' }).locator('.code').first()
91 + await expect(block).not.toHaveClass(/code-wrap/)
92 + await block.getByRole('button', { name: 'Wrap long lines' }).click()
93 + await expect(block).toHaveClass(/code-wrap/)
94 + expect(await page.evaluate(() => localStorage.getItem('hfmd.codewrap'))).toBe('1')
95 + await page.reload()
96 + await expect(page.getByRole('complementary', { name: 'Code and outline' }).locator('.code').first()).toHaveClass(/code-wrap/)
97 + const texts = await page.locator('.docs-content h2').evaluateAll(els => els.map(e => e.textContent))
98 + for (const t of texts) expect(t.trim().startsWith('#')).toBe(false)
99 + await page.evaluate(() => localStorage.removeItem('hfmd.codewrap'))
100 + })
101 +
66 102 test('errors page has an anchor per code', async ({ page }) => {
67 103 await page.goto('/docs/errors#rate_limit_exceeded')
68 104 await expect(page.getByTestId('errors-page')).toBeVisible()
@@ -70,6 +106,7 @@ test.describe('docs', () => {
70 106 await expect(page.locator(`#${id}`)).toBeAttached()
71 107 }
72 108 await expect(page.locator('#rate_limit_exceeded')).toContainText('RATE_LIMIT_EXCEEDED')
109 + expect((await page.locator('#rate_limit_exceeded h3').textContent()).trim().endsWith('#')).toBe(false)
73 110 })
74 111
75 112 test('Cmd+K search: open, type, keyboard navigate, Enter opens the page, Esc closes', async ({ page }) => {
@@ -86,7 +123,6 @@ test.describe('docs', () => {
86 123 await page.keyboard.press('Enter')
87 124 await expect(dialog).toBeHidden()
88 125 await expect(page).toHaveURL(/\/docs\/futures-contracts/)
89 − // slash shortcut + Escape
90 126 await page.keyboard.press('/')
91 127 await expect(page.getByTestId('search-dialog')).toBeVisible()
92 128 await expect(page.getByTestId('search-dialog').getByRole('combobox')).toBeFocused()
@@ -94,7 +130,6 @@ test.describe('docs', () => {
94 130 await expect(page.getByTestId('search-dialog').getByRole('option').first()).toContainText(/Opt Chain|chain/i)
95 131 await page.keyboard.press('Escape')
96 132 await expect(page.getByTestId('search-dialog')).toBeHidden()
97 − // trigger button also opens it
98 133 await page.getByTestId('search-trigger').click()
99 134 await expect(page.getByTestId('search-dialog')).toBeVisible()
100 135 })
@@ -106,9 +141,7 @@ test.describe('docs', () => {
106 141 await expect(page.locator('.callout').first()).toBeVisible()
107 142 await expect(page.locator('.figure svg').first()).toBeVisible()
108 143 await expect(page.locator('.docs-content table').first()).toBeVisible()
109 − // On this page outline lists the headings
110 144 await expect(page.getByRole('navigation', { name: 'On this page' }).getByRole('link', { name: /Adjustment methods/ })).toBeVisible()
111 − // Edit / report link is a mailto with the page URL
112 145 const mail = await page.getByRole('link', { name: /Edit \/ Report an issue/ }).getAttribute('href')
113 146 expect(mail).toMatch(/^mailto:contact@spboucher\.ai\?subject=/)
114 147 expect(decodeURIComponent(mail)).toContain('/docs/futures-contracts')
@@ -117,5 +150,7 @@ test.describe('docs', () => {
117 150 test('changelog has the v2.0.0 entry dated 2026-09-04', async ({ page }) => {
118 151 await page.goto('/docs/changelog')
119 152 await expect(page.getByRole('heading', { level: 2, name: /2\.0\.0 — 2026-09-04/ })).toBeVisible()
153 + await page.goto('/changelog')
154 + await expect(page).toHaveURL(/\/docs\/changelog$/)
120 155 })
121 156 })
modified hfmarketdata/web/e2e/mobile.spec.js +45 −7
@@ -1,29 +1,67 @@
1 −// Mobile (Pixel 7): drawer navigation, docs nav toggle, code panel below content (DOM order = visual order).
1 +// Mobile (Pixel 7): drawer navigation, docs nav toggle + inline panel order, playground form + sticky Run,
2 +// tap targets ≥ 44 px, no horizontal overflow anywhere.
2 3 import { expect, test } from '@playwright/test'
4 +import { mockAnon } from './mocks.js'
5 +
6 +const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
3 7
4 8 test.describe('mobile', () => {
5 − test('drawer opens from the menu button and navigates', async ({ page }) => {
9 + test('drawer opens from the menu button and navigates; header tap targets ≥ 44 px', async ({ page }) => {
6 10 await page.goto('/')
7 11 await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden()
12 + const small = await page.evaluate(() => [...document.querySelectorAll('header a, header button')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && (r.height < 44 || r.width < 44) }).length)
13 + expect(small).toBe(0)
8 14 await page.getByRole('button', { name: 'Open menu' }).click()
9 15 const drawer = page.getByRole('navigation', { name: 'Mobile' })
10 16 await expect(drawer).toBeVisible()
17 + await expect(drawer.getByRole('link', { name: 'Changelog' })).toBeVisible()
11 18 await drawer.getByRole('link', { name: 'Limits' }).click()
12 19 await expect(page).toHaveURL(/\/limits$/)
13 20 await expect(drawer).toBeHidden()
14 21 })
15 22
16 − test('docs: nav toggle, code panel after content', async ({ page }) => {
23 + test('no horizontal overflow on the public pages', async ({ page }) => {
24 + await mockAnon(page)
25 + for (const path of ['/', '/docs', '/docs/quickstart', '/docs/reference/bars_v1_bars__asset___ticker__get', '/playground', '/integrations', '/limits', '/status', '/terms', '/nope']) {
26 + await page.goto(path)
27 + await page.waitForTimeout(300)
28 + expect(await noOverflow(page), path).toBe(true)
29 + }
30 + })
31 +
32 + test('docs: nav toggle; request/response panel right after the intro, before Parameters', async ({ page }) => {
17 33 await page.goto('/docs/quickstart')
18 34 await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeHidden()
19 35 await page.getByRole('button', { name: /Docs menu/ }).click()
20 36 await expect(page.getByRole('navigation', { name: 'Documentation' })).toBeVisible()
21 37 await page.getByRole('navigation', { name: 'Documentation' }).getByRole('link', { name: 'Rate limits' }).click()
22 38 await expect(page).toHaveURL(/\/docs\/rate-limits$/)
23 − const content = page.locator('.docs-content')
24 − const panel = page.getByRole('complementary', { name: 'Code and outline' })
25 − const [c, p] = await Promise.all([content.boundingBox(), panel.boundingBox()])
26 − expect(p.y).toBeGreaterThan(c.y) // panel rendered below the article on small screens
39 + await expect(page.getByRole('complementary', { name: 'Code and outline' })).toHaveCount(0)
40 + await expect(page.getByTestId('docs-panel-inline')).toBeVisible()
41 + // operation page: inline panel between the header actions and the Parameters heading
42 + await page.goto('/docs/reference/bars_v1_bars__asset___ticker__get')
43 + const [tryIt, panel, params, prevNext] = await Promise.all([
44 + page.getByTestId('try-it').boundingBox(), page.getByTestId('docs-panel-inline').boundingBox(),
45 + page.locator('#parameters').boundingBox(), page.getByRole('navigation', { name: 'Previous / next' }).boundingBox(),
46 + ])
47 + expect(panel.y).toBeGreaterThan(tryIt.y)
48 + expect(params.y).toBeGreaterThan(panel.y)
49 + expect(prevNext.y).toBeGreaterThan(params.y)
50 + // parameters table is readable: no cell narrower than its label column, no horizontal overflow
51 + expect(await noOverflow(page)).toBe(true)
52 + })
53 +
54 + test('playground: form visible near the top, Run reachable, results below', async ({ page }) => {
55 + await mockAnon(page)
56 + await page.goto('/playground')
57 + const form = await page.getByTestId('pg-form').boundingBox()
58 + expect(form.y).toBeLessThan(900)
59 + const run = page.getByTestId('pg-run')
60 + await run.scrollIntoViewIfNeeded()
61 + const box = await run.boundingBox()
62 + expect(box.height).toBeGreaterThanOrEqual(44)
63 + await expect(page.getByTestId('pg-sample')).toBeVisible()
64 + expect(await noOverflow(page)).toBe(true)
27 65 })
28 66
29 67 test('search dialog usable on touch viewport', async ({ page }) => {
modified hfmarketdata/web/e2e/playground.spec.js +80 −27
@@ -1,3 +1,5 @@
1 +// Playground: endpoint picker, form, deep links (incl. auto-run and dependent enums), captured example empty state,
2 +// results views, code tab, 429 countdown, CSV download, WebSocket gating.
1 3 import { expect, test } from '@playwright/test'
2 4 import { bars, json, mockAnon } from './mocks.js'
3 5
@@ -16,13 +18,28 @@ test.beforeEach(async ({ page }) => {
16 18 await page.route('**/openapi.json', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SPEC) }))
17 19 })
18 20
19 −test('keyless banner, default request and live URL preview', async ({ page }) => {
20 − await mockAnon(page)
21 +async function pickEndpoint(page, name) {
22 + await page.getByTestId('pg-endpoint').click()
23 + await page.getByTestId('pg-endpoint-search').fill(name)
24 + await page.getByRole('listbox', { name: 'Endpoints' }).getByRole('option', { name: new RegExp(name.replace(/[()]/g, '\\$&'), 'i') }).first().click()
25 +}
26 +
27 +test('keyless banner, default request, captured example as empty state, live URL preview, form above the fold', async ({ page }) => {
28 + const calls = []
29 + await mockAnon(page, url => { calls.push(url.pathname); return null })
21 30 await page.goto('/playground')
22 31 await expect(page.getByText('Keyless mode:')).toBeVisible()
23 32 await expect(page.getByRole('link', { name: 'Create free account' }).first()).toBeVisible()
33 + // parameters form is visible without scrolling on desktop
34 + const form = await page.getByTestId('pg-form').boundingBox()
35 + expect(form.y + 100).toBeLessThan(page.viewportSize().height)
24 36 const url = page.getByTestId('pg-url')
25 37 await expect(url).toContainText('https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day')
38 + // empty state = captured production example, no API call made
39 + await expect(page.getByTestId('pg-sample')).toContainText('Edit a parameter and run')
40 + await expect(page.getByRole('tab', { name: /^Table/ })).toHaveAttribute('aria-selected', 'true')
41 + await expect(page.getByRole('region', { name: 'Results table' })).toContainText('AAPL')
42 + expect(calls.filter(p => p !== '/v1/me')).toEqual([])
26 43 await page.getByLabel(/^ticker/).fill('MSFT')
27 44 await expect(url).toContainText('/v1/bars/stock/MSFT')
28 45 await expect(page).toHaveURL(/ep=stock-bars/)
@@ -31,16 +48,31 @@ test('keyless banner, default request and live URL preview', async ({ page }) =>
31 48 await expect(url).toContainText('/v1/bars/etf/MSFT')
32 49 })
33 50
34 −test('run → status, latency, rows, table / chart / json views and rate-limit panel', async ({ page }) => {
51 +test('endpoint picker: search, keyboard, selection', async ({ page }) => {
52 + await mockAnon(page)
53 + await page.goto('/playground')
54 + await page.getByTestId('pg-endpoint').click()
55 + const search = page.getByTestId('pg-endpoint-search')
56 + await expect(search).toBeFocused()
57 + await search.fill('term')
58 + await expect(page.getByRole('listbox', { name: 'Endpoints' }).getByRole('option')).toHaveCount(1)
59 + await page.keyboard.press('Enter')
60 + await expect(page.getByTestId('pg-endpoint')).toContainText('Term structure')
61 + await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/NG/term-structure')
62 + await expect(page).toHaveURL(/ep=futures-term-structure/)
63 +})
64 +
65 +test('run → status, latency, rows, table / chart / json / code views and rate-limit panel', async ({ page }) => {
35 66 const rows = bars(120)
36 67 await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' ? json({ count: rows.length, data: rows }) : null))
37 − await page.goto('/playground?ep=stock-bars&timeframe=1min')
68 + await page.goto('/playground')
69 + await page.getByLabel(/^timeframe/).selectOption('1min')
38 70 await page.getByTestId('pg-run').click()
39 71 const status = page.getByTestId('pg-status')
40 72 await expect(status).toContainText('200 OK')
41 73 await expect(status).toContainText('120 rows')
42 74 await expect(status).toContainText(/\d+ ms/)
43 − // bars → chart by default; table + json available
75 + await expect(page.getByTestId('pg-sample')).toHaveCount(0)
44 76 await expect(page.getByRole('tab', { name: 'Chart' })).toHaveAttribute('aria-selected', 'true')
45 77 await expect(page.getByRole('img', { name: /Candlestick chart of 120 rows/ })).toBeVisible()
46 78 await page.getByRole('tab', { name: /^Table/ }).click()
@@ -50,33 +82,49 @@ test('run → status, latency, rows, table / chart / json views and rate-limit p
50 82 await expect(page.getByText(/sorted by close asc/)).toBeVisible()
51 83 await page.getByRole('tab', { name: 'JSON' }).click()
52 84 await expect(page.getByText('120 rows in data[]')).toBeVisible()
53 − // rate-limit panel from headers
54 85 const rl = page.getByRole('region', { name: 'Rate limit' })
55 86 await expect(rl).toContainText('29 / 30 left')
56 87 await expect(rl).toContainText('99,500 / 100,000 left')
57 88 await expect(rl).toContainText('Window resets in')
58 − // code export without key → no Authorization header
59 − await expect(page.locator('.pg-codex')).toContainText('curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL')
60 − await expect(page.locator('.pg-codex')).not.toContainText('Authorization')
61 − await page.getByRole('tab', { name: /Python/ }).click()
62 − await expect(page.locator('.pg-codex')).toContainText('pd.DataFrame(payload["data"])')
89 + // code tab without key → no Authorization header
90 + await page.getByRole('tab', { name: 'Code' }).click()
91 + const code = page.getByTestId('pg-code')
92 + await code.getByRole('radio', { name: 'curl' }).click()
93 + await expect(code).toContainText('curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL')
94 + await expect(code).not.toContainText('Authorization')
95 + await code.getByRole('radio', { name: 'Python' }).click()
96 + await expect(code).toContainText('pd.DataFrame(payload["data"])')
63 97 })
64 98
65 −test('deep link by operationId selects the catalog entry and fills params', async ({ page }) => {
66 − await mockAnon(page)
99 +test('deep link by operationId selects the catalog entry, fills params and auto-runs once', async ({ page }) => {
100 + let hits = 0
101 + await mockAnon(page, url => { if (url.pathname === '/v1/options/chain/TSLA') { hits++; return json({ count: 1, data: [{ strike: 200, delta: 0.5 }] }) } return null })
67 102 await page.goto('/playground?ep=opt_chain_v1_options_chain__ticker__get&ticker=TSLA&trade_date=2024-06-21&call_put=p')
68 − await expect(page.getByRole('button', { name: /Options chain \(Greeks\)/ })).toHaveAttribute('aria-current', 'true')
103 + await expect(page.getByTestId('pg-endpoint')).toContainText('Options chain (Greeks)')
69 104 await expect(page.getByTestId('pg-url')).toContainText('/v1/options/chain/TSLA?trade_date=2024-06-21')
70 105 await expect(page.getByTestId('pg-url')).toContainText('call_put=p')
71 − // URL is normalised to the catalog id
72 106 await expect(page).toHaveURL(/ep=options-chain/)
107 + await expect(page.getByTestId('pg-status')).toContainText('200 OK')
108 + await page.waitForTimeout(300)
109 + expect(hits).toBe(1)
73 110 })
74 111
75 −test('deep link with fixed asset picks the crypto entry', async ({ page }) => {
112 +test('deep link with fixed asset picks the crypto entry; futures deep link from the home is runnable', async ({ page }) => {
76 113 await mockAnon(page)
77 114 await page.goto('/playground?ep=bars_v1_bars__asset___ticker__get&asset=crypto&ticker=ETHUSD')
78 − await expect(page.getByRole('button', { name: /Crypto bars/ })).toHaveAttribute('aria-current', 'true')
115 + await expect(page.getByTestId('pg-endpoint')).toContainText('Crypto bars')
79 116 await expect(page.getByTestId('pg-url')).toContainText('/v1/bars/crypto/ETHUSD')
117 + // asset=futures resolves to the futures entry, dependent enums are recomputed, Run is enabled (regression: "Not an allowed value")
118 + await page.goto('/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5')
119 + await expect(page.getByTestId('pg-endpoint')).toContainText('Futures continuous')
120 + await expect(page.getByTestId('pg-url')).toContainText('/v1/bars/futures/ES?adjustment=contin_adj_ratio&timeframe=1day')
121 + await expect(page.getByText('Not an allowed value')).toHaveCount(0)
122 + await expect(page.getByTestId('pg-run')).toBeEnabled()
123 + // multi-ticker with asset=futures from the URL: adjustment enum switches to futures values
124 + await page.goto('/playground?ep=bars-multi&asset=futures&tickers=ES,CL&adjustment=adj_split')
125 + await expect(page.getByText('Not an allowed value')).toHaveCount(0)
126 + await expect(page.getByLabel(/^adjustment/)).toHaveValue('')
127 + await expect(page.getByTestId('pg-run')).toBeEnabled()
80 128 })
81 129
82 130 test('unknown operationId falls back to a generic form built from /openapi.json', async ({ page }) => {
@@ -96,15 +144,18 @@ test('presets: CL chain and ES continuous', async ({ page }) => {
96 144 await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/ES/continuous?roll=volume&adjust=back_adjusted&depth=1&interval=1d&from=2015-01-01&to=2025-12-31')
97 145 })
98 146
99 −test('429 shows a Retry-After countdown; missing route explains "coming soon"', async ({ page }) => {
147 +test('429 shows a countdown + free-account CTA; missing route explains "coming soon"', async ({ page }) => {
100 148 await mockAnon(page, url => {
101 149 if (url.pathname === '/v1/bars/stock/AAPL') return json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests', docs: 'https://www.hfmarketdata.io/docs/errors#RATE_LIMIT_EXCEEDED' }, detail: 'Too many requests' }, 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' })
102 150 return null
103 151 })
104 − await page.goto('/playground?ep=stock-bars')
152 + await page.goto('/playground')
105 153 await page.getByTestId('pg-run').click()
106 154 await expect(page.getByTestId('pg-status')).toContainText('429')
107 − await expect(page.getByText('RATE_LIMIT_EXCEEDED · HTTP 429')).toBeVisible()
155 + const err = page.getByTestId('pg-error')
156 + await expect(err).toContainText('Rate limited — not down')
157 + await expect(err.locator('.countdown')).toContainText(/1 min \d+ s/)
158 + await expect(err.getByRole('link', { name: /Create free account/ })).toBeVisible()
108 159 await expect(page.getByRole('region', { name: 'Rate limit' })).toContainText(/Retry in 1 min \d+ s/)
109 160 // v2 endpoint not deployed → coming soon
110 161 await page.getByRole('button', { name: 'NG term structure' }).click()
@@ -113,13 +164,13 @@ test('429 shows a Retry-After countdown; missing route explains "coming soon"',
113 164 await expect(page.getByText(/coming soon/)).toBeVisible()
114 165 })
115 166
116 −test('validation blocks Run; WebSocket tab is disabled without a key', async ({ page }) => {
167 +test('validation blocks Run; WebSocket needs a key', async ({ page }) => {
117 168 await mockAnon(page)
118 169 await page.goto('/playground?ep=options-history')
119 170 await page.getByLabel(/^strike/).fill('')
120 171 await expect(page.getByTestId('pg-run')).toBeDisabled()
121 172 await expect(page.getByText('Required')).toBeVisible()
122 − await page.getByRole('button', { name: /WebSocket stream/ }).click()
173 + await pickEndpoint(page, 'WebSocket stream')
123 174 await expect(page.getByText('WebSocket streaming needs an API key')).toBeVisible()
124 175 await expect(page.getByRole('link', { name: 'Create free account' }).last()).toBeVisible()
125 176 })
@@ -128,10 +179,12 @@ test('CSV format gives a download and a parsed table', async ({ page }) => {
128 179 await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' && url.searchParams.get('format') === 'csv'
129 180 ? { status: 200, contentType: 'text/csv', headers: { 'X-Row-Count': '2' }, body: 'ticker,datetime,open,high,low,close,volume\nAAPL,2024-06-03,1,2,0.5,1.5,100\nAAPL,2024-06-04,1.5,2.5,1,2,200\n' } : null))
130 181 await page.goto('/playground?ep=stock-bars&format=csv')
131 − await page.getByTestId('pg-run').click()
132 − await expect(page.getByTestId('pg-status')).toContainText('2 rows')
182 + await expect(page.getByTestId('pg-status')).toContainText('2 rows') // auto-run on a valid deep link
133 183 await expect(page.getByRole('link', { name: 'Download CSV' })).toBeVisible()
134 − await expect(page.locator('.pg-codex')).toContainText('-o data.csv')
135 − await page.getByRole('tab', { name: /Python/ }).click()
136 − await expect(page.locator('.pg-codex')).toContainText('pd.read_csv')
184 + await page.getByRole('tab', { name: 'Code' }).click()
185 + const code = page.getByTestId('pg-code')
186 + await code.getByRole('radio', { name: 'curl' }).click()
187 + await expect(code).toContainText('-o data.csv')
188 + await code.getByRole('radio', { name: 'Python' }).click()
189 + await expect(code).toContainText('pd.read_csv')
137 190 })
modified hfmarketdata/web/e2e/site.spec.js +107 −17
@@ -1,25 +1,49 @@
1 −// Site shell: homepage, limits page content (free platform), theme toggle persistence, status page, prerendered shells.
1 +// Site shell: homepage (no API calls except /v1/status), limits, theme, status (429 ≠ down), 404, legal, prerender, a11y.
2 2 import { expect, test } from '@playwright/test'
3 +import { json, mockAnon } from './mocks.js'
4 +
5 +const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
3 6
4 7 test.describe('site', () => {
5 − test('homepage hero, nav and key sections', async ({ page }) => {
8 + test('homepage: hero with captured response, nav, sections, no horizontal overflow', async ({ page }) => {
9 + const calls = []
10 + await mockAnon(page, url => { calls.push(url.pathname); return null })
6 11 await page.goto('/')
7 12 await expect(page.getByRole('heading', { level: 1 })).toContainText('Open high-frequency market data')
8 13 const nav = page.getByRole('navigation', { name: 'Main' })
9 14 for (const label of ['Docs', 'Playground', 'Integrations', 'Limits', 'Status']) await expect(nav.getByRole('link', { name: label })).toBeVisible()
10 15 await expect(nav.getByRole('link', { name: /Pricing/ })).toHaveCount(0)
11 − await expect(page.getByRole('heading', { name: /Get an API key/ })).toBeVisible()
16 + // hero shows the captured AAPL response and a request in four languages
17 + await expect(page.getByTestId('hero-response')).toContainText('"ticker": "AAPL"')
18 + await expect(page.locator('.hero-code').getByRole('radio', { name: 'R', exact: true })).toBeVisible()
19 + await expect(page.getByRole('heading', { name: /Start with a URL/ })).toBeVisible()
12 20 await expect(page.getByRole('heading', { name: /A real request, a real response/ })).toBeVisible()
13 21 await expect(page.getByRole('heading', { name: /SEC EDGAR, point-in-time/ })).toBeVisible()
14 − await expect(page.getByTestId('pe-chart')).toBeVisible()
15 − // preview falls back to the captured ES response when the API is not reachable
16 − await expect(page.locator('.preview .code').nth(1)).toContainText('"ticker": "ES"')
17 22 await expect(page.getByRole('link', { name: 'Request higher limits' })).toHaveAttribute('href', /^mailto:contact@spboucher\.ai/)
23 + // counters fall back to the captured /v1/status silently (no apology text)
24 + await expect(page.getByTestId('counters')).toContainText('7,665')
25 + await expect(page.locator('main')).not.toContainText(/not reachable|did not complete/)
26 + // footer: legal + changelog + author
27 + const footer = page.getByRole('contentinfo')
28 + for (const l of ['Terms', 'Privacy', 'Data license & attribution', 'Changelog', 'Status', 'Playground']) await expect(footer.getByRole('link', { name: l })).toBeVisible()
29 + await expect(footer).toContainText('Built by Simon-Pierre Boucher')
30 + expect(await noOverflow(page)).toBe(true)
31 + // the home makes at most one API call on load: /v1/status (quota-exempt)
32 + await page.waitForTimeout(500)
33 + expect(calls.filter(p => p !== '/v1/me' && p !== '/v1/status')).toEqual([])
34 + })
35 +
36 + test('homepage charts render from bundled data when scrolled into view', async ({ page }) => {
37 + await mockAnon(page)
38 + await page.goto('/')
39 + await page.getByTestId('preview-chart').scrollIntoViewIfNeeded()
40 + await expect(page.getByTestId('preview-chart').locator('canvas').first()).toBeVisible({ timeout: 15_000 })
41 + await page.getByTestId('pe-chart').scrollIntoViewIfNeeded()
42 + await expect(page.getByTestId('pe-chart').locator('canvas').first()).toBeVisible({ timeout: 15_000 })
43 + await expect(page.locator('main')).not.toContainText(/not reachable/)
18 44 })
19 45
20 46 test('limits page: three levels with all numbers, CTAs, no paid wording', async ({ page }) => {
21 − // /pricing is the route App.jsx currently serves; it becomes a redirect to /limits (web-app coordinator), so
22 − // navigating here works before and after that change. Switch to '/limits' once the redirect is in.
23 47 await page.goto('/limits')
24 48 await expect(page.getByTestId('limits-page')).toBeVisible()
25 49 await expect(page.getByRole('heading', { level: 1 })).toHaveText('Free for everyone.')
@@ -32,16 +56,16 @@ test.describe('site', () => {
32 56 await expect(page.getByTestId('cta-signup')).toHaveAttribute('href', '/signup')
33 57 const mail = await page.getByTestId('cta-high-usage').getAttribute('href')
34 58 expect(mail).toMatch(/^mailto:contact@spboucher\.ai\?subject=/)
35 − // incentives + FAQ
36 59 await expect(page.getByText('Parquet responses count half', { exact: true })).toBeVisible()
37 60 await page.getByText('What happens on 429?').click()
38 61 await expect(page.getByText(/RATE_LIMIT_EXCEEDED/).first()).toBeVisible()
39 62 const text = (await page.locator('main').innerText()).toLowerCase()
40 63 for (const banned of ['pricing', 'paid', 'per month', '$/']) expect(text).not.toContain(banned)
41 64 await expect(page).toHaveTitle(/Access & limits — free for everyone/)
65 + expect(await noOverflow(page)).toBe(true)
42 66 })
43 67
44 − test('theme toggle switches and persists across reloads', async ({ page }) => {
68 + test('theme toggle switches and persists across reloads; code blocks stay readable in light mode', async ({ page }) => {
45 69 await page.goto('/')
46 70 const html = page.locator('html')
47 71 await expect(html).toHaveAttribute('data-theme', 'dark')
@@ -50,42 +74,108 @@ test.describe('site', () => {
50 74 expect(await page.evaluate(() => localStorage.getItem('hfmd.theme'))).toBe('light')
51 75 await page.reload()
52 76 await expect(html).toHaveAttribute('data-theme', 'light')
53 − // applied before React mounts (inline script in index.html)
54 77 const early = await page.evaluate(() => document.documentElement.getAttribute('data-theme'))
55 78 expect(early).toBe('light')
79 + // code block background follows the theme (no hard-coded dark palette)
80 + const bg = await page.getByTestId('hero-response').evaluate(el => getComputedStyle(el).backgroundColor)
81 + expect(bg).not.toBe('rgb(1, 22, 39)')
82 + const [r, g, b] = bg.match(/\d+/g).map(Number)
83 + expect(r + g + b).toBeGreaterThan(600)
56 84 await page.getByTestId('theme-toggle').click()
57 85 await expect(html).toHaveAttribute('data-theme', 'dark')
58 86 })
59 87
60 − test('status page shows health cards and handles an unreachable API', async ({ page }) => {
88 + test('status page: health cards, unreachable API', async ({ page }) => {
61 89 await page.goto('/status')
62 90 await expect(page.getByTestId('status-page')).toBeVisible()
63 91 await expect(page.getByRole('heading', { level: 1 })).toContainText('API health')
64 92 await expect(page.locator('.health-card')).toHaveCount(4)
65 − // vite preview has no API → cards settle to Down, warning callout shown, page still usable
93 + // vite preview has no API → cards settle to Down, alert shown, page still usable
66 94 await expect(page.locator('.health-card[data-state="down"]').first()).toBeVisible({ timeout: 15_000 })
67 95 await expect(page.getByRole('alert')).toContainText('did not answer')
68 96 })
69 97
70 − test('prerendered shells carry route meta and preload the route chunk', async ({ request }) => {
98 + test('status page: a 429 is reported as rate limited, not down; inventory renders from /v1/status', async ({ page }) => {
99 + await page.route('**/health', r => r.fulfill(json({ status: 'ok', service: 'hfmarketdata-api', version: '2.0.0', data_root_present: true })))
100 + await page.route('**/openapi.json', r => r.fulfill({ status: 429, contentType: 'application/json', body: '{}' }))
101 + await mockAnon(page, url => {
102 + if (url.pathname === '/v1/status') return json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' }, detail: 'Too many requests' }, 429, { 'Retry-After': '60' })
103 + return null
104 + })
105 + await page.goto('/status')
106 + await expect(page.getByTestId('status-limited')).toBeVisible()
107 + await expect(page.getByTestId('status-overall')).toContainText('rate limited')
108 + await expect(page.locator('.health-card[data-state="limited"]')).toHaveCount(2)
109 + await expect(page.locator('.health-card[data-state="down"]')).toHaveCount(0)
110 + // now a healthy inventory with a last-date field the API may add
111 + await page.unroute('**/v1/**')
112 + await mockAnon(page, url => (url.pathname === '/v1/status'
113 + ? json({ datasets: { stock: { '1day': { adj_split: 7664, UNADJUSTED: 7665, last_date: '2026-09-05' }, '1min': { adj_split: 7670 } }, options: { quarters: ['2010_q1', '2026_q3_partial'], files_latest_quarter: 6056 } } })
114 + : null))
115 + await page.getByRole('button', { name: 'Re-check' }).click()
116 + await expect(page.getByTestId('inv-stock')).toContainText('7,664')
117 + await expect(page.getByTestId('last-dates')).toContainText('2026-09-05')
118 + await expect(page.getByTestId('inv-options')).toContainText('6,056')
119 + })
120 +
121 + test('404 page has a title, search and links', async ({ page }) => {
122 + await page.goto('/this/does/not/exist')
123 + await expect(page.getByTestId('not-found')).toBeVisible()
124 + await expect(page).toHaveTitle(/Page not found/)
125 + await expect(page.getByRole('heading', { level: 1 })).toContainText('/this/does/not/exist')
126 + for (const l of ['Documentation', 'Playground', 'Status']) await expect(page.getByTestId('not-found').getByRole('link', { name: new RegExp(l) })).toBeVisible()
127 + await page.getByLabel('Search the documentation').fill('rate limits')
128 + await page.getByTestId('not-found').getByRole('button', { name: 'Search' }).click()
129 + const dialog = page.getByTestId('search-dialog')
130 + await expect(dialog).toBeVisible()
131 + await expect(dialog.getByRole('combobox')).toHaveValue('rate limits')
132 + await expect(dialog.getByRole('option').first()).toContainText(/Rate limits/i)
133 + })
134 +
135 + test('legal pages render and cross-link', async ({ page }) => {
136 + await page.goto('/terms')
137 + await expect(page.getByTestId('legal-terms')).toBeVisible()
138 + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Terms of use')
139 + await expect(page.getByRole('heading', { name: /Reasonable use/ })).toBeVisible()
140 + await page.getByRole('navigation', { name: 'Legal pages' }).getByRole('link', { name: 'Privacy' }).click()
141 + await expect(page).toHaveURL(/\/privacy$/)
142 + await expect(page.locator('main')).toContainText('Resend')
143 + await expect(page.locator('main')).toContainText(/no third-party/i)
144 + await page.goto('/data-license')
145 + await expect(page.locator('main')).toContainText('FirstRate Data')
146 + await expect(page.locator('main')).toContainText('public domain')
147 + await expect(page).toHaveTitle(/Data license/)
148 + })
149 +
150 + test('prerendered shells carry route meta and preload the route chunk; every guide is prerendered', async ({ request }) => {
71 151 const limits = await (await request.get('/limits')).text()
72 152 expect(limits).toContain('<title>Access &amp; limits — free for everyone · HF Market Data</title>')
73 153 expect(limits).toContain('<link rel="canonical" href="https://www.hfmarketdata.io/limits" />')
74 − expect(limits).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/(Limits|Pricing)-[^"]+\.js">/)
154 + expect(limits).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/Limits-[^"]+\.js">/)
75 155 expect(limits).toContain('<h1>Free for everyone.</h1>')
76 156 const home = await (await request.get('/')).text()
77 157 expect(home).toMatch(/<link rel="modulepreload" crossorigin href="\/assets\/Home-[^"]+\.js">/)
78 158 expect(home).not.toContain('fonts.googleapis.com')
159 + expect(home).toContain('og.png')
160 + expect(home).toContain('rel="manifest"')
161 + const guide = await (await request.get('/docs/futures-contracts')).text()
162 + expect(guide).toContain('<title>Futures: individual contracts · HF Market Data</title>')
163 + expect(guide).toMatch(/modulepreload[^>]+futures-contracts-[^"]+\.js/)
164 + const ref = await (await request.get('/docs/reference')).text()
165 + expect(ref).toContain('<h1>API reference</h1>')
79 166 const sitemap = await (await request.get('/sitemap.xml')).text()
80 − expect(sitemap).toContain('<loc>https://www.hfmarketdata.io/docs/quickstart</loc>')
167 + for (const u of ['/docs/quickstart', '/docs/recipes/pandas-backtest', '/docs/reference', '/terms', '/privacy', '/data-license', '/playground']) expect(sitemap).toContain(`<loc>https://www.hfmarketdata.io${u}</loc>`)
168 + for (const asset of ['/og.png', '/apple-touch-icon.png', '/icon-192.png', '/manifest.webmanifest', '/fonts/jetbrains-mono-latin.woff2']) expect((await request.get(asset)).status()).toBe(200)
81 169 })
82 170
83 − test('accessibility basics: skip link, landmarks, focus-visible on nav', async ({ page }) => {
171 + test('accessibility basics: skip link, landmarks, tap targets ≥ 36 px on desktop', async ({ page }) => {
84 172 await page.goto('/limits')
85 173 await page.keyboard.press('Tab')
86 174 await expect(page.getByRole('link', { name: 'Skip to content' })).toBeFocused()
87 175 await expect(page.getByRole('banner')).toBeVisible()
88 176 await expect(page.getByRole('contentinfo')).toBeVisible()
89 177 await expect(page.getByRole('main')).toBeVisible()
178 + const small = await page.evaluate(() => [...document.querySelectorAll('header a, header button')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 36 }).length)
179 + expect(small).toBe(0)
90 180 })
91 181 })
modified hfmarketdata/web/index.html +12 −4
@@ -2,7 +2,7 @@
2 2 <html lang="en" data-theme="dark">
3 3 <head>
4 4 <meta charset="UTF-8" />
5 − <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6 6 <meta name="color-scheme" content="dark light" />
7 7 <meta name="theme-color" content="#0a0c10" media="(prefers-color-scheme: dark)" />
8 8 <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
@@ -10,14 +10,22 @@
10 10 <meta property="og:type" content="website" />
11 11 <meta property="og:site_name" content="HF Market Data" />
12 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" />
13 + <meta property="og:description" content="1-minute to daily bars, futures contracts, options chains with Greeks and point-in-time fundamentals. Free, no signup needed to start." />
14 + <meta property="og:image" content="https://www.hfmarketdata.io/og.png" />
15 + <meta property="og:image:width" content="1200" />
16 + <meta property="og:image:height" content="630" />
17 + <meta property="og:image:alt" content="HF Market Data — open high-frequency market data API" />
15 18 <meta name="twitter:card" content="summary_large_image" />
19 + <meta name="twitter:image" content="https://www.hfmarketdata.io/og.png" />
16 20 <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
21 + <link rel="icon" href="/icon-32.png" type="image/png" sizes="32x32" />
22 + <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
23 + <link rel="manifest" href="/manifest.webmanifest" />
24 + <link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin />
17 25 <link rel="canonical" href="https://www.hfmarketdata.io/" />
18 26 <title>HF Market Data — Open High-Frequency Market Data API</title>
19 27 <script>
20 − // Theme is resolved before first paint (no flash): localStorage → system preference → dark.
28 + // Theme is resolved before first paint (no flash): localStorage → dark.
21 29 try {
22 30 var t = localStorage.getItem('hfmd.theme');
23 31 if (t !== 'light' && t !== 'dark') t = 'dark';
added hfmarketdata/web/public/apple-touch-icon.png +0 −0

Binary file not shown.

modified hfmarketdata/web/public/favicon.svg +2 −2
@@ -1,6 +1,6 @@
1 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>
2 + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#60a5fa"/></linearGradient></defs>
3 3 <rect width="64" height="64" rx="14" fill="#0a0c10"/>
4 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"/>
5 + <rect x="10" y="50" width="44" height="4" rx="2" fill="#34d399" opacity=".5"/>
6 6 </svg>
added hfmarketdata/web/public/fonts/jetbrains-mono-latin.woff2 +0 −0

Binary file not shown.

added hfmarketdata/web/public/icon-192.png +0 −0

Binary file not shown.

added hfmarketdata/web/public/icon-32.png +0 −0

Binary file not shown.

added hfmarketdata/web/public/icon-512.png +0 −0

Binary file not shown.

modified hfmarketdata/web/public/logo.svg +1 −1
@@ -1,5 +1,5 @@
1 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>
2 + <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#60a5fa"/></linearGradient></defs>
3 3 <rect width="64" height="64" rx="14" fill="#0a0c10"/>
4 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 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>
added hfmarketdata/web/public/manifest.webmanifest +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "name": "HF Market Data",
3 + "short_name": "HF Market Data",
4 + "description": "Open high-frequency market data API — free, 1-minute to daily bars, futures contracts, options chains with Greeks, SEC EDGAR fundamentals.",
5 + "start_url": "/",
6 + "display": "standalone",
7 + "background_color": "#0a0c10",
8 + "theme_color": "#0a0c10",
9 + "icons": [
10 + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
11 + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
12 + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
13 + ]
14 +}
added hfmarketdata/web/public/og.png +0 −0

Binary file not shown.

deleted hfmarketdata/web/public/og.svg +0 −16
@@ -1,16 +0,0 @@
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/scripts/make-brand-assets.mjs +72 −0
@@ -0,0 +1,72 @@
1 +#!/usr/bin/env node
2 +// Renders public/og.png (1200×630), public/apple-touch-icon.png (180) and public/icon-{32,192,512}.png with Playwright
3 +// from self-contained HTML (design tokens inlined, no network). Run after changing the brand: node scripts/make-brand-assets.mjs
4 +import { mkdirSync, writeFileSync } from 'node:fs'
5 +import { dirname, join, resolve } from 'node:path'
6 +import { fileURLToPath } from 'node:url'
7 +import { chromium } from 'playwright'
8 +
9 +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
10 +const PUB = join(ROOT, 'public')
11 +mkdirSync(PUB, { recursive: true })
12 +
13 +const MARK = (size, radius) => `
14 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}">
15 + <rect width="64" height="64" rx="${radius}" fill="#0a0c10"/>
16 + <path d="M10 44 L20 30 L28 36 L38 20 L46 26 L54 14" fill="none" stroke="#34d399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
17 + <rect x="10" y="50" width="44" height="4" rx="2" fill="#34d399" opacity=".55"/>
18 + </svg>`
19 +
20 +const SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
21 +const MONO = `ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace`
22 +
23 +const OG = `<!doctype html><html><head><meta charset="utf-8"><style>
24 + html, body { margin: 0; width: 1200px; height: 630px; background: #0a0c10; color: #e8ebf1; font-family: ${SANS}; -webkit-font-smoothing: antialiased; }
25 + .wrap { position: relative; width: 1200px; height: 630px; overflow: hidden; background: radial-gradient(60% 70% at 15% 20%, rgba(52,211,153,.16), transparent 70%), radial-gradient(45% 60% at 85% 80%, rgba(96,165,250,.14), transparent 70%), #0a0c10; }
26 + .grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 60px 60px; }
27 + .brand { position: absolute; left: 72px; top: 64px; display: flex; align-items: center; gap: 16px; font-weight: 650; font-size: 30px; letter-spacing: -.01em; }
28 + .brand svg { border-radius: 14px; }
29 + h1 { position: absolute; left: 72px; top: 170px; margin: 0; font-size: 66px; line-height: 1.08; letter-spacing: -.035em; font-weight: 700; max-width: 680px; }
30 + h1 em { font-style: normal; color: #34d399; }
31 + p { position: absolute; left: 72px; top: 400px; margin: 0; font-size: 26px; line-height: 1.4; color: #b4bcc8; max-width: 640px; }
32 + .tags { position: absolute; left: 72px; bottom: 60px; display: flex; gap: 12px; }
33 + .tag { font-family: ${MONO}; font-size: 20px; color: #e8ebf1; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14); padding: 8px 16px; border-radius: 999px; }
34 + .code { position: absolute; right: 64px; top: 120px; width: 400px; background: #0c0f15; border: 1px solid #313847; border-radius: 16px; box-shadow: 0 30px 80px -30px rgba(0,0,0,.8); overflow: hidden; }
35 + .code-head { padding: 12px 16px; border-bottom: 1px solid #232833; background: #10131a; font-family: ${MONO}; font-size: 15px; color: #8f98a8; display: flex; justify-content: space-between; }
36 + .code pre { margin: 0; padding: 14px 18px; font-family: ${MONO}; font-size: 16px; line-height: 1.45; color: #d9dee8; }
37 + .k { color: #7fd1ff; } .s { color: #9fe7c0; } .n { color: #ffcf7a; } .c { color: #7d8797; }
38 + .chart { position: absolute; right: 64px; bottom: 52px; width: 400px; height: 90px; }
39 +</style></head><body><div class="wrap"><div class="grid"></div>
40 + <div class="brand">${MARK(56, 14)}HF Market Data</div>
41 + <h1>Open high-frequency market data. <em>Free.</em></h1>
42 + <p>1-minute to daily bars, futures contracts, options chains with Greeks, SEC fundamentals — one URL, no signup to start.</p>
43 + <div class="tags"><span class="tag">JSON · CSV · Parquet</span><span class="tag">since 2010</span><span class="tag">MCP · Python · R · JS</span></div>
44 + <div class="code"><div class="code-head"><span>GET /v1/bars/stock/AAPL</span><span>200 OK</span></div><pre>{
45 + <span class="k">"count"</span>: <span class="n">5</span>,
46 + <span class="k">"data"</span>: [{
47 + <span class="k">"datetime"</span>: <span class="s">"2024-06-03"</span>,
48 + <span class="k">"open"</span>: <span class="n">191.2434</span>,
49 + <span class="k">"high"</span>: <span class="n">193.3154</span>,
50 + <span class="k">"low"</span>: <span class="n">190.8666</span>,
51 + <span class="k">"close"</span>: <span class="n">192.3637</span>,
52 + <span class="k">"volume"</span>: <span class="n">50080500</span>
53 + }, <span class="c">…</span>]
54 +}</pre></div>
55 + <svg class="chart" viewBox="0 0 400 110" preserveAspectRatio="none" fill="none">
56 + ${[[10,70,60,'#2fbf71'],[45,40,55,'#e5484d'],[80,50,75,'#2fbf71'],[115,30,50,'#e5484d'],[150,45,80,'#2fbf71'],[185,60,90,'#2fbf71'],[220,35,65,'#e5484d'],[255,50,85,'#2fbf71'],[290,65,95,'#2fbf71'],[325,40,70,'#e5484d'],[360,55,100,'#2fbf71']].map(([x,y1,y2,c]) => `<line x1="${x+9}" x2="${x+9}" y1="${110-y2-8}" y2="${110-y1+8}" stroke="${c}" stroke-width="2"/><rect x="${x}" y="${110-y2}" width="18" height="${y2-y1}" rx="2" fill="${c}"/>`).join('')}
57 + </svg>
58 +</div></body></html>`
59 +
60 +const ICON = (size, radius) => `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:transparent}svg{display:block}</style></head><body>${MARK(size, radius)}</body></html>`
61 +
62 +const browser = await chromium.launch()
63 +const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 })
64 +await page.setContent(OG, { waitUntil: 'load' })
65 +writeFileSync(join(PUB, 'og.png'), await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 1200, height: 630 } }))
66 +for (const [file, size, radius] of [['apple-touch-icon.png', 180, 0], ['icon-32.png', 32, 7], ['icon-192.png', 192, 40], ['icon-512.png', 512, 108]]) {
67 + await page.setViewportSize({ width: size, height: size })
68 + await page.setContent(ICON(size, radius), { waitUntil: 'load' })
69 + writeFileSync(join(PUB, file), await page.screenshot({ type: 'png', omitBackground: radius > 0, clip: { x: 0, y: 0, width: size, height: size } }))
70 +}
71 +await browser.close()
72 +console.log('wrote public/og.png, apple-touch-icon.png, icon-32/192/512.png')
modified hfmarketdata/web/scripts/prerender.mjs +63 −35
@@ -1,16 +1,14 @@
1 1 #!/usr/bin/env node
2 −// Post-build prerender of static HTML shells for the public entry routes.
2 +// Post-build prerender of static HTML shells for every public entry route: home, limits, status, playground,
3 +// integrations, legal pages, docs overview, reference index, errors, and EVERY guide (read from src/docs/guides.js +
4 +// the guide's `meta` export). See README "Prerender" for the trade-off versus full SSR.
3 5 //
4 −// Trade-off (see README "Prerender"): a full SSR of the React tree would need a second SSR build + a Node render step
5 −// for every route (lazy routes, MDX, lightweight-charts, browser-only APIs), for pages that are mostly fetched live.
6 −// Instead we emit, per route, a copy of dist/index.html with: route-specific <title>/<meta>/<link rel=canonical>,
7 −// <link rel=modulepreload> for the route's chunk graph and <link rel=stylesheet> for its CSS (kills the lazy-route
8 −// waterfall), and the above-the-fold content (header + h1 + lead) as static markup inside #root so first paint is
9 −// meaningful before React mounts. React's createRoot() then replaces it with the live tree.
10 −//
11 −// Serving: vite preview / any static host resolves /limits → dist/limits/index.html. The FastAPI SPA fallback currently
12 −// serves index.html for directories; it needs a one-line `candidate / "index.html"` check to pick these up.
13 −import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6 +// Per route we emit a copy of dist/index.html with route-specific <title>/<meta>/<link rel=canonical>,
7 +// <link rel=modulepreload> for the route's chunk graph + <link rel=stylesheet> for its CSS (kills the lazy-route
8 +// waterfall), and the above-the-fold content (header + h1 + lead) inside #root so first paint is meaningful before React
9 +// mounts. Serving: vite preview / static hosts resolve /limits → dist/limits/index.html (and dist/limits.html); the
10 +// FastAPI SPA fallback tries candidate, candidate.html and candidate/index.html.
11 +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
14 12 import { dirname, join, resolve } from 'node:path'
15 13 import { fileURLToPath } from 'node:url'
16 14
@@ -20,29 +18,59 @@ const SITE = 'https://www.hfmarketdata.io'
20 18
21 19 const NAV = [['/docs', 'Docs'], ['/playground', 'Playground'], ['/integrations', 'Integrations'], ['/limits', 'Limits'], ['/status', 'Status']]
22 20
23 −const ROUTES = [
21 +const HOME_LEAD = 'Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, docs generated from the spec, a playground, and limits that only grow.'
22 +
23 +const STATIC_ROUTES = [
24 24 { path: '/', entry: 'src/pages/home/Home.jsx', title: 'HF Market Data — Open High-Frequency Market Data API',
25 25 description: 'Free open API for high-frequency historical market data: stocks, ETFs, futures (continuous and individual contracts), crypto, indices, FX from 1-minute to daily, options chains with Greeks and SEC EDGAR fundamentals since 2010.',
26 − h1: 'Open high-frequency market data. 1-minute to daily, since 2010.',
27 − lead: 'Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, generated docs, a playground, and limits that only grow.', cls: 'hero' },
28 − // entry falls back to the temporary src/pages/pricing shim until App.jsx imports Limits.jsx directly
29 − { path: '/limits', entry: ['src/pages/limits/Limits.jsx', 'src/pages/pricing/Pricing.jsx'], title: 'Access & limits — free for everyone · HF Market Data',
26 + h1: 'Open high-frequency market data. 1-minute to daily, since 2010.', lead: HOME_LEAD, cls: 'hero' },
27 + { path: '/limits', entry: 'src/pages/limits/Limits.jsx', title: 'Access & limits — free for everyone · HF Market Data',
30 28 description: 'HF Market Data is free. Keyless access is very limited, a free account gives much higher limits, and higher limits are granted on request by e-mail.',
31 29 h1: 'Free for everyone.', lead: 'There is nothing to buy. Keyless access is deliberately small so you can try things without signing up; a free account raises the limits a lot; and if you need more, you simply ask.' },
32 − { path: '/docs', entry: 'src/pages/docs/Docs.jsx', title: 'Documentation · HF Market Data',
33 − description: 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference generated from OpenAPI.',
34 − h1: 'Documentation', lead: 'Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures, crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.' },
35 − { path: '/docs/quickstart', entry: 'src/pages/docs/Docs.jsx', title: 'Quickstart · HF Market Data',
36 − description: 'Your first HF Market Data request in 30 seconds — no signup, no key.', h1: 'Quickstart',
37 − lead: 'You can pull real data right now, with nothing but a URL. No account, no key, no SDK.' },
38 30 { path: '/status', entry: 'src/pages/status/Status.jsx', title: 'Status · HF Market Data',
39 31 description: 'Live health and dataset inventory of the HF Market Data API.', h1: 'API health & dataset inventory',
40 32 lead: 'Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained.' },
33 + { path: '/playground', entry: 'src/pages/playground/Playground.jsx', title: 'Playground · HF Market Data',
34 + description: 'Run any HF Market Data request in the browser: pick an endpoint, edit parameters, inspect JSON, table or chart, copy the code.', h1: 'Playground',
35 + lead: 'Pick an endpoint, edit the parameters, run it against the live API and inspect the result as JSON, table or chart — then copy the code.' },
36 + { path: '/integrations', entry: 'src/pages/integrations/Integrations.jsx', title: 'Integrations — MCP server, Claude Code, Cursor, Codex, skills · HF Market Data',
37 + description: 'Use HF Market Data from Claude Code, Cursor, Codex and any MCP client: one MCP server with 14 read-only tools, plus a skills pack with ready-to-run Python.', h1: 'Market data where your agent already works.',
38 + lead: 'One MCP server gives Claude Code, Cursor, Codex and Claude Desktop 14 read-only tools over the whole HF Market Data API. Plus five Claude skills with ready-to-run Python.' },
39 + { path: '/terms', entry: 'src/pages/legal/Legal.jsx', title: 'Terms of use · HF Market Data', description: 'Terms of use of the HF Market Data API and website — a free platform, offered as is, with reasonable-use limits.', h1: 'Terms of use', lead: 'HF Market Data is a free, open API and website for historical market data. Using it means you accept these short terms.' },
40 + { path: '/privacy', entry: 'src/pages/legal/Legal.jsx', title: 'Privacy · HF Market Data', description: 'What HF Market Data stores about you (account e-mail, hashed keys, request counters), what it does not (no third-party tracking), and how to delete it.', h1: 'Privacy', lead: 'An e-mail address if you create an account, hashed API keys, request counters — and nothing for advertising.' },
41 + { path: '/data-license', entry: 'src/pages/legal/Legal.jsx', title: 'Data license & attribution · HF Market Data', description: 'Where the data comes from (FirstRate Data, SEC EDGAR), what you may do with it, and how to attribute it.', h1: 'Data license & attribution', lead: 'Market data from FirstRate Data, fundamentals from SEC EDGAR — what you may do with each, and how to give credit.' },
42 + { path: '/docs', entry: 'src/pages/docs/Docs.jsx', title: 'Documentation · HF Market Data',
43 + description: 'HF Market Data documentation: quickstart, authentication, rate limits, futures contracts, options, fundamentals, and the full API reference generated from OpenAPI.',
44 + h1: 'Documentation', lead: 'Historical market data from 1-minute to daily since 2010 — stocks, ETFs, futures, crypto, indices, FX — plus options chains with Greeks and SEC EDGAR fundamentals. One base URL, JSON / CSV / Parquet, no signup to start.' },
45 + { path: '/docs/reference', entry: 'src/pages/docs/Docs.jsx', title: 'API reference · HF Market Data',
46 + description: 'Every endpoint of the HF Market Data API, generated from the OpenAPI 3.1 specification.', h1: 'API reference',
47 + lead: 'Every endpoint, generated from openapi.json. Each page shows parameters, real examples, error codes and a one-click Try it.' },
48 + { path: '/docs/errors', entry: 'src/pages/docs/Docs.jsx', title: 'Error codes · HF Market Data',
49 + description: 'Every error code returned by the HF Market Data API, its HTTP status, meaning and how to fix it.', h1: 'Errors',
50 + lead: 'Every error response — on every endpoint, v1 and v2 — has the same JSON envelope. The error.docs URL points to the matching anchor on this page.' },
41 51 ]
42 52
43 53 const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;')
44 54
45 −function chunkGraph(manifest, entry) {
55 +/** Guides from src/docs/guides.js (slug/title/file/summary) + the MDX `meta.description` when present. */
56 +function guideRoutes() {
57 + const src = readFileSync(join(ROOT, 'src/docs/guides.js'), 'utf8')
58 + const re = /\{\s*slug:\s*'([^']+)',\s*title:\s*'((?:\\'|[^'])+)',\s*section:\s*'[^']+',\s*file:\s*'([^']+)',\s*summary:\s*'((?:\\'|[^'])+)'/g
59 + const out = []
60 + for (const m of src.matchAll(re)) {
61 + const [, slug, title, file, summary] = m.map(x => (x || '').replace(/\\'/g, "'"))
62 + const mdxPath = file === '__changelog' ? join(ROOT, 'content/changelog.mdx') : join(ROOT, `content/guides/${file}.mdx`)
63 + let description = summary
64 + if (existsSync(mdxPath)) {
65 + const meta = /export const meta = \{[^}]*description:\s*'((?:\\'|[^'])+)'/.exec(readFileSync(mdxPath, 'utf8'))
66 + if (meta) description = meta[1].replace(/\\'/g, "'")
67 + }
68 + out.push({ path: `/docs/${slug}`, entry: 'src/pages/docs/Docs.jsx', title: `${title} · HF Market Data`, description, h1: title, lead: description, guide: file })
69 + }
70 + return out
71 +}
72 +
73 +function chunkGraph(manifest, entry, extra = []) {
46 74 const files = new Set(), css = new Set()
47 75 const walk = key => {
48 76 const m = manifest[key]
@@ -53,13 +81,15 @@ function chunkGraph(manifest, entry) {
53 81 }
54 82 const entries = Array.isArray(entry) ? entry : [entry]
55 83 walk(entries.find(e => manifest[e]) || entries[0])
84 + for (const e of extra) walk(e)
56 85 return { files: [...files], css: [...css] }
57 86 }
58 87
59 88 function shell(route) {
60 89 const nav = NAV.map(([to, label]) => `<a href="${to}"${route.path.startsWith(to) ? ' class="active"' : ''}>${label}</a>`).join('')
90 + const hero = route.cls === 'hero'
61 91 return `<div class="shell"><header class="topbar"><a class="brand" href="/"><span class="brand-mark">HF</span><span class="brand-name">Market Data</span></a><nav class="topnav" aria-label="Main">${nav}</nav></header>` +
62 − `<main class="${route.cls === 'hero' ? 'home' : 'page'}"><section class="${route.cls === 'hero' ? 'hero' : 'limits-head'}" style="padding-top:${route.cls === 'hero' ? '72px' : '0'}"><div class="hero-inner"><h1>${esc(route.h1)}</h1><p class="lead">${esc(route.lead)}</p></div></section></main></div>`
92 + `<main class="${hero ? 'home' : 'page'}"><section class="${hero ? 'hero' : 'prerender-head'}"><div class="hero-inner"><h1>${esc(route.h1)}</h1><p class="lead">${esc(route.lead)}</p></div></section></main></div>`
63 93 }
64 94
65 95 function main() {
@@ -68,13 +98,15 @@ function main() {
68 98 const base = readFileSync(indexPath, 'utf8')
69 99 const manifestPath = join(DIST, '.vite', 'manifest.json')
70 100 const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {}
101 + const routes = [...STATIC_ROUTES, ...guideRoutes()]
71 102 let count = 0
72 − for (const route of ROUTES) {
73 − const { files, css } = chunkGraph(manifest, route.entry)
103 + for (const route of routes) {
104 + const extra = route.guide ? [route.guide === '__changelog' ? 'content/changelog.mdx' : `content/guides/${route.guide}.mdx`] : []
105 + const { files, css } = chunkGraph(manifest, route.entry, extra)
74 106 const alreadyLinked = f => base.includes(`/${f}`)
75 107 const preload = files.filter(f => !alreadyLinked(f)).map(f => `<link rel="modulepreload" crossorigin href="/${f}">`).join('')
76 108 const styles = css.filter(f => !alreadyLinked(f)).map(f => `<link rel="stylesheet" href="/${f}">`).join('')
77 − let html = base
109 + const html = base
78 110 .replace(/<title>[^<]*<\/title>/, `<title>${esc(route.title)}</title>`)
79 111 .replace(/<meta name="description" content="[^"]*"\s*\/?>/, `<meta name="description" content="${esc(route.description)}" />`)
80 112 .replace(/<meta property="og:title" content="[^"]*"\s*\/?>/, `<meta property="og:title" content="${esc(route.title)}" />`)
@@ -82,18 +114,14 @@ function main() {
82 114 .replace(/<link rel="canonical" href="[^"]*"\s*\/?>/, `<link rel="canonical" href="${SITE}${route.path === '/' ? '/' : route.path}" />`)
83 115 .replace('</head>', `${styles}${preload}<meta property="og:url" content="${SITE}${route.path}" /></head>`)
84 116 .replace('<div id="root"></div>', `<div id="root">${shell(route)}</div>`)
85 − // Two file forms so any static server picks the shell up: <route>/index.html (nginx try_files, Netlify, S3)
86 − // and <route>.html (vite preview's html fallback, GitHub Pages-style hosts).
117 + // Two file forms so any static server picks the shell up: <route>/index.html and <route>.html.
87 118 const outs = route.path === '/' ? [indexPath] : [join(DIST, route.path.replace(/^\//, ''), 'index.html'), join(DIST, `${route.path.replace(/^\//, '')}.html`)]
88 119 for (const out of outs) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, html) }
89 120 count++
90 − console.log(`prerendered ${route.path} → ${outs.map(o => o.replace(DIST + '/', '')).join(', ')} (${files.length} chunks preloaded, ${css.length} css)`)
91 121 }
92 − // sitemap for the static routes + guides known at build time
93 − const guides = Object.keys(manifest).filter(k => k.startsWith('content/guides/')).map(k => k.replace('content/guides/', '').replace(/\.mdx$/, ''))
94 − const urls = [...ROUTES.map(r => r.path), '/docs/reference', '/docs/errors', '/docs/changelog', ...guides.map(g => `/docs/${g.replace(/^recipe-/, 'recipes/').replace(/^fundamentals-/, 'fundamentals/')}`)]
95 − writeFileSync(join(DIST, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${[...new Set(urls)].map(u => ` <url><loc>${SITE}${u}</loc></url>`).join('\n')}\n</urlset>\n`)
96 − console.log(`${count} shells + sitemap.xml (${urls.length} urls)`)
122 + const urls = [...new Set(routes.map(r => r.path))]
123 + writeFileSync(join(DIST, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map(u => ` <url><loc>${SITE}${u}</loc></url>`).join('\n')}\n</urlset>\n`)
124 + console.log(`prerendered ${count} shells (${routes.filter(r => r.guide).length} guides) + sitemap.xml (${urls.length} urls)`)
97 125 }
98 126
99 127 main()
modified hfmarketdata/web/src/App.jsx +9 −2
@@ -1,5 +1,7 @@
1 −// Route table — one lazy chunk per area. Owners: web-core (home, limits, docs, status, layout),
1 +// Route table — one lazy chunk per area. Owners: web-core (home, limits, docs, status, legal, layout, 404),
2 2 // web-app (playground, auth, dashboard, admin), mcp-skills (integrations).
3 +// Public routes (mirrored by the server's SPA whitelist): / /docs/* /playground /integrations/* /limits /pricing
4 +// /status /terms /privacy /data-license /changelog (→ /docs/changelog) + auth/dashboard/admin.
3 5 import React, { Suspense, lazy } from 'react'
4 6 import { Navigate, Route, Routes } from 'react-router-dom'
5 7 import Layout from './app/Layout.jsx'
@@ -10,10 +12,11 @@ const Playground = lazy(() => import('./pages/playground/Playground.jsx'))
10 12 const Integrations = lazy(() => import('./pages/integrations/Integrations.jsx'))
11 13 const Limits = lazy(() => import('./pages/limits/Limits.jsx'))
12 14 const Status = lazy(() => import('./pages/status/Status.jsx'))
15 +const Legal = lazy(() => import('./pages/legal/Legal.jsx'))
16 +const NotFound = lazy(() => import('./pages/NotFound.jsx'))
13 17 const Auth = lazy(() => import('./pages/auth/Auth.jsx'))
14 18 const Dashboard = lazy(() => import('./pages/dashboard/Dashboard.jsx'))
15 19 const Admin = lazy(() => import('./pages/admin/Admin.jsx'))
16 −const NotFound = () => <main className="page narrow"><h1>404</h1><p className="muted">This page does not exist.</p></main>
17 20
18 21 export default function App() {
19 22 return (
@@ -27,6 +30,10 @@ export default function App() {
27 30 <Route path="/limits" element={<Limits />} />
28 31 <Route path="/pricing" element={<Navigate to="/limits" replace />} />
29 32 <Route path="/status" element={<Status />} />
33 + <Route path="/changelog" element={<Navigate to="/docs/changelog" replace />} />
34 + <Route path="/terms" element={<Legal page="terms" />} />
35 + <Route path="/privacy" element={<Legal page="privacy" />} />
36 + <Route path="/data-license" element={<Legal page="data-license" />} />
30 37 <Route path="/signin" element={<Auth mode="signin" />} />
31 38 <Route path="/signup" element={<Auth mode="signup" />} />
32 39 <Route path="/verify" element={<Auth mode="verify" />} />
modified hfmarketdata/web/src/app/Layout.jsx +24 −8
@@ -1,4 +1,5 @@
1 1 // App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core.
2 +// Header is 56 px; every nav target is ≥ 36 px on desktop and ≥ 44 px on touch viewports (theme.css --tap).
2 3 import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react'
3 4 import { Link, NavLink, useLocation } from 'react-router-dom'
4 5 import { useAuth } from './auth.jsx'
@@ -28,6 +29,7 @@ export const NAV = [
28 29 ]
29 30
30 31 export const REPO_URL = 'https://git.spboucher.ai/hfmarketdata.git'
32 +export const AUTHOR = 'Simon-Pierre Boucher'
31 33
32 34 const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
33 35
@@ -36,15 +38,24 @@ export default function Layout({ children }) {
36 38 const { theme, toggle } = useTheme()
37 39 const [drawer, setDrawer] = useState(false)
38 40 const [searchOpen, setSearchOpen] = useState(false)
41 + const [searchSeed, setSearchSeed] = useState('')
39 42 const location = useLocation()
40 − const openSearch = useCallback(() => setSearchOpen(true), [])
43 + const openSearch = useCallback(() => { setSearchSeed(''); setSearchOpen(true) }, [])
41 44 useSearchShortcut(openSearch)
45 + // Other pages (404) can open the search dialog with a query: window.dispatchEvent(new CustomEvent('hfmd:search', { detail }))
46 + useEffect(() => {
47 + const on = e => { setSearchSeed(typeof e.detail === 'string' ? e.detail : ''); setSearchOpen(true) }
48 + window.addEventListener('hfmd:search', on)
49 + return () => window.removeEventListener('hfmd:search', on)
50 + }, [])
42 51 useEffect(() => { setDrawer(false) }, [location.pathname])
43 52 useEffect(() => {
44 − if (!drawer) return
53 + if (!drawer) return undefined
45 54 const onKey = e => { if (e.key === 'Escape') setDrawer(false) }
46 55 window.addEventListener('keydown', onKey)
47 − return () => window.removeEventListener('keydown', onKey)
56 + const prev = document.body.style.overflow
57 + document.body.style.overflow = 'hidden'
58 + return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev }
48 59 }, [drawer])
49 60 // Scroll to top on route change (hash links handled by the docs pages).
50 61 useEffect(() => { if (!location.hash) window.scrollTo(0, 0) }, [location.pathname, location.hash])
@@ -87,6 +98,7 @@ export default function Layout({ children }) {
87 98 </div>
88 99 <NavLink to="/" end className="navlink">Home</NavLink>
89 100 {NAV.map(([to, label]) => <NavLink key={to} to={to} className="navlink">{label}</NavLink>)}
101 + <NavLink to="/docs/changelog" className="navlink">Changelog</NavLink>
90 102 <div className="drawer-foot">
91 103 {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 104 </div>
@@ -100,16 +112,20 @@ export default function Layout({ children }) {
100 112 <div className="footer-inner">
101 113 <div>
102 114 <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>
115 + <p className="muted" style={{ maxWidth: 320 }}>Open high-frequency market data API. 1-minute to daily bars, futures contracts, options chains with Greeks, SEC EDGAR fundamentals. Free for everyone.</p>
104 116 <p className="muted small">Base URL <code>{PUBLIC_BASE}</code></p>
105 117 </div>
106 118 <div>
107 119 <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="/limits">Access &amp; limits</Link></li></ul>
120 + <ul><li><Link to="/docs">Docs</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="/limits">Limits</Link></li></ul>
109 121 </div>
110 122 <div>
111 123 <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>
124 + <ul><li><Link to="/docs/quickstart">Quickstart</Link></li><li><Link to="/status">Status</Link></li><li><Link to="/docs/changelog">Changelog</Link></li><li><Link to="/docs/errors">Error codes</Link></li><li><a href={`${PUBLIC_BASE}/openapi.json`}>openapi.json</a></li></ul>
125 + </div>
126 + <div>
127 + <h4>Legal</h4>
128 + <ul><li><Link to="/terms">Terms</Link></li><li><Link to="/privacy">Privacy</Link></li><li><Link to="/data-license">Data license &amp; attribution</Link></li></ul>
113 129 </div>
114 130 <div>
115 131 <h4>Contact</h4>
@@ -117,14 +133,14 @@ export default function Layout({ children }) {
117 133 </div>
118 134 </div>
119 135 <div className="footer-bottom">
120 − <span>© {new Date().getFullYear()} HF Market Data · Simon-Pierre Boucher</span>
136 + <span>© {new Date().getFullYear()} HF Market Data · Built by {AUTHOR}</span>
121 137 <span>Data: FirstRate Data · SEC EDGAR · UTC / ISO 8601 everywhere</span>
122 138 </div>
123 139 </footer>
124 140
125 141 {searchOpen && (
126 142 <Suspense fallback={null}>
127 − <SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
143 + <SearchDialog open={searchOpen} initialQuery={searchSeed} onClose={() => setSearchOpen(false)} />
128 144 </Suspense>
129 145 )}
130 146 </div>
modified hfmarketdata/web/src/app/theme.css +188 −82
@@ -1,146 +1,252 @@
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. */
1 +/* HF Market Data design system — tokens, base, shell, utilities. Owner: web-core.
2 + Dark by default, light via [data-theme="light"] (persisted; index.html applies it before first paint).
3 + Sans = system stack. Mono = JetBrains Mono (self-hosted, latin subset, variable weight) — the "data platform" signal.
4 + Breakpoints (CSS cannot read custom properties in @media): 640 / 960 / 1200 px. */
5 +
6 +@font-face {
7 + font-family: "JetBrains Mono";
8 + font-style: normal;
9 + font-weight: 100 800;
10 + font-display: swap;
11 + src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2");
12 + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
13 +}
14 +
3 15 :root {
4 16 color-scheme: dark;
17 + /* surfaces */
5 18 --bg: #0a0c10; --bg-1: #10131a; --bg-2: #161a22; --bg-3: #1d222c;
6 19 --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;
20 + /* text — every --fg-* is ≥ 4.5:1 on --bg, --bg-1 and --bg-2; --fg-muted-deco is decorative only */
21 + --fg: #e8ebf1; --fg-1: #b4bcc8; --fg-2: #8f98a8; --fg-3: #7d8797; --fg-muted-deco: #5a6373;
22 + /* accent + secondary */
23 + --accent: #34d399; --accent-ink: #052e1c; --accent-soft: rgba(52, 211, 153, 0.13); --accent-strong: #6ee7b7;
24 + --accent-2: #60a5fa; --accent-2-soft: rgba(96, 165, 250, 0.13);
25 + /* market semantics */
26 + --up: #2fbf71; --down: #e5484d; --neutral: #8b95a7;
27 + /* states */
28 + --warn: #f5b544; --warn-soft: rgba(245, 181, 68, 0.13);
29 + --danger: #e5484d; --danger-soft: rgba(229, 72, 77, 0.13);
30 + --info: #60a5fa; --info-soft: rgba(96, 165, 250, 0.13);
31 + --success: #34d399; --success-soft: rgba(52, 211, 153, 0.13);
32 + /* HTTP verbs */
33 + --get: #60a5fa; --post: #34d399; --delete: #e5484d; --put: #f5b544; --patch: #c084fc;
34 + /* type */
14 35 --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;
36 + --mono: "JetBrains Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
37 + --fs-0: 12px; --fs-1: 13px; --fs-2: 14px; --fs-3: 15px; --fs-4: 17px; --fs-5: 20px; --fs-6: 24px; --fs-7: 32px; --fs-8: 44px;
38 + --lh-tight: 1.15; --lh-snug: 1.3; --lh-body: 1.55; --lh-prose: 1.7;
39 + /* spacing 4·n */
40 + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-7: 28px; --sp-8: 32px; --sp-9: 36px; --sp-10: 40px;
41 + /* radii */
42 + --r-sm: 6px; --r-md: 10px; --r-lg: 16px;
43 + --radius: var(--r-md); --radius-lg: var(--r-lg); /* legacy aliases (dashboard.css, auth.css) */
44 + /* shadows */
45 + --shadow-1: 0 1px 2px rgba(0, 0, 0, .35);
46 + --shadow-2: 0 1px 2px rgba(0, 0, 0, .3), 0 16px 48px -16px rgba(0, 0, 0, .6);
47 + --shadow: var(--shadow-2);
48 + /* layers */
49 + --z-sticky: 10; --z-drawer: 60; --z-modal: 80;
50 + /* layout */
51 + --max: 1240px; --topbar-h: 56px;
52 + /* code */
53 + --code-bg: #0c0f15; --code-fg: #d9dee8; --code-comment: #7d8797; --code-key: #7fd1ff; --code-string: #9fe7c0; --code-number: #ffcf7a; --code-keyword: #d6a6ff; --code-punct: #98a2b3; --code-func: #9fd0ff;
54 + /* controls */
55 + --tap: 36px; --tap-mobile: 44px;
19 56 }
20 57 [data-theme="light"] {
21 58 color-scheme: light;
22 59 --bg: #ffffff; --bg-1: #f6f7f9; --bg-2: #eef0f4; --bg-3: #e4e7ed;
23 60 --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;
61 + --fg: #12161d; --fg-1: #3b4452; --fg-2: #4f5a6b; --fg-3: #5f6b7d; --fg-muted-deco: #98a2b3;
62 + --accent: #0f766e; --accent-ink: #ffffff; --accent-soft: rgba(15, 118, 110, 0.10); --accent-strong: #0d9488;
63 + --accent-2: #1d4ed8; --accent-2-soft: rgba(29, 78, 216, 0.10);
64 + --up: #15803d; --down: #d92d20; --neutral: #64708a;
65 + --warn: #b45309; --warn-soft: rgba(180, 83, 9, 0.10);
66 + --danger: #d92d20; --danger-soft: rgba(217, 45, 32, 0.10);
67 + --info: #1d4ed8; --info-soft: rgba(29, 78, 216, 0.10);
68 + --success: #0f766e; --success-soft: rgba(15, 118, 110, 0.10);
69 + --get: #1d4ed8; --post: #0f766e; --delete: #d92d20; --put: #b45309; --patch: #7c3aed;
70 + --shadow-1: 0 1px 2px rgba(20, 24, 31, .06);
71 + --shadow-2: 0 1px 2px rgba(20, 24, 31, .06), 0 16px 48px -20px rgba(20, 24, 31, .25);
72 + --code-bg: #f4f6f9; --code-fg: #1d2430; --code-comment: #5f6b7d; --code-key: #0b5ea8; --code-string: #0b6b45; --code-number: #9a4d00; --code-keyword: #6d28d9; --code-punct: #4f5a6b; --code-func: #0b5ea8;
32 73 }
33 74
34 −/* ---- base ---- */
75 +/* ---- base ---------------------------------------------------------------------------------- */
35 76 *, *::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; }
77 +html { font-family: var(--sans); background: var(--bg); color: var(--fg); -webkit-font-smoothing: antialiased; font-size: var(--fs-3); line-height: var(--lh-body); text-rendering: optimizeLegibility; scroll-padding-top: calc(var(--topbar-h) + 16px); overflow-x: clip; }
78 +body { margin: 0; min-height: 100dvh; overflow-x: clip; }
79 +h1, h2, h3, h4 { line-height: var(--lh-tight); letter-spacing: -0.015em; margin: 0 0 0.5em; font-weight: 650; text-wrap: balance; }
80 +h1 { font-size: clamp(var(--fs-7), 4vw, var(--fs-8)); letter-spacing: -0.025em; }
81 +h2 { font-size: clamp(var(--fs-6), 2.6vw, var(--fs-7)); }
82 +h3 { font-size: var(--fs-5); line-height: var(--lh-snug); }
83 +h4 { font-size: var(--fs-3); }
43 84 p { margin: 0 0 1em; }
44 85 a { color: var(--accent-2); text-decoration: none; }
45 86 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; }
87 +:focus { outline: none; }
88 +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
89 +input:focus-visible, select:focus-visible, textarea:focus-visible { outline-offset: 0; border-color: var(--accent); border-radius: var(--r-sm); }
47 90 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); }
91 +code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; overflow-wrap: anywhere; }
92 +pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px 14px; overflow: auto; line-height: 1.55; }
93 +pre code { background: none; border: 0; padding: 0; font-size: var(--fs-1); }
94 +kbd { background: var(--bg-2); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px; font-size: var(--fs-0); color: var(--fg-1); }
52 95 hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; }
53 96 img, svg { max-width: 100%; }
54 −button { font: inherit; }
97 +button { font: inherit; color: inherit; }
98 +::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
99 +@media (prefers-reduced-motion: reduce) {
100 + *, *::before, *::after { animation-duration: 0.01ms; animation-iteration-count: 1; transition-duration: 0.01ms; scroll-behavior: auto; }
101 +}
102 +
103 +/* ---- text utilities ------------------------------------------------------------------------ */
55 104 .muted { color: var(--fg-2); }
56 −.small { font-size: 13px; }
105 +.small { font-size: var(--fs-1); }
57 106 .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; }
107 +.eyebrow { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-0); font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin: 0 0 10px; }
108 +.eyebrow svg { width: 14px; height: 14px; }
109 +.lead { font-size: var(--fs-4); color: var(--fg-1); max-width: 62ch; line-height: var(--lh-body); }
110 +.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: var(--fs-1); }
111 +.up { color: var(--up); } .down { color: var(--down); } .neutral { color: var(--neutral); }
112 +.skip-link { position: absolute; left: 12px; top: -60px; z-index: 100; background: var(--accent); color: var(--accent-ink); padding: 10px 14px; border-radius: var(--r-sm); font-weight: 600; }
59 113 .skip-link:focus { top: 12px; text-decoration: none; }
60 114
61 −/* ---- shell ---- */
115 +/* ---- shell --------------------------------------------------------------------------------- */
62 116 .shell { min-height: 100dvh; display: flex; flex-direction: column; }
63 −.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 20px; padding: 0 20px; height: var(--topbar-h); background: var(--bg); 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; }
117 +.topbar { position: sticky; top: 0; z-index: var(--z-sticky); display: flex; align-items: center; gap: 16px; 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); }
118 +.brand { display: inline-flex; align-items: center; gap: 9px; min-height: var(--tap); color: var(--fg); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; }
65 119 .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; }
120 +.brand-mark { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: var(--accent); color: var(--accent-ink); font-family: var(--mono); font-size: var(--fs-0); font-weight: 700; }
67 121 .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; }
122 +.topnav a { display: inline-flex; align-items: center; min-height: var(--tap); color: var(--fg-1); padding: 0 12px; border-radius: var(--r-sm); font-weight: 500; font-size: var(--fs-2); }
69 123 .topnav a.active, .topnav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
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; }
124 +.topbar-right { display: flex; align-items: center; gap: 6px; margin-left: auto; }
125 +.search-trigger { display: inline-flex; align-items: center; gap: 10px; height: var(--tap); padding: 0 10px 0 12px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); cursor: pointer; font-size: var(--fs-1); min-width: 190px; }
72 126 .search-trigger:hover { border-color: var(--line-3); color: var(--fg-1); }
73 127 .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; }
128 +.icon-btn { display: inline-grid; place-items: center; width: var(--tap); height: var(--tap); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; color: var(--fg-1); cursor: pointer; }
75 129 .icon-btn:hover { background: var(--bg-2); color: var(--fg); }
76 130 .icon-btn svg { width: 18px; height: 18px; }
131 +.icon-btn[disabled] { opacity: .5; cursor: not-allowed; }
77 132 .menu-btn { display: none; }
78 133
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; }
134 +/* ---- buttons ------------------------------------------------------------------------------- */
135 +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: var(--tap); padding: 0 14px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; font: inherit; font-weight: 500; font-size: var(--fs-2); line-height: 1.2; white-space: nowrap; text-decoration: none; transition: border-color .12s, background .12s, transform .06s; }
80 136 .btn:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); }
81 137 .btn:active { transform: translateY(1px); }
138 +.btn svg { width: 16px; height: 16px; flex: none; }
82 139 .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 650; }
83 −.btn-primary:hover { background: var(--accent); background: color-mix(in srgb, var(--accent) 88%, white); border-color: color-mix(in srgb, var(--accent) 88%, white); }
140 +.btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--accent-ink); }
141 +.btn-secondary { background: var(--bg-1); }
84 142 .btn-ghost { background: transparent; border-color: transparent; color: var(--fg-1); }
85 143 .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; }
144 +.btn-danger { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
145 +.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
146 +.btn-lg { min-height: 44px; padding: 0 20px; font-size: var(--fs-3); border-radius: var(--r-md); }
147 +.btn-sm { min-height: var(--tap); padding: 0 10px; font-size: var(--fs-1); border-radius: var(--r-sm); }
148 +.btn[disabled], .btn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
149 +.btn-block { width: 100%; }
89 150
90 −.page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; }
151 +/* ---- page + footer ------------------------------------------------------------------------- */
152 +.page { width: 100%; max-width: var(--max); margin: 0 auto; padding: 40px 20px 80px; flex: 1; min-width: 0; }
91 153 .page.narrow { max-width: 780px; }
92 154 .page-loading { flex: 1; min-height: 40vh; }
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); }
155 +.footer { border-top: 1px solid var(--line); padding: 36px 20px 40px; color: var(--fg-1); font-size: var(--fs-1); }
156 +.footer-inner { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 1.5fr repeat(4, 1fr); gap: 24px; }
157 +.footer h4 { font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin: 0 0 10px; }
158 +.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
159 +.footer a { display: inline-flex; align-items: center; min-height: var(--tap); color: var(--fg-1); }
98 160 .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; }
161 +.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: var(--fs-0); }
100 162
101 −.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 20px; }
163 +/* ---- layout primitives --------------------------------------------------------------------- */
164 +.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 20px; min-width: 0; }
165 +.card-sm { padding: 14px 16px; border-radius: var(--r-md); }
102 166 .grid { display: grid; gap: 16px; }
103 167 .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
104 168 .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
105 169 .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; }
170 +.stack { display: flex; flex-direction: column; gap: var(--sp-3); }
171 +.row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
108 172
109 −table { border-collapse: collapse; width: 100%; font-size: 14px; }
173 +/* ---- tables -------------------------------------------------------------------------------- */
174 +table { border-collapse: collapse; width: 100%; font-size: var(--fs-2); }
110 175 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: var(--bg-2); 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; white-space: nowrap; overflow-wrap: normal; }
176 +th { color: var(--fg-1); font-weight: 600; font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .04em; }
177 +tbody tr:hover { background: color-mix(in srgb, var(--bg-2) 60%, transparent); }
178 +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r-md); max-width: 100%; -webkit-overflow-scrolling: touch; }
179 +.table-wrap th { background: var(--bg-1); position: sticky; top: 0; white-space: nowrap; z-index: 1; }
116 180 .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; }
181 +.table-wrap.dense th, .table-wrap.dense td { padding: 6px 10px; font-size: var(--fs-1); }
182 +.table-wrap td.num, .table-wrap th.num { text-align: right; }
183 +.table-wrap.sticky-first th:first-child, .table-wrap.sticky-first td:first-child { position: sticky; left: 0; background: var(--bg-1); z-index: 2; }
184 +.table-wrap.sticky-first th:first-child { z-index: 3; }
185 +
186 +/* ---- forms --------------------------------------------------------------------------------- */
187 +input, select, textarea { font: inherit; color: var(--fg); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-sm); padding: 0 10px; min-height: var(--tap); max-width: 100%; }
188 +textarea { padding: 8px 10px; line-height: 1.5; }
189 +input::placeholder, textarea::placeholder { color: var(--fg-3); }
190 +input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"], input.invalid, select.invalid { border-color: var(--danger); }
191 +input[type="checkbox"], input[type="radio"] { min-height: 0; width: 18px; height: 18px; accent-color: var(--accent); }
118 192
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; }
120 −input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); border-color: var(--accent); }
121 −::selection { background: var(--accent); 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; } }
193 +/* ---- dashboard-oriented utilities ---------------------------------------------------------- */
194 +.tabs-nav { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
195 +.tabs-nav::-webkit-scrollbar { display: none; }
196 +.tabs-nav a, .tabs-nav button { position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 12px; border: 0; background: transparent; color: var(--fg-2); font-weight: 500; font-size: var(--fs-2); white-space: nowrap; cursor: pointer; border-radius: var(--r-sm) var(--r-sm) 0 0; }
197 +.tabs-nav a:hover, .tabs-nav button:hover { color: var(--fg); text-decoration: none; }
198 +.tabs-nav a.active, .tabs-nav button.active, .tabs-nav [aria-selected="true"], .tabs-nav [aria-current="page"] { color: var(--fg); }
199 +.tabs-nav a.active::after, .tabs-nav button.active::after, .tabs-nav [aria-selected="true"]::after, .tabs-nav [aria-current="page"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; }
200 +.stat { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); min-width: 0; }
201 +.stat-label { color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; font-size: var(--fs-0); font-weight: 600; }
202 +.stat-value { font-size: var(--fs-6); font-weight: 650; color: var(--fg); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1.1; overflow-wrap: anywhere; }
203 +.stat-hint { color: var(--fg-2); font-size: var(--fs-0); }
204 +.stat-delta { font-size: var(--fs-1); font-weight: 600; }
205 +.skeleton { display: inline-block; width: 90px; height: 1em; border-radius: 6px; background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); background-size: 200% 100%; animation: shimmer 1.2s infinite; vertical-align: middle; color: transparent; }
206 +.skeleton-block { display: block; width: 100%; }
207 +.skeleton-text { height: .9em; margin: .35em 0; }
208 +@keyframes shimmer { to { background-position: -200% 0; } }
209 +.empty-state, .error-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; padding: 32px 20px; border: 1px dashed var(--line-2); border-radius: var(--r-md); color: var(--fg-1); }
210 +.empty-state svg, .error-state svg { width: 28px; height: 28px; color: var(--fg-3); }
211 +.empty-state h3, .error-state h3 { margin: 4px 0 0; font-size: var(--fs-4); }
212 +.empty-state p, .error-state p { margin: 0; max-width: 46ch; color: var(--fg-2); font-size: var(--fs-2); }
213 +.empty-state .actions, .error-state .actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; margin-top: 6px; }
214 +.error-state { border-style: solid; border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: color-mix(in srgb, var(--danger) 5%, var(--bg-1)); }
215 +.error-state svg { color: var(--danger); }
216 +.error-state.is-429 { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); background: color-mix(in srgb, var(--warn) 6%, var(--bg-1)); }
217 +.error-state.is-429 svg { color: var(--warn); }
218 +.error-state .countdown { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: var(--fs-6); font-weight: 600; color: var(--fg); }
123 219
124 −/* ---- mobile drawer ---- */
125 −.drawer-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, .62); }
126 −.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 61; width: min(320px, 86vw); background: var(--bg-1); background-color: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; 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; }
220 +/* ---- mobile drawer ------------------------------------------------------------------------- */
221 +.drawer-backdrop { position: fixed; inset: 0; z-index: var(--z-drawer); background: rgba(0, 0, 0, .62); }
222 +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); }
223 +.drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; min-height: var(--tap-mobile); }
224 +.drawer a.navlink { display: flex; align-items: center; min-height: var(--tap-mobile); padding: 0 12px; border-radius: var(--r-md); color: var(--fg-1); font-weight: 500; font-size: var(--fs-3); }
129 225 .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; }
226 +.drawer-foot { margin-top: auto; display: grid; gap: 8px; padding-top: 12px; }
227 +.drawer-foot .btn { min-height: var(--tap-mobile); }
131 228
132 −@media (max-width: 900px) {
229 +/* ---- responsive ---------------------------------------------------------------------------- */
230 +@media (max-width: 1200px) {
231 + .footer-inner { grid-template-columns: 1.4fr repeat(2, 1fr); }
232 +}
233 +@media (max-width: 960px) {
234 + :root { --tap: var(--tap-mobile); }
133 235 .topnav { display: none; }
134 236 .menu-btn { display: inline-grid; }
135 − .search-trigger { min-width: 0; padding: 0 9px; }
237 + .search-trigger { min-width: 0; width: var(--tap-mobile); padding: 0; justify-content: center; }
136 238 .search-trigger span, .search-trigger kbd { display: none; }
137 − .topbar { gap: 12px; padding: 0 14px; }
239 + .topbar { gap: 8px; padding: 0 12px; }
240 + .topbar-right { gap: 2px; }
138 241 .footer-inner { grid-template-columns: 1fr 1fr; }
139 242 .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; }
140 243 .page { padding: 28px 16px 64px; }
244 + .btn-sm { min-height: var(--tap-mobile); }
141 245 }
142 −@media (max-width: 600px) {
246 +@media (max-width: 640px) {
143 247 .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
144 − .footer-inner { grid-template-columns: 1fr; }
248 + .footer-inner { grid-template-columns: 1fr; gap: 18px; }
145 249 .topbar-right .btn-signin { display: none; }
250 + h1 { font-size: var(--fs-7); }
251 + h2 { font-size: var(--fs-6); }
146 252 }
modified hfmarketdata/web/src/components/Badge.jsx +10 −4
@@ -1,8 +1,8 @@
1 −// <Badge> generic pill · <MethodBadge method="GET"> coloured HTTP verb · <StatusBadge code={404}>.
1 +// <Badge tone> generic pill · <MethodBadge method="GET"> HTTP verb · <StatusBadge code={404}> · <TierBadge tier="free">.
2 2 import React from 'react'
3 3
4 −export default function Badge({ tone = 'neutral', children, className = '', title }) {
5 − return <span className={`badge badge-${tone} ${className}`} title={title}>{children}</span>
4 +export default function Badge({ tone = 'neutral', children, className = '', title, dot = false }) {
5 + return <span className={`badge badge-${tone} ${className}`} title={title}>{dot && <span className="badge-dot" aria-hidden="true" />}{children}</span>
6 6 }
7 7
8 8 export function MethodBadge({ method = 'GET', className = '' }) {
@@ -12,6 +12,12 @@ export function MethodBadge({ method = 'GET', className = '' }) {
12 12
13 13 export function StatusBadge({ code }) {
14 14 const n = Number(code)
15 − const tone = n < 300 ? 'success' : n < 400 ? 'info' : n < 500 ? 'warn' : 'danger'
15 + const tone = n < 300 ? 'success' : n < 400 ? 'info' : n === 429 ? 'warn' : n < 500 ? 'warn' : 'danger'
16 16 return <Badge tone={tone} className="mono">{code}</Badge>
17 17 }
18 +
19 +const TIERS = { keyless: ['Keyless', 'neutral'], free: ['Free', 'success'], high_usage: ['High usage', 'info'], unlimited: ['Unlimited', 'info'], admin: ['Admin', 'warn'] }
20 +export function TierBadge({ tier = 'keyless' }) {
21 + const [label, tone] = TIERS[tier] || [tier, 'neutral']
22 + return <Badge tone={tone} className="badge-tier">{label}</Badge>
23 +}
added hfmarketdata/web/src/components/BrandLogos.jsx +27 −0
@@ -0,0 +1,27 @@
1 +// Inline brand marks used by the homepage and the Integrations pages — one source, 40×40 viewBox, no requests.
2 +import React from 'react'
3 +
4 +const wrap = (children, bg, stroke) => (
5 + <svg viewBox="0 0 40 40" aria-hidden="true" className="brand-logo">
6 + <rect width="40" height="40" rx="10" fill={bg} stroke={stroke} />
7 + {children}
8 + </svg>
9 +)
10 +
11 +export const LOGOS = {
12 + claude: () => wrap(<path d="M12 28 20 12l8 16h-4l-4-8-4 8z" fill="#fff" />, '#d97757'),
13 + cursor: () => wrap(<><path d="M20 8l11 6.5v11L20 32 9 25.5v-11z" fill="none" stroke="#fff" strokeWidth="2" /><path d="M20 8v24M9 14.5l11 6.5 11-6.5" stroke="#fff" strokeWidth="1.5" fill="none" /></>, '#111'),
14 + codex: () => wrap(<><circle cx="20" cy="20" r="9" fill="none" stroke="#fff" strokeWidth="2.2" /><circle cx="20" cy="20" r="3" fill="#fff" /></>, '#0a0c10', '#313847'),
15 + mcp: () => wrap(<path d="M12 26l8-14 8 14M15 22h10" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />, 'var(--bg-2)', 'var(--line-2)'),
16 + skills: () => wrap(<><path d="M13 12h14v16H13z" fill="none" stroke="var(--accent-2)" strokeWidth="2" strokeLinejoin="round" /><path d="M16 17h8M16 21h8M16 25h5" stroke="var(--accent-2)" strokeWidth="2" strokeLinecap="round" /></>, 'var(--bg-2)', 'var(--line-2)'),
17 + python: () => wrap(<><path d="M19.5 9c-4 0-5.5 1.6-5.5 4v3h6v1h-8.5C9 17 8 18.6 8 21s1 4 3.5 4H14v-3c0-2.4 1.6-4 4-4h5c2 0 3.5-1.5 3.5-3.5V13c0-2.4-1.6-4-4-4h-3z" fill="#3b82f6" /><path d="M20.5 31c4 0 5.5-1.6 5.5-4v-3h-6v-1h8.5c2.5 0 3.5-1.6 3.5-4s-1-4-3.5-4H26v3c0 2.4-1.6 4-4 4h-5c-2 0-3.5 1.5-3.5 3.5V27c0 2.4 1.6 4 4 4h3z" fill="#fbbf24" /></>, 'var(--bg-2)', 'var(--line-2)'),
18 + r: () => wrap(<><ellipse cx="20" cy="18" rx="12" ry="8" fill="none" stroke="#9ca3af" strokeWidth="2.5" /><path d="M15 30V16h6a4 4 0 0 1 0 8h-4m4 0 4 6" fill="none" stroke="#2563eb" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" /></>, 'var(--bg-2)', 'var(--line-2)'),
19 + javascript: () => wrap(<><path d="M22 23.5v-8h2.6v7.9c0 2.6-1.4 3.9-3.8 3.9-1.8 0-3-.8-3.6-2l2.1-1.2c.3.6.7.9 1.4.9.8 0 1.3-.4 1.3-1.5zm-9.6 2.7-2.1 1.2c.6 1.5 2 2.7 4.3 2.7 2.4 0 4-1.3 4-3.6 0-2.1-1.2-3-3.4-3.9l-.6-.3c-1.1-.5-1.6-.8-1.6-1.5s.5-1.1 1.2-1.1c.7 0 1.2.3 1.6 1.1l2-1.3c-.8-1.5-2-2-3.6-2-2.3 0-3.7 1.4-3.7 3.3 0 2 1.2 3 3 3.8l.6.3c1.2.5 1.9.8 1.9 1.7 0 .7-.7 1.2-1.7 1.2-1.2 0-1.9-.6-2.4-1.5z" fill="#111" /></>, '#f7df1e'),
20 +}
21 +
22 +/** <BrandLogo name="claude|cursor|codex|mcp|skills|python|r|javascript" size={40} /> */
23 +export default function BrandLogo({ name, size = 40, className = '' }) {
24 + const L = LOGOS[name]
25 + if (!L) return null
26 + return <span className={`brand-logo-wrap ${className}`} style={{ width: size, height: size }}>{L()}</span>
27 +}
added hfmarketdata/web/src/components/Button.jsx +27 −0
@@ -0,0 +1,27 @@
1 +// <Button> / <IconButton> — render a <button>, or a router <Link> / <a> when `to` / `href` is given.
2 +import React from 'react'
3 +import { Link } from 'react-router-dom'
4 +
5 +/**
6 + * @param {'primary'|'secondary'|'ghost'|'danger'} [variant='secondary']
7 + * @param {'sm'|'md'|'lg'} [size='md']
8 + * @param {string} [to] router link
9 + * @param {string} [href] external / mailto link
10 + * @param {boolean} [block] full width
11 + * @param {boolean} [loading] disables and shows a spinner
12 + */
13 +export default function Button({ variant = 'secondary', size = 'md', to, href, block = false, loading = false, disabled, className = '', children, type = 'button', ...rest }) {
14 + const cls = `btn ${variant !== 'secondary' ? `btn-${variant}` : ''} ${size !== 'md' ? `btn-${size}` : ''} ${block ? 'btn-block' : ''} ${loading ? 'is-loading' : ''} ${className}`.replace(/\s+/g, ' ').trim()
15 + const inner = <>{loading && <span className="spinner" aria-hidden="true" />}{children}</>
16 + if (to && !disabled) return <Link to={to} className={cls} {...rest}>{inner}</Link>
17 + if (href && !disabled) return <a href={href} className={cls} {...rest}>{inner}</a>
18 + return <button type={type} className={cls} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>{inner}</button>
19 +}
20 +
21 +/** Square icon-only button (36 px desktop, 44 px mobile). `label` is required for accessibility. */
22 +export function IconButton({ label, to, href, className = '', children, type = 'button', ...rest }) {
23 + const cls = `icon-btn ${className}`.trim()
24 + if (to) return <Link to={to} className={cls} aria-label={label} title={label} {...rest}>{children}</Link>
25 + if (href) return <a href={href} className={cls} aria-label={label} title={label} {...rest}>{children}</a>
26 + return <button type={type} className={cls} aria-label={label} title={label} {...rest}>{children}</button>
27 +}
modified hfmarketdata/web/src/components/Callout.jsx +10 −6
@@ -1,18 +1,22 @@
1 −// <Callout type="info|tip|warning|danger" title?> — highlighted aside.
1 +// <Callout type="info|tip|success|warning|danger" title? action?> — highlighted aside.
2 +// `kind` is accepted as an alias of `type` (info | warn | danger | success) for the former PgCallout API.
2 3 import React from 'react'
3 −import { BoltIcon, InfoIcon, WarnIcon } from './Icons.jsx'
4 +import { BoltIcon, CheckCircleIcon, InfoIcon, WarnIcon } from './Icons.jsx'
4 5
5 −const ICONS = { info: InfoIcon, tip: BoltIcon, warning: WarnIcon, danger: WarnIcon }
6 +const ICONS = { info: InfoIcon, tip: BoltIcon, success: CheckCircleIcon, warning: WarnIcon, danger: WarnIcon }
7 +const KIND = { warn: 'warning', info: 'info', danger: 'danger', success: 'success', tip: 'tip', warning: 'warning' }
6 8
7 −export default function Callout({ type = 'info', title, children, className = '' }) {
8 − const Icon = ICONS[type] || InfoIcon
9 +export default function Callout({ type, kind, title, children, action = null, className = '', compact = false, ...rest }) {
10 + const t = KIND[type || kind] || 'info'
11 + const Icon = ICONS[t]
9 12 return (
10 − <aside className={`callout callout-${type} ${className}`} role={type === 'danger' || type === 'warning' ? 'alert' : 'note'}>
13 + <aside className={`callout callout-${t} ${compact ? 'callout-compact' : ''} ${className}`} role={t === 'danger' || t === 'warning' ? 'alert' : 'status'} {...rest}>
11 14 <span className="callout-icon"><Icon /></span>
12 15 <div className="callout-body">
13 16 {title && <div className="callout-title">{title}</div>}
14 17 <div>{children}</div>
15 18 </div>
19 + {action && <div className="callout-action">{action}</div>}
16 20 </aside>
17 21 )
18 22 }
added hfmarketdata/web/src/components/Card.jsx +9 −0
@@ -0,0 +1,9 @@
1 +// <Card> — surface with border; `as` for semantic element, `to` renders a router Link card.
2 +import React from 'react'
3 +import { Link } from 'react-router-dom'
4 +
5 +export default function Card({ as: Tag = 'div', to, size = 'md', className = '', children, ...rest }) {
6 + const cls = `card ${size === 'sm' ? 'card-sm' : ''} ${to ? 'card-link' : ''} ${className}`.replace(/\s+/g, ' ').trim()
7 + if (to) return <Link to={to} className={cls} {...rest}>{children}</Link>
8 + return <Tag className={cls} {...rest}>{children}</Tag>
9 +}
deleted hfmarketdata/web/src/components/Code.jsx +0 −110
@@ -1,110 +0,0 @@
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/CodeBlock.jsx +58 −0
@@ -0,0 +1,58 @@
1 +// <CodeBlock> — the one code block of the site: title, copy, wrap toggle, optional line numbers, theme-driven
2 +// colours. Prism is a lazy chunk: the block renders as a plain <pre> immediately and colours in when it loads.
3 +import React, { Suspense, lazy, useState } from 'react'
4 +import CopyButton from './CopyButton.jsx'
5 +import { WrapIcon } from './Icons.jsx'
6 +
7 +const Highlighter = lazy(() => import('./CodeHighlight.jsx'))
8 +const WRAP_KEY = 'hfmd.codewrap'
9 +const readWrap = () => { try { return localStorage.getItem(WRAP_KEY) === '1' } catch { return false } }
10 +
11 +function Plain({ code, lineNumbers }) {
12 + return (
13 + <code>
14 + {code.split('\n').map((line, i) => (
15 + <span key={i} className="code-line">{lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>}{line}{'\n'}</span>
16 + ))}
17 + </code>
18 + )
19 +}
20 +
21 +/**
22 + * @param {string} code source text (or children)
23 + * @param {string} [language='text'] bash | python | javascript | r | json | http | toml | text …
24 + * @param {string} [title] header label (file name, "Response · 200"…); header hidden when no title/copy/wrap
25 + * @param {boolean} [copy=true]
26 + * @param {boolean} [wrapToggle=true] show the wrap-lines toggle (persisted in localStorage `hfmd.codewrap`)
27 + * @param {boolean} [wrap] force initial wrap state
28 + * @param {number} [maxHeight] px — scrolls inside when longer
29 + * @param {boolean} [lineNumbers=false]
30 + * @param {ReactNode} [actions] extra header actions
31 + * @param {ReactNode} [footer] caption under the code (source note…)
32 + */
33 +export default function CodeBlock({ code = '', language = 'text', title, copy = true, wrapToggle = true, wrap, maxHeight, lineNumbers = false, actions, footer, className = '', children, ...rest }) {
34 + const src = (code || children || '').toString().replace(/\n$/, '')
35 + const [wrapped, setWrapped] = useState(() => (wrap !== undefined ? wrap : readWrap()))
36 + const toggleWrap = () => setWrapped(w => { try { localStorage.setItem(WRAP_KEY, w ? '0' : '1') } catch { /* private mode */ } return !w })
37 + const head = title || copy || wrapToggle || actions
38 + return (
39 + <div className={`code ${wrapped ? 'code-wrap' : ''} ${className}`} data-language={language} {...rest}>
40 + {head && (
41 + <div className="code-head">
42 + <span className="code-title">{title || language}</span>
43 + <span className="code-actions">
44 + {actions}
45 + {wrapToggle && <button type="button" className={`copy-btn copy-sm copy-ghost copy-icon-only ${wrapped ? 'copied' : ''}`} onClick={toggleWrap} aria-pressed={wrapped} aria-label={wrapped ? 'Disable line wrapping' : 'Wrap long lines'} title="Wrap lines"><WrapIcon /></button>}
46 + {copy && <CopyButton text={src} />}
47 + </span>
48 + </div>
49 + )}
50 + <pre className="code-pre" style={maxHeight ? { maxHeight } : undefined} tabIndex={0}>
51 + <Suspense fallback={<Plain code={src} lineNumbers={lineNumbers} />}>
52 + <Highlighter code={src} language={language} lineNumbers={lineNumbers} />
53 + </Suspense>
54 + </pre>
55 + {footer && <div className="code-foot">{footer}</div>}
56 + </div>
57 + )
58 +}
added hfmarketdata/web/src/components/CodeHighlight.jsx +86 −0
@@ -0,0 +1,86 @@
1 +// Prism highlighter (lazy chunk) — colours are CSS variables (--code-*) so every theme works with one stylesheet.
2 +// prism-react-renderer ships no bash/R grammar; minimal ones cover API snippets.
3 +import React from 'react'
4 +import { Highlight, Prism } from 'prism-react-renderer'
5 +
6 +if (!Prism.languages.bash) {
7 + Prism.languages.bash = {
8 + comment: /#.*/,
9 + string: [{ pattern: /"(?:\\[\s\S]|\$\([^)]*\)|[^"\\])*"/, greedy: true, inside: { variable: /\$\{?[\w.]+\}?/ } }, { pattern: /'[^']*'/, greedy: true }],
10 + variable: /\$\{?[\w.]+\}?/,
11 + flag: { pattern: /(^|\s)--?[\w-]+/, lookbehind: true, alias: 'attr-name' },
12 + function: /\b(?:curl|jq|python3?|pip|npm|npx|node|export|echo|cat|head|wget|Rscript|unzip|claude|codex|websocat)\b/,
13 + operator: /\\$|\||&&|>/m,
14 + punctuation: /[{}()]/,
15 + }
16 + Prism.languages.sh = Prism.languages.shell = Prism.languages.bash
17 +}
18 +if (!Prism.languages.r) {
19 + Prism.languages.r = {
20 + comment: /#.*/,
21 + string: { pattern: /(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/, greedy: true },
22 + 'percent-operator': { pattern: /%[^%\s]*%/, alias: 'operator' },
23 + boolean: /\b(?:TRUE|FALSE|T|F)\b/,
24 + keyword: /\b(?:NA|NULL|Inf|NaN|if|else|for|while|repeat|function|return|in|next|break|library|require)\b/,
25 + number: /\b(?:0x[\dA-Fa-f]+|\d*\.?\d+(?:e[+-]?\d+)?)[iL]?\b/i,
26 + function: /\b[a-zA-Z_.][\w.]*(?=\s*\()/,
27 + operator: /->?>?|<(?:=|<?-)?|[>=!]=?|::?|&&?|\|\|?|[+*\/^$@~]/,
28 + punctuation: /[(){}\[\],;]/,
29 + }
30 +}
31 +if (!Prism.languages.toml) {
32 + Prism.languages.toml = {
33 + comment: /#.*/,
34 + table: { pattern: /^\s*\[[^\]]+\]/m, alias: 'keyword' },
35 + string: { pattern: /"(?:\\.|[^"\\])*"|'[^']*'/, greedy: true },
36 + key: { pattern: /^\s*[\w.-]+(?=\s*=)/m, alias: 'attr-name' },
37 + number: /\b\d[\d_]*(?:\.\d+)?\b/,
38 + boolean: /\b(?:true|false)\b/,
39 + punctuation: /[=[\]{},]/,
40 + }
41 +}
42 +if (!Prism.languages.http) {
43 + Prism.languages.http = {
44 + 'request-line': { pattern: /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s.+/m, alias: 'keyword' },
45 + 'status-line': { pattern: /^HTTP\/[\d.]+\s+\d{3}.*/m, alias: 'keyword' },
46 + 'header-name': { pattern: /^[\w-]+(?=:)/m, alias: 'attr-name' },
47 + number: /\b\d+\b/,
48 + punctuation: /:/,
49 + }
50 +}
51 +
52 +const THEME = {
53 + plain: { color: 'var(--code-fg)', backgroundColor: 'transparent' },
54 + styles: [
55 + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--code-comment)', fontStyle: 'italic' } },
56 + { types: ['punctuation', 'operator'], style: { color: 'var(--code-punct)' } },
57 + { types: ['property', 'attr-name', 'variable', 'flag', 'key', 'header-name'], style: { color: 'var(--code-key)' } },
58 + { types: ['string', 'char', 'url', 'inserted'], style: { color: 'var(--code-string)' } },
59 + { types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--code-number)' } },
60 + { types: ['keyword', 'builtin', 'important', 'atrule', 'tag', 'table'], style: { color: 'var(--code-keyword)' } },
61 + { types: ['function', 'class-name', 'percent-operator'], style: { color: 'var(--code-func)' } },
62 + { types: ['deleted'], style: { color: 'var(--danger)' } },
63 + ],
64 +}
65 +
66 +export const LANG_ALIASES = { curl: 'bash', sh: 'bash', shell: 'bash', js: 'javascript', ts: 'typescript', py: 'python', text: 'text', txt: 'text' }
67 +
68 +export default function CodeHighlight({ code, language, lineNumbers }) {
69 + const lang = LANG_ALIASES[language] || language
70 + const known = Prism.languages[lang] ? lang : 'text'
71 + return (
72 + <Highlight code={code} language={known} theme={THEME}>
73 + {({ tokens, getLineProps, getTokenProps }) => (
74 + <code>
75 + {tokens.map((line, i) => (
76 + <span key={i} {...getLineProps({ line })} className="code-line">
77 + {lineNumbers && <span className="code-ln" aria-hidden="true">{i + 1}</span>}
78 + {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)}
79 + {'\n'}
80 + </span>
81 + ))}
82 + </code>
83 + )}
84 + </Highlight>
85 + )
86 +}
modified hfmarketdata/web/src/components/CodeTabs.jsx +3 −3
@@ -1,10 +1,10 @@
1 1 // <CodeTabs> — one code block per language, driven by the global persisted language (LangSelector).
2 2 // snippets: { curl, python, javascript, r } (any subset) or an array [{ id, label, language, code }].
3 3 import React from 'react'
4 −import Code from './Code.jsx'
4 +import CodeBlock from './CodeBlock.jsx'
5 5 import LangSelector, { LANGS, useLang } from './LangSelector.jsx'
6 6
7 −export default function CodeTabs({ snippets, title, maxHeight, className = '' }) {
7 +export default function CodeTabs({ snippets, title, maxHeight, className = '', footer, lineNumbers = false }) {
8 8 const [lang, setLang] = useLang()
9 9 const items = Array.isArray(snippets)
10 10 ? snippets
@@ -20,7 +20,7 @@ export default function CodeTabs({ snippets, title, maxHeight, className = '' })
20 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 21 : <LangSelector value={value} onChange={setLang} />}
22 22 </div>
23 − <Code code={active.code} language={active.language} maxHeight={maxHeight} title={null} />
23 + <CodeBlock code={active.code} language={active.language} maxHeight={maxHeight} title={active.language === 'bash' ? 'shell' : active.language} lineNumbers={lineNumbers} footer={footer} />
24 24 </div>
25 25 )
26 26 }
added hfmarketdata/web/src/components/CopyButton.jsx +38 −0
@@ -0,0 +1,38 @@
1 +// useCopy() + <CopyButton> — the single clipboard helper of the site (code blocks, URLs, keys, prompts).
2 +import React, { useCallback, useEffect, useState } from 'react'
3 +import { CheckIcon, CopyIcon } from './Icons.jsx'
4 +
5 +export function useCopy(timeout = 1600) {
6 + const [copied, setCopied] = useState(false)
7 + const copy = useCallback(async text => {
8 + const value = typeof text === 'function' ? text() : text
9 + try {
10 + await navigator.clipboard.writeText(value)
11 + } catch {
12 + const ta = document.createElement('textarea'); ta.value = value; ta.setAttribute('readonly', '')
13 + ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select()
14 + try { document.execCommand('copy') } catch { /* blocked */ } finally { document.body.removeChild(ta) }
15 + }
16 + setCopied(true)
17 + return true
18 + }, [])
19 + useEffect(() => { if (!copied) return undefined; const t = setTimeout(() => setCopied(false), timeout); return () => clearTimeout(t) }, [copied, timeout])
20 + return { copied, copy }
21 +}
22 +
23 +/**
24 + * @param {string|() => string} text what to copy
25 + * @param {string} [label='Copy'] visible label (also the aria-label when `iconOnly`)
26 + * @param {'sm'|'md'} [size='sm']
27 + * @param {boolean} [iconOnly=false]
28 + * @param {'ghost'|'secondary'} [variant='ghost']
29 + */
30 +export default function CopyButton({ text, label = 'Copy', copiedLabel = 'Copied', size = 'sm', iconOnly = false, variant = 'ghost', className = '', ...rest }) {
31 + const { copied, copy } = useCopy()
32 + return (
33 + <button type="button" className={`copy-btn copy-${size} copy-${variant} ${copied ? 'copied' : ''} ${iconOnly ? 'copy-icon-only' : ''} ${className}`}
34 + onClick={() => copy(text)} aria-label={copied ? copiedLabel : label} title={label} aria-live="polite" {...rest}>
35 + {copied ? <CheckIcon /> : <CopyIcon />}{!iconOnly && <span>{copied ? copiedLabel : label}</span>}
36 + </button>
37 + )
38 +}
added hfmarketdata/web/src/components/Field.jsx +40 −0
@@ -0,0 +1,40 @@
1 +// Form primitives: <Field label hint error required> wraps one control and wires id / aria-describedby /
2 +// aria-invalid; <Input>, <Select>, <Textarea> are thin styled wrappers that accept `invalid`.
3 +import React, { cloneElement, isValidElement, useId } from 'react'
4 +
5 +export function Field({ label, hint, error, required = false, id, className = '', children, inline = false, labelExtra }) {
6 + const auto = useId()
7 + const fid = id || `f-${auto}`
8 + const hintId = hint ? `${fid}-hint` : undefined
9 + const errId = error ? `${fid}-err` : undefined
10 + const described = [hintId, errId].filter(Boolean).join(' ') || undefined
11 + const control = isValidElement(children)
12 + ? cloneElement(children, { id: fid, 'aria-describedby': [children.props['aria-describedby'], described].filter(Boolean).join(' ') || undefined, 'aria-invalid': error ? true : children.props['aria-invalid'], 'aria-required': required || undefined })
13 + : children
14 + return (
15 + <div className={`field ${inline ? 'field-inline' : ''} ${error ? 'field-error' : ''} ${className}`}>
16 + {label && <label htmlFor={fid} className="field-label">{label}{required && <span className="field-req" aria-hidden="true">*</span>}{labelExtra && <span className="field-label-extra">{labelExtra}</span>}</label>}
17 + {control}
18 + {hint && <small id={hintId} className="field-hint">{hint}</small>}
19 + {error && <small id={errId} className="field-err" role="alert">{error}</small>}
20 + </div>
21 + )
22 +}
23 +
24 +export const Input = React.forwardRef(function Input({ invalid, className = '', mono = false, ...props }, ref) {
25 + return <input ref={ref} className={`input ${mono ? 'mono' : ''} ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props} />
26 +})
27 +
28 +export const Select = React.forwardRef(function Select({ invalid, className = '', options, children, ...props }, ref) {
29 + return (
30 + <select ref={ref} className={`select ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props}>
31 + {options ? options.map(o => (typeof o === 'string' ? <option key={o} value={o}>{o === '' ? '(default)' : o}</option> : <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)) : children}
32 + </select>
33 + )
34 +})
35 +
36 +export const Textarea = React.forwardRef(function Textarea({ invalid, className = '', ...props }, ref) {
37 + return <textarea ref={ref} className={`textarea ${invalid ? 'invalid' : ''} ${className}`.trim()} aria-invalid={invalid || props['aria-invalid'] || undefined} {...props} />
38 +})
39 +
40 +export default Field
modified hfmarketdata/web/src/components/Icons.jsx +11 −0
@@ -28,3 +28,14 @@ export const TerminalIcon = p => <I {...p}><path d="m5 7 5 5-5 5M12 17h7" /></I>
28 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 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 30 export const PulseIcon = p => <I {...p}><path d="M3 12h4l3-7 4 14 3-7h4" /></I>
31 +export const WrapIcon = p => <I {...p}><path d="M4 6h16M4 12h11a3 3 0 0 1 0 6h-3M4 18h5M14 15l-2 3 2 3" /></I>
32 +export const CheckCircleIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.5 2.5L16 9" /></I>
33 +export const InboxIcon = p => <I {...p}><path d="M4 13h4l2 3h4l2-3h4M5 5h14l2 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6z" /></I>
34 +export const WifiOffIcon = p => <I {...p}><path d="M2 8.5a16 16 0 0 1 6-3.2M12 12.5a8 8 0 0 1 4 1.5M8.5 16a4 4 0 0 1 3.5-1M12 20h.01M3 3l18 18M9.5 5.4A16 16 0 0 1 22 8.5M5.5 12.5a12 12 0 0 1 3-1.7" /></I>
35 +export const ClockIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></I>
36 +export const ChartIcon = p => <I {...p}><path d="M4 19V5M4 19h16M8 15v-4M12 15V8M16 15v-2M20 15V6" /></I>
37 +export const LinkIcon = p => <I {...p}><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></I>
38 +export const ShieldIcon = p => <I {...p}><path d="M12 3 4 6v6c0 4.5 3.4 8 8 9 4.6-1 8-4.5 8-9V6z" /><path d="m9 12 2 2 4-4" /></I>
39 +export const GlobeIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></I>
40 +export const FileIcon = p => <I {...p}><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /><path d="M14 3v5h5M9 13h6M9 17h6" /></I>
41 +export const CompassIcon = p => <I {...p}><circle cx="12" cy="12" r="9" /><path d="m15.5 8.5-2 5-5 2 2-5z" /></I>
deleted hfmarketdata/web/src/components/IntCopyBlock.jsx +0 −66
@@ -1,66 +0,0 @@
1 −// Copy-in-one-click code block for the Integrations pages. Local to this area (web-core will ship a
2 −// shared CodeBlock later); uses only theme.css tokens, no external deps, no CDN.
3 −import React, { useCallback, useEffect, useState } from 'react'
4 −
5 −export function useCopy(timeoutMs = 1600) {
6 − const [copied, setCopied] = useState(false)
7 − useEffect(() => {
8 − if (!copied) return undefined
9 − const t = setTimeout(() => setCopied(false), timeoutMs)
10 − return () => clearTimeout(t)
11 − }, [copied, timeoutMs])
12 − const copy = useCallback(async (text) => {
13 − try {
14 − if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text)
15 − else {
16 − const ta = document.createElement('textarea')
17 − ta.value = text
18 − ta.setAttribute('readonly', '')
19 − ta.style.position = 'fixed'
20 − ta.style.opacity = '0'
21 − document.body.appendChild(ta)
22 − ta.select()
23 − document.execCommand('copy')
24 − document.body.removeChild(ta)
25 − }
26 − setCopied(true)
27 − return true
28 − } catch {
29 − return false
30 − }
31 − }, [])
32 − return { copied, copy }
33 −}
34 −
35 −/**
36 − * <IntCopyBlock code="…" lang="bash" title="Terminal" />
37 − * - `title` renders a small header with the language/filename and the copy button.
38 − * - `lines` (array) may be used instead of `code`.
39 − * - `note` renders a muted caption under the block.
40 − */
41 −export default function IntCopyBlock({ code, lines, lang = 'text', title, note, compact = false }) {
42 − const text = code ?? (lines || []).join('\n')
43 − const { copied, copy } = useCopy()
44 − return (
45 − <figure className={`int-copy${compact ? ' compact' : ''}`}>
46 − <div className="int-copy-head">
47 − <span className="int-copy-title mono">{title || lang}</span>
48 − <button type="button" className={`int-copy-btn${copied ? ' ok' : ''}`} onClick={() => copy(text)} aria-live="polite" aria-label={copied ? 'Copied' : 'Copy to clipboard'}>
49 − {copied ? 'Copied' : 'Copy'}
50 − </button>
51 − </div>
52 − <pre className="int-copy-pre" data-lang={lang}><code>{text}</code></pre>
53 − {note ? <figcaption className="int-copy-note muted">{note}</figcaption> : null}
54 − </figure>
55 − )
56 −}
57 −
58 −/** Inline copyable chip, e.g. for a single command or an env var. */
59 −export function IntCopyChip({ text, label }) {
60 − const { copied, copy } = useCopy()
61 − return (
62 − <button type="button" className={`int-chip mono${copied ? ' ok' : ''}`} onClick={() => copy(text)} title="Copy">
63 − {label || text}<span className="int-chip-ico" aria-hidden="true">{copied ? ' ✓' : ' ⧉'}</span>
64 − </button>
65 − )
66 −}
modified hfmarketdata/web/src/components/Markdown.jsx +1 −1
@@ -3,7 +3,7 @@
3 3 // GFM tables, horizontal rules; inline code, **bold**, *italic*, [links](href), autolinks. Unsafe URL schemes are dropped.
4 4 import React from 'react'
5 5 import { Link } from 'react-router-dom'
6 −import Code from './Code.jsx'
6 +import Code from './CodeBlock.jsx'
7 7
8 8 const SAFE_HREF = /^(https?:|mailto:|\/|#|\.\.?\/)/i
9 9
modified hfmarketdata/web/src/components/PgCallout.jsx +4 −14
@@ -1,14 +1,4 @@
1 −import React from 'react'
2 −
3 −// kind: info | warn | danger | success
4 −export default function PgCallout({ kind = 'info', title, children, action = null, className = '' }) {
5 − return (
6 − <div role={kind === 'danger' ? 'alert' : 'status'} className={`pg-callout pg-callout-${kind} ${className}`}>
7 − <div className="pg-callout-body">
8 − {title && <strong className="pg-callout-title">{title}</strong>}
9 − <div>{children}</div>
10 − </div>
11 − {action && <div className="pg-callout-action">{action}</div>}
12 − </div>
13 − )
14 −}
1 +// Compatibility alias — the playground callout is now the shared <Callout>. Kept because dashboard/auth/admin pages
2 +// import this path (`kind` prop maps to `type`). New code should import Callout from './Callout.jsx'.
3 +import Callout from './Callout.jsx'
4 +export default Callout
deleted hfmarketdata/web/src/components/PgCodeBlock.jsx +0 −28
@@ -1,28 +0,0 @@
1 −// Syntax-highlighted code block with copy button (prism-react-renderer, bundled — no CDN).
2 −import React from 'react'
3 −import { Highlight, themes } from 'prism-react-renderer'
4 −import PgCopyButton from './PgCopyButton.jsx'
5 −
6 −const LANG = { curl: 'bash', bash: 'bash', python: 'python', javascript: 'javascript', js: 'javascript', json: 'json' }
7 −
8 −export default function PgCodeBlock({ code, language = 'bash', title, maxHeight = 360 }) {
9 − return (
10 − <div className="pg-code">
11 − <div className="pg-code-head">
12 − <span className="pg-code-title mono">{title || language}</span>
13 − <PgCopyButton text={code} small />
14 − </div>
15 − <Highlight code={code} language={LANG[language] || language} theme={themes.nightOwl}>
16 − {({ className, tokens, getLineProps, getTokenProps }) => (
17 − <pre className={`${className} pg-code-pre`} style={{ maxHeight }} tabIndex={0}>
18 − {tokens.map((line, i) => (
19 − <div key={i} {...getLineProps({ line })}>
20 − {line.map((token, k) => <span key={k} {...getTokenProps({ token })} />)}
21 − </div>
22 − ))}
23 − </pre>
24 − )}
25 − </Highlight>
26 − </div>
27 − )
28 −}
modified hfmarketdata/web/src/components/PgCopyButton.jsx +6 −12
@@ -1,14 +1,8 @@
1 −import React, { useEffect, useState } from 'react'
1 +// Compatibility alias — the playground copy button is now the shared <CopyButton>. Kept because dashboard/admin pages
2 +// import this path with `{ text, label, small }`. New code should import CopyButton from './CopyButton.jsx'.
3 +import React from 'react'
4 +import CopyButton from './CopyButton.jsx'
2 5
3 −export default function PgCopyButton({ text, label = 'Copy', className = '', small = false }) {
4 − const [done, setDone] = useState(false)
5 − useEffect(() => { if (!done) return; const t = setTimeout(() => setDone(false), 1500); return () => clearTimeout(t) }, [done])
6 − const copy = async () => {
7 − try { await navigator.clipboard.writeText(typeof text === 'function' ? text() : text); setDone(true) } catch { /* clipboard blocked */ }
8 − }
9 − return (
10 − <button type="button" className={`btn ${small ? 'btn-sm' : ''} ${className}`} onClick={copy} aria-live="polite">
11 − {done ? 'Copied' : label}
12 − </button>
13 − )
6 +export default function PgCopyButton({ text, label = 'Copy', small = false, className = '' }) {
7 + return <CopyButton text={text} label={label} size={small ? 'sm' : 'md'} variant="secondary" className={className} />
14 8 }
deleted hfmarketdata/web/src/components/PgTabs.jsx +0 −35
@@ -1,35 +0,0 @@
1 −// Minimal accessible tabs (roving tabindex). Swappable with web-core's shared `Tabs` later.
2 −import React, { useId } from 'react'
3 −
4 −export default function PgTabs({ tabs, value, onChange, label = 'Tabs', children, right = null, className = '' }) {
5 − const id = useId()
6 − const idx = Math.max(0, tabs.findIndex(t => t.id === value))
7 − const onKey = e => {
8 − if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
9 − e.preventDefault()
10 − let next = idx
11 − if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length
12 − if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length
13 − if (e.key === 'Home') next = 0
14 − if (e.key === 'End') next = tabs.length - 1
15 − onChange(tabs[next].id)
16 − document.getElementById(`${id}-tab-${tabs[next].id}`)?.focus()
17 − }
18 − return (
19 − <div className={`pg-tabs ${className}`}>
20 − <div className="pg-tablist-row">
21 − <div role="tablist" aria-label={label} className="pg-tablist" onKeyDown={onKey}>
22 − {tabs.map(t => (
23 − <button key={t.id} id={`${id}-tab-${t.id}`} role="tab" type="button" aria-selected={t.id === value}
24 − aria-controls={`${id}-panel`} tabIndex={t.id === value ? 0 : -1} disabled={t.disabled}
25 − className={`pg-tab ${t.id === value ? 'active' : ''}`} onClick={() => onChange(t.id)}>
26 − {t.label}{t.badge != null && <span className="pg-tab-badge">{t.badge}</span>}
27 − </button>
28 − ))}
29 − </div>
30 − {right}
31 − </div>
32 − <div role="tabpanel" id={`${id}-panel`} className="pg-tabpanel">{children}</div>
33 − </div>
34 − )
35 −}
modified hfmarketdata/web/src/components/SearchDialog.jsx +4 −4
@@ -15,8 +15,8 @@ async function getIndex() {
15 15 return indexMemo
16 16 }
17 17
18 −export default function SearchDialog({ open, onClose }) {
19 − const [q, setQ] = useState('')
18 +export default function SearchDialog({ open, onClose, initialQuery = '' }) {
19 + const [q, setQ] = useState(initialQuery)
20 20 const [items, setItems] = useState(indexMemo || [])
21 21 const [sel, setSel] = useState(0)
22 22 const inputRef = useRef(null)
@@ -25,13 +25,13 @@ export default function SearchDialog({ open, onClose }) {
25 25
26 26 useEffect(() => {
27 27 if (!open) return
28 − setQ(''); setSel(0)
28 + setQ(initialQuery || ''); setSel(0)
29 29 getIndex().then(setItems)
30 30 const t = setTimeout(() => inputRef.current?.focus(), 10)
31 31 const prev = document.body.style.overflow
32 32 document.body.style.overflow = 'hidden'
33 33 return () => { clearTimeout(t); document.body.style.overflow = prev }
34 − }, [open])
34 + }, [open, initialQuery])
35 35
36 36 const results = useMemo(() => search(items, q), [items, q])
37 37 const groups = useMemo(() => groupResults(results), [results])
added hfmarketdata/web/src/components/States.jsx +87 −0
@@ -0,0 +1,87 @@
1 +// Loading / empty / error states + Stat tile. ErrorState understands network errors, 4xx, 429 (Retry-After countdown
2 +// with a free-account CTA) and 5xx; the countdown helpers are shared with the playground rate-limit panel.
3 +import React, { useEffect, useState } from 'react'
4 +import { Link } from 'react-router-dom'
5 +import { InboxIcon, WarnIcon, WifiOffIcon } from './Icons.jsx'
6 +
7 +/** <Skeleton width height /> inline shimmer; <Skeleton lines={3} /> paragraph placeholder. */
8 +export function Skeleton({ width, height, lines, block = false, className = '', style }) {
9 + if (lines) {
10 + return <div className={`skeleton-lines ${className}`} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <span key={i} className="skeleton skeleton-block skeleton-text" style={{ width: i === lines - 1 ? '70%' : '100%' }} />)}</div>
11 + }
12 + return <span className={`skeleton ${block ? 'skeleton-block' : ''} ${className}`} style={{ width, height, ...style }} aria-hidden="true" />
13 +}
14 +
15 +export function EmptyState({ icon, title = 'Nothing here yet', children, actions, className = '', ...rest }) {
16 + const Icon = icon || InboxIcon
17 + return (
18 + <div className={`empty-state ${className}`} {...rest}>
19 + <Icon />
20 + <h3>{title}</h3>
21 + {children && <p>{children}</p>}
22 + {actions && <div className="actions">{actions}</div>}
23 + </div>
24 + )
25 +}
26 +
27 +export function useCountdown(untilEpochSec) {
28 + const [left, setLeft] = useState(() => (untilEpochSec ? Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)) : 0))
29 + useEffect(() => {
30 + if (!untilEpochSec) { setLeft(0); return undefined }
31 + const tick = () => setLeft(Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)))
32 + tick()
33 + const t = setInterval(tick, 1000)
34 + return () => clearInterval(t)
35 + }, [untilEpochSec])
36 + return left
37 +}
38 +
39 +export const fmtDuration = s => (s >= 3600 ? `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min` : s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`)
40 +
41 +/**
42 + * @param {number} [status] HTTP status (0 = network)
43 + * @param {string} [code] API error code
44 + * @param {string} [message]
45 + * @param {number} [retryUntil] epoch seconds when a 429 lifts (drives the countdown)
46 + * @param {boolean} [authenticated] hides the free-account CTA on 429
47 + * @param {string} [docs] error.docs URL
48 + * @param {() => void} [onRetry]
49 + */
50 +export function ErrorState({ status = 0, code, message, title, retryUntil, authenticated = false, docs, onRetry, actions, children, className = '', ...rest }) {
51 + const left = useCountdown(status === 429 ? retryUntil || null : null)
52 + const network = !status
53 + const limited = status === 429
54 + const Icon = network ? WifiOffIcon : WarnIcon
55 + const heading = title || (network ? 'Network error' : limited ? 'Rate limited — not down' : status >= 500 ? `Server error · HTTP ${status}` : `${code || 'Error'} · HTTP ${status}`)
56 + return (
57 + <div className={`error-state ${limited ? 'is-429' : ''} ${className}`} role="alert" {...rest}>
58 + <Icon />
59 + <h3>{heading}</h3>
60 + {limited && retryUntil ? <div className="countdown" aria-live="polite">{left > 0 ? fmtDuration(left) : 'you can retry now'}</div> : null}
61 + {message && <p>{message}</p>}
62 + {children}
63 + <div className="actions">
64 + {limited && !authenticated && <Link to="/signup" className="btn btn-primary btn-sm">Create free account · 120 req/min</Link>}
65 + {onRetry && <button type="button" className="btn btn-sm" onClick={onRetry} disabled={limited && left > 0}>Retry</button>}
66 + {docs && <a className="btn btn-ghost btn-sm" href={docs}>Docs</a>}
67 + {actions}
68 + </div>
69 + </div>
70 + )
71 +}
72 +
73 +/** <Stat label value hint delta /> — number tile. `delta` ≥ 0 renders in --up, < 0 in --down. */
74 +export function Stat({ label, value, hint, delta, loading = false, className = '', ...rest }) {
75 + return (
76 + <div className={`stat ${className}`} {...rest}>
77 + <span className="stat-label">{label}</span>
78 + <span className="stat-value">{loading ? <Skeleton width="60%" height="1em" /> : value}</span>
79 + {(hint || delta != null) && (
80 + <span className="stat-hint">
81 + {delta != null && <span className={`stat-delta ${delta >= 0 ? 'up' : 'down'}`}>{delta >= 0 ? '+' : ''}{delta}</span>}
82 + {delta != null && hint ? ' · ' : ''}{hint}
83 + </span>
84 + )}
85 + </div>
86 + )
87 +}
modified hfmarketdata/web/src/components/Table.jsx +10 −4
@@ -1,9 +1,11 @@
1 −// <Table columns rows> — responsive data table. columns: [{ key, label, align?: 'right', render?(row) }].
1 +// <Table columns rows> — data table. columns: [{ key, label, align?: 'right', render?(row), width?, mono? }].
2 +// `dense` tighter rows · `stickyFirst` pins the first column when scrolling horizontally · `stack` turns rows into
3 +// label/value pairs under 640 px (cells carry data-label) · header is sticky inside `.table-wrap` by default.
2 4 import React from 'react'
3 5
4 −export default function Table({ columns, rows, caption, keyField, dense = false, className = '', emptyText = 'No rows' }) {
6 +export default function Table({ columns, rows, caption, keyField, dense = false, stickyFirst = false, stack = false, maxHeight, className = '', emptyText = 'No rows', ...rest }) {
5 7 return (
6 − <div className={`table-wrap ${dense ? 'dense' : ''} ${className}`}>
8 + <div className={`table-wrap ${dense ? 'dense' : ''} ${stickyFirst ? 'sticky-first' : ''} ${stack ? 'table-stack' : ''} ${className}`} style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined} {...rest}>
7 9 <table>
8 10 {caption && <caption className="sr-only">{caption}</caption>}
9 11 <thead>
@@ -13,7 +15,11 @@ export default function Table({ columns, rows, caption, keyField, dense = false,
13 15 {rows.length === 0 && <tr><td colSpan={columns.length} className="muted">{emptyText}</td></tr>}
14 16 {rows.map((r, i) => (
15 17 <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>)}
18 + {columns.map(c => (
19 + <td key={c.key} className={`${c.align === 'right' ? 'num' : ''} ${c.mono ? 'mono' : ''}`.trim()} data-label={typeof c.label === 'string' ? c.label : undefined}>
20 + {c.render ? c.render(r) : r[c.key]}
21 + </td>
22 + ))}
17 23 </tr>
18 24 ))}
19 25 </tbody>
modified hfmarketdata/web/src/components/Tabs.jsx +23 −18
@@ -1,9 +1,10 @@
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.
1 +// <Tabs> — accessible tab list (roving focus, arrow keys). Controlled (`value` + `onChange`) or uncontrolled.
2 +// items: [{ id, label, content?, icon?, badge?, disabled? }] · `right` = slot at the end of the tab bar ·
3 +// panel content = item.content, else children(activeId), else children.
3 4 import React, { useId, useRef, useState } from 'react'
4 5
5 −export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, ariaLabel = 'Tabs' }) {
6 − const [inner, setInner] = useState(defaultValue ?? items[0]?.id)
6 +export default function Tabs({ items, value, onChange, defaultValue, size = 'md', className = '', children, right = null, ariaLabel = 'Tabs', panel = true }) {
7 + const [inner, setInner] = useState(defaultValue ?? items.find(t => !t.disabled)?.id)
7 8 const active = value ?? inner
8 9 const set = id => { setInner(id); onChange?.(id) }
9 10 const base = useId()
@@ -11,27 +12,31 @@ export default function Tabs({ items, value, onChange, defaultValue, size = 'md'
11 12 const onKey = (e, i) => {
12 13 const n = items.length
13 14 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
15 + const step = dir => { let k = i; for (let s = 0; s < n; s++) { k = (k + dir + n) % n; if (!items[k].disabled) return k } return i }
16 + if (e.key === 'ArrowRight') j = step(1)
17 + else if (e.key === 'ArrowLeft') j = step(-1)
18 + else if (e.key === 'Home') j = items.findIndex(t => !t.disabled)
19 + else if (e.key === 'End') { for (let k = n - 1; k >= 0; k--) if (!items[k].disabled) { j = k; break } }
18 20 if (j == null) return
19 21 e.preventDefault(); set(items[j].id); refs.current[j]?.focus()
20 22 }
21 23 const current = items.find(t => t.id === active) || items[0]
22 24 return (
23 25 <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 − ))}
26 + <div className="tabs-bar">
27 + <div role="tablist" aria-label={ariaLabel} className="tablist">
28 + {items.map((t, i) => (
29 + <button key={t.id} ref={el => (refs.current[i] = el)} role="tab" type="button" id={`${base}-tab-${t.id}`}
30 + aria-selected={t.id === active} aria-controls={panel ? `${base}-panel` : undefined} tabIndex={t.id === active ? 0 : -1} disabled={t.disabled}
31 + className={`tab ${t.id === active ? 'active' : ''}`} onClick={() => set(t.id)} onKeyDown={e => onKey(e, i)}>
32 + {t.icon}{t.label}{t.badge != null && <span className="tab-badge">{t.badge}</span>}
33 + </button>
34 + ))}
35 + </div>
36 + {right && <div className="tabs-right">{right}</div>}
32 37 </div>
33 − {current && (
34 − <div role="tabpanel" id={`${base}-panel-${current.id}`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel">
38 + {panel && current && (
39 + <div role="tabpanel" id={`${base}-panel`} aria-labelledby={`${base}-tab-${current.id}`} className="tabpanel">
35 40 {current.content ?? (typeof children === 'function' ? children(current.id) : children)}
36 41 </div>
37 42 )}
modified hfmarketdata/web/src/components/components.css +88 −36
@@ -1,77 +1,129 @@
1 −/* Shared components: Code, Tabs, Callout, Badge, LangSelector, SearchDialog, Markdown prose. Owner: web-core. */
1 +/* Shared components: CodeBlock, CopyButton, Tabs, Callout, Badge, Field, LangSelector, SearchDialog, Markdown,
2 + brand logos, stacked tables. Owner: web-core. Tokens only — no raw colours. */
2 3
3 −/* Code */
4 −.code { border: 1px solid var(--line); border-radius: var(--radius); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; min-width: 0; max-width: 100%; }
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: var(--bg-1); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 34px; flex-wrap: wrap; }
6 −.code-title { font-family: var(--mono); font-size: 12px; color: var(--fg-2); letter-spacing: .02em; }
7 −/* contain: inline-size → the (scrolling) pre never inflates the intrinsic width of grid/flex ancestors on narrow screens */
8 −.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: 13px; line-height: 1.55; tab-size: 2; contain: inline-size; }
4 +/* Code block */
5 +.code { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--code-bg); overflow: hidden; margin: 0 0 16px; min-width: 0; max-width: 100%; }
6 +.code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 2px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg-1) 70%, var(--code-bg)); min-height: 40px; }
7 +.code-title { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); letter-spacing: .02em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
8 +.code-actions { display: inline-flex; align-items: center; gap: 2px; flex: none; }
9 +/* contain: inline-size → the (scrolling) pre never inflates the intrinsic width of grid/flex ancestors */
10 +.code-pre { margin: 0; border: 0; border-radius: 0; background: transparent; padding: 12px 14px; font-size: var(--fs-1); line-height: 1.6; tab-size: 2; contain: inline-size; overflow: auto; }
11 +.code-pre code { font-size: inherit; }
9 12 .code-line { display: block; white-space: pre; }
13 +.code-wrap .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
10 14 .code-ln { display: inline-block; width: 2.4em; color: var(--fg-3); user-select: none; text-align: right; padding-right: 1.2em; }
11 −.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; }
15 +.code-foot { padding: 6px 12px 8px; border-top: 1px solid var(--line); font-size: var(--fs-0); color: var(--fg-2); }
16 +.code-foot p { margin: 0; }
17 +.copy-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: var(--tap); min-width: var(--tap); padding: 0 8px; border-radius: var(--r-sm); border: 1px solid transparent; background: transparent; color: var(--fg-2); cursor: pointer; font-size: var(--fs-0); font-weight: 500; white-space: nowrap; }
12 18 .copy-btn:hover { background: var(--bg-2); color: var(--fg); border-color: var(--line-2); }
13 19 .copy-btn.copied { color: var(--accent); }
14 20 .copy-btn svg { width: 14px; height: 14px; }
21 +.copy-md { min-height: var(--tap); padding: 0 12px; font-size: var(--fs-1); }
22 +.copy-md svg { width: 16px; height: 16px; }
23 +.copy-secondary { border-color: var(--line-2); background: var(--bg-1); color: var(--fg-1); }
24 +.copy-icon-only { padding: 0; width: var(--tap); }
25 +.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid color-mix(in srgb, currentColor 30%, transparent); border-top-color: currentColor; border-radius: 50%; animation: spin .8s linear infinite; }
26 +@keyframes spin { to { transform: rotate(360deg); } }
15 27
16 28 /* Tabs */
17 −.tablist { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
29 +.tabs { min-width: 0; }
30 +.tabs-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); }
31 +.tablist { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; }
18 32 .tablist::-webkit-scrollbar { display: none; }
19 −.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; }
20 −.tab:hover { color: var(--fg); }
33 +.tab { position: relative; min-height: 40px; padding: 0 12px; border: 0; background: transparent; color: var(--fg-2); cursor: pointer; font-weight: 500; font-size: var(--fs-2); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; border-radius: var(--r-sm) var(--r-sm) 0 0; }
34 +.tab:hover:not(:disabled) { color: var(--fg); }
21 35 .tab.active { color: var(--fg); }
22 36 .tab.active::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--accent); border-radius: 2px; }
23 −.tabs-sm .tab { padding: 6px 10px; font-size: 13px; }
24 −.tabpanel { padding-top: 14px; }
37 +.tab:disabled { opacity: .45; cursor: not-allowed; }
38 +.tab-badge { font-family: var(--mono); font-size: var(--fs-0); background: var(--bg-2); padding: 0 6px; border-radius: 10px; color: var(--fg-1); }
39 +.tabs-sm .tab { min-height: 34px; padding: 0 10px; font-size: var(--fs-1); }
40 +.tabs-right { display: flex; align-items: center; gap: 6px; flex: none; padding: 2px 4px; }
41 +.tabpanel { padding-top: 14px; min-width: 0; }
42 +.tabs-flush .tabpanel { padding-top: 0; }
43 +@media (max-width: 960px) { .tab { min-height: 44px; } }
25 44
26 45 /* Language selector */
27 −.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: 7px; background: var(--bg-2); border: 1px solid var(--line); }
28 −.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; }
46 +.lang-selector { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--r-sm); background: var(--bg-2); border: 1px solid var(--line); }
47 +.lang-btn { border: 0; background: transparent; color: var(--fg-2); font-size: var(--fs-0); min-height: calc(var(--tap) - 4px); min-width: 36px; padding: 0 10px; border-radius: 5px; cursor: pointer; font-weight: 500; }
29 48 .lang-btn:hover { color: var(--fg); }
30 −.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .25); }
49 +.lang-btn.active { background: var(--bg); color: var(--fg); box-shadow: var(--shadow-1); }
31 50 .codetabs { margin: 0 0 16px; min-width: 0; max-width: 100%; }
32 51 .codetabs-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
33 52 .codetabs .code { margin-bottom: 0; }
34 53
35 54 /* Callout */
36 −.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; }
55 +.callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--r-md); border: 1px solid var(--line); background: var(--bg-1); margin: 0 0 18px; border-left-width: 3px; align-items: flex-start; flex-wrap: wrap; }
37 56 .callout-icon { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; margin-top: 1px; }
38 57 .callout-icon svg { width: 18px; height: 18px; }
39 −.callout-body { min-width: 0; overflow-wrap: anywhere; }
40 −.callout-title { font-weight: 650; margin-bottom: 3px; }
58 +.callout-body { min-width: 0; flex: 1 1 240px; overflow-wrap: anywhere; font-size: var(--fs-2); }
59 +.callout-title { font-weight: 650; margin-bottom: 3px; color: var(--fg); font-size: var(--fs-3); }
41 60 .callout-body p:last-child { margin-bottom: 0; }
42 −.callout-info { border-left-color: var(--accent-2); } .callout-info .callout-icon { color: var(--accent-2); }
61 +.callout-action { flex: none; display: flex; align-items: center; gap: 8px; align-self: center; }
62 +.callout-compact { padding: 10px 12px; }
63 +.callout-info { border-left-color: var(--info); } .callout-info .callout-icon { color: var(--info); }
43 64 .callout-tip { border-left-color: var(--accent); } .callout-tip .callout-icon { color: var(--accent); }
44 −.callout-warning { border-left-color: var(--warn); background: var(--warn-soft); } .callout-warning .callout-icon { color: var(--warn); }
45 −.callout-danger { border-left-color: var(--danger); background: var(--danger-soft); } .callout-danger .callout-icon { color: var(--danger); }
65 +.callout-success { border-left-color: var(--success); background: color-mix(in srgb, var(--success) 6%, var(--bg-1)); } .callout-success .callout-icon { color: var(--success); }
66 +.callout-warning { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 7%, var(--bg-1)); } .callout-warning .callout-icon { color: var(--warn); }
67 +.callout-danger { border-left-color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, var(--bg-1)); } .callout-danger .callout-icon { color: var(--danger); }
46 68
47 69 /* Badges */
48 −.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; }
49 −.badge-success { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: var(--accent-soft); }
50 −.badge-info { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: var(--accent-2-soft); }
70 +.badge { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; font-size: var(--fs-0); font-weight: 600; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-2); line-height: 1.6; white-space: nowrap; }
71 +.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
72 +.badge-success { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, transparent); background: var(--success-soft); }
73 +.badge-info { color: var(--info); border-color: color-mix(in srgb, var(--info) 40%, transparent); background: var(--info-soft); }
51 74 .badge-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); }
52 75 .badge-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: var(--danger-soft); }
76 +.badge-tier { text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
53 77 .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; }
54 −.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); }
55 −[data-theme="light"] .method-post { color: #fff; }
78 +.method-get { background: var(--get); color: #fff; } .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); } .method-ws { background: var(--bg-3); color: var(--fg); }
79 +[data-theme="dark"] .method-get { color: #041a2a; }
80 +[data-theme="light"] .method-post, [data-theme="light"] .method-put { color: #fff; }
81 +
82 +/* Fields */
83 +.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
84 +.field-inline { flex-direction: row; align-items: center; gap: 10px; }
85 +.field-label { display: flex; align-items: baseline; gap: 6px; font-size: var(--fs-1); color: var(--fg-1); font-weight: 500; }
86 +.field-req { color: var(--danger); }
87 +.field-label-extra { margin-left: auto; font-size: var(--fs-0); color: var(--fg-3); font-weight: 400; }
88 +.field-hint { font-size: var(--fs-0); color: var(--fg-2); line-height: 1.4; }
89 +.field-err { font-size: var(--fs-0); color: var(--danger); }
90 +.field .input, .field .select, .field .textarea { width: 100%; }
91 +
92 +/* Brand logos */
93 +.brand-logo-wrap { display: inline-grid; place-items: center; flex: none; }
94 +.brand-logo { width: 100%; height: 100%; display: block; }
95 +
96 +/* Stacked table (mobile) */
97 +@media (max-width: 640px) {
98 + .table-stack thead { display: none; }
99 + .table-stack tr { display: block; padding: 8px 0; border-bottom: 1px solid var(--line); }
100 + .table-stack tr:last-child { border-bottom: 0; }
101 + .table-stack td { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; border: 0; padding: 3px 12px; text-align: left; }
102 + .table-stack td.num { text-align: left; }
103 + .table-stack td::before { content: attr(data-label); color: var(--fg-2); font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; padding-top: 2px; }
104 + .table-stack td:not([data-label])::before { content: none; }
105 + .table-stack td:not([data-label]) { grid-template-columns: 1fr; }
106 +}
56 107
57 108 /* Search dialog */
58 −.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; }
59 −.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; }
60 −.search-input-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); }
61 −.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; }
62 −.search-input-row input:focus { outline: none; }
109 +.search-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: rgba(0, 0, 0, .55); display: flex; justify-content: center; align-items: flex-start; padding: 12vh 16px 16px; }
110 +.search-dialog { width: min(640px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-lg); box-shadow: var(--shadow-2); overflow: hidden; }
111 +.search-input-row { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); color: var(--fg-2); }
112 +.search-input-row input { flex: 1; border: 0; background: transparent; padding: 6px 0; font-size: 16px; min-height: 40px; }
113 +.search-input-row input:focus-visible { outline: none; }
63 114 .search-results { max-height: 52vh; overflow-y: auto; padding: 6px; }
64 115 .search-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; }
65 −.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; color: var(--fg); cursor: pointer; }
116 +.search-item { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 10px; border-radius: var(--r-md); color: var(--fg); cursor: pointer; }
66 117 .search-item[aria-selected="true"] { background: var(--bg-2); }
67 118 .search-item .method { flex: none; }
68 119 .search-item-text { min-width: 0; flex: 1; }
69 120 .search-item-title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
70 −.search-item-sub { color: var(--fg-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
71 −.search-item-tag { color: var(--fg-3); font-size: 12px; flex: none; }
121 +.search-item-sub { color: var(--fg-2); font-size: var(--fs-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
122 +.search-item-tag { color: var(--fg-3); font-size: var(--fs-0); flex: none; }
72 123 .search-empty { padding: 28px 12px; text-align: center; color: var(--fg-2); }
73 −.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: 12px; }
124 +.search-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: var(--fs-0); }
74 125 mark { background: var(--accent-soft); color: inherit; border-radius: 2px; }
126 +@media (max-width: 640px) { .search-backdrop { padding: 8px; align-items: flex-start; } .search-results { max-height: 70vh; } }
75 127
76 128 /* Markdown prose */
77 129 .md > :first-child { margin-top: 0; }
modified hfmarketdata/web/src/components/index.js +9 −2
@@ -1,10 +1,17 @@
1 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'
2 +export { default as CodeBlock, default as Code } from './CodeBlock.jsx'
3 +export { default as CopyButton, useCopy } from './CopyButton.jsx'
3 4 export { default as CodeTabs } from './CodeTabs.jsx'
5 +export { default as Button, IconButton } from './Button.jsx'
4 6 export { default as Tabs } from './Tabs.jsx'
5 7 export { default as Callout } from './Callout.jsx'
8 +export { default as Card } from './Card.jsx'
6 9 export { default as Table } from './Table.jsx'
7 −export { default as Badge, MethodBadge, StatusBadge } from './Badge.jsx'
10 +export { default as Field, Input, Select, Textarea } from './Field.jsx'
11 +export { Skeleton, EmptyState, ErrorState, Stat, useCountdown, fmtDuration } from './States.jsx'
12 +export { default as Badge, MethodBadge, StatusBadge, TierBadge } from './Badge.jsx'
13 +export { default as BrandLogo, LOGOS } from './BrandLogos.jsx'
8 14 export { default as LangSelector, LangProvider, useLang, LANGS } from './LangSelector.jsx'
9 15 export { default as Markdown, InlineMd } from './Markdown.jsx'
16 +export { default as useMediaQuery } from './useMediaQuery.js'
10 17 export * from './Icons.jsx'
added hfmarketdata/web/src/components/useMediaQuery.js +16 −0
@@ -0,0 +1,16 @@
1 +// useMediaQuery('(max-width: 1199px)') → boolean, SSR/prerender-safe (false when window is undefined).
2 +import { useEffect, useState } from 'react'
3 +
4 +export default function useMediaQuery(query) {
5 + const get = () => (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(query).matches : false)
6 + const [matches, setMatches] = useState(get)
7 + useEffect(() => {
8 + if (typeof window === 'undefined' || !window.matchMedia) return undefined
9 + const mq = window.matchMedia(query)
10 + const on = e => setMatches(e.matches)
11 + setMatches(mq.matches)
12 + mq.addEventListener ? mq.addEventListener('change', on) : mq.addListener(on)
13 + return () => { mq.removeEventListener ? mq.removeEventListener('change', on) : mq.removeListener(on) }
14 + }, [query])
15 + return matches
16 +}
modified hfmarketdata/web/src/docs/mdx-components.jsx +2 −2
@@ -4,7 +4,7 @@ import React from 'react'
4 4 import { Link } from 'react-router-dom'
5 5 import Badge, { MethodBadge } from '../components/Badge.jsx'
6 6 import Callout from '../components/Callout.jsx'
7 −import Code from '../components/Code.jsx'
7 +import Code from '../components/CodeBlock.jsx'
8 8 import CodeTabs from '../components/CodeTabs.jsx'
9 9 import Table from '../components/Table.jsx'
10 10 import Tabs from '../components/Tabs.jsx'
@@ -26,7 +26,7 @@ const Heading = level => function H({ children, id, ...rest }) {
26 26 const slug = id || slugify(textOf(children))
27 27 return (
28 28 <Tag id={slug} className="anchor-heading" {...rest}>
29 − <a href={`#${slug}`} className="anchor" aria-label="Link to this section">#</a>{children}
29 + <a href={`#${slug}`} className="anchor" aria-label="Link to this section" />{children}
30 30 </Tag>
31 31 )
32 32 }
deleted hfmarketdata/web/src/legacy-spec.js +0 −366
@@ -1,366 +0,0 @@
1 −// HF Market Data — API reference specification (rendered by the docs UI)
2 −// Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 −
4 −export const BASE_URL = 'https://www.hfmarketdata.io'
5 −
6 −export const ASSET_TYPES = [
7 − { id: 'stock', name: 'Stocks', desc: 'US equities — every listed ticker, split & dividend adjusted variants.', adjustments: ['adj_split', 'adj_splitdiv', 'UNADJUSTED'], example: 'AAPL' },
8 − { id: 'etf', name: 'ETFs', desc: 'US exchange-traded funds, same adjustment variants as stocks.', adjustments: ['adj_split', 'adj_splitdiv', 'UNADJUSTED'], example: 'SPY' },
9 − { id: 'futures', name: 'Futures', desc: 'Continuous front-month series, roll-adjusted variants.', adjustments: ['contin_UNadj', 'contin_adj_ratio', 'contin_adj_absolute'], example: 'ES' },
10 − { id: 'futures_contracts', name: 'Futures contracts', desc: 'Individual expiry contracts, archive and current year.', adjustments: ['archive', 'update'], example: 'ESH24' },
11 − { id: 'crypto', name: 'Crypto', desc: 'Major cryptocurrency pairs.', adjustments: ['none'], example: 'BTCUSD' },
12 − { id: 'index', name: 'Indices', desc: 'Equity & volatility indices.', adjustments: ['none'], example: 'SPX' },
13 − { id: 'fx', name: 'FX', desc: 'Foreign-exchange pairs.', adjustments: ['none'], example: 'EURUSD' },
14 −]
15 −
16 −export const ASSET_IDS = ASSET_TYPES.map(a => a.id)
17 −export const ADJUSTMENTS_BY_ASSET = Object.fromEntries(
18 − ASSET_TYPES.map(a => [a.id, a.adjustments]))
19 −export const EXAMPLE_TICKER = Object.fromEntries(
20 − ASSET_TYPES.map(a => [a.id, a.example]))
21 −
22 −export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
23 −
24 −export const BAR_FIELDS = [
25 − { name: 'ticker', type: 'string', desc: 'Instrument symbol.' },
26 − { name: 'datetime', type: 'timestamp', desc: 'Bar timestamp — US Eastern Time for intraday, date for daily.' },
27 − { name: 'open', type: 'double', desc: 'Opening price of the bar.' },
28 − { name: 'high', type: 'double', desc: 'Highest traded price.' },
29 − { name: 'low', type: 'double', desc: 'Lowest traded price.' },
30 − { name: 'close', type: 'double', desc: 'Closing price of the bar.' },
31 − { name: 'volume', type: 'double', desc: 'Traded volume. Zero-volume bars are excluded from the dataset.' },
32 − { name: 'open_interest', type: 'double', desc: 'Open interest — daily futures bars only.' },
33 −]
34 −
35 −export const OPTION_FIELDS = [
36 − { name: 'ticker', type: 'string', desc: 'Underlying symbol.' },
37 − { name: 'trade_date', type: 'date', desc: 'Observation date (end-of-day snapshot, sampled 30s before the close).' },
38 − { name: 'strike', type: 'double', desc: 'Contract strike price.' },
39 − { name: 'expiry', type: 'date', desc: 'Contract expiration date.' },
40 − { name: 'call_put', type: 'string', desc: '"c" for calls, "p" for puts.' },
41 − { name: 'last_price', type: 'double', desc: 'Last traded price of the contract.' },
42 − { name: 'bid', type: 'double', desc: 'End-of-day best bid.' },
43 − { name: 'ask', type: 'double', desc: 'End-of-day best ask.' },
44 − { name: 'bid_iv', type: 'double', desc: 'Implied volatility computed from the bid.' },
45 − { name: 'ask_iv', type: 'double', desc: 'Implied volatility computed from the ask.' },
46 − { name: 'open_interest', type: 'double', desc: 'Open interest.' },
47 − { name: 'volume', type: 'double', desc: 'Contracts traded that day.' },
48 − { name: 'delta', type: 'double', desc: 'Δ — sensitivity of the option price to the underlying.' },
49 − { name: 'gamma', type: 'double', desc: 'Γ — rate of change of delta.' },
50 − { name: 'vega', type: 'double', desc: 'ν — sensitivity to implied volatility.' },
51 − { name: 'theta', type: 'double', desc: 'Θ — time decay.' },
52 − { name: 'rho', type: 'double', desc: 'ρ — sensitivity to interest rates.' },
53 −]
54 −
55 −export const ERRORS = [
56 − { code: 200, meaning: 'OK', desc: 'Request succeeded.' },
57 − { code: 400, meaning: 'Bad request', desc: 'Invalid parameter — unknown timeframe, adjustment not available for this asset type, malformed value. The detail field explains what to fix and lists valid values.' },
58 − { code: 404, meaning: 'Not found', desc: 'Unknown asset type, ticker or quarter. The detail field lists what is available.' },
59 − { code: 422, meaning: 'Validation error', desc: 'A query parameter failed type validation (FastAPI validation payload).' },
60 − { code: 503, meaning: 'Unavailable', desc: 'The requested dataset segment has not been ingested yet.' },
61 −]
62 −
63 −export const CAPABILITIES = [
64 − { title: 'Any window, to the minute', desc: 'start / end accept full timestamps — pull exactly 4 hours of 1-minute bars, one session, or 20 years of dailies.' },
65 − { title: 'Whole watchlists in one call', desc: 'Up to 50 tickers per request on /v1/bars/{asset} — per-ticker limits, rows grouped by symbol.' },
66 − { title: 'Point-in-time snapshots', desc: 'The state of every ticker you follow at 10:35:00 on any given day — one request.' },
67 − { title: 'Options with the full Greek set', desc: 'Chains, expirations and single-contract life stories — quotes, bid/ask IV, OI, delta gamma vega theta rho.' },
68 − { title: 'Bulk CSV extraction', desc: 'format=csv streams up to 2,000,000 rows per request — pandas.read_csv straight from the URL.' },
69 − { title: 'Every adjustment variant', desc: 'Split-only, split+dividend or raw unadjusted equities; ratio / absolute / unadjusted futures rolls.' },
70 −]
71 −
72 −// t: input type ('select' | 'text'), opts: fixed list or dynamic key,
73 −// def: playground default, ph: placeholder
74 −export const ENDPOINTS = [
75 − {
76 − id: 'health',
77 − method: 'GET',
78 − path: '/health',
79 − title: 'Health check',
80 − tag: 'Meta',
81 − desc: 'Service liveness probe. Returns service name, version and whether the data lake is mounted.',
82 − params: [],
83 − response: `{
84 − "status": "ok",
85 − "service": "hfmarketdata-api",
86 − "version": "1.0.0",
87 − "data_root_present": true
88 −}`,
89 − },
90 − {
91 − id: 'status',
92 − method: 'GET',
93 − path: '/v1/status',
94 − title: 'Dataset inventory',
95 − tag: 'Meta',
96 − desc: 'Full inventory of the data lake: every asset type, timeframe and adjustment with the number of instruments available, plus the list of options quarters.',
97 − params: [],
98 − response: `{
99 − "datasets": {
100 − "stock": {
101 − "1min": { "adj_split": 7669, "adj_splitdiv": 7669, "UNADJUSTED": 7670 },
102 − "1day": { "adj_split": 7664, "adj_splitdiv": 7664, "UNADJUSTED": 7665 }
103 − },
104 − "fx": { "1min": { "none": 79 } },
105 − "options": {
106 − "quarters": ["2010_q1", "2010_q2", "…", "2026_q2"],
107 − "files_latest_quarter": 5972
108 − }
109 − }
110 −}`,
111 − },
112 − {
113 − id: 'tickers',
114 − method: 'GET',
115 − path: '/v1/{asset}/tickers',
116 − title: 'List tickers',
117 − tag: 'Bars',
118 − desc: 'Every instrument available for an asset type / timeframe / adjustment combination. Use it to discover coverage before requesting bars.',
119 − params: [
120 − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' },
121 − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1day' },
122 − { name: 'adjustment', in: 'query', req: false, desc: 'Adjustment variant; sensible default per asset type', t: 'select', opts: 'ADJ_BY_ASSET', def: '' },
123 − { name: 'search', in: 'query', req: false, desc: 'Case-insensitive substring filter', t: 'text', def: 'AAP', ph: 'AAP' },
124 − { name: 'limit', in: 'query', req: false, desc: 'Max results — default 10 000', t: 'text', def: '', ph: '10000' },
125 − ],
126 − response: `{
127 − "asset": "stock",
128 − "timeframe": "1day",
129 − "adjustment": "adj_splitdiv",
130 − "count": 4,
131 − "tickers": ["AAP", "AAPD", "AAPL", "AAPU"]
132 −}`,
133 − },
134 − {
135 − id: 'bars',
136 − method: 'GET',
137 − path: '/v1/bars/{asset}/{ticker}',
138 − title: 'OHLCV bars',
139 − tag: 'Bars',
140 − desc: 'Historical bars for one instrument, 1-minute to daily. start and end accept full timestamps, so any precise window works — one trading hour or twenty years. Daily futures bars also carry open interest.',
141 − params: [
142 − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' },
143 − { name: 'ticker', in: 'path', req: true, desc: 'Instrument symbol — AAPL, SPY, ES, BTCUSD, EURUSD…', t: 'text', def: 'AAPL', ph: 'AAPL' },
144 − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1day' },
145 − { name: 'adjustment', in: 'query', req: false, desc: 'stock/etf: adj_split · adj_splitdiv · UNADJUSTED — futures: contin_UNadj · contin_adj_ratio · contin_adj_absolute', t: 'select', opts: 'ADJ_BY_ASSET', def: '' },
146 − { name: 'start', in: 'query', req: false, desc: 'ISO lower bound — 2024-01-01 or 2024-06-03 09:30:00', t: 'text', def: '2024-06-03', ph: '2024-06-03 09:30:00' },
147 − { name: 'end', in: 'query', req: false, desc: 'ISO upper bound', t: 'text', def: '', ph: '2024-06-07' },
148 − { name: 'order', in: 'query', req: false, desc: 'Sort direction', t: 'select', opts: ['asc', 'desc'], def: 'asc' },
149 − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 5 000 · JSON cap 50 000 · CSV cap 2 000 000', t: 'text', def: '5', ph: '5000' },
150 − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' },
151 − ],
152 − response: `{
153 − "count": 2,
154 − "data": [
155 − { "ticker": "AAPL", "datetime": "2024-06-03", "open": 192.9,
156 − "high": 194.99, "low": 192.52, "close": 194.03, "volume": 50080500.0 },
157 − { "ticker": "AAPL", "datetime": "2024-06-04", "open": 194.64,
158 − "high": 195.32, "low": 193.03, "close": 194.35, "volume": 47471400.0 }
159 − ]
160 −}`,
161 − fields: 'bars',
162 − },
163 − {
164 − id: 'bars-multi',
165 − method: 'GET',
166 − path: '/v1/bars/{asset}',
167 − title: 'Multi-ticker bars',
168 − tag: 'Bars',
169 − desc: 'Bars for several instruments in ONE call. Combine tickers with start/end to slice any precise window — e.g. 4 hours of 1-minute bars across a whole watchlist. The row limit applies per ticker, and rows come back grouped by ticker.',
170 − params: [
171 − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' },
172 − { name: 'tickers', in: 'query', req: true, desc: 'Comma-separated list — max 50', t: 'text', def: 'AAPL,MSFT,NVDA', ph: 'AAPL,MSFT,NVDA' },
173 − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1day', t: 'select', opts: TIMEFRAMES, def: '1min' },
174 − { name: 'adjustment', in: 'query', req: false, desc: 'Same variants as single-ticker bars', t: 'select', opts: 'ADJ_BY_ASSET', def: '' },
175 − { name: 'start', in: 'query', req: false, desc: 'ISO lower bound — precise to the minute', t: 'text', def: '2024-06-03 09:30:00', ph: '2024-06-03 09:30:00' },
176 − { name: 'end', in: 'query', req: false, desc: 'ISO upper bound', t: 'text', def: '2024-06-03 13:30:00', ph: '2024-06-03 13:30:00' },
177 − { name: 'order', in: 'query', req: false, desc: 'Sort direction', t: 'select', opts: ['asc', 'desc'], def: 'asc' },
178 − { name: 'limit', in: 'query', req: false, desc: 'Max rows PER TICKER — default 5 000', t: 'text', def: '3', ph: '5000' },
179 − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' },
180 − ],
181 − response: `{
182 − "count": 720,
183 − "data": [
184 − { "ticker": "AAPL", "datetime": "2024-06-03 09:30:00", "open": 192.9, "…": "…" },
185 − { "ticker": "AAPL", "datetime": "2024-06-03 09:31:00", "open": 192.7, "…": "…" },
186 − { "ticker": "MSFT", "datetime": "2024-06-03 09:30:00", "open": 415.2, "…": "…" }
187 − ]
188 −}`,
189 − fields: 'bars',
190 − },
191 − {
192 − id: 'snapshot',
193 − method: 'GET',
194 − path: '/v1/snapshot/{asset}',
195 − title: 'Point-in-time snapshot',
196 − tag: 'Bars',
197 − desc: 'The state of a whole watchlist at one precise moment: for each requested instrument, the last bar at or before the given timestamp. Perfect for reconstructing a cross-section of the market at 10:35:00 on any given day.',
198 − params: [
199 − { name: 'asset', in: 'path', req: true, desc: 'Asset class', t: 'select', opts: 'ASSETS', def: 'stock' },
200 − { name: 'tickers', in: 'query', req: true, desc: 'Comma-separated list — max 50', t: 'text', def: 'AAPL,MSFT,NVDA', ph: 'AAPL,MSFT,NVDA' },
201 − { name: 'at', in: 'query', req: true, desc: 'The precise moment', t: 'text', def: '2024-06-03 10:35:00', ph: '2024-06-03 10:35:00' },
202 − { name: 'timeframe', in: 'query', req: false, desc: 'Bar resolution — default 1min', t: 'select', opts: TIMEFRAMES, def: '1min' },
203 − { name: 'adjustment', in: 'query', req: false, desc: 'Same variants as bars', t: 'select', opts: 'ADJ_BY_ASSET', def: '' },
204 − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' },
205 − ],
206 − response: `{
207 − "count": 3,
208 − "data": [
209 − { "ticker": "AAPL", "datetime": "2024-06-03 10:35:00", "close": 193.1, "…": "…" },
210 − { "ticker": "MSFT", "datetime": "2024-06-03 10:35:00", "close": 414.8, "…": "…" },
211 − { "ticker": "NVDA", "datetime": "2024-06-03 10:34:00", "close": 113.9, "…": "…" }
212 − ]
213 −}`,
214 − fields: 'bars',
215 − },
216 − {
217 − id: 'opt-quarters',
218 − method: 'GET',
219 − path: '/v1/options/quarters',
220 − title: 'Options quarters',
221 − tag: 'Options',
222 − desc: 'The quarterly archives available, from 2010_q1 through the current quarter. Each quarter holds one end-of-day chain file per underlying.',
223 − params: [],
224 − response: `{
225 − "quarters": ["2010_q1", "2010_q2", "2010_q3", "…", "2026_q1", "2026_q2"]
226 −}`,
227 − },
228 − {
229 − id: 'opt-tickers',
230 − method: 'GET',
231 − path: '/v1/options/tickers',
232 − title: 'Options underlyings',
233 − tag: 'Options',
234 − desc: 'Underlyings with options data in a given quarter — 5,800+ US equities and indices.',
235 − params: [
236 − { name: 'quarter', in: 'query', req: false, desc: 'e.g. 2024_q4 — default: latest quarter', t: 'text', def: '', ph: '2024_q4' },
237 − { name: 'search', in: 'query', req: false, desc: 'Substring filter', t: 'text', def: 'TSL', ph: 'TSL' },
238 − { name: 'limit', in: 'query', req: false, desc: 'Max results — default 10 000', t: 'text', def: '', ph: '10000' },
239 − ],
240 − response: `{
241 − "quarter": "2026_q2",
242 − "count": 14,
243 − "tickers": ["TSL", "TSLA", "TSLG", "TSLL", "…"]
244 −}`,
245 − },
246 − {
247 − id: 'opt-chain',
248 − method: 'GET',
249 − path: '/v1/options/chain/{ticker}',
250 − title: 'Option chain',
251 − tag: 'Options',
252 − desc: 'The complete end-of-day chain for an underlying on one trade date: last price, bid/ask quotes, bid/ask implied volatility, open interest, volume and the full set of Greeks. Snapshots are sampled 30 seconds before the close to avoid rebalancing-order noise.',
253 − params: [
254 − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol — AAPL, SPX…', t: 'text', def: 'AAPL', ph: 'AAPL' },
255 − { name: 'trade_date', in: 'query', req: false, desc: 'yyyy-mm-dd — default: latest available date', t: 'text', def: '2024-06-21', ph: '2024-06-21' },
256 − { name: 'expiry', in: 'query', req: false, desc: 'Restrict to a single expiry date', t: 'text', def: '', ph: '2024-12-20' },
257 − { name: 'call_put', in: 'query', req: false, desc: 'Side', t: 'select', opts: ['', 'c', 'p'], def: 'c' },
258 − { name: 'strike_min', in: 'query', req: false, desc: 'Minimum strike', t: 'text', def: '200', ph: '150' },
259 − { name: 'strike_max', in: 'query', req: false, desc: 'Maximum strike', t: 'text', def: '210', ph: '250' },
260 − { name: 'min_volume', in: 'query', req: false, desc: 'Minimum traded volume', t: 'text', def: '', ph: '10' },
261 − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 20 000', t: 'text', def: '5', ph: '20000' },
262 − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' },
263 − ],
264 − response: `{
265 − "count": 1,
266 − "data": [
267 − { "ticker": "AAPL", "trade_date": "2024-06-21", "strike": 200.0,
268 − "expiry": "2024-06-28", "call_put": "c", "last_price": 8.0,
269 − "bid": 7.85, "ask": 8.15, "bid_iv": 0.264, "ask_iv": 0.271,
270 − "open_interest": 5231.0, "volume": 1930.0, "delta": 0.694,
271 − "gamma": 0.041, "vega": 0.104, "theta": -0.213, "rho": 0.026 }
272 − ]
273 −}`,
274 − fields: 'options',
275 − },
276 − {
277 − id: 'opt-expirations',
278 − method: 'GET',
279 − path: '/v1/options/expirations/{ticker}',
280 − title: 'Expiration dates',
281 − tag: 'Options',
282 − desc: 'Available expiry dates for an underlying, optionally as of one trade date — the natural first call before requesting a filtered chain.',
283 − params: [
284 − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol', t: 'text', def: 'AAPL', ph: 'AAPL' },
285 − { name: 'trade_date', in: 'query', req: false, desc: 'yyyy-mm-dd', t: 'text', def: '2024-06-21', ph: '2024-06-21' },
286 − ],
287 − response: `{
288 − "ticker": "AAPL",
289 − "expirations": ["2024-06-28", "2024-07-05", "2024-07-19", "…"]
290 −}`,
291 − },
292 − {
293 − id: 'opt-history',
294 − method: 'GET',
295 − path: '/v1/options/history/{ticker}',
296 − title: 'Contract history',
297 − tag: 'Options',
298 − desc: 'The daily time series of one specific contract — strike + expiry + side — across its entire life: price, quotes, implied volatility, open interest and Greeks, day by day.',
299 − params: [
300 − { name: 'ticker', in: 'path', req: true, desc: 'Underlying symbol', t: 'text', def: 'AAPL', ph: 'AAPL' },
301 − { name: 'strike', in: 'query', req: true, desc: 'Contract strike', t: 'text', def: '200', ph: '200' },
302 − { name: 'expiry', in: 'query', req: true, desc: 'Contract expiry — yyyy-mm-dd', t: 'text', def: '2024-12-20', ph: '2024-12-20' },
303 − { name: 'call_put', in: 'query', req: true, desc: 'Side', t: 'select', opts: ['c', 'p'], def: 'c' },
304 − { name: 'limit', in: 'query', req: false, desc: 'Max rows — default 5 000', t: 'text', def: '5', ph: '5000' },
305 − { name: 'format', in: 'query', req: false, desc: 'Response format', t: 'select', opts: ['json', 'csv'], def: 'json' },
306 − ],
307 − response: `{
308 − "count": 214,
309 − "data": [
310 − { "ticker": "AAPL", "trade_date": "2024-01-02", "strike": 200.0,
311 − "expiry": "2024-12-20", "call_put": "c", "last_price": 11.2, "…": "…" }
312 − ]
313 −}`,
314 − fields: 'options',
315 − },
316 −]
317 −
318 −// ---- URL building + code snippets (shared by docs & playground) -------------
319 −
320 −export function buildUrl(ep, values) {
321 − let path = ep.path
322 − const query = []
323 − for (const p of ep.params) {
324 − const v = (values[p.name] ?? '').toString().trim()
325 − if (p.in === 'path') {
326 − path = path.replace(`{${p.name}}`, encodeURIComponent(v || `{${p.name}}`))
327 − } else if (v !== '') {
328 − query.push(`${p.name}=${encodeURIComponent(v).replace(/%2C/g, ',')}`)
329 − }
330 − }
331 − return path + (query.length ? '?' + query.join('&') : '')
332 −}
333 −
334 −export function defaultValues(ep) {
335 − const v = {}
336 − for (const p of ep.params) v[p.name] = p.def ?? ''
337 − return v
338 −}
339 −
340 −export function paramOptions(p, values) {
341 − if (p.opts === 'ASSETS') return ASSET_IDS
342 − if (p.opts === 'ADJ_BY_ASSET') {
343 − const asset = values.asset || 'stock'
344 − return ['', ...(ADJUSTMENTS_BY_ASSET[asset] || [])]
345 − }
346 − return p.opts || []
347 −}
348 −
349 −export function curlSnippet(url) {
350 − return `curl "${BASE_URL}${url}"`
351 −}
352 −
353 −export function pythonSnippet(url) {
354 − return `import requests
355 −
356 −r = requests.get("${BASE_URL}${url}")
357 −r.raise_for_status()
358 −data = r.json()
359 −print(f"{data.get('count', '')} rows")`
360 −}
361 −
362 −export function jsSnippet(url) {
363 − return `const res = await fetch("${BASE_URL}${url}");
364 −const data = await res.json();
365 −console.log(data);`
366 −}
added hfmarketdata/web/src/pages/NotFound.jsx +49 −0
@@ -0,0 +1,49 @@
1 +// 404 — document title, a search box (opens the ⌘K dialog with the query), and the three places people want.
2 +import React, { useState } from 'react'
3 +import { Link, useLocation } from 'react-router-dom'
4 +import { CONTACT_EMAIL } from '../app/api.js'
5 +import { BookIcon, PlayIcon, PulseIcon, SearchIcon } from '../components/Icons.jsx'
6 +import useTitle from '../docs/useTitle.js'
7 +
8 +const SUGGESTIONS = [
9 + { to: '/docs', label: 'Documentation', sub: 'Guides and the generated API reference', icon: BookIcon },
10 + { to: '/playground', label: 'Playground', sub: 'Run any request in the browser', icon: PlayIcon },
11 + { to: '/status', label: 'Status', sub: 'Health and dataset inventory', icon: PulseIcon },
12 +]
13 +
14 +export default function NotFound() {
15 + useTitle('Page not found', 'This page does not exist on HF Market Data.')
16 + const { pathname } = useLocation()
17 + const [q, setQ] = useState('')
18 + // Layout listens for this event and opens the ⌘K dialog seeded with the query.
19 + const submit = e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('hfmd:search', { detail: q })) }
20 + return (
21 + <main className="page narrow notfound" data-testid="not-found">
22 + <p className="eyebrow">404</p>
23 + <h1>There is nothing at <code className="notfound-path">{pathname}</code></h1>
24 + <p className="lead">The page may have moved or the URL may be misspelled. Search the documentation, or jump to one of the usual places.</p>
25 + <form className="notfound-search" onSubmit={submit} role="search">
26 + <label htmlFor="nf-q" className="sr-only">Search the documentation</label>
27 + <input id="nf-q" type="search" value={q} onChange={e => setQ(e.target.value)} placeholder="Search endpoints, guides, error codes…" autoComplete="off" />
28 + <button type="submit" className="btn btn-primary"><SearchIcon /> Search</button>
29 + </form>
30 + <div className="notfound-links">
31 + {SUGGESTIONS.map(s => (
32 + <Link key={s.to} to={s.to} className="card card-sm notfound-card"><s.icon /><span><strong>{s.label}</strong><span className="muted small">{s.sub}</span></span></Link>
33 + ))}
34 + </div>
35 + <p className="muted small">Think this is a broken link on our side? <a href={`mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(`Broken link: ${pathname}`)}`}>Tell us</a>.</p>
36 + <style>{`
37 + .notfound-path { font-size: .7em; vertical-align: middle; }
38 + .notfound-search { display: flex; gap: 8px; margin: 20px 0; max-width: 560px; }
39 + .notfound-search input { flex: 1; min-width: 0; }
40 + .notfound-links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 8px 0 22px; }
41 + .notfound-card { display: flex; gap: 12px; align-items: flex-start; color: var(--fg); }
42 + .notfound-card:hover { text-decoration: none; border-color: var(--line-3); }
43 + .notfound-card svg { flex: none; color: var(--accent); margin-top: 2px; }
44 + .notfound-card > span { display: grid; gap: 2px; }
45 + @media (max-width: 640px) { .notfound-links { grid-template-columns: 1fr; } .notfound-search { flex-direction: column; } }
46 + `}</style>
47 + </main>
48 + )
49 +}
modified hfmarketdata/web/src/pages/docs/DocsHome.jsx +1 −1
@@ -19,8 +19,8 @@ export default function DocsHome() {
19 19 const headings = SECTIONS.filter(s => s.id !== 'more').map(s => ({ depth: 2, text: s.title, id: s.id }))
20 20 const panel = (
21 21 <>
22 − <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div>
23 22 <OnThisPage headings={headings} />
23 + <div className="panel-block"><CodeTabs title="Your first request — no signup" snippets={snippets} maxHeight={420} /></div>
24 24 </>
25 25 )
26 26 return (
modified hfmarketdata/web/src/pages/docs/DocsLayout.jsx +28 −9
@@ -1,21 +1,30 @@
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.
1 +// Docs shell: left nav (guides + reference generated from the spec), DocPage (content + code panel), OnThisPage
2 +// outline, footer with Edit / Report links. DOM order = visual order on every breakpoint: under 1200 px the panel
3 +// is rendered INSIDE the article (after the intro, before the body) instead of in the right-hand aside.
3 4 import React, { useEffect, useMemo, useState } from 'react'
4 5 import { Link, NavLink, useLocation } from 'react-router-dom'
5 6 import { CONTACT_EMAIL, PUBLIC_BASE } from '../../app/api.js'
6 7 import { REPO_URL } from '../../app/Layout.jsx'
7 8 import { MethodBadge } from '../../components/Badge.jsx'
8 9 import { ChevronIcon, EditIcon, GitIcon, MenuIcon, SearchIcon } from '../../components/Icons.jsx'
10 +import useMediaQuery from '../../components/useMediaQuery.js'
9 11 import { EXTERNAL_LINKS, GUIDES, SECTIONS, STATIC_PAGES, guideHref } from '../../docs/guides.js'
10 12 import { groupByTag, listOperations, useSpec } from '../../docs/spec.js'
11 13 import './docs.css'
12 14
13 15 const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
16 +export const PANEL_BREAKPOINT = '(max-width: 1199px)'
14 17
15 18 export default function DocsLayout({ children }) {
16 19 const [open, setOpen] = useState(false)
17 20 const location = useLocation()
18 21 useEffect(() => { setOpen(false) }, [location.pathname])
22 + useEffect(() => {
23 + if (!open) return undefined
24 + const onKey = e => { if (e.key === 'Escape') setOpen(false) }
25 + window.addEventListener('keydown', onKey)
26 + return () => window.removeEventListener('keydown', onKey)
27 + }, [open])
19 28 return (
20 29 <div className="docs">
21 30 <div className="docs-nav-toggle">
@@ -36,7 +45,7 @@ function currentLabel(path) {
36 45 return 'Overview'
37 46 }
38 47
39 −function openSearch() { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true })) }
48 +function openSearch() { window.dispatchEvent(new CustomEvent('hfmd:search', { detail: '' })) }
40 49
41 50 export function DocsNav({ open }) {
42 51 const { spec } = useSpec()
@@ -81,9 +90,15 @@ export function DocsNav({ open }) {
81 90 )
82 91 }
83 92
84 −/** Centre column + sticky right panel. `panel` null → single wide column. */
85 −export function DocPage({ children, panel, crumbs, footer = true, testId }) {
93 +/**
94 + * Centre column + right panel. `intro` = header block (h1, description, actions) · `children` = body ·
95 + * `after` = prev/next · `panel` = code + outline. Wide: panel in a sticky aside. Narrow: panel rendered inside the
96 + * article right after `intro` (or before `after` when there is no intro), so request/response come before Parameters.
97 + */
98 +export function DocPage({ children, intro, after, panel, crumbs, footer = true, testId }) {
86 99 const location = useLocation()
100 + const narrow = useMediaQuery(PANEL_BREAKPOINT)
101 + const inline = narrow && panel ? <div className="docs-panel-inline" data-testid="docs-panel-inline">{panel}</div> : null
87 102 return (
88 103 <div className={`docpage ${panel ? '' : 'no-panel'}`} data-testid={testId}>
89 104 <article className="docs-content">
@@ -93,10 +108,14 @@ export function DocPage({ children, panel, crumbs, footer = true, testId }) {
93 108 {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 109 </div>
95 110 )}
111 + {intro}
112 + {intro ? inline : null}
96 113 {children}
114 + {!intro ? inline : null}
115 + {after}
97 116 {footer && <DocsFooter path={location.pathname} />}
98 117 </article>
99 − {panel && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>}
118 + {panel && !narrow && <aside className="docs-panel" aria-label="Code and outline"><div className="docs-panel-inner">{panel}</div></aside>}
100 119 </div>
101 120 )
102 121 }
@@ -116,9 +135,9 @@ export function DocsFooter({ path }) {
116 135 export function OnThisPage({ headings }) {
117 136 const [active, setActive] = useState(null)
118 137 useEffect(() => {
119 − if (!headings?.length || typeof IntersectionObserver === 'undefined') return
138 + if (!headings?.length || typeof IntersectionObserver === 'undefined') return undefined
120 139 const els = headings.map(h => document.getElementById(h.id)).filter(Boolean)
121 − if (!els.length) return
140 + if (!els.length) return undefined
122 141 const io = new IntersectionObserver(entries => {
123 142 const visible = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
124 143 if (visible[0]) setActive(visible[0].target.id)
@@ -128,7 +147,7 @@ export function OnThisPage({ headings }) {
128 147 }, [headings])
129 148 if (!headings?.length) return null
130 149 return (
131 − <div className="panel-block">
150 + <div className="panel-block panel-toc">
132 151 <h4>On this page</h4>
133 152 <nav className="toc" aria-label="On this page">
134 153 {headings.map(h => <a key={h.id} href={`#${h.id}`} className={`depth-${h.depth} ${active === h.id ? 'active' : ''}`}>{h.text}</a>)}
modified hfmarketdata/web/src/pages/docs/ErrorsPage.jsx +2 −2
@@ -3,7 +3,7 @@
3 3 import React, { useEffect, useMemo } from 'react'
4 4 import { Link, useLocation } from 'react-router-dom'
5 5 import { StatusBadge } from '../../components/Badge.jsx'
6 −import Code from '../../components/Code.jsx'
6 +import Code from '../../components/CodeBlock.jsx'
7 7 import Callout from '../../components/Callout.jsx'
8 8 import { errorCodes, useSpec } from '../../docs/spec.js'
9 9 import useTitle from '../../docs/useTitle.js'
@@ -72,7 +72,7 @@ export default function ErrorsPage() {
72 72 const info = ERROR_INFO[c] || { status: 400, meaning: 'See the endpoint description.', fix: '' }
73 73 return (
74 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>
75 + <h3 className="anchor-heading"><StatusBadge code={info.status} /> {c} <a href={`#${c.toLowerCase()}`} className="anchor" aria-label={`Link to ${c}`} /></h3>
76 76 <p><Md text={info.meaning} /></p>
77 77 {info.fix && <p className="muted"><strong>Fix:</strong> <Md text={info.fix} /></p>}
78 78 </div>
modified hfmarketdata/web/src/pages/docs/GuidePage.jsx +2 −2
@@ -47,7 +47,8 @@ export default function GuidePage({ slug }) {
47 47 )
48 48 const Content = mod?.default
49 49 return (
50 − <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page">
50 + <DocPage crumbs={[{ label: sectionTitle(guide.section) }, { label: guide.title }]} panel={panel} testId="guide-page"
51 + after={<PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} />}>
51 52 {error && <p role="alert" className="muted">Could not load this guide ({String(error.message || error)}).</p>}
52 53 {!mod && !error && <p className="muted" aria-busy="true">Loading…</p>}
53 54 {Content && (
@@ -55,7 +56,6 @@ export default function GuidePage({ slug }) {
55 56 <MDXProvider components={mdxComponents}><Content /></MDXProvider>
56 57 </div>
57 58 )}
58 − <PrevNext prev={prev && { href: guideHref(prev), title: prev.title }} next={next && { href: guideHref(next), title: next.title }} />
59 59 </DocPage>
60 60 )
61 61 }
modified hfmarketdata/web/src/pages/docs/OperationPage.jsx +84 −56
@@ -1,15 +1,17 @@
1 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'
2 +// (curl / Python / JS / R), response example (spec → captured file; "Run live" on demand only — the docs never
3 +// spend the visitor's keyless quota by themselves), errors, Try it.
4 +import React, { useCallback, useEffect, useMemo, useState } from 'react'
4 5 import { Link, useParams } from 'react-router-dom'
5 −import { BASE_URL, PUBLIC_BASE } from '../../app/api.js'
6 +import { BASE_URL, PUBLIC_BASE, rateHeaders } from '../../app/api.js'
6 7 import Badge, { MethodBadge, StatusBadge } from '../../components/Badge.jsx'
7 8 import Callout from '../../components/Callout.jsx'
8 −import Code from '../../components/Code.jsx'
9 +import Code from '../../components/CodeBlock.jsx'
9 10 import CodeTabs from '../../components/CodeTabs.jsx'
10 11 import { PlayIcon } from '../../components/Icons.jsx'
11 12 import Markdown, { InlineMd } from '../../components/Markdown.jsx'
12 −import { buildRequest, exampleParams, hasExampleFile, loadExampleFile, playgroundLink } from '../../docs/enrich.js'
13 +import { fmtDuration, useCountdown } from '../../components/States.jsx'
14 +import { buildRequest, exampleParams, loadExampleFile, playgroundLink } from '../../docs/enrich.js'
13 15 import { snippetsFor } from '../../docs/snippets.js'
14 16 import { errorResponses, listOperations, resolveRef, responseExample, useSpec } from '../../docs/spec.js'
15 17 import useTitle from '../../docs/useTitle.js'
@@ -40,29 +42,42 @@ function Operation({ op, spec, source, prev, next }) {
40 42 const specExample = useMemo(() => responseExample(spec, op), [spec, op])
41 43 const errors = useMemo(() => errorResponses(spec, op), [spec, op])
42 44 const [example, setExample] = useState(specExample ? { ...specExample, origin: 'spec' } : null)
45 + const [live, setLive] = useState({ state: 'idle' }) // idle | running | done | error
46 + const retryLeft = useCountdown(live.retryUntil || null)
47 + const canRunLive = op.method === 'GET' && PUBLIC_TAGS.has(op.tag) && !isStream && !needsAuth
43 48
44 49 useEffect(() => {
45 − if (specExample) return
50 + if (specExample) return undefined
46 51 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 − })()
52 + loadExampleFile(op.id).then(file => { if (file && alive) setExample({ code: file.status || 200, value: file.body ?? file, origin: 'captured', capturedAt: file.captured_at, url: file.url }) })
64 53 return () => { alive = false }
65 − }, [op.id, op.method, op.tag, req.rel, specExample, isStream])
54 + }, [op.id, specExample])
55 +
56 + const runLive = useCallback(async () => {
57 + if (liveCache.has(op.id)) { setExample(liveCache.get(op.id)); setLive({ state: 'done' }); return }
58 + setLive({ state: 'running' })
59 + try {
60 + const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 10000)
61 + const t0 = performance.now()
62 + const res = await fetch(BASE_URL + req.rel, { headers: { Accept: 'application/json' }, signal: ctl.signal })
63 + clearTimeout(t)
64 + const ms = Math.round(performance.now() - t0)
65 + const rate = rateHeaders(res)
66 + const ct = res.headers.get('content-type') || ''
67 + const body = ct.includes('json') ? await res.json() : { text: (await res.text()).slice(0, 2000) }
68 + if (res.status === 429) {
69 + const wait = rate.retryAfter != null ? rate.retryAfter : rate.reset ? rate.reset - Math.floor(Date.now() / 1000) : 60
70 + setLive({ state: 'error', status: 429, retryUntil: Math.floor(Date.now() / 1000) + Math.max(1, wait), message: body?.error?.message })
71 + return
72 + }
73 + const ex = { code: res.status, value: trimExample(body), origin: 'live', url: PUBLIC_BASE + req.rel, ms, rate }
74 + if (res.ok) liveCache.set(op.id, ex)
75 + setExample(ex)
76 + setLive({ state: 'done', status: res.status })
77 + } catch (e) {
78 + setLive({ state: 'error', status: 0, message: e.name === 'AbortError' ? 'The request timed out.' : 'The API could not be reached.' })
79 + }
80 + }, [op.id, req.rel])
66 81
67 82 const successCodes = Object.keys(op.responses).filter(c => c.startsWith('2'))
68 83 const success = successCodes.map(c => resolveRef(spec, op.responses[c]))
@@ -75,52 +90,67 @@ function Operation({ op, spec, source, prev, next }) {
75 90 { depth: 2, text: 'Response', id: 'response' }, { depth: 2, text: 'Errors', id: 'errors' },
76 91 ]
77 92
93 + const exampleTitle = example ? `Response · ${example.code}${example.origin === 'live' ? ` · live${example.ms ? ` · ${example.ms} ms` : ''}` : example.origin === 'captured' ? ' · captured' : ''}` : 'Response'
94 + const runBtn = canRunLive && (
95 + <button type="button" className="copy-btn run-live" onClick={runLive} disabled={live.state === 'running' || (live.status === 429 && retryLeft > 0)} data-testid="run-live" title="Fetch this example from the live API (counts against your quota)">
96 + <PlayIcon /> {live.state === 'running' ? 'Running…' : example?.origin === 'live' ? 'Run again' : 'Run live'}
97 + </button>
98 + )
99 + const exampleFooter = live.state === 'error'
100 + ? (live.status === 429
101 + ? <p className="source-note">Rate limited — not down. Your keyless quota is used up; retry in <span className="mono">{fmtDuration(retryLeft)}</span> or <Link to="/signup">create a free account</Link> (120 requests / minute).</p>
102 + : <p className="source-note">{live.message}</p>)
103 + : example?.origin === 'live' ? <p className="source-note">Fetched live from <code>{example.url}</code>{example.rate?.remainingRequests != null ? ` · ${example.rate.remainingRequests} keyless requests left this hour` : ''}.</p>
104 + : example?.origin === 'captured' ? <p className="source-note">Captured from production{example.capturedAt ? ` on ${example.capturedAt.slice(0, 10)}` : ''}{canRunLive ? ' — “Run live” fetches a fresh copy (one request of your quota).' : '.'}</p>
105 + : null
106 +
78 107 const panel = (
79 108 <>
109 + <OnThisPage headings={headings} />
80 110 <div className="panel-block">
81 111 <CodeTabs title={`${op.method} request`} snippets={snippets} maxHeight={420} />
82 112 </div>
83 113 <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>}
114 + <Code title={exampleTitle} language="json" maxHeight={520} actions={runBtn} footer={exampleFooter} data-testid="response-example"
115 + code={example ? JSON.stringify(example.value, null, 2) : `// No example in the spec yet${canRunLive ? ' — press “Run live” to fetch one.' : '.'}`} />
88 116 </div>
89 − <OnThisPage headings={headings} />
90 117 </>
91 118 )
92 119
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>
120 + const intro = (
121 + <header>
122 + <h1>{op.summary}</h1>
123 + <div className="op-head">
124 + <MethodBadge method={op.method} />
125 + <span className="op-path">{PUBLIC_BASE}{highlightPath(op.path)}</span>
126 + {op.deprecated && <Badge tone="warn">Deprecated</Badge>}
127 + {needsAuth ? <Badge tone="info">Requires API key</Badge> : <Badge tone="success">Public · keyless OK</Badge>}
128 + </div>
129 + {op.description ? <Markdown className="prose" text={op.description} headingOffset={2} /> : <p className="muted">No description in the specification yet.</p>}
130 + <div className="op-actions">
131 + {!isStream && <Link to={playgroundLink(op, values)} className="btn btn-primary" data-testid="try-it"><PlayIcon /> Try it in the playground</Link>}
132 + <a className="btn" href={`${PUBLIC_BASE}/openapi.json`}>OpenAPI JSON</a>
133 + </div>
134 + </header>
135 + )
109 136
137 + return (
138 + <DocPage crumbs={[{ label: 'Reference', href: '/docs/reference' }, { label: op.tag, href: `/docs/reference/tag/${op.tag}` }]} intro={intro} panel={panel} testId="operation-page"
139 + after={<PrevNext prev={prev && { href: `/docs/reference/${prev.id}`, title: prev.summary }} next={next && { href: `/docs/reference/${next.id}`, title: next.summary }} />}>
110 140 <section>
111 141 <h2 id="parameters">Parameters</h2>
112 142 {params.length === 0 && <p className="muted">This endpoint takes no parameters.</p>}
113 143 {params.length > 0 && (
114 − <div className="table-wrap">
144 + <div className="table-wrap table-stack params-table">
115 145 <table>
116 146 <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
117 147 <tbody>
118 148 {params.map(p => (
119 149 <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>
150 + <td data-label="Name"><span className="param-name">{p.name}</span><span className="param-in">{p.in}</span>{p.required && <span className="param-req">required</span>}</td>
151 + <td data-label="Type"><span className="param-type">{p.type}</span>{p.constraints.length > 0 && <div className="param-constraints">{p.constraints.join(' · ')}</div>}</td>
152 + <td data-label="Default">{p.default !== undefined ? <span className="param-default">{String(p.default)}</span> : <span className="muted">—</span>}</td>
153 + <td data-label="Description">
124 154 <div className="param-desc"><InlineMd text={p.description} /></div>
125 155 {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>}
126 156 {values[p.name] !== undefined && <div className="param-constraints">Example: <code>{values[p.name]}</code></div>}
@@ -177,8 +207,6 @@ function Operation({ op, spec, source, prev, next }) {
177 207 ))}
178 208 </div>
179 209 </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 210 </DocPage>
183 211 )
184 212 }
@@ -205,7 +233,7 @@ export function SchemaTable({ spec, schema, depth = 0 }) {
205 233 }
206 234 const required = new Set(s.required || resolveRef(spec, s.items)?.required || [])
207 235 return (
208 − <div className="table-wrap" style={{ marginBottom: 12 }}>
236 + <div className="table-wrap table-stack" style={{ marginBottom: 12 }}>
209 237 <table>
210 238 <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
211 239 <tbody>
@@ -215,9 +243,9 @@ export function SchemaTable({ spec, schema, depth = 0 }) {
215 243 const inner = (p.type === 'object' || p.type === 'array') && depth < 1 && (p.properties || resolveRef(spec, p.items)?.properties)
216 244 return (
217 245 <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>
246 + <td data-label="Field"><span className="param-name">{name}</span>{required.has(name) && <span className="param-req">required</span>}</td>
247 + <td data-label="Type"><span className="param-type">{type}{p.type === 'array' && p.items ? `<${resolveRef(spec, p.items)?.type || 'object'}>` : ''}{p.format ? ` (${p.format})` : ''}</span></td>
248 + <td data-label="Description">
221 249 <div className="param-desc"><InlineMd text={p.description || ''} /></div>
222 250 {p.enum && <div className="param-enum">{p.enum.map(v => <code key={String(v)}>{String(v)}</code>)}</div>}
223 251 {p.example !== undefined && <div className="param-constraints">Example: <code>{JSON.stringify(p.example)}</code></div>}
modified hfmarketdata/web/src/pages/docs/docs.css +68 −52
@@ -1,137 +1,153 @@
1 −/* Docs: 3 columns (nav · content · code panel). DOM order = visual order on every breakpoint. */
1 +/* Docs: 3 columns (nav 250 · content ≤ 720 · panel 420) from 1200 px. DOM order = visual order on every breakpoint:
2 + under 1200 the panel is rendered inside the article (see DocsLayout.jsx › DocPage). */
2 3 .docs { display: grid; grid-template-columns: 250px minmax(0, 1fr); flex: 1; width: 100%; max-width: 1480px; margin: 0 auto; }
3 4 .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 5 .docs-nav-section { margin-bottom: 18px; }
5 6 .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 { display: flex; align-items: center; min-height: var(--tap); padding: 4px 8px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-1); line-height: 1.35; }
7 8 .docs-nav a:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
8 9 .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 { gap: 7px; padding-left: 8px; font-size: var(--fs-1); }
10 11 .docs-nav .nav-op .method { min-width: 0; font-size: 9.5px; padding: 1px 4px; }
11 12 .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 { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: var(--tap); padding: 4px 8px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-1); font-weight: 500; }
13 14 .docs-nav summary::-webkit-details-marker { display: none; }
14 15 .docs-nav summary:hover { background: var(--bg-2); color: var(--fg); }
15 16 .docs-nav summary svg { width: 14px; height: 14px; transition: transform .15s; color: var(--fg-3); }
16 17 .docs-nav details[open] summary svg { transform: rotate(90deg); }
17 18 .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 { display: flex; align-items: center; gap: 8px; width: 100%; margin-bottom: 16px; min-height: var(--tap); padding: 0 10px; border-radius: var(--r-md); border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-2); font-size: var(--fs-1); cursor: text; text-align: left; }
19 20 .docs-nav-search kbd { margin-left: auto; }
20 21 .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 { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; min-height: calc(100dvh - var(--topbar-h)); }
22 23 .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 { min-width: 0; padding: 34px 40px 80px; max-width: 800px; }
24 25 .docs-content > header { margin-bottom: 26px; }
25 26 .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 { display: flex; gap: 6px; align-items: center; color: var(--fg-2); font-size: var(--fs-1); margin-bottom: 12px; flex-wrap: wrap; }
27 28 .docs-crumbs a { color: var(--fg-2); }
28 29 .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 { border-left: 1px solid var(--line); background: var(--bg-1); padding: 26px 22px 60px; min-width: 0; }
30 31 .docs-panel-inner { position: sticky; top: calc(var(--topbar-h) + 18px); display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 18px; max-height: calc(100dvh - var(--topbar-h) - 36px); overflow-y: auto; scrollbar-width: thin; padding-right: 2px; }
31 32 .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); }
33 +.docs-panel h4, .docs-panel-inline h4 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-3); margin: 0 0 8px; }
34 +.panel-block { display: grid; gap: 6px; min-width: 0; }
35 +.panel-block .code, .panel-block .codetabs { margin: 0; }
36 +.docs-panel-inline { display: grid; gap: 16px; margin: 0 0 30px; padding: 18px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); min-width: 0; }
37 +.docs-panel-inline .panel-toc { display: none; }
38 +.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: var(--fs-1); }
39 +.docs-footer a { display: inline-flex; align-items: center; gap: 5px; min-height: var(--tap); color: var(--fg-1); }
36 40 .docs-footer svg { width: 14px; height: 14px; }
37 41 .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); }
42 +.docs-prevnext a { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-md); color: var(--fg); }
39 43 .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; }
44 +.docs-prevnext .lbl { display: block; font-size: var(--fs-0); color: var(--fg-2); margin-bottom: 4px; }
41 45 .docs-prevnext .next { text-align: right; }
42 46
43 47 /* prose (MDX + markdown descriptions) */
44 −.prose { font-size: 15.5px; line-height: 1.7; color: var(--fg); overflow-wrap: anywhere; min-width: 0; }
48 +.prose { font-size: 16px; line-height: var(--lh-prose); color: var(--fg); overflow-wrap: anywhere; min-width: 0; }
45 49 .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; }
50 +.prose h2 { margin-top: 2.2em; padding-top: .3em; font-size: var(--fs-6); }
51 +.prose h3 { margin-top: 1.8em; font-size: var(--fs-4); }
52 +.prose h4 { margin-top: 1.4em; font-size: var(--fs-3); }
49 53 .prose ul, .prose ol { padding-left: 1.4em; margin: 0 0 1em; }
50 54 .prose li + li { margin-top: 5px; }
51 55 .prose li > p { margin: 0; }
52 −.prose img { border-radius: var(--radius); border: 1px solid var(--line); }
56 +.prose img { border-radius: var(--r-md); border: 1px solid var(--line); }
53 57 .prose .table-wrap { margin: 0 0 20px; }
54 −.prose table { font-size: 13.5px; }
58 +.prose table { font-size: var(--fs-1); }
55 59 .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); }
60 +.prose blockquote { margin: 0 0 16px; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-1); border-radius: 0 var(--r-md) var(--r-md) 0; color: var(--fg-1); }
57 61 .prose hr { margin: 36px 0; }
58 62 .prose .code, .prose .codetabs, .prose .callout { margin: 16px 0 22px; }
63 +/* Heading anchors: the "#" is drawn by CSS so it never enters the heading's textContent. */
59 64 .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; }
65 +.anchor-heading .anchor { position: absolute; left: -1.1em; top: 0; bottom: 0; display: inline-flex; align-items: center; color: var(--fg-3); opacity: 0; font-weight: 400; padding-right: .3em; text-decoration: none; }
66 +.anchor-heading .anchor::before { content: "#"; }
67 +.anchor-heading:hover .anchor, .anchor-heading .anchor:focus-visible { opacity: 1; }
62 68 .figure { margin: 18px 0 26px; }
63 69 .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; }
70 +.figure figcaption { font-size: var(--fs-1); color: var(--fg-2); margin-top: 8px; text-align: center; }
71 +.endpoint-chip { display: inline-flex; align-items: center; gap: 8px; min-height: var(--tap); padding: 0 10px 0 5px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); color: var(--fg); margin: 2px 4px 6px 0; font-size: var(--fs-1); }
66 72 .endpoint-chip:hover { border-color: var(--line-3); text-decoration: none; }
67 73 .endpoint-chip code { background: none; border: 0; padding: 0; }
68 74
69 75 /* 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; }
76 +.toc a { display: flex; align-items: center; min-height: 32px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line); color: var(--fg-2); font-size: var(--fs-1); line-height: 1.4; }
71 77 .toc a.depth-3 { padding-left: 22px; }
72 78 .toc a:hover { color: var(--fg); text-decoration: none; }
73 79 .toc a.active { color: var(--fg); border-left-color: var(--accent); }
74 80
75 81 /* operation page */
76 82 .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; }
83 +.op-path { font-family: var(--mono); font-size: var(--fs-3); word-break: break-all; }
78 84 .op-path .var { color: var(--accent-2); }
79 85 .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; }
86 +.param-name { font-family: var(--mono); font-size: var(--fs-1); font-weight: 600; }
81 87 .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); }
88 +.param-type { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); }
83 89 .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); }
90 +.param-default { font-family: var(--mono); font-size: var(--fs-0); color: var(--fg-2); }
85 91 .param-enum { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
86 92 .param-enum code { font-size: 11.5px; }
87 −.param-desc { color: var(--fg-1); font-size: 13.5px; }
93 +.param-desc { color: var(--fg-1); font-size: var(--fs-1); }
88 94 .param-desc p { margin: 0; }
89 −.param-constraints { font-size: 12px; color: var(--fg-3); margin-top: 3px; }
95 +.param-constraints { font-size: var(--fs-0); color: var(--fg-3); margin-top: 3px; }
96 +.params-table td { vertical-align: top; }
90 97 .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); }
98 +.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: var(--fs-1); }
99 +.error-row code { font-size: var(--fs-0); }
100 +.headers-list dt { font-family: var(--mono); font-size: var(--fs-0); font-weight: 600; margin-top: 8px; }
101 +.headers-list dd { margin: 2px 0 0; color: var(--fg-1); font-size: var(--fs-1); }
102 +.source-note { font-size: var(--fs-0); color: var(--fg-3); margin: 0; }
103 +.run-live { display: inline-flex; align-items: center; gap: 5px; }
104 +.run-live svg { width: 12px; height: 12px; }
96 105
97 106 /* reference index */
98 107 .ref-tag { margin-bottom: 36px; }
99 108 .ref-tag h2 { text-transform: capitalize; display: flex; align-items: baseline; gap: 10px; }
100 109 .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); }
110 +.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(--r-md); color: var(--fg); }
102 111 .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; }
112 +.ref-op .op-path { font-size: var(--fs-1); }
113 +.ref-op .sum { color: var(--fg-2); font-size: var(--fs-1); margin-top: 2px; }
105 114
106 115 /* docs home */
107 116 .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); }
117 +.docs-home-card { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); color: var(--fg); background: var(--bg-1); }
109 118 .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; }
119 +.docs-home-card h3 { font-size: var(--fs-3); margin-bottom: 4px; }
120 +.docs-home-card p { margin: 0; color: var(--fg-2); font-size: var(--fs-1); }
112 121
113 122 /* error code page */
114 123 .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; }
124 +.error-code-block h3 { font-family: var(--mono); font-size: var(--fs-3); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
125 +.error-code-block h3 .anchor { position: static; opacity: 1; padding: 0; color: var(--fg-3); }
116 126
117 127 /* mobile nav toggle */
118 128 .docs-nav-toggle { display: none; }
119 129
120 −@media (max-width: 1180px) {
130 +@media (max-width: 1199px) {
121 131 .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 132 .docs-content { max-width: 900px; }
125 133 }
126 −@media (max-width: 900px) {
134 +@media (max-width: 960px) {
127 135 .docs { grid-template-columns: 1fr; }
128 136 .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); background-color: var(--bg); isolation: isolate; 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); }
137 + .docs-nav.open { display: block; position: fixed; inset: var(--topbar-h) 0 0 0; z-index: calc(var(--z-sticky) + 5); height: auto; background: var(--bg); isolation: isolate; border-right: 0; padding: 16px; }
138 + .docs-nav a, .docs-nav summary { min-height: var(--tap-mobile); }
139 + .docs-nav-toggle { display: flex; align-items: center; gap: 8px; padding: 6px 16px; border-bottom: 1px solid var(--line); background: var(--bg-1); position: sticky; top: var(--topbar-h); z-index: var(--z-sticky); font-size: var(--fs-1); color: var(--fg-1); min-height: 48px; }
131 140 .docs-nav-toggle button { display: inline-flex; align-items: center; gap: 6px; }
132 141 .docs-content { padding: 24px 16px 60px; }
133 − .docs-panel { padding: 18px 16px 40px; }
142 + .docs-panel-inline { padding: 14px 12px; }
134 143 .docs-prevnext { grid-template-columns: 1fr; }
144 + .docs-prevnext .next { text-align: left; }
135 145 .docs-home-grid { grid-template-columns: 1fr; }
136 146 .anchor-heading .anchor { display: none; }
147 + .prose { font-size: 15.5px; }
148 + .op-actions .btn { flex: 1 1 auto; }
149 +}
150 +@media (max-width: 640px) {
151 + .error-row { grid-template-columns: 1fr; gap: 4px; }
152 + .params-table.table-stack td { grid-template-columns: 84px minmax(0, 1fr); }
137 153 }
modified hfmarketdata/web/src/pages/home/Home.jsx +212 −171
@@ -1,35 +1,33 @@
1 −// Homepage: hero, get-a-key steps, live playground preview, integrations, live dataset counters, fundamentals chart.
2 −// Every live call degrades gracefully to a static sample; no external requests besides the API itself.
1 +// Homepage. Rule: the page makes at most ONE API call on load — GET /v1/status (quota-exempt) — and falls back
2 +// silently to the captured copy in content/examples. Everything else (hero response, charts) is real data captured
3 +// from production and bundled. Charts load lazily (lightweight-charts chunk) when scrolled into view.
3 4 import React, { useEffect, useMemo, useRef, useState } from 'react'
4 5 import { Link } from 'react-router-dom'
5 −import { BASE_URL, CONTACT_EMAIL, HIGH_USAGE_MAILTO, PUBLIC_BASE, api } from '../../app/api.js'
6 −import Code, { CopyButton } from '../../components/Code.jsx'
6 +import { CONTACT_EMAIL, HIGH_USAGE_MAILTO, PUBLIC_BASE, api } from '../../app/api.js'
7 +import BrandLogo from '../../components/BrandLogos.jsx'
8 +import CodeBlock from '../../components/CodeBlock.jsx'
7 9 import CodeTabs from '../../components/CodeTabs.jsx'
10 +import CopyButton from '../../components/CopyButton.jsx'
8 11 import { ArrowRightIcon, BoltIcon, DatabaseIcon, KeyIcon, MailIcon, PlayIcon, PulseIcon } from '../../components/Icons.jsx'
12 +import { Skeleton } from '../../components/States.jsx'
13 +import heroExample from '../../../content/examples/bars_v1_bars__asset___ticker__get.json'
14 +import statusFallback from '../../../content/examples/status_v1_status_get.json'
9 15 import { snippetsFor } from '../../docs/snippets.js'
10 16 import useTitle from '../../docs/useTitle.js'
11 17 import './home.css'
12 18
13 −const PREVIEW_PATH = '/v1/bars/futures/ES?timeframe=1day&limit=5'
14 −// Real response captured from production on 2026-09-04 (first five daily ES continuous bars, contin_UNadj).
15 −const PREVIEW_FALLBACK = { count: 5, data: [
16 − { ticker: 'ES', datetime: '2008-01-02', open: 1478.75, high: 1482.75, low: 1449.0, close: 1458.5, volume: 1623508.0, open_interest: 1764399.0 },
17 − { ticker: 'ES', datetime: '2008-01-03', open: 1458.5, high: 1464.75, low: 1451.0, close: 1458.75, volume: 1266237.0, open_interest: 1758867.0 },
18 − { ticker: 'ES', datetime: '2008-01-04', open: 1458.75, high: 1463.25, low: 1417.5, close: 1423.0, volume: 2199479.0, open_interest: 1882526.0 },
19 − { ticker: 'ES', datetime: '2008-01-07', open: 1422.5, high: 1432.25, low: 1410.0, close: 1421.5, volume: 2436934.0, open_interest: 1884889.0 },
20 − { ticker: 'ES', datetime: '2008-01-08', open: 1421.75, high: 1437.75, low: 1393.0, close: 1397.0, volume: 2902242.0, open_interest: 1979612.0 },
21 −] }
22 −
23 19 const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US'))
24 20
25 21 export default function Home() {
26 22 useTitle(null, 'Free open API for high-frequency historical market data: stocks, ETFs, futures contracts, crypto, indices, FX from 1-minute to daily, options chains with Greeks and SEC EDGAR fundamentals since 2010.')
23 + const status = useStatus()
27 24 return (
28 25 <main className="home" data-testid="home">
29 26 <Hero />
30 − <Datasets />
31 − <GetKey />
27 + <Numbers status={status} />
28 + <HowItWorks />
32 29 <Preview />
30 + <Datasets status={status} />
33 31 <Fundamentals />
34 32 <Integrations />
35 33 <Closing />
@@ -37,122 +35,96 @@ export default function Home() {
37 35 )
38 36 }
39 37
38 +/** /v1/status is exempt from the quota; on any failure (offline, 429 on an old server) the captured copy is used silently. */
39 +function useStatus() {
40 + const [status, setStatus] = useState({ data: statusFallback.body, live: false })
41 + useEffect(() => {
42 + let alive = true
43 + api('/v1/status').then(({ data }) => { if (alive && data?.datasets) setStatus({ data, live: true }) }, () => {})
44 + return () => { alive = false }
45 + }, [])
46 + return status
47 +}
48 +
49 +export function countInstruments(datasets, asset) {
50 + const d = datasets?.[asset]
51 + if (!d) return null
52 + const tf = d['1day'] || d['1min'] || Object.values(d)[0]
53 + if (!tf || typeof tf !== 'object') return null
54 + return Math.max(...Object.values(tf).map(Number).filter(Number.isFinite))
55 +}
56 +
57 +// ---------------------------------------------------------------------------------------------------- hero
40 58 function Hero() {
41 − const curl = `curl "${PUBLIC_BASE}/v1/bars/stock/AAPL?timeframe=1min&limit=5"`
59 + const url = heroExample.url
60 + const snippets = useMemo(() => snippetsFor({ url }), [url])
61 + const curl = `curl "${url}"`
42 62 return (
43 63 <section className="hero">
44 64 <div className="hero-inner">
45 65 <p className="eyebrow">Free · open · no signup to start</p>
46 − <h1>Open high-frequency market data.<br /><span className="grad">1-minute to daily, since 2010.</span></h1>
47 − <p className="lead">Stocks, ETFs, futures — continuous series <em>and</em> every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, generated docs, a playground, and limits that only grow.</p>
66 + <h1>Open high-frequency market data.<br />1-minute to daily, since 2010.</h1>
67 + <p className="lead">Stocks, ETFs, futures — continuous series and every individual contract — crypto, indices and FX, plus full options chains with Greeks and point-in-time SEC fundamentals. One base URL, JSON / CSV / Parquet, docs generated from the spec, a playground, and limits that only grow.</p>
48 68 <div className="hero-cta">
49 69 <Link to="/docs/quickstart" className="btn btn-primary btn-lg">Read the quickstart <ArrowRightIcon /></Link>
50 70 <Link to="/playground" className="btn btn-lg"><PlayIcon /> Open the playground</Link>
51 71 </div>
52 72 <div className="hero-curl" role="group" aria-label="First request">
53 73 <code>{curl}</code>
54 − <CopyButton text={curl} />
74 + <CopyButton text={curl} iconOnly label="Copy the curl command" />
55 75 </div>
56 76 </div>
57 − <HeroArt />
77 + <div className="hero-code" aria-label="A real request and its response">
78 + <CodeTabs title="Request" snippets={snippets} maxHeight={200} />
79 + <CodeBlock title={`Response · ${heroExample.status} · captured ${heroExample.captured_at.slice(0, 10)}`} language="json" maxHeight={260} code={JSON.stringify(heroExample.body, null, 2)} data-testid="hero-response" />
80 + </div>
58 81 </section>
59 82 )
60 83 }
61 84
62 −function HeroArt() {
63 − // Decorative candlesticks drawn from the fallback ES bars (real data), scaled — no external asset.
64 − const bars = PREVIEW_FALLBACK.data
65 − const lo = Math.min(...bars.map(b => b.low)), hi = Math.max(...bars.map(b => b.high))
66 − const y = v => 20 + (1 - (v - lo) / (hi - lo)) * 160
67 − return (
68 − <svg className="hero-art" viewBox="0 0 320 200" aria-hidden="true">
69 − <defs><linearGradient id="hg" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stopColor="var(--accent)" stopOpacity=".35" /><stop offset="1" stopColor="var(--accent)" stopOpacity="0" /></linearGradient></defs>
70 − {[0, 1, 2, 3, 4].map(i => <line key={i} x1="0" x2="320" y1={20 + i * 40} y2={20 + i * 40} stroke="var(--line)" />)}
71 − {bars.map((b, i) => {
72 − const x = 40 + i * 60
73 − const up = b.close >= b.open
74 − return (
75 − <g key={b.datetime}>
76 − <line x1={x} x2={x} y1={y(b.high)} y2={y(b.low)} stroke={up ? 'var(--accent)' : 'var(--danger)'} strokeWidth="2" />
77 − <rect x={x - 12} width="24" y={y(Math.max(b.open, b.close))} height={Math.max(2, Math.abs(y(b.open) - y(b.close)))} fill={up ? 'var(--accent)' : 'var(--danger)'} rx="2" />
78 − </g>
79 − )
80 − })}
81 − <path d={`M40 ${y(bars[0].close)} ${bars.map((b, i) => `L${40 + i * 60} ${y(b.close)}`).join(' ')} L280 200 L40 200 Z`} fill="url(#hg)" />
82 − </svg>
83 − )
84 −}
85 −
86 −function useStatus() {
87 − const [status, setStatus] = useState(null)
88 − useEffect(() => {
89 − let alive = true
90 − api('/v1/status').then(({ data }) => alive && setStatus(data), () => {})
91 − return () => { alive = false }
92 − }, [])
93 − return status
94 −}
95 −
96 −export function countInstruments(datasets, asset) {
97 − const d = datasets?.[asset]
98 − if (!d) return null
99 − const tf = d['1day'] || d['1min'] || Object.values(d)[0]
100 − if (!tf || typeof tf !== 'object') return null
101 − return Math.max(...Object.values(tf).map(Number).filter(Number.isFinite))
102 −}
103 −
104 −function Datasets() {
105 − const status = useStatus()
106 − const ds = status?.datasets
107 − const cards = [
108 − { k: 'stock', label: 'Stocks', sub: 'US equities · 5 timeframes · 3 adjustments' },
109 − { k: 'etf', label: 'ETFs', sub: 'same variants as stocks' },
110 − { k: 'futures', label: 'Futures roots', sub: 'continuous · unadjusted, ratio, absolute' },
111 − { k: 'futures_contracts', label: 'Futures contracts', sub: 'individual expiries, daily to 1-minute' },
112 − { k: 'crypto', label: 'Crypto pairs', sub: '24 × 7 bars' },
113 − { k: 'index', label: 'Indices', sub: 'equity & volatility indices' },
114 − { k: 'fx', label: 'FX pairs', sub: 'majors, crosses, EM' },
85 +// ---------------------------------------------------------------------------------------------------- numbers
86 +function Numbers({ status }) {
87 + const ds = status.data?.datasets
88 + const quarters = ds?.options?.quarters || []
89 + const firstYear = quarters[0] ? Number(quarters[0].slice(0, 4)) : null
90 + const years = firstYear ? new Date().getUTCFullYear() - firstYear : null
91 + const items = [
92 + { v: countInstruments(ds, 'stock'), l: 'US stocks', s: '5 timeframes · 3 adjustments' },
93 + { v: countInstruments(ds, 'etf'), l: 'ETFs', s: 'same variants as stocks' },
94 + { v: ds?.futures_contracts?.['1day']?.archive, l: 'Futures contracts', s: `individual expiries · ${fmt(countInstruments(ds, 'futures'))} roots` },
95 + { v: ds?.options?.files_latest_quarter, l: 'Optionable underlyings', s: `${fmt(quarters.length)} quarterly chains with Greeks` },
96 + { v: years != null ? `${years}+` : null, l: 'Years of history', s: firstYear ? `${firstYear} → today · point-in-time fundamentals` : '' },
115 97 ]
116 − const quarters = ds?.options?.quarters?.length
117 98 return (
118 − <section className="section datasets" aria-labelledby="datasets-h">
119 − <div className="section-head">
120 − <p className="eyebrow"><DatabaseIcon /> Datasets</p>
121 − <h2 id="datasets-h">What is in the lake — live from <code>/v1/status</code></h2>
122 − <p className="muted">Counts refresh from the API each time this page loads. {status ? '' : 'Loading live counts…'}</p>
123 − </div>
124 − <div className="counters" data-testid="counters">
125 − {cards.map(c => (
126 − <div key={c.k} className="counter">
127 − <div className="counter-n">{ds ? fmt(countInstruments(ds, c.k)) : <span className="skeleton" />}</div>
128 − <div className="counter-l">{c.label}</div>
129 − <div className="counter-s">{c.sub}</div>
99 + <section className="numbers" aria-label="Inventory">
100 + <div className="numbers-inner" data-testid="counters">
101 + {items.map(it => (
102 + <div key={it.l} className="number">
103 + <div className="number-v">{it.v == null ? <Skeleton width={80} height={28} /> : (typeof it.v === 'string' ? it.v : fmt(it.v))}</div>
104 + <div className="number-l">{it.l}</div>
105 + <div className="number-s">{it.s}</div>
130 106 </div>
131 107 ))}
132 − <div className="counter">
133 − <div className="counter-n">{ds ? fmt(quarters) : <span className="skeleton" />}</div>
134 − <div className="counter-l">Options quarters</div>
135 − <div className="counter-s">end-of-day chains with Greeks{ds?.options?.files_latest_quarter ? ` · ${fmt(ds.options.files_latest_quarter)} underlyings last quarter` : ''}</div>
136 − </div>
137 108 </div>
138 − <p className="muted small">Full inventory by timeframe and adjustment on the <Link to="/status">status page</Link>.</p>
109 + <p className="numbers-note muted small">Counts from <code>GET /v1/status</code>{status.live ? ', live' : ` as captured on ${statusFallback.captured_at.slice(0, 10)}`} · full inventory on the <Link to="/status">status page</Link>.</p>
139 110 </section>
140 111 )
141 112 }
142 113
143 −function GetKey() {
114 +// ---------------------------------------------------------------------------------------------------- how it works
115 +function HowItWorks() {
144 116 return (
145 − <section className="section getkey" aria-labelledby="getkey-h">
117 + <section className="section how" aria-labelledby="how-h">
146 118 <div className="section-head">
147 − <p className="eyebrow"><KeyIcon /> Access</p>
148 − <h2 id="getkey-h">Get an API key — or don't</h2>
119 + <p className="eyebrow"><KeyIcon /> How it works</p>
120 + <h2 id="how-h">Start with a URL. Grow into a key.</h2>
149 121 <p className="muted">Everything is free. The three levels differ only in how much you can pull per window.</p>
150 122 </div>
151 123 <ol className="steps">
152 124 <li className="step">
153 125 <span className="step-n">1</span>
154 − <h3>Start keyless</h3>
155 − <p>Paste a URL. Per IP: <strong>30 requests / hour</strong>, 100 000 rows / hour, 5 000 rows per request. Enough to explore every endpoint.</p>
126 + <h3>Paste a URL</h3>
127 + <p>No account, no key. Per IP: <strong>30 requests / hour</strong>, 100 000 rows / hour, 5 000 rows per request — enough to explore every endpoint.</p>
156 128 <Link to="/playground" className="btn btn-sm">Try in the playground</Link>
157 129 </li>
158 130 <li className="step step-featured">
@@ -163,83 +135,146 @@ function GetKey() {
163 135 </li>
164 136 <li className="step">
165 137 <span className="step-n">3</span>
166 − <h3>Ask for higher limits</h3>
167 − <p>Building something bigger? E-mail <a href={HIGH_USAGE_MAILTO}>{CONTACT_EMAIL}</a> and your key moves to <strong>600 requests and 10 000 000 rows / minute</strong>. Also free — on request.</p>
138 + <h3>Ask for more</h3>
139 + <p>Building something bigger? E-mail <a href={HIGH_USAGE_MAILTO}>{CONTACT_EMAIL}</a> and your key moves to <strong>600 requests and 10 000 000 rows / minute</strong>. Still free.</p>
168 140 <a href={HIGH_USAGE_MAILTO} className="btn btn-sm"><MailIcon /> Request higher limits</a>
169 141 </li>
170 142 </ol>
171 − <p className="muted small">Parquet counts half, bulk downloads count zero, 304 responses are free. <Link to="/limits">All limits &amp; how they are counted →</Link></p>
143 + <p className="muted small">Parquet counts half, bulk downloads count zero, 304 responses are free. <Link to="/limits">All limits and how they are counted →</Link></p>
172 144 </section>
173 145 )
174 146 }
175 147
176 −function Preview() {
177 − const [state, setState] = useState({ body: PREVIEW_FALLBACK, live: false, ms: null, rate: null })
148 +// ---------------------------------------------------------------------------------------------------- chart helpers
149 +/** Runs `draw(el, lib)` with lightweight-charts once the element is near the viewport. Returns the container ref. */
150 +function useLazyChart(draw, deps = []) {
151 + const ref = useRef(null)
178 152 useEffect(() => {
179 − let alive = true
180 − api(PREVIEW_PATH).then(({ data, ms, rate }) => alive && setState({ body: data, live: true, ms, rate }), () => {})
181 − return () => { alive = false }
182 − }, [])
183 − const snippets = useMemo(() => snippetsFor({ url: PUBLIC_BASE + PREVIEW_PATH }), [])
153 + const el = ref.current
154 + if (!el) return undefined
155 + let alive = true, cleanup = null
156 + const start = async () => {
157 + const lib = await import('lightweight-charts')
158 + if (!alive || !ref.current) return
159 + cleanup = await draw(ref.current, lib)
160 + }
161 + const io = typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) { io.disconnect(); start() } }, { rootMargin: '200px' }) : null
162 + if (io) io.observe(el); else start()
163 + return () => { alive = false; io?.disconnect(); if (typeof cleanup === 'function') cleanup() }
164 + }, deps) // eslint-disable-line react-hooks/exhaustive-deps
165 + return ref
166 +}
167 +
168 +const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
169 +
170 +function chartOptions({ createChart, LineStyle, ColorType }, el, height) {
171 + return createChart(el, {
172 + height, autoSize: true,
173 + layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: cssVar('--fg-2'), fontFamily: cssVar('--sans'), attributionLogo: true },
174 + grid: { vertLines: { color: cssVar('--line'), style: LineStyle.Dotted }, horzLines: { color: cssVar('--line'), style: LineStyle.Dotted } },
175 + rightPriceScale: { borderVisible: false }, timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
176 + handleScroll: false, handleScale: false, crosshair: { mode: 1 },
177 + })
178 +}
179 +
180 +// ---------------------------------------------------------------------------------------------------- preview
181 +function Preview() {
182 + const [meta, setMeta] = useState(null)
183 + const ref = useLazyChart(async (el, lib) => {
184 + const mod = await import('../../../content/examples/home-aapl-daily.json')
185 + const sample = mod.default || mod
186 + const rows = sample.rows.map(([time, open, high, low, close]) => ({ time, open, high, low, close }))
187 + const chart = chartOptions(lib, el, 300)
188 + const series = chart.addCandlestickSeries({ upColor: cssVar('--up'), downColor: cssVar('--down'), wickUpColor: cssVar('--up'), wickDownColor: cssVar('--down'), borderVisible: false })
189 + series.setData(rows)
190 + chart.timeScale().fitContent()
191 + const onTheme = () => { chart.applyOptions({ layout: { textColor: cssVar('--fg-2') }, grid: { vertLines: { color: cssVar('--line') }, horzLines: { color: cssVar('--line') } } }); series.applyOptions({ upColor: cssVar('--up'), downColor: cssVar('--down'), wickUpColor: cssVar('--up'), wickDownColor: cssVar('--down') }) }
192 + window.addEventListener('hfmd:theme', onTheme)
193 + const last = rows.at(-1), first = rows[0]
194 + setMeta({ n: rows.length, first: first.time, last: last.time, close: last.close, change: (last.close / first.close - 1) * 100, captured: sample.captured_at.slice(0, 10) })
195 + return () => { window.removeEventListener('hfmd:theme', onTheme); chart.remove() }
196 + })
184 197 return (
185 198 <section className="section preview" aria-labelledby="preview-h">
186 199 <div className="section-head">
187 200 <p className="eyebrow"><PlayIcon /> Playground</p>
188 − <h2 id="preview-h">A real request, a real response</h2>
189 − <p className="muted">Five daily bars of the E-mini S&amp;P 500 continuous series — {state.live ? <>fetched live in <strong>{state.ms} ms</strong>{state.rate?.remainingRequests != null ? <>, {fmt(state.rate.remainingRequests)} keyless requests left this hour</> : ''}</> : 'captured from production (the live call did not complete)'}. Change anything in the <Link to="/playground">playground</Link>.</p>
190 − </div>
191 − <div className="preview-grid">
192 − <CodeTabs title="Request" snippets={snippets} maxHeight={360} />
193 − <Code title={`Response · 200${state.live ? ' · live' : ' · captured'}`} language="json" maxHeight={360} code={JSON.stringify(state.body, null, 2)} />
201 + <h2 id="preview-h">A real request, a real response — charted</h2>
202 + <p className="muted">One year of daily AAPL bars from <code>/v1/bars/stock/AAPL?timeframe=1day</code>, captured from production and drawn with the same chart the playground uses. Change the symbol, the timeframe or the asset class there.</p>
194 203 </div>
204 + <figure className="preview-card card">
205 + <figcaption className="preview-cap">
206 + <span><strong>AAPL</strong> · daily · split &amp; dividend adjusted</span>
207 + {meta ? <span className="muted small">{meta.first} → {meta.last} · {meta.n} sessions · last close <span className="mono">{meta.close.toFixed(2)}</span> · <span className={meta.change >= 0 ? 'up' : 'down'}>{meta.change >= 0 ? '+' : ''}{meta.change.toFixed(1)} %</span> · captured {meta.captured}</span> : <Skeleton width={260} height={14} />}
208 + </figcaption>
209 + <div ref={ref} className="chart chart-lg" data-testid="preview-chart" role="img" aria-label="Candlestick chart of one year of daily AAPL bars" />
210 + </figure>
195 211 <div className="preview-actions">
196 − <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5" className="btn btn-primary">Open this request in the playground <ArrowRightIcon /></Link>
197 − <Link to="/docs/reference" className="btn">Browse all endpoints</Link>
212 + <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=stock&ticker=AAPL&timeframe=1day&start=2025-09-02&limit=300" className="btn btn-primary">Open this request in the playground <ArrowRightIcon /></Link>
213 + <Link to="/playground?ep=bars_v1_bars__asset___ticker__get&asset=futures&ticker=ES&timeframe=1day&limit=5" className="btn">Same for ES futures</Link>
214 + <Link to="/docs/reference" className="btn btn-ghost">Browse all endpoints</Link>
198 215 </div>
199 216 </section>
200 217 )
201 218 }
202 219
220 +// ---------------------------------------------------------------------------------------------------- datasets
221 +const DATASET_CARDS = [
222 + { k: 'stock', label: 'Stocks', sub: 'US equities · 1min → 1day · split, split+dividend, unadjusted', ep: 'stock-bars' },
223 + { k: 'etf', label: 'ETFs', sub: 'same variants as stocks', ep: 'stock-bars&asset=etf&ticker=SPY' },
224 + { k: 'futures', label: 'Futures roots', sub: 'continuous: unadjusted, ratio, absolute', ep: 'futures-continuous-legacy' },
225 + { k: 'futures_contracts', label: 'Futures contracts', sub: 'individual expiries, daily to 1-minute', ep: 'futures-contract-bars' },
226 + { k: 'crypto', label: 'Crypto pairs', sub: '24 × 7 bars', ep: 'crypto-bars' },
227 + { k: 'index', label: 'Indices', sub: 'equity and volatility indices', ep: 'index-bars' },
228 + { k: 'fx', label: 'FX pairs', sub: 'majors, crosses, EM', ep: 'fx-bars' },
229 +]
230 +
231 +function Datasets({ status }) {
232 + const ds = status.data?.datasets
233 + const q = ds?.options?.quarters
234 + return (
235 + <section className="section datasets" aria-labelledby="datasets-h">
236 + <div className="section-head">
237 + <p className="eyebrow"><DatabaseIcon /> Datasets</p>
238 + <h2 id="datasets-h">What is in the lake</h2>
239 + <p className="muted">Instrument counts per asset class, from <code>/v1/status</code>. Every dataset is available at every access level.</p>
240 + </div>
241 + <div className="dataset-grid">
242 + {DATASET_CARDS.map(c => {
243 + const n = c.k === 'futures_contracts' ? ds?.futures_contracts?.['1day']?.archive : countInstruments(ds, c.k)
244 + return (
245 + <Link key={c.k} to={`/playground?ep=${c.ep}`} className="dataset">
246 + <span className="dataset-n">{n == null ? <Skeleton width={70} height={24} /> : fmt(n)}</span>
247 + <span className="dataset-l">{c.label}</span>
248 + <span className="dataset-s">{c.sub}</span>
249 + </Link>
250 + )
251 + })}
252 + <Link to="/playground?ep=options-chain" className="dataset">
253 + <span className="dataset-n">{q ? fmt(q.length) : <Skeleton width={70} height={24} />}</span>
254 + <span className="dataset-l">Options quarters</span>
255 + <span className="dataset-s">end-of-day chains with Greeks{q ? ` · ${q[0].slice(0, 4)} → ${q.at(-1).slice(0, 4)}` : ''}</span>
256 + </Link>
257 + </div>
258 + </section>
259 + )
260 +}
261 +
262 +// ---------------------------------------------------------------------------------------------------- fundamentals
203 263 function Fundamentals() {
204 − const ref = useRef(null)
205 − const [meta, setMeta] = useState({ source: null, points: 0, first: null, last: null, latest: null })
206 − useEffect(() => {
207 − let alive = true, chart = null, onResize = null
208 − ;(async () => {
209 − let points = null, source = null
210 − try {
211 − const { data } = await api('/v1/fundamentals/AAPL/ratios/daily?from=2015-01-01&limit=50000')
212 − const rows = data?.data || []
213 − const pick = r => r.pe ?? r.pe_ratio ?? r.price_to_earnings
214 − const pts = rows.filter(r => pick(r) != null).map(r => ({ time: (r.date || r.datetime || '').slice(0, 10), value: Number(pick(r)) }))
215 − if (pts.length > 50) { points = pts; source = 'live' }
216 − } catch { /* not live yet */ }
217 − if (!points) {
218 − const mod = await import('../../../content/samples/aapl-pe.json')
219 − const sample = mod.default || mod
220 − points = sample.points.map(p => ({ time: p.date, value: p.pe }))
221 − source = 'sample'
222 − }
223 − if (!alive || !ref.current) return
224 − const { createChart, LineStyle } = await import('lightweight-charts')
225 − if (!alive || !ref.current) return
226 − const dark = document.documentElement.getAttribute('data-theme') !== 'light'
227 − const colors = () => ({ text: dark ? '#aeb6c4' : '#66707f', grid: dark ? '#232833' : '#e2e5eb' })
228 − chart = createChart(ref.current, {
229 − height: 260, layout: { background: { color: 'transparent' }, textColor: colors().text, fontFamily: 'inherit', attributionLogo: false },
230 − grid: { vertLines: { color: colors().grid, style: LineStyle.Dotted }, horzLines: { color: colors().grid, style: LineStyle.Dotted } },
231 − rightPriceScale: { borderVisible: false }, timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
232 − handleScroll: false, handleScale: false, crosshair: { mode: 1 },
233 − })
234 − const series = chart.addLineSeries({ color: dark ? '#5ee7a5' : '#0d8a55', lineWidth: 2, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'price', precision: 1, minMove: 0.1 } })
235 − series.setData(points)
236 − chart.timeScale().fitContent()
237 − onResize = () => chart.applyOptions({ width: ref.current.clientWidth })
238 − onResize(); window.addEventListener('resize', onResize)
239 − setMeta({ source, points: points.length, first: points[0].time.slice(0, 4), last: points.at(-1).time.slice(0, 4), latest: points.at(-1).value })
240 − })()
241 − return () => { alive = false; if (onResize) window.removeEventListener('resize', onResize); chart?.remove() }
242 − }, [])
264 + const [meta, setMeta] = useState(null)
265 + const ref = useLazyChart(async (el, lib) => {
266 + const mod = await import('../../../content/samples/aapl-pe.json')
267 + const sample = mod.default || mod
268 + const points = sample.points.map(p => ({ time: p.date, value: p.pe }))
269 + const chart = chartOptions(lib, el, 240)
270 + const series = chart.addLineSeries({ color: cssVar('--accent'), lineWidth: 2, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'price', precision: 1, minMove: 0.1 } })
271 + series.setData(points)
272 + chart.timeScale().fitContent()
273 + const onTheme = () => { chart.applyOptions({ layout: { textColor: cssVar('--fg-2') }, grid: { vertLines: { color: cssVar('--line') }, horzLines: { color: cssVar('--line') } } }); series.applyOptions({ color: cssVar('--accent') }) }
274 + window.addEventListener('hfmd:theme', onTheme)
275 + setMeta({ first: points[0].time.slice(0, 4), last: points.at(-1).time.slice(0, 4), latest: points.at(-1).value })
276 + return () => { window.removeEventListener('hfmd:theme', onTheme); chart.remove() }
277 + })
243 278 return (
244 279 <section className="section fundamentals" aria-labelledby="fund-h">
245 280 <div className="fund-grid">
@@ -255,34 +290,39 @@ function Fundamentals() {
255 290 </div>
256 291 <figure className="fund-chart card">
257 292 <figcaption>
258 − <strong>Apple — P/E, {meta.first || '2015'}–{meta.last || 'today'}</strong>
259 − <span className="muted small">{meta.source === 'live' ? <>live from <code>/v1/fundamentals/AAPL/ratios/daily</code></> : meta.source === 'sample' ? <>sample: split-adjusted close ÷ 10-K diluted EPS (live ratios endpoint not reachable)</> : 'loading…'}{meta.latest ? ` · latest ${meta.latest.toFixed(1)}×` : ''}</span>
293 + <strong>Apple — price / earnings, {meta?.first || '2015'}–{meta?.last || 'today'}</strong>
294 + <span className="muted small">Weekly split-adjusted close ÷ diluted EPS from the latest 10-K{meta?.latest ? ` · latest ${meta.latest.toFixed(1)}×` : ''}</span>
260 295 </figcaption>
261 − <div ref={ref} className="chart" data-testid="pe-chart" />
296 + <div ref={ref} className="chart" data-testid="pe-chart" role="img" aria-label="Line chart of Apple's price-to-earnings ratio since 2015" />
262 297 </figure>
263 298 </div>
264 299 </section>
265 300 )
266 301 }
267 302
303 +// ---------------------------------------------------------------------------------------------------- integrations
304 +const INTEGRATIONS = [
305 + { logo: 'claude', name: 'Claude Code', desc: 'Add the MCP server, ask for data in plain English.', to: '/integrations/claude-code' },
306 + { logo: 'cursor', name: 'Cursor', desc: 'One-line config in .cursor/mcp.json.', to: '/integrations/cursor' },
307 + { logo: 'codex', name: 'Codex', desc: 'config.toml block + skills pack for the CLI.', to: '/integrations/codex' },
308 + { logo: 'mcp', name: 'MCP server', desc: '14 read-only tools, stdio, any MCP client.', to: '/integrations/mcp' },
309 + { logo: 'python', name: 'Python', desc: 'requests + pandas, three lines to a DataFrame.', to: '/docs/quickstart' },
310 + { logo: 'r', name: 'R', desc: 'httr2 and arrow for Parquet.', to: '/docs/quickstart' },
311 + { logo: 'javascript', name: 'JavaScript', desc: 'fetch in the browser or Node.', to: '/docs/quickstart' },
312 +]
313 +
268 314 function Integrations() {
269 − const items = [
270 − { name: 'Claude Code', desc: 'Add the MCP server and ask for data in plain English.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#d97757" /><path d="M12 28 20 12l8 16h-4l-4-8-4 8z" fill="#fff" /></svg> },
271 − { name: 'Cursor', desc: 'One-line MCP config in .cursor/mcp.json.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#111" /><path d="M20 8l11 6.5v11L20 32 9 25.5v-11z" fill="none" stroke="#fff" strokeWidth="2" /><path d="M20 8v24M9 14.5l11 6.5 11-6.5" stroke="#fff" strokeWidth="1.5" fill="none" /></svg> },
272 − { name: 'Codex', desc: 'Skills pack with ready-made recipes for the CLI.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#0a0c10" stroke="#313847" /><circle cx="20" cy="20" r="9" fill="none" stroke="#fff" strokeWidth="2.2" /><circle cx="20" cy="20" r="3" fill="#fff" /></svg> },
273 − { name: 'MCP', desc: 'Model Context Protocol server, stdio, any compatible client.', logo: <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="var(--bg-2)" stroke="var(--line-2)" /><path d="M12 26l8-14 8 14M15 22h10" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg> },
274 − ]
275 315 return (
276 316 <section className="section integrations" aria-labelledby="int-h">
277 317 <div className="section-head">
278 318 <p className="eyebrow"><BoltIcon /> Integrations</p>
279 319 <h2 id="int-h">Works where you already work</h2>
280 − <p className="muted">An MCP server exposes every endpoint as a tool; a skills pack teaches coding agents the recipes. Install once, ask questions.</p>
320 + <p className="muted">An MCP server exposes every endpoint as a tool, a skills pack teaches coding agents the recipes, and every docs page has samples in four languages.</p>
281 321 </div>
282 322 <div className="int-grid">
283 − {items.map(it => (
284 − <Link key={it.name} to="/integrations" className="int-card">
285 − <span className="int-logo">{it.logo}</span>
323 + {INTEGRATIONS.map(it => (
324 + <Link key={it.name} to={it.to} className="int-card">
325 + <BrandLogo name={it.logo} size={36} />
286 326 <span><strong>{it.name}</strong><span className="muted">{it.desc}</span></span>
287 327 </Link>
288 328 ))}
@@ -291,12 +331,13 @@ function Integrations() {
291 331 )
292 332 }
293 333
334 +// ---------------------------------------------------------------------------------------------------- closing
294 335 function Closing() {
295 336 return (
296 337 <section className="section closing">
297 338 <div className="closing-card">
298 339 <div>
299 − <h2>Start with a URL. Grow into a key.</h2>
340 + <h2>Free for everyone. Seriously.</h2>
300 341 <p className="muted">No signup for your first request, a free account when you need throughput, higher limits on request. Documentation generated from the live spec, every example runnable.</p>
301 342 </div>
302 343 <div className="hero-cta">
@@ -304,7 +345,7 @@ function Closing() {
304 345 <Link to="/signup" className="btn btn-lg">Create free account</Link>
305 346 </div>
306 347 </div>
307 − <p className="muted small" style={{ textAlign: 'center' }}>API base <code>{BASE_URL || PUBLIC_BASE}/v1</code> · <a href="/openapi.json">openapi.json</a> · <Link to="/status">status</Link></p>
348 + <p className="muted small" style={{ textAlign: 'center' }}>API base <code>{PUBLIC_BASE}/v1</code> · <a href="/openapi.json">openapi.json</a> · <Link to="/status">status</Link> · <Link to="/data-license">data license</Link></p>
308 349 </section>
309 350 )
310 351 }
modified hfmarketdata/web/src/pages/home/home.css +56 −44
@@ -1,64 +1,68 @@
1 −.home { flex: 1; }
1 +.home { flex: 1; min-width: 0; }
2 2 .section { max-width: var(--max); margin: 0 auto; padding: 56px 20px; }
3 3 .section-head { max-width: 720px; margin-bottom: 26px; }
4 −.section-head .eyebrow { display: inline-flex; align-items: center; gap: 6px; }
5 −.section-head .eyebrow svg { width: 14px; height: 14px; }
6 4 .section-head h2 { margin-bottom: 6px; }
7 5 .section-head p { margin: 0; }
8 6
9 −/* hero */
10 −.hero { max-width: var(--max); margin: 0 auto; padding: 72px 20px 40px; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr); gap: 40px; align-items: center; position: relative; }
11 −.hero::before { content: ""; position: absolute; inset: -80px -20% auto -20%; height: 420px; background: radial-gradient(60% 60% at 30% 30%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%), radial-gradient(40% 50% at 75% 20%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 70%); pointer-events: none; z-index: -1; }
12 −.hero h1 { font-size: clamp(34px, 5.2vw, 58px); letter-spacing: -0.03em; margin-bottom: 18px; }
13 −.hero .grad { background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
14 −.hero .lead { font-size: 18px; }
7 +/* hero — 2 columns: copy + real request/response. The glow is clipped by the section (no horizontal overflow). */
8 +.hero { max-width: var(--max); margin: 0 auto; padding: 64px 20px 40px; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: 40px; align-items: center; position: relative; overflow: clip; }
9 +.hero::before { content: ""; position: absolute; inset: 0; background: radial-gradient(55% 60% at 20% 20%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%), radial-gradient(40% 50% at 85% 30%, color-mix(in srgb, var(--accent-2) 9%, transparent), transparent 70%); pointer-events: none; z-index: -1; }
10 +.hero h1 { font-size: clamp(32px, 4.4vw, 52px); letter-spacing: -0.03em; margin-bottom: 18px; }
11 +.hero .lead { font-size: var(--fs-4); }
15 12 .hero-cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 26px 0 22px; }
16 13 .hero-inner { min-width: 0; }
17 −.hero-curl { display: flex; align-items: center; gap: 10px; padding: 10px 8px 10px 14px; border: 1px solid var(--line-2); border-radius: 10px; background: var(--code-bg); max-width: 640px; min-width: 0; }
18 −.hero-curl code { background: none; border: 0; padding: 0; font-size: 13px; color: var(--code-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
19 −.hero-art { width: 100%; height: auto; }
14 +.hero-curl { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 14px; border: 1px solid var(--line-2); border-radius: var(--r-md); background: var(--code-bg); max-width: 640px; min-width: 0; }
15 +.hero-curl code { background: none; border: 0; padding: 0; font-size: var(--fs-1); color: var(--code-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
16 +.hero-code { min-width: 0; display: grid; gap: 12px; }
17 +.hero-code .codetabs, .hero-code .code { margin: 0; }
20 18
21 −/* counters */
22 −.counters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
23 −.counter { padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); }
24 −.counter-n { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.1; min-height: 34px; }
25 −.counter-l { font-weight: 600; margin-top: 6px; }
26 −.counter-s { color: var(--fg-2); font-size: 12.5px; margin-top: 2px; }
27 −.skeleton { display: inline-block; width: 90px; height: 28px; border-radius: 6px; background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
28 −@keyframes shimmer { to { background-position: -200% 0; } }
19 +/* numbers band */
20 +.numbers { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--bg-1); }
21 +.numbers-inner { max-width: var(--max); margin: 0 auto; padding: 22px 20px 6px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px 20px; }
22 +.number { min-width: 0; padding: 6px 0; }
23 +.number-v { font-size: clamp(24px, 2.4vw, var(--fs-7)); font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.1; min-height: 34px; font-family: var(--mono); }
24 +.number-l { font-weight: 600; margin-top: 6px; font-size: var(--fs-2); }
25 +.number-s { color: var(--fg-2); font-size: var(--fs-0); margin-top: 2px; }
26 +.numbers-note { max-width: var(--max); margin: 0 auto; padding: 0 20px 16px; }
29 27
30 28 /* steps */
31 −.steps { list-style: none; margin: 0 0 14px; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; counter-reset: step; }
32 −.step { position: relative; padding: 24px 22px 22px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); display: flex; flex-direction: column; gap: 8px; }
33 −.step h3 { margin: 0; font-size: 17px; }
34 −.step p { margin: 0 0 6px; color: var(--fg-1); font-size: 14.5px; flex: 1; }
35 −.step-n { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-2); border: 1px solid var(--line-2); font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--fg-1); }
29 +.steps { list-style: none; margin: 0 0 14px; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
30 +.step { position: relative; padding: 24px 22px 22px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); display: flex; flex-direction: column; gap: 8px; min-width: 0; }
31 +.step h3 { margin: 0; font-size: var(--fs-4); }
32 +.step p { margin: 0 0 6px; color: var(--fg-1); font-size: var(--fs-2); flex: 1; }
33 +.step-n { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-2); border: 1px solid var(--line-2); font-family: var(--mono); font-size: var(--fs-1); font-weight: 700; color: var(--fg-1); }
36 34 .step-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); }
37 35 .step-featured .step-n { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
38 36 .step .btn { align-self: flex-start; }
39 37
40 38 /* preview */
41 −.preview-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
42 −.preview-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
39 +.preview-card { margin: 0; padding: 14px 16px 8px; }
40 +.preview-cap { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: var(--fs-2); }
41 +.chart { width: 100%; height: 240px; min-width: 0; }
42 +.chart-lg { height: 300px; }
43 +.preview-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
44 +
45 +/* datasets */
46 +.dataset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
47 +.dataset { display: grid; gap: 2px; padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); color: var(--fg); min-width: 0; }
48 +.dataset:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); }
49 +.dataset-n { font-size: var(--fs-6); font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; font-family: var(--mono); line-height: 1.1; min-height: 28px; }
50 +.dataset-l { font-weight: 600; margin-top: 4px; }
51 +.dataset-s { color: var(--fg-2); font-size: var(--fs-0); }
43 52
44 53 /* fundamentals */
45 54 .fundamentals { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
46 55 .fund-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 36px; align-items: center; }
47 −.fund-grid .eyebrow { display: inline-flex; align-items: center; gap: 6px; }
48 −.fund-grid .eyebrow svg { width: 14px; height: 14px; }
49 56 .fund-list { margin: 16px 0 0; padding-left: 1.1em; color: var(--fg-1); }
50 57 .fund-list li + li { margin-top: 6px; }
51 58 .fund-chart { margin: 0; padding: 16px 16px 8px; }
52 −.fund-chart figcaption { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: 14px; }
53 −.chart { width: 100%; height: 260px; }
59 +.fund-chart figcaption { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 8px; font-size: var(--fs-2); }
54 60
55 61 /* integrations */
56 62 .int-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
57 −.int-card { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--fg); background: var(--bg-1); }
58 −.int-card:hover { border-color: var(--line-3); text-decoration: none; }
59 −.int-card > span:last-child { display: grid; gap: 2px; font-size: 13.5px; }
60 −.int-logo { flex: none; width: 40px; height: 40px; }
61 −.int-logo svg { width: 40px; height: 40px; }
63 +.int-card { display: flex; gap: 12px; align-items: flex-start; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); color: var(--fg); background: var(--bg-1); min-width: 0; }
64 +.int-card:hover { border-color: var(--line-3); text-decoration: none; background: var(--bg-2); }
65 +.int-card > span:last-child { display: grid; gap: 2px; font-size: var(--fs-1); min-width: 0; }
62 66
63 67 /* closing */
64 68 .closing-card { display: grid; grid-template-columns: minmax(0, 1.3fr) auto; gap: 24px; align-items: center; padding: 34px; border-radius: 18px; border: 1px solid var(--line); background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-1)), color-mix(in srgb, var(--accent-2) 8%, var(--bg-1))); margin-bottom: 14px; }
@@ -66,16 +70,24 @@
66 70 .closing-card p { margin: 0; }
67 71 .closing-card .hero-cta { margin: 0; }
68 72
69 −@media (max-width: 1000px) {
70 − .hero { grid-template-columns: minmax(0, 1fr); padding-top: 48px; }
71 − .hero-art { max-width: 520px; }
72 − .counters, .int-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
73 +@media (max-width: 1200px) {
74 + .numbers-inner { grid-template-columns: repeat(3, minmax(0, 1fr)); }
75 + .int-grid, .dataset-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
76 +}
77 +@media (max-width: 960px) {
78 + .hero { grid-template-columns: minmax(0, 1fr); padding-top: 44px; gap: 28px; }
79 + .int-grid, .dataset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
73 80 .steps { grid-template-columns: 1fr; }
74 − .preview-grid, .fund-grid { grid-template-columns: 1fr; }
81 + .fund-grid { grid-template-columns: 1fr; gap: 20px; }
75 82 .closing-card { grid-template-columns: 1fr; }
76 83 }
77 −@media (max-width: 560px) {
78 − .counters, .int-grid { grid-template-columns: 1fr; }
84 +@media (max-width: 640px) {
85 + .numbers-inner { grid-template-columns: 1fr 1fr; padding: 16px 16px 4px; }
86 + .numbers-note { padding: 0 16px 14px; }
87 + .dataset-grid { grid-template-columns: 1fr 1fr; }
88 + .int-grid { grid-template-columns: 1fr; }
79 89 .section { padding: 40px 16px; }
80 − .hero { padding: 36px 16px 24px; }
90 + .hero { padding: 32px 16px 24px; }
91 + .chart, .chart-lg { height: 220px; }
92 + .closing-card { padding: 24px 20px; }
81 93 }
modified hfmarketdata/web/src/pages/integrations/Integrations.jsx +19 −9
@@ -2,10 +2,19 @@
2 2 // Sub-routes: /integrations, /integrations/mcp, /claude-code, /cursor, /codex, /skills.
3 3 import React, { createContext, useContext, useMemo, useState } from 'react'
4 4 import { Link, NavLink, Route, Routes } from 'react-router-dom'
5 −import IntCopyBlock, { IntCopyChip } from '../../components/IntCopyBlock.jsx'
6 5 import { PUBLIC_BASE, CONTACT_EMAIL } from '../../app/api.js'
6 +import BrandLogo from '../../components/BrandLogos.jsx'
7 +import CodeBlock from '../../components/CodeBlock.jsx'
8 +import CopyButton from '../../components/CopyButton.jsx'
9 +import useTitle from '../../docs/useTitle.js'
7 10 import './integrations.css'
8 11
12 +// Thin adapters over the shared CodeBlock / CopyButton (the former IntCopyBlock / IntCopyChip API).
13 +const IntCopyBlock = ({ code, lines, lang = 'text', title, note, compact = false }) => (
14 + <CodeBlock code={code ?? (lines || []).join('\n')} language={lang} title={title || lang} footer={note} className={compact ? 'int-copy compact' : 'int-copy'} />
15 +)
16 +const IntCopyChip = ({ text, label }) => <CopyButton text={text} label={label || text} size="md" variant="secondary" className="int-chip" />
17 +
9 18 // ---------------------------------------------------------------------------------------------
10 19 // Constants (bump MCP_VERSION together with mcp/package.json and the tarball in public/downloads)
11 20 // ---------------------------------------------------------------------------------------------
@@ -110,6 +119,7 @@ const SUBNAV = [
110 119 ]
111 120
112 121 export default function Integrations() {
122 + useTitle('Integrations — MCP server, Claude Code, Cursor, Codex, skills', 'Use HF Market Data from Claude Code, Cursor, Codex and any MCP client: one MCP server with 14 read-only tools, plus a skills pack with ready-to-run Python.')
113 123 const [mode, setMode] = useState('tarball')
114 124 const [key, setKey] = useState('')
115 125 const value = useMemo(() => ({ mode, setMode, key, setKey, keyOrPlaceholder: key || KEY_PLACEHOLDER }), [mode, key])
@@ -191,11 +201,11 @@ function Overview() {
191 201 return (
192 202 <>
193 203 <section className="int-grid">
194 − <Link to="/integrations/mcp" className="card int-card"><h3><span className="int-logo">MCP</span>MCP server</h3><p>What the 14 tools do, how outputs are bounded, resources and prompts, install without npm.</p><span className="go">Read →</span></Link>
195 − <Link to="/integrations/claude-code" className="card int-card"><h3><span className="int-logo g">CC</span>Claude Code</h3><p>One <code>claude mcp add</code> command, or a shared <code>.mcp.json</code> for your project.</p><span className="go">Set up →</span></Link>
196 − <Link to="/integrations/cursor" className="card int-card"><h3><span className="int-logo b">Cu</span>Cursor</h3><p><code>.cursor/mcp.json</code> snippet and a one-click deeplink.</p><span className="go">Set up →</span></Link>
197 − <Link to="/integrations/codex" className="card int-card"><h3><span className="int-logo w">Cx</span>Codex</h3><p><code>~/.codex/config.toml</code> block, ready to paste.</p><span className="go">Set up →</span></Link>
198 − <Link to="/integrations/skills" className="card int-card"><h3><span className="int-logo">Sk</span>Skills pack</h3><p>Five Claude skills: analysis, walk-forward backtest, continuous futures, fundamentals screen, term structure.</p><span className="go">Download →</span></Link>
204 + <Link to="/integrations/mcp" className="card int-card"><h3><BrandLogo name="mcp" size={28} />MCP server</h3><p>What the 14 tools do, how outputs are bounded, resources and prompts, install without npm.</p><span className="go">Read →</span></Link>
205 + <Link to="/integrations/claude-code" className="card int-card"><h3><BrandLogo name="claude" size={28} />Claude Code</h3><p>One <code>claude mcp add</code> command, or a shared <code>.mcp.json</code> for your project.</p><span className="go">Set up →</span></Link>
206 + <Link to="/integrations/cursor" className="card int-card"><h3><BrandLogo name="cursor" size={28} />Cursor</h3><p><code>.cursor/mcp.json</code> snippet and a one-click deeplink.</p><span className="go">Set up →</span></Link>
207 + <Link to="/integrations/codex" className="card int-card"><h3><BrandLogo name="codex" size={28} />Codex</h3><p><code>~/.codex/config.toml</code> block, ready to paste.</p><span className="go">Set up →</span></Link>
208 + <Link to="/integrations/skills" className="card int-card"><h3><BrandLogo name="skills" size={28} />Skills pack</h3><p>Five Claude skills: analysis, walk-forward backtest, continuous futures, fundamentals screen, term structure.</p><span className="go">Download →</span></Link>
199 209 </section>
200 210
201 211 <section className="int-section">
@@ -249,7 +259,7 @@ function McpPage() {
249 259 <h2>Tools</h2>
250 260 <div className="int-table"><table>
251 261 <thead><tr><th>Tool</th><th>What it does</th><th>Endpoint(s)</th></tr></thead>
252 − <tbody>{TOOLS.map(([n, d, e]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted" style={{ fontSize: 12 }}>{e}</td></tr>)}</tbody>
262 + <tbody>{TOOLS.map(([n, d, e]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted small">{e}</td></tr>)}</tbody>
253 263 </table></div>
254 264 <p><strong>Resources:</strong> <code>hfmarketdata://status</code> (dataset inventory) · <code>hfmarketdata://limits</code> (rate limits + live headroom — everything is free). <strong>Prompts:</strong> <code>term-structure-analysis</code>, <code>compare-contracts</code>, <code>fundamentals-snapshot</code>.</p>
255 265 <div className="int-callout">The futures v2 and fundamentals endpoints roll out progressively on the API. Until an endpoint is live, the corresponding tool returns a clear <code>404 NOT_FOUND</code> message pointing to <code>hfmarketdata://status</code> — nothing is fabricated.</div>
@@ -298,7 +308,7 @@ function CursorPage() {
298 308 <a href={link} className="btn btn-primary" title="Opens Cursor’s MCP install dialog">Add to Cursor (one click)</a>
299 309 <IntCopyChip text={link} label="Copy deeplink" />
300 310 </div>
301 − <p className="muted" style={{ fontSize: 13 }}>Best effort: the deeplink (<code>cursor://anysphere.cursor-deeplink/mcp/install?name=…&config=&lt;base64 JSON&gt;</code>) is supported by Cursor ≥ 1.0; if nothing happens, paste the JSON below.</p>
311 + <p className="muted small">Best effort: the deeplink (<code>cursor://anysphere.cursor-deeplink/mcp/install?name=…&config=&lt;base64 JSON&gt;</code>) is supported by Cursor ≥ 1.0; if nothing happens, paste the JSON below.</p>
302 312 <ol className="int-steps">
303 313 <li><strong>Install the server</strong><IntCopyBlock compact lang="bash" title="Terminal" code={installCommand(mode)} /></li>
304 314 <li><strong>Add the config</strong><IntCopyBlock compact lang="json" title=".cursor/mcp.json" code={mcpJson(mode, keyOrPlaceholder)} note="Cursor → Settings → MCP → the server should list 14 tools. Toggle it on for the Agent." /></li>
@@ -339,7 +349,7 @@ function SkillsPage() {
339 349 </div>
340 350 <div className="int-table"><table>
341 351 <thead><tr><th>Skill</th><th>What it does</th><th>Scripts</th></tr></thead>
342 − <tbody>{SKILLS.map(([n, d, s]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted" style={{ fontSize: 12 }}>{s}</td></tr>)}</tbody>
352 + <tbody>{SKILLS.map(([n, d, s]) => <tr key={n}><td><code>{n}</code></td><td>{d}</td><td className="mono muted small">{s}</td></tr>)}</tbody>
343 353 </table></div>
344 354 </section>
345 355 <section className="int-section">
modified hfmarketdata/web/src/pages/integrations/integrations.css +50 −52
@@ -1,90 +1,88 @@
1 −/* Integrations area — uses the shell tokens from src/app/theme.css only. Owner: mcp-skills. */
2 −.int-hero { display: grid; grid-template-columns: 1.4fr 1fr; gap: 28px; align-items: start; margin-bottom: 28px; }
3 −.int-hero h1 { font-size: 34px; line-height: 1.15; margin: 0 0 10px; letter-spacing: -0.01em; }
1 +/* Integrations area — shell tokens only. Owner: mcp-skills (design pass by web-core). */
2 +.int-hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 28px; align-items: start; margin-bottom: 28px; }
3 +.int-hero h1 { font-size: clamp(28px, 3.2vw, 36px); line-height: 1.15; margin: 0 0 10px; letter-spacing: -0.02em; }
4 4 .int-hero p.lead { color: var(--fg-1); font-size: 16px; margin: 0 0 18px; max-width: 60ch; }
5 −.int-kicker { display: inline-flex; gap: 8px; align-items: center; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-bottom: 10px; }
6 −.int-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 4px; }
5 +.int-kicker { display: inline-flex; gap: 8px; align-items: center; font-size: var(--fs-0); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-bottom: 10px; font-weight: 650; }
6 +.int-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 4px; align-items: center; }
7 7 .int-actions .btn { text-decoration: none; }
8 8 .int-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
9 −.int-badge { font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-1); }
9 +.int-badge { font-size: var(--fs-0); padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--fg-1); background: var(--bg-1); }
10 10 .int-badge.ok { border-color: color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); }
11 11
12 −.int-demo { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; }
13 −.int-demo-bar { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); }
12 +/* demo terminal — wraps instead of overflowing */
13 +.int-demo { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--code-bg); overflow: hidden; min-width: 0; }
14 +.int-demo-bar { display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--bg-1); }
14 15 .int-demo-bar i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-2); display: inline-block; }
15 −.int-demo pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: 12.5px; line-height: 1.55; padding: 14px 16px; min-height: 250px; }
16 +.int-demo pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: var(--fs-0); line-height: 1.6; padding: 14px 16px; min-height: 250px; white-space: pre-wrap; overflow-wrap: anywhere; }
16 17 .int-demo .u { color: var(--accent-2); }
17 18 .int-demo .t { color: var(--fg-2); }
18 −.int-demo .a { color: var(--fg); }
19 +.int-demo .a { color: var(--code-fg); }
19 20 .int-demo .g { color: var(--accent); }
20 21
21 −.int-subnav { display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 10px; margin: 8px 0 26px; }
22 −.int-subnav a { padding: 7px 12px; border-radius: 6px; color: var(--fg-1); font-size: 14px; }
22 +.int-subnav { display: flex; gap: 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 8px; margin: 8px 0 26px; }
23 +.int-subnav a { display: inline-flex; align-items: center; min-height: var(--tap); padding: 0 12px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-2); font-weight: 500; }
23 24 .int-subnav a.active, .int-subnav a:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; }
24 25
25 −.int-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin: 18px 0 28px; }
26 −.int-card { display: flex; flex-direction: column; gap: 8px; text-decoration: none; color: inherit; transition: border-color 0.15s; }
27 −.int-card:hover { border-color: var(--fg-2); text-decoration: none; }
28 −.int-card h3 { margin: 0; font-size: 16px; display: flex; align-items: center; gap: 8px; }
29 −.int-card p { margin: 0; color: var(--fg-1); font-size: 14px; }
30 −.int-card .go { color: var(--accent-2); font-size: 13px; margin-top: auto; }
31 −.int-logo { width: 26px; height: 26px; border-radius: 7px; display: inline-grid; place-items: center; font-family: var(--mono); font-size: 12px; font-weight: 700; color: #05140c; background: var(--accent); }
32 −.int-logo.b { background: var(--accent-2); color: #041a2a; }
33 −.int-logo.w { background: var(--warn); color: #2a1c00; }
34 −.int-logo.g { background: var(--fg-1); color: var(--bg); }
26 +/* overview cards: 5 equal columns, 3 + 2 centred on tablets, 1 on phones */
27 +.int-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 18px 0 28px; }
28 +.int-card { display: flex; flex-direction: column; gap: 8px; text-decoration: none; color: inherit; transition: border-color 0.15s; padding: 16px; min-width: 0; }
29 +.int-card:hover { border-color: var(--line-3); background: var(--bg-2); text-decoration: none; }
30 +.int-card h3 { margin: 0; font-size: var(--fs-3); display: flex; align-items: center; gap: 10px; }
31 +.int-card p { margin: 0; color: var(--fg-1); font-size: var(--fs-1); }
32 +.int-card .go { color: var(--accent-2); font-size: var(--fs-1); margin-top: auto; }
35 33
36 −.int-section { margin: 0 0 34px; }
37 −.int-section h2 { font-size: 22px; margin: 0 0 6px; }
38 −.int-section h3 { font-size: 16px; margin: 22px 0 8px; }
34 +.int-section { margin: 0 0 34px; min-width: 0; }
35 +.int-section h2 { font-size: var(--fs-6); margin: 0 0 6px; }
36 +.int-section h3 { font-size: var(--fs-3); margin: 22px 0 8px; }
39 37 .int-section p { color: var(--fg-1); max-width: 78ch; }
40 38 .int-steps { counter-reset: s; list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 14px; }
41 −.int-steps li { position: relative; padding-left: 40px; }
42 −.int-steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 0; top: 2px; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-2); border: 1px solid var(--line-2); color: var(--fg); font-size: 13px; font-weight: 600; }
39 +.int-steps li { position: relative; padding-left: 40px; min-width: 0; }
40 +.int-steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 0; top: 2px; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-2); border: 1px solid var(--line-2); color: var(--fg); font-size: var(--fs-1); font-weight: 600; }
43 41 .int-steps li > strong { display: block; margin-bottom: 6px; }
44 42
45 −.int-toggle { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; margin: 6px 0 12px; }
46 −.int-toggle button { font: inherit; font-size: 13px; padding: 6px 12px; background: var(--bg-1); color: var(--fg-1); border: 0; cursor: pointer; }
43 +.int-toggle { display: inline-flex; border: 1px solid var(--line-2); border-radius: var(--r-md); overflow: hidden; margin: 6px 0 12px; }
44 +.int-toggle button { font: inherit; font-size: var(--fs-1); min-height: var(--tap); padding: 0 12px; background: var(--bg-1); color: var(--fg-1); border: 0; cursor: pointer; }
47 45 .int-toggle button + button { border-left: 1px solid var(--line-2); }
48 −.int-toggle button.on { background: var(--bg-2); color: var(--fg); }
46 +.int-toggle button.on, .int-toggle button[aria-selected="true"] { background: var(--bg-2); color: var(--fg); }
49 47
50 48 .int-field { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 6px 0 14px; }
51 −.int-field label { font-size: 13px; color: var(--fg-1); }
52 −.int-field input { min-width: 280px; font-family: var(--mono); font-size: 13px; }
53 −.int-field .hint { font-size: 12px; color: var(--fg-2); }
49 +.int-field label { font-size: var(--fs-1); color: var(--fg-1); }
50 +.int-field input { min-width: 280px; font-family: var(--mono); font-size: var(--fs-1); }
51 +.int-field .hint { font-size: var(--fs-0); color: var(--fg-2); }
54 52
55 −.int-copy { margin: 10px 0 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; }
56 −.int-copy-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 6px 12px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
57 −.int-copy-title { font-size: 12px; color: var(--fg-2); }
58 −.int-copy-btn { font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg-1); cursor: pointer; }
59 −.int-copy-btn:hover { border-color: var(--fg-2); color: var(--fg); }
60 −.int-copy-btn.ok { border-color: var(--accent); color: var(--accent); }
61 −.int-copy-pre { margin: 0; border: 0; border-radius: 0; background: transparent; font-size: 13px; line-height: 1.55; padding: 12px 14px; }
62 −.int-copy.compact .int-copy-pre { padding: 8px 12px; }
63 −.int-copy-note { padding: 6px 12px 10px; font-size: 12.5px; }
64 −.int-chip { font: inherit; font-size: 12.5px; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--line-2); background: var(--bg-1); color: var(--fg); cursor: pointer; }
65 −.int-chip:hover { border-color: var(--fg-2); }
66 −.int-chip.ok { border-color: var(--accent); color: var(--accent); }
67 −.int-chip-ico { color: var(--fg-2); }
53 +.int-copy { margin: 10px 0 16px; }
54 +.int-copy.compact .code-pre { padding: 8px 12px; }
55 +.int-chip { align-self: flex-start; }
68 56
69 −.int-table { margin: 12px 0 18px; overflow-x: auto; }
70 −.int-table table { font-size: 13.5px; }
57 +.int-table { margin: 12px 0 18px; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r-md); }
58 +.int-table table { font-size: var(--fs-1); }
71 59 .int-table td:first-child { white-space: nowrap; }
72 60 .int-table code { white-space: nowrap; }
61 +.int-table tr:last-child td { border-bottom: 0; }
73 62
74 63 .int-prompts { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin: 10px 0 18px; }
75 64 .int-prompt { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
76 65 .int-prompt q { font-style: italic; color: var(--fg); quotes: '“' '”'; }
77 66 .int-prompt small { color: var(--fg-2); }
78 67
79 −.int-callout { border-left: 3px solid var(--accent-2); padding: 10px 14px; background: var(--bg-1); border-radius: 0 var(--radius) var(--radius) 0; margin: 14px 0; color: var(--fg-1); font-size: 14px; }
68 +.int-callout { border-left: 3px solid var(--info); padding: 10px 14px; background: var(--bg-1); border-radius: 0 var(--r-md) var(--r-md) 0; margin: 14px 0; color: var(--fg-1); font-size: var(--fs-2); }
80 69 .int-callout.warn { border-left-color: var(--warn); }
81 70 .int-dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 14px 0 22px; }
82 71 .int-dl .card { display: flex; flex-direction: column; gap: 6px; }
83 −.int-dl .card .size { color: var(--fg-2); font-size: 12.5px; }
72 +.int-dl .card .size { color: var(--fg-2); font-size: var(--fs-0); }
84 73 .int-dl .btn { align-self: flex-start; margin-top: 4px; }
85 74
86 −@media (max-width: 860px) {
75 +@media (max-width: 1100px) {
76 + .int-grid { display: flex; flex-wrap: wrap; justify-content: center; }
77 + .int-grid .int-card { flex: 0 1 calc(33.333% - 8px); min-width: 240px; }
78 +}
79 +@media (max-width: 960px) {
87 80 .int-hero { grid-template-columns: 1fr; }
88 − .int-hero h1 { font-size: 28px; }
89 81 .int-field input { min-width: 0; width: 100%; }
82 + .int-actions .btn { flex: 1 1 auto; }
83 +}
84 +@media (max-width: 640px) {
85 + .int-grid { display: grid; grid-template-columns: 1fr; }
86 + .int-toggle { display: flex; }
87 + .int-toggle button { flex: 1; min-height: var(--tap-mobile); }
90 88 }
added hfmarketdata/web/src/pages/legal/Legal.jsx +39 −0
@@ -0,0 +1,39 @@
1 +// /terms · /privacy · /data-license — sober MDX pages. One chunk per page; the MDX components map is the docs one.
2 +import React, { Suspense, lazy, useMemo } from 'react'
3 +import { MDXProvider } from '@mdx-js/react'
4 +import { Link } from 'react-router-dom'
5 +import { mdxComponents } from '../../docs/mdx-components.jsx'
6 +import useTitle from '../../docs/useTitle.js'
7 +import './legal.css'
8 +
9 +export const LEGAL_PAGES = {
10 + terms: { title: 'Terms of use', description: 'Terms of use of the HF Market Data API and website — a free platform, offered as is, with reasonable-use limits.', load: () => import('./terms.mdx') },
11 + privacy: { title: 'Privacy', description: 'What HF Market Data stores about you (account e-mail, hashed keys, request counters), what it does not (no third-party tracking), and how to delete it.', load: () => import('./privacy.mdx') },
12 + 'data-license': { title: 'Data license & attribution', description: 'Where the data comes from (FirstRate Data, SEC EDGAR), what you may do with it, and how to attribute it.', load: () => import('./data-license.mdx') },
13 +}
14 +
15 +const UPDATED = '2026-09-06'
16 +
17 +export default function Legal({ page }) {
18 + const meta = LEGAL_PAGES[page]
19 + useTitle(meta?.title, meta?.description)
20 + const Content = useMemo(() => (meta ? lazy(meta.load) : null), [meta])
21 + if (!meta) return null
22 + return (
23 + <main className="page narrow legal" data-testid={`legal-${page}`}>
24 + <nav className="legal-nav" aria-label="Legal pages">
25 + {Object.entries(LEGAL_PAGES).map(([slug, p]) => <Link key={slug} to={`/${slug}`} className={slug === page ? 'active' : ''} aria-current={slug === page ? 'page' : undefined}>{p.title}</Link>)}
26 + </nav>
27 + <header className="legal-head">
28 + <p className="eyebrow">Legal</p>
29 + <h1>{meta.title}</h1>
30 + <p className="muted small">Last updated {UPDATED} · Applies to <code>www.hfmarketdata.io</code> and its API.</p>
31 + </header>
32 + <article className="prose legal-body">
33 + <Suspense fallback={<p className="muted" aria-busy="true">Loading…</p>}>
34 + <MDXProvider components={mdxComponents}><Content /></MDXProvider>
35 + </Suspense>
36 + </article>
37 + </main>
38 + )
39 +}
added hfmarketdata/web/src/pages/legal/data-license.mdx +44 −0
@@ -0,0 +1,44 @@
1 +HF Market Data redistributes data from two sources. They come with different licences, and this page tells you what you may do with each — and how to give credit.
2 +
3 +## Sources
4 +
5 +| Dataset | Source | Coverage |
6 +| --- | --- | --- |
7 +| Stocks, ETFs, indices, FX, crypto bars (1-minute to daily) | [FirstRate Data](https://firstratedata.com) | since ~2007 / 2010 depending on the instrument |
8 +| Futures — continuous series and individual contracts | FirstRate Data | 142 roots, since 2010 |
9 +| Options chains with Greeks (end of day) | FirstRate Data | quarterly archives since 2010 Q1 |
10 +| Fundamentals, filings, ratios, screener | [SEC EDGAR](https://www.sec.gov/edgar) (XBRL financial data) | since 2010, point-in-time |
11 +
12 +## FirstRate Data (market data)
13 +
14 +The bars, futures and options datasets are licensed by their owner, FirstRate Data, and redistributed here for **personal, academic and internal business use** — research, backtesting, teaching, building tools that consume the API.
15 +
16 +You may **not**:
17 +
18 +- redistribute the raw data in bulk as a dataset, feed or download service, whether free or paid;
19 +- resell or sublicense the data;
20 +- present it as your own data product.
21 +
22 +You may publish charts, statistics, models and derived results built from the data, with attribution. If you are unsure whether your use is covered, ask us or FirstRate Data.
23 +
24 +## SEC EDGAR (fundamentals)
25 +
26 +Filings and XBRL financial data published by the U.S. Securities and Exchange Commission are in the **public domain**. The fundamentals endpoints (statements, facts, ratios, screener, frames, filings stream) reproduce that data as filed, with the filing date on every number. Ratios are computed by us from those filings and daily closes; their formulas are documented on [All ratio formulas](/docs/fundamentals/ratios).
27 +
28 +## Attribution
29 +
30 +When you publish something built on this API, a line like the following is appreciated (and, for FirstRate Data, required):
31 +
32 +> Market data: FirstRate Data via HF Market Data (hfmarketdata.io). Fundamentals: SEC EDGAR.
33 +
34 +## Accuracy and warranty
35 +
36 +Data is provided as is, without warranty of accuracy, completeness or fitness for any purpose. Errors and gaps exist; missing values are returned as `null` and never estimated. Nothing here is investment advice.
37 +
38 +## Software
39 +
40 +The MCP server and skills pack published on [Integrations](/integrations) are released under the MIT licence. The API's OpenAPI specification (`/openapi.json`) may be used freely to generate clients.
41 +
42 +## Questions
43 +
44 +[contact@spboucher.ai](mailto:contact@spboucher.ai)
added hfmarketdata/web/src/pages/legal/legal.css +11 −0
@@ -0,0 +1,11 @@
1 +.legal-nav { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 22px; border-bottom: 1px solid var(--line); padding-bottom: 8px; }
2 +.legal-nav a { display: inline-flex; align-items: center; min-height: var(--tap); padding: 0 12px; border-radius: var(--r-sm); color: var(--fg-1); font-size: var(--fs-2); font-weight: 500; }
3 +.legal-nav a.active, .legal-nav a:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; }
4 +.legal-head { margin-bottom: 26px; }
5 +.legal-head h1 { font-size: clamp(var(--fs-7), 3.5vw, 40px); }
6 +.legal-body { font-size: 16px; line-height: var(--lh-prose); }
7 +.legal-body h2 { font-size: var(--fs-5); margin-top: 2em; }
8 +.legal-body h3 { font-size: var(--fs-4); margin-top: 1.6em; }
9 +.legal-body ul, .legal-body ol { padding-left: 1.3em; }
10 +.legal-body li + li { margin-top: 6px; }
11 +.legal-body .anchor-heading .anchor { display: none; }
added hfmarketdata/web/src/pages/legal/privacy.mdx +28 −0
@@ -0,0 +1,28 @@
1 +This page describes exactly what HF Market Data stores about you. The short version: an e-mail address if you create an account, hashed API keys, request counters, and nothing for advertising.
2 +
3 +## Without an account
4 +
5 +- **Rate limiting.** Keyless requests are metered per IP address. The IP and a request/row counter live in Redis for the length of the sliding window (one hour) and then expire. No per-request log tied to your IP is kept beyond standard, short-lived server logs used for debugging incidents.
6 +- **No tracking.** The website loads no third-party script, font, analytics or advertising pixel. Everything is served from `www.hfmarketdata.io`.
7 +- **Browser storage.** The site keeps your theme (`hfmd.theme`), preferred code language (`hfmd.lang`), code-wrap preference and a cached copy of the OpenAPI specification in `localStorage`. These never leave your browser.
8 +
9 +## With an account
10 +
11 +- **What we store:** your e-mail address, a password hash (never the password), the display name you choose, the SHA-256 hash and prefix of each API key (the full key is shown once and not retained), your access level, and creation / last-used timestamps.
12 +- **Usage counters:** per key and per day, the number of requests and rows served, and per-endpoint counts, so the dashboard can show your consumption and so we can apply limits fairly. These are aggregates, not a log of individual requests or of the symbols you looked at.
13 +- **Session cookie.** Signing in sets an `HttpOnly`, `Secure`, `SameSite` session cookie. It is strictly necessary for the dashboard and is not used for anything else.
14 +- **Transactional e-mail.** Verification, password-reset and invitation e-mails are sent through [Resend](https://resend.com). Your address is shared with them only to deliver those messages. We send no newsletters.
15 +
16 +## What we do not do
17 +
18 +- No selling, renting or sharing of personal data.
19 +- No third-party analytics, no advertising, no fingerprinting.
20 +- No profiling of the data you query.
21 +
22 +## Your rights
23 +
24 +You can view and change your e-mail and name in the dashboard, rotate or revoke keys at any time, and delete your account (all account data is removed; aggregated, anonymous usage totals may remain). For any request — access, correction, deletion — e-mail [contact@spboucher.ai](mailto:contact@spboucher.ai). Data is hosted in Canada.
25 +
26 +## Contact
27 +
28 +Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai).
added hfmarketdata/web/src/pages/legal/terms.mdx +45 −0
@@ -0,0 +1,45 @@
1 +HF Market Data ("the service") is a free, open API and website for historical market data, run by Simon-Pierre Boucher. Using the service — with or without an account — means you accept these terms. They are short on purpose; if something is unclear, write to [contact@spboucher.ai](mailto:contact@spboucher.ai).
2 +
3 +## 1. What the service is
4 +
5 +- A read-only HTTP API under `https://www.hfmarketdata.io/v1`, a WebSocket stream, documentation, a playground and a dashboard for API keys.
6 +- Everything is **free**. There is no paid plan. Access levels differ only by rate limits (see [Limits](/limits)): keyless per IP, free account with an API key, and higher limits granted on request by e-mail.
7 +- The service is provided **as is**, on a best-effort basis, by one person, on dedicated hardware. There is no service-level agreement, no uptime guarantee and no scheduled maintenance window.
8 +
9 +## 2. Accounts and API keys
10 +
11 +- An account needs a working e-mail address, which we verify. You are responsible for what is done with your API keys; keep them out of public code and rotate them from the dashboard if they leak.
12 +- One person, one account. Sharing a key to circumvent limits, or creating several accounts for the same purpose, may lead to the keys being revoked.
13 +- We may suspend keys that break the service for others (abusive traffic, scraping the whole lake through the row quota, attempts to bypass rate limiting).
14 +
15 +## 3. Reasonable use
16 +
17 +The limits are generous and grow on request. Please do not:
18 +
19 +- run automated retry storms against `429` responses — honour `Retry-After`;
20 +- redistribute the raw data as a competing data feed or resell it (see [Data license & attribution](/data-license));
21 +- probe, disrupt or reverse-engineer the service's infrastructure.
22 +
23 +If you need more than the free account gives you, ask — higher limits are also free.
24 +
25 +## 4. Data and accuracy
26 +
27 +- Market data comes from FirstRate Data; fundamentals come from SEC EDGAR. We pass it through without alteration beyond format conversion and documented adjustments (split / dividend adjustment, continuous-contract construction).
28 +- Data may contain errors, gaps or delays. **Missing values are returned as `null`, never filled in.** Nothing on this service is investment advice; you are solely responsible for decisions made with it.
29 +- We may add endpoints and fields at any time. Existing `/v1` endpoints keep their paths, parameters and response shapes; changes are announced in the [changelog](/docs/changelog) under the [versioning policy](/docs/versioning).
30 +
31 +## 5. Availability and termination
32 +
33 +We may change, suspend or stop the service, in whole or in part, at any time. We will try to give notice through the changelog and the status page. You may delete your account at any time from the dashboard or by e-mail.
34 +
35 +## 6. Liability
36 +
37 +To the fullest extent permitted by law, the service is provided without warranty of any kind, express or implied, and its operator is not liable for any loss or damage arising from its use or unavailability, including trading losses.
38 +
39 +## 7. Governing law
40 +
41 +These terms are governed by the laws of Québec and the federal laws of Canada applicable therein. Any dispute will be brought before the courts of the judicial district of Gatineau, Québec.
42 +
43 +## 8. Changes to these terms
44 +
45 +We may update these terms; the date at the top of the page changes when we do. Continued use after a change means you accept the new terms.
modified hfmarketdata/web/src/pages/limits/Limits.jsx +1 −1
@@ -3,7 +3,7 @@ import React from 'react'
3 3 import { Link } from 'react-router-dom'
4 4 import { CONTACT_EMAIL, HIGH_USAGE_MAILTO, TIERS } from '../../app/api.js'
5 5 import Callout from '../../components/Callout.jsx'
6 −import Code from '../../components/Code.jsx'
6 +import Code from '../../components/CodeBlock.jsx'
7 7 import { BoltIcon, CheckIcon, KeyIcon, MailIcon } from '../../components/Icons.jsx'
8 8 import useTitle from '../../docs/useTitle.js'
9 9 import './limits.css'
modified hfmarketdata/web/src/pages/limits/limits.css +15 −11
@@ -2,17 +2,17 @@
2 2 .limits-head h1 { font-size: clamp(34px, 5vw, 52px); }
3 3 .limits-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
4 4 .tiers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 14px; }
5 −.tier { display: flex; flex-direction: column; gap: 18px; padding: 24px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); }
6 −.tier-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), var(--shadow); position: relative; }
5 +.tier { display: flex; flex-direction: column; gap: 18px; padding: 24px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); min-width: 0; }
6 +.tier-featured { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), var(--shadow-2); position: relative; }
7 7 .tier-featured::before { content: "Most people need this"; position: absolute; top: -11px; left: 20px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; background: var(--accent); color: var(--accent-ink); padding: 3px 9px; border-radius: 999px; }
8 −.tier header h3 { font-size: 20px; margin-bottom: 2px; }
9 −.tier header p { margin: 0; font-size: 13px; }
8 +.tier header h3 { font-size: var(--fs-5); margin-bottom: 2px; }
9 +.tier header p { margin: 0; font-size: var(--fs-1); }
10 10 .tier-numbers { display: grid; gap: 10px; margin: 0; }
11 11 .tier-numbers div { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
12 −.tier-numbers dt { font-size: 13px; color: var(--fg-2); }
12 +.tier-numbers dt { font-size: var(--fs-1); color: var(--fg-2); }
13 13 .tier-numbers dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
14 −.tier-numbers dd strong { font-size: 16px; font-weight: 650; }
15 −.tier-numbers dd span { display: block; font-size: 12px; color: var(--fg-3); }
14 +.tier-numbers dd strong { font-size: 16px; font-weight: 650; font-family: var(--mono); }
15 +.tier-numbers dd span { display: block; font-size: var(--fs-0); color: var(--fg-3); }
16 16 .tier footer { margin-top: auto; }
17 17 .tier footer .btn { width: 100%; }
18 18 .limits-incentives { margin: 36px 0; }
@@ -21,19 +21,23 @@
21 21 .limits-headers { margin: 40px 0; }
22 22 .limits-faq { margin: 48px 0; max-width: 820px; }
23 23 .faq details { border-bottom: 1px solid var(--line); }
24 −.faq summary { padding: 14px 4px; cursor: pointer; font-weight: 550; list-style: none; display: flex; justify-content: space-between; gap: 12px; }
24 +.faq summary { min-height: var(--tap-mobile); padding: 10px 4px; cursor: pointer; font-weight: 550; list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
25 25 .faq summary::-webkit-details-marker { display: none; }
26 −.faq summary::after { content: "+"; color: var(--fg-3); font-weight: 400; }
26 +.faq summary::after { content: "+"; color: var(--fg-3); font-weight: 400; font-size: 18px; }
27 27 .faq details[open] summary::after { content: "–"; }
28 28 .faq p { margin: 0 4px 16px; color: var(--fg-1); max-width: 70ch; }
29 29 .limits-final-card { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; padding: 26px; }
30 30 .limits-final-card > svg { width: 34px; height: 34px; color: var(--accent); }
31 31 .limits-final-card h3 { margin-bottom: 4px; }
32 32 .limits-final-card p { margin: 0; }
33 −.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
33 +.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: var(--fs-2); }
34 34 .checks li { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
35 35 .checks svg { width: 16px; height: 16px; color: var(--accent); }
36 −@media (max-width: 900px) {
36 +@media (max-width: 960px) {
37 37 .tiers { grid-template-columns: 1fr; }
38 38 .limits-final-card { grid-template-columns: 1fr; }
39 + .checks li { white-space: normal; }
40 +}
41 +@media (max-width: 640px) {
42 + .limits-cta .btn { width: 100%; white-space: normal; text-align: center; }
39 43 }
modified hfmarketdata/web/src/pages/playground/Playground.jsx +1 −1
@@ -7,7 +7,7 @@ export default function PlaygroundPage() {
7 7 <main className="page pg-page">
8 8 <header className="pg-page-head">
9 9 <h1>Playground</h1>
10 − <p className="muted">Build a request, run it against the live API, inspect the result as JSON, table or chart, and export the code.</p>
10 + <p className="muted">Pick an endpoint, edit the parameters, run it against the live API and inspect the result as JSON, table or chart — then copy the code.</p>
11 11 </header>
12 12 <PlaygroundApp />
13 13 </main>
modified hfmarketdata/web/src/pages/status/Status.jsx +66 −38
@@ -1,9 +1,13 @@
1 −// /status — API health + live dataset inventory (assets × timeframes × adjustments) from /v1/status and /health.
2 −import React, { useEffect, useState } from 'react'
1 +// /status — API health + live dataset inventory. /v1/status and /health are quota-exempt; a 429 (old server, shared
2 +// egress) is reported as "rate limited — not down", never as an outage. Last data dates are shown only when the API
3 +// provides them (never invented).
4 +import React, { useCallback, useEffect, useState } from 'react'
3 5 import { Link } from 'react-router-dom'
4 −import { CONTACT_EMAIL, PUBLIC_BASE, api } from '../../app/api.js'
6 +import { BASE_URL, CONTACT_EMAIL, PUBLIC_BASE, api } from '../../app/api.js'
5 7 import Badge from '../../components/Badge.jsx'
6 8 import Callout from '../../components/Callout.jsx'
9 +import { Skeleton, Stat } from '../../components/States.jsx'
10 +import Table from '../../components/Table.jsx'
7 11 import useTitle from '../../docs/useTitle.js'
8 12 import './status.css'
9 13
@@ -11,61 +15,75 @@ const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
11 15 const ASSET_LABEL = { stock: 'Stocks', etf: 'ETFs', futures: 'Futures (continuous)', futures_contracts: 'Futures contracts', crypto: 'Crypto', index: 'Indices', fx: 'FX' }
12 16 const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US'))
13 17
18 +/** 'loading' | 'ok' | 'limited' (429) | 'down' */
19 +function classify(e) { return e?.status === 429 ? 'limited' : 'down' }
20 +
14 21 export default function Status() {
15 22 useTitle('Status', 'Live health and dataset inventory of the HF Market Data API: every asset type, timeframe and adjustment with instrument counts.')
16 23 const [health, setHealth] = useState({ state: 'loading' })
17 24 const [status, setStatus] = useState({ state: 'loading' })
18 25 const [openapi, setOpenapi] = useState({ state: 'loading' })
19 − useEffect(() => {
26 + const [checkedAt, setCheckedAt] = useState(null)
27 +
28 + const check = useCallback(() => {
20 29 let alive = true
21 − api('/health').then(({ data, ms }) => alive && setHealth({ state: 'ok', data, ms }), e => alive && setHealth({ state: 'down', error: e }))
22 − api('/v1/status').then(({ data, ms }) => alive && setStatus({ state: 'ok', data, ms }), e => alive && setStatus({ state: 'down', error: e }))
23 − fetch('/openapi.json', { headers: { Accept: 'application/json' } }).then(r => alive && setOpenapi({ state: r.ok && (r.headers.get('content-type') || '').includes('json') ? 'ok' : 'down' }), () => alive && setOpenapi({ state: 'down' }))
30 + setHealth({ state: 'loading' }); setStatus({ state: 'loading' }); setOpenapi({ state: 'loading' })
31 + api('/health').then(({ data, ms }) => alive && setHealth({ state: 'ok', data, ms }), e => alive && setHealth({ state: classify(e), error: e }))
32 + api('/v1/status').then(({ data, ms }) => alive && setStatus({ state: 'ok', data, ms }), e => alive && setStatus({ state: classify(e), error: e }))
33 + fetch(`${BASE_URL}/openapi.json`, { headers: { Accept: 'application/json' } })
34 + .then(r => alive && setOpenapi({ state: r.ok && (r.headers.get('content-type') || '').includes('json') ? 'ok' : r.status === 429 ? 'limited' : 'down' }), () => alive && setOpenapi({ state: 'down' }))
35 + setCheckedAt(new Date())
24 36 return () => { alive = false }
25 37 }, [])
38 + useEffect(() => check(), [check])
39 +
26 40 const ds = status.data?.datasets || {}
27 41 const assets = Object.keys(ds).filter(k => k !== 'options')
28 − const allUp = health.state === 'ok' && status.state === 'ok'
42 + const states = [health.state, status.state, openapi.state]
43 + const overall = states.includes('loading') ? 'loading' : states.includes('down') ? 'down' : states.includes('limited') ? 'limited' : 'ok'
44 + const lakeState = health.state === 'ok' ? (health.data?.data_root_present === false ? 'down' : 'ok') : health.state
45 +
29 46 return (
30 47 <main className="page status" data-testid="status-page">
31 48 <header className="status-head">
32 49 <p className="eyebrow">Status</p>
33 50 <h1>API health &amp; dataset inventory</h1>
34 − <p className="lead">Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained.</p>
51 + <p className="lead">Everything on this page is fetched live from the API when you open it — nothing is cached or hand-maintained. <code>/v1/status</code> and <code>/health</code> do not count against your quota.</p>
52 + <div className="status-overall" data-state={overall} data-testid="status-overall">
53 + <span className="status-dot" aria-hidden="true" />
54 + <strong>{overall === 'ok' ? 'All systems operational' : overall === 'limited' ? 'Operational — your IP is rate limited' : overall === 'down' ? 'Some checks failed' : 'Checking…'}</strong>
55 + {checkedAt && <span className="muted small">checked {checkedAt.toISOString().replace('T', ' ').slice(0, 19)} UTC</span>}
56 + <button type="button" className="btn btn-sm" onClick={check} disabled={overall === 'loading'}>Re-check</button>
57 + </div>
35 58 </header>
36 59
37 60 <section className="health-grid" aria-label="Service health">
38 − <HealthCard label="API" state={health.state} detail={health.state === 'ok' ? `${health.data?.service || 'hfmarketdata-api'} v${health.data?.version || '?'} · ${health.ms} ms` : health.state === 'down' ? 'Unreachable' : 'Checking…'} />
39 − <HealthCard label="Data lake" state={health.state === 'ok' ? (health.data?.data_root_present === false ? 'down' : 'ok') : health.state} detail={health.state === 'ok' ? (health.data?.data_root_present === false ? 'Not mounted' : 'Mounted') : health.state === 'down' ? 'Unknown' : 'Checking…'} />
40 − <HealthCard label="Inventory (/v1/status)" state={status.state} detail={status.state === 'ok' ? `${assets.length} asset types · ${status.ms} ms` : status.state === 'down' ? 'Unreachable' : 'Checking…'} />
41 − <HealthCard label="OpenAPI spec" state={openapi.state} detail={openapi.state === 'ok' ? 'Served at /openapi.json' : openapi.state === 'down' ? 'Not served (docs use the bundled snapshot)' : 'Checking…'} />
61 + <HealthCard label="API" state={health.state} detail={health.state === 'ok' ? `${health.data?.service || 'hfmarketdata-api'} v${health.data?.version || '?'} · ${health.ms} ms` : describe(health)} />
62 + <HealthCard label="Data lake" state={lakeState} detail={health.state === 'ok' ? (health.data?.data_root_present === false ? 'Not mounted' : 'Mounted') : describe(health)} />
63 + <HealthCard label="Inventory" state={status.state} detail={status.state === 'ok' ? `${assets.length} asset types · ${status.ms} ms` : describe(status)} />
64 + <HealthCard label="OpenAPI spec" state={openapi.state} detail={openapi.state === 'ok' ? 'Served at /openapi.json' : openapi.state === 'limited' ? 'Rate limited' : openapi.state === 'down' ? 'Not served (docs use the bundled snapshot)' : 'Checking…'} />
42 65 </section>
43 66
44 − {status.state === 'down' && <Callout type="warning" title="The API did not answer">This page could not reach <code>{PUBLIC_BASE}/v1/status</code>. If the problem persists, e-mail <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</Callout>}
67 + {status.state === 'limited' && (
68 + <Callout type="warning" title="Rate limited, not down" data-testid="status-limited">
69 + The API answered <code>429</code>: the keyless quota of your IP address is used up (30 requests / hour, shared behind a corporate NAT). The service itself is up. Retry after the window resets, or <Link to="/signup">create a free account</Link> for 120 requests / minute.
70 + </Callout>
71 + )}
72 + {status.state === 'down' && <Callout type="danger" title="The API did not answer">This page could not reach <code>{PUBLIC_BASE}/v1/status</code>. If the problem persists, e-mail <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</Callout>}
45 73
46 74 <section aria-labelledby="inv-h">
47 75 <h2 id="inv-h">Datasets</h2>
48 76 <p className="muted">Number of instruments available per asset type, timeframe and adjustment variant. Intraday timeframes for stocks and ETFs exist in split- and split+dividend-adjusted variants; unadjusted files exist for 1-minute and daily.</p>
49 − {status.state === 'loading' && <p className="muted" aria-busy="true">Loading inventory…</p>}
77 + {status.state === 'loading' && <div className="inv-skeleton" aria-busy="true"><Skeleton block height={18} /><Skeleton block height={120} /></div>}
50 78 {assets.map(asset => {
51 79 const tfs = ds[asset]
52 − const adjustments = [...new Set(Object.values(tfs).flatMap(o => Object.keys(o)))]
80 + const adjustments = [...new Set(Object.values(tfs).flatMap(o => Object.keys(o).filter(k => typeof o[k] === 'number')))]
81 + const columns = [{ key: 'adj', label: 'Adjustment', render: r => <code>{r.adj}</code> }, ...TIMEFRAMES.map(tf => ({ key: tf, label: tf, align: 'right', render: r => (r[tf] != null ? fmt(r[tf]) : <span className="muted">—</span>) }))]
82 + const rows = adjustments.map(adj => ({ adj, ...Object.fromEntries(TIMEFRAMES.map(tf => [tf, tfs[tf]?.[adj]])) }))
53 83 return (
54 84 <div key={asset} className="inv-block" data-testid={`inv-${asset}`}>
55 85 <h3>{ASSET_LABEL[asset] || asset} <code className="muted">{asset}</code></h3>
56 − <div className="table-wrap">
57 − <table>
58 − <thead><tr><th>Adjustment</th>{TIMEFRAMES.map(tf => <th key={tf} className="num">{tf}</th>)}</tr></thead>
59 − <tbody>
60 − {adjustments.map(adj => (
61 − <tr key={adj}>
62 − <td><code>{adj}</code></td>
63 − {TIMEFRAMES.map(tf => <td key={tf} className="num">{tfs[tf]?.[adj] != null ? fmt(tfs[tf][adj]) : <span className="muted">—</span>}</td>)}
64 − </tr>
65 − ))}
66 − </tbody>
67 − </table>
68 − </div>
86 + <Table columns={columns} rows={rows} keyField="adj" dense stickyFirst caption={`${ASSET_LABEL[asset] || asset}: instruments per adjustment and timeframe`} />
69 87 <LastDates data={tfs} />
70 88 </div>
71 89 )
@@ -74,13 +92,13 @@ export default function Status() {
74 92 <div className="inv-block" data-testid="inv-options">
75 93 <h3>Options <code className="muted">options</code></h3>
76 94 <div className="opt-facts">
77 − <div><span className="n">{fmt(ds.options.quarters?.length)}</span><span>quarterly archives</span></div>
78 − <div><span className="n">{ds.options.quarters?.[0]} → {ds.options.quarters?.at(-1)}</span><span>coverage</span></div>
79 − {ds.options.files_latest_quarter != null && <div><span className="n">{fmt(ds.options.files_latest_quarter)}</span><span>underlyings in the latest quarter</span></div>}
95 + <Stat label="Quarterly archives" value={fmt(ds.options.quarters?.length)} />
96 + <Stat label="Coverage" value={<>{ds.options.quarters?.[0]} → {ds.options.quarters?.at(-1)}</>} />
97 + {ds.options.files_latest_quarter != null && <Stat label="Underlyings, latest quarter" value={fmt(ds.options.files_latest_quarter)} />}
80 98 </div>
99 + <LastDates data={ds.options} />
81 100 </div>
82 101 )}
83 − {Object.keys(ds).filter(k => !assets.includes(k) && k !== 'options').length > 0 && <pre>{JSON.stringify(Object.fromEntries(Object.entries(ds).filter(([k]) => !assets.includes(k) && k !== 'options')), null, 2)}</pre>}
84 102 </section>
85 103
86 104 <section className="status-notes">
@@ -91,17 +109,23 @@ export default function Status() {
91 109 <li><strong>Limits.</strong> Your own remaining quota is in the <code>X-RateLimit-*</code> headers of every response and at <code>GET /v1/limits</code>. See <Link to="/limits">access &amp; limits</Link>.</li>
92 110 <li><strong>Contact.</strong> <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</li>
93 111 </ul>
94 − {allUp && <p className="muted small">All checks passed at {new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC.</p>}
95 112 </section>
96 113 </main>
97 114 )
98 115 }
99 116
117 +function describe(x) {
118 + if (x.state === 'limited') return 'Rate limited (429) — the service is up'
119 + if (x.state === 'down') return 'Unreachable'
120 + return 'Checking…'
121 +}
122 +
100 123 function HealthCard({ label, state, detail }) {
101 − const tone = state === 'ok' ? 'success' : state === 'down' ? 'danger' : 'neutral'
124 + const tone = state === 'ok' ? 'success' : state === 'down' ? 'danger' : state === 'limited' ? 'warn' : 'neutral'
125 + const text = state === 'ok' ? 'Operational' : state === 'down' ? 'Down' : state === 'limited' ? 'Rate limited' : '…'
102 126 return (
103 127 <div className="health-card" data-state={state}>
104 − <div className="health-top"><span>{label}</span><Badge tone={tone}>{state === 'ok' ? 'Operational' : state === 'down' ? 'Down' : '…'}</Badge></div>
128 + <div className="health-top"><span>{label}</span><Badge tone={tone} dot={state !== 'loading'}>{text}</Badge></div>
105 129 <div className="muted small">{detail}</div>
106 130 </div>
107 131 )
@@ -109,7 +133,11 @@ function HealthCard({ label, state, detail }) {
109 133
110 134 // /v1/status may grow `last_date` / `first_date` style fields per timeframe — render them when present, never invent.
111 135 function LastDates({ data }) {
112 − const rows = Object.entries(data).flatMap(([tf, o]) => Object.entries(o).filter(([k, v]) => /date|updated|as_of/i.test(k) && typeof v === 'string').map(([k, v]) => `${tf} ${k}: ${v}`))
136 + const rows = []
137 + for (const [k, v] of Object.entries(data || {})) {
138 + if (typeof v === 'string' && /date|updated|as_of/i.test(k)) rows.push(`${k}: ${v}`)
139 + else if (v && typeof v === 'object' && !Array.isArray(v)) for (const [k2, v2] of Object.entries(v)) if (typeof v2 === 'string' && /date|updated|as_of/i.test(k2)) rows.push(`${k} ${k2}: ${v2}`)
140 + }
113 141 if (!rows.length) return null
114 − return <p className="muted small">{rows.join(' · ')}</p>
142 + return <p className="muted small inv-dates" data-testid="last-dates">Last data: {rows.join(' · ')}</p>
115 143 }
modified hfmarketdata/web/src/pages/status/status.css +20 −8
@@ -1,17 +1,29 @@
1 1 .status-head { max-width: 760px; margin-bottom: 28px; }
2 +.status-overall { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); }
3 +.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fg-3); flex: none; }
4 +.status-overall[data-state="ok"] .status-dot { background: var(--success); box-shadow: 0 0 0 4px var(--success-soft); }
5 +.status-overall[data-state="limited"] .status-dot { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-soft); }
6 +.status-overall[data-state="down"] .status-dot { background: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); }
7 +.status-overall .btn { margin-left: auto; }
2 8 .health-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 36px; }
3 −.health-card { padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--bg-1); border-left-width: 3px; }
4 −.health-card[data-state="ok"] { border-left-color: var(--accent); }
9 +.health-card { padding: 16px 18px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); border-left-width: 3px; min-width: 0; }
10 +.health-card[data-state="ok"] { border-left-color: var(--success); }
11 +.health-card[data-state="limited"] { border-left-color: var(--warn); }
5 12 .health-card[data-state="down"] { border-left-color: var(--danger); }
6 13 .health-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 6px; }
7 14 .inv-block { margin: 26px 0; }
8 −.inv-block h3 { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
9 −.inv-block h3 code { font-size: 12px; }
15 +.inv-block h3 { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; font-size: var(--fs-4); }
16 +.inv-block h3 code { font-size: var(--fs-0); }
17 +.inv-block .table-wrap code { font-size: var(--fs-0); white-space: nowrap; }
18 +.inv-dates { margin: 8px 0 0; }
19 +.inv-skeleton { display: grid; gap: 10px; margin: 20px 0; }
10 20 .opt-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
11 −.opt-facts > div { padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); display: grid; gap: 2px; font-size: 13px; color: var(--fg-2); }
12 −.opt-facts .n { font-size: 22px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; }
13 21 .status-notes { margin-top: 40px; max-width: 820px; }
14 22 .status-notes ul { padding-left: 1.2em; color: var(--fg-1); }
15 23 .status-notes li + li { margin-top: 8px; }
16 −@media (max-width: 900px) { .health-grid { grid-template-columns: 1fr 1fr; } .opt-facts { grid-template-columns: 1fr; } }
17 −@media (max-width: 560px) { .health-grid { grid-template-columns: 1fr; } }
24 +@media (max-width: 960px) { .health-grid { grid-template-columns: 1fr 1fr; } .opt-facts { grid-template-columns: 1fr; } }
25 +@media (max-width: 640px) {
26 + .health-grid { grid-template-columns: 1fr; }
27 + .status-overall .btn { margin-left: 0; width: 100%; }
28 + .inv-block .table-wrap th, .inv-block .table-wrap td { padding: 6px 8px; font-size: var(--fs-0); }
29 +}
added hfmarketdata/web/src/playground/EndpointPicker.jsx +86 −0
@@ -0,0 +1,86 @@
1 +// Endpoint selector — a command-palette style combobox: trigger shows METHOD · title · path; the popover has a search
2 +// box and a listbox grouped by catalog group. Keyboard: ↑ ↓ Enter Esc; typing filters on title, path and group.
3 +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'
4 +import { ChevronDownIcon, SearchIcon } from '../components/Icons.jsx'
5 +import { CATALOG, GROUPS } from './catalog.js'
6 +
7 +export default function EndpointPicker({ entry, onSelect, extra = [] }) {
8 + const [open, setOpen] = useState(false)
9 + const [q, setQ] = useState('')
10 + const [active, setActive] = useState(0)
11 + const wrapRef = useRef(null)
12 + const inputRef = useRef(null)
13 + const listRef = useRef(null)
14 + const id = useId()
15 +
16 + const all = useMemo(() => [...CATALOG, ...extra.filter(e => !CATALOG.some(c => c.id === e.id))], [extra])
17 + const filtered = useMemo(() => {
18 + const t = q.trim().toLowerCase()
19 + if (!t) return all
20 + return all.filter(e => `${e.title} ${e.path} ${e.group} ${e.method}`.toLowerCase().includes(t))
21 + }, [all, q])
22 + const groups = useMemo(() => {
23 + const names = [...GROUPS, ...filtered.map(e => e.group).filter(g => !GROUPS.includes(g))]
24 + return [...new Set(names)].map(g => ({ name: g, items: filtered.filter(e => e.group === g) })).filter(g => g.items.length)
25 + }, [filtered])
26 + const flat = useMemo(() => groups.flatMap(g => g.items), [groups])
27 +
28 + useEffect(() => { if (!open) return undefined; setQ(''); setActive(Math.max(0, all.findIndex(e => e.id === entry.id))); const t = setTimeout(() => inputRef.current?.focus(), 10); return () => clearTimeout(t) }, [open]) // eslint-disable-line react-hooks/exhaustive-deps
29 + useEffect(() => { setActive(0) }, [q])
30 + useEffect(() => {
31 + if (!open) return undefined
32 + const onDoc = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
33 + document.addEventListener('mousedown', onDoc)
34 + return () => document.removeEventListener('mousedown', onDoc)
35 + }, [open])
36 + useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) }, [active, open])
37 +
38 + const pick = e => { if (!e) return; onSelect(e); setOpen(false) }
39 + const onKey = e => {
40 + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(flat.length - 1, a + 1)) }
41 + else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(0, a - 1)) }
42 + else if (e.key === 'Enter') { e.preventDefault(); pick(flat[active]) }
43 + else if (e.key === 'Escape') { e.preventDefault(); setOpen(false) }
44 + else if (e.key === 'Tab') setOpen(false)
45 + }
46 + let idx = -1
47 + return (
48 + <div className="pg-ep" ref={wrapRef}>
49 + <button type="button" className="pg-ep-trigger" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} data-testid="pg-endpoint" aria-label={`Endpoint: ${entry.title}. Change endpoint`}>
50 + <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span>
51 + <span className="pg-ep-text"><span className="pg-ep-title">{entry.title}</span><span className="pg-ep-path mono">{entry.path}</span></span>
52 + <ChevronDownIcon />
53 + </button>
54 + {open && (
55 + <div className="pg-ep-pop" role="presentation">
56 + <div className="pg-ep-search">
57 + <SearchIcon />
58 + <input ref={inputRef} type="text" value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} placeholder="Search endpoints… (title, path, group)" role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={flat[active] ? `${id}-opt-${flat[active].id}` : undefined} aria-label="Search endpoints" autoComplete="off" spellCheck={false} data-testid="pg-endpoint-search" />
59 + <kbd>esc</kbd>
60 + </div>
61 + <div className="pg-ep-list" id={`${id}-list`} role="listbox" aria-label="Endpoints" ref={listRef}>
62 + {flat.length === 0 && <div className="pg-ep-empty muted">No endpoint matches “{q}”.</div>}
63 + {groups.map(g => (
64 + <div key={g.name} role="group" aria-label={g.name}>
65 + <div className="pg-ep-group">{g.name}</div>
66 + {g.items.map(e => {
67 + idx += 1
68 + const i = idx
69 + return (
70 + <div key={e.id} id={`${id}-opt-${e.id}`} role="option" aria-selected={i === active} className={`pg-ep-opt ${e.id === entry.id ? 'current' : ''}`} data-ep={e.id}
71 + onMouseEnter={() => setActive(i)} onMouseDown={ev => ev.preventDefault()} onClick={() => pick(e)}>
72 + <span className={`pg-method ${e.method.toLowerCase()}`}>{e.method}</span>
73 + <span className="pg-ep-text"><span className="pg-ep-title">{e.title}</span><span className="pg-ep-path mono">{e.path}</span></span>
74 + {e.id === entry.id && <span className="pg-ep-current muted">current</span>}
75 + </div>
76 + )
77 + })}
78 + </div>
79 + ))}
80 + </div>
81 + <div className="pg-ep-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> select</span><span>{all.length} endpoints</span></div>
82 + </div>
83 + )}
84 + </div>
85 + )
86 +}
modified hfmarketdata/web/src/playground/Playground.jsx +181 −143
@@ -1,15 +1,22 @@
1 1 // Interactive playground — reusable: <Playground apiKey={sessionKey} embedded /> (dashboard) or bare (/playground).
2 −// Deep links: /playground?ep=<catalogId|operationId>&<param>=<value>… (see PLAYGROUND.md).
2 +// Layout: endpoint picker (combobox) + presets · parameters grid · URL + Run · full-width results (JSON / Table /
3 +// Chart / Code / Download) · rate-limit panel. Empty state = the captured production example of the selected
4 +// endpoint ("Edit a parameter and run"). A valid deep link (?ep=…&param=…) runs once automatically.
5 +// Deep links: /playground?ep=<catalogId|operationId>&<param>=<value>… (see README).
3 6 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4 7 import { Link, useLocation, useSearchParams } from 'react-router-dom'
5 8 import { BASE_URL, PUBLIC_BASE, rateHeaders } from '../app/api.js'
6 9 import { useAuth } from '../app/auth.jsx'
7 −import PgCallout from '../components/PgCallout.jsx'
8 −import PgCodeBlock from '../components/PgCodeBlock.jsx'
9 −import PgCopyButton from '../components/PgCopyButton.jsx'
10 −import PgTabs from '../components/PgTabs.jsx'
11 −import { CATALOG, GROUPS, buildPath, byId, defaultValues, entryFromSpec, findByPath, findEntry, specOperation, validate } from './catalog.js'
10 +import Callout from '../components/Callout.jsx'
11 +import CodeTabs from '../components/CodeTabs.jsx'
12 +import CopyButton from '../components/CopyButton.jsx'
13 +import { PlayIcon } from '../components/Icons.jsx'
14 +import { EmptyState, ErrorState } from '../components/States.jsx'
15 +import Tabs from '../components/Tabs.jsx'
16 +import { loadExampleFile } from '../docs/enrich.js'
17 +import { CATALOG, buildPath, byId, defaultValues, entryFromSpec, findByPath, findEntry, normalizeValues, specOperation, validate } from './catalog.js'
12 18 import { curlSnippet, jsSnippet, pythonSnippet } from './codegen.js'
19 +import EndpointPicker from './EndpointPicker.jsx'
13 20 import { PRESETS } from './presets.js'
14 21 import RateLimitPanel from './RateLimitPanel.jsx'
15 22 import RequestForm from './RequestForm.jsx'
@@ -60,7 +67,7 @@ function parseCsv(text, max = 50_000) {
60 67 return rows
61 68 }
62 69
63 −function extractRows(json, entry) {
70 +export function extractRows(json, entry) {
64 71 if (!json || typeof json !== 'object') return []
65 72 if (Array.isArray(json)) return json
66 73 if (Array.isArray(json.data)) return json.data
@@ -74,28 +81,37 @@ function extractRows(json, entry) {
74 81
75 82 const isRouteMissing = (status, body) => status === 404 && (body == null || typeof body === 'string' || (body.detail === 'Not Found' && !body.error) || body?.error?.code === 'NOT_FOUND' && /route|endpoint|not found$/i.test(body?.error?.message || ''))
76 83
84 +const pickView = (out, entry) => {
85 + const hasRows = out.rows?.length > 0
86 + const kind = entry.result?.kind
87 + if (out.blob && out.format !== 'csv') return 'download'
88 + if (hasRows && (kind === 'bars' || kind === 'series') && detectTimeKey(out.rows, entry.result?.timeKey)) return 'chart'
89 + if (hasRows) return 'table'
90 + return 'json'
91 +}
92 +
77 93 export default function Playground({ apiKey, embedded = false }) {
78 94 const { user } = useAuth()
79 95 const location = useLocation()
80 − const [searchParams, setSearchParams] = useSearchParams()
96 + const [, setSearchParams] = useSearchParams()
81 97 const [entry, setEntry] = useState(() => byId(DEFAULT_EP))
82 98 const [values, setValues] = useState(() => defaultValues(byId(DEFAULT_EP)))
83 99 const [notice, setNotice] = useState(null)
84 100 const [result, setResult] = useState(null)
101 + const [sample, setSample] = useState(null) // captured production example of the current entry (empty state)
85 102 const [running, setRunning] = useState(false)
86 103 const [view, setView] = useState('table')
87 − const [codeLang, setCodeLang] = useState('curl')
88 104 const [retryUntil, setRetryUntil] = useState(null)
89 105 const [lastRate, setLastRate] = useState(null)
90 − const [filter, setFilter] = useState('')
106 + const [autoRun, setAutoRun] = useState(false)
91 107 const lastWritten = useRef(null)
92 108 const ready = useRef(false) // URL → state initialisation done; only then do we write state → URL
109 + const autoRan = useRef(false)
93 110 const abortRef = useRef(null)
94 − const resultRef = useRef(null)
95 111
96 112 // ---- deep links: (re)initialise from the URL whenever it changes externally
97 113 useEffect(() => {
98 − if (location.search === lastWritten.current) return
114 + if (location.search === lastWritten.current) return undefined
99 115 const sp = new URLSearchParams(location.search)
100 116 const ref = sp.get('ep')
101 117 const urlValues = {}
@@ -104,12 +120,13 @@ export default function Playground({ apiKey, embedded = false }) {
104 120 setEntry(e)
105 121 const dv = defaultValues(e)
106 122 for (const [k, v] of Object.entries(urlValues)) if (k in dv || e.fromSpec) dv[k] = v
107 − setValues(dv)
123 + setValues(normalizeValues(e, dv))
108 124 setResult(null)
109 125 if (unknown) setNotice({ kind: 'warn', text: `Unknown request type "${unknown}" — showing ${e.title} instead.` })
110 126 else if (generic) setNotice({ kind: 'info', text: `${e.title}: form generated from the OpenAPI spec (no curated preset yet).` })
111 127 else setNotice(null)
112 128 ready.current = true
129 + if (ref && !unknown && !autoRan.current) { autoRan.current = true; setAutoRun(true) }
113 130 }
114 131 // synchronous fast path (catalog id / known operationId) avoids a flash of the default form
115 132 const direct = ref ? findEntry(ref, urlValues) : byId(DEFAULT_EP)
@@ -131,6 +148,22 @@ export default function Playground({ apiKey, embedded = false }) {
131 148 else lastWritten.current = next
132 149 }, [entry, values]) // eslint-disable-line react-hooks/exhaustive-deps
133 150
151 + // ---- captured production example for the empty state
152 + useEffect(() => {
153 + let alive = true
154 + setSample(null)
155 + const opId = entry.operationIds?.[0]
156 + if (!opId) return undefined
157 + loadExampleFile(opId).then(file => {
158 + if (!alive || !file?.body) return
159 + const json = file.body
160 + const rows = extractRows(json, entry)
161 + const out = { status: file.status || 200, ok: true, json, rows, rowCount: json?.count ?? rows.length, format: 'json', captured: true, capturedAt: file.captured_at, url: file.url }
162 + setSample(out)
163 + })
164 + return () => { alive = false }
165 + }, [entry])
166 +
134 167 const errors = useMemo(() => validate(entry, values), [entry, values])
135 168 const relUrl = useMemo(() => buildPath(entry, values), [entry, values])
136 169 const fullUrl = PUBLIC_BASE + relUrl
@@ -138,26 +171,27 @@ export default function Playground({ apiKey, embedded = false }) {
138 171 const isWs = entry.result?.kind === 'ws'
139 172 const tier = user?.tier || (apiKey ? 'free' : 'keyless')
140 173 const authenticated = !!apiKey
174 + const errorCount = Object.keys(errors).length
141 175
142 176 const selectEntry = useCallback((e, preset) => {
143 177 abortRef.current?.abort()
144 178 setEntry(e)
145 − setValues({ ...defaultValues(e), ...(preset || {}) })
179 + setValues(normalizeValues(e, { ...defaultValues(e), ...(preset || {}) }))
146 180 setResult(null)
147 181 setNotice(null)
148 182 }, [])
149 183
150 184 const onChange = useCallback((name, v) => setValues(vals => {
151 185 const next = { ...vals, [name]: v }
152 − // reset dependent enums (adjustment) when asset changes
186 + // dependent enums (adjustment) follow the asset
153 187 if (name === 'asset') for (const prm of entry.params) if (prm.enumBy?.asset) next[prm.name] = ''
154 − return next
188 + return normalizeValues(entry, next)
155 189 }), [entry])
156 190
157 191 const applyPreset = ps => { const e = byId(ps.ep); if (e) selectEntry(e, ps.values()) }
158 192
159 193 const run = useCallback(async () => {
160 − if (Object.keys(errors).length || isWs) return
194 + if (errorCount || isWs) return
161 195 abortRef.current?.abort()
162 196 const ctrl = new AbortController()
163 197 abortRef.current = ctrl
@@ -175,7 +209,8 @@ export default function Playground({ apiKey, embedded = false }) {
175 209 const out = { status: res.status, ok: res.ok, ms, rate, format, statusText: res.statusText }
176 210 if (res.status === 429) {
177 211 const wait = rate.retryAfter != null ? rate.retryAfter : rate.reset ? rate.reset - Math.floor(Date.now() / 1000) : 60
178 − setRetryUntil(Math.floor(Date.now() / 1000) + Math.max(1, wait))
212 + out.retryUntil = Math.floor(Date.now() / 1000) + Math.max(1, wait)
213 + setRetryUntil(out.retryUntil)
179 214 } else setRetryUntil(null)
180 215 if (ct.includes('json')) {
181 216 out.json = await res.json()
@@ -202,12 +237,7 @@ export default function Playground({ apiKey, embedded = false }) {
202 237 }
203 238 if (!res.ok && isRouteMissing(res.status, out.json ?? out.text)) out.comingSoon = true
204 239 setResult(out)
205 − const hasRows = out.rows?.length > 0
206 − const kind = entry.result?.kind
207 − if (out.blob && format !== 'csv') setView('download')
208 − else if (hasRows && (kind === 'bars' || kind === 'series') && detectTimeKey(out.rows, entry.result?.timeKey)) setView('chart')
209 − else if (hasRows) setView('table')
210 − else setView('json')
240 + setView(pickView(out, entry))
211 241 } catch (e) {
212 242 if (e.name === 'AbortError') return
213 243 setResult({ status: 0, ok: false, ms: Math.round(performance.now() - t0), error: { code: 'NETWORK_ERROR', message: e.message || 'Network error' } })
@@ -215,7 +245,14 @@ export default function Playground({ apiKey, embedded = false }) {
215 245 } finally {
216 246 if (!ctrl.signal.aborted) setRunning(false)
217 247 }
218 − }, [errors, isWs, format, apiKey, relUrl, entry])
248 + }, [errorCount, isWs, format, apiKey, relUrl, entry])
249 +
250 + // auto-run once on a valid deep link
251 + useEffect(() => {
252 + if (!autoRun) return
253 + setAutoRun(false)
254 + if (!errorCount && !isWs) run()
255 + }, [autoRun, errorCount, isWs, run])
219 256
220 257 // Ctrl/Cmd+Enter runs
221 258 useEffect(() => {
@@ -226,153 +263,154 @@ export default function Playground({ apiKey, embedded = false }) {
226 263
227 264 const downloadUrl = useMemo(() => (result?.blob ? URL.createObjectURL(result.blob) : null), [result])
228 265 useEffect(() => () => { if (downloadUrl) URL.revokeObjectURL(downloadUrl) }, [downloadUrl])
266 + const jsonDownload = useMemo(() => (result?.json && result.ok ? URL.createObjectURL(new Blob([JSON.stringify(result.json)], { type: 'application/json' })) : null), [result])
267 + useEffect(() => () => { if (jsonDownload) URL.revokeObjectURL(jsonDownload) }, [jsonDownload])
229 268
230 − const code = useMemo(() => {
269 + const codeSnippets = useMemo(() => {
231 270 const opts = { hasKey: authenticated, format, envelope: entry.result?.envelope }
232 − return { curl: curlSnippet(relUrl, opts), python: pythonSnippet(relUrl, opts), javascript: jsSnippet(relUrl, opts) }
271 + return [
272 + { id: 'curl', label: 'curl', language: 'bash', code: curlSnippet(relUrl, opts) },
273 + { id: 'python', label: 'Python', language: 'python', code: pythonSnippet(relUrl, opts) },
274 + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: jsSnippet(relUrl, opts) },
275 + ]
233 276 }, [relUrl, authenticated, format, entry])
234 277
235 − const rows = result?.rows || []
278 + // what the results area shows: a live result, or the captured sample while nothing has run
279 + const shown = result && !result.error ? result : (!result && !running && sample) || null
280 + const showingSample = !!shown?.captured
281 + const rows = shown?.rows || []
236 282 const chartable = rows.length > 0 && !!detectTimeKey(rows, entry.result?.timeKey)
283 + const effectiveView = shown ? (showingSample && view === 'download' ? pickView(shown, entry) : view) : 'json'
284 + useEffect(() => { if (showingSample && sample) setView(sample.rows?.length ? 'table' : 'json') }, [showingSample, sample, entry])
285 +
237 286 const viewTabs = [
238 − { id: 'json', label: 'JSON', disabled: !result?.json },
287 + { id: 'json', label: 'JSON', disabled: !shown?.json },
239 288 { id: 'table', label: 'Table', disabled: !rows.length, badge: rows.length ? rows.length.toLocaleString() : undefined },
240 289 { id: 'chart', label: 'Chart', disabled: !chartable },
241 − { id: 'download', label: 'Download', disabled: !result?.blob },
290 + { id: 'code', label: 'Code' },
291 + ...(shown?.blob ? [{ id: 'download', label: 'Download' }] : []),
242 292 ]
243 − const visibleCatalog = CATALOG.filter(e => !filter || `${e.title} ${e.path} ${e.group}`.toLowerCase().includes(filter.toLowerCase()))
244 − const jsonDownload = useMemo(() => (result?.json && result.ok ? URL.createObjectURL(new Blob([JSON.stringify(result.json)], { type: 'application/json' })) : null), [result])
245 − useEffect(() => () => { if (jsonDownload) URL.revokeObjectURL(jsonDownload) }, [jsonDownload])
293 +
294 + // Code export is always in the DOM (hidden unless the Code tab is active) so the dashboard can assert on it.
295 + const codex = visible => (
296 + <div className="pg-codex" data-testid="pg-code" hidden={!visible}>
297 + <CodeTabs snippets={codeSnippets} maxHeight={360} footer={authenticated ? 'Your key is read from $HFMD_API_KEY — never pasted into the snippet.' : 'Keyless request — a free API key (sent as a Bearer token) raises the limit to 120 requests / minute.'} />
298 + </div>
299 + )
246 300
247 301 return (
248 302 <div className={`pg ${embedded ? 'pg-embedded' : ''}`} data-testid="playground">
249 303 {!embedded && !authenticated && (
250 − <PgCallout kind="info" className="pg-banner" action={<Link to="/signup" className="btn btn-primary">Create free account</Link>}>
251 − <strong>Keyless mode:</strong> 30 requests / hour, 5 000 rows per request. Create a free account for 120 requests / minute and 50 000 rows per request.
252 − </PgCallout>
304 + <Callout type="info" compact className="pg-banner" action={<Link to="/signup" className="btn btn-primary btn-sm">Create free account</Link>}>
305 + <strong>Keyless mode:</strong> 30 requests / hour, 5 000 rows per request. A free account gives 120 requests / minute and 50 000 rows per request.
306 + </Callout>
253 307 )}
254 308 {authenticated && (
255 309 <div className="pg-authbar" data-testid="pg-authbar">
256 310 <span className="pg-authdot" aria-hidden="true" /> Authenticated{user?.email ? <> as <strong>{user.email}</strong></> : ''} · tier <span className="pg-tier">{tier}</span> · requests carry <code>Authorization: Bearer $HFMD_API_KEY</code>
257 311 </div>
258 312 )}
259 − <div className="pg-grid">
260 − {/* ------------------------------------------------ left: request type + form */}
261 − <aside className="pg-left">
262 − <div className="pg-left-head">
263 − <h2 className="pg-h">Request type</h2>
264 − <input type="search" className="pg-filter" placeholder="Filter…" value={filter} onChange={e => setFilter(e.target.value)} aria-label="Filter request types" />
265 − </div>
266 − <nav className="pg-types" aria-label="Request type">
267 − {GROUPS.map(g => {
268 − const items = visibleCatalog.filter(e => e.group === g)
269 − if (!items.length) return null
270 − return (
271 − <div key={g} className="pg-type-group">
272 − <div className="pg-type-group-name">{g}</div>
273 − {items.map(e => (
274 − <button key={e.id} type="button" className={`pg-type ${entry.id === e.id ? 'active' : ''}`} aria-current={entry.id === e.id ? 'true' : undefined}
275 − onClick={() => selectEntry(e)} data-ep={e.id}>
276 − <span className={`pg-method ${e.method.toLowerCase()}`}>{e.method}</span>
277 − <span className="pg-type-title">{e.title}</span>
278 − <span className="pg-type-path mono">{e.path}</span>
279 − </button>
280 − ))}
281 − </div>
282 − )
283 − })}
284 − {entry.fromSpec && (
285 − <div className="pg-type-group"><div className="pg-type-group-name">From OpenAPI</div>
286 − <button type="button" className="pg-type active" aria-current="true"><span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span><span className="pg-type-title">{entry.title}</span><span className="pg-type-path mono">{entry.path}</span></button>
287 − </div>
288 − )}
289 − </nav>
290 − {!isWs && (
291 − <section className="pg-form-section" aria-label="Parameters">
292 − <h2 className="pg-h">Parameters</h2>
293 − {entry.description && <p className="muted pg-desc">{entry.description}</p>}
294 − <RequestForm entry={entry} values={values} errors={errors} onChange={onChange} apiKey={apiKey} />
295 − {entry.fixed && <p className="muted pg-fixed">Fixed: {Object.entries(entry.fixed).map(([k, v]) => <code key={k}>{k}={v}</code>)}</p>}
296 − </section>
297 − )}
298 − </aside>
299 313
300 − {/* ------------------------------------------------ right: URL, run, results */}
301 − <section className="pg-right" aria-label="Request and results">
302 − {notice && <PgCallout kind={notice.kind}>{notice.text}</PgCallout>}
303 − {isWs ? (
304 − <WebSocketTab apiKey={apiKey} authenticated={!!user} />
305 − ) : (
306 − <>
307 − <div className="pg-urlbar">
308 − <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span>
309 − <output className="pg-url mono" data-testid="pg-url" aria-live="polite" htmlFor="">{fullUrl}</output>
310 − <PgCopyButton text={fullUrl} label="Copy URL" small />
311 − </div>
312 − <div className="pg-runrow">
313 − <button type="button" className="btn btn-primary pg-run" onClick={run} disabled={running || Object.keys(errors).length > 0} data-testid="pg-run">
314 − {running ? 'Running…' : 'Run ▶'}
315 − </button>
316 − {running && <button type="button" className="btn" onClick={() => { abortRef.current?.abort(); setRunning(false) }}>Cancel</button>}
317 − <span className="pg-kbd muted">⌘/Ctrl + Enter</span>
318 − {Object.keys(errors).length > 0 && <span className="pg-err">Fix {Object.keys(errors).length} field{Object.keys(errors).length > 1 ? 's' : ''} to run.</span>}
319 − {result && (
320 − <span className="pg-status" data-testid="pg-status">
321 − <span className={`pg-code-badge ${result.ok ? 'ok' : 'bad'}`}>{result.status || 'ERR'}{result.ok ? ' OK' : ''}</span>
322 − <span className="mono">{result.ms} ms</span>
323 − {result.rowCount != null && <span className="mono">{Number(result.rowCount).toLocaleString()} rows</span>}
324 − {result.blob && <span className="mono">{(result.blob.size / 1024).toFixed(1)} KB</span>}
325 − </span>
326 − )}
327 − </div>
314 + {/* ------------------------------------------------ top bar: endpoint + presets */}
315 + <div className="pg-top">
316 + <EndpointPicker entry={entry} onSelect={e => selectEntry(e)} extra={entry.fromSpec ? [entry] : []} />
317 + <div className="pg-presets" aria-label="Examples">
318 + <span className="muted small">Examples</span>
319 + {PRESETS.map(ps => <button key={ps.id} type="button" className="pg-preset" onClick={() => applyPreset(ps)} data-preset={ps.id}>{ps.label}</button>)}
320 + </div>
321 + </div>
322 + {notice && <Callout kind={notice.kind} compact>{notice.text}</Callout>}
328 323
329 − {result?.error && (
330 − <PgCallout kind={result.comingSoon ? 'warn' : 'danger'} title={result.comingSoon ? 'Endpoint not available yet' : `${result.error.code || 'Error'} · HTTP ${result.status}`}
331 − action={result.status === 429 && !authenticated ? <Link className="btn btn-primary" to="/signup">Create free account</Link> : result.error.docs ? <a className="btn" href={result.error.docs}>Docs</a> : null}>
332 − {result.comingSoon
333 − ? <>This request type is part of the v2 upgrade and is not deployed on this server yet — coming soon. Route: <code>{entry.method} {entry.path}</code>.</>
334 − : result.error.message}
335 − {result.error.details && <pre className="pg-err-details">{JSON.stringify(result.error.details, null, 2)}</pre>}
336 − </PgCallout>
324 + {/* ------------------------------------------------ request */}
325 + <section className="pg-request card" aria-label="Request">
326 + <div className="pg-request-head">
327 + <div>
328 + <h2 className="pg-request-title">{entry.title}</h2>
329 + {entry.description && <p className="muted pg-desc">{entry.description}</p>}
330 + </div>
331 + {entry.fixed && <p className="muted pg-fixed">Fixed: {Object.entries(entry.fixed).map(([k, v]) => <code key={k}>{k}={v}</code>)}</p>}
332 + </div>
333 + {isWs ? (
334 + <WebSocketTab apiKey={apiKey} authenticated={!!user} />
335 + ) : (
336 + <>
337 + <RequestForm entry={entry} values={values} errors={errors} onChange={onChange} apiKey={apiKey} />
338 + <div className="pg-urlbar">
339 + <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span>
340 + <output className="pg-url mono" data-testid="pg-url" aria-live="polite" htmlFor="">{fullUrl}</output>
341 + <CopyButton text={fullUrl} label="Copy URL" />
342 + </div>
343 + <div className="pg-runrow">
344 + <button type="button" className="btn btn-primary pg-run" onClick={run} disabled={running || errorCount > 0} data-testid="pg-run">
345 + <PlayIcon /> {running ? 'Running…' : 'Run'}
346 + </button>
347 + {running && <button type="button" className="btn" onClick={() => { abortRef.current?.abort(); setRunning(false) }}>Cancel</button>}
348 + <span className="pg-kbd muted">⌘/Ctrl + Enter</span>
349 + {errorCount > 0 && <span className="pg-err" role="status">Fix {errorCount} field{errorCount > 1 ? 's' : ''} to run.</span>}
350 + {result && (
351 + <span className="pg-status" data-testid="pg-status">
352 + <span className={`pg-code-badge ${result.ok ? 'ok' : 'bad'}`}>{result.status || 'ERR'}{result.ok ? ' OK' : ''}</span>
353 + <span className="mono">{result.ms} ms</span>
354 + {result.rowCount != null && <span className="mono">{Number(result.rowCount).toLocaleString()} rows</span>}
355 + {result.blob && <span className="mono">{(result.blob.size / 1024).toFixed(1)} KB</span>}
356 + </span>
337 357 )}
358 + </div>
359 + </>
360 + )}
361 + </section>
338 362
339 − {result && !result.error && (
340 − <div className="pg-results" ref={resultRef}>
341 − <PgTabs tabs={viewTabs} value={view} onChange={setView} label="Result view" right={
342 − <div className="pg-dl-row">
343 − {jsonDownload && <a className="btn btn-sm" href={jsonDownload} download={`${entry.id}.json`}>Download JSON</a>}
344 − {downloadUrl && <a className="btn btn-sm" href={downloadUrl} download={result.filename}>Download {format.toUpperCase()}</a>}
345 − </div>}>
346 − {view === 'json' && (result.json ? <JsonView data={result.json} /> : <pre className="pg-text">{(result.text || '').slice(0, 20_000)}</pre>)}
347 − {view === 'table' && <TableView rows={rows} />}
348 − {view === 'chart' && <ChartView rows={rows} meta={result.meta} hint={entry.result || {}} />}
349 − {view === 'download' && (
363 + {/* ------------------------------------------------ results */}
364 + {!isWs && (
365 + <section className="pg-results" aria-label="Results" data-testid="pg-results">
366 + {result?.error && (
367 + result.comingSoon
368 + ? <Callout type="warning" title="Endpoint not available yet">This request type is part of the v2 upgrade and is not deployed on this server yet — coming soon. Route: <code>{entry.method} {entry.path}</code>.</Callout>
369 + : <ErrorState status={result.status} code={result.error.code} message={result.error.message} retryUntil={result.retryUntil} authenticated={authenticated} docs={result.error.docs} onRetry={run} data-testid="pg-error">
370 + {result.error.details && <pre className="pg-err-details">{JSON.stringify(result.error.details, null, 2)}</pre>}
371 + </ErrorState>
372 + )}
373 + {!result?.error && !shown && (
374 + running
375 + ? <EmptyState title="Running…" data-testid="pg-empty"><span className="spinner" aria-hidden="true" /> Waiting for the API.</EmptyState>
376 + : <EmptyState icon={PlayIcon} title="Edit a parameter and run" data-testid="pg-empty">Results appear here as JSON, a sortable table or a chart. Nothing is sent until you press Run.</EmptyState>
377 + )}
378 + {!result?.error && shown && (
379 + <div className="pg-results-box">
380 + {showingSample && (
381 + <div className="pg-sample-note" data-testid="pg-sample">
382 + <span className="badge badge-info">Captured example</span>
383 + <span>Real response from production{shown.capturedAt ? ` on ${shown.capturedAt.slice(0, 10)}` : ''} for <code className="pg-sample-url">{shown.url?.replace(PUBLIC_BASE, '')}</code>. <strong>Edit a parameter and run</strong> to query the live API.</span>
384 + </div>
385 + )}
386 + <Tabs items={viewTabs} value={effectiveView} onChange={setView} ariaLabel="Result view" className="tabs-flush" right={
387 + <div className="pg-dl-row">
388 + {jsonDownload && <a className="btn btn-sm" href={jsonDownload} download={`${entry.id}.json`}>Download JSON</a>}
389 + {downloadUrl && <a className="btn btn-sm" href={downloadUrl} download={result.filename}>Download {format.toUpperCase()}</a>}
390 + </div>}>
391 + {v => (
392 + <>
393 + {v === 'json' && (shown.json ? <JsonView data={shown.json} /> : <pre className="pg-text">{(shown.text || '').slice(0, 20_000)}</pre>)}
394 + {v === 'table' && <TableView rows={rows} />}
395 + {v === 'chart' && <ChartView rows={rows} meta={shown.meta} hint={entry.result || {}} />}
396 + {v === 'download' && shown.blob && (
350 397 <div className="pg-download">
351 − <p>{format.toUpperCase()} file ready · {(result.blob.size / 1024).toFixed(1)} KB{result.rowCount != null && ` · ${Number(result.rowCount).toLocaleString()} rows`}.</p>
352 − <a className="btn btn-primary" href={downloadUrl} download={result.filename}>Download {result.filename}</a>
398 + <p>{format.toUpperCase()} file ready · {(shown.blob.size / 1024).toFixed(1)} KB{shown.rowCount != null && ` · ${Number(shown.rowCount).toLocaleString()} rows`}.</p>
399 + <a className="btn btn-primary" href={downloadUrl} download={shown.filename}>Download {shown.filename}</a>
353 400 {format === 'parquet' && <p className="muted">Parquet responses count half the rows against your quota. Read it with <code>pd.read_parquet</code>.</p>}
354 401 </div>
355 402 )}
356 − </PgTabs>
357 − </div>
358 − )}
359 −
360 − <RateLimitPanel rate={lastRate} tier={tier} authenticated={authenticated} retryUntil={retryUntil} />
361 −
362 − <section className="pg-codex" aria-label="Code export">
363 − <PgTabs tabs={[{ id: 'curl', label: 'curl' }, { id: 'python', label: 'Python (requests + pandas)' }, { id: 'javascript', label: 'JavaScript (fetch)' }]} value={codeLang} onChange={setCodeLang} label="Code export">
364 − <PgCodeBlock code={code[codeLang]} language={codeLang === 'curl' ? 'bash' : codeLang} title={`${codeLang}${authenticated ? ' · key via $HFMD_API_KEY' : ''}`} />
365 − </PgTabs>
366 − </section>
367 − </>
403 + </>
404 + )}
405 + </Tabs>
406 + {codex(effectiveView === 'code')}
407 + </div>
368 408 )}
369 −
370 − <section className="pg-presets" aria-label="Examples">
371 − <span className="muted">Examples:</span>
372 − {PRESETS.map(ps => <button key={ps.id} type="button" className="pg-preset" onClick={() => applyPreset(ps)} data-preset={ps.id}>{ps.label}</button>)}
373 − </section>
409 + {(result?.error || !shown) && codex(false)}
374 410 </section>
375 − </div>
411 + )}
412 +
413 + {!isWs && <RateLimitPanel rate={lastRate} tier={tier} authenticated={authenticated} retryUntil={retryUntil} />}
376 414 </div>
377 415 )
378 416 }
modified hfmarketdata/web/src/playground/RateLimitPanel.jsx +5 −16
@@ -1,7 +1,10 @@
1 1 // Quota panel fed by X-RateLimit-* headers of the last response; Retry-After countdown after a 429.
2 −import React, { useEffect, useState } from 'react'
2 +import React from 'react'
3 3 import { Link } from 'react-router-dom'
4 4 import { TIERS } from '../app/api.js'
5 +import { fmtDuration, useCountdown } from '../components/States.jsx'
6 +
7 +export { useCountdown, fmtDuration }
5 8
6 9 const n = v => (v == null ? '—' : Number(v).toLocaleString())
7 10
@@ -18,20 +21,6 @@ function Bar({ label, used, limit, remaining }) {
18 21 )
19 22 }
20 23
21 −export function useCountdown(untilEpochSec) {
22 − const [left, setLeft] = useState(() => (untilEpochSec ? Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)) : 0))
23 − useEffect(() => {
24 − if (!untilEpochSec) { setLeft(0); return undefined }
25 − const tick = () => setLeft(Math.max(0, untilEpochSec - Math.floor(Date.now() / 1000)))
26 − tick()
27 − const t = setInterval(tick, 1000)
28 − return () => clearInterval(t)
29 − }, [untilEpochSec])
30 − return left
31 −}
32 −
33 −export const fmtDuration = s => (s >= 3600 ? `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min` : s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`)
34 −
35 24 export default function RateLimitPanel({ rate, tier, authenticated, retryUntil }) {
36 25 const resetLeft = useCountdown(rate?.reset || null)
37 26 const retryLeft = useCountdown(retryUntil || null)
@@ -42,7 +31,7 @@ export default function RateLimitPanel({ rate, tier, authenticated, retryUntil }
42 31 const remRows = rate?.remainingRows
43 32 const known = rate && (rate.limitRequests != null || rate.remainingRequests != null)
44 33 return (
45 − <section className="pg-rl card" aria-label="Rate limit">
34 + <section className="pg-rl card card-sm" aria-label="Rate limit">
46 35 <div className="pg-rl-head">
47 36 <strong>Rate limit</strong>
48 37 <span className="muted">{tierInfo.name} · {n(tierInfo.requests)} req / {tierInfo.window} · {n(tierInfo.rows)} rows / {tierInfo.window}</span>
modified hfmarketdata/web/src/playground/RequestForm.jsx +12 −29
@@ -1,5 +1,6 @@
1 −// Dynamic form generated from a catalog entry.
1 +// Dynamic form generated from a catalog entry — a responsive grid of shared <Field> controls.
2 2 import React from 'react'
3 +import { Field, Input, Select } from '../components/Field.jsx'
3 4 import { enumFor } from './catalog.js'
4 5 import SymbolInput from './SymbolInput.jsx'
5 6
@@ -10,49 +11,31 @@ export default function RequestForm({ entry, values, errors, onChange, apiKey })
10 11 if (!entry.params.length) return <p className="muted pg-form-empty">This request takes no parameters.</p>
11 12 const asset = values.asset || entry.fixed?.asset
12 13 return (
13 − <div className="pg-form">
14 + <div className="pg-form" data-testid="pg-form">
14 15 {entry.params.map(prm => {
15 16 const id = `pg-f-${entry.id}-${prm.name}`
16 − const helpId = prm.help ? `${id}-help` : undefined
17 17 const err = errors[prm.name]
18 − const errId = err ? `${id}-err` : undefined
19 − const described = [helpId, errId].filter(Boolean).join(' ') || undefined
20 18 const val = values[prm.name] ?? ''
21 19 const set = v => onChange(prm.name, v)
20 + const label = <><span className="mono">{prm.name}</span></>
22 21 let control
23 22 if (prm.type === 'symbol') {
24 − control = <SymbolInput id={id} value={val} onChange={set} source={prm.source} asset={asset} apiKey={apiKey} placeholder={prm.placeholder} invalid={err} describedBy={described} />
23 + control = <SymbolInput id={id} value={val} onChange={set} source={prm.source} asset={asset} apiKey={apiKey} placeholder={prm.placeholder} invalid={!!err} />
25 24 } else if (prm.type === 'enum') {
26 − const opts = enumFor(prm, values)
27 − control = (
28 − <select id={id} value={val} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''}>
29 − {opts.map(o => <option key={o} value={o}>{o === '' ? '(default)' : o}</option>)}
30 − </select>
31 − )
25 + control = <Select id={id} value={val} onChange={e => set(e.target.value)} invalid={!!err} options={enumFor(prm, values)} />
32 26 } else if (prm.type === 'date' || prm.type === 'datetime') {
33 − control = (
34 − <input id={id} type={prm.type === 'date' ? 'date' : 'datetime-local'} step={prm.type === 'datetime' ? 1 : undefined}
35 − value={toNative(val, prm.type)} onChange={e => set(fromNative(e.target.value, prm.type))}
36 − aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''} />
37 − )
27 + control = <Input id={id} type={prm.type === 'date' ? 'date' : 'datetime-local'} step={prm.type === 'datetime' ? 1 : undefined} value={toNative(val, prm.type)} onChange={e => set(fromNative(e.target.value, prm.type))} invalid={!!err} />
38 28 } else if (prm.type === 'number') {
39 − control = <input id={id} type="number" inputMode="numeric" min={prm.min} max={prm.max} value={val} placeholder={prm.placeholder} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} className={err ? 'invalid' : ''} />
29 + control = <Input id={id} type="number" inputMode="numeric" min={prm.min} max={prm.max} value={val} placeholder={prm.placeholder} onChange={e => set(e.target.value)} invalid={!!err} mono />
40 30 } else if (prm.type === 'bool') {
41 − control = <input id={id} type="checkbox" checked={val === 'true'} onChange={e => set(e.target.checked ? 'true' : '')} aria-describedby={described} />
31 + control = <input id={id} type="checkbox" checked={val === 'true'} onChange={e => set(e.target.checked ? 'true' : '')} />
42 32 } else {
43 − control = <input id={id} type="text" className={`mono ${err ? 'invalid' : ''}`} value={val} placeholder={prm.placeholder} spellCheck={false} onChange={e => set(e.target.value)} aria-invalid={!!err} aria-describedby={described} />
33 + control = <Input id={id} type="text" mono value={val} placeholder={prm.placeholder} spellCheck={false} onChange={e => set(e.target.value)} invalid={!!err} />
44 34 }
45 35 return (
46 − <div key={prm.name} className={`pg-field pg-field-${prm.type}`}>
47 − <label htmlFor={id} className="pg-label">
48 − <span className="mono">{prm.name}</span>
49 − {prm.required && <span className="pg-req" aria-label="required">*</span>}
50 − <span className="pg-in muted">{prm.in}</span>
51 − </label>
36 + <Field key={prm.name} id={id} label={label} required={!!prm.required} hint={prm.help} error={err} labelExtra={prm.in} className={`pg-field pg-field-${prm.type}`}>
52 37 {control}
53 − {prm.help && <small id={helpId} className="pg-help muted">{prm.help}</small>}
54 − {err && <small id={errId} className="pg-err" role="alert">{err}</small>}
55 − </div>
38 + </Field>
56 39 )
57 40 })}
58 41 </div>
modified hfmarketdata/web/src/playground/WebSocketTab.jsx +15 −15
@@ -2,9 +2,8 @@
2 2 import React, { useEffect, useRef, useState } from 'react'
3 3 import { Link } from 'react-router-dom'
4 4 import { BASE_URL } from '../app/api.js'
5 −import PgCallout from '../components/PgCallout.jsx'
6 −import PgCodeBlock from '../components/PgCodeBlock.jsx'
7 −import PgTabs from '../components/PgTabs.jsx'
5 +import Callout from '../components/Callout.jsx'
6 +import CodeTabs from '../components/CodeTabs.jsx'
8 7 import { wsSnippet } from './codegen.js'
9 8
10 9 export function wsUrl(apiKey) {
@@ -17,11 +16,16 @@ export function wsUrl(apiKey) {
17 16
18 17 const MAX_MSGS = 500
19 18
19 +const toTabs = s => [
20 + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: s.js },
21 + { id: 'python', label: 'Python', language: 'python', code: s.python },
22 + { id: 'curl', label: 'websocat', language: 'bash', code: s.curl },
23 +]
24 +
20 25 export default function WebSocketTab({ apiKey, authenticated }) {
21 26 const [state, setState] = useState('idle') // idle | connecting | open | closed | error
22 27 const [msgs, setMsgs] = useState([])
23 28 const [sub, setSub] = useState('{"action":"subscribe","channel":"filings"}')
24 − const [lang, setLang] = useState('js')
25 29 const [info, setInfo] = useState('')
26 30 const wsRef = useRef(null)
27 31 const logRef = useRef(null)
@@ -46,18 +50,16 @@ export default function WebSocketTab({ apiKey, authenticated }) {
46 50 const disconnect = () => { wsRef.current?.close(); wsRef.current = null }
47 51 const send = () => { if (wsRef.current?.readyState === 1 && sub.trim()) { wsRef.current.send(sub); push('out', sub) } }
48 52
49 − const snippets = wsSnippet({ hasKey: !!apiKey })
53 + const snippets = toTabs(wsSnippet({ hasKey: !!apiKey }))
50 54 if (!apiKey) {
51 55 return (
52 56 <div className="pg-ws">
53 − <PgCallout kind="info" title="WebSocket streaming needs an API key"
54 − action={authenticated ? <Link className="btn btn-primary" to="/dashboard/keys">Get a key</Link> : <Link className="btn btn-primary" to="/signup">Create free account</Link>}>
57 + <Callout type="info" title="WebSocket streaming needs an API key"
58 + action={authenticated ? <Link className="btn btn-primary btn-sm" to="/dashboard/keys">Get a key</Link> : <Link className="btn btn-primary btn-sm" to="/signup">Create free account</Link>}>
55 59 Browsers cannot set an <code>Authorization</code> header on a WebSocket, so <code>/v1/stream</code> authenticates with <code>?api_key=</code>.
56 60 Keyless (IP-only) access is not available for streams: sign in, create a key in the dashboard and open the playground from there.
57 − </PgCallout>
58 − <PgTabs tabs={[{ id: 'js', label: 'JavaScript' }, { id: 'python', label: 'Python' }, { id: 'curl', label: 'websocat' }]} value={lang} onChange={setLang} label="WebSocket examples">
59 − <PgCodeBlock code={snippets[lang]} language={lang === 'curl' ? 'bash' : lang} title={`${lang} · wss://www.hfmarketdata.io/v1/stream`} />
60 − </PgTabs>
61 + </Callout>
62 + <CodeTabs title="wss://www.hfmarketdata.io/v1/stream" snippets={snippets} maxHeight={320} />
61 63 </div>
62 64 )
63 65 }
@@ -72,7 +74,7 @@ export default function WebSocketTab({ apiKey, authenticated }) {
72 74 : <button type="button" className="btn btn-primary" onClick={connect}>Connect</button>}
73 75 <button type="button" className="btn" onClick={() => setMsgs([])} disabled={!msgs.length}>Clear</button>
74 76 </div>
75 − {info && <PgCallout kind="warn">{info}</PgCallout>}
77 + {info && <Callout type="warning" compact>{info}</Callout>}
76 78 <label className="pg-ws-sub">
77 79 <span>Subscribe message (sent on connect; edit and press Send to send again)</span>
78 80 <div className="pg-ws-sub-row">
@@ -84,9 +86,7 @@ export default function WebSocketTab({ apiKey, authenticated }) {
84 86 {msgs.length === 0 && <div className="muted">No messages yet.</div>}
85 87 {msgs.map((m, i) => <div key={i} className={`pg-ws-msg ${m.kind}`}><span className="muted">{m.t}</span> <span className="pg-ws-dir">{m.kind === 'in' ? '←' : m.kind === 'out' ? '→' : '·'}</span> {m.text}</div>)}
86 88 </div>
87 − <PgTabs tabs={[{ id: 'js', label: 'JavaScript' }, { id: 'python', label: 'Python' }, { id: 'curl', label: 'websocat' }]} value={lang} onChange={setLang} label="WebSocket examples">
88 − <PgCodeBlock code={snippets[lang]} language={lang === 'curl' ? 'bash' : lang} title={`${lang} · wss://www.hfmarketdata.io/v1/stream`} />
89 − </PgTabs>
89 + <CodeTabs title="wss://www.hfmarketdata.io/v1/stream" snippets={snippets} maxHeight={320} />
90 90 </div>
91 91 )
92 92 }
modified hfmarketdata/web/src/playground/catalog.js +19 −1
@@ -67,7 +67,7 @@ export const CATALOG = [
67 67 barsEntry('index-bars', 'Index bars', 'index', 'OHLCV bars for equity and volatility indices (SPX, VIX…).', 'SPX'),
68 68 {
69 69 id: 'futures-continuous-legacy', group: 'Futures', title: 'Futures continuous (legacy)', method: 'GET', path: '/v1/bars/{asset}/{ticker}',
70 − operationIds: [],
70 + operationIds: ['bars_v1_bars__asset___ticker__get'], // resolved by pickCandidate when the deep link carries asset=futures
71 71 description: 'Front-month continuous series from the v1 bars endpoint: unadjusted, ratio-adjusted or absolute-adjusted rolls.',
72 72 fixed: { asset: 'futures' },
73 73 params: [
@@ -388,6 +388,24 @@ export function enumFor(param, values) {
388 388 return param.enum || []
389 389 }
390 390
391 +/**
392 + * Make `values` consistent with the entry's enums: a value that is not allowed for the current dependent enum
393 + * (e.g. `adjustment` after `asset` came from a deep link) falls back to the param default, else to the first choice.
394 + */
395 +export function normalizeValues(entry, values) {
396 + const out = { ...values }
397 + for (const prm of entry.params) {
398 + if (prm.type !== 'enum') continue
399 + const allowed = enumFor(prm, out)
400 + if (!allowed.length) continue
401 + const v = (out[prm.name] ?? '').toString()
402 + if (allowed.includes(v)) continue
403 + const dflt = prm.default !== undefined ? String(prm.default) : ''
404 + out[prm.name] = allowed.includes(dflt) ? dflt : allowed[0]
405 + }
406 + return out
407 +}
408 +
391 409 export function defaultValues(entry) {
392 410 const v = {}
393 411 for (const prm of entry.params) v[prm.name] = prm.default !== undefined ? String(prm.default) : ''
modified hfmarketdata/web/src/playground/playground.css +117 −101
@@ -1,95 +1,102 @@
1 −/* Playground + shared Pg* components. Prefixed pg- to avoid collisions with web-core styles. */
2 −.pg { display: flex; flex-direction: column; gap: 14px; }
1 +/* Playground. Two zones: request (endpoint picker · parameters grid · URL · Run) then full-width results.
2 + Prefixed pg- to avoid collisions with shell styles. Tokens only. */
3 +.pg { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
3 4 .pg-banner { margin: 0; }
4 −.pg-authbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--fg-1); padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); }
5 +.pg-authbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: var(--fs-1); color: var(--fg-1); padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg-1); }
5 6 .pg-authdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); }
6 7 .pg-tier { text-transform: uppercase; font-size: 11px; letter-spacing: .06em; padding: 1px 6px; border-radius: 4px; background: var(--bg-2); border: 1px solid var(--line-2); }
7 −.pg-grid { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 18px; align-items: start; }
8 −.pg-embedded .pg-grid { grid-template-columns: 320px minmax(0, 1fr); }
9 −@media (max-width: 960px) { .pg-grid, .pg-embedded .pg-grid { grid-template-columns: 1fr; } }
10 8
11 −.pg-h { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-2); margin: 0 0 8px; font-weight: 600; }
12 −.pg-left { display: flex; flex-direction: column; gap: 18px; position: sticky; top: 70px; max-height: calc(100dvh - 90px); overflow: auto; padding-right: 4px; }
13 −@media (max-width: 960px) { .pg-left { position: static; max-height: none; } }
14 −.pg-left-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
15 −.pg-filter { padding: 4px 8px; font-size: 13px; width: 130px; }
16 −.pg-types { display: flex; flex-direction: column; gap: 10px; }
17 −.pg-type-group-name { font-size: 11px; color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; margin: 0 0 4px 6px; }
18 −.pg-type { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; width: 100%; text-align: left; padding: 7px 8px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--fg-1); cursor: pointer; font: inherit; }
19 −.pg-type:hover { background: var(--bg-1); color: var(--fg); }
20 −.pg-type.active { background: var(--bg-1); border-color: var(--line-2); color: var(--fg); }
21 −.pg-type-title { font-weight: 500; }
22 −.pg-type-path { grid-column: 2; font-size: 11px; color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
23 −.pg-method { font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .06em; padding: 2px 6px; border-radius: 4px; align-self: center; background: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
24 −.pg-method.ws { background: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 18%, transparent); color: var(--accent-2); }
25 −.pg-method.post, .pg-method.patch, .pg-method.delete { background: var(--warn); background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
9 +/* method chip */
10 +.pg-method { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .06em; padding: 3px 6px; border-radius: 4px; align-self: center; background: color-mix(in srgb, var(--get) 18%, transparent); color: var(--get); flex: none; }
11 +.pg-method.ws { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
12 +.pg-method.post, .pg-method.patch, .pg-method.delete { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
26 13
27 −.pg-form-section { border-top: 1px solid var(--line); padding-top: 14px; }
28 −.pg-desc { font-size: 13px; margin: 0 0 12px; }
29 −.pg-form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
30 −.pg-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
31 −.pg-field-symbol, .pg-field-text, .pg-field-datetime { grid-column: 1 / -1; }
32 −.pg-field input, .pg-field select { width: 100%; padding: 6px 8px; font-size: 13px; }
33 −.pg-field input.invalid, .pg-field select.invalid { border-color: var(--danger); }
34 −.pg-label { display: flex; align-items: baseline; gap: 6px; font-size: 12px; color: var(--fg-1); }
35 −.pg-req { color: var(--danger); }
36 −.pg-in { font-size: 10px; margin-left: auto; }
37 −.pg-help { font-size: 11px; line-height: 1.35; }
38 −.pg-err { color: var(--danger); font-size: 12px; }
39 −.pg-fixed { font-size: 12px; margin: 10px 0 0; display: flex; gap: 6px; flex-wrap: wrap; }
40 −.pg-form-empty { font-size: 13px; }
14 +/* top bar: endpoint picker + presets */
15 +.pg-top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }
16 +.pg-ep { position: relative; min-width: 0; }
17 +.pg-ep-trigger { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 48px; padding: 6px 10px 6px 12px; border: 1px solid var(--line-2); border-radius: var(--r-md); background: var(--bg-1); color: var(--fg); cursor: pointer; text-align: left; }
18 +.pg-ep-trigger:hover { border-color: var(--line-3); background: var(--bg-2); }
19 +.pg-ep-trigger > svg { margin-left: auto; color: var(--fg-2); flex: none; }
20 +.pg-ep-text { display: grid; gap: 1px; min-width: 0; }
21 +.pg-ep-title { font-weight: 600; font-size: var(--fs-2); line-height: 1.25; }
22 +.pg-ep-path { font-size: var(--fs-0); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
23 +.pg-ep-pop { position: absolute; z-index: var(--z-drawer); left: 0; right: 0; top: calc(100% + 6px); min-width: min(560px, 100%); background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-lg); box-shadow: var(--shadow-2); overflow: hidden; }
24 +.pg-ep-search { display: flex; align-items: center; gap: 10px; padding: 6px 12px; border-bottom: 1px solid var(--line); color: var(--fg-2); }
25 +.pg-ep-search input { flex: 1; border: 0; background: transparent; min-height: 40px; padding: 0; font-size: var(--fs-3); }
26 +.pg-ep-search input:focus-visible { outline: none; }
27 +.pg-ep-list { max-height: min(440px, 60vh); overflow-y: auto; padding: 6px; }
28 +.pg-ep-group { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-3); padding: 10px 10px 4px; font-weight: 650; }
29 +.pg-ep-opt { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 5px 10px; border-radius: var(--r-md); cursor: pointer; color: var(--fg); }
30 +.pg-ep-opt[aria-selected="true"] { background: var(--bg-2); }
31 +.pg-ep-opt.current .pg-ep-title { color: var(--accent); }
32 +.pg-ep-current { margin-left: auto; font-size: var(--fs-0); flex: none; }
33 +.pg-ep-empty { padding: 20px 12px; text-align: center; }
34 +.pg-ep-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--fg-3); font-size: var(--fs-0); }
35 +.pg-ep-foot span:last-child { margin-left: auto; }
36 +.pg-presets { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 48px; }
37 +.pg-preset { background: var(--bg-1); border: 1px solid var(--line-2); color: var(--fg-1); border-radius: 999px; min-height: var(--tap); padding: 0 12px; cursor: pointer; font: inherit; font-size: var(--fs-0); }
38 +.pg-preset:hover { color: var(--fg); border-color: var(--line-3); background: var(--bg-2); }
39 +
40 +/* request card */
41 +.pg-request { display: flex; flex-direction: column; gap: 16px; padding: 18px 20px; }
42 +.pg-request-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: flex-start; }
43 +.pg-request-title { font-size: var(--fs-4); margin: 0 0 2px; }
44 +.pg-desc { font-size: var(--fs-1); margin: 0; max-width: 72ch; }
45 +.pg-fixed { font-size: var(--fs-0); margin: 0; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
46 +.pg-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; }
47 +.pg-field-symbol, .pg-field-text { grid-column: span 2; }
48 +.pg-field-datetime { grid-column: span 2; }
49 +.pg-embedded .pg-form { grid-template-columns: repeat(3, minmax(0, 1fr)); }
50 +.pg-field .field-label .mono { font-size: var(--fs-1); color: var(--fg); }
51 +.pg-form-empty { font-size: var(--fs-1); margin: 0; }
52 +.pg-err { color: var(--danger); font-size: var(--fs-0); }
41 53
42 54 .pg-symbol { position: relative; }
43 −.pg-symbol-spin { position: absolute; right: 10px; top: 50%; width: 10px; height: 10px; margin-top: -5px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: pg-spin .8s linear infinite; }
44 −@keyframes pg-spin { to { transform: rotate(360deg); } }
45 −.pg-symbol-list { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 6px; box-shadow: 0 10px 30px rgba(0,0,0,.35); max-height: 240px; overflow: auto; }
46 −.pg-symbol-list li { padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; display: flex; gap: 8px; }
55 +.pg-symbol input { width: 100%; }
56 +.pg-symbol-spin { position: absolute; right: 10px; top: 50%; width: 10px; height: 10px; margin-top: -5px; border: 2px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
57 +.pg-symbol-list { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--r-sm); box-shadow: var(--shadow-2); max-height: 240px; overflow: auto; }
58 +.pg-symbol-list li { padding: 8px 8px; border-radius: 4px; cursor: pointer; font-size: var(--fs-1); display: flex; gap: 8px; min-height: 36px; align-items: center; }
47 59 .pg-symbol-list li.active { background: var(--bg-2); }
48 60
49 −.pg-right { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
50 −.pg-urlbar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); }
51 −.pg-url { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; color: var(--fg); }
61 +.pg-urlbar { display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 10px; background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-md); }
62 +.pg-url { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-1); color: var(--code-fg); }
52 63 .pg-runrow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
53 −.pg-run { min-width: 110px; justify-content: center; }
54 −.pg-run:disabled { opacity: .55; cursor: not-allowed; }
55 −.pg-kbd { font-size: 12px; }
56 −.pg-status { display: inline-flex; align-items: center; gap: 10px; font-size: 13px; }
57 −.pg-code-badge { padding: 2px 8px; border-radius: 4px; font-family: var(--mono); font-size: 12px; font-weight: 600; }
58 −.pg-code-badge.ok { background: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
59 −.pg-code-badge.bad { background: var(--danger); background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); }
60 −.pg-err-details { margin: 8px 0 0; font-size: 12px; }
64 +.pg-run { min-width: 120px; }
65 +.pg-kbd { font-size: var(--fs-0); }
66 +.pg-status { display: inline-flex; align-items: center; gap: 10px; font-size: var(--fs-1); flex-wrap: wrap; }
67 +.pg-code-badge { padding: 2px 8px; border-radius: 4px; font-family: var(--mono); font-size: var(--fs-0); font-weight: 600; }
68 +.pg-code-badge.ok { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); }
69 +.pg-code-badge.bad { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); }
70 +.pg-err-details { margin: 8px 0 0; font-size: var(--fs-0); text-align: left; max-width: 100%; }
61 71
62 −.pg-results { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); overflow: hidden; }
63 −.pg-tabs { display: flex; flex-direction: column; }
64 −.pg-tablist-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); padding: 0 8px; flex-wrap: wrap; }
65 −.pg-tablist { display: flex; gap: 2px; }
66 −.pg-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--fg-1); padding: 10px 12px; cursor: pointer; font: inherit; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
67 −.pg-tab:hover:not(:disabled) { color: var(--fg); }
68 −.pg-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
69 −.pg-tab:disabled { opacity: .4; cursor: not-allowed; }
70 −.pg-tab-badge { font-family: var(--mono); font-size: 11px; background: var(--bg-2); padding: 0 6px; border-radius: 10px; }
71 −.pg-tabpanel { min-width: 0; }
72 +/* results */
73 +.pg-results { min-width: 0; }
74 +.pg-results-box { border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg-1); overflow: hidden; }
75 +.pg-results-box .tabs-bar { padding: 0 8px; }
76 +.pg-sample-note { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); font-size: var(--fs-1); color: var(--fg-1); background: color-mix(in srgb, var(--info) 5%, var(--bg-1)); }
77 +.pg-sample-url { font-size: var(--fs-0); }
72 78 .pg-dl-row { display: flex; gap: 6px; }
73 −.btn-sm { padding: 4px 10px; font-size: 12px; }
74 −.pg-view-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
79 +.pg-view-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; font-size: var(--fs-0); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
75 80 .pg-empty { padding: 20px 12px; margin: 0; }
76 −.pg-text { margin: 0; border: 0; border-radius: 0; max-height: 460px; font-size: 12px; }
81 +.pg-text { margin: 0; border: 0; border-radius: 0; max-height: 460px; font-size: var(--fs-0); }
82 +.pg-codex { padding: 12px 14px 4px; }
83 +.pg-codex .codetabs { margin: 0; }
77 84
78 −.pg-json-tree { padding: 10px 12px; font-size: 12.5px; line-height: 1.55; max-height: 520px; overflow: auto; }
85 +.pg-json-tree { padding: 10px 12px; font-size: var(--fs-0); line-height: 1.6; max-height: 520px; overflow: auto; }
79 86 .pg-j-line { white-space: pre; }
80 87 .pg-j-toggle { background: none; border: 0; color: var(--fg-2); cursor: pointer; padding: 0 4px 0 0; font: inherit; }
81 −.pg-j-key { color: var(--accent-2); }
82 −.pg-j-str { color: var(--accent); }
83 −.pg-j-num { color: #ffb86b; }
84 −.pg-j-bool { color: #c792ea; }
88 +.pg-j-key { color: var(--code-key); }
89 +.pg-j-str { color: var(--code-string); }
90 +.pg-j-num { color: var(--code-number); }
91 +.pg-j-bool { color: var(--code-keyword); }
85 92 .pg-j-null { color: var(--fg-2); font-style: italic; }
86 93 .pg-j-brace { color: var(--fg-1); }
87 −.pg-j-more { background: none; border: 0; color: var(--accent-2); cursor: pointer; font: inherit; font-size: 12px; text-decoration: underline; }
94 +.pg-j-more { background: none; border: 0; color: var(--accent-2); cursor: pointer; font: inherit; font-size: var(--fs-0); text-decoration: underline; }
88 95
89 96 .pg-table-wrap { overflow: auto; position: relative; }
90 −.pg-table { font-size: 12.5px; border-collapse: separate; border-spacing: 0; }
97 +.pg-table { font-size: var(--fs-0); border-collapse: separate; border-spacing: 0; }
91 98 .pg-table th { position: sticky; top: 0; background: var(--bg-2); z-index: 1; padding: 0; white-space: nowrap; }
92 −.pg-th-btn { background: none; border: 0; color: var(--fg-1); font: inherit; font-size: 12px; font-weight: 600; padding: 8px 10px; cursor: pointer; width: 100%; text-align: left; }
99 +.pg-th-btn { background: none; border: 0; color: var(--fg-1); font: inherit; font-size: var(--fs-0); font-weight: 600; padding: 8px 10px; cursor: pointer; width: 100%; text-align: left; min-height: var(--tap); }
93 100 .pg-th-btn:hover { color: var(--fg); }
94 101 .pg-th-idx, .pg-td-idx { width: 48px; text-align: right; font-size: 11px; }
95 102 .pg-table td { padding: 0 10px; white-space: nowrap; border-bottom: 1px solid var(--line); }
@@ -98,52 +105,61 @@
98 105 .pg-chart-canvas { width: 100%; }
99 106 .pg-chart-controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
100 107 .pg-chart-controls label { display: inline-flex; gap: 6px; align-items: center; }
101 −.pg-chart-controls select { padding: 3px 6px; font-size: 12px; }
108 +.pg-chart-controls select { min-height: 30px; padding: 0 6px; font-size: var(--fs-0); }
102 109 .pg-chart-note { font-size: 11px; margin: 0; padding: 6px 12px; }
103 110 .pg-download { padding: 20px 14px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
104 111
105 −.pg-rl { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
106 −.pg-rl-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 13px; }
107 −.pg-rl-row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
112 +/* rate limit */
113 +.pg-rl { display: flex; flex-direction: column; gap: 10px; }
114 +.pg-rl-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: var(--fs-1); }
115 +.pg-rl-row { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-0); }
108 116 .pg-rl-label { display: flex; justify-content: space-between; color: var(--fg-1); }
109 117 .pg-rl-bar { height: 6px; border-radius: 3px; background: var(--bg-2); overflow: hidden; }
110 118 .pg-rl-fill { height: 100%; background: var(--accent); transition: width .3s; }
111 119 .pg-rl-fill.warn { background: var(--warn); }
112 120 .pg-rl-fill.danger { background: var(--danger); }
113 −.pg-rl-foot { font-size: 12px; margin: 0; }
114 −.pg-rl-429 { font-size: 13px; padding: 8px 10px; border-radius: 6px; background: var(--danger); background: color-mix(in srgb, var(--danger) 15%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
115 −
116 −.pg-code { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: #011627; }
117 −.pg-code-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--bg-2); border-bottom: 1px solid var(--line); font-size: 12px; color: var(--fg-1); }
118 −.pg-code-pre { margin: 0; border: 0; border-radius: 0; padding: 12px 14px; font-size: 12.5px; line-height: 1.5; overflow: auto; }
119 −.pg-codex .pg-tablist-row { border-bottom: 0; padding: 0; }
120 −.pg-codex .pg-tab.active { border-bottom-color: var(--accent); }
121 −
122 −.pg-presets { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
123 −.pg-preset { background: var(--bg-1); border: 1px solid var(--line-2); color: var(--fg-1); border-radius: 999px; padding: 4px 12px; cursor: pointer; font: inherit; font-size: 12.5px; }
124 −.pg-preset:hover { color: var(--fg); border-color: var(--fg-2); }
125 −
126 −.pg-callout { display: flex; justify-content: space-between; gap: 14px; align-items: center; padding: 12px 14px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--bg-1); font-size: 13.5px; flex-wrap: wrap; }
127 −.pg-callout-info { border-color: color-mix(in srgb, var(--accent-2) 45%, transparent); background: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 8%, var(--bg-1)); }
128 −.pg-callout-warn { border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: var(--warn); background: color-mix(in srgb, var(--warn) 8%, var(--bg-1)); }
129 −.pg-callout-danger { border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--bg-1)); }
130 −.pg-callout-success { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg-1)); }
131 −.pg-callout-body { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 220px; }
132 −.pg-callout-title { font-size: 14px; }
121 +.pg-rl-foot { font-size: var(--fs-0); margin: 0; }
122 +.pg-rl-429 { font-size: var(--fs-1); padding: 8px 10px; border-radius: var(--r-sm); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); }
133 123
124 +/* websocket */
134 125 .pg-ws { display: flex; flex-direction: column; gap: 12px; }
135 −.pg-ws-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 10px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); }
126 +.pg-ws-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-md); }
136 127 .pg-ws-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fg-2); }
137 128 .pg-ws-dot.open { background: var(--accent); }
138 129 .pg-ws-dot.connecting { background: var(--warn); }
139 130 .pg-ws-dot.error { background: var(--danger); }
140 −.pg-ws-state { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--fg-1); min-width: 80px; }
141 −.pg-ws-url { flex: 1; font-size: 12px; }
142 −.pg-ws-sub { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--fg-1); }
131 +.pg-ws-state { font-size: var(--fs-0); text-transform: uppercase; letter-spacing: .06em; color: var(--fg-1); min-width: 80px; }
132 +.pg-ws-url { flex: 1; font-size: var(--fs-0); }
133 +.pg-ws-sub { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-0); color: var(--fg-1); }
143 134 .pg-ws-sub-row { display: flex; gap: 8px; }
144 −.pg-ws-sub-row input { flex: 1; font-size: 12.5px; }
145 −.pg-ws-log { height: 260px; overflow: auto; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; font-size: 12px; line-height: 1.5; }
135 +.pg-ws-sub-row input { flex: 1; font-size: var(--fs-1); }
136 +.pg-ws-log { height: 260px; overflow: auto; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 8px 10px; font-size: var(--fs-0); line-height: 1.5; }
146 137 .pg-ws-msg { white-space: pre-wrap; overflow-wrap: anywhere; }
147 138 .pg-ws-msg.in .pg-ws-dir { color: var(--accent); }
148 139 .pg-ws-msg.out .pg-ws-dir { color: var(--accent-2); }
149 140 .pg-ws-msg.sys { color: var(--fg-2); }
141 +
142 +/* page */
143 +.pg-page-head { margin-bottom: 18px; max-width: 760px; }
144 +.pg-page-head h1 { margin-bottom: 4px; }
145 +.pg-page-head p { margin: 0; }
146 +
147 +@media (max-width: 1200px) {
148 + .pg-form { grid-template-columns: repeat(3, minmax(0, 1fr)); }
149 +}
150 +@media (max-width: 960px) {
151 + .pg-top { grid-template-columns: 1fr; }
152 + .pg-form, .pg-embedded .pg-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
153 + .pg-request { padding: 14px; }
154 + .pg-runrow { position: sticky; bottom: 0; z-index: var(--z-sticky); margin: 0 -14px -14px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: color-mix(in srgb, var(--bg-1) 92%, transparent); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border-top: 1px solid var(--line); border-radius: 0 0 var(--r-lg) var(--r-lg); }
155 + .pg-run { min-height: 44px; flex: 1 1 140px; }
156 + .pg-kbd { display: none; }
157 + .pg-ep-pop { position: fixed; left: 8px; right: 8px; top: calc(var(--topbar-h) + 8px); }
158 + .pg-ep-list { max-height: calc(100dvh - var(--topbar-h) - 150px); }
159 +}
160 +@media (max-width: 640px) {
161 + .pg-form, .pg-embedded .pg-form { grid-template-columns: 1fr 1fr; }
162 + .pg-field-symbol, .pg-field-text, .pg-field-datetime, .pg-field-date { grid-column: 1 / -1; }
163 + .pg-dl-row { display: none; }
164 + .pg-results-box .tabs-bar { padding: 0 4px; }
165 +}
modified hfmarketdata/web/src/playground/views/ChartView.jsx +9 −7
@@ -1,4 +1,5 @@
1 1 // Chart view: candlesticks when OHLC is present, otherwise a line on one numeric column. Roll dates → markers.
2 +// Colours come from the semantic tokens (--up / --down / --accent-2 / --warn); the TradingView attribution stays on (Apache-2.0).
2 3 import React, { useEffect, useMemo, useRef, useState } from 'react'
3 4 import { createChart, ColorType, CrosshairMode } from 'lightweight-charts'
4 5
@@ -55,21 +56,22 @@ export default function ChartView({ rows, meta, hint = {}, height = 420 }) {
55 56 if (!el) return undefined
56 57 if (!timeKey) { setMsg('No time column found — nothing to plot.'); return undefined }
57 58 const css = getComputedStyle(document.documentElement)
59 + const v = name => css.getPropertyValue(name).trim()
58 60 const chart = createChart(el, {
59 61 height, autoSize: true,
60 − layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: css.getPropertyValue('--fg-1').trim() || '#aeb6c2', fontFamily: css.getPropertyValue('--sans') },
61 − grid: { vertLines: { color: css.getPropertyValue('--line').trim() || '#262b34' }, horzLines: { color: css.getPropertyValue('--line').trim() || '#262b34' } },
62 + layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: v('--fg-1'), fontFamily: v('--sans'), attributionLogo: true },
63 + grid: { vertLines: { color: v('--line') }, horzLines: { color: v('--line') } },
62 64 crosshair: { mode: CrosshairMode.Normal },
63 − rightPriceScale: { borderColor: css.getPropertyValue('--line-2').trim() },
64 − timeScale: { borderColor: css.getPropertyValue('--line-2').trim(), timeVisible: true, secondsVisible: false },
65 + rightPriceScale: { borderColor: v('--line-2') },
66 + timeScale: { borderColor: v('--line-2'), timeVisible: true, secondsVisible: false },
65 67 })
66 68 let series
67 69 let points = []
68 70 if (mode === 'candles' && ohlc) {
69 − series = chart.addCandlestickSeries({ upColor: '#5ee7a5', downColor: '#ff6b6b', borderVisible: false, wickUpColor: '#5ee7a5', wickDownColor: '#ff6b6b' })
71 + series = chart.addCandlestickSeries({ upColor: v('--up'), downColor: v('--down'), borderVisible: false, wickUpColor: v('--up'), wickDownColor: v('--down') })
70 72 for (const r of rows) { const t = toTime(r[timeKey]); if (t != null) points.push({ time: t, open: r.open, high: r.high, low: r.low, close: r.close }) }
71 73 } else {
72 − series = chart.addLineSeries({ color: '#37b3ff', lineWidth: 2, priceLineVisible: false })
74 + series = chart.addLineSeries({ color: v('--accent-2'), lineWidth: 2, priceLineVisible: false })
73 75 for (const r of rows) { const t = toTime(r[timeKey]); const v = r[col]; if (t != null && typeof v === 'number' && Number.isFinite(v)) points.push({ time: t, value: v }) }
74 76 }
75 77 points = dedupeSorted(points)
@@ -81,7 +83,7 @@ export default function ChartView({ rows, meta, hint = {}, height = 420 }) {
81 83 const markers = rolls.map(rd => {
82 84 const d = typeof rd === 'string' ? rd : rd?.date || rd?.datetime || rd?.roll_date
83 85 const t = toTime(d)
84 − return t == null ? null : { time: t, position: 'belowBar', color: '#ffc857', shape: 'arrowUp', text: typeof rd === 'object' && rd?.to ? `roll → ${rd.to}` : 'roll' }
86 + return t == null ? null : { time: t, position: 'belowBar', color: v('--warn'), shape: 'arrowUp', text: typeof rd === 'object' && rd?.to ? `roll → ${rd.to}` : 'roll' }
85 87 }).filter(m => m && (times.has(String(m.time)) || typeof m.time === 'string'))
86 88 try { series.setMarkers(dedupeSorted(markers)) } catch { /* marker outside range */ }
87 89 }
modified hfmarketdata/web/src/playground/views/JsonView.jsx +2 −2
@@ -1,6 +1,6 @@
1 1 // Collapsible, syntax-coloured JSON tree. Large arrays are paginated (PAGE children at a time) to stay responsive.
2 2 import React, { useState } from 'react'
3 −import PgCopyButton from '../../components/PgCopyButton.jsx'
3 +import CopyButton from '../../components/CopyButton.jsx'
4 4
5 5 const PAGE = 100
6 6
@@ -56,7 +56,7 @@ export default function JsonView({ data }) {
56 56 <div className="pg-json">
57 57 <div className="pg-view-toolbar">
58 58 <span className="muted">{Array.isArray(data?.data) ? `${data.data.length.toLocaleString()} rows in data[]` : 'JSON'}</span>
59 − <PgCopyButton text={text} label="Copy JSON" small />
59 + <CopyButton text={text} label="Copy JSON" />
60 60 </div>
61 61 <div className="pg-json-tree mono" tabIndex={0}>
62 62 <Node v={data} depth={0} defaultOpen />
deleted hfmarketdata/web/src/styles.css +0 −581
@@ -1,581 +0,0 @@
1 −/* HF Market Data — refined editorial light theme
2 − Author: Simon-Pierre Boucher <contact@spboucher.ai> */
3 −
4 −:root {
5 − --bg: #fbfaf7;
6 − --bg-soft: #f4f2ec;
7 − --card: #ffffff;
8 − --line: #e7e3d8;
9 − --line-strong: #d8d3c4;
10 − --ink: #1c2430;
11 − --ink-soft: #4a5568;
12 − --muted: #8a8f98;
13 − --accent: #2e5ce6;
14 − --accent-deep: #1e3fa8;
15 − --accent-wash: #eef2fd;
16 − --mono: 'JetBrains Mono', ui-monospace, monospace;
17 − --sans: 'Inter', system-ui, sans-serif;
18 − --serif: 'Fraunces', Georgia, serif;
19 − --shadow: 0 1px 2px rgba(28, 36, 48, 0.05), 0 8px 24px -12px rgba(28, 36, 48, 0.12);
20 − --shadow-sm: 0 1px 2px rgba(28, 36, 48, 0.05), 0 4px 12px -6px rgba(28, 36, 48, 0.08);
21 −}
22 −
23 −* { box-sizing: border-box; }
24 −html { scroll-behavior: smooth; scroll-padding-top: 24px; }
25 −body {
26 − margin: 0;
27 − font-family: var(--sans);
28 − background: var(--bg);
29 − color: var(--ink);
30 − line-height: 1.7;
31 − font-size: 15.5px;
32 − -webkit-font-smoothing: antialiased;
33 −}
34 −
35 −a { color: var(--accent); text-decoration: none; }
36 −a:hover { text-decoration: underline; text-underline-offset: 3px; }
37 −
38 −code {
39 − font-family: var(--mono);
40 − font-size: 0.82em;
41 − background: var(--bg-soft);
42 − border: 1px solid var(--line);
43 − border-radius: 5px;
44 − padding: 1px 6px;
45 − margin: 0 2px;
46 − white-space: nowrap;
47 − color: var(--accent-deep);
48 −}
49 −
50 −pre {
51 − background: var(--bg-soft);
52 − border: 1px solid var(--line);
53 − border-radius: 10px;
54 − padding: 18px 20px;
55 − overflow-x: auto;
56 − margin: 0;
57 −}
58 −pre code {
59 − background: none; border: none; padding: 0; margin: 0;
60 − font-size: 0.8rem; white-space: pre;
61 − color: var(--ink-soft);
62 −}
63 −
64 −/* ---------------------------------------------------------------- layout */
65 −
66 −.layout { display: flex; min-height: 100vh; }
67 −
68 −.sidebar {
69 − width: 268px;
70 − flex-shrink: 0;
71 − border-right: 1px solid var(--line);
72 − padding: 30px 22px 40px;
73 − position: sticky;
74 − top: 0;
75 − height: 100vh;
76 − overflow-y: auto;
77 − background: var(--bg);
78 −}
79 −
80 −main {
81 − flex: 1;
82 − max-width: 880px;
83 − padding: 56px 56px 60px;
84 − margin: 0 auto;
85 − min-width: 0;
86 −}
87 −section { margin-bottom: 84px; }
88 −
89 −/* brand */
90 −.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; color: var(--ink); }
91 −.brand:hover { text-decoration: none; }
92 −.logo {
93 − display: grid; place-items: center;
94 − width: 40px; height: 40px;
95 − background: var(--accent);
96 − color: white;
97 − border-radius: 10px;
98 − font-family: var(--serif);
99 − font-weight: 600;
100 − font-size: 0.95rem;
101 − box-shadow: var(--shadow-sm);
102 −}
103 −.brand-text { display: flex; flex-direction: column; line-height: 1.3; }
104 −.brand-name { font-family: var(--serif); font-weight: 600; font-size: 1.02rem; letter-spacing: 0.01em; }
105 −.brand-sub { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
106 −
107 −/* nav */
108 −.sidebar nav { display: flex; flex-direction: column; gap: 1px; }
109 −.kicker {
110 − font-size: 0.66rem;
111 − font-weight: 700;
112 − letter-spacing: 0.16em;
113 − text-transform: uppercase;
114 − color: var(--muted);
115 − margin: 20px 0 8px;
116 −}
117 −.hero .kicker, section > .kicker, .ep-group > .kicker { color: var(--accent); }
118 −.sidebar nav a {
119 − color: var(--ink-soft);
120 − font-size: 0.86rem;
121 − padding: 5px 10px;
122 − border-radius: 7px;
123 − transition: background 0.12s, color 0.12s;
124 −}
125 −.sidebar nav a:hover {
126 − background: var(--accent-wash);
127 − color: var(--accent-deep);
128 − text-decoration: none;
129 −}
130 −.ep-link { display: flex; align-items: baseline; gap: 8px; }
131 −.ep-method {
132 − font-family: var(--mono);
133 − font-size: 0.56rem;
134 − font-weight: 600;
135 − color: var(--accent);
136 − letter-spacing: 0.06em;
137 −}
138 −
139 −/* ---------------------------------------------------------------- hero */
140 −
141 −.hero { padding-top: 8px; }
142 −.hero h1 {
143 − font-family: var(--serif);
144 − font-size: clamp(2.7rem, 5.5vw, 4rem);
145 − font-weight: 500;
146 − line-height: 1.06;
147 − letter-spacing: -0.01em;
148 − margin: 16px 0 22px;
149 −}
150 −.accent-word {
151 − font-style: italic;
152 − color: var(--accent);
153 −}
154 −.lede { font-size: 1.1rem; color: var(--ink-soft); max-width: 620px; }
155 −.lede strong { color: var(--ink); font-weight: 600; }
156 −
157 −.hero-actions { display: flex; gap: 12px; margin: 28px 0 40px; flex-wrap: wrap; }
158 −.btn {
159 − display: inline-block;
160 − font-weight: 600;
161 − font-size: 0.9rem;
162 − background: var(--accent);
163 − color: white;
164 − padding: 11px 26px;
165 − border: 1px solid var(--accent);
166 − border-radius: 999px;
167 − box-shadow: var(--shadow-sm);
168 − cursor: pointer;
169 − transition: background 0.12s, transform 0.12s;
170 −}
171 −.btn:hover { text-decoration: none; background: var(--accent-deep); transform: translateY(-1px); }
172 −.btn.ghost {
173 − background: var(--card);
174 − color: var(--ink);
175 − border-color: var(--line-strong);
176 −}
177 −.btn.ghost:hover { background: var(--bg-soft); }
178 −
179 −/* stat row */
180 −.stat-chips { display: flex; flex-wrap: wrap; gap: 0; }
181 −.chip {
182 − display: flex;
183 − flex-direction: column;
184 − padding: 4px 28px 4px 18px;
185 − border-left: 1px solid var(--line-strong);
186 − min-width: 120px;
187 −}
188 −.chip b {
189 − font-family: var(--serif);
190 − font-size: 1.7rem;
191 − font-weight: 500;
192 − color: var(--ink);
193 − line-height: 1.15;
194 −}
195 −.chip span { font-size: 0.74rem; color: var(--muted); }
196 −.hero-chips { margin-top: 0; }
197 −
198 −/* ---------------------------------------------------------------- headings */
199 −
200 −h2 {
201 − font-family: var(--serif);
202 − font-size: 1.8rem;
203 − font-weight: 500;
204 − letter-spacing: -0.005em;
205 − margin: 6px 0 16px;
206 −}
207 −h3 { font-family: var(--serif); font-weight: 500; margin: 12px 0 6px; }
208 −h4 { font-weight: 600; margin: 0 0 6px; font-size: 0.92rem; }
209 −.muted { color: var(--muted); }
210 −.small { font-size: 0.8rem; }
211 −
212 −/* callout */
213 −.callout {
214 − margin-top: 20px;
215 − background: var(--accent-wash);
216 − border: 1px solid #d9e2fa;
217 − border-radius: 10px;
218 − padding: 16px 20px;
219 − font-size: 0.9rem;
220 − color: var(--ink-soft);
221 −}
222 −.callout b { color: var(--ink); }
223 −
224 −/* conventions grid */
225 −.conv-grid {
226 − display: grid;
227 − grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
228 − gap: 16px;
229 −}
230 −.conv {
231 − background: var(--card);
232 − border: 1px solid var(--line);
233 − border-radius: 12px;
234 − padding: 18px 20px;
235 − box-shadow: var(--shadow-sm);
236 −}
237 −.conv h4 { font-family: var(--serif); font-size: 1.05rem; font-weight: 500; }
238 −.conv p { font-size: 0.85rem; color: var(--ink-soft); margin: 0; }
239 −
240 −/* coverage cards */
241 −.cards {
242 − display: grid;
243 − grid-template-columns: repeat(auto-fill, minmax(255px, 1fr));
244 − gap: 16px;
245 − margin-bottom: 16px;
246 −}
247 −.card {
248 − background: var(--card);
249 − border: 1px solid var(--line);
250 − border-radius: 12px;
251 − padding: 18px 20px;
252 − box-shadow: var(--shadow-sm);
253 −}
254 −.card.accent {
255 − border-color: #c9d7f8;
256 − background: linear-gradient(160deg, var(--accent-wash), #ffffff 70%);
257 −}
258 −.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
259 −.card-head h3 { margin: 0; font-size: 1.15rem; }
260 −.card-ex { font-size: 0.66rem; color: var(--muted); }
261 −.card p { font-size: 0.85rem; color: var(--ink-soft); margin: 8px 0 0; }
262 −.card .small code { margin: 2px 3px 0 0; display: inline-block; }
263 −
264 −/* ---------------------------------------------------------------- endpoints */
265 −
266 −.ep-group { margin-bottom: 40px; }
267 −.ep-group > h2 { margin-bottom: 26px; }
268 −
269 −.endpoint {
270 − display: flex;
271 − background: var(--card);
272 − border: 1px solid var(--line);
273 − border-radius: 14px;
274 − box-shadow: var(--shadow);
275 − margin-bottom: 30px;
276 − overflow: hidden;
277 −}
278 −.endpoint-index {
279 − flex-shrink: 0;
280 − width: 58px;
281 − display: flex;
282 − align-items: flex-start;
283 − justify-content: center;
284 − padding-top: 26px;
285 − font-family: var(--serif);
286 − font-style: italic;
287 − font-size: 0.95rem;
288 − color: var(--muted);
289 − background: var(--bg-soft);
290 − border-right: 1px solid var(--line);
291 −}
292 −.endpoint-body { padding: 22px 28px 26px; min-width: 0; flex: 1; }
293 −.endpoint-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
294 −.method {
295 − font-family: var(--mono);
296 − font-size: 0.64rem;
297 − font-weight: 600;
298 − letter-spacing: 0.08em;
299 − color: var(--accent-deep);
300 − background: var(--accent-wash);
301 − border: 1px solid #d9e2fa;
302 − border-radius: 999px;
303 − padding: 2px 11px;
304 −}
305 −.path {
306 − font-size: 0.86rem;
307 − background: var(--bg-soft);
308 − white-space: normal;
309 − word-break: break-all;
310 − color: var(--ink);
311 −}
312 −.endpoint h3 { font-size: 1.35rem; margin: 14px 0 4px; }
313 −.endpoint-desc { font-size: 0.9rem; color: var(--ink-soft); margin: 0 0 16px; }
314 −
315 −/* params table */
316 −table.params {
317 − width: 100%;
318 − border-collapse: collapse;
319 − font-size: 0.82rem;
320 − margin: 8px 0 20px;
321 −}
322 −table.params th, table.params td {
323 − text-align: left;
324 − padding: 9px 12px;
325 − border-bottom: 1px solid var(--line);
326 − vertical-align: top;
327 −}
328 −table.params thead th {
329 − font-size: 0.64rem;
330 − font-weight: 700;
331 − text-transform: uppercase;
332 − letter-spacing: 0.12em;
333 − color: var(--muted);
334 − border-bottom: 1px solid var(--line-strong);
335 −}
336 −table.params tr:last-child td { border-bottom: none; }
337 −table.params td code { white-space: nowrap; }
338 −td.type { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); white-space: nowrap; }
339 −td.num { font-family: var(--mono); font-size: 0.78rem; text-align: right; }
340 −.req {
341 − font-family: var(--mono); font-size: 0.6rem; font-weight: 600;
342 − color: var(--accent-deep); background: var(--accent-wash);
343 − border: 1px solid #d9e2fa;
344 − border-radius: 999px; padding: 2px 8px; letter-spacing: 0.04em;
345 −}
346 −.opt { font-family: var(--mono); font-size: 0.6rem; color: var(--muted); }
347 −
348 −/* code tabs + response */
349 −.code-tabs, .response-block { margin-top: 16px; }
350 −.code-tabs-bar { display: flex; gap: 5px; align-items: center; margin-bottom: 8px; }
351 −.code-tabs-label {
352 − font-size: 0.62rem;
353 − font-weight: 700;
354 − letter-spacing: 0.14em;
355 − text-transform: uppercase;
356 − color: var(--muted);
357 − margin-right: 8px;
358 −}
359 −.tab {
360 − font-family: var(--sans);
361 − font-size: 0.74rem;
362 − font-weight: 500;
363 − background: transparent;
364 − border: 1px solid var(--line);
365 − border-radius: 999px;
366 − color: var(--ink-soft);
367 − padding: 4px 14px;
368 − cursor: pointer;
369 − transition: all 0.12s;
370 −}
371 −.tab:hover { border-color: var(--line-strong); background: var(--bg-soft); }
372 −.tab.active {
373 − background: var(--ink);
374 − border-color: var(--ink);
375 − color: #fff;
376 −}
377 −.tab.copy, .tab.live { margin-left: auto; }
378 −.tab.live {
379 − background: var(--accent);
380 − border-color: var(--accent);
381 − color: white;
382 −}
383 −.tab.live:hover { background: var(--accent-deep); }
384 −.tab:disabled { opacity: 0.6; cursor: default; }
385 −pre.response { max-height: 340px; overflow: auto; }
386 −
387 −/* ---------------------------------------------------------------- playground */
388 −
389 −.playground {
390 − margin-top: 18px;
391 − border: 1px solid var(--line);
392 − border-radius: 12px;
393 − background: linear-gradient(180deg, var(--accent-wash) 0, var(--card) 90px);
394 − padding: 16px 18px 18px;
395 −}
396 −.pg-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
397 −.pg-title {
398 − font-family: var(--serif);
399 − font-style: italic;
400 − font-size: 1.05rem;
401 − color: var(--accent-deep);
402 −}
403 −.pg-hint { font-size: 0.72rem; color: var(--muted); }
404 −
405 −.pg-grid {
406 − display: grid;
407 − grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
408 − gap: 10px 12px;
409 − margin-bottom: 14px;
410 −}
411 −.pg-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
412 −.pg-label {
413 − font-family: var(--mono);
414 − font-size: 0.68rem;
415 − font-weight: 600;
416 − color: var(--ink-soft);
417 − display: flex;
418 − align-items: baseline;
419 − gap: 6px;
420 −}
421 −.pg-req {
422 − font-style: normal;
423 − font-size: 0.56rem;
424 − color: var(--accent-deep);
425 − background: var(--accent-wash);
426 − border: 1px solid #d9e2fa;
427 − border-radius: 999px;
428 − padding: 0 6px;
429 −}
430 −.pg-in { margin-left: auto; font-size: 0.58rem; color: var(--muted); font-weight: 400; }
431 −.pg-field input, .pg-field select {
432 − font-family: var(--mono);
433 − font-size: 0.78rem;
434 − color: var(--ink);
435 − background: var(--card);
436 − border: 1px solid var(--line-strong);
437 − border-radius: 8px;
438 − padding: 7px 10px;
439 − outline: none;
440 − width: 100%;
441 − transition: border-color 0.12s, box-shadow 0.12s;
442 − -webkit-appearance: none;
443 − appearance: none;
444 −}
445 −.pg-field select {
446 − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8f98' fill='none' stroke-width='1.5'/%3E%3C/svg%3E");
447 − background-repeat: no-repeat;
448 − background-position: right 10px center;
449 − padding-right: 26px;
450 −}
451 −.pg-field input:focus, .pg-field select:focus {
452 − border-color: var(--accent);
453 − box-shadow: 0 0 0 3px var(--accent-wash);
454 −}
455 −
456 −.pg-url {
457 − display: flex;
458 − align-items: center;
459 − gap: 10px;
460 − background: var(--card);
461 − border: 1px solid var(--line);
462 − border-radius: 8px;
463 − padding: 8px 12px;
464 − margin-bottom: 12px;
465 − overflow-x: auto;
466 −}
467 −.pg-url-method {
468 − font-family: var(--mono);
469 − font-size: 0.62rem;
470 − font-weight: 600;
471 − color: var(--accent-deep);
472 − letter-spacing: 0.06em;
473 − flex-shrink: 0;
474 −}
475 −.pg-url code {
476 − background: none; border: none; padding: 0; margin: 0;
477 − font-size: 0.74rem;
478 − white-space: nowrap;
479 − color: var(--ink-soft);
480 −}
481 −
482 −.tab.run {
483 − background: var(--accent);
484 − border-color: var(--accent);
485 − color: white;
486 − font-weight: 600;
487 −}
488 −.tab.run:hover { background: var(--accent-deep); }
489 −.pg-spacer { margin-left: auto; }
490 −
491 −.pg-response { margin-top: 12px; }
492 −.pg-resp-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
493 −.pg-status {
494 − font-family: var(--mono);
495 − font-size: 0.68rem;
496 − font-weight: 600;
497 − border-radius: 999px;
498 − padding: 2px 10px;
499 −}
500 −.pg-status.ok { color: #116632; background: #e3f5ea; border: 1px solid #bfe6cd; }
501 −.pg-status.err { color: #9e2a25; background: #fdeae9; border: 1px solid #f5c9c6; }
502 −.pg-ms { font-family: var(--mono); font-size: 0.68rem; color: var(--muted); }
503 −.pg-resp-label {
504 − font-size: 0.62rem;
505 − font-weight: 700;
506 − letter-spacing: 0.14em;
507 − text-transform: uppercase;
508 − color: var(--muted);
509 −}
510 −
511 −/* fields accordion */
512 −.fields { margin-top: 16px; }
513 −.fields-toggle {
514 − font-family: var(--sans);
515 − font-size: 0.78rem;
516 − font-weight: 600;
517 − background: transparent;
518 − border: 1px solid var(--line);
519 − border-radius: 999px;
520 − padding: 6px 16px;
521 − cursor: pointer;
522 − color: var(--ink-soft);
523 − transition: all 0.12s;
524 −}
525 −.fields-toggle:hover { background: var(--accent-wash); border-color: #d9e2fa; color: var(--accent-deep); }
526 −.fields-n {
527 − font-family: var(--mono);
528 − font-size: 0.62rem;
529 − background: var(--accent-wash);
530 − color: var(--accent-deep);
531 − border-radius: 999px;
532 − padding: 1px 8px;
533 − margin-left: 6px;
534 −}
535 −.fields table.params { margin-top: 12px; }
536 −
537 −/* live status */
538 −.table-scroll {
539 − max-height: 430px;
540 − overflow-y: auto;
541 − border: 1px solid var(--line);
542 − border-radius: 12px;
543 − box-shadow: var(--shadow-sm);
544 − background: var(--card);
545 − margin-top: 18px;
546 −}
547 −.table-scroll table.params { margin: 0; }
548 −.table-scroll thead th { position: sticky; top: 0; background: var(--card); }
549 −#live .stat-chips { margin-top: 8px; }
550 −
551 −/* about + footer */
552 −.about p { max-width: 640px; }
553 −footer {
554 − border-top: 1px solid var(--line);
555 − padding-top: 24px;
556 − color: var(--muted);
557 − font-size: 0.8rem;
558 − display: flex;
559 − justify-content: space-between;
560 − align-items: baseline;
561 − gap: 16px;
562 − flex-wrap: wrap;
563 −}
564 −.foot-brand {
565 − font-family: var(--serif);
566 − font-style: italic;
567 − letter-spacing: 0.02em;
568 − font-size: 0.85rem;
569 − color: var(--ink-soft);
570 − text-transform: none;
571 −}
572 −
573 −/* ---------------------------------------------------------------- responsive */
574 −
575 −@media (max-width: 860px) {
576 − .sidebar { display: none; }
577 − main { padding: 30px 22px 50px; }
578 − .endpoint-index { display: none; }
579 − .hero h1 { font-size: 2.5rem; }
580 − .chip { padding-right: 18px; }
581 −}
582