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%

core: tick persistence sampling, AT_CLOSE semantics, bigint freshness/latency, connector fixes (OKX pairs, hfmd BRK.B, Cboe rate limit)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 485d449

21 changed files +817 −10

modified .env.example +1 −0
@@ -13,3 +13,4 @@ HFMD_API_KEY= # hfmarketdata.io key (own data lake) — option
13 13 MA_DISABLED_CONNECTORS= # comma-separated connector ids to keep off
14 14 MA_RAW_SAMPLE_RATE=0.02 # share of streaming frames archived at L0 (bulk/poll payloads are always archived)
15 15 MA_OBSERVATION_RETENTION_DAYS=45
16 +MA_TICK_PERSIST_INTERVAL_MS=2000 # store at most one REALTIME tick per source/instrument/field per 2 s (consensus sees every tick)
modified apps/api/src/api/routes/public.ts +2 −2
@@ -104,7 +104,7 @@ export async function registerPublicRoutes(app: FastifyInstance) {
104 104 connectors_total: list.length,
105 105 connectors_healthy: states.filter((s) => s === "HEALTHY").length,
106 106 observations_today: daily.rows[0]?.observations ?? 0,
107 − observations_per_sec: round(telemetry.rate("observations_received_total"), 1),
107 + observations_per_sec: round(telemetry.rate("observations_accepted_total"), 1),
108 108 observations_total: telemetry.counter("observations_written_total"),
109 109 events_today: daily.rows[0]?.events ?? 0,
110 110 events_24h: evts.rows[0]?.n ?? 0,
@@ -432,7 +432,7 @@ export async function registerPublicRoutes(app: FastifyInstance) {
432 432 multi_source_quotes: quotes.filter((x) => x.sourceCount >= 2).length,
433 433 mean_confidence: quotes.length ? round(quotes.reduce((a, x) => a + x.confidence, 0) / quotes.length, 3) : null,
434 434 mean_dispersion_bps: round(mean(quotes.map((x) => x.dispersionBps).filter((x): x is number => x != null)), 2),
435 − observations_per_sec: round(telemetry.rate("observations_received_total"), 1),
435 + observations_per_sec: round(telemetry.rate("observations_accepted_total"), 1),
436 436 queue_depth: telemetry.counter("observation_queue_depth"),
437 437 by_connector: snaps.map((s) => ({ id: s.connectorId, state: s.state, messages_1m: s.messages1m, median_latency_ms: s.medianLatencyMs, reliability_score: s.reliabilityScore, instruments: s.instrumentsCovered, last_message_at: s.lastMessageAt ? new Date(s.lastMessageAt).toISOString() : null, last_error: s.lastError })),
438 438 incidents: incidents.rows.map((r) => ({ id: r.id, type: r.type, ts: r.ts, severity: r.severity, title: r.title, data: r.data })),
modified apps/api/src/config.ts +6 −0
@@ -40,6 +40,12 @@ export const config = {
40 40 writerMaxBatch: 2000, // 17 columns × 2000 rows stays under Postgres' 65,535 bind-parameter limit
41 41 /** Consensus: observations older than this (ms) are excluded for REALTIME sources. */
42 42 consensusFreshWindowMs: 15_000,
43 + /**
44 + * Persistence sampling for REALTIME streaming ticks: at most one stored observation per
45 + * (source, instrument, field) per interval. Consensus/events still see every tick in memory.
46 + * 0 disables sampling (store everything).
47 + */
48 + tickPersistIntervalMs: Number(env("MA_TICK_PERSIST_INTERVAL_MS", "2000")),
43 49 version: "0.1.0",
44 50 };
45 51
modified apps/api/src/core/consensus.ts +1 −1
@@ -18,7 +18,7 @@ const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = {
18 18 REALTIME: config.consensusFreshWindowMs,
19 19 DELAYED: 30 * 60_000,
20 20 INDICATIVE: 60 * 60_000,
21 − END_OF_DAY: 3 * 86_400_000,
21 + END_OF_DAY: 10 * 86_400_000, // daily/weekly-refreshed official series stay "last close" over long weekends and weekly lake reloads
22 22 STALE: 0,
23 23 UNKNOWN: 60_000,
24 24 };
modified apps/api/src/core/pipeline.ts +17 −1
@@ -1,6 +1,7 @@
1 1 import type { Bar, Instrument, NormalizedObservation, Observation, RawObservation } from "@market-atlas/market-model";
2 2 import { CANONICAL_VERSIONS, NormalizedObservationSchema, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model";
3 3 import { observationFingerprint, type ConnectorDefinition, type NormalizedBatch } from "@market-atlas/connector-sdk";
4 +import { config } from "../config.js";
4 5 import { pool } from "../db/pool.js";
5 6 import { logger } from "../logger.js";
6 7 import { barAggregator, upsertBars } from "./bars.js";
@@ -82,13 +83,15 @@ export class Pipeline {
82 83 continue;
83 84 }
84 85 const obs = this.toObservation(n, inst, raw, rawRef);
85 − observationWriter.enqueue(obs);
86 + if (this.shouldPersist(obs)) observationWriter.enqueue(obs);
87 + else telemetry.inc("observations_sampled_out_total");
86 88 this.consensus.ingest(obs);
87 89 bus.publish("normalized.observation", obs);
88 90 touched.set(inst.id, inst);
89 91 ids.push(inst.id);
90 92 accepted++;
91 93 }
94 + telemetry.inc("observations_accepted_total", accepted);
92 95 const latency = batch.observations.find((o) => o.sourceTimestamp)?.sourceTimestamp;
93 96 health.message(connectorId, latency ? raw.receivedAt - latency : null, ids);
94 97 for (const [id, inst] of touched) this.recomputeQueue.set(id, inst);
@@ -113,6 +116,19 @@ export class Pipeline {
113 116 if (out.length) telemetry.inc("bars_backfilled_total", out.length, { connector: def.metadata.id });
114 117 }
115 118
119 + private lastPersisted = new Map<string, number>();
120 +
121 + /** REALTIME streaming ticks are stored at most once per (source, instrument, field) per interval; everything else always. */
122 + private shouldPersist(o: Observation): boolean {
123 + if (o.realtimeStatus !== "REALTIME" || config.tickPersistIntervalMs <= 0) return true;
124 + const key = `${o.sourceId}|${o.instrumentId}|${o.field}`;
125 + const last = this.lastPersisted.get(key) ?? 0;
126 + if (o.receivedAt - last < config.tickPersistIntervalMs) return false;
127 + this.lastPersisted.set(key, o.receivedAt);
128 + if (this.lastPersisted.size > 200_000) this.lastPersisted.clear();
129 + return true;
130 + }
131 +
116 132 private toObservation(n: NormalizedObservation, inst: Instrument, raw: RawObservation, rawRef: string | null): Observation {
117 133 const fp = observationFingerprint({
118 134 sourceId: raw.sourceId,
added apps/web/src/components/market/chart.tsx +173 −0
@@ -0,0 +1,173 @@
1 +"use client";
2 +
3 +import { AreaSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type Time, type UTCTimestamp } from "lightweight-charts";
4 +import { useEffect, useMemo, useRef, useState } from "react";
5 +import { clientApi } from "@/lib/client-api";
6 +import { cx, priceDecimals } from "@/lib/format";
7 +import { useLiveQuote } from "@/lib/stream";
8 +import type { Bar, Envelope } from "@/lib/types";
9 +
10 +export type Range = "1D" | "5D" | "1M" | "3M" | "6M" | "YTD" | "1Y" | "MAX";
11 +const RANGES: Range[] = ["1D", "5D", "1M", "3M", "6M", "YTD", "1Y", "MAX"];
12 +
13 +function rangeQuery(r: Range): { resolution: "1m" | "1h" | "1d"; from: Date | null; limit: number } {
14 + const now = new Date();
15 + const d = (n: number) => new Date(now.getTime() - n * 86_400_000);
16 + switch (r) {
17 + case "1D":
18 + return { resolution: "1m", from: d(1), limit: 1500 };
19 + case "5D":
20 + return { resolution: "1h", from: d(5), limit: 500 };
21 + case "1M":
22 + return { resolution: "1d", from: d(31), limit: 400 };
23 + case "3M":
24 + return { resolution: "1d", from: d(93), limit: 400 };
25 + case "6M":
26 + return { resolution: "1d", from: d(186), limit: 400 };
27 + case "YTD":
28 + return { resolution: "1d", from: new Date(Date.UTC(now.getUTCFullYear(), 0, 1)), limit: 400 };
29 + case "1Y":
30 + return { resolution: "1d", from: d(366), limit: 400 };
31 + default:
32 + return { resolution: "1d", from: null, limit: 5000 };
33 + }
34 +}
35 +
36 +function cssVar(name: string): string {
37 + if (typeof window === "undefined") return "#000";
38 + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#000";
39 +}
40 +
41 +/**
42 + * Price chart backed by GET /v1/history/:id. Intraday (1D) is fed live from the stream;
43 + * daily history comes from end-of-day sources and is labelled as such. Never interpolates gaps.
44 + */
45 +export function PriceChart({ instrumentId, assetClass, className, defaultRange = "1D", height = 340 }: { instrumentId: string; assetClass?: string | null; className?: string; defaultRange?: Range; height?: number }) {
46 + const [range, setRange] = useState<Range>(defaultRange);
47 + const [bars, setBars] = useState<Bar[] | null>(null);
48 + const [meta, setMeta] = useState<{ producers?: string[]; data_status?: string }>({});
49 + const [error, setError] = useState<string | null>(null);
50 + const [mode, setMode] = useState<"area" | "line">("area");
51 + const wrap = useRef<HTMLDivElement>(null);
52 + const chart = useRef<IChartApi | null>(null);
53 + const series = useRef<ISeriesApi<"Area" | "Line"> | null>(null);
54 + const volume = useRef<ISeriesApi<"Histogram"> | null>(null);
55 + const live = useLiveQuote(instrumentId);
56 + const q = useMemo(() => rangeQuery(range), [range]);
57 +
58 + useEffect(() => {
59 + let alive = true;
60 + setBars(null);
61 + setError(null);
62 + const params = new URLSearchParams({ resolution: q.resolution, limit: String(q.limit) });
63 + if (q.from) params.set("from", q.from.toISOString());
64 + fetch(`/v1/history/${encodeURIComponent(instrumentId)}?${params}`)
65 + .then(async (r) => {
66 + const body = (await r.json()) as Envelope<Bar[]>;
67 + if (!r.ok) throw new Error("history unavailable");
68 + if (!alive) return;
69 + setBars(body.data);
70 + setMeta(body.meta as { producers?: string[]; data_status?: string });
71 + })
72 + .catch((e) => alive && setError(e instanceof Error ? e.message : "error"));
73 + return () => {
74 + alive = false;
75 + };
76 + }, [instrumentId, q]);
77 +
78 + useEffect(() => {
79 + if (!wrap.current) return;
80 + const el = wrap.current;
81 + const c = createChart(el, {
82 + autoSize: true,
83 + layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false },
84 + grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } },
85 + rightPriceScale: { borderColor: cssVar("--rule") },
86 + timeScale: { borderColor: cssVar("--rule"), timeVisible: true, secondsVisible: false },
87 + crosshair: { vertLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") }, horzLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") } },
88 + handleScroll: true,
89 + handleScale: true,
90 + });
91 + chart.current = c;
92 + volume.current = c.addSeries(HistogramSeries, { priceScaleId: "vol", color: cssVar("--rule-strong"), priceFormat: { type: "volume" }, lastValueVisible: false, priceLineVisible: false });
93 + c.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
94 + const obs = new MutationObserver(() => {
95 + c.applyOptions({ layout: { textColor: cssVar("--ink-3") }, grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } } });
96 + series.current?.applyOptions(seriesColors(mode));
97 + });
98 + obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
99 + return () => {
100 + obs.disconnect();
101 + c.remove();
102 + chart.current = null;
103 + series.current = null;
104 + volume.current = null;
105 + };
106 + // eslint-disable-next-line react-hooks/exhaustive-deps
107 + }, []);
108 +
109 + const seriesColors = (m: "area" | "line") => {
110 + const accent = cssVar("--accent");
111 + return m === "area" ? { lineColor: accent, topColor: accent + "55", bottomColor: accent + "05", lineWidth: 2 as const } : { color: accent, lineWidth: 2 as const };
112 + };
113 +
114 + useEffect(() => {
115 + const c = chart.current;
116 + if (!c || !bars) return;
117 + if (series.current) c.removeSeries(series.current);
118 + const up = bars.length > 1 && bars[bars.length - 1]!.c >= bars[0]!.c;
119 + const color = cssVar(up ? "--positive" : "--negative");
120 + 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 });
121 + const decimals = bars.length ? priceDecimals(bars[bars.length - 1]!.c, assetClass) : 2;
122 + s.applyOptions({ priceFormat: { type: "price", precision: decimals, minMove: 10 ** -decimals } });
123 + s.setData(bars.map((b) => ({ time: (b.t / 1000) as UTCTimestamp, value: b.c })));
124 + 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" })));
125 + series.current = s;
126 + c.timeScale().fitContent();
127 + }, [bars, mode, assetClass]);
128 +
129 + // Live intraday updates: extend/refresh the last 1-minute point from the stream (1D only).
130 + useEffect(() => {
131 + if (range !== "1D" || !live || live.price == null || !series.current || !bars) return;
132 + const minute = Math.floor(live.timestamp / 60_000) * 60;
133 + const lastBarSec = bars.length ? bars[bars.length - 1]!.t / 1000 : 0;
134 + if (minute < lastBarSec) return;
135 + try {
136 + series.current.update({ time: minute as Time as UTCTimestamp, value: live.price });
137 + } catch {
138 + /* out-of-order update ignored */
139 + }
140 + }, [live, range, bars]);
141 +
142 + const producers = meta.producers ?? [];
143 + 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";
144 + return (
145 + <div className={cx("rounded-md border border-rule bg-surface", className)}>
146 + <div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule px-2 py-1.5">
147 + <div className="flex gap-0.5">
148 + {RANGES.map((r) => (
149 + <button key={r} type="button" onClick={() => setRange(r)} className={cx("mono h-8 min-w-[40px] rounded px-2 text-xs", r === range ? "bg-ink text-canvas" : "text-ink-2 hover:bg-surface-2")}>
150 + {r}
151 + </button>
152 + ))}
153 + </div>
154 + <div className="flex items-center gap-2 text-[11px] text-ink-3">
155 + {label && <span className="hidden sm:inline">{label}</span>}
156 + <button type="button" onClick={() => setMode((m) => (m === "area" ? "line" : "area"))} className="h-8 rounded border border-rule px-2 hover:text-ink">
157 + {mode === "area" ? "line" : "area"}
158 + </button>
159 + </div>
160 + </div>
161 + <div className="relative" style={{ height }}>
162 + <div ref={wrap} className="absolute inset-0" />
163 + {bars && bars.length === 0 && (
164 + <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-ink-3">
165 + {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."}
166 + </div>
167 + )}
168 + {!bars && !error && <div className="absolute inset-0 flex items-center justify-center text-sm text-ink-3">Loading history…</div>}
169 + {error && <div className="absolute inset-0 flex items-center justify-center text-sm text-negative">History unavailable.</div>}
170 + </div>
171 + </div>
172 + );
173 +}
added apps/web/src/components/market/event-row.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import Link from "next/link";
2 +import { RelativeTime } from "@/components/ui/freshness";
3 +import { StatusBadge } from "@/components/ui/status-badge";
4 +import { cx, EVENT_TYPE_LABEL, instrumentHref } from "@/lib/format";
5 +import type { MarketEvent } from "@/lib/types";
6 +
7 +const TYPE_TONE: Record<string, string> = {
8 + TRADING_HALT: "text-negative",
9 + SCHEMA_DRIFT: "text-negative",
10 + SOURCE_FAILURE: "text-warning",
11 + SOURCE_DIVERGENCE: "text-warning",
12 + VOLATILITY_SPIKE: "text-warning",
13 + MARKET_OPEN: "text-positive",
14 + TRADING_RESUME: "text-positive",
15 + SOURCE_RECOVERY: "text-positive",
16 + FILING_PUBLISHED: "text-accent",
17 + DOCUMENT_CHANGED: "text-accent",
18 +};
19 +
20 +export function EventRow({ e, dense, className }: { e: MarketEvent; dense?: boolean; className?: string }) {
21 + const first = e.instruments?.[0];
22 + const url = typeof e.data?.url === "string" ? (e.data.url as string) : null;
23 + return (
24 + <li className={cx("flex gap-3 border-b border-rule py-2.5 last:border-0", dense && "py-1.5", className)}>
25 + <div className="w-[76px] shrink-0 pt-0.5 text-right">
26 + <RelativeTime value={e.timestamp} className="mono text-[11px] text-ink-3" />
27 + </div>
28 + <div className="min-w-0 flex-1">
29 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
30 + <span className={cx("text-[10.5px] font-medium uppercase tracking-wide", TYPE_TONE[e.type] ?? "text-ink-3")}>{EVENT_TYPE_LABEL[e.type] ?? e.type.replace(/_/g, " ").toLowerCase()}</span>
31 + {(e.severity === "WARNING" || e.severity === "CRITICAL") && <StatusBadge status={e.severity} />}
32 + {first?.symbol && (
33 + <Link href={instrumentHref(first.id)} className="mono text-xs font-medium text-accent hover:underline">
34 + {first.symbol}
35 + </Link>
36 + )}
37 + </div>
38 + <Link href={`/events/${e.id}`} className="mt-0.5 block text-sm leading-snug hover:underline">
39 + {e.title}
40 + </Link>
41 + {!dense && e.summary && <div className="mt-0.5 truncate text-xs text-ink-3">{e.summary}</div>}
42 + <div className="mt-0.5 flex flex-wrap items-center gap-x-3 text-[11px] text-ink-3">
43 + <span>
44 + {e.source_count} source{e.source_count === 1 ? "" : "s"}
45 + {e.sources?.length ? ` · ${e.sources.slice(0, 3).join(", ")}` : ""}
46 + </span>
47 + <span className="mono">confidence {Math.round(e.confidence * 100)}%</span>
48 + {url && (
49 + <a href={url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
50 + document ↗
51 + </a>
52 + )}
53 + </div>
54 + </div>
55 + </li>
56 + );
57 +}
added apps/web/src/components/market/instrument-table.tsx +60 −0
@@ -0,0 +1,60 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo } from "react";
5 +import { ConfidenceMeter } from "@/components/ui/confidence";
6 +import { DataTable, type Column } from "@/components/ui/data-table";
7 +import { FreshnessLabel } from "@/components/ui/freshness";
8 +import { LivePrice, ChangeCell } from "@/components/ui/price";
9 +import { StatusBadge } from "@/components/ui/status-badge";
10 +import { ASSET_CLASS_LABEL, formatCompact, instrumentHref } from "@/lib/format";
11 +import { useLiveQuote, useMarketStream } from "@/lib/stream";
12 +import type { InstrumentWithQuote } from "@/lib/types";
13 +
14 +function LiveChange({ row }: { row: InstrumentWithQuote }) {
15 + const live = useLiveQuote(row.id);
16 + const useLive = !!live && row.quote && live.received >= Date.parse(row.quote.updated_at);
17 + return <ChangeCell value={useLive ? live!.change_pct : row.quote?.change_percent} />;
18 +}
19 +
20 +function LiveVolume({ row }: { row: InstrumentWithQuote }) {
21 + const live = useLiveQuote(row.id);
22 + const v = live && row.quote && live.received >= Date.parse(row.quote.updated_at) ? live.volume : row.quote?.volume;
23 + return <span className="mono tnum text-ink-2">{formatCompact(v)}</span>;
24 +}
25 +
26 +/**
27 + * Dense instrument table with live cells. Subscribes to the per-class stream channel when
28 + * `liveClass` is given (one subscription for the whole table), otherwise per-instrument channels
29 + * for tables under ~60 rows.
30 + */
31 +export function InstrumentTable({ rows, liveClass, showClass, showExchange, defaultSort = "change", compact, maxHeight, emptyText }: { rows: InstrumentWithQuote[]; liveClass?: string | string[]; showClass?: boolean; showExchange?: boolean; defaultSort?: string; compact?: boolean; maxHeight?: string; emptyText?: string }) {
32 + const channels = useMemo(() => {
33 + if (liveClass) return (Array.isArray(liveClass) ? liveClass : [liveClass]).map((c) => `quotes:class:${c}`);
34 + return rows.slice(0, 80).map((r) => `quotes:${r.id}`);
35 + }, [liveClass, rows]);
36 + useMarketStream(channels);
37 +
38 + const columns: Column<InstrumentWithQuote>[] = [
39 + {
40 + key: "symbol",
41 + header: "Symbol",
42 + sort: (r) => r.symbol,
43 + cell: (r) => (
44 + <Link href={instrumentHref(r.id)} className="block min-w-0">
45 + <span className="mono block font-medium leading-tight">{r.symbol}</span>
46 + <span className="block max-w-[180px] truncate text-[11.5px] text-ink-3 sm:max-w-[260px]">{r.name}</span>
47 + </Link>
48 + ),
49 + },
50 + ...(showClass ? [{ key: "class", header: "Class", hideBelow: "sm" as const, sort: (r: InstrumentWithQuote) => r.asset_class, cell: (r: InstrumentWithQuote) => <span className="text-xs text-ink-2">{ASSET_CLASS_LABEL[r.asset_class] ?? r.asset_class}</span> }] : []),
51 + ...(showExchange ? [{ key: "exchange", header: "Venue", hideBelow: "md" as const, sort: (r: InstrumentWithQuote) => r.exchange_id ?? "", cell: (r: InstrumentWithQuote) => <span className="mono text-xs text-ink-2">{r.mic ?? r.exchange_id?.toUpperCase() ?? "—"}</span> }] : []),
52 + { key: "price", header: "Price", num: true, sort: (r) => r.quote?.price, cell: (r) => <LivePrice instrumentId={r.id} quote={r.quote} assetClass={r.asset_class} showChange={false} /> },
53 + { key: "change", header: "Chg %", num: true, sort: (r) => r.quote?.change_percent, cell: (r) => <LiveChange row={r} /> },
54 + ...(compact ? [] : [{ key: "volume", header: "Volume", num: true, hideBelow: "md" as const, sort: (r: InstrumentWithQuote) => r.quote?.volume, cell: (r: InstrumentWithQuote) => <LiveVolume row={r} /> }]),
55 + { key: "conf", header: "Confidence", hideBelow: "sm", sort: (r) => r.quote?.confidence, cell: (r) => <ConfidenceMeter confidence={r.quote?.confidence} sources={r.quote?.source_count} /> },
56 + { key: "status", header: "Status", sort: (r) => r.quote?.data_status, cell: (r) => <StatusBadge status={r.quote?.data_status} /> },
57 + ...(compact ? [] : [{ key: "fresh", header: "Freshness", hideBelow: "lg" as const, cell: (r: InstrumentWithQuote) => <FreshnessLabel quote={r.quote} /> }]),
58 + ];
59 + return <DataTable rows={rows} columns={columns} rowKey={(r) => r.id} defaultSort={defaultSort} maxHeight={maxHeight} emptyText={emptyText} />;
60 +}
added apps/web/src/components/market/live-events.tsx +33 −0
@@ -0,0 +1,33 @@
1 +"use client";
2 +
3 +import { useMemo } from "react";
4 +import { EventRow } from "./event-row";
5 +import { cx } from "@/lib/format";
6 +import { useLiveEvents, useMarketStream } from "@/lib/stream";
7 +import type { MarketEvent } from "@/lib/types";
8 +
9 +/** Initial events (SSR) merged with events arriving on `events:*` (or a narrower channel). */
10 +export function LiveEvents({ initial, channel = "events:*", max = 40, dense, className, filter }: { initial: MarketEvent[]; channel?: string; max?: number; dense?: boolean; className?: string; filter?: (e: MarketEvent) => boolean }) {
11 + useMarketStream([channel]);
12 + const live = useLiveEvents(max);
13 + const merged = useMemo(() => {
14 + const seen = new Set<string>();
15 + const out: MarketEvent[] = [];
16 + for (const e of [...live, ...initial]) {
17 + if (seen.has(e.id)) continue;
18 + if (filter && !filter(e)) continue;
19 + seen.add(e.id);
20 + out.push(e);
21 + if (out.length >= max) break;
22 + }
23 + return out;
24 + }, [live, initial, max, filter]);
25 + if (!merged.length) return <div className={cx("rounded-md border border-dashed border-rule px-4 py-8 text-center text-sm text-ink-3", className)}>No events yet — the feed fills as sources emit changes.</div>;
26 + return (
27 + <ul className={cx("rounded-md border border-rule bg-surface px-3", className)}>
28 + {merged.map((e) => (
29 + <EventRow key={e.id} e={e} dense={dense} />
30 + ))}
31 + </ul>
32 + );
33 +}
added apps/web/src/components/market/pulse-grid.tsx +42 −0
@@ -0,0 +1,42 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useMemo } from "react";
5 +import { FreshnessLabel } from "@/components/ui/freshness";
6 +import { LivePrice } from "@/components/ui/price";
7 +import { StatusBadge } from "@/components/ui/status-badge";
8 +import { cx, instrumentHref } from "@/lib/format";
9 +import { useMarketStream } from "@/lib/stream";
10 +import type { InstrumentWithQuote } from "@/lib/types";
11 +
12 +/** Compact quote tiles for the homepage pulse. One row of tiles per asset class, scrollable on mobile. */
13 +export function PulseRow({ title, href, items, className }: { title: string; href: string; items: InstrumentWithQuote[]; className?: string }) {
14 + const channels = useMemo(() => items.map((i) => `quotes:${i.id}`), [items]);
15 + useMarketStream(channels);
16 + if (!items.length) return null;
17 + return (
18 + <div className={cx("rule-b py-3", className)}>
19 + <div className="mb-2 flex items-baseline justify-between">
20 + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">{title}</h3>
21 + <Link href={href} className="text-xs text-accent hover:underline">
22 + All →
23 + </Link>
24 + </div>
25 + <div className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid sm:grid-cols-3 sm:px-0 lg:grid-cols-6 [&::-webkit-scrollbar]:hidden">
26 + {items.slice(0, 6).map((i) => (
27 + <Link key={i.id} href={instrumentHref(i.id)} className="min-w-[164px] shrink-0 rounded-md border border-rule bg-surface px-3 py-2 hover:border-rule-strong sm:min-w-0">
28 + <div className="flex items-center justify-between gap-2">
29 + <span className="mono text-xs font-medium">{i.symbol}</span>
30 + <StatusBadge status={i.quote?.data_status} />
31 + </div>
32 + <div className="mt-1 truncate text-[11px] text-ink-3">{i.name}</div>
33 + <div className="mt-1.5">
34 + <LivePrice instrumentId={i.id} quote={i.quote} assetClass={i.asset_class} className="text-[15px]" />
35 + </div>
36 + <FreshnessLabel quote={i.quote} className="mt-1 !text-[10.5px]" />
37 + </Link>
38 + ))}
39 + </div>
40 + </div>
41 + );
42 +}
added apps/web/src/components/market/tape.tsx +39 −0
@@ -0,0 +1,39 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { cx, formatDateTime, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format";
5 +import { useMarketStream, useTape } from "@/lib/stream";
6 +import { toneOf } from "@/components/ui/price";
7 +
8 +/** Live tape: latest canonical price changes across everything the stream publishes (channel `tape`). */
9 +export function LiveTape({ rows = 24, className }: { rows?: number; className?: string }) {
10 + const state = useMarketStream(["tape"]);
11 + const tape = useTape(rows);
12 + return (
13 + <div className={cx("mono overflow-hidden rounded-md border border-rule bg-surface text-[12px]", className)}>
14 + <div className="flex items-center justify-between border-b border-rule px-3 py-1.5 text-[10.5px] uppercase tracking-wide text-ink-3">
15 + <span>Live tape · canonical price changes</span>
16 + <span className={cx("inline-flex items-center gap-1.5", state === "open" ? "text-positive" : "text-warning")}>
17 + <span className={cx("inline-block h-1.5 w-1.5 rounded-full", state === "open" ? "bg-positive live-dot" : "bg-warning")} />
18 + {state === "open" ? "streaming" : state}
19 + </span>
20 + </div>
21 + {tape.length === 0 && <div className="px-3 py-6 text-center text-ink-3">Waiting for the first ticks…</div>}
22 + <ul className="max-h-[420px] overflow-y-auto">
23 + {tape.map((q, i) => (
24 + <li key={`${q.instrument_id}-${q.received}-${i}`} className={cx("flex items-center gap-3 border-b border-rule px-3 py-1 last:border-0", i === 0 && "bg-surface-2")}>
25 + <span className="w-[86px] shrink-0 text-ink-3" suppressHydrationWarning>
26 + {formatDateTime(q.timestamp, { seconds: true }).replace(/^.*?, /, "").replace(" UTC", "")}
27 + </span>
28 + <Link href={instrumentHref(q.instrument_id)} className="w-[84px] shrink-0 truncate font-medium hover:text-accent">
29 + {q.symbol}
30 + </Link>
31 + <span className="flex-1 text-right tnum">{formatQuoteValue(q.price, undefined, q.currency)}</span>
32 + <span className={cx("w-[72px] shrink-0 text-right tnum", toneOf(q.change_pct))}>{formatPercent(q.change_pct)}</span>
33 + <span className="hidden w-12 shrink-0 text-right text-ink-3 sm:inline">{q.sources} src</span>
34 + </li>
35 + ))}
36 + </ul>
37 + </div>
38 + );
39 +}
added apps/web/src/components/market/telemetry-strip.tsx +61 −0
@@ -0,0 +1,61 @@
1 +"use client";
2 +
3 +import { useEffect, useRef, useState } from "react";
4 +import { clientApi } from "@/lib/client-api";
5 +import { cx, formatCompact, formatDuration, formatInt } from "@/lib/format";
6 +import type { Stats } from "@/lib/types";
7 +
8 +function Counter({ value, format }: { value: number; format: (n: number) => string }) {
9 + const [display, setDisplay] = useState(value);
10 + const from = useRef(value);
11 + useEffect(() => {
12 + const start = from.current;
13 + const delta = value - start;
14 + if (!delta) return;
15 + const t0 = performance.now();
16 + let raf = 0;
17 + const step = (t: number) => {
18 + const k = Math.min(1, (t - t0) / 600);
19 + setDisplay(start + delta * (1 - (1 - k) ** 3));
20 + if (k < 1) raf = requestAnimationFrame(step);
21 + else from.current = value;
22 + };
23 + raf = requestAnimationFrame(step);
24 + return () => cancelAnimationFrame(raf);
25 + }, [value]);
26 + return <span suppressHydrationWarning>{format(display)}</span>;
27 +}
28 +
29 +/** Homepage telemetry — every number comes from GET /v1/stats (refreshed every 5 s). */
30 +export function TelemetryStrip({ initial, className }: { initial: Stats; className?: string }) {
31 + const [s, setS] = useState(initial);
32 + useEffect(() => {
33 + let alive = true;
34 + const t = setInterval(() => clientApi<Stats>("/v1/stats").then((x) => alive && setS(x)).catch(() => {}), 5000);
35 + return () => {
36 + alive = false;
37 + clearInterval(t);
38 + };
39 + }, []);
40 + const items: Array<{ label: string; value: number; format: (n: number) => string; sub?: string }> = [
41 + { label: "Instruments", value: s.instruments_total, format: formatInt, sub: `${formatInt(s.instruments_quoted)} quoted · ${formatInt(s.instruments_live)} live` },
42 + { label: "Exchanges", value: s.exchanges_total, format: formatInt, sub: `${s.exchanges_open} open now` },
43 + { label: "Active connectors", value: s.connectors_healthy, format: formatInt, sub: `${s.connectors_total} connectors · ${s.sources_total} sources` },
44 + { label: "Observations today", value: s.observations_today, format: (n) => formatCompact(n, 2), sub: `${s.observations_per_sec.toFixed(1)} / sec` },
45 + { label: "Events · 24h", value: s.events_24h, format: formatInt, sub: `${s.filings_24h} filings` },
46 + { label: "Median freshness", value: s.median_freshness_ms ?? 0, format: (n) => (s.median_freshness_ms == null ? "—" : formatDuration(n)), sub: "real-time quotes" },
47 + ];
48 + return (
49 + <div className={cx("grid grid-cols-2 gap-x-4 gap-y-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-3 lg:grid-cols-6", className)}>
50 + {items.map((it) => (
51 + <div key={it.label} className="min-w-0">
52 + <div className="text-[10.5px] font-medium uppercase tracking-wide text-ink-3">{it.label}</div>
53 + <div className="mono mt-0.5 text-xl font-semibold tracking-tight tnum sm:text-2xl">
54 + <Counter value={it.value} format={it.format} />
55 + </div>
56 + {it.sub && <div className="mt-0.5 truncate text-[11px] text-ink-3">{it.sub}</div>}
57 + </div>
58 + ))}
59 + </div>
60 + );
61 +}
added apps/web/src/components/market/world-map.tsx +115 −0
@@ -0,0 +1,115 @@
1 +"use client";
2 +
3 +import { geoNaturalEarth1, geoPath } from "d3-geo";
4 +import Link from "next/link";
5 +import { useEffect, useMemo, useState } from "react";
6 +import * as topojson from "topojson-client";
7 +import type { Topology, GeometryCollection } from "topojson-specification";
8 +import { cx } from "@/lib/format";
9 +import { useNow } from "@/lib/stream";
10 +import type { MarketsOverview } from "@/lib/types";
11 +
12 +type Ex = MarketsOverview["exchanges"][number] & { breadth?: { median_change_percent: number | null } | null };
13 +
14 +const STATE_FILL: Record<string, string> = { OPEN: "var(--positive)", PRE: "var(--warning)", POST: "var(--warning)", AUCTION: "var(--warning)", HALTED: "var(--negative)", CLOSED: "var(--stale)", UNKNOWN: "var(--stale)" };
15 +
16 +const W = 960;
17 +const H = 470;
18 +
19 +/**
20 + * World Market Map: countries-110m (world-atlas) drawn with d3-geo Natural Earth; one marker per
21 + * exchange, coloured by session state, sized by whether it trades now. Time-aware: as the clock
22 + * moves, states come from the API (refreshed by the parent) and local times tick here.
23 + */
24 +export function WorldMap({ exchanges, className }: { exchanges: Ex[]; className?: string }) {
25 + const [land, setLand] = useState<string | null>(null);
26 + const [hover, setHover] = useState<string | null>(null);
27 + const now = useNow(1000);
28 + const projection = useMemo(() => geoNaturalEarth1().scale(W / 6.1).translate([W / 2, H / 2 + 8]), []);
29 + const path = useMemo(() => geoPath(projection), [projection]);
30 +
31 + useEffect(() => {
32 + let alive = true;
33 + import("world-atlas/countries-110m.json")
34 + .then((mod) => {
35 + if (!alive) return;
36 + const topo = (mod.default ?? mod) as unknown as Topology<{ countries: GeometryCollection }>;
37 + const fc = topojson.feature(topo, topo.objects.countries);
38 + setLand(path(fc) ?? "");
39 + })
40 + .catch(() => setLand(""));
41 + return () => {
42 + alive = false;
43 + };
44 + }, [path]);
45 +
46 + const markers = useMemo(
47 + () =>
48 + exchanges
49 + .filter((e) => e.lat != null && e.lon != null)
50 + .map((e) => {
51 + const p = projection([e.lon!, e.lat!]);
52 + return p ? { e, x: p[0], y: p[1] } : null;
53 + })
54 + .filter((m): m is { e: Ex; x: number; y: number } => !!m),
55 + [exchanges, projection],
56 + );
57 + const open = exchanges.filter((e) => e.status.state === "OPEN").length;
58 + const hovered = markers.find((m) => m.e.id === hover);
59 + const timeIn = (tz: string) => {
60 + try {
61 + return new Intl.DateTimeFormat("en-GB", { timeZone: tz, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(now || Date.now()));
62 + } catch {
63 + return "";
64 + }
65 + };
66 + return (
67 + <div className={cx("relative overflow-hidden rounded-md border border-rule bg-surface", className)}>
68 + <div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule px-3 py-2 text-[11px] text-ink-3">
69 + <span className="uppercase tracking-wide">
70 + World market map · <span className="text-ink-2">{open}</span> of {exchanges.length} venues trading now
71 + </span>
72 + <span className="flex items-center gap-3">
73 + <Legend color="var(--positive)" label="open" />
74 + <Legend color="var(--warning)" label="pre/post" />
75 + <Legend color="var(--stale)" label="closed" />
76 + </span>
77 + </div>
78 + <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full" role="img" aria-label="World map of exchanges and their session state">
79 + {land != null ? <path d={land} fill="var(--map-land)" stroke="var(--map-stroke)" strokeWidth="0.6" /> : <rect width={W} height={H} fill="var(--surface-2)" />}
80 + {markers.map(({ e, x, y }) => {
81 + const isOpen = e.status.state === "OPEN";
82 + const fill = STATE_FILL[e.status.state] ?? "var(--stale)";
83 + return (
84 + <g key={e.id} transform={`translate(${x},${y})`} onMouseEnter={() => setHover(e.id)} onMouseLeave={() => setHover(null)} className="cursor-pointer">
85 + <Link href={`/exchanges/${e.id}`}>
86 + {isOpen && <circle r={9} fill={fill} opacity={0.18} className="live-dot" />}
87 + <circle r={isOpen ? 4.2 : 3} fill={fill} stroke="var(--surface)" strokeWidth="1" />
88 + <circle r={12} fill="transparent" />
89 + </Link>
90 + </g>
91 + );
92 + })}
93 + </svg>
94 + {hovered && (
95 + <div className="pointer-events-none absolute left-3 top-11 rounded-md border border-rule bg-surface/95 px-3 py-2 text-xs shadow-lg backdrop-blur">
96 + <div className="font-medium">{hovered.e.name}</div>
97 + <div className="text-ink-3">
98 + {hovered.e.city ?? hovered.e.country} · <span className="mono">{timeIn(hovered.e.timezone)}</span> local · <span className="uppercase">{hovered.e.status.state.toLowerCase()}</span>
99 + {hovered.e.status.isHoliday && hovered.e.status.holidayName ? ` · ${hovered.e.status.holidayName}` : ""}
100 + </div>
101 + {hovered.e.breadth?.median_change_percent != null && <div className="mono text-ink-2">median change {hovered.e.breadth.median_change_percent.toFixed(2)}%</div>}
102 + </div>
103 + )}
104 + </div>
105 + );
106 +}
107 +
108 +function Legend({ color, label }: { color: string; label: string }) {
109 + return (
110 + <span className="inline-flex items-center gap-1">
111 + <span className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
112 + {label}
113 + </span>
114 + );
115 +}
added apps/web/src/components/ui/data-table.tsx +85 −0
@@ -0,0 +1,85 @@
1 +"use client";
2 +
3 +import { ChevronDown, ChevronUp } from "lucide-react";
4 +import { useMemo, useState, type ReactNode } from "react";
5 +import { cx } from "@/lib/format";
6 +
7 +export interface Column<T> {
8 + key: string;
9 + header: ReactNode;
10 + cell: (row: T) => ReactNode;
11 + /** Sort accessor; omit for unsortable columns. */
12 + sort?: (row: T) => number | string | null | undefined;
13 + num?: boolean;
14 + className?: string;
15 + hideBelow?: "sm" | "md" | "lg";
16 +}
17 +
18 +const HIDE: Record<string, string> = { sm: "hidden sm:table-cell", md: "hidden md:table-cell", lg: "hidden lg:table-cell" };
19 +
20 +/** Dense, client-sortable table with sticky header. Rows are rendered by the caller's cells (which may be live components). */
21 +export function DataTable<T>({ rows, columns, rowKey, defaultSort, defaultDir = "desc", className, maxHeight, emptyText = "Nothing to show yet.", onRowHref }: { rows: T[]; columns: Column<T>[]; rowKey: (row: T) => string; defaultSort?: string; defaultDir?: "asc" | "desc"; className?: string; maxHeight?: string; emptyText?: string; onRowHref?: (row: T) => string }) {
22 + const [sortKey, setSortKey] = useState<string | undefined>(defaultSort);
23 + const [dir, setDir] = useState<"asc" | "desc">(defaultDir);
24 + const sorted = useMemo(() => {
25 + const col = columns.find((c) => c.key === sortKey);
26 + if (!col?.sort) return rows;
27 + const acc = col.sort;
28 + return [...rows].sort((a, b) => {
29 + const va = acc(a);
30 + const vb = acc(b);
31 + if (va == null && vb == null) return 0;
32 + if (va == null) return 1;
33 + if (vb == null) return -1;
34 + const c = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
35 + return dir === "asc" ? c : -c;
36 + });
37 + }, [rows, columns, sortKey, dir]);
38 + const toggle = (key: string) => {
39 + if (sortKey === key) setDir((d) => (d === "asc" ? "desc" : "asc"));
40 + else {
41 + setSortKey(key);
42 + setDir("desc");
43 + }
44 + };
45 + return (
46 + <div className={cx("overflow-x-auto rounded-md border border-rule bg-surface", className)} style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>
47 + <table className="table-dense">
48 + <thead>
49 + <tr>
50 + {columns.map((c) => (
51 + <th key={c.key} className={cx(c.num && "text-right", c.hideBelow && HIDE[c.hideBelow], c.className)}>
52 + {c.sort ? (
53 + <button type="button" onClick={() => toggle(c.key)} className={cx("inline-flex h-6 items-center gap-0.5 uppercase hover:text-ink", sortKey === c.key && "text-ink")}>
54 + {c.header}
55 + {sortKey === c.key && (dir === "asc" ? <ChevronUp size={11} /> : <ChevronDown size={11} />)}
56 + </button>
57 + ) : (
58 + c.header
59 + )}
60 + </th>
61 + ))}
62 + </tr>
63 + </thead>
64 + <tbody>
65 + {sorted.length === 0 && (
66 + <tr>
67 + <td colSpan={columns.length} className="py-8 text-center text-ink-3">
68 + {emptyText}
69 + </td>
70 + </tr>
71 + )}
72 + {sorted.map((r) => (
73 + <tr key={rowKey(r)} className={cx(onRowHref && "cursor-pointer")} onClick={onRowHref ? () => (window.location.href = onRowHref(r)) : undefined}>
74 + {columns.map((c) => (
75 + <td key={c.key} className={cx(c.num && "num", c.hideBelow && HIDE[c.hideBelow], c.className)}>
76 + {c.cell(r)}
77 + </td>
78 + ))}
79 + </tr>
80 + ))}
81 + </tbody>
82 + </table>
83 + </div>
84 + );
85 +}
added apps/web/src/components/ui/section.tsx +82 −0
@@ -0,0 +1,82 @@
1 +import Link from "next/link";
2 +import type { ReactNode } from "react";
3 +import { cx } from "@/lib/format";
4 +
5 +export function Page({ children, className, wide }: { children: ReactNode; className?: string; wide?: boolean }) {
6 + return <div className={cx("mx-auto w-full px-3 py-5 sm:px-5 sm:py-7", wide ? "max-w-[1440px]" : "max-w-[1200px]", className)}>{children}</div>;
7 +}
8 +
9 +export function PageHeader({ title, lead, kicker, actions, className }: { title: ReactNode; lead?: ReactNode; kicker?: ReactNode; actions?: ReactNode; className?: string }) {
10 + return (
11 + <div className={cx("mb-5 flex flex-wrap items-end justify-between gap-3 sm:mb-7", className)}>
12 + <div className="min-w-0">
13 + {kicker && <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">{kicker}</div>}
14 + <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
15 + {lead && <p className="mt-1.5 max-w-2xl text-sm text-ink-2">{lead}</p>}
16 + </div>
17 + {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
18 + </div>
19 + );
20 +}
21 +
22 +export function Section({ title, hint, href, hrefLabel = "View all", children, className, id, badge }: { title: ReactNode; hint?: ReactNode; href?: string; hrefLabel?: string; children: ReactNode; className?: string; id?: string; badge?: ReactNode }) {
23 + return (
24 + <section id={id} className={cx("mt-8 first:mt-0", className)}>
25 + <div className="mb-2.5 flex items-baseline justify-between gap-3 border-b border-rule pb-2">
26 + <div className="flex min-w-0 items-baseline gap-2">
27 + <h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
28 + {badge}
29 + {hint && <span className="hidden truncate text-xs text-ink-3 sm:inline">{hint}</span>}
30 + </div>
31 + {href && (
32 + <Link href={href} className="shrink-0 text-xs text-accent hover:underline">
33 + {hrefLabel} →
34 + </Link>
35 + )}
36 + </div>
37 + {children}
38 + </section>
39 + );
40 +}
41 +
42 +export function Empty({ children, className }: { children: ReactNode; className?: string }) {
43 + return <div className={cx("rounded-md border border-dashed border-rule px-4 py-8 text-center text-sm text-ink-3", className)}>{children}</div>;
44 +}
45 +
46 +export function Kv({ items, className, cols = 2 }: { items: Array<[ReactNode, ReactNode]>; className?: string; cols?: 1 | 2 | 3 | 4 }) {
47 + return (
48 + <dl className={cx("grid gap-x-6", cols === 1 ? "grid-cols-1" : cols === 2 ? "grid-cols-2" : cols === 3 ? "grid-cols-2 sm:grid-cols-3" : "grid-cols-2 sm:grid-cols-4", className)}>
49 + {items.map(([k, v], i) => (
50 + <div key={i} className="flex items-baseline justify-between gap-3 border-b border-rule py-1.5 text-sm">
51 + <dt className="text-ink-3">{k}</dt>
52 + <dd className="mono truncate text-right tnum">{v}</dd>
53 + </div>
54 + ))}
55 + </dl>
56 + );
57 +}
58 +
59 +export function Stat({ label, value, sub, tone, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; tone?: "pos" | "neg" | "warn" | "muted"; className?: string }) {
60 + return (
61 + <div className={cx("min-w-0", className)}>
62 + <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">{label}</div>
63 + <div className={cx("mono mt-0.5 truncate text-xl font-semibold tracking-tight tnum sm:text-2xl", tone === "pos" && "text-positive", tone === "neg" && "text-negative", tone === "warn" && "text-warning", tone === "muted" && "text-ink-2")}>{value}</div>
64 + {sub && <div className="mt-0.5 truncate text-xs text-ink-3">{sub}</div>}
65 + </div>
66 + );
67 +}
68 +
69 +export function Pill({ children, active, href, onClick }: { children: ReactNode; active?: boolean; href?: string; onClick?: () => void }) {
70 + const cls = cx("inline-flex h-8 items-center whitespace-nowrap rounded-full border px-3 text-xs", active ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong hover:text-ink");
71 + if (href)
72 + return (
73 + <Link href={href} className={cls}>
74 + {children}
75 + </Link>
76 + );
77 + return (
78 + <button type="button" onClick={onClick} className={cls}>
79 + {children}
80 + </button>
81 + );
82 +}
added apps/web/src/components/ui/sparkline.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import { cx } from "@/lib/format";
2 +
3 +/** Inline SVG sparkline (no library). Colour follows the sign of the series unless `stroke` is given. */
4 +export function Sparkline({ values, width = 96, height = 28, className, stroke, fill = true }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean }) {
5 + const pts = values.filter((v) => Number.isFinite(v));
6 + if (pts.length < 2) return <svg width={width} height={height} className={className} aria-hidden="true" />;
7 + const min = Math.min(...pts);
8 + const max = Math.max(...pts);
9 + const span = max - min || 1;
10 + const step = width / (pts.length - 1);
11 + const coords = pts.map((v, i) => [i * step, height - 2 - ((v - min) / span) * (height - 4)] as const);
12 + const d = coords.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
13 + const up = pts[pts.length - 1]! >= pts[0]!;
14 + const color = stroke ?? (up ? "var(--positive)" : "var(--negative)");
15 + return (
16 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cx("overflow-visible", className)} aria-hidden="true">
17 + {fill && <path d={`${d} L${width},${height} L0,${height} Z`} fill={color} opacity="0.12" />}
18 + <path d={d} fill="none" stroke={color} strokeWidth="1.4" strokeLinejoin="round" strokeLinecap="round" />
19 + </svg>
20 + );
21 +}
modified connectors/src/hfmarketdata/index.ts +1 −1
@@ -47,7 +47,7 @@ export const commodityHint = (root: string, name: string, exchangeId: string): I
47 47 });
48 48
49 49 const TARGETS: Target[] = [
50 − ...US_EQUITIES.map(([s, n, v]) => ({ path: `/bars/stock/${encodeURIComponent(s.replace(".", "-"))}?timeframe=1day&limit=400&order=desc`, symbol: s, hint: equityHint(s, n, v, "EQUITY"), kind: "bars" as const, sessionCloseLocal: "16:00", timezone: ET })),
50 + ...US_EQUITIES.map(([s, n, v]) => ({ path: `/bars/stock/${encodeURIComponent(s)}?timeframe=1day&limit=400&order=desc`, symbol: s, hint: equityHint(s, n, v, "EQUITY"), kind: "bars" as const, sessionCloseLocal: "16:00", timezone: ET })),
51 51 ...US_ETFS.map(([s, n, v]) => ({ path: `/bars/etf/${s}?timeframe=1day&limit=400&order=desc`, symbol: s, hint: equityHint(s, n, v, "ETF"), kind: "bars" as const, sessionCloseLocal: "16:00", timezone: ET })),
52 52 ...US_INDICES.filter((i) => i.hfmd).map((i) => ({ path: `/bars/index/${i.hfmd}?timeframe=1day&limit=400&order=desc`, symbol: i.cboe, hint: indexHint(i), kind: "bars" as const, sessionCloseLocal: "16:00", timezone: ET })),
53 53 ...FX_PAIRS.map((p) => ({ path: `/bars/fx/${p}?timeframe=1day&limit=400&order=desc`, symbol: p, hint: fxHint(p.slice(0, 3), p.slice(3)), kind: "bars" as const, sessionCloseLocal: "17:00", timezone: ET })),
modified connectors/src/okx-ws/index.ts +1 −1
@@ -3,7 +3,7 @@ import { parseTimestamp } from "@market-atlas/market-model";
3 3 import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js";
4 4
5 5 const WS_URL = "wss://ws.okx.com:8443/ws/v5/public";
6 −const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TON", "USDT"], ["TRX", "USDT"]]);
6 +const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TRX", "USDT"]]);
7 7 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`);
8 8
9 9 /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */
modified docs/ARCHITECTURE.md +3 −3
@@ -32,14 +32,14 @@ derives events, keeps the history, and redistributes the result through one web
32 32
33 33 - **L0 raw** — `MA_DATA_DIR/raw/<day>/<connector>/<sha>.json.gz` (redacted, content-addressed). Polled/bulk payloads always; streaming frames sampled (`MA_RAW_SAMPLE_RATE`). `raw_ref` on observations points back. `ma replay <day>` re-normalizes offline.
34 34 - **L1/L2** — connector `normalize()` (pure, deterministic, no network) → `NormalizedObservation` with source symbol + instrument hint, rights & real-time status, timestamp trust.
35 −- **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index).
35 +- **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`; REALTIME streaming ticks are persisted at most once per source/instrument/field per `MA_TICK_PERSIST_INTERVAL_MS` = 2 s while consensus/events see every tick). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index).
36 36 - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`.
37 37
38 38 ## Consensus (apps/api/src/core/consensus.ts)
39 39
40 40 Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a
41 −window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 3 d; 4 d for live classes while the
42 −venue is closed). Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus. One vote per
41 +window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 10 d; 4 d for live classes while the
42 +venue is closed — the last session value is reported as AT_CLOSE, never as live). Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus. One vote per
43 43 **source family** (sources believed to share an upstream count once). When live classes exist, END_OF_DAY/INDICATIVE values are
44 44 *superseded*. Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps),
45 45 independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full
modified infra/migrations/0001_init.sql +1 −1
@@ -211,7 +211,7 @@ create table if not exists canonical_quotes (
211 211 source_count integer not null default 0,
212 212 dispersion_bps double precision,
213 213 confidence double precision not null default 0,
214 − freshness_ms integer,
214 + freshness_ms bigint,
215 215 realtime_status text not null default 'UNKNOWN',
216 216 rights_status text not null default 'UNKNOWN',
217 217 updated_at timestamptz not null,
added scripts/backup-offnode.sh +16 −0
@@ -0,0 +1,16 @@
1 +#!/bin/bash
2 +# Copy Market Atlas backups (pg_dump metadata) and the observation archive from the production node to the gateway.
3 +# Usage: scripts/backup-offnode.sh [node] [dest-host] (defaults: M2U64 → M1M32:~/backups/market-atlas)
4 +set -euo pipefail
5 +NODE=${1:-M2U64}
6 +DEST=${2:-M1M32}
7 +ssh "$DEST" 'mkdir -p ~/backups/market-atlas/{backups,archive}'
8 +ssh "$NODE" 'ls ~/market-atlas-data/backups | tail -3'
9 +# Node → laptop → gateway (nodes reach each other on the LAN, but keep the laptop as the pivot so agent forwarding works everywhere).
10 +TMP=$(mktemp -d)
11 +rsync -a --delete "$NODE:~/market-atlas-data/backups/" "$TMP/backups/"
12 +rsync -a "$NODE:~/market-atlas-data/archive/" "$TMP/archive/"
13 +rsync -a "$TMP/backups/" "$DEST:~/backups/market-atlas/backups/"
14 +rsync -a "$TMP/archive/" "$DEST:~/backups/market-atlas/archive/"
15 +rm -rf "$TMP"
16 +ssh "$DEST" 'du -sh ~/backups/market-atlas'
17