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: page d'accueil (héros, compteurs live /v1/status, parcours clé d'API en 3 étapes, aperçu playground avec appel réel + repli, section Fundamentals avec graphique P/E Apple lightweight-charts + repli statique, intégrations) et page Status (santé API, inventaire assets × timeframes × ajustements)

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

5 changed files +520 −7

modified hfmarketdata/web/src/app/theme.css +1 −1
@@ -112,7 +112,7 @@ th { color: var(--fg-1); font-weight: 600; font-size: 12.5px; text-transform: up
112 112 tbody tr:hover { background: color-mix(in srgb, var(--bg-2) 60%, transparent); }
113 113 .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
114 114 .table-wrap table { min-width: 520px; }
115 −.table-wrap th { background: var(--bg-1); position: sticky; top: 0; }
115 +.table-wrap th { background: var(--bg-1); position: sticky; top: 0; white-space: nowrap; overflow-wrap: normal; }
116 116 .table-wrap tr:last-child td { border-bottom: 0; }
117 117 .num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 13px; }
118 118
modified hfmarketdata/web/src/pages/home/Home.jsx +308 −3
@@ -1,5 +1,310 @@
1 −import React from 'react'
2 −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name.
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.
3 +import React, { useEffect, useMemo, useRef, useState } from 'react'
4 +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'
7 +import CodeTabs from '../../components/CodeTabs.jsx'
8 +import { ArrowRightIcon, BoltIcon, DatabaseIcon, KeyIcon, MailIcon, PlayIcon, PulseIcon } from '../../components/Icons.jsx'
9 +import { snippetsFor } from '../../docs/snippets.js'
10 +import useTitle from '../../docs/useTitle.js'
11 +import './home.css'
12 +
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 +const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US'))
24 +
3 25 export default function Home() {
4 − return <main className="page"><h1>Home</h1><p className="muted">Coming soon.</p></main>
26 + 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.')
27 + return (
28 + <main className="home" data-testid="home">
29 + <Hero />
30 + <Datasets />
31 + <GetKey />
32 + <Preview />
33 + <Fundamentals />
34 + <Integrations />
35 + <Closing />
36 + </main>
37 + )
38 +}
39 +
40 +function Hero() {
41 + const curl = `curl "${PUBLIC_BASE}/v1/bars/stock/AAPL?timeframe=1min&limit=5"`
42 + return (
43 + <section className="hero">
44 + <div className="hero-inner">
45 + <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>
48 + <div className="hero-cta">
49 + <Link to="/docs/quickstart" className="btn btn-primary btn-lg">Read the quickstart <ArrowRightIcon /></Link>
50 + <Link to="/playground" className="btn btn-lg"><PlayIcon /> Open the playground</Link>
51 + </div>
52 + <div className="hero-curl" role="group" aria-label="First request">
53 + <code>{curl}</code>
54 + <CopyButton text={curl} />
55 + </div>
56 + </div>
57 + <HeroArt />
58 + </section>
59 + )
60 +}
61 +
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' },
115 + ]
116 + const quarters = ds?.options?.quarters?.length
117 + 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>
130 + </div>
131 + ))}
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 + </div>
138 + <p className="muted small">Full inventory by timeframe and adjustment on the <Link to="/status">status page</Link>.</p>
139 + </section>
140 + )
141 +}
142 +
143 +function GetKey() {
144 + return (
145 + <section className="section getkey" aria-labelledby="getkey-h">
146 + <div className="section-head">
147 + <p className="eyebrow"><KeyIcon /> Access</p>
148 + <h2 id="getkey-h">Get an API key — or don't</h2>
149 + <p className="muted">Everything is free. The three levels differ only in how much you can pull per window.</p>
150 + </div>
151 + <ol className="steps">
152 + <li className="step">
153 + <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>
156 + <Link to="/playground" className="btn btn-sm">Try in the playground</Link>
157 + </li>
158 + <li className="step step-featured">
159 + <span className="step-n">2</span>
160 + <h3>Create a free account</h3>
161 + <p>An e-mail address gets you a key with <strong>120 requests and 1 000 000 rows / minute</strong>, 50 000 rows per request. Keys are shown once, rotatable, revocable.</p>
162 + <Link to="/signup" className="btn btn-sm btn-primary">Create free account</Link>
163 + </li>
164 + <li className="step">
165 + <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>
168 + <a href={HIGH_USAGE_MAILTO} className="btn btn-sm"><MailIcon /> Request higher limits</a>
169 + </li>
170 + </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>
172 + </section>
173 + )
174 +}
175 +
176 +function Preview() {
177 + const [state, setState] = useState({ body: PREVIEW_FALLBACK, live: false, ms: null, rate: null })
178 + 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 }), [])
184 + return (
185 + <section className="section preview" aria-labelledby="preview-h">
186 + <div className="section-head">
187 + <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)} />
194 + </div>
195 + <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>
198 + </div>
199 + </section>
200 + )
201 +}
202 +
203 +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 + }, [])
243 + return (
244 + <section className="section fundamentals" aria-labelledby="fund-h">
245 + <div className="fund-grid">
246 + <div>
247 + <p className="eyebrow"><PulseIcon /> Fundamentals &amp; ratios</p>
248 + <h2 id="fund-h">SEC EDGAR, point-in-time, since 2010</h2>
249 + <p className="muted">Balance sheets, income and cash-flow statements exactly as filed, with the filing date on every number — so a backtest only sees what was public. Dozens of ratios per period, market multiples per trading day, a screener across the universe, bulk Parquet per year and a WebSocket stream of new filings.</p>
250 + <ul className="fund-list">
251 + <li><Link to="/docs/fundamentals/balance-sheet">Reading a balance sheet through the API</Link> — Apple Q2 FY2024, line by line</li>
252 + <li><Link to="/docs/fundamentals/point-in-time">Point-in-time &amp; look-ahead bias</Link></li>
253 + <li><Link to="/docs/fundamentals/ratios">Every ratio formula</Link> · <Link to="/docs/fundamentals/screener">Screener</Link> · <Link to="/docs/fundamentals/stream">Filings stream</Link></li>
254 + </ul>
255 + </div>
256 + <figure className="fund-chart card">
257 + <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>
260 + </figcaption>
261 + <div ref={ref} className="chart" data-testid="pe-chart" />
262 + </figure>
263 + </div>
264 + </section>
265 + )
266 +}
267 +
268 +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 + return (
276 + <section className="section integrations" aria-labelledby="int-h">
277 + <div className="section-head">
278 + <p className="eyebrow"><BoltIcon /> Integrations</p>
279 + <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>
281 + </div>
282 + <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>
286 + <span><strong>{it.name}</strong><span className="muted">{it.desc}</span></span>
287 + </Link>
288 + ))}
289 + </div>
290 + </section>
291 + )
292 +}
293 +
294 +function Closing() {
295 + return (
296 + <section className="section closing">
297 + <div className="closing-card">
298 + <div>
299 + <h2>Start with a URL. Grow into a key.</h2>
300 + <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 + </div>
302 + <div className="hero-cta">
303 + <Link to="/docs" className="btn btn-primary btn-lg">Documentation</Link>
304 + <Link to="/signup" className="btn btn-lg">Create free account</Link>
305 + </div>
306 + </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>
308 + </section>
309 + )
5 310 }
added hfmarketdata/web/src/pages/home/home.css +81 −0
@@ -0,0 +1,81 @@
1 +.home { flex: 1; }
2 +.section { max-width: var(--max); margin: 0 auto; padding: 56px 20px; }
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 +.section-head h2 { margin-bottom: 6px; }
7 +.section-head p { margin: 0; }
8 +
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; }
15 +.hero-cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 26px 0 22px; }
16 +.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; }
20 +
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; } }
29 +
30 +/* 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); }
36 +.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 +.step-featured .step-n { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
38 +.step .btn { align-self: flex-start; }
39 +
40 +/* 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; }
43 +
44 +/* fundamentals */
45 +.fundamentals { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
46 +.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 +.fund-list { margin: 16px 0 0; padding-left: 1.1em; color: var(--fg-1); }
50 +.fund-list li + li { margin-top: 6px; }
51 +.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; }
54 +
55 +/* integrations */
56 +.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; }
62 +
63 +/* closing */
64 +.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; }
65 +.closing-card h2 { margin-bottom: 6px; }
66 +.closing-card p { margin: 0; }
67 +.closing-card .hero-cta { margin: 0; }
68 +
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 + .steps { grid-template-columns: 1fr; }
74 + .preview-grid, .fund-grid { grid-template-columns: 1fr; }
75 + .closing-card { grid-template-columns: 1fr; }
76 +}
77 +@media (max-width: 560px) {
78 + .counters, .int-grid { grid-template-columns: 1fr; }
79 + .section { padding: 40px 16px; }
80 + .hero { padding: 36px 16px 24px; }
81 +}
modified hfmarketdata/web/src/pages/status/Status.jsx +113 −3
@@ -1,5 +1,115 @@
1 −import React from 'react'
2 −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name.
1 +// /status — API health + live dataset inventory (assets × timeframes × adjustments) from /v1/status and /health.
2 +import React, { useEffect, useState } from 'react'
3 +import { Link } from 'react-router-dom'
4 +import { CONTACT_EMAIL, PUBLIC_BASE, api } from '../../app/api.js'
5 +import Badge from '../../components/Badge.jsx'
6 +import Callout from '../../components/Callout.jsx'
7 +import useTitle from '../../docs/useTitle.js'
8 +import './status.css'
9 +
10 +const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
11 +const ASSET_LABEL = { stock: 'Stocks', etf: 'ETFs', futures: 'Futures (continuous)', futures_contracts: 'Futures contracts', crypto: 'Crypto', index: 'Indices', fx: 'FX' }
12 +const fmt = n => (n == null ? '—' : Number(n).toLocaleString('en-US'))
13 +
3 14 export default function Status() {
4 − return <main className="page"><h1>Status</h1><p className="muted">Coming soon.</p></main>
15 + useTitle('Status', 'Live health and dataset inventory of the HF Market Data API: every asset type, timeframe and adjustment with instrument counts.')
16 + const [health, setHealth] = useState({ state: 'loading' })
17 + const [status, setStatus] = useState({ state: 'loading' })
18 + const [openapi, setOpenapi] = useState({ state: 'loading' })
19 + useEffect(() => {
20 + 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' }))
24 + return () => { alive = false }
25 + }, [])
26 + const ds = status.data?.datasets || {}
27 + const assets = Object.keys(ds).filter(k => k !== 'options')
28 + const allUp = health.state === 'ok' && status.state === 'ok'
29 + return (
30 + <main className="page status" data-testid="status-page">
31 + <header className="status-head">
32 + <p className="eyebrow">Status</p>
33 + <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>
35 + </header>
36 +
37 + <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…'} />
42 + </section>
43 +
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>}
45 +
46 + <section aria-labelledby="inv-h">
47 + <h2 id="inv-h">Datasets</h2>
48 + <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>}
50 + {assets.map(asset => {
51 + const tfs = ds[asset]
52 + const adjustments = [...new Set(Object.values(tfs).flatMap(o => Object.keys(o)))]
53 + return (
54 + <div key={asset} className="inv-block" data-testid={`inv-${asset}`}>
55 + <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>
69 + <LastDates data={tfs} />
70 + </div>
71 + )
72 + })}
73 + {ds.options && (
74 + <div className="inv-block" data-testid="inv-options">
75 + <h3>Options <code className="muted">options</code></h3>
76 + <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>}
80 + </div>
81 + </div>
82 + )}
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 + </section>
85 +
86 + <section className="status-notes">
87 + <h2>Notes</h2>
88 + <ul>
89 + <li><strong>Updates.</strong> Intraday and daily bars are refreshed from FirstRate Data; futures contract files come as an archive (≤ 2025) plus a rolling update set; options are quarterly archives. When the API reports last data dates for a dataset they appear under its table above.</li>
90 + <li><strong>Uptime.</strong> The API runs on dedicated hardware with process supervision and automatic restarts; there is no scheduled maintenance window. Incidents are noted in the <Link to="/docs/changelog">changelog</Link>. If this page loads but a request fails, check the <Link to="/docs/errors">error code</Link> first — most are request-side.</li>
91 + <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 + <li><strong>Contact.</strong> <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</li>
93 + </ul>
94 + {allUp && <p className="muted small">All checks passed at {new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC.</p>}
95 + </section>
96 + </main>
97 + )
98 +}
99 +
100 +function HealthCard({ label, state, detail }) {
101 + const tone = state === 'ok' ? 'success' : state === 'down' ? 'danger' : 'neutral'
102 + return (
103 + <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>
105 + <div className="muted small">{detail}</div>
106 + </div>
107 + )
108 +}
109 +
110 +// /v1/status may grow `last_date` / `first_date` style fields per timeframe — render them when present, never invent.
111 +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}`))
113 + if (!rows.length) return null
114 + return <p className="muted small">{rows.join(' · ')}</p>
5 115 }
added hfmarketdata/web/src/pages/status/status.css +17 −0
@@ -0,0 +1,17 @@
1 +.status-head { max-width: 760px; margin-bottom: 28px; }
2 +.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); }
5 +.health-card[data-state="down"] { border-left-color: var(--danger); }
6 +.health-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 6px; }
7 +.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; }
10 +.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 +.status-notes { margin-top: 40px; max-width: 820px; }
14 +.status-notes ul { padding-left: 1.2em; color: var(--fg-1); }
15 +.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; } }
18