"use client"; import { ColorType, createChart, LineSeries, type IChartApi, type UTCTimestamp } from "lightweight-charts"; import { X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { clientApi } from "@/lib/client-api"; import { cx, formatPercent, instrumentHref } from "@/lib/format"; import type { Instrument, Quote, SearchResult } from "@/lib/types"; interface Series { instrument: Instrument; quote: Quote | null; points: Array<{ t: number; c: number; n: number | null }>; stats: { return_percent: number | null; annualized_volatility_percent: number | null; max_drawdown_percent: number | null; points: number }; } interface CompareData { resolution: string; series: Series[]; correlations: Array<{ a: string; b: string; rho: number | null }>; } const COLORS = ["--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7", "--series-8"]; const cssVar = (n: string) => (typeof window === "undefined" ? "#000" : getComputedStyle(document.documentElement).getPropertyValue(n).trim()); export function CompareView({ initialIds, initialResolution }: { initialIds: string[]; initialResolution: "1m" | "1h" | "1d" }) { const router = useRouter(); const [ids, setIds] = useState(initialIds); const [resolution, setResolution] = useState(initialResolution); const [data, setData] = useState(null); const [q, setQ] = useState(""); const [hits, setHits] = useState>([]); const wrap = useRef(null); const chart = useRef(null); useEffect(() => { const u = new URLSearchParams({ ids: ids.join(","), resolution }); router.replace(`/compare?${u}`, { scroll: false }); if (!ids.length) { setData({ resolution, series: [], correlations: [] }); return; } clientApi(`/v1/compare?${u}&limit=${resolution === "1m" ? 1500 : resolution === "1h" ? 700 : 500}`) .then(setData) .catch(() => setData({ resolution, series: [], correlations: [] })); }, [ids, resolution, router]); useEffect(() => { if (!q.trim()) { setHits([]); return; } const t = setTimeout(() => { clientApi(`/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`) .then((r) => setHits(r.results.filter((x) => x.group !== "EXCHANGE" && x.group !== "COUNTRY").map((x) => ({ id: String(x.item.id), symbol: String(x.item.symbol), name: String(x.item.name) })))) .catch(() => {}); }, 150); return () => clearTimeout(t); }, [q]); useEffect(() => { if (!wrap.current) return; const c = createChart(wrap.current, { 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 }, }); chart.current = c; return () => { c.remove(); chart.current = null; }; }, []); useEffect(() => { const c = chart.current; if (!c || !data) return; // Rebuild series: remove all, add one per instrument. // lightweight-charts has no "remove all", so we recreate via a fresh chart instance when the set changes. const el = wrap.current; if (!el) return; c.remove(); const nc = 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 }, }); chart.current = nc; data.series.forEach((s, i) => { const line = nc.addSeries(LineSeries, { color: cssVar(COLORS[i % COLORS.length]!), lineWidth: 2, title: s.instrument.symbol, priceFormat: { type: "custom", formatter: (v: number) => v.toFixed(1) } }); line.setData(s.points.filter((p) => p.n != null).map((p) => ({ time: (p.t / 1000) as UTCTimestamp, value: p.n! }))); }); nc.timeScale().fitContent(); }, [data]); const rho = (a: string, b: string) => (a === b ? 1 : data?.correlations.find((c) => (c.a === a && c.b === b) || (c.a === b && c.b === a))?.rho ?? null); return (
{data?.series.map((s, i) => ( {s.instrument.symbol} ))}
setQ(e.target.value)} placeholder={ids.length >= 8 ? "Maximum 8 instruments" : "Add instrument…"} disabled={ids.length >= 8} className="h-9 w-56 rounded-md border border-rule bg-surface px-2 text-sm outline-none focus:border-rule-strong" /> {hits.length > 0 && (
    {hits.map((h) => (
  • ))}
)}
{(["1m", "1h", "1d"] as const).map((r) => ( ))}
{data && data.series.every((s) => s.points.length < 2) &&
Not enough history at this resolution yet. Intraday series start when Market Atlas first observes an instrument live; daily history comes from end-of-day sources.
}
{data && data.series.length > 0 && (
{data.series.map((s, i) => ( ))}
Instrument Return Ann. volatility Max drawdown Points
{s.instrument.symbol} {s.instrument.name} 0 ? "text-positive" : (s.stats.return_percent ?? 0) < 0 ? "text-negative" : "")}>{formatPercent(s.stats.return_percent)} {s.stats.annualized_volatility_percent == null ? "—" : `${s.stats.annualized_volatility_percent.toFixed(1)}%`} {formatPercent(s.stats.max_drawdown_percent, 2, false)} {s.stats.points}
Correlation of returns
))} {data.series.map((a) => ( {data.series.map((b) => { const r = rho(a.instrument.id, b.instrument.id); return ( ); })} ))}
{data.series.map((s) => ( {s.instrument.symbol}
{a.instrument.symbol} 0 ? `rgba(29,79,216,${Math.abs(r) * 0.3})` : `rgba(192,50,60,${Math.abs(r) * 0.3})` }}> {r == null ? "—" : r.toFixed(2)}

Pearson correlation of period returns on aligned timestamps; “—” when fewer than 5 overlapping points.

)}
); }