"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { Download, Play } from "lucide-react"; import { api, isApiError, type Bar, type BarsOut } from "@/lib/api"; import { useQueryParam } from "@/lib/hooks"; import { useToast } from "@/lib/toast"; import { daysAgo, downloadText, fmtInt, fmtNum, fmtTs, toCsv, today } from "@/lib/format"; import { Banner, Card, Empty, ErrorState, Field, PageHeader, Tabs } from "@/components/ui"; import { DataTable } from "@/components/DataTable"; import { CloseLineChart, VolumeBarChart } from "@/components/charts"; const MAX_SPAN: Record = { "1m": 31, "5m": 186, "1d": 3660 }; export default function BarsPage() { const qSymbol = useQueryParam("symbol"); const qInterval = useQueryParam("interval"); const qStart = useQueryParam("start"); const qEnd = useQueryParam("end"); const [symbol, setSymbol] = useState("AAPL"); const [interval, setInterval_] = useState<"1m" | "5m" | "1d">("1m"); const [start, setStart] = useState(daysAgo(7)); const [end, setEnd] = useState(today()); const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw"); const [limit, setLimit] = useState(2000); const [out, setOut] = useState(null); const [rows, setRows] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [view, setView] = useState<"chart" | "table">("chart"); const toast = useToast(); useEffect(() => { if (qSymbol) setSymbol(qSymbol.toUpperCase()); }, [qSymbol]); useEffect(() => { if (qInterval === "1m" || qInterval === "5m" || qInterval === "1d") setInterval_(qInterval); }, [qInterval]); useEffect(() => { if (qStart && /^\d{4}-\d{2}-\d{2}$/.test(qStart)) setStart(qStart); if (qEnd && /^\d{4}-\d{2}-\d{2}$/.test(qEnd)) setEnd(qEnd); }, [qStart, qEnd]); // deep link (/bars/?symbol=…&start=…&end=…) loads automatically once the params are applied const autoRan = useRef(false); useEffect(() => { if (autoRan.current || !qSymbol || !qStart || !qEnd) return; if (symbol === qSymbol.toUpperCase() && start === qStart && end === qEnd) { autoRan.current = true; void run(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [symbol, start, end, qSymbol, qStart, qEnd]); const spanDays = useMemo(() => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000), [start, end]); const tooLarge = spanDays > (MAX_SPAN[interval] ?? 31); async function run(e?: FormEvent) { e?.preventDefault(); setBusy(true); setError(null); try { const o = await api.get("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit }); setOut(o); setRows(o.data); } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); setOut(null); setRows([]); } finally { setBusy(false); } } async function loadMore() { if (!out?.next_cursor) return; setBusy(true); try { const o = await api.get("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit, cursor: out.next_cursor }); setOut({ ...o, data: [] }); setRows((r) => [...r, ...o.data]); } catch (err) { toast.error(err); } finally { setBusy(false); } } const chart = useMemo( () => rows.map((b) => ({ t: fmtTs(b.timestamp_utc), label: interval === "1d" ? b.session_date : fmtTs(b.timestamp_utc).slice(5, 16), close: b.close, open: b.open, high: b.high, low: b.low, volume: b.volume, })), [rows, interval], ); const flagged = useMemo(() => rows.filter((b) => b.quality_flags && b.quality_flags.length > 0).length, [rows]); const flagCounts = useMemo(() => { const m = new Map(); for (const b of rows) for (const f of b.quality_flags ?? []) m.set(f, (m.get(f) ?? 0) + 1); return Array.from(m.entries()).sort((a, b) => b[1] - a[1]); }, [rows]); const rangeErr = isApiError(error) && error.code === "range_too_large"; return ( <>
setSymbol(e.target.value)} required /> setStart(e.target.value)} required /> setEnd(e.target.value)} required />
setLimit(Number(e.target.value) || 2000)} />

Range: {spanDays} day{spanDays === 1 ? "" : "s"} · limit for {interval}: {MAX_SPAN[interval]} days per call (HTTP 413 range_too_large beyond). Larger ranges →{" "} Exports {" "} (async Parquet/CSV).

{error && !rangeErr && void run()} />} {rangeErr && (
Range too large for {interval}
{(error as Error).message} Use{" "} an export {" "} for {spanDays} days, or shorten the window.
)} {out && ( <>
{out.meta.symbol} {out.meta.interval} {out.meta.adjustment} {out.meta.version !== undefined && dataset v{String(out.meta.version)}} {fmtInt(rows.length)} of {fmtInt(out.meta.rows_matching || rows.length)} rows {flagged > 0 && {fmtInt(flagged)} flagged bars}
{out.meta.adjustment_notes?.length > 0 && (
Adjustment notes
    {out.meta.adjustment_notes.map((n, i) => (
  • {n}
  • ))}
)} {flagCounts.length > 0 && (
quality_flags: {flagCounts.map(([f, n]) => ( {f} × {fmtInt(n)} ))}
)} {rows.length === 0 ? ( No bars for this range. Zero-volume bars are not stored; check the session coverage. ) : view === "chart" ? (
Volume

Hover for OHLCV. Single series per chart — close and volume use separate axes by design.

) : ( 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("|") }))))}> CSV (loaded rows) } > r.timestamp_utc} mobile="scroll" maxHeight="60dvh" columns={[ { key: "timestamp_utc", header: "Timestamp (UTC, bar open)", render: (r) => {fmtTs(r.timestamp_utc)} }, { key: "session_date", header: "Session", hideSm: true, render: (r) => {r.session_date} }, { key: "open", header: "Open", num: true, render: (r) => fmtNum(r.open, 4) }, { key: "high", header: "High", num: true, render: (r) => fmtNum(r.high, 4) }, { key: "low", header: "Low", num: true, render: (r) => fmtNum(r.low, 4) }, { key: "close", header: "Close", num: true, render: (r) => fmtNum(r.close, 4) }, { key: "volume", header: "Volume", num: true, render: (r) => fmtInt(r.volume) }, { key: "vwap", header: "VWAP", num: true, hideSm: true, render: (r) => (r.vwap === null || r.vwap === undefined ? "—" : fmtNum(r.vwap, 4)) }, { key: "quality_flags", header: "Flags", render: (r) => (r.quality_flags?.length ? r.quality_flags.map((f) => {f}) : —) }, ]} /> )}
{out.meta.license} · timestamps = {out.meta.timestamp_convention} {out.next_cursor && ( )}
)} ); }