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>
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 | ||