SPB Git

spb/findata

Public
12.0 KB · 254 lines tsx
1"use client";2import Link from "next/link";3import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";4import { Download, Play } from "lucide-react";5import { api, isApiError, type Bar, type BarsOut } from "@/lib/api";6import { useQueryParam } from "@/lib/hooks";7import { useToast } from "@/lib/toast";8import { daysAgo, downloadText, fmtInt, fmtNum, fmtTs, toCsv, today } from "@/lib/format";9import { Banner, Card, Empty, ErrorState, Field, PageHeader, Tabs } from "@/components/ui";10import { DataTable } from "@/components/DataTable";11import { CloseLineChart, VolumeBarChart } from "@/components/charts";1213const MAX_SPAN: Record<string, number> = { "1m": 31, "5m": 186, "1d": 3660 };1415export default function BarsPage() {16  const qSymbol = useQueryParam("symbol");17  const qInterval = useQueryParam("interval");18  const qStart = useQueryParam("start");19  const qEnd = useQueryParam("end");20  const [symbol, setSymbol] = useState("AAPL");21  const [interval, setInterval_] = useState<"1m" | "5m" | "1d">("1m");22  const [start, setStart] = useState(daysAgo(7));23  const [end, setEnd] = useState(today());24  const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");25  const [limit, setLimit] = useState(2000);26  const [out, setOut] = useState<BarsOut | null>(null);27  const [rows, setRows] = useState<Bar[]>([]);28  const [busy, setBusy] = useState(false);29  const [error, setError] = useState<Error | null>(null);30  const [view, setView] = useState<"chart" | "table">("chart");31  const toast = useToast();3233  useEffect(() => {34    if (qSymbol) setSymbol(qSymbol.toUpperCase());35  }, [qSymbol]);36  useEffect(() => {37    if (qInterval === "1m" || qInterval === "5m" || qInterval === "1d") setInterval_(qInterval);38  }, [qInterval]);39  useEffect(() => {40    if (qStart && /^\d{4}-\d{2}-\d{2}$/.test(qStart)) setStart(qStart);41    if (qEnd && /^\d{4}-\d{2}-\d{2}$/.test(qEnd)) setEnd(qEnd);42  }, [qStart, qEnd]);43  // deep link (/bars/?symbol=…&start=…&end=…) loads automatically once the params are applied44  const autoRan = useRef(false);45  useEffect(() => {46    if (autoRan.current || !qSymbol || !qStart || !qEnd) return;47    if (symbol === qSymbol.toUpperCase() && start === qStart && end === qEnd) {48      autoRan.current = true;49      void run();50    }51    // eslint-disable-next-line react-hooks/exhaustive-deps52  }, [symbol, start, end, qSymbol, qStart, qEnd]);5354  const spanDays = useMemo(() => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000), [start, end]);55  const tooLarge = spanDays > (MAX_SPAN[interval] ?? 31);5657  async function run(e?: FormEvent) {58    e?.preventDefault();59    setBusy(true);60    setError(null);61    try {62      const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit });63      setOut(o);64      setRows(o.data);65    } catch (err) {66      setError(err instanceof Error ? err : new Error(String(err)));67      setOut(null);68      setRows([]);69    } finally {70      setBusy(false);71    }72  }7374  async function loadMore() {75    if (!out?.next_cursor) return;76    setBusy(true);77    try {78      const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit, cursor: out.next_cursor });79      setOut({ ...o, data: [] });80      setRows((r) => [...r, ...o.data]);81    } catch (err) {82      toast.error(err);83    } finally {84      setBusy(false);85    }86  }8788  const chart = useMemo(89    () =>90      rows.map((b) => ({91        t: fmtTs(b.timestamp_utc),92        label: interval === "1d" ? b.session_date : fmtTs(b.timestamp_utc).slice(5, 16),93        close: b.close,94        open: b.open,95        high: b.high,96        low: b.low,97        volume: b.volume,98      })),99    [rows, interval],100  );101  const flagged = useMemo(() => rows.filter((b) => b.quality_flags && b.quality_flags.length > 0).length, [rows]);102  const flagCounts = useMemo(() => {103    const m = new Map<string, number>();104    for (const b of rows) for (const f of b.quality_flags ?? []) m.set(f, (m.get(f) ?? 0) + 1);105    return Array.from(m.entries()).sort((a, b) => b[1] - a[1]);106  }, [rows]);107108  const rangeErr = isApiError(error) && error.code === "range_too_large";109110  return (111    <>112      <PageHeader title="Bars explorer" subtitle="OHLCV bars stamped at bar open (UTC). Adjustments are applied at query time from corporate actions." />113      <Card className="mb-4">114        <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6 lg:items-end">115          <Field label="Symbol">116            <input className="input font-mono uppercase" value={symbol} onChange={(e) => setSymbol(e.target.value)} required />117          </Field>118          <Field label="Interval">119            <select className="input" value={interval} onChange={(e) => setInterval_(e.target.value as typeof interval)}>120              <option value="1m">1 minute</option>121              <option value="5m">5 minutes</option>122              <option value="1d">1 day</option>123            </select>124          </Field>125          <Field label="Start">126            <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />127          </Field>128          <Field label="End">129            <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />130          </Field>131          <Field label="Adjustment">132            <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>133              <option value="raw">raw (as traded)</option>134              <option value="split">split-adjusted</option>135              <option value="split_div">split + dividend</option>136            </select>137          </Field>138          <div className="flex items-end gap-2">139            <Field label="Page size" className="w-24">140              <input type="number" className="input" min={100} max={10000} step={100} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 2000)} />141            </Field>142            <button className="btn btn-primary flex-1" disabled={busy || !symbol}>143              <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}144            </button>145          </div>146        </form>147        <p className={tooLarge ? "mt-2 text-xs text-warn-ink" : "mt-2 text-xs text-ink-3"}>148          Range: {spanDays} day{spanDays === 1 ? "" : "s"} · limit for {interval}: {MAX_SPAN[interval]} days per call (HTTP 413 <code>range_too_large</code> beyond). Larger ranges →{" "}149          <Link href="/exports/" className="text-accent-ink hover:underline">150            Exports151          </Link>{" "}152          (async Parquet/CSV).153        </p>154      </Card>155156      {error && !rangeErr && <ErrorState error={error} retry={() => void run()} />}157      {rangeErr && (158        <Banner tone="warn" className="mb-4">159          <div className="font-medium">Range too large for {interval}</div>160          <div className="text-xs">161            {(error as Error).message} Use{" "}162            <Link href={`/exports/?symbols=${encodeURIComponent(symbol)}&start=${start}&end=${end}&adjustment=${adjustment}`} className="underline">163              an export164            </Link>{" "}165            for {spanDays} days, or shorten the window.166          </div>167        </Banner>168      )}169170      {out && (171        <>172          <div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-ink-2">173            <span className="pill bg-surface-2">{out.meta.symbol}</span>174            <span className="pill bg-surface-2">{out.meta.interval}</span>175            <span className="pill bg-surface-2">{out.meta.adjustment}</span>176            {out.meta.version !== undefined && <span className="pill bg-surface-2">dataset v{String(out.meta.version)}</span>}177            <span className="tnum">178              {fmtInt(rows.length)} of {fmtInt(out.meta.rows_matching || rows.length)} rows179            </span>180            {flagged > 0 && <span className="pill bg-warn-soft text-warn-ink">{fmtInt(flagged)} flagged bars</span>}181            <span className="ml-auto">182              <Tabs value={view} onChange={setView} items={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]} />183            </span>184          </div>185          {out.meta.adjustment_notes?.length > 0 && (186            <Banner tone="accent" className="mb-3">187              <div className="text-xs font-medium">Adjustment notes</div>188              <ul className="list-disc pl-4 text-xs">189                {out.meta.adjustment_notes.map((n, i) => (190                  <li key={i}>{n}</li>191                ))}192              </ul>193            </Banner>194          )}195          {flagCounts.length > 0 && (196            <div className="mb-3 flex flex-wrap gap-1 text-xs">197              <span className="text-ink-3">quality_flags:</span>198              {flagCounts.map(([f, n]) => (199                <span key={f} className="pill bg-warn-soft text-warn-ink">200                  {f} × {fmtInt(n)}201                </span>202              ))}203            </div>204          )}205          {rows.length === 0 ? (206            <Empty>No bars for this range. Zero-volume bars are not stored; check the session coverage.</Empty>207          ) : view === "chart" ? (208            <Card title={`Close · ${out.meta.symbol} (${out.meta.adjustment})`}>209              <CloseLineChart data={chart} height={280} />210              <div className="mt-2 text-xs font-semibold text-ink-2">Volume</div>211              <VolumeBarChart data={chart} height={110} />212              <p className="mt-2 text-[11px] text-ink-3">Hover for OHLCV. Single series per chart — close and volume use separate axes by design.</p>213            </Card>214          ) : (215            <Card216              actions={217                <button className="btn btn-sm" onClick={() => downloadText(`${out.meta.symbol}_${interval}_${start}_${end}.csv`, toCsv(["timestamp_utc", "session_date", "open", "high", "low", "close", "volume", "vwap", "trade_count", "quality_flags"], rows.map((r) => ({ ...r, quality_flags: (r.quality_flags ?? []).join("|") }))))}>218                  <Download className="h-3.5 w-3.5" /> CSV (loaded rows)219                </button>220              }221            >222              <DataTable223                rows={rows}224                rowKey={(r) => r.timestamp_utc}225                mobile="scroll"226                maxHeight="60dvh"227                columns={[228                  { key: "timestamp_utc", header: "Timestamp (UTC, bar open)", render: (r) => <span className="tnum font-mono text-xs">{fmtTs(r.timestamp_utc)}</span> },229                  { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date}</span> },230                  { key: "open", header: "Open", num: true, render: (r) => fmtNum(r.open, 4) },231                  { key: "high", header: "High", num: true, render: (r) => fmtNum(r.high, 4) },232                  { key: "low", header: "Low", num: true, render: (r) => fmtNum(r.low, 4) },233                  { key: "close", header: "Close", num: true, render: (r) => fmtNum(r.close, 4) },234                  { key: "volume", header: "Volume", num: true, render: (r) => fmtInt(r.volume) },235                  { key: "vwap", header: "VWAP", num: true, hideSm: true, render: (r) => (r.vwap === null || r.vwap === undefined ? "—" : fmtNum(r.vwap, 4)) },236                  { key: "quality_flags", header: "Flags", render: (r) => (r.quality_flags?.length ? r.quality_flags.map((f) => <span key={f} className="pill mr-1 bg-warn-soft text-warn-ink">{f}</span>) : <span className="text-ink-3">—</span>) },237                ]}238              />239            </Card>240          )}241          <div className="mt-3 flex items-center justify-between text-xs text-ink-3">242            <span>{out.meta.license} · timestamps = {out.meta.timestamp_convention}</span>243            {out.next_cursor && (244              <button className="btn btn-sm" onClick={loadMore} disabled={busy}>245                {busy ? "Loading…" : `Load next ${fmtInt(limit)}`}246              </button>247            )}248          </div>249        </>250      )}251    </>252  );253}254
Files · b31ccfb82046b1137ca27720f0467b178d4cebd6
Menu

Theme