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: playground interactif (catalogue de requêtes, formulaire dynamique, autocomplétion, vues JSON/table virtualisée/graphique, quotas, export de code, presets, onglet WebSocket, liens profonds)

Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent 7ee2ef9

17 changed files +1,796 −3

added hfmarketdata/web/src/components/PgCallout.jsx +14 −0
@@ -0,0 +1,14 @@
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 +}
added hfmarketdata/web/src/components/PgCodeBlock.jsx +28 −0
@@ -0,0 +1,28 @@
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 +}
added hfmarketdata/web/src/components/PgCopyButton.jsx +14 −0
@@ -0,0 +1,14 @@
1 +import React, { useEffect, useState } from 'react'
2 +
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 + )
14 +}
added hfmarketdata/web/src/components/PgTabs.jsx +35 −0
@@ -0,0 +1,35 @@
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/pages/playground/Playground.jsx +13 −3
@@ -1,5 +1,15 @@
1 +// Public playground page (keyless). The dashboard renders the same component with a session key injected.
1 2 import React from 'react'
2 −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name.
3 −export default function Playground() {
4 − return <main className="page"><h1>Playground</h1><p className="muted">Coming soon.</p></main>
3 +import PlaygroundApp from '../../playground/Playground.jsx'
4 +
5 +export default function PlaygroundPage() {
6 + return (
7 + <main className="page pg-page">
8 + <header className="pg-page-head">
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>
11 + </header>
12 + <PlaygroundApp />
13 + </main>
14 + )
5 15 }
added hfmarketdata/web/src/playground/Playground.jsx +373 −0
@@ -0,0 +1,373 @@
1 +// Interactive playground — reusable: <Playground apiKey={sessionKey} embedded /> (dashboard) or bare (/playground).
2 +// Deep links: /playground?ep=<catalogId|operationId>&<param>=<value>… (see PLAYGROUND.md).
3 +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4 +import { Link, useLocation, useSearchParams } from 'react-router-dom'
5 +import { BASE_URL, PUBLIC_BASE, rateHeaders } from '../app/api.js'
6 +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'
12 +import { curlSnippet, jsSnippet, pythonSnippet } from './codegen.js'
13 +import { PRESETS } from './presets.js'
14 +import RateLimitPanel from './RateLimitPanel.jsx'
15 +import RequestForm from './RequestForm.jsx'
16 +import WebSocketTab from './WebSocketTab.jsx'
17 +import ChartView, { detectTimeKey } from './views/ChartView.jsx'
18 +import JsonView from './views/JsonView.jsx'
19 +import TableView from './views/TableView.jsx'
20 +import './playground.css'
21 +
22 +const DEFAULT_EP = 'stock-bars'
23 +let specPromise = null
24 +export function loadSpec() {
25 + if (!specPromise) {
26 + specPromise = fetch(`${BASE_URL}/openapi.json`, { headers: { Accept: 'application/json' } })
27 + .then(r => (r.ok ? r.json() : null)).catch(() => null)
28 + }
29 + return specPromise
30 +}
31 +
32 +/** Resolve `ep` (catalog id or operationId) → entry; falls back to the OpenAPI spec for unknown ids. */
33 +export async function resolveEntry(ref, values) {
34 + if (!ref) return { entry: byId(DEFAULT_EP) }
35 + const direct = findEntry(ref, values)
36 + if (direct) return { entry: direct }
37 + const spec = await loadSpec()
38 + const op = spec ? specOperation(ref, spec) : null
39 + if (op) {
40 + const byPath = findByPath(op.method, op.path, values)
41 + if (byPath) return { entry: byPath }
42 + const generic = entryFromSpec(ref, spec)
43 + if (generic) return { entry: generic, generic: true }
44 + }
45 + return { entry: byId(DEFAULT_EP), unknown: ref }
46 +}
47 +
48 +function parseCsv(text, max = 50_000) {
49 + const lines = text.split(/\r?\n/).filter(Boolean)
50 + if (!lines.length) return []
51 + const split = l => l.split(',')
52 + const head = split(lines[0])
53 + const rows = []
54 + for (let i = 1; i < lines.length && rows.length < max; i++) {
55 + const cells = split(lines[i])
56 + const r = {}
57 + head.forEach((h, j) => { const c = cells[j]; const num = c !== '' && c !== undefined && !Number.isNaN(Number(c)) ? Number(c) : c; r[h] = num === undefined ? null : num })
58 + rows.push(r)
59 + }
60 + return rows
61 +}
62 +
63 +function extractRows(json, entry) {
64 + if (!json || typeof json !== 'object') return []
65 + if (Array.isArray(json)) return json
66 + if (Array.isArray(json.data)) return json.data
67 + const lk = entry.result?.listKey
68 + if (lk && Array.isArray(json[lk])) return json[lk].map(v => (typeof v === 'object' ? v : { [lk.replace(/s$/, '')]: v }))
69 + for (const k of ['tickers', 'expirations', 'quarters', 'roots', 'contracts', 'items', 'results']) {
70 + if (Array.isArray(json[k])) return json[k].map(v => (typeof v === 'object' ? v : { [k.replace(/s$/, '')]: v }))
71 + }
72 + return []
73 +}
74 +
75 +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 +
77 +export default function Playground({ apiKey, embedded = false }) {
78 + const { user } = useAuth()
79 + const location = useLocation()
80 + const [searchParams, setSearchParams] = useSearchParams()
81 + const [entry, setEntry] = useState(() => byId(DEFAULT_EP))
82 + const [values, setValues] = useState(() => defaultValues(byId(DEFAULT_EP)))
83 + const [notice, setNotice] = useState(null)
84 + const [result, setResult] = useState(null)
85 + const [running, setRunning] = useState(false)
86 + const [view, setView] = useState('table')
87 + const [codeLang, setCodeLang] = useState('curl')
88 + const [retryUntil, setRetryUntil] = useState(null)
89 + const [lastRate, setLastRate] = useState(null)
90 + const [filter, setFilter] = useState('')
91 + const lastWritten = useRef(null)
92 + const abortRef = useRef(null)
93 + const resultRef = useRef(null)
94 +
95 + // ---- deep links: (re)initialise from the URL whenever it changes externally
96 + useEffect(() => {
97 + if (location.search === lastWritten.current) return
98 + const sp = new URLSearchParams(location.search)
99 + const ref = sp.get('ep')
100 + const urlValues = {}
101 + sp.forEach((v, k) => { if (k !== 'ep') urlValues[k] = v })
102 + let cancelled = false
103 + resolveEntry(ref, urlValues).then(({ entry: e, generic, unknown }) => {
104 + if (cancelled) return
105 + setEntry(e)
106 + const dv = defaultValues(e)
107 + for (const [k, v] of Object.entries(urlValues)) if (k in dv || e.fromSpec) dv[k] = v
108 + setValues(dv)
109 + setResult(null)
110 + if (unknown) setNotice({ kind: 'warn', text: `Unknown request type "${unknown}" — showing ${e.title} instead.` })
111 + else if (generic) setNotice({ kind: 'info', text: `${e.title}: form generated from the OpenAPI spec (no curated preset yet).` })
112 + else setNotice(null)
113 + })
114 + return () => { cancelled = true }
115 + }, [location.search])
116 +
117 + // ---- write the URL from the state (replace, no history spam)
118 + useEffect(() => {
119 + if (!entry) return
120 + const sp = new URLSearchParams()
121 + sp.set('ep', entry.fromSpec ? entry.operationIds[0] : entry.id)
122 + const dv = defaultValues(entry)
123 + for (const prm of entry.params) { const v = values[prm.name] ?? ''; if (v !== '' && v !== dv[prm.name]) sp.set(prm.name, v) }
124 + const next = '?' + sp.toString()
125 + if (next !== location.search) { lastWritten.current = next; setSearchParams(sp, { replace: true }) }
126 + else lastWritten.current = next
127 + }, [entry, values]) // eslint-disable-line react-hooks/exhaustive-deps
128 +
129 + const errors = useMemo(() => validate(entry, values), [entry, values])
130 + const relUrl = useMemo(() => buildPath(entry, values), [entry, values])
131 + const fullUrl = PUBLIC_BASE + relUrl
132 + const format = values.format || 'json'
133 + const isWs = entry.result?.kind === 'ws'
134 + const tier = user?.tier || (apiKey ? 'free' : 'keyless')
135 + const authenticated = !!apiKey
136 +
137 + const selectEntry = useCallback((e, preset) => {
138 + abortRef.current?.abort()
139 + setEntry(e)
140 + setValues({ ...defaultValues(e), ...(preset || {}) })
141 + setResult(null)
142 + setNotice(null)
143 + }, [])
144 +
145 + const onChange = useCallback((name, v) => setValues(vals => {
146 + const next = { ...vals, [name]: v }
147 + // reset dependent enums (adjustment) when asset changes
148 + if (name === 'asset') for (const prm of entry.params) if (prm.enumBy?.asset) next[prm.name] = ''
149 + return next
150 + }), [entry])
151 +
152 + const applyPreset = ps => { const e = byId(ps.ep); if (e) selectEntry(e, ps.values()) }
153 +
154 + const run = useCallback(async () => {
155 + if (Object.keys(errors).length || isWs) return
156 + abortRef.current?.abort()
157 + const ctrl = new AbortController()
158 + abortRef.current = ctrl
159 + setRunning(true)
160 + setResult(null)
161 + const headers = { Accept: format === 'json' ? 'application/json' : format === 'csv' ? 'text/csv' : 'application/octet-stream' }
162 + if (apiKey) headers.Authorization = `Bearer ${apiKey}`
163 + const t0 = performance.now()
164 + try {
165 + const res = await fetch(BASE_URL + relUrl, { headers, credentials: 'include', signal: ctrl.signal })
166 + const ms = Math.round(performance.now() - t0)
167 + const rate = rateHeaders(res)
168 + setLastRate(rate)
169 + const ct = res.headers.get('content-type') || ''
170 + const out = { status: res.status, ok: res.ok, ms, rate, format, statusText: res.statusText }
171 + if (res.status === 429) {
172 + const wait = rate.retryAfter != null ? rate.retryAfter : rate.reset ? rate.reset - Math.floor(Date.now() / 1000) : 60
173 + setRetryUntil(Math.floor(Date.now() / 1000) + Math.max(1, wait))
174 + } else setRetryUntil(null)
175 + if (ct.includes('json')) {
176 + out.json = await res.json()
177 + if (res.ok) {
178 + out.rows = extractRows(out.json, entry)
179 + out.meta = out.json?.meta || null
180 + out.rowCount = rate.rowCount ?? (out.json?.count ?? out.rows.length)
181 + } else {
182 + out.error = out.json?.error || { code: out.json?.detail ? 'ERROR' : 'HTTP_ERROR', message: typeof out.json?.detail === 'string' ? out.json.detail : JSON.stringify(out.json?.detail ?? out.json) }
183 + }
184 + } else if (ct.includes('csv') || ct.startsWith('text/')) {
185 + out.text = await res.text()
186 + if (res.ok && (format === 'csv' || ct.includes('csv'))) {
187 + out.rows = parseCsv(out.text)
188 + out.rowCount = rate.rowCount ?? out.rows.length
189 + out.blob = new Blob([out.text], { type: 'text/csv' })
190 + out.filename = `${entry.id}.csv`
191 + } else if (!res.ok) out.error = { code: 'HTTP_ERROR', message: out.text.slice(0, 300) }
192 + } else {
193 + out.blob = await res.blob()
194 + out.filename = `${entry.id}.${format === 'parquet' ? 'parquet' : 'bin'}`
195 + out.rowCount = rate.rowCount
196 + if (!res.ok) out.error = { code: 'HTTP_ERROR', message: `HTTP ${res.status}` }
197 + }
198 + if (!res.ok && isRouteMissing(res.status, out.json ?? out.text)) out.comingSoon = true
199 + setResult(out)
200 + const hasRows = out.rows?.length > 0
201 + const kind = entry.result?.kind
202 + if (out.blob && format !== 'csv') setView('download')
203 + else if (hasRows && (kind === 'bars' || kind === 'series') && detectTimeKey(out.rows, entry.result?.timeKey)) setView('chart')
204 + else if (hasRows) setView('table')
205 + else setView('json')
206 + } catch (e) {
207 + if (e.name === 'AbortError') return
208 + setResult({ status: 0, ok: false, ms: Math.round(performance.now() - t0), error: { code: 'NETWORK_ERROR', message: e.message || 'Network error' } })
209 + setView('json')
210 + } finally {
211 + if (!ctrl.signal.aborted) setRunning(false)
212 + }
213 + }, [errors, isWs, format, apiKey, relUrl, entry])
214 +
215 + // Ctrl/Cmd+Enter runs
216 + useEffect(() => {
217 + const onKey = e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run() } }
218 + window.addEventListener('keydown', onKey)
219 + return () => window.removeEventListener('keydown', onKey)
220 + }, [run])
221 +
222 + const downloadUrl = useMemo(() => (result?.blob ? URL.createObjectURL(result.blob) : null), [result])
223 + useEffect(() => () => { if (downloadUrl) URL.revokeObjectURL(downloadUrl) }, [downloadUrl])
224 +
225 + const code = useMemo(() => {
226 + const opts = { hasKey: authenticated, format, envelope: entry.result?.envelope }
227 + return { curl: curlSnippet(relUrl, opts), python: pythonSnippet(relUrl, opts), javascript: jsSnippet(relUrl, opts) }
228 + }, [relUrl, authenticated, format, entry])
229 +
230 + const rows = result?.rows || []
231 + const chartable = rows.length > 0 && !!detectTimeKey(rows, entry.result?.timeKey)
232 + const viewTabs = [
233 + { id: 'json', label: 'JSON', disabled: !result?.json },
234 + { id: 'table', label: 'Table', disabled: !rows.length, badge: rows.length ? rows.length.toLocaleString() : undefined },
235 + { id: 'chart', label: 'Chart', disabled: !chartable },
236 + { id: 'download', label: 'Download', disabled: !result?.blob },
237 + ]
238 + const visibleCatalog = CATALOG.filter(e => !filter || `${e.title} ${e.path} ${e.group}`.toLowerCase().includes(filter.toLowerCase()))
239 + const jsonDownload = useMemo(() => (result?.json && result.ok ? URL.createObjectURL(new Blob([JSON.stringify(result.json)], { type: 'application/json' })) : null), [result])
240 + useEffect(() => () => { if (jsonDownload) URL.revokeObjectURL(jsonDownload) }, [jsonDownload])
241 +
242 + return (
243 + <div className={`pg ${embedded ? 'pg-embedded' : ''}`} data-testid="playground">
244 + {!embedded && !authenticated && (
245 + <PgCallout kind="info" className="pg-banner" action={<Link to="/signup" className="btn btn-primary">Create free account</Link>}>
246 + <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.
247 + </PgCallout>
248 + )}
249 + {authenticated && (
250 + <div className="pg-authbar" data-testid="pg-authbar">
251 + <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>
252 + </div>
253 + )}
254 + <div className="pg-grid">
255 + {/* ------------------------------------------------ left: request type + form */}
256 + <aside className="pg-left">
257 + <div className="pg-left-head">
258 + <h2 className="pg-h">Request type</h2>
259 + <input type="search" className="pg-filter" placeholder="Filter…" value={filter} onChange={e => setFilter(e.target.value)} aria-label="Filter request types" />
260 + </div>
261 + <nav className="pg-types" aria-label="Request type">
262 + {GROUPS.map(g => {
263 + const items = visibleCatalog.filter(e => e.group === g)
264 + if (!items.length) return null
265 + return (
266 + <div key={g} className="pg-type-group">
267 + <div className="pg-type-group-name">{g}</div>
268 + {items.map(e => (
269 + <button key={e.id} type="button" className={`pg-type ${entry.id === e.id ? 'active' : ''}`} aria-current={entry.id === e.id ? 'true' : undefined}
270 + onClick={() => selectEntry(e)} data-ep={e.id}>
271 + <span className={`pg-method ${e.method.toLowerCase()}`}>{e.method}</span>
272 + <span className="pg-type-title">{e.title}</span>
273 + <span className="pg-type-path mono">{e.path}</span>
274 + </button>
275 + ))}
276 + </div>
277 + )
278 + })}
279 + {entry.fromSpec && (
280 + <div className="pg-type-group"><div className="pg-type-group-name">From OpenAPI</div>
281 + <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>
282 + </div>
283 + )}
284 + </nav>
285 + {!isWs && (
286 + <section className="pg-form-section" aria-label="Parameters">
287 + <h2 className="pg-h">Parameters</h2>
288 + {entry.description && <p className="muted pg-desc">{entry.description}</p>}
289 + <RequestForm entry={entry} values={values} errors={errors} onChange={onChange} apiKey={apiKey} />
290 + {entry.fixed && <p className="muted pg-fixed">Fixed: {Object.entries(entry.fixed).map(([k, v]) => <code key={k}>{k}={v}</code>)}</p>}
291 + </section>
292 + )}
293 + </aside>
294 +
295 + {/* ------------------------------------------------ right: URL, run, results */}
296 + <section className="pg-right" aria-label="Request and results">
297 + {notice && <PgCallout kind={notice.kind}>{notice.text}</PgCallout>}
298 + {isWs ? (
299 + <WebSocketTab apiKey={apiKey} authenticated={!!user} />
300 + ) : (
301 + <>
302 + <div className="pg-urlbar">
303 + <span className={`pg-method ${entry.method.toLowerCase()}`}>{entry.method}</span>
304 + <output className="pg-url mono" data-testid="pg-url" aria-live="polite" htmlFor="">{fullUrl}</output>
305 + <PgCopyButton text={fullUrl} label="Copy URL" small />
306 + </div>
307 + <div className="pg-runrow">
308 + <button type="button" className="btn btn-primary pg-run" onClick={run} disabled={running || Object.keys(errors).length > 0} data-testid="pg-run">
309 + {running ? 'Running…' : 'Run ▶'}
310 + </button>
311 + {running && <button type="button" className="btn" onClick={() => { abortRef.current?.abort(); setRunning(false) }}>Cancel</button>}
312 + <span className="pg-kbd muted">⌘/Ctrl + Enter</span>
313 + {Object.keys(errors).length > 0 && <span className="pg-err">Fix {Object.keys(errors).length} field{Object.keys(errors).length > 1 ? 's' : ''} to run.</span>}
314 + {result && (
315 + <span className="pg-status" data-testid="pg-status">
316 + <span className={`pg-code-badge ${result.ok ? 'ok' : 'bad'}`}>{result.status || 'ERR'}{result.ok ? ' OK' : ''}</span>
317 + <span className="mono">{result.ms} ms</span>
318 + {result.rowCount != null && <span className="mono">{Number(result.rowCount).toLocaleString()} rows</span>}
319 + {result.blob && <span className="mono">{(result.blob.size / 1024).toFixed(1)} KB</span>}
320 + </span>
321 + )}
322 + </div>
323 +
324 + {result?.error && (
325 + <PgCallout kind={result.comingSoon ? 'warn' : 'danger'} title={result.comingSoon ? 'Endpoint not available yet' : `${result.error.code || 'Error'} · HTTP ${result.status}`}
326 + 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}>
327 + {result.comingSoon
328 + ? <>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>.</>
329 + : result.error.message}
330 + {result.error.details && <pre className="pg-err-details">{JSON.stringify(result.error.details, null, 2)}</pre>}
331 + </PgCallout>
332 + )}
333 +
334 + {result && !result.error && (
335 + <div className="pg-results" ref={resultRef}>
336 + <PgTabs tabs={viewTabs} value={view} onChange={setView} label="Result view" right={
337 + <div className="pg-dl-row">
338 + {jsonDownload && <a className="btn btn-sm" href={jsonDownload} download={`${entry.id}.json`}>Download JSON</a>}
339 + {downloadUrl && <a className="btn btn-sm" href={downloadUrl} download={result.filename}>Download {format.toUpperCase()}</a>}
340 + </div>}>
341 + {view === 'json' && (result.json ? <JsonView data={result.json} /> : <pre className="pg-text">{(result.text || '').slice(0, 20_000)}</pre>)}
342 + {view === 'table' && <TableView rows={rows} />}
343 + {view === 'chart' && <ChartView rows={rows} meta={result.meta} hint={entry.result || {}} />}
344 + {view === 'download' && (
345 + <div className="pg-download">
346 + <p>{format.toUpperCase()} file ready · {(result.blob.size / 1024).toFixed(1)} KB{result.rowCount != null && ` · ${Number(result.rowCount).toLocaleString()} rows`}.</p>
347 + <a className="btn btn-primary" href={downloadUrl} download={result.filename}>Download {result.filename}</a>
348 + {format === 'parquet' && <p className="muted">Parquet responses count half the rows against your quota. Read it with <code>pd.read_parquet</code>.</p>}
349 + </div>
350 + )}
351 + </PgTabs>
352 + </div>
353 + )}
354 +
355 + <RateLimitPanel rate={lastRate} tier={tier} authenticated={authenticated} retryUntil={retryUntil} />
356 +
357 + <section className="pg-codex" aria-label="Code export">
358 + <PgTabs tabs={[{ id: 'curl', label: 'curl' }, { id: 'python', label: 'Python (requests + pandas)' }, { id: 'javascript', label: 'JavaScript (fetch)' }]} value={codeLang} onChange={setCodeLang} label="Code export">
359 + <PgCodeBlock code={code[codeLang]} language={codeLang === 'curl' ? 'bash' : codeLang} title={`${codeLang}${authenticated ? ' · key via $HFMD_API_KEY' : ''}`} />
360 + </PgTabs>
361 + </section>
362 + </>
363 + )}
364 +
365 + <section className="pg-presets" aria-label="Examples">
366 + <span className="muted">Examples:</span>
367 + {PRESETS.map(ps => <button key={ps.id} type="button" className="pg-preset" onClick={() => applyPreset(ps)} data-preset={ps.id}>{ps.label}</button>)}
368 + </section>
369 + </section>
370 + </div>
371 + </div>
372 + )
373 +}
added hfmarketdata/web/src/playground/RateLimitPanel.jsx +70 −0
@@ -0,0 +1,70 @@
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'
3 +import { Link } from 'react-router-dom'
4 +import { TIERS } from '../app/api.js'
5 +
6 +const n = v => (v == null ? '—' : Number(v).toLocaleString())
7 +
8 +function Bar({ label, used, limit, remaining }) {
9 + const pct = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0
10 + const tone = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : 'ok'
11 + return (
12 + <div className="pg-rl-row">
13 + <div className="pg-rl-label"><span>{label}</span><span className="mono">{n(remaining)} / {n(limit)} left</span></div>
14 + <div className="pg-rl-bar" role="progressbar" aria-valuemin={0} aria-valuemax={limit || 0} aria-valuenow={used} aria-label={`${label} used`}>
15 + <div className={`pg-rl-fill ${tone}`} style={{ width: `${pct}%` }} />
16 + </div>
17 + </div>
18 + )
19 +}
20 +
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 +export default function RateLimitPanel({ rate, tier, authenticated, retryUntil }) {
36 + const resetLeft = useCountdown(rate?.reset || null)
37 + const retryLeft = useCountdown(retryUntil || null)
38 + const tierInfo = TIERS.find(t => t.id === (tier || (authenticated ? 'free' : 'keyless'))) || TIERS[0]
39 + const limitReq = rate?.limitRequests ?? tierInfo.requests
40 + const limitRows = rate?.limitRows ?? tierInfo.rows
41 + const remReq = rate?.remainingRequests
42 + const remRows = rate?.remainingRows
43 + const known = rate && (rate.limitRequests != null || rate.remainingRequests != null)
44 + return (
45 + <section className="pg-rl card" aria-label="Rate limit">
46 + <div className="pg-rl-head">
47 + <strong>Rate limit</strong>
48 + <span className="muted">{tierInfo.name} · {n(tierInfo.requests)} req / {tierInfo.window} · {n(tierInfo.rows)} rows / {tierInfo.window}</span>
49 + </div>
50 + {retryLeft > 0 && (
51 + <div className="pg-rl-429" role="alert">
52 + <strong>429 Too Many Requests.</strong> Retry in <span className="mono">{fmtDuration(retryLeft)}</span>.
53 + {!authenticated && <> Keyless mode is limited to 30 requests / hour — <Link to="/signup">create a free account</Link> for 120 / min.</>}
54 + </div>
55 + )}
56 + {known ? (
57 + <>
58 + <Bar label="Requests" used={Math.max(0, (limitReq ?? 0) - (remReq ?? 0))} limit={limitReq} remaining={remReq} />
59 + <Bar label="Rows" used={Math.max(0, (limitRows ?? 0) - (remRows ?? 0))} limit={limitRows} remaining={remRows} />
60 + <div className="pg-rl-foot muted">
61 + {rate.reset ? <>Window resets in <span className="mono">{fmtDuration(resetLeft)}</span>.</> : 'No reset header.'}
62 + {rate.rowCount != null && <> This response counted <span className="mono">{n(rate.rowCount)}</span> rows.</>}
63 + </div>
64 + </>
65 + ) : (
66 + <p className="muted pg-rl-foot">Run a request to see your remaining quota. Limits are per {tierInfo.window} and count both requests and rows; Parquet responses count half the rows.</p>
67 + )}
68 + </section>
69 + )
70 +}
added hfmarketdata/web/src/playground/RequestForm.jsx +60 −0
@@ -0,0 +1,60 @@
1 +// Dynamic form generated from a catalog entry.
2 +import React from 'react'
3 +import { enumFor } from './catalog.js'
4 +import SymbolInput from './SymbolInput.jsx'
5 +
6 +const toNative = (v, type) => (type === 'datetime' ? (v || '').replace(' ', 'T') : v || '')
7 +const fromNative = (v, type) => (type === 'datetime' ? v.replace('T', ' ') : v)
8 +
9 +export default function RequestForm({ entry, values, errors, onChange, apiKey }) {
10 + if (!entry.params.length) return <p className="muted pg-form-empty">This request takes no parameters.</p>
11 + const asset = values.asset || entry.fixed?.asset
12 + return (
13 + <div className="pg-form">
14 + {entry.params.map(prm => {
15 + const id = `pg-f-${entry.id}-${prm.name}`
16 + const helpId = prm.help ? `${id}-help` : undefined
17 + const err = errors[prm.name]
18 + const errId = err ? `${id}-err` : undefined
19 + const described = [helpId, errId].filter(Boolean).join(' ') || undefined
20 + const val = values[prm.name] ?? ''
21 + const set = v => onChange(prm.name, v)
22 + let control
23 + 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} />
25 + } 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 + )
32 + } 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 + )
38 + } 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' : ''} />
40 + } 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} />
42 + } 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} />
44 + }
45 + 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>
52 + {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>
56 + )
57 + })}
58 + </div>
59 + )
60 +}
added hfmarketdata/web/src/playground/SymbolInput.jsx +98 −0
@@ -0,0 +1,98 @@
1 +// Symbol autocomplete: debounced fetch of /v1/{asset}/tickers?search= (or /v1/futures/roots), keyboard navigation.
2 +import React, { useEffect, useId, useRef, useState } from 'react'
3 +import { api } from '../app/api.js'
4 +
5 +const cache = new Map()
6 +
7 +async function fetchSuggestions(source, asset, q, apiKey, signal) {
8 + const key = `${source}|${asset}|${q}`
9 + if (cache.has(key)) return cache.get(key)
10 + let list = []
11 + try {
12 + if (source === 'futures_root') {
13 + try {
14 + const { data } = await api(`/v1/futures/roots?search=${encodeURIComponent(q)}&limit=20`, { apiKey, signal })
15 + const rows = data?.data || data?.roots || []
16 + list = rows.map(r => (typeof r === 'string' ? { symbol: r } : { symbol: r.root || r.symbol, name: r.name }))
17 + } catch (e) {
18 + if (e.name === 'AbortError') throw e
19 + const { data } = await api(`/v1/futures/tickers?search=${encodeURIComponent(q)}&limit=20&adjustment=contin_adj_ratio`, { apiKey, signal })
20 + list = (data?.tickers || []).map(s => ({ symbol: s }))
21 + }
22 + } else if (source === 'options') {
23 + const { data } = await api(`/v1/options/tickers?search=${encodeURIComponent(q)}&limit=20`, { apiKey, signal })
24 + list = (data?.tickers || []).map(s => ({ symbol: s }))
25 + } else {
26 + const a = source === 'asset' ? asset || 'stock' : source
27 + const { data } = await api(`/v1/${a}/tickers?search=${encodeURIComponent(q)}&limit=20`, { apiKey, signal })
28 + list = (data?.tickers || []).map(s => ({ symbol: s }))
29 + }
30 + } catch (e) {
31 + if (e.name === 'AbortError') throw e
32 + list = []
33 + }
34 + cache.set(key, list)
35 + return list
36 +}
37 +
38 +export default function SymbolInput({ id, value, onChange, source = 'asset', asset, apiKey, placeholder, invalid, describedBy }) {
39 + const [open, setOpen] = useState(false)
40 + const [items, setItems] = useState([])
41 + const [active, setActive] = useState(-1)
42 + const [loading, setLoading] = useState(false)
43 + const listId = useId()
44 + const abortRef = useRef(null)
45 + const wrapRef = useRef(null)
46 +
47 + useEffect(() => {
48 + if (!open) return
49 + const q = (value || '').trim().toUpperCase()
50 + if (q.length < 1) { setItems([]); return }
51 + const t = setTimeout(async () => {
52 + abortRef.current?.abort()
53 + const ctrl = new AbortController()
54 + abortRef.current = ctrl
55 + setLoading(true)
56 + try {
57 + const list = await fetchSuggestions(source, asset, q, apiKey, ctrl.signal)
58 + if (!ctrl.signal.aborted) { setItems(list.filter(x => x.symbol !== q).slice(0, 12)); setActive(-1) }
59 + } catch { /* aborted */ } finally { if (!ctrl.signal.aborted) setLoading(false) }
60 + }, 220)
61 + return () => clearTimeout(t)
62 + }, [value, open, source, asset, apiKey])
63 +
64 + useEffect(() => {
65 + const onDoc = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
66 + document.addEventListener('mousedown', onDoc)
67 + return () => document.removeEventListener('mousedown', onDoc)
68 + }, [])
69 +
70 + const pick = s => { onChange(s); setOpen(false); setItems([]) }
71 + const onKey = e => {
72 + if (!open || !items.length) { if (e.key === 'ArrowDown') setOpen(true); return }
73 + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => (a + 1) % items.length) }
74 + else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => (a - 1 + items.length) % items.length) }
75 + else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); pick(items[active].symbol) }
76 + else if (e.key === 'Escape') setOpen(false)
77 + }
78 + const expanded = open && items.length > 0
79 + return (
80 + <div className="pg-symbol" ref={wrapRef}>
81 + <input id={id} type="text" className={`mono ${invalid ? 'invalid' : ''}`} value={value} placeholder={placeholder} autoComplete="off" spellCheck={false}
82 + role="combobox" aria-expanded={expanded} aria-controls={listId} aria-autocomplete="list" aria-invalid={!!invalid} aria-describedby={describedBy}
83 + aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
84 + onChange={e => { onChange(e.target.value.toUpperCase()); setOpen(true) }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
85 + {loading && <span className="pg-symbol-spin" aria-hidden="true" />}
86 + {expanded && (
87 + <ul id={listId} role="listbox" className="pg-symbol-list">
88 + {items.map((it, i) => (
89 + <li key={it.symbol} id={`${listId}-${i}`} role="option" aria-selected={i === active} className={i === active ? 'active' : ''}
90 + onMouseDown={e => { e.preventDefault(); pick(it.symbol) }} onMouseEnter={() => setActive(i)}>
91 + <span className="mono">{it.symbol}</span>{it.name && <span className="muted"> {it.name}</span>}
92 + </li>
93 + ))}
94 + </ul>
95 + )}
96 + </div>
97 + )
98 +}
added hfmarketdata/web/src/playground/WebSocketTab.jsx +92 −0
@@ -0,0 +1,92 @@
1 +// WebSocket tab: connect to /v1/stream with the session key, send a subscribe message, show live messages.
2 +import React, { useEffect, useRef, useState } from 'react'
3 +import { Link } from 'react-router-dom'
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'
8 +import { wsSnippet } from './codegen.js'
9 +
10 +export function wsUrl(apiKey) {
11 + const base = BASE_URL ? new URL(BASE_URL, window.location.origin) : new URL(window.location.origin)
12 + const proto = base.protocol === 'https:' ? 'wss:' : 'ws:'
13 + const u = new URL(`${proto}//${base.host}/v1/stream`)
14 + if (apiKey) u.searchParams.set('api_key', apiKey)
15 + return u.toString()
16 +}
17 +
18 +const MAX_MSGS = 500
19 +
20 +export default function WebSocketTab({ apiKey, authenticated }) {
21 + const [state, setState] = useState('idle') // idle | connecting | open | closed | error
22 + const [msgs, setMsgs] = useState([])
23 + const [sub, setSub] = useState('{"action":"subscribe","channel":"filings"}')
24 + const [lang, setLang] = useState('js')
25 + const [info, setInfo] = useState('')
26 + const wsRef = useRef(null)
27 + const logRef = useRef(null)
28 +
29 + useEffect(() => () => wsRef.current?.close(), [])
30 + useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight }, [msgs])
31 +
32 + const push = (kind, text) => setMsgs(m => [...m.slice(-(MAX_MSGS - 1)), { t: new Date().toISOString().slice(11, 19), kind, text }])
33 +
34 + const connect = () => {
35 + if (!apiKey) return
36 + try {
37 + const ws = new WebSocket(wsUrl(apiKey))
38 + wsRef.current = ws
39 + setState('connecting'); setInfo('')
40 + ws.onopen = () => { setState('open'); push('sys', 'connected'); if (sub.trim()) { ws.send(sub); push('out', sub) } }
41 + ws.onmessage = e => push('in', typeof e.data === 'string' ? e.data : '[binary]')
42 + ws.onerror = () => { setState('error'); setInfo('Connection error. The stream endpoint may not be deployed yet (coming soon) or the key was rejected.') }
43 + ws.onclose = e => { setState(s => (s === 'error' ? s : 'closed')); push('sys', `closed (${e.code}${e.reason ? ' ' + e.reason : ''})`) }
44 + } catch (e) { setState('error'); setInfo(String(e.message || e)) }
45 + }
46 + const disconnect = () => { wsRef.current?.close(); wsRef.current = null }
47 + const send = () => { if (wsRef.current?.readyState === 1 && sub.trim()) { wsRef.current.send(sub); push('out', sub) } }
48 +
49 + const snippets = wsSnippet({ hasKey: !!apiKey })
50 + if (!apiKey) {
51 + return (
52 + <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>}>
55 + Browsers cannot set an <code>Authorization</code> header on a WebSocket, so <code>/v1/stream</code> authenticates with <code>?api_key=</code>.
56 + 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 + </div>
62 + )
63 + }
64 + return (
65 + <div className="pg-ws">
66 + <div className="pg-ws-bar">
67 + <span className={`pg-ws-dot ${state}`} aria-hidden="true" />
68 + <span className="pg-ws-state">{state}</span>
69 + <code className="pg-ws-url">wss://…/v1/stream?api_key=$HFMD_API_KEY</code>
70 + {state === 'open' || state === 'connecting'
71 + ? <button type="button" className="btn" onClick={disconnect}>Disconnect</button>
72 + : <button type="button" className="btn btn-primary" onClick={connect}>Connect</button>}
73 + <button type="button" className="btn" onClick={() => setMsgs([])} disabled={!msgs.length}>Clear</button>
74 + </div>
75 + {info && <PgCallout kind="warn">{info}</PgCallout>}
76 + <label className="pg-ws-sub">
77 + <span>Subscribe message (sent on connect; edit and press Send to send again)</span>
78 + <div className="pg-ws-sub-row">
79 + <input className="mono" value={sub} onChange={e => setSub(e.target.value)} aria-label="Subscribe message" />
80 + <button type="button" className="btn" onClick={send} disabled={state !== 'open'}>Send</button>
81 + </div>
82 + </label>
83 + <div className="pg-ws-log mono" ref={logRef} role="log" aria-live="polite" aria-label="Stream messages">
84 + {msgs.length === 0 && <div className="muted">No messages yet.</div>}
85 + {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 + </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>
90 + </div>
91 + )
92 +}
added hfmarketdata/web/src/playground/catalog.js +483 −0
@@ -0,0 +1,483 @@
1 +// Playground catalog — one entry per request type. See PLAYGROUND.md for the schema and the deep-link contract.
2 +// Every entry is data only (no React) so it can be reused by docs "Try it" buttons, code export and tests.
3 +//
4 +// Param types: 'text' | 'symbol' | 'enum' | 'number' | 'date' | 'datetime' | 'bool'
5 +// symbol → autocomplete. `source` = 'asset' (uses the asset param/fixed value) | 'stock' | 'futures_root' | 'options'
6 +// enum → `enum: [...]` (an empty string means "server default"). `enumBy: { paramName: { value: [...] } }` for dependent enums.
7 +// `fixed` = path/query values that are not shown in the form (e.g. asset=crypto for the "Crypto bars" entry).
8 +// `operationIds` = OpenAPI operationIds (FastAPI default naming) that resolve to this entry from a deep link.
9 +
10 +export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
11 +export const INTERVALS = ['1m', '5m', '30m', '1h', '1d']
12 +export const EQUITY_ADJ = ['', 'adj_split', 'adj_splitdiv', 'UNADJUSTED']
13 +export const FUTURES_ADJ = ['', 'contin_UNadj', 'contin_adj_ratio', 'contin_adj_absolute']
14 +const FORMATS = ['json', 'csv']
15 +const FORMATS_V2 = ['json', 'csv', 'parquet']
16 +
17 +const p = (name, type, extra = {}) => ({ name, in: 'query', type, ...extra })
18 +const path = (name, type, extra = {}) => ({ name, in: 'path', type, required: true, ...extra })
19 +
20 +const barsCommon = [
21 + p('timeframe', 'enum', { enum: TIMEFRAMES, default: '1day', help: 'Bar resolution.' }),
22 + p('start', 'datetime', { placeholder: '2024-06-03 09:30:00', help: 'ISO lower bound (date or date-time, US/Eastern for intraday).' }),
23 + p('end', 'datetime', { placeholder: '2024-06-07', help: 'ISO upper bound.' }),
24 + p('order', 'enum', { enum: ['asc', 'desc'], default: 'asc' }),
25 + p('limit', 'number', { default: 500, min: 1, placeholder: '5000', help: 'Max rows (JSON cap 50 000, CSV cap 2 000 000).' }),
26 + p('format', 'enum', { enum: FORMATS, default: 'json' }),
27 +]
28 +
29 +const v2Common = [
30 + p('interval', 'enum', { enum: INTERVALS, default: '1d', help: 'Alias of timeframe: 1m · 5m · 30m · 1h · 1d.' }),
31 + p('from', 'date', { help: 'Inclusive lower bound (UTC).' }),
32 + p('to', 'date', { help: 'Inclusive upper bound (UTC).' }),
33 + p('session', 'enum', { enum: ['', 'rth', 'eth', 'all'], help: 'Regular hours, extended hours or everything.' }),
34 + p('limit', 'number', { default: 1000, min: 1, placeholder: '5000' }),
35 + p('cursor', 'text', { help: 'Opaque cursor from meta.next_cursor.' }),
36 + p('format', 'enum', { enum: FORMATS_V2, default: 'json', help: 'Parquet counts half the rows against your quota.' }),
37 +]
38 +
39 +const barsEntry = (id, title, asset, description, symbolDefault, extraFixed = {}) => ({
40 + id, group: 'Bars', title, method: 'GET', path: '/v1/bars/{asset}/{ticker}',
41 + operationIds: ['bars_v1_bars__asset___ticker__get'],
42 + description,
43 + fixed: { asset, ...extraFixed },
44 + params: [
45 + path('ticker', 'symbol', { source: 'asset', default: symbolDefault, placeholder: symbolDefault, help: 'Instrument symbol.' }),
46 + ...barsCommon,
47 + ],
48 + result: { kind: 'bars', timeKey: 'datetime' },
49 +})
50 +
51 +export const CATALOG = [
52 + // ---------------------------------------------------------------- Bars (v1)
53 + {
54 + id: 'stock-bars', group: 'Bars', title: 'Stock / ETF bars', method: 'GET', path: '/v1/bars/{asset}/{ticker}',
55 + operationIds: ['bars_v1_bars__asset___ticker__get'],
56 + description: 'OHLCV bars for one US equity or ETF, 1-minute to daily, with split / split+dividend / unadjusted variants.',
57 + params: [
58 + path('asset', 'enum', { enum: ['stock', 'etf'], default: 'stock' }),
59 + path('ticker', 'symbol', { source: 'asset', default: 'AAPL', placeholder: 'AAPL' }),
60 + p('adjustment', 'enum', { enum: EQUITY_ADJ, help: 'Empty = server default (adj_splitdiv).' }),
61 + ...barsCommon,
62 + ],
63 + result: { kind: 'bars', timeKey: 'datetime' },
64 + },
65 + barsEntry('crypto-bars', 'Crypto bars', 'crypto', 'OHLCV bars for major cryptocurrency pairs (BTCUSD, ETHUSD…).', 'BTCUSD'),
66 + barsEntry('fx-bars', 'FX bars', 'fx', 'OHLCV bars for foreign-exchange pairs (EURUSD, USDJPY…).', 'EURUSD'),
67 + barsEntry('index-bars', 'Index bars', 'index', 'OHLCV bars for equity and volatility indices (SPX, VIX…).', 'SPX'),
68 + {
69 + id: 'futures-continuous-legacy', group: 'Futures', title: 'Futures continuous (legacy)', method: 'GET', path: '/v1/bars/{asset}/{ticker}',
70 + operationIds: [],
71 + description: 'Front-month continuous series from the v1 bars endpoint: unadjusted, ratio-adjusted or absolute-adjusted rolls.',
72 + fixed: { asset: 'futures' },
73 + params: [
74 + path('ticker', 'symbol', { source: 'futures_root', default: 'ES', placeholder: 'ES', help: 'Futures root (ES, CL, NG…).' }),
75 + p('adjustment', 'enum', { enum: FUTURES_ADJ, default: 'contin_adj_ratio' }),
76 + ...barsCommon,
77 + ],
78 + result: { kind: 'bars', timeKey: 'datetime' },
79 + },
80 + {
81 + id: 'bars-multi', group: 'Bars', title: 'Multi-ticker bars', method: 'GET', path: '/v1/bars/{asset}',
82 + operationIds: ['bars_multi_v1_bars__asset__get'],
83 + description: 'Bars for up to 50 instruments in one call, rows grouped by ticker; the limit applies per ticker.',
84 + params: [
85 + path('asset', 'enum', { enum: ['stock', 'etf', 'crypto', 'index', 'fx', 'futures'], default: 'stock' }),
86 + p('tickers', 'text', { required: true, default: 'AAPL,MSFT,NVDA', placeholder: 'AAPL,MSFT,NVDA', help: 'Comma-separated, max 50.' }),
87 + p('adjustment', 'enum', { enumBy: { asset: { stock: EQUITY_ADJ, etf: EQUITY_ADJ, futures: FUTURES_ADJ } }, enum: [''] }),
88 + ...barsCommon.map(x => (x.name === 'limit' ? { ...x, default: 50, help: 'Max rows PER TICKER.' } : x)),
89 + ],
90 + result: { kind: 'bars', timeKey: 'datetime', multi: true },
91 + },
92 + {
93 + id: 'snapshot', group: 'Bars', title: 'Point-in-time snapshot', method: 'GET', path: '/v1/snapshot/{asset}',
94 + operationIds: ['snapshot_v1_snapshot__asset__get'],
95 + description: 'The last bar at or before one precise moment, for every instrument of a watchlist.',
96 + params: [
97 + path('asset', 'enum', { enum: ['stock', 'etf', 'crypto', 'index', 'fx', 'futures'], default: 'stock' }),
98 + p('tickers', 'text', { required: true, default: 'AAPL,MSFT,NVDA', placeholder: 'AAPL,MSFT,NVDA' }),
99 + p('at', 'datetime', { required: true, default: '2024-06-03 10:35:00', placeholder: '2024-06-03 10:35:00' }),
100 + p('timeframe', 'enum', { enum: TIMEFRAMES, default: '1min' }),
101 + p('adjustment', 'enum', { enumBy: { asset: { stock: EQUITY_ADJ, etf: EQUITY_ADJ, futures: FUTURES_ADJ } }, enum: [''] }),
102 + p('format', 'enum', { enum: FORMATS, default: 'json' }),
103 + ],
104 + result: { kind: 'table' },
105 + },
106 + // ---------------------------------------------------------------- Futures v2
107 + {
108 + id: 'futures-continuous', group: 'Futures', title: 'Futures continuous (v2)', method: 'GET', path: '/v1/futures/{root}/continuous',
109 + operationIds: ['futures_continuous_v1_futures__root__continuous_get', 'continuous_v1_futures__root__continuous_get'],
110 + description: 'Continuous series built from individual contracts with a configurable roll rule, adjustment and depth. `meta.roll_dates` lists every roll.',
111 + params: [
112 + path('root', 'symbol', { source: 'futures_root', default: 'ES', placeholder: 'ES' }),
113 + p('roll', 'enum', { enum: ['', 'volume', 'open_interest', 'calendar', 'first_notice'], default: 'volume', help: 'When to switch to the next contract.' }),
114 + p('adjust', 'enum', { enum: ['', 'none', 'back_adjusted', 'ratio'], default: 'back_adjusted', help: 'Price adjustment at each roll.' }),
115 + p('depth', 'number', { default: 1, min: 1, max: 12, help: '1 = front month, 2 = second month…' }),
116 + ...v2Common,
117 + ],
118 + result: { kind: 'bars', timeKey: 'datetime', envelope: 'v2' },
119 + },
120 + {
121 + id: 'futures-contract-bars', group: 'Futures', title: 'Individual contract bars', method: 'GET', path: '/v1/futures/contract/{symbol}/bars',
122 + operationIds: ['contract_bars_v1_futures_contract__symbol__bars_get', 'futures_contract_bars_v1_futures_contract__symbol__bars_get'],
123 + description: 'Bars for one expiry contract (ESZ25, CLM24…). Long form ESZ2025 is accepted.',
124 + params: [
125 + path('symbol', 'text', { default: 'ESZ24', placeholder: 'ESZ24', pattern: '^[A-Za-z0-9]{2,4}[FGHJKMNQUVXZ]\\d{2}(\\d{2})?$', help: 'Root + month code + 2- or 4-digit year.' }),
126 + ...v2Common,
127 + ],
128 + result: { kind: 'bars', timeKey: 'datetime', envelope: 'v2' },
129 + },
130 + {
131 + id: 'futures-contract-coverage', group: 'Futures', title: 'Contract coverage', method: 'GET', path: '/v1/futures/contract/{symbol}/coverage',
132 + operationIds: ['contract_coverage_v1_futures_contract__symbol__coverage_get', 'futures_contract_coverage_v1_futures_contract__symbol__coverage_get'],
133 + description: 'Date range, timeframes and gaps available for one contract.',
134 + params: [path('symbol', 'text', { default: 'ESZ24', placeholder: 'ESZ24' })],
135 + result: { kind: 'object' },
136 + },
137 + {
138 + id: 'futures-contracts', group: 'Futures', title: 'Contracts of a root', method: 'GET', path: '/v1/futures/{root}/contracts',
139 + operationIds: ['contracts_v1_futures__root__contracts_get', 'futures_contracts_v1_futures__root__contracts_get'],
140 + description: 'Every contract known for a root with expiration, status, data range and liquidity.',
141 + params: [
142 + path('root', 'symbol', { source: 'futures_root', default: 'CL', placeholder: 'CL' }),
143 + p('status', 'enum', { enum: ['', 'active', 'expired'] }),
144 + p('from', 'date', { help: 'Expiration ≥' }),
145 + p('to', 'date', { help: 'Expiration ≤' }),
146 + p('limit', 'number', { default: 200, min: 1 }),
147 + p('cursor', 'text'),
148 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
149 + ],
150 + result: { kind: 'table', envelope: 'v2' },
151 + },
152 + {
153 + id: 'futures-chain', group: 'Futures', title: 'Futures contract chain', method: 'GET', path: '/v1/futures/{root}/chain',
154 + operationIds: ['chain_v1_futures__root__chain_get', 'futures_chain_v1_futures__root__chain_get'],
155 + description: 'Contracts listed at a given date, ordered by expiration, with last price, volume and open interest.',
156 + params: [
157 + path('root', 'symbol', { source: 'futures_root', default: 'CL', placeholder: 'CL' }),
158 + p('as_of', 'date', { help: 'Defaults to the latest session.' }),
159 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
160 + ],
161 + result: { kind: 'table', envelope: 'v2' },
162 + },
163 + {
164 + id: 'futures-term-structure', group: 'Futures', title: 'Term structure', method: 'GET', path: '/v1/futures/{root}/term-structure',
165 + operationIds: ['term_structure_v1_futures__root__term_structure_get', 'futures_term_structure_v1_futures__root__term_structure_get'],
166 + description: 'Settlement price per expiration on one date — the forward curve.',
167 + params: [
168 + path('root', 'symbol', { source: 'futures_root', default: 'NG', placeholder: 'NG' }),
169 + p('as_of', 'date'),
170 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
171 + ],
172 + result: { kind: 'series', timeKey: 'expiration_date', valueKeys: ['close', 'settle', 'price', 'last'], envelope: 'v2' },
173 + },
174 + {
175 + id: 'futures-roots', group: 'Symbols', title: 'Futures roots', method: 'GET', path: '/v1/futures/roots',
176 + operationIds: ['roots_v1_futures_roots_get', 'futures_roots_v1_futures_roots_get'],
177 + description: 'Reference table of the 142 futures products: exchange, asset class, tick size, contract size, data range.',
178 + params: [
179 + p('asset_class', 'enum', { enum: ['', 'equity_index', 'energy', 'metals', 'rates', 'ags', 'fx', 'crypto', 'volatility', 'softs', 'livestock'] }),
180 + p('search', 'text', { placeholder: 'crude' }),
181 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
182 + ],
183 + result: { kind: 'table', envelope: 'v2' },
184 + },
185 + // ---------------------------------------------------------------- Options
186 + {
187 + id: 'options-chain', group: 'Options', title: 'Options chain (Greeks)', method: 'GET', path: '/v1/options/chain/{ticker}',
188 + operationIds: ['opt_chain_v1_options_chain__ticker__get'],
189 + description: 'Complete end-of-day chain for one underlying and trade date: quotes, IV, open interest, volume and the Greeks.',
190 + params: [
191 + path('ticker', 'symbol', { source: 'options', default: 'AAPL', placeholder: 'AAPL' }),
192 + p('trade_date', 'date', { default: '2024-06-21', help: 'Default: latest available date.' }),
193 + p('expiry', 'date', { help: 'Restrict to one expiry.' }),
194 + p('call_put', 'enum', { enum: ['', 'c', 'p'], default: 'c' }),
195 + p('strike_min', 'number', { default: 200 }),
196 + p('strike_max', 'number', { default: 210 }),
197 + p('min_volume', 'number', { placeholder: '10' }),
198 + p('limit', 'number', { default: 500, min: 1, placeholder: '20000' }),
199 + p('format', 'enum', { enum: FORMATS, default: 'json' }),
200 + ],
201 + result: { kind: 'table' },
202 + },
203 + {
204 + id: 'options-expirations', group: 'Options', title: 'Options expirations', method: 'GET', path: '/v1/options/expirations/{ticker}',
205 + operationIds: ['opt_expirations_v1_options_expirations__ticker__get'],
206 + description: 'Expiry dates available for an underlying, optionally as of one trade date.',
207 + params: [
208 + path('ticker', 'symbol', { source: 'options', default: 'AAPL', placeholder: 'AAPL' }),
209 + p('trade_date', 'date', { default: '2024-06-21' }),
210 + ],
211 + result: { kind: 'list', listKey: 'expirations' },
212 + },
213 + {
214 + id: 'options-history', group: 'Options', title: 'Option contract history', method: 'GET', path: '/v1/options/history/{ticker}',
215 + operationIds: ['opt_history_v1_options_history__ticker__get'],
216 + description: 'Daily life of one contract (strike + expiry + side): price, quotes, IV, OI and Greeks.',
217 + params: [
218 + path('ticker', 'symbol', { source: 'options', default: 'AAPL', placeholder: 'AAPL' }),
219 + p('strike', 'number', { required: true, default: 200 }),
220 + p('expiry', 'date', { required: true, default: '2024-12-20' }),
221 + p('call_put', 'enum', { enum: ['c', 'p'], default: 'c', required: true }),
222 + p('limit', 'number', { default: 500, min: 1 }),
223 + p('format', 'enum', { enum: FORMATS, default: 'json' }),
224 + ],
225 + result: { kind: 'series', timeKey: 'trade_date', valueKeys: ['last_price', 'bid_iv', 'delta'] },
226 + },
227 + {
228 + id: 'options-quarters', group: 'Options', title: 'Options quarters', method: 'GET', path: '/v1/options/quarters',
229 + operationIds: ['opt_quarters_v1_options_quarters_get'],
230 + description: 'Quarterly archives available (2010_q1 → current quarter).',
231 + params: [],
232 + result: { kind: 'list', listKey: 'quarters' },
233 + },
234 + {
235 + id: 'options-tickers', group: 'Options', title: 'Options underlyings', method: 'GET', path: '/v1/options/tickers',
236 + operationIds: ['opt_tickers_v1_options_tickers_get'],
237 + description: 'Underlyings with options data in a quarter.',
238 + params: [
239 + p('quarter', 'text', { placeholder: '2024_q4', pattern: '^\\d{4}_q[1-4]$' }),
240 + p('search', 'text', { default: 'TSL' }),
241 + p('limit', 'number', { placeholder: '10000' }),
242 + ],
243 + result: { kind: 'list', listKey: 'tickers' },
244 + },
245 + // ---------------------------------------------------------------- Symbols / meta
246 + {
247 + id: 'tickers', group: 'Symbols', title: 'Symbols list', method: 'GET', path: '/v1/{asset}/tickers',
248 + operationIds: ['tickers_v1__asset__tickers_get'],
249 + description: 'Every instrument available for an asset type / timeframe / adjustment.',
250 + params: [
251 + path('asset', 'enum', { enum: ['stock', 'etf', 'crypto', 'index', 'fx', 'futures', 'options'], default: 'stock' }),
252 + p('timeframe', 'enum', { enum: TIMEFRAMES, default: '1day' }),
253 + p('adjustment', 'enum', { enumBy: { asset: { stock: EQUITY_ADJ, etf: EQUITY_ADJ, futures: FUTURES_ADJ } }, enum: [''] }),
254 + p('search', 'text', { default: 'AAP', placeholder: 'AAP' }),
255 + p('limit', 'number', { placeholder: '10000' }),
256 + ],
257 + result: { kind: 'list', listKey: 'tickers' },
258 + },
259 + {
260 + id: 'status', group: 'Symbols', title: 'Dataset inventory', method: 'GET', path: '/v1/status',
261 + operationIds: ['status_v1_status_get'],
262 + description: 'Instrument counts per asset type / timeframe / adjustment and the options quarters.',
263 + params: [],
264 + result: { kind: 'object' },
265 + },
266 + {
267 + id: 'limits', group: 'Symbols', title: 'My limits', method: 'GET', path: '/v1/limits',
268 + operationIds: ['limits_v1_limits_get'],
269 + description: 'Tier and remaining quota of the current principal (IP or key).',
270 + params: [],
271 + result: { kind: 'object' },
272 + },
273 + // ---------------------------------------------------------------- Fundamentals
274 + {
275 + id: 'fundamentals-statements', group: 'Fundamentals', title: 'Financial statements', method: 'GET', path: '/v1/fundamentals/{ticker}/statements',
276 + operationIds: ['statements_v1_fundamentals__ticker__statements_get', 'fundamentals_statements_v1_fundamentals__ticker__statements_get'],
277 + description: 'Income statement, balance sheet and cash-flow statement from SEC EDGAR (XBRL), point-in-time.',
278 + params: [
279 + path('ticker', 'symbol', { source: 'stock', default: 'AAPL', placeholder: 'AAPL' }),
280 + p('statement', 'enum', { enum: ['', 'income', 'balance', 'cashflow'], default: 'income' }),
281 + p('period', 'enum', { enum: ['', 'quarterly', 'annual'], default: 'quarterly' }),
282 + p('from', 'date'), p('to', 'date'),
283 + p('limit', 'number', { default: 40, min: 1 }),
284 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
285 + ],
286 + result: { kind: 'table', envelope: 'v2' },
287 + },
288 + {
289 + id: 'fundamentals-ratios', group: 'Fundamentals', title: 'Ratios (per filing)', method: 'GET', path: '/v1/fundamentals/{ticker}/ratios',
290 + operationIds: ['ratios_v1_fundamentals__ticker__ratios_get', 'fundamentals_ratios_v1_fundamentals__ticker__ratios_get'],
291 + description: 'Valuation, profitability, leverage and liquidity ratios computed at each filing.',
292 + params: [
293 + path('ticker', 'symbol', { source: 'stock', default: 'AAPL', placeholder: 'AAPL' }),
294 + p('period', 'enum', { enum: ['', 'quarterly', 'annual'], default: 'quarterly' }),
295 + p('from', 'date'), p('to', 'date'),
296 + p('limit', 'number', { default: 40, min: 1 }),
297 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
298 + ],
299 + result: { kind: 'table', envelope: 'v2' },
300 + },
301 + {
302 + id: 'fundamentals-ratios-daily', group: 'Fundamentals', title: 'Ratios daily', method: 'GET', path: '/v1/fundamentals/{ticker}/ratios/daily',
303 + operationIds: ['ratios_daily_v1_fundamentals__ticker__ratios_daily_get', 'fundamentals_ratios_daily_v1_fundamentals__ticker__ratios_daily_get'],
304 + description: 'Market-based ratios (P/E, P/B, EV/EBITDA, yield…) recomputed every trading day from the latest filing and the daily close.',
305 + params: [
306 + path('ticker', 'symbol', { source: 'stock', default: 'AAPL', placeholder: 'AAPL' }),
307 + p('from', 'date', { default: '2024-01-01' }), p('to', 'date'),
308 + p('limit', 'number', { default: 1000, min: 1 }),
309 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
310 + ],
311 + result: { kind: 'series', timeKey: 'date', valueKeys: ['pe', 'pb', 'ev_ebitda', 'dividend_yield'], envelope: 'v2' },
312 + },
313 + {
314 + id: 'fundamentals-screener', group: 'Fundamentals', title: 'Screener', method: 'GET', path: '/v1/fundamentals/screener',
315 + operationIds: ['screener_v1_fundamentals_screener_get', 'fundamentals_screener_v1_fundamentals_screener_get'],
316 + description: 'Filter the whole universe on ratios as of a date. Costs 2 requests.',
317 + params: [
318 + p('filter', 'text', { default: 'pe<15,roe>0.15', placeholder: 'pe<15,roe>0.15', help: 'Comma-separated conditions: field<op>value (op: < <= > >= =).' }),
319 + p('as_of', 'date'),
320 + p('sort', 'text', { placeholder: '-market_cap', help: 'Field name, prefix - for descending.' }),
321 + p('limit', 'number', { default: 100, min: 1 }),
322 + p('cursor', 'text'),
323 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
324 + ],
325 + result: { kind: 'table', envelope: 'v2' },
326 + },
327 + {
328 + id: 'fundamentals-frames', group: 'Fundamentals', title: 'Frames (one concept, all companies)', method: 'GET', path: '/v1/fundamentals/frames/{concept}',
329 + operationIds: ['frames_v1_fundamentals_frames__concept__get', 'fundamentals_frames_v1_fundamentals_frames__concept__get'],
330 + description: 'One XBRL concept across every filer for a fiscal period (EDGAR frames). Costs 2 requests.',
331 + params: [
332 + path('concept', 'text', { default: 'Revenues', placeholder: 'Revenues', help: 'us-gaap concept name.' }),
333 + p('period', 'text', { default: 'CY2024Q1', placeholder: 'CY2024Q1', pattern: '^CY\\d{4}(Q[1-4]I?)?$', help: 'CY2024, CY2024Q1 or CY2024Q1I (instant).' }),
334 + p('limit', 'number', { default: 500, min: 1 }),
335 + p('cursor', 'text'),
336 + p('format', 'enum', { enum: FORMATS_V2, default: 'json' }),
337 + ],
338 + result: { kind: 'table', envelope: 'v2' },
339 + },
340 + // ---------------------------------------------------------------- Stream
341 + {
342 + id: 'stream', group: 'Stream', title: 'WebSocket stream', method: 'WS', path: '/v1/stream',
343 + operationIds: ['stream_v1_stream_get'],
344 + description: 'Live filings stream. Requires an API key (passed as ?api_key= because browsers cannot set WebSocket headers).',
345 + params: [],
346 + result: { kind: 'ws' },
347 + requiresKey: true,
348 + },
349 +]
350 +
351 +export const GROUPS = ['Bars', 'Futures', 'Options', 'Fundamentals', 'Symbols', 'Stream']
352 +
353 +export const byId = id => CATALOG.find(e => e.id === id)
354 +
355 +/** Resolve a catalog entry from a catalog id or an OpenAPI operationId (+ query values to disambiguate fixed params). */
356 +export function findEntry(ref, values = {}) {
357 + if (!ref) return null
358 + const direct = byId(ref)
359 + if (direct) return direct
360 + const candidates = CATALOG.filter(e => e.operationIds?.includes(ref))
361 + return pickCandidate(candidates, values)
362 +}
363 +
364 +/** Resolve by method + path template (used after looking the operationId up in /openapi.json). */
365 +export function findByPath(method, pathTemplate, values = {}) {
366 + const m = (method || 'GET').toUpperCase()
367 + const candidates = CATALOG.filter(e => e.method === m && e.path === pathTemplate)
368 + return pickCandidate(candidates, values)
369 +}
370 +
371 +function pickCandidate(candidates, values) {
372 + if (!candidates.length) return null
373 + const exact = candidates.find(e => e.fixed && Object.entries(e.fixed).every(([k, v]) => values[k] === v))
374 + if (exact) return exact
375 + const noFixedConflict = candidates.find(e => !e.fixed || Object.keys(e.fixed).every(k => values[k] === undefined))
376 + return noFixedConflict || candidates[0]
377 +}
378 +
379 +/** Effective enum for a param given the current values (dependent enums). */
380 +export function enumFor(param, values) {
381 + if (param.enumBy) {
382 + for (const [dep, table] of Object.entries(param.enumBy)) {
383 + const list = table[values[dep]]
384 + if (list) return list
385 + }
386 + return param.enum || ['']
387 + }
388 + return param.enum || []
389 +}
390 +
391 +export function defaultValues(entry) {
392 + const v = {}
393 + for (const prm of entry.params) v[prm.name] = prm.default !== undefined ? String(prm.default) : ''
394 + return v
395 +}
396 +
397 +/** Build the relative URL (path + query) for an entry and its values. */
398 +export function buildPath(entry, values) {
399 + const all = { ...(entry.fixed || {}), ...values }
400 + let out = entry.path
401 + const query = []
402 + const pathNames = new Set()
403 + out = out.replace(/\{(\w+)\}/g, (_, name) => {
404 + pathNames.add(name)
405 + const v = (all[name] ?? '').toString().trim()
406 + return v ? encodeURIComponent(v) : `{${name}}`
407 + })
408 + for (const prm of entry.params) {
409 + if (prm.in !== 'query') continue
410 + const v = (values[prm.name] ?? '').toString().trim()
411 + if (v !== '') query.push(`${prm.name}=${encodeURIComponent(v).replace(/%2C/g, ',').replace(/%3A/g, ':').replace(/%20/g, '+')}`)
412 + }
413 + for (const [k, v] of Object.entries(entry.fixed || {})) {
414 + if (!pathNames.has(k) && !entry.params.some(x => x.name === k)) query.push(`${k}=${encodeURIComponent(v)}`)
415 + }
416 + return out + (query.length ? '?' + query.join('&') : '')
417 +}
418 +
419 +/** Validation: returns { field: message }. */
420 +export function validate(entry, values) {
421 + const errors = {}
422 + for (const prm of entry.params) {
423 + const raw = (values[prm.name] ?? '').toString().trim()
424 + if (prm.required && raw === '') { errors[prm.name] = 'Required'; continue }
425 + if (raw === '') continue
426 + if (prm.type === 'number') {
427 + const n = Number(raw)
428 + if (!Number.isFinite(n)) errors[prm.name] = 'Must be a number'
429 + else if (prm.min !== undefined && n < prm.min) errors[prm.name] = `Must be ≥ ${prm.min}`
430 + else if (prm.max !== undefined && n > prm.max) errors[prm.name] = `Must be ≤ ${prm.max}`
431 + } else if (prm.type === 'date') {
432 + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) errors[prm.name] = 'Use YYYY-MM-DD'
433 + } else if (prm.type === 'datetime') {
434 + if (!/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$/.test(raw)) errors[prm.name] = 'Use YYYY-MM-DD or YYYY-MM-DD HH:MM:SS'
435 + } else if (prm.pattern) {
436 + if (!new RegExp(prm.pattern).test(raw)) errors[prm.name] = 'Invalid format'
437 + }
438 + const en = prm.type === 'enum' ? enumFor(prm, values) : null
439 + if (en && en.length && !en.includes(raw)) errors[prm.name] = 'Not an allowed value'
440 + }
441 + return errors
442 +}
443 +
444 +/** Build a catalog-like entry from an OpenAPI operation (fallback for unknown operationIds). */
445 +export function entryFromSpec(operationId, spec) {
446 + if (!spec?.paths) return null
447 + for (const [pth, ops] of Object.entries(spec.paths)) {
448 + for (const [method, op] of Object.entries(ops)) {
449 + if (op?.operationId !== operationId) continue
450 + const params = (op.parameters || []).map(prm => {
451 + const sch = prm.schema || {}
452 + const enumVals = sch.enum || (sch.pattern && /^\^\(([\w|]+)\)\$$/.test(sch.pattern) ? sch.pattern.slice(2, -2).split('|') : null)
453 + let type = 'text'
454 + if (enumVals) type = 'enum'
455 + else if (['integer', 'number'].includes(sch.type)) type = 'number'
456 + else if (sch.format === 'date') type = 'date'
457 + else if (sch.format === 'date-time') type = 'datetime'
458 + return {
459 + name: prm.name, in: prm.in === 'path' ? 'path' : 'query', type, required: !!prm.required,
460 + enum: enumVals ? (prm.required ? enumVals : ['', ...enumVals]) : undefined,
461 + default: sch.default !== undefined ? sch.default : (prm.in === 'path' ? '' : undefined),
462 + help: prm.description || sch.description || '', placeholder: sch.example !== undefined ? String(sch.example) : '',
463 + }
464 + }).filter(prm => prm.in === 'path' || prm.in === 'query')
465 + return {
466 + id: `spec:${operationId}`, group: op.tags?.[0] || 'Other', title: op.summary || operationId, method: method.toUpperCase(),
467 + path: pth, operationIds: [operationId], description: op.description || '', params, result: { kind: 'auto' }, fromSpec: true,
468 + }
469 + }
470 + }
471 + return null
472 +}
473 +
474 +/** Find the path template + method of an operationId in the spec. */
475 +export function specOperation(operationId, spec) {
476 + if (!spec?.paths) return null
477 + for (const [pth, ops] of Object.entries(spec.paths)) {
478 + for (const [method, op] of Object.entries(ops)) {
479 + if (op?.operationId === operationId) return { path: pth, method: method.toUpperCase(), op }
480 + }
481 + }
482 + return null
483 +}
added hfmarketdata/web/src/playground/codegen.js +81 −0
@@ -0,0 +1,81 @@
1 +// Code export for the current request. The real key is never emitted: `$HFMD_API_KEY` / env lookups only.
2 +import { PUBLIC_BASE } from '../app/api.js'
3 +
4 +const KEY_PLACEHOLDER = '$HFMD_API_KEY'
5 +
6 +export function curlSnippet(relUrl, { hasKey, format = 'json' } = {}) {
7 + const lines = [`curl "${PUBLIC_BASE}${relUrl}"`]
8 + if (hasKey) lines.push(` -H "Authorization: Bearer ${KEY_PLACEHOLDER}"`)
9 + if (format === 'csv') lines.push(' -o data.csv')
10 + if (format === 'parquet') lines.push(' -o data.parquet')
11 + return lines.join(' \\\n')
12 +}
13 +
14 +export function pythonSnippet(relUrl, { hasKey, format = 'json', envelope } = {}) {
15 + const headers = hasKey ? `\nheaders = {"Authorization": f"Bearer {os.environ['HFMD_API_KEY']}"}` : ''
16 + const hdrArg = hasKey ? ', headers=headers' : ''
17 + const imports = `import ${hasKey ? 'os, ' : ''}requests\nimport pandas as pd`
18 + if (format === 'csv') {
19 + return `${imports}
20 +import io${headers}
21 +
22 +url = "${PUBLIC_BASE}${relUrl}"
23 +r = requests.get(url${hdrArg})
24 +r.raise_for_status()
25 +df = pd.read_csv(io.StringIO(r.text))
26 +print(df.shape, r.headers.get("X-RateLimit-Remaining-Requests"))`
27 + }
28 + if (format === 'parquet') {
29 + return `${imports}
30 +import io${headers}
31 +
32 +url = "${PUBLIC_BASE}${relUrl}"
33 +r = requests.get(url${hdrArg})
34 +r.raise_for_status()
35 +df = pd.read_parquet(io.BytesIO(r.content))
36 +print(df.shape)`
37 + }
38 + return `${imports}${headers}
39 +
40 +url = "${PUBLIC_BASE}${relUrl}"
41 +r = requests.get(url${hdrArg})
42 +r.raise_for_status()
43 +payload = r.json()
44 +df = pd.DataFrame(payload["data"])${envelope === 'v2' ? '\nmeta = payload.get("meta", {}) # count, next_cursor, roll_dates…' : ''}
45 +print(df.head(), r.headers.get("X-RateLimit-Remaining-Requests"))`
46 +}
47 +
48 +export function jsSnippet(relUrl, { hasKey, format = 'json' } = {}) {
49 + const headers = hasKey ? `, {\n headers: { Authorization: \`Bearer \${process.env.HFMD_API_KEY}\` },\n}` : ''
50 + if (format === 'csv' || format === 'parquet') {
51 + return `const res = await fetch("${PUBLIC_BASE}${relUrl}"${headers});
52 +if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
53 +const bytes = await res.arrayBuffer(); // write to data.${format}
54 +console.log(bytes.byteLength, res.headers.get("x-row-count"));`
55 + }
56 + return `const res = await fetch("${PUBLIC_BASE}${relUrl}"${headers});
57 +if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
58 +const { data, meta } = await res.json();
59 +console.log(data.length, res.headers.get("x-ratelimit-remaining-requests"));`
60 +}
61 +
62 +export function wsSnippet({ hasKey }) {
63 + const key = hasKey ? KEY_PLACEHOLDER : '<your key>'
64 + return {
65 + js: `const ws = new WebSocket("wss://www.hfmarketdata.io/v1/stream?api_key=${key}");
66 +ws.onopen = () => ws.send(JSON.stringify({ action: "subscribe", channel: "filings" }));
67 +ws.onmessage = (e) => console.log(JSON.parse(e.data));`,
68 + python: `import asyncio, json, os, websockets
69 +
70 +async def main():
71 + url = f"wss://www.hfmarketdata.io/v1/stream?api_key={os.environ['HFMD_API_KEY']}"
72 + async with websockets.connect(url) as ws:
73 + await ws.send(json.dumps({"action": "subscribe", "channel": "filings"}))
74 + async for msg in ws:
75 + print(json.loads(msg))
76 +
77 +asyncio.run(main())`,
78 + curl: `# websocat: https://github.com/vi/websocat
79 +websocat "wss://www.hfmarketdata.io/v1/stream?api_key=${key}"`,
80 + }
81 +}
added hfmarketdata/web/src/playground/playground.css +149 −0
@@ -0,0 +1,149 @@
1 +/* Playground + shared Pg* components. Prefixed pg- to avoid collisions with web-core styles. */
2 +.pg { display: flex; flex-direction: column; gap: 14px; }
3 +.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-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 +.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 +
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: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
24 +.pg-method.ws { 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: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
26 +
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; }
41 +
42 +.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; }
47 +.pg-symbol-list li.active { background: var(--bg-2); }
48 +
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); }
52 +.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: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
59 +.pg-code-badge.bad { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); }
60 +.pg-err-details { margin: 8px 0 0; font-size: 12px; }
61 +
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 +.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; }
75 +.pg-empty { padding: 20px 12px; margin: 0; }
76 +.pg-text { margin: 0; border: 0; border-radius: 0; max-height: 460px; font-size: 12px; }
77 +
78 +.pg-json-tree { padding: 10px 12px; font-size: 12.5px; line-height: 1.55; max-height: 520px; overflow: auto; }
79 +.pg-j-line { white-space: pre; }
80 +.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; }
85 +.pg-j-null { color: var(--fg-2); font-style: italic; }
86 +.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; }
88 +
89 +.pg-table-wrap { overflow: auto; position: relative; }
90 +.pg-table { font-size: 12.5px; border-collapse: separate; border-spacing: 0; }
91 +.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; }
93 +.pg-th-btn:hover { color: var(--fg); }
94 +.pg-th-idx, .pg-td-idx { width: 48px; text-align: right; font-size: 11px; }
95 +.pg-table td { padding: 0 10px; white-space: nowrap; border-bottom: 1px solid var(--line); }
96 +.pg-table td.num { text-align: right; }
97 +
98 +.pg-chart-canvas { width: 100%; }
99 +.pg-chart-controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
100 +.pg-chart-controls label { display: inline-flex; gap: 6px; align-items: center; }
101 +.pg-chart-controls select { padding: 3px 6px; font-size: 12px; }
102 +.pg-chart-note { font-size: 11px; margin: 0; padding: 6px 12px; }
103 +.pg-download { padding: 20px 14px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
104 +
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; }
108 +.pg-rl-label { display: flex; justify-content: space-between; color: var(--fg-1); }
109 +.pg-rl-bar { height: 6px; border-radius: 3px; background: var(--bg-2); overflow: hidden; }
110 +.pg-rl-fill { height: 100%; background: var(--accent); transition: width .3s; }
111 +.pg-rl-fill.warn { background: var(--warn); }
112 +.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: 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: 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: 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: 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: 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; }
133 +
134 +.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); }
136 +.pg-ws-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fg-2); }
137 +.pg-ws-dot.open { background: var(--accent); }
138 +.pg-ws-dot.connecting { background: var(--warn); }
139 +.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); }
143 +.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; }
146 +.pg-ws-msg { white-space: pre-wrap; overflow-wrap: anywhere; }
147 +.pg-ws-msg.in .pg-ws-dir { color: var(--accent); }
148 +.pg-ws-msg.out .pg-ws-dir { color: var(--accent-2); }
149 +.pg-ws-msg.sys { color: var(--fg-2); }
added hfmarketdata/web/src/playground/presets.js +11 −0
@@ -0,0 +1,11 @@
1 +// One-click presets: { label, ep (catalog id), values }. `values` are merged over the entry defaults.
2 +const today = () => new Date().toISOString().slice(0, 10)
3 +
4 +export const PRESETS = [
5 + { id: 'aapl-1min-today', label: 'AAPL 1-min today', ep: 'stock-bars', values: () => ({ asset: 'stock', ticker: 'AAPL', timeframe: '1min', start: today(), end: '', limit: '500' }) },
6 + { id: 'cl-chain', label: 'CL chain', ep: 'futures-chain', values: () => ({ root: 'CL', as_of: '' }) },
7 + { id: 'es-continuous', label: 'ES continuous back-adjusted 2015-2025', ep: 'futures-continuous', values: () => ({ root: 'ES', roll: 'volume', adjust: 'back_adjusted', depth: '1', interval: '1d', from: '2015-01-01', to: '2025-12-31', limit: '5000' }) },
8 + { id: 'ng-term', label: 'NG term structure', ep: 'futures-term-structure', values: () => ({ root: 'NG', as_of: '' }) },
9 + { id: 'aapl-income', label: 'AAPL income statements (quarterly)', ep: 'fundamentals-statements', values: () => ({ ticker: 'AAPL', statement: 'income', period: 'quarterly', limit: '40' }) },
10 + { id: 'screener-value', label: 'Screener: PE<15 & ROE>15%', ep: 'fundamentals-screener', values: () => ({ filter: 'pe<15,roe>0.15', sort: '-market_cap', limit: '100' }) },
11 +]
added hfmarketdata/web/src/playground/views/ChartView.jsx +111 −0
@@ -0,0 +1,111 @@
1 +// Chart view: candlesticks when OHLC is present, otherwise a line on one numeric column. Roll dates → markers.
2 +import React, { useEffect, useMemo, useRef, useState } from 'react'
3 +import { createChart, ColorType, CrosshairMode } from 'lightweight-charts'
4 +
5 +const TIME_KEYS = ['datetime', 'trade_date', 'date', 'timestamp', 'ts', 'expiration_date', 'expiry', 'as_of', 'period_end', 'fiscal_period_end']
6 +
7 +export function detectTimeKey(rows, hint) {
8 + const sample = rows.find(r => r && typeof r === 'object') || {}
9 + if (hint && hint in sample) return hint
10 + return TIME_KEYS.find(k => k in sample) || null
11 +}
12 +
13 +export function numericColumns(rows, exclude = []) {
14 + const sample = rows.slice(0, 50)
15 + const cols = new Set()
16 + for (const r of sample) for (const [k, v] of Object.entries(r || {})) if (typeof v === 'number' && !exclude.includes(k)) cols.add(k)
17 + return [...cols]
18 +}
19 +
20 +const hasOHLC = rows => { const r = rows.find(Boolean) || {}; return ['open', 'high', 'low', 'close'].every(k => typeof r[k] === 'number') }
21 +
22 +/** Convert a date/datetime string to a lightweight-charts time. Intraday naive timestamps are treated as UTC for display. */
23 +export function toTime(v) {
24 + if (v == null) return null
25 + if (typeof v === 'number') return v > 1e12 ? Math.floor(v / 1000) : v
26 + const s = String(v)
27 + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s
28 + const t = Date.parse(s.includes('T') || /[zZ]|[+-]\d{2}:\d{2}$/.test(s) ? s : s.replace(' ', 'T') + 'Z')
29 + return Number.isFinite(t) ? Math.floor(t / 1000) : null
30 +}
31 +
32 +function dedupeSorted(points) {
33 + points.sort((a, b) => (typeof a.time === 'number' ? a.time - b.time : String(a.time).localeCompare(String(b.time))))
34 + const out = []
35 + for (const pt of points) { if (out.length && String(out[out.length - 1].time) === String(pt.time)) out[out.length - 1] = pt; else out.push(pt) }
36 + return out
37 +}
38 +
39 +export default function ChartView({ rows, meta, hint = {}, height = 420 }) {
40 + const timeKey = useMemo(() => detectTimeKey(rows, hint.timeKey), [rows, hint.timeKey])
41 + const ohlc = useMemo(() => hasOHLC(rows), [rows])
42 + const numCols = useMemo(() => numericColumns(rows, ['open', 'high', 'low', 'volume', 'open_interest']), [rows])
43 + const [col, setCol] = useState(() => (hint.valueKeys || []).find(k => numCols.includes(k)) || numCols.find(k => k === 'close') || numCols[0])
44 + const [mode, setMode] = useState(ohlc ? 'candles' : 'line')
45 + const ref = useRef(null)
46 + const [msg, setMsg] = useState('')
47 +
48 + useEffect(() => {
49 + setMode(ohlc ? 'candles' : 'line')
50 + setCol((hint.valueKeys || []).find(k => numCols.includes(k)) || numCols.find(k => k === 'close') || numCols[0])
51 + }, [rows, ohlc, numCols, hint.valueKeys])
52 +
53 + useEffect(() => {
54 + const el = ref.current
55 + if (!el) return undefined
56 + if (!timeKey) { setMsg('No time column found — nothing to plot.'); return undefined }
57 + const css = getComputedStyle(document.documentElement)
58 + const chart = createChart(el, {
59 + 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 + 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 + })
66 + let series
67 + let points = []
68 + if (mode === 'candles' && ohlc) {
69 + series = chart.addCandlestickSeries({ upColor: '#5ee7a5', downColor: '#ff6b6b', borderVisible: false, wickUpColor: '#5ee7a5', wickDownColor: '#ff6b6b' })
70 + 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 + } else {
72 + series = chart.addLineSeries({ color: '#37b3ff', lineWidth: 2, priceLineVisible: false })
73 + 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 + }
75 + points = dedupeSorted(points)
76 + if (!points.length) { setMsg('No plottable points (need a time column and numeric values).') } else { setMsg('') }
77 + series.setData(points)
78 + const rolls = meta?.roll_dates
79 + if (Array.isArray(rolls) && rolls.length) {
80 + const times = new Set(points.map(pt => String(pt.time)))
81 + const markers = rolls.map(rd => {
82 + const d = typeof rd === 'string' ? rd : rd?.date || rd?.datetime || rd?.roll_date
83 + 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' }
85 + }).filter(m => m && (times.has(String(m.time)) || typeof m.time === 'string'))
86 + try { series.setMarkers(dedupeSorted(markers)) } catch { /* marker outside range */ }
87 + }
88 + chart.timeScale().fitContent()
89 + return () => chart.remove()
90 + }, [rows, meta, timeKey, col, mode, ohlc, height])
91 +
92 + if (!rows.length) return <p className="muted pg-empty">No rows to chart.</p>
93 + return (
94 + <div className="pg-chart">
95 + <div className="pg-view-toolbar">
96 + <div className="pg-chart-controls">
97 + {ohlc && (
98 + <label>Mode <select value={mode} onChange={e => setMode(e.target.value)}><option value="candles">Candlesticks</option><option value="line">Line</option></select></label>
99 + )}
100 + {(mode === 'line') && numCols.length > 0 && (
101 + <label>Series <select value={col || ''} onChange={e => setCol(e.target.value)}>{numCols.map(c => <option key={c} value={c}>{c}</option>)}</select></label>
102 + )}
103 + <span className="muted">x: <span className="mono">{timeKey || '—'}</span>{Array.isArray(meta?.roll_dates) && ` · ${meta.roll_dates.length} roll markers`}</span>
104 + </div>
105 + </div>
106 + {msg && <p className="muted pg-empty">{msg}</p>}
107 + <div ref={ref} className="pg-chart-canvas" style={{ height }} role="img" aria-label={`${mode === 'candles' ? 'Candlestick' : 'Line'} chart of ${rows.length} rows`} />
108 + <p className="muted pg-chart-note">Intraday timestamps are US/Eastern in the data and shown as-is (no timezone conversion).</p>
109 + </div>
110 + )
111 +}
added hfmarketdata/web/src/playground/views/JsonView.jsx +66 −0
@@ -0,0 +1,66 @@
1 +// Collapsible, syntax-coloured JSON tree. Large arrays are paginated (PAGE children at a time) to stay responsive.
2 +import React, { useState } from 'react'
3 +import PgCopyButton from '../../components/PgCopyButton.jsx'
4 +
5 +const PAGE = 100
6 +
7 +function Scalar({ v }) {
8 + if (v === null) return <span className="pg-j-null">null</span>
9 + switch (typeof v) {
10 + case 'string': return <span className="pg-j-str">"{v}"</span>
11 + case 'number': return <span className="pg-j-num">{String(v)}</span>
12 + case 'boolean': return <span className="pg-j-bool">{String(v)}</span>
13 + default: return <span>{String(v)}</span>
14 + }
15 +}
16 +
17 +function Node({ k, v, depth, defaultOpen }) {
18 + const isObj = v !== null && typeof v === 'object'
19 + const isArr = Array.isArray(v)
20 + const [open, setOpen] = useState(defaultOpen)
21 + const [shown, setShown] = useState(PAGE)
22 + const keyEl = k !== undefined && <span className="pg-j-key">{typeof k === 'number' ? k : `"${k}"`}: </span>
23 + if (!isObj) return <div className="pg-j-line" style={{ paddingLeft: depth * 14 }}>{keyEl}<Scalar v={v} /></div>
24 + const entries = isArr ? v : Object.keys(v)
25 + const n = entries.length
26 + const summary = isArr ? `Array(${n})` : `{${n} ${n === 1 ? 'key' : 'keys'}}`
27 + return (
28 + <div className="pg-j-node">
29 + <div className="pg-j-line" style={{ paddingLeft: depth * 14 }}>
30 + <button type="button" className="pg-j-toggle" aria-expanded={open} onClick={() => setOpen(o => !o)} aria-label={open ? 'Collapse' : 'Expand'}>{open ? '▾' : '▸'}</button>
31 + {keyEl}<span className="pg-j-brace">{isArr ? '[' : '{'}</span>
32 + {!open && <span className="pg-j-summary muted"> {summary} </span>}
33 + {!open && <span className="pg-j-brace">{isArr ? ']' : '}'}</span>}
34 + </div>
35 + {open && (
36 + <>
37 + {(isArr ? entries.slice(0, shown) : entries.slice(0, shown)).map((e, i) => (
38 + isArr ? <Node key={i} k={i} v={e} depth={depth + 1} defaultOpen={depth < 1 && i < 3} />
39 + : <Node key={e} k={e} v={v[e]} depth={depth + 1} defaultOpen={depth < 2} />
40 + ))}
41 + {n > shown && (
42 + <div className="pg-j-line" style={{ paddingLeft: (depth + 1) * 14 }}>
43 + <button type="button" className="pg-j-more" onClick={() => setShown(s => s + PAGE * 5)}>… {n - shown} more (show {Math.min(PAGE * 5, n - shown)})</button>
44 + </div>
45 + )}
46 + <div className="pg-j-line" style={{ paddingLeft: depth * 14 }}><span className="pg-j-brace">{isArr ? ']' : '}'}</span></div>
47 + </>
48 + )}
49 + </div>
50 + )
51 +}
52 +
53 +export default function JsonView({ data }) {
54 + const text = () => JSON.stringify(data, null, 2)
55 + return (
56 + <div className="pg-json">
57 + <div className="pg-view-toolbar">
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 />
60 + </div>
61 + <div className="pg-json-tree mono" tabIndex={0}>
62 + <Node v={data} depth={0} defaultOpen />
63 + </div>
64 + </div>
65 + )
66 +}
added hfmarketdata/web/src/playground/views/TableView.jsx +98 −0
@@ -0,0 +1,98 @@
1 +// Virtualised, sortable table (fixed row height, windowed rendering) — handles 50 000 rows without a dependency.
2 +import React, { useEffect, useMemo, useRef, useState } from 'react'
3 +
4 +const ROW_H = 30
5 +const OVERSCAN = 12
6 +
7 +function fmt(v) {
8 + if (v === null || v === undefined) return <span className="muted">null</span>
9 + if (typeof v === 'number') return Number.isInteger(v) ? v.toLocaleString() : v.toLocaleString(undefined, { maximumFractionDigits: 6 })
10 + if (typeof v === 'object') return JSON.stringify(v)
11 + return String(v)
12 +}
13 +
14 +export function columnsOf(rows) {
15 + const cols = []
16 + const seen = new Set()
17 + for (const r of rows.slice(0, 200)) {
18 + if (r && typeof r === 'object' && !Array.isArray(r)) for (const k of Object.keys(r)) if (!seen.has(k)) { seen.add(k); cols.push(k) }
19 + }
20 + return cols
21 +}
22 +
23 +export default function TableView({ rows, height = 460 }) {
24 + const cols = useMemo(() => columnsOf(rows), [rows])
25 + const [sort, setSort] = useState(null) // { col, dir }
26 + const [scrollTop, setScrollTop] = useState(0)
27 + const [viewH, setViewH] = useState(height)
28 + const ref = useRef(null)
29 +
30 + useEffect(() => { setSort(null); setScrollTop(0); if (ref.current) ref.current.scrollTop = 0 }, [rows])
31 + useEffect(() => {
32 + if (!ref.current) return
33 + const ro = new ResizeObserver(([e]) => setViewH(e.contentRect.height || height))
34 + ro.observe(ref.current)
35 + return () => ro.disconnect()
36 + }, [height])
37 +
38 + const sorted = useMemo(() => {
39 + if (!sort) return rows
40 + const { col, dir } = sort
41 + const m = dir === 'asc' ? 1 : -1
42 + return [...rows].sort((a, b) => {
43 + const x = a?.[col], y = b?.[col]
44 + if (x == null && y == null) return 0
45 + if (x == null) return 1
46 + if (y == null) return -1
47 + if (typeof x === 'number' && typeof y === 'number') return (x - y) * m
48 + return String(x).localeCompare(String(y), undefined, { numeric: true }) * m
49 + })
50 + }, [rows, sort])
51 +
52 + if (!rows.length) return <p className="muted pg-empty">No rows.</p>
53 + if (!cols.length) {
54 + return (
55 + <div className="pg-table-wrap" style={{ maxHeight: height }}>
56 + <table className="pg-table"><tbody>{rows.slice(0, 5000).map((r, i) => <tr key={i}><td className="mono">{fmt(r)}</td></tr>)}</tbody></table>
57 + </div>
58 + )
59 + }
60 +
61 + const total = sorted.length
62 + const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN)
63 + const end = Math.min(total, Math.ceil((scrollTop + viewH) / ROW_H) + OVERSCAN)
64 + const slice = sorted.slice(start, end)
65 + const toggle = col => setSort(s => (s?.col !== col ? { col, dir: 'asc' } : s.dir === 'asc' ? { col, dir: 'desc' } : null))
66 +
67 + return (
68 + <div className="pg-table-view">
69 + <div className="pg-view-toolbar">
70 + <span className="muted">{total.toLocaleString()} rows · {cols.length} columns{sort ? ` · sorted by ${sort.col} ${sort.dir}` : ''}</span>
71 + </div>
72 + <div className="pg-table-wrap" ref={ref} style={{ height }} onScroll={e => setScrollTop(e.currentTarget.scrollTop)} tabIndex={0} role="region" aria-label="Results table">
73 + <table className="pg-table" role="grid" aria-rowcount={total}>
74 + <thead>
75 + <tr>
76 + <th className="pg-th-idx" aria-label="row">#</th>
77 + {cols.map(c => (
78 + <th key={c} aria-sort={sort?.col === c ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
79 + <button type="button" className="pg-th-btn mono" onClick={() => toggle(c)}>{c}{sort?.col === c && <span aria-hidden="true"> {sort.dir === 'asc' ? '↑' : '↓'}</span>}</button>
80 + </th>
81 + ))}
82 + </tr>
83 + </thead>
84 + <tbody>
85 + {start > 0 && <tr style={{ height: start * ROW_H }} aria-hidden="true"><td colSpan={cols.length + 1} /></tr>}
86 + {slice.map((r, i) => (
87 + <tr key={start + i} style={{ height: ROW_H }} aria-rowindex={start + i + 1}>
88 + <td className="pg-td-idx muted">{start + i + 1}</td>
89 + {cols.map(c => <td key={c} className={typeof r?.[c] === 'number' ? 'num mono' : ''}>{fmt(r?.[c])}</td>)}
90 + </tr>
91 + ))}
92 + {end < total && <tr style={{ height: (total - end) * ROW_H }} aria-hidden="true"><td colSpan={cols.length + 1} /></tr>}
93 + </tbody>
94 + </table>
95 + </div>
96 + </div>
97 + )
98 +}
99