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