SPB Git

spb/findata

Public
11.3 KB · 238 lines tsx
1"use client";2import Link from "next/link";3import { useEffect, useMemo, 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 [symbol, setSymbol] = useState("AAPL");19  const [interval, setInterval_] = useState<"1m" | "5m" | "1d">("1m");20  const [start, setStart] = useState(daysAgo(7));21  const [end, setEnd] = useState(today());22  const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");23  const [limit, setLimit] = useState(2000);24  const [out, setOut] = useState<BarsOut | null>(null);25  const [rows, setRows] = useState<Bar[]>([]);26  const [busy, setBusy] = useState(false);27  const [error, setError] = useState<Error | null>(null);28  const [view, setView] = useState<"chart" | "table">("chart");29  const toast = useToast();3031  useEffect(() => {32    if (qSymbol) setSymbol(qSymbol.toUpperCase());33  }, [qSymbol]);34  useEffect(() => {35    if (qInterval === "1m" || qInterval === "5m" || qInterval === "1d") setInterval_(qInterval);36  }, [qInterval]);3738  const spanDays = useMemo(() => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000), [start, end]);39  const tooLarge = spanDays > (MAX_SPAN[interval] ?? 31);4041  async function run(e?: FormEvent) {42    e?.preventDefault();43    setBusy(true);44    setError(null);45    try {46      const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit });47      setOut(o);48      setRows(o.data);49    } catch (err) {50      setError(err instanceof Error ? err : new Error(String(err)));51      setOut(null);52      setRows([]);53    } finally {54      setBusy(false);55    }56  }5758  async function loadMore() {59    if (!out?.next_cursor) return;60    setBusy(true);61    try {62      const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit, cursor: out.next_cursor });63      setOut({ ...o, data: [] });64      setRows((r) => [...r, ...o.data]);65    } catch (err) {66      toast.error(err);67    } finally {68      setBusy(false);69    }70  }7172  const chart = useMemo(73    () =>74      rows.map((b) => ({75        t: fmtTs(b.timestamp_utc),76        label: interval === "1d" ? b.session_date : fmtTs(b.timestamp_utc).slice(5, 16),77        close: b.close,78        open: b.open,79        high: b.high,80        low: b.low,81        volume: b.volume,82      })),83    [rows, interval],84  );85  const flagged = useMemo(() => rows.filter((b) => b.quality_flags && b.quality_flags.length > 0).length, [rows]);86  const flagCounts = useMemo(() => {87    const m = new Map<string, number>();88    for (const b of rows) for (const f of b.quality_flags ?? []) m.set(f, (m.get(f) ?? 0) + 1);89    return Array.from(m.entries()).sort((a, b) => b[1] - a[1]);90  }, [rows]);9192  const rangeErr = isApiError(error) && error.code === "range_too_large";9394  return (95    <>96      <PageHeader title="Bars explorer" subtitle="OHLCV bars stamped at bar open (UTC). Adjustments are applied at query time from corporate actions." />97      <Card className="mb-4">98        <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6 lg:items-end">99          <Field label="Symbol">100            <input className="input font-mono uppercase" value={symbol} onChange={(e) => setSymbol(e.target.value)} required />101          </Field>102          <Field label="Interval">103            <select className="input" value={interval} onChange={(e) => setInterval_(e.target.value as typeof interval)}>104              <option value="1m">1 minute</option>105              <option value="5m">5 minutes</option>106              <option value="1d">1 day</option>107            </select>108          </Field>109          <Field label="Start">110            <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />111          </Field>112          <Field label="End">113            <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />114          </Field>115          <Field label="Adjustment">116            <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>117              <option value="raw">raw (as traded)</option>118              <option value="split">split-adjusted</option>119              <option value="split_div">split + dividend</option>120            </select>121          </Field>122          <div className="flex items-end gap-2">123            <Field label="Page size" className="w-24">124              <input type="number" className="input" min={100} max={10000} step={100} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 2000)} />125            </Field>126            <button className="btn btn-primary flex-1" disabled={busy || !symbol}>127              <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}128            </button>129          </div>130        </form>131        <p className={tooLarge ? "mt-2 text-xs text-warn-ink" : "mt-2 text-xs text-ink-3"}>132          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 →{" "}133          <Link href="/exports/" className="text-accent-ink hover:underline">134            Exports135          </Link>{" "}136          (async Parquet/CSV).137        </p>138      </Card>139140      {error && !rangeErr && <ErrorState error={error} retry={() => void run()} />}141      {rangeErr && (142        <Banner tone="warn" className="mb-4">143          <div className="font-medium">Range too large for {interval}</div>144          <div className="text-xs">145            {(error as Error).message} Use{" "}146            <Link href={`/exports/?symbols=${encodeURIComponent(symbol)}&start=${start}&end=${end}&adjustment=${adjustment}`} className="underline">147              an export148            </Link>{" "}149            for {spanDays} days, or shorten the window.150          </div>151        </Banner>152      )}153154      {out && (155        <>156          <div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-ink-2">157            <span className="pill bg-surface-2">{out.meta.symbol}</span>158            <span className="pill bg-surface-2">{out.meta.interval}</span>159            <span className="pill bg-surface-2">{out.meta.adjustment}</span>160            {out.meta.version !== undefined && <span className="pill bg-surface-2">dataset v{String(out.meta.version)}</span>}161            <span className="tnum">162              {fmtInt(rows.length)} of {fmtInt(out.meta.rows_matching || rows.length)} rows163            </span>164            {flagged > 0 && <span className="pill bg-warn-soft text-warn-ink">{fmtInt(flagged)} flagged bars</span>}165            <span className="ml-auto">166              <Tabs value={view} onChange={setView} items={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]} />167            </span>168          </div>169          {out.meta.adjustment_notes?.length > 0 && (170            <Banner tone="accent" className="mb-3">171              <div className="text-xs font-medium">Adjustment notes</div>172              <ul className="list-disc pl-4 text-xs">173                {out.meta.adjustment_notes.map((n, i) => (174                  <li key={i}>{n}</li>175                ))}176              </ul>177            </Banner>178          )}179          {flagCounts.length > 0 && (180            <div className="mb-3 flex flex-wrap gap-1 text-xs">181              <span className="text-ink-3">quality_flags:</span>182              {flagCounts.map(([f, n]) => (183                <span key={f} className="pill bg-warn-soft text-warn-ink">184                  {f} × {fmtInt(n)}185                </span>186              ))}187            </div>188          )}189          {rows.length === 0 ? (190            <Empty>No bars for this range. Zero-volume bars are not stored; check the session coverage.</Empty>191          ) : view === "chart" ? (192            <Card title={`Close · ${out.meta.symbol} (${out.meta.adjustment})`}>193              <CloseLineChart data={chart} height={280} />194              <div className="mt-2 text-xs font-semibold text-ink-2">Volume</div>195              <VolumeBarChart data={chart} height={110} />196              <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>197            </Card>198          ) : (199            <Card200              actions={201                <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("|") }))))}>202                  <Download className="h-3.5 w-3.5" /> CSV (loaded rows)203                </button>204              }205            >206              <DataTable207                rows={rows}208                rowKey={(r) => r.timestamp_utc}209                mobile="scroll"210                maxHeight="60dvh"211                columns={[212                  { key: "timestamp_utc", header: "Timestamp (UTC, bar open)", render: (r) => <span className="tnum font-mono text-xs">{fmtTs(r.timestamp_utc)}</span> },213                  { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date}</span> },214                  { key: "open", header: "Open", num: true, render: (r) => fmtNum(r.open, 4) },215                  { key: "high", header: "High", num: true, render: (r) => fmtNum(r.high, 4) },216                  { key: "low", header: "Low", num: true, render: (r) => fmtNum(r.low, 4) },217                  { key: "close", header: "Close", num: true, render: (r) => fmtNum(r.close, 4) },218                  { key: "volume", header: "Volume", num: true, render: (r) => fmtInt(r.volume) },219                  { key: "vwap", header: "VWAP", num: true, hideSm: true, render: (r) => (r.vwap === null || r.vwap === undefined ? "—" : fmtNum(r.vwap, 4)) },220                  { 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>) },221                ]}222              />223            </Card>224          )}225          <div className="mt-3 flex items-center justify-between text-xs text-ink-3">226            <span>{out.meta.license} · timestamps = {out.meta.timestamp_convention}</span>227            {out.next_cursor && (228              <button className="btn btn-sm" onClick={loadMore} disabled={busy}>229                {busy ? "Loading…" : `Load next ${fmtInt(limit)}`}230              </button>231            )}232          </div>233        </>234      )}235    </>236  );237}238
Files · fe690344fa9a90f9edec7abe72fc51ac18e87113
Menu

Theme