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)
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 | ||