SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
11.0 KB · 219 lines tsx
Raw Blame History
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