spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { ColorType, createChart, LineSeries, type IChartApi, type UTCTimestamp } from "lightweight-charts";4import { X } from "lucide-react";5import Link from "next/link";6import { useRouter } from "next/navigation";7import { useEffect, useRef, useState } from "react";8import { clientApi } from "@/lib/client-api";9import { cx, formatPercent, instrumentHref } from "@/lib/format";10import type { Instrument, Quote, SearchResult } from "@/lib/types";1112interface Series {13 instrument: Instrument;14 quote: Quote | null;15 points: Array<{ t: number; c: number; n: number | null }>;16 stats: { return_percent: number | null; annualized_volatility_percent: number | null; max_drawdown_percent: number | null; points: number };17}18interface CompareData {19 resolution: string;20 series: Series[];21 correlations: Array<{ a: string; b: string; rho: number | null }>;22}2324const COLORS = ["--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7", "--series-8"];25const cssVar = (n: string) => (typeof window === "undefined" ? "#000" : getComputedStyle(document.documentElement).getPropertyValue(n).trim());2627export function CompareView({ initialIds, initialResolution }: { initialIds: string[]; initialResolution: "1m" | "1h" | "1d" }) {28 const router = useRouter();29 const [ids, setIds] = useState(initialIds);30 const [resolution, setResolution] = useState(initialResolution);31 const [data, setData] = useState<CompareData | null>(null);32 const [q, setQ] = useState("");33 const [hits, setHits] = useState<Array<{ id: string; symbol: string; name: string }>>([]);34 const wrap = useRef<HTMLDivElement>(null);35 const chart = useRef<IChartApi | null>(null);3637 useEffect(() => {38 const u = new URLSearchParams({ ids: ids.join(","), resolution });39 router.replace(`/compare?${u}`, { scroll: false });40 if (!ids.length) {41 setData({ resolution, series: [], correlations: [] });42 return;43 }44 clientApi<CompareData>(`/v1/compare?${u}&limit=${resolution === "1m" ? 1500 : resolution === "1h" ? 700 : 500}`)45 .then(setData)46 .catch(() => setData({ resolution, series: [], correlations: [] }));47 }, [ids, resolution, router]);4849 useEffect(() => {50 if (!q.trim()) {51 setHits([]);52 return;53 }54 const t = setTimeout(() => {55 clientApi<SearchResult>(`/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`)56 .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) }))))57 .catch(() => {});58 }, 150);59 return () => clearTimeout(t);60 }, [q]);6162 useEffect(() => {63 if (!wrap.current) return;64 const c = createChart(wrap.current, {65 autoSize: true,66 layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false },67 grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } },68 rightPriceScale: { borderColor: cssVar("--rule") },69 timeScale: { borderColor: cssVar("--rule"), timeVisible: true },70 });71 chart.current = c;72 return () => {73 c.remove();74 chart.current = null;75 };76 }, []);7778 useEffect(() => {79 const c = chart.current;80 if (!c || !data) return;81 // Rebuild series: remove all, add one per instrument.82 // lightweight-charts has no "remove all", so we recreate via a fresh chart instance when the set changes.83 const el = wrap.current;84 if (!el) return;85 c.remove();86 const nc = createChart(el, {87 autoSize: true,88 layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false },89 grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } },90 rightPriceScale: { borderColor: cssVar("--rule") },91 timeScale: { borderColor: cssVar("--rule"), timeVisible: true },92 });93 chart.current = nc;94 data.series.forEach((s, i) => {95 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) } });96 line.setData(s.points.filter((p) => p.n != null).map((p) => ({ time: (p.t / 1000) as UTCTimestamp, value: p.n! })));97 });98 nc.timeScale().fitContent();99 }, [data]);100101 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);102 return (103 <div>104 <div className="mb-3 flex flex-wrap items-center gap-2">105 {data?.series.map((s, i) => (106 <span key={s.instrument.id} className="inline-flex h-9 items-center gap-2 rounded-md border border-rule bg-surface pl-2 pr-1 text-sm">107 <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: `var(${COLORS[i % COLORS.length]})` }} />108 <Link href={instrumentHref(s.instrument.id)} className="mono font-medium hover:underline">109 {s.instrument.symbol}110 </Link>111 <button type="button" aria-label={`Remove ${s.instrument.symbol}`} onClick={() => setIds((x) => x.filter((id) => id !== s.instrument.id))} className="flex h-7 w-7 items-center justify-center text-ink-3 hover:text-ink">112 <X size={13} />113 </button>114 </span>115 ))}116 <div className="relative">117 <input value={q} onChange={(e) => 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" />118 {hits.length > 0 && (119 <ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-md border border-rule bg-surface shadow-lg">120 {hits.map((h) => (121 <li key={h.id}>122 <button123 type="button"124 onClick={() => {125 if (!ids.includes(h.id)) setIds((x) => [...x, h.id]);126 setQ("");127 setHits([]);128 }}129 className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-2"130 >131 <span className="mono font-medium">{h.symbol}</span>132 <span className="truncate text-xs text-ink-3">{h.name}</span>133 </button>134 </li>135 ))}136 </ul>137 )}138 </div>139 <div className="ml-auto flex gap-0.5">140 {(["1m", "1h", "1d"] as const).map((r) => (141 <button key={r} type="button" onClick={() => setResolution(r)} className={cx("mono h-9 rounded px-3 text-xs", r === resolution ? "bg-ink text-canvas" : "text-ink-2 hover:bg-surface-2")}>142 {r === "1m" ? "Intraday" : r === "1h" ? "Hourly" : "Daily"}143 </button>144 ))}145 </div>146 </div>147 <div className="relative h-[380px] rounded-md border border-rule bg-surface">148 <div ref={wrap} className="absolute inset-0" />149 {data && data.series.every((s) => s.points.length < 2) && <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-ink-3">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.</div>}150 </div>151 {data && data.series.length > 0 && (152 <div className="mt-4 grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-[1fr_auto]">153 <div className="overflow-x-auto rounded-md border border-rule bg-surface">154 <table className="table-dense">155 <thead>156 <tr>157 <th>Instrument</th>158 <th className="text-right">Return</th>159 <th className="text-right">Ann. volatility</th>160 <th className="text-right">Max drawdown</th>161 <th className="text-right">Points</th>162 </tr>163 </thead>164 <tbody>165 {data.series.map((s, i) => (166 <tr key={s.instrument.id}>167 <td>168 <span className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: `var(${COLORS[i % COLORS.length]})` }} />169 <Link href={instrumentHref(s.instrument.id)} className="mono font-medium hover:underline">170 {s.instrument.symbol}171 </Link>172 <span className="ml-2 text-xs text-ink-3">{s.instrument.name}</span>173 </td>174 <td className={cx("num", (s.stats.return_percent ?? 0) > 0 ? "text-positive" : (s.stats.return_percent ?? 0) < 0 ? "text-negative" : "")}>{formatPercent(s.stats.return_percent)}</td>175 <td className="num">{s.stats.annualized_volatility_percent == null ? "—" : `${s.stats.annualized_volatility_percent.toFixed(1)}%`}</td>176 <td className="num text-negative">{formatPercent(s.stats.max_drawdown_percent, 2, false)}</td>177 <td className="num">{s.stats.points}</td>178 </tr>179 ))}180 </tbody>181 </table>182 </div>183 <div className="overflow-x-auto rounded-md border border-rule bg-surface p-3">184 <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Correlation of returns</div>185 <table className="mono text-xs">186 <thead>187 <tr>188 <th />189 {data.series.map((s) => (190 <th key={s.instrument.id} className="px-2 py-1 text-right font-medium text-ink-3">191 {s.instrument.symbol}192 </th>193 ))}194 </tr>195 </thead>196 <tbody>197 {data.series.map((a) => (198 <tr key={a.instrument.id}>199 <td className="py-1 pr-2 text-ink-3">{a.instrument.symbol}</td>200 {data.series.map((b) => {201 const r = rho(a.instrument.id, b.instrument.id);202 return (203 <td key={b.instrument.id} className="px-2 py-1 text-right tnum" style={r == null ? undefined : { background: r > 0 ? `rgba(29,79,216,${Math.abs(r) * 0.3})` : `rgba(192,50,60,${Math.abs(r) * 0.3})` }}>204 {r == null ? "—" : r.toFixed(2)}205 </td>206 );207 })}208 </tr>209 ))}210 </tbody>211 </table>212 <p className="mt-2 max-w-[260px] text-[11px] text-ink-3">Pearson correlation of period returns on aligned timestamps; “—” when fewer than 5 overlapping points.</p>213 </div>214 </div>215 )}216 </div>217 );218}219