"use client"; import { AreaSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type Time, type UTCTimestamp } from "lightweight-charts"; import { useEffect, useMemo, useRef, useState } from "react"; import { clientApi } from "@/lib/client-api"; import { cx, priceDecimals } from "@/lib/format"; import { useLiveQuote } from "@/lib/stream"; import type { Bar, Envelope } from "@/lib/types"; export type Range = "1D" | "5D" | "1M" | "3M" | "6M" | "YTD" | "1Y" | "MAX"; const RANGES: Range[] = ["1D", "5D", "1M", "3M", "6M", "YTD", "1Y", "MAX"]; function rangeQuery(r: Range): { resolution: "1m" | "1h" | "1d"; from: Date | null; limit: number } { const now = new Date(); const d = (n: number) => new Date(now.getTime() - n * 86_400_000); switch (r) { case "1D": return { resolution: "1m", from: d(1), limit: 1500 }; case "5D": return { resolution: "1h", from: d(5), limit: 500 }; case "1M": return { resolution: "1d", from: d(31), limit: 400 }; case "3M": return { resolution: "1d", from: d(93), limit: 400 }; case "6M": return { resolution: "1d", from: d(186), limit: 400 }; case "YTD": return { resolution: "1d", from: new Date(Date.UTC(now.getUTCFullYear(), 0, 1)), limit: 400 }; case "1Y": return { resolution: "1d", from: d(366), limit: 400 }; default: return { resolution: "1d", from: null, limit: 5000 }; } } function cssVar(name: string): string { if (typeof window === "undefined") return "#000"; return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#000"; } /** * Price chart backed by GET /v1/history/:id. Intraday (1D) is fed live from the stream; * daily history comes from end-of-day sources and is labelled as such. Never interpolates gaps. */ export function PriceChart({ instrumentId, assetClass, className, defaultRange = "1D", height = 340 }: { instrumentId: string; assetClass?: string | null; className?: string; defaultRange?: Range; height?: number }) { const [range, setRange] = useState(defaultRange); const [bars, setBars] = useState(null); const [meta, setMeta] = useState<{ producers?: string[]; data_status?: string }>({}); const [error, setError] = useState(null); const [mode, setMode] = useState<"area" | "line">("area"); const wrap = useRef(null); const chart = useRef(null); const series = useRef | null>(null); const volume = useRef | null>(null); const live = useLiveQuote(instrumentId); const q = useMemo(() => rangeQuery(range), [range]); useEffect(() => { let alive = true; setBars(null); setError(null); const params = new URLSearchParams({ resolution: q.resolution, limit: String(q.limit) }); if (q.from) params.set("from", q.from.toISOString()); fetch(`/v1/history/${encodeURIComponent(instrumentId)}?${params}`) .then(async (r) => { const body = (await r.json()) as Envelope; if (!r.ok) throw new Error("history unavailable"); if (!alive) return; setBars(body.data); setMeta(body.meta as { producers?: string[]; data_status?: string }); }) .catch((e) => alive && setError(e instanceof Error ? e.message : "error")); return () => { alive = false; }; }, [instrumentId, q]); useEffect(() => { if (!wrap.current) return; const el = wrap.current; const c = createChart(el, { autoSize: true, layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false }, grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } }, rightPriceScale: { borderColor: cssVar("--rule") }, timeScale: { borderColor: cssVar("--rule"), timeVisible: true, secondsVisible: false }, crosshair: { vertLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") }, horzLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") } }, handleScroll: true, handleScale: true, }); chart.current = c; volume.current = c.addSeries(HistogramSeries, { priceScaleId: "vol", color: cssVar("--rule-strong"), priceFormat: { type: "volume" }, lastValueVisible: false, priceLineVisible: false }); c.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }); const obs = new MutationObserver(() => { c.applyOptions({ layout: { textColor: cssVar("--ink-3") }, grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } } }); series.current?.applyOptions(seriesColors(mode)); }); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); return () => { obs.disconnect(); c.remove(); chart.current = null; series.current = null; volume.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const seriesColors = (m: "area" | "line") => { const accent = cssVar("--accent"); return m === "area" ? { lineColor: accent, topColor: accent + "55", bottomColor: accent + "05", lineWidth: 2 as const } : { color: accent, lineWidth: 2 as const }; }; useEffect(() => { const c = chart.current; if (!c || !bars) return; if (series.current) c.removeSeries(series.current); const up = bars.length > 1 && bars[bars.length - 1]!.c >= bars[0]!.c; const color = cssVar(up ? "--positive" : "--negative"); const s = mode === "area" ? c.addSeries(AreaSeries, { lineColor: color, topColor: color + "55", bottomColor: color + "05", lineWidth: 2, priceLineVisible: true, lastValueVisible: true }) : c.addSeries(LineSeries, { color, lineWidth: 2 }); const decimals = bars.length ? priceDecimals(bars[bars.length - 1]!.c, assetClass) : 2; s.applyOptions({ priceFormat: { type: "price", precision: decimals, minMove: 10 ** -decimals } }); s.setData(bars.map((b) => ({ time: (b.t / 1000) as UTCTimestamp, value: b.c }))); volume.current?.setData(bars.filter((b) => b.v != null).map((b, i, arr) => ({ time: (b.t / 1000) as UTCTimestamp, value: b.v!, color: (i > 0 && b.c < arr[i - 1]!.c ? cssVar("--negative") : cssVar("--positive")) + "66" }))); series.current = s; c.timeScale().fitContent(); }, [bars, mode, assetClass]); // Live intraday updates: extend/refresh the last 1-minute point from the stream (1D only). useEffect(() => { if (range !== "1D" || !live || live.price == null || !series.current || !bars) return; const minute = Math.floor(live.timestamp / 60_000) * 60; const lastBarSec = bars.length ? bars[bars.length - 1]!.t / 1000 : 0; if (minute < lastBarSec) return; try { series.current.update({ time: minute as Time as UTCTimestamp, value: live.price }); } catch { /* out-of-order update ignored */ } }, [live, range, bars]); const producers = meta.producers ?? []; const label = producers.length === 0 ? null : producers.includes("consensus") && producers.length === 1 ? "Derived from Market Atlas consensus (1-minute bars)" : producers.includes("consensus") ? "Consensus intraday + end-of-day history" : "End-of-day history · licensed daily bars"; return (
{RANGES.map((r) => ( ))}
{label && {label}}
{bars && bars.length === 0 && (
{range === "1D" || range === "5D" ? "No intraday history yet — Market Atlas records 1-minute bars from the moment an instrument is observed live." : "No daily history is available for this range."}
)} {!bars && !error &&
Loading history…
} {error &&
History unavailable.
}
); }