SPB Git

spb/market-atlas

Public

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 (Sep 12, 2026) parent 485d449 Browse files

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 MA_DISABLED_CONNECTORS= # comma-separated connector ids to keep off 13 MA_DISABLED_CONNECTORS= # comma-separated connector ids to keep off
14 MA_RAW_SAMPLE_RATE=0.02 # share of streaming frames archived at L0 (bulk/poll payloads are always archived) 14 MA_RAW_SAMPLE_RATE=0.02 # share of streaming frames archived at L0 (bulk/poll payloads are always archived)
15 MA_OBSERVATION_RETENTION_DAYS=45 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 connectors_total: list.length, 104 connectors_total: list.length,
105 connectors_healthy: states.filter((s) => s === "HEALTHY").length, 105 connectors_healthy: states.filter((s) => s === "HEALTHY").length,
106 observations_today: daily.rows[0]?.observations ?? 0, 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 observations_total: telemetry.counter("observations_written_total"), 108 observations_total: telemetry.counter("observations_written_total"),
109 events_today: daily.rows[0]?.events ?? 0, 109 events_today: daily.rows[0]?.events ?? 0,
110 events_24h: evts.rows[0]?.n ?? 0, 110 events_24h: evts.rows[0]?.n ?? 0,
@@ -432,7 +432,7 @@ export async function registerPublicRoutes(app: FastifyInstance) {
432 multi_source_quotes: quotes.filter((x) => x.sourceCount >= 2).length, 432 multi_source_quotes: quotes.filter((x) => x.sourceCount >= 2).length,
433 mean_confidence: quotes.length ? round(quotes.reduce((a, x) => a + x.confidence, 0) / quotes.length, 3) : null, 433 mean_confidence: quotes.length ? round(quotes.reduce((a, x) => a + x.confidence, 0) / quotes.length, 3) : null,
434 mean_dispersion_bps: round(mean(quotes.map((x) => x.dispersionBps).filter((x): x is number => x != null)), 2), 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 queue_depth: telemetry.counter("observation_queue_depth"), 436 queue_depth: telemetry.counter("observation_queue_depth"),
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 })), 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 incidents: incidents.rows.map((r) => ({ id: r.id, type: r.type, ts: r.ts, severity: r.severity, title: r.title, data: r.data })), 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 writerMaxBatch: 2000, // 17 columns × 2000 rows stays under Postgres' 65,535 bind-parameter limit 40 writerMaxBatch: 2000, // 17 columns × 2000 rows stays under Postgres' 65,535 bind-parameter limit
41 /** Consensus: observations older than this (ms) are excluded for REALTIME sources. */ 41 /** Consensus: observations older than this (ms) are excluded for REALTIME sources. */
42 consensusFreshWindowMs: 15_000, 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 version: "0.1.0", 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 REALTIME: config.consensusFreshWindowMs, 18 REALTIME: config.consensusFreshWindowMs,
19 DELAYED: 30 * 60_000, 19 DELAYED: 30 * 60_000,
20 INDICATIVE: 60 * 60_000, 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 STALE: 0, 22 STALE: 0,
23 UNKNOWN: 60_000, 23 UNKNOWN: 60_000,
24 }; 24 };
modified apps/api/src/core/pipeline.ts +17 −1
@@ -1,6 +1,7 @@
1 import type { Bar, Instrument, NormalizedObservation, Observation, RawObservation } from "@market-atlas/market-model"; 1 import type { Bar, Instrument, NormalizedObservation, Observation, RawObservation } from "@market-atlas/market-model";
2 import { CANONICAL_VERSIONS, NormalizedObservationSchema, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; 2 import { CANONICAL_VERSIONS, NormalizedObservationSchema, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model";
3 import { observationFingerprint, type ConnectorDefinition, type NormalizedBatch } from "@market-atlas/connector-sdk"; 3 import { observationFingerprint, type ConnectorDefinition, type NormalizedBatch } from "@market-atlas/connector-sdk";
4+import { config } from "../config.js";
4 import { pool } from "../db/pool.js"; 5 import { pool } from "../db/pool.js";
5 import { logger } from "../logger.js"; 6 import { logger } from "../logger.js";
6 import { barAggregator, upsertBars } from "./bars.js"; 7 import { barAggregator, upsertBars } from "./bars.js";
@@ -82,13 +83,15 @@ export class Pipeline {
82 continue; 83 continue;
83 } 84 }
84 const obs = this.toObservation(n, inst, raw, rawRef); 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 this.consensus.ingest(obs); 88 this.consensus.ingest(obs);
87 bus.publish("normalized.observation", obs); 89 bus.publish("normalized.observation", obs);
88 touched.set(inst.id, inst); 90 touched.set(inst.id, inst);
89 ids.push(inst.id); 91 ids.push(inst.id);
90 accepted++; 92 accepted++;
91 } 93 }
94+ telemetry.inc("observations_accepted_total", accepted);
92 const latency = batch.observations.find((o) => o.sourceTimestamp)?.sourceTimestamp; 95 const latency = batch.observations.find((o) => o.sourceTimestamp)?.sourceTimestamp;
93 health.message(connectorId, latency ? raw.receivedAt - latency : null, ids); 96 health.message(connectorId, latency ? raw.receivedAt - latency : null, ids);
94 for (const [id, inst] of touched) this.recomputeQueue.set(id, inst); 97 for (const [id, inst] of touched) this.recomputeQueue.set(id, inst);
@@ -113,6 +116,19 @@ export class Pipeline {
113 if (out.length) telemetry.inc("bars_backfilled_total", out.length, { connector: def.metadata.id }); 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 private toObservation(n: NormalizedObservation, inst: Instrument, raw: RawObservation, rawRef: string | null): Observation { 132 private toObservation(n: NormalizedObservation, inst: Instrument, raw: RawObservation, rawRef: string | null): Observation {
117 const fp = observationFingerprint({ 133 const fp = observationFingerprint({
118 sourceId: raw.sourceId, 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 const TARGETS: Target[] = [ 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 ...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 })), 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 ...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 })), 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 ...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 })), 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 import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; 3 import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js";
4 4
5 const WS_URL = "wss://ws.okx.com:8443/ws/v5/public"; 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 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); 7 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`);
8 8
9 /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */ 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 - **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. 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 - **L1/L2** — connector `normalize()` (pure, deterministic, no network) → `NormalizedObservation` with source symbol + instrument hint, rights & real-time status, timestamp trust. 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 - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`. 36 - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`.
37 37
38 ## Consensus (apps/api/src/core/consensus.ts) 38 ## Consensus (apps/api/src/core/consensus.ts)
39 39
40 Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a 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 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). Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus. One vote per 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 **source family** (sources believed to share an upstream count once). When live classes exist, END_OF_DAY/INDICATIVE values are 43 **source family** (sources believed to share an upstream count once). When live classes exist, END_OF_DAY/INDICATIVE values are
44 *superseded*. Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps), 44 *superseded*. Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps),
45 independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full 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 source_count integer not null default 0, 211 source_count integer not null default 0,
212 dispersion_bps double precision, 212 dispersion_bps double precision,
213 confidence double precision not null default 0, 213 confidence double precision not null default 0,
214− freshness_ms integer, 214+ freshness_ms bigint,
215 realtime_status text not null default 'UNKNOWN', 215 realtime_status text not null default 'UNKNOWN',
216 rights_status text not null default 'UNKNOWN', 216 rights_status text not null default 'UNKNOWN',
217 updated_at timestamptz not null, 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
Menu

Theme