SPB Git

spb/market-atlas

Public

web: Next.js app (30 routes, live stream hook, world map, provenance panel, admin console); api: ISO timestamps, hfmd continuous window, calendar labels

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 9bd871f Browse files

69 changed files +3,684 −19

69 changed files
modified .env.example +1 −0
@@ -14,3 +14,4 @@ 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) 16 MA_TICK_PERSIST_INTERVAL_MS=2000 # store at most one REALTIME tick per source/instrument/field per 2 s (consensus sees every tick)
17+MA_WEB_UPSTREAM= # production edge mode: proxy every non-/v1 path to the Next.js app (http://127.0.0.1:8382)
modified .gitignore +2 −0
@@ -17,3 +17,5 @@ deploy/.hfmd-key
17 deploy/rendered/ 17 deploy/rendered/
18 apps/web/qa/screens/ 18 apps/web/qa/screens/
19 coverage/ 19 coverage/
20+apps/web/AGENTS.md
21+apps/web/CLAUDE.md
modified apps/api/src/api/routes/admin.ts +2 −0
@@ -79,6 +79,8 @@ export async function registerAdminRoutes(app: FastifyInstance) {
79 symbols: rt.symbols, 79 symbols: rt.symbols,
80 schedule: rt.def.schedule ?? null, 80 schedule: rt.def.schedule ?? null,
81 state: redactObject(rt.stateCache), 81 state: redactObject(rt.stateCache),
82+ consecutive_failures: rt.consecutiveFailures,
83+ drift_strikes: rt.driftStrikes,
82 schema_changes: changes.rows, 84 schema_changes: changes.rows,
83 health_history: healthHistory.rows, 85 health_history: healthHistory.rows,
84 recent_observations: recent.rows, 86 recent_observations: recent.rows,
modified apps/api/src/db/pool.ts +1 −2
@@ -3,8 +3,7 @@ import { config } from "../config.js";
3 import { logger } from "../logger.js"; 3 import { logger } from "../logger.js";
4 4
5 const { Pool, types } = pg; 5 const { Pool, types } = pg;
6−// Keep timestamptz as ISO strings → we convert to ms explicitly; numeric/int8 as numbers where safe. 6+// timestamptz stays a Date (pg default → ISO in JSON); numeric/int8 as numbers where safe.
7−types.setTypeParser(1184, (v) => v);
8 types.setTypeParser(20, (v) => Number(v)); 7 types.setTypeParser(20, (v) => Number(v));
9 types.setTypeParser(1700, (v) => Number(v)); 8 types.setTypeParser(1700, (v) => Number(v));
10 9
modified apps/web/package.json +1 −0
@@ -28,6 +28,7 @@
28 "@types/react": "^19", 28 "@types/react": "^19",
29 "@types/react-dom": "^19", 29 "@types/react-dom": "^19",
30 "@types/topojson-client": "^3.1.5", 30 "@types/topojson-client": "^3.1.5",
31+ "@types/topojson-specification": "^1.0.5",
31 "tailwindcss": "^4", 32 "tailwindcss": "^4",
32 "typescript": "^5.9.3" 33 "typescript": "^5.9.3"
33 } 34 }
added apps/web/qa/screens.mjs +45 −0
@@ -0,0 +1,45 @@
1+/**
2+ * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot.
3+ * Run: node qa/screens.mjs [BASE_URL] (default http://localhost:8390)
4+ * Playwright is resolved from the uqo-eval project on this machine (see import below); adjust if needed.
5+ */
6+import { chromium } from "/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs";
7+import { mkdirSync } from "node:fs";
8+
9+const BASE = process.argv[2] ?? "http://localhost:8390";
10+const OUT = new URL("./screens/", import.meta.url).pathname;
11+mkdirSync(OUT, { recursive: true });
12+
13+const PAGES = ["/", "/live", "/markets", "/crypto", "/stocks", "/indices", "/forex", "/rates", "/commodities", "/instruments/crypto_btc_usd", "/instruments/AAPL", "/instruments/US10Y", "/exchanges", "/exchanges/xnas", "/countries", "/countries/CA", "/events", "/halts", "/filings", "/sources", "/connectors", "/data-health", "/search?q=bitcoin", "/compare?ids=crypto_btc_usd,crypto_eth_usd", "/status", "/methodology", "/developers", "/licensing", "/admin", "/does-not-exist"];
14+const WIDTHS = [390, 1440];
15+const THEMES = ["dark", "light"];
16+
17+const browser = await chromium.launch();
18+let failures = 0;
19+for (const theme of THEMES) {
20+ for (const width of WIDTHS) {
21+ const ctx = await browser.newContext({ viewport: { width, height: width < 600 ? 844 : 900 }, deviceScaleFactor: 1 });
22+ await ctx.addInitScript((t) => localStorage.setItem("ma-theme", t), theme);
23+ for (const path of PAGES) {
24+ const page = await ctx.newPage();
25+ const errors = [];
26+ page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
27+ page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
28+ const res = await page.goto(BASE + path, { waitUntil: "networkidle", timeout: 60_000 }).catch((e) => ({ status: () => `ERR ${e.message}` }));
29+ await page.waitForTimeout(1200);
30+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
31+ const status = res.status();
32+ const expected = path === "/does-not-exist" ? 404 : 200;
33+ const errs = errors.filter((e) => !/favicon|404 \(Not Found\)|hydrat/i.test(e) || /Hydration failed|did not match/.test(e));
34+ const ok = status === expected && !overflow && errs.length === 0;
35+ if (!ok) failures++;
36+ console.log(`${ok ? "OK " : "FAIL"} ${theme.padEnd(5)} ${String(width).padEnd(4)} ${String(status).padEnd(4)} ${overflow ? "OVERFLOW " : ""}${path}${errs.length ? `\n ${errs.slice(0, 3).join("\n ")}` : ""}`);
37+ await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "home"}.png`, fullPage: width >= 600 });
38+ await page.close();
39+ }
40+ await ctx.close();
41+ }
42+}
43+await browser.close();
44+console.log(failures ? `\n${failures} failing checks` : "\nall checks passed");
45+process.exit(failures ? 1 : 0);
added apps/web/src/app/admin/page.tsx +8 −0
@@ -0,0 +1,8 @@
1+import type { Metadata } from "next";
2+import { AdminConsole } from "@/components/admin/admin-console";
3+
4+export const metadata: Metadata = { title: "Admin", robots: { index: false, follow: false } };
5+
6+export default function AdminPage() {
7+ return <AdminConsole />;
8+}
added apps/web/src/app/commodities/page.tsx +9 −0
@@ -0,0 +1,9 @@
1+import type { Metadata } from "next";
2+import { ClassPage } from "@/components/market/class-page";
3+
4+export const metadata: Metadata = { title: "Commodities & futures", description: "Continuous futures for gold, silver, crude oil, natural gas, copper, grains and index futures.", alternates: { canonical: "/commodities" } };
5+export const dynamic = "force-dynamic";
6+
7+export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
8+ return <ClassPage classes={["COMMODITY", "FUTURE"]} kicker="Derivatives" title="Commodities & futures" lead="CME continuous front-month series (volume roll, unadjusted) from the HF Market Data lake — end-of-day settlements, labelled accordingly." basePath="/commodities" searchParams={searchParams} showExchange={false} defaultSort="symbol" />;
9+}
added apps/web/src/app/compare/page.tsx +18 −0
@@ -0,0 +1,18 @@
1+import type { Metadata } from "next";
2+import { CompareView } from "@/components/market/compare-view";
3+import { Page, PageHeader } from "@/components/ui/section";
4+
5+export const metadata: Metadata = { title: "Compare", description: "Compare instruments across asset classes: normalized performance, volatility, drawdown and correlation.", alternates: { canonical: "/compare" } };
6+export const dynamic = "force-dynamic";
7+
8+export default async function ComparePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
9+ const sp = await searchParams;
10+ const ids = (typeof sp.ids === "string" ? sp.ids : "crypto_btc_usd,crypto_eth_usd,eq_us_xnas_aapl,index_us_spx").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 8);
11+ const resolution = typeof sp.resolution === "string" && ["1m", "1h", "1d"].includes(sp.resolution) ? (sp.resolution as "1m" | "1h" | "1d") : "1d";
12+ return (
13+ <Page wide>
14+ <PageHeader kicker="Analysis" title="Compare" lead="Rebased performance (first point = 100), realized volatility, maximum drawdown and pairwise correlation of returns on aligned timestamps. Derived metrics computed by Market Atlas from canonical bars." />
15+ <CompareView initialIds={ids} initialResolution={resolution} />
16+ </Page>
17+ );
18+}
added apps/web/src/app/connectors/page.tsx +76 −0
@@ -0,0 +1,76 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { RelativeTime } from "@/components/ui/freshness";
4+import { Page, PageHeader } from "@/components/ui/section";
5+import { RightsBadge, StatusBadge } from "@/components/ui/status-badge";
6+import { api } from "@/lib/api";
7+import { formatDuration } from "@/lib/format";
8+import type { ConnectorPublic } from "@/lib/types";
9+
10+export const metadata: Metadata = { title: "Connectors", description: "Operational status of every Market Atlas connector: type, rights, message rate, latency, coverage and reliability.", alternates: { canonical: "/connectors" } };
11+export const dynamic = "force-dynamic";
12+
13+export default async function ConnectorsPage() {
14+ const rows = await api<ConnectorPublic[]>("/v1/connectors");
15+ const healthy = rows.filter((r) => r.status === "HEALTHY").length;
16+ return (
17+ <Page wide>
18+ <PageHeader kicker="Operations" title="Connectors" lead={`${rows.length} connectors, ${healthy} healthy. A connector is one technical integration of a source (WebSocket feed, JSON endpoint, official file, RSS, HTML page…). The framework proves it generalizes across ${new Set(rows.map((r) => r.source_type)).size} transport types.`} actions={<Link href="/data-health" className="text-sm text-accent hover:underline">Data health →</Link>} />
19+ <div className="overflow-x-auto rounded-md border border-rule bg-surface">
20+ <table className="table-dense">
21+ <thead>
22+ <tr>
23+ <th>Connector</th>
24+ <th>Status</th>
25+ <th>Type</th>
26+ <th>Rights</th>
27+ <th className="hidden md:table-cell">Real time</th>
28+ <th className="text-right">Msg / min</th>
29+ <th className="text-right">Total</th>
30+ <th className="hidden text-right lg:table-cell">p50 / p95 latency</th>
31+ <th className="hidden text-right lg:table-cell">Parse</th>
32+ <th className="text-right">Instruments</th>
33+ <th className="text-right">Score</th>
34+ <th>Last message</th>
35+ <th className="hidden xl:table-cell">Next poll</th>
36+ </tr>
37+ </thead>
38+ <tbody>
39+ {rows.map((r) => (
40+ <tr key={r.id}>
41+ <td className="max-w-[340px] whitespace-normal">
42+ <div className="font-medium">{r.name}</div>
43+ <div className="mono text-[11px] text-ink-3">
44+ {r.id} · v{r.version} ·{" "}
45+ <Link href={`/sources#${r.source_id}`} className="hover:underline">
46+ {r.source_id}
47+ </Link>
48+ </div>
49+ <div className="mt-0.5 text-xs text-ink-2">{r.description}</div>
50+ </td>
51+ <td>
52+ <StatusBadge status={r.status} />
53+ </td>
54+ <td className="mono text-xs">{r.source_type}</td>
55+ <td>
56+ <RightsBadge status={r.rights_status} />
57+ </td>
58+ <td className="hidden text-xs text-ink-2 md:table-cell">{r.realtime_status.replace(/_/g, " ").toLowerCase()}</td>
59+ <td className="num">{r.messages_1m}</td>
60+ <td className="num">{r.messages_total.toLocaleString("en-US")}</td>
61+ <td className="num hidden lg:table-cell">
62+ {r.median_latency_ms == null ? "—" : formatDuration(r.median_latency_ms)} / {r.p95_latency_ms == null ? "—" : formatDuration(r.p95_latency_ms)}
63+ </td>
64+ <td className="num hidden lg:table-cell">{r.parse_success_rate == null ? "—" : `${Math.round(r.parse_success_rate * 100)}%`}</td>
65+ <td className="num">{r.instruments_covered}</td>
66+ <td className="num">{r.reliability_score ?? "—"}</td>
67+ <td className="text-xs text-ink-3">{r.last_message_at ? <RelativeTime value={r.last_message_at} /> : "—"}</td>
68+ <td className="hidden text-xs text-ink-3 xl:table-cell">{r.next_poll_at ? <RelativeTime value={r.next_poll_at} /> : r.supports_streaming ? "streaming" : "—"}</td>
69+ </tr>
70+ ))}
71+ </tbody>
72+ </table>
73+ </div>
74+ </Page>
75+ );
76+}
added apps/web/src/app/countries/[code]/page.tsx +103 −0
@@ -0,0 +1,103 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { EventRow } from "@/components/market/event-row";
4+import { InstrumentTable } from "@/components/market/instrument-table";
5+import { Empty, Page, PageHeader, Section, Stat } from "@/components/ui/section";
6+import { StatusBadge } from "@/components/ui/status-badge";
7+import { api } from "@/lib/api";
8+import type { Breadth, Country, Exchange, ExchangeStatus, InstrumentWithQuote, MarketEvent } from "@/lib/types";
9+
10+interface Detail {
11+ country: Country;
12+ exchanges: Array<Exchange & { status: ExchangeStatus }>;
13+ indices: InstrumentWithQuote[];
14+ rates: InstrumentWithQuote[];
15+ fx: InstrumentWithQuote[];
16+ equities: InstrumentWithQuote[];
17+ breadth: Breadth | null;
18+ events: MarketEvent[];
19+ instruments_tracked: number;
20+}
21+
22+export const dynamic = "force-dynamic";
23+
24+export async function generateMetadata({ params }: { params: Promise<{ code: string }> }): Promise<Metadata> {
25+ const { code } = await params;
26+ try {
27+ const d = await api<Detail>(`/v1/countries/${encodeURIComponent(code)}`);
28+ return { title: `${d.country.name} — markets`, description: `${d.country.name}: exchanges, indices, rates, ${d.country.currency ?? "currency"} pairs and the equities Market Atlas observes.`, alternates: { canonical: `/countries/${d.country.code}` } };
29+ } catch {
30+ return { title: "Country" };
31+ }
32+}
33+
34+export default async function CountryPage({ params }: { params: Promise<{ code: string }> }) {
35+ const { code } = await params;
36+ const d = await api<Detail>(`/v1/countries/${encodeURIComponent(code)}`);
37+ const c = d.country;
38+ const open = d.exchanges.filter((e) => e.status.state === "OPEN").length;
39+ return (
40+ <Page wide>
41+ <PageHeader
42+ kicker={
43+ <Link href="/countries" className="hover:text-ink">
44+ Countries · {c.region}
45+ </Link>
46+ }
47+ title={c.name}
48+ lead={`${c.currency ? `Currency ${c.currency} · ` : ""}${d.exchanges.length} venue${d.exchanges.length === 1 ? "" : "s"} in the atlas · ${d.instruments_tracked.toLocaleString("en-US")} instruments tracked.`}
49+ />
50+ <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4">
51+ <Stat label="Venues open" value={`${open} / ${d.exchanges.length}`} />
52+ <Stat label="Indices" value={d.indices.length} sub="with canonical level" />
53+ <Stat label="Rates & yields" value={d.rates.length} sub="official series" />
54+ <Stat label="Equity breadth" value={d.breadth ? `${d.breadth.advancers} ▲ ${d.breadth.decliners} ▼` : "—"} sub={d.breadth ? `${d.breadth.instruments} quoted` : "no quoted equities"} />
55+ </div>
56+ <Section title="Exchanges" href="/exchanges">
57+ {d.exchanges.length ? (
58+ <ul className="grid grid-cols-1 [&>*]:min-w-0 gap-2 sm:grid-cols-2 lg:grid-cols-3">
59+ {d.exchanges.map((e) => (
60+ <li key={e.id} className="rounded-md border border-rule bg-surface px-3 py-2.5">
61+ <Link href={`/exchanges/${e.id}`} className="font-medium hover:underline">
62+ {e.name}
63+ </Link>
64+ <div className="mt-1 flex items-center gap-2 text-xs text-ink-3">
65+ <StatusBadge status={e.status.state} /> <span className="mono">{e.status.localTime}</span> local · {e.mic ?? e.id}
66+ </div>
67+ </li>
68+ ))}
69+ </ul>
70+ ) : (
71+ <Empty>No venue of {c.name} is in the atlas yet.</Empty>
72+ )}
73+ </Section>
74+ {d.indices.length > 0 && (
75+ <Section title="Indices" href="/indices">
76+ <InstrumentTable rows={d.indices} compact showExchange={false} />
77+ </Section>
78+ )}
79+ <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-2">
80+ <Section title="Rates & yields" href="/rates" hint="percent · official">
81+ <InstrumentTable rows={d.rates} compact showExchange={false} defaultSort="symbol" emptyText={`No official rate series for ${c.name} yet.`} />
82+ </Section>
83+ <Section title={`${c.currency ?? "Currency"} pairs`} href="/forex" hint="reference rates">
84+ <InstrumentTable rows={d.fx} compact showExchange={false} defaultSort="symbol" emptyText="No currency pairs yet." />
85+ </Section>
86+ </div>
87+ <Section title="Equities" href={`/stocks`} hint="most active among tracked names">
88+ <InstrumentTable rows={d.equities} compact emptyText={`No quoted equities for ${c.name} yet — the instrument master lists them but no source publishes their prices.`} />
89+ </Section>
90+ <Section title="Events">
91+ {d.events.length ? (
92+ <ul className="rounded-md border border-rule bg-surface px-3">
93+ {d.events.map((e) => (
94+ <EventRow key={e.id} e={e} />
95+ ))}
96+ </ul>
97+ ) : (
98+ <Empty>No events touching {c.name} instruments yet.</Empty>
99+ )}
100+ </Section>
101+ </Page>
102+ );
103+}
added apps/web/src/app/countries/page.tsx +49 −0
@@ -0,0 +1,49 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Page, PageHeader, Section } from "@/components/ui/section";
4+import { StatusBadge } from "@/components/ui/status-badge";
5+import { api } from "@/lib/api";
6+import type { Country } from "@/lib/types";
7+
8+type Row = Country & { exchanges: Array<{ id: string; name: string; status: string }>; instruments_tracked: number };
9+
10+export const metadata: Metadata = { title: "Countries", description: "Market atlas by country: exchanges, indices, rates and currencies observed by Market Atlas.", alternates: { canonical: "/countries" } };
11+export const dynamic = "force-dynamic";
12+
13+export default async function CountriesPage() {
14+ const rows = await api<Row[]>("/v1/countries");
15+ const regions = [...new Set(rows.map((r) => r.region))].sort();
16+ return (
17+ <Page wide>
18+ <PageHeader kicker="World" title="Countries" lead="Every country in the atlas with its venues and what Market Atlas currently tracks there. Coverage is a measure of Market Atlas observation, not of market size." />
19+ {regions.map((region) => (
20+ <Section key={region} title={region}>
21+ <ul className="grid grid-cols-1 [&>*]:min-w-0 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
22+ {rows
23+ .filter((r) => r.region === region)
24+ .sort((a, b) => b.instruments_tracked - a.instruments_tracked || a.name.localeCompare(b.name))
25+ .map((r) => (
26+ <li key={r.code} className="rounded-md border border-rule bg-surface px-3 py-2.5 hover:border-rule-strong">
27+ <Link href={`/countries/${r.code}`} className="flex items-baseline justify-between gap-2">
28+ <span className="font-medium">{r.name}</span>
29+ <span className="mono text-xs text-ink-3">
30+ {r.code} · {r.currency ?? "—"}
31+ </span>
32+ </Link>
33+ <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">
34+ {r.exchanges.slice(0, 3).map((e) => (
35+ <span key={e.id} className="inline-flex items-center gap-1">
36+ <StatusBadge status={e.status} label={e.status.toLowerCase()} /> {e.name}
37+ </span>
38+ ))}
39+ {!r.exchanges.length && <span>no venue in the atlas</span>}
40+ </div>
41+ <div className="mono mt-1 text-[11px] text-ink-3">{r.instruments_tracked.toLocaleString("en-US")} instruments tracked</div>
42+ </li>
43+ ))}
44+ </ul>
45+ </Section>
46+ ))}
47+ </Page>
48+ );
49+}
added apps/web/src/app/crypto/[symbol]/page.tsx +7 −0
@@ -0,0 +1,7 @@
1+import { permanentRedirect } from "next/navigation";
2+
3+/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */
4+export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) {
5+ const { symbol } = await params;
6+ permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`);
7+}
added apps/web/src/app/crypto/page.tsx +9 −0
@@ -0,0 +1,9 @@
1+import type { Metadata } from "next";
2+import { ClassPage } from "@/components/market/class-page";
3+
4+export const metadata: Metadata = { title: "Crypto", description: "Live crypto prices as a consensus of Coinbase, Kraken, Binance and OKX public feeds.", alternates: { canonical: "/crypto" } };
5+export const dynamic = "force-dynamic";
6+
7+export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
8+ return <ClassPage classes={["CRYPTO"]} kicker="Digital assets" title="Crypto" lead="Real-time spot prices formed from independent venue feeds (Coinbase, Kraken, Binance, OKX). USD and USDT pairs are distinct instruments; the dispersion between venues is visible in every provenance panel." basePath="/crypto" searchParams={searchParams} defaultSort="volume" />;
9+}
added apps/web/src/app/data-health/page.tsx +111 −0
@@ -0,0 +1,111 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { RelativeTime } from "@/components/ui/freshness";
4+import { Empty, Page, PageHeader, Section, Stat } from "@/components/ui/section";
5+import { Sparkline } from "@/components/ui/sparkline";
6+import { StatusBadge } from "@/components/ui/status-badge";
7+import { api } from "@/lib/api";
8+import { formatDateTime, formatDuration } from "@/lib/format";
9+import type { DataHealth } from "@/lib/types";
10+
11+export const metadata: Metadata = { title: "Data health", description: "Live health of Market Atlas' observation network: connector states, freshness, multi-source coverage, confidence and incidents.", alternates: { canonical: "/data-health" } };
12+export const dynamic = "force-dynamic";
13+
14+export default async function DataHealthPage() {
15+ const h = await api<DataHealth>("/v1/data-health");
16+ const c = h.connectors;
17+ return (
18+ <Page wide>
19+ <PageHeader kicker="Transparency" title="Data health" lead="How well Market Atlas is observing the market right now. These are operational metrics about our own network — freshness, redundancy, agreement, incidents — not statements about market quality." actions={<Link href="/status" className="text-sm text-accent hover:underline">Public status →</Link>} />
20+ <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-3 lg:grid-cols-6">
21+ <Stat label="Connectors healthy" value={h.healthy_ratio == null ? "—" : `${Math.round(h.healthy_ratio * 100)}%`} sub={`${c.healthy} / ${c.total}`} tone={h.healthy_ratio != null && h.healthy_ratio < 0.8 ? "warn" : "pos"} />
22+ <Stat label="Median freshness" value={formatDuration(h.median_freshness_ms)} sub={`p95 ${formatDuration(h.p95_freshness_ms)} · live quotes`} />
23+ <Stat label="Public quotes" value={h.quotes_public} sub={`${h.quotes_realtime} real time`} />
24+ <Stat label="Multi-source" value={h.quotes_public ? `${Math.round((h.multi_source_quotes / h.quotes_public) * 100)}%` : "—"} sub={`${h.multi_source_quotes} quotes with ≥ 2 independent sources`} />
25+ <Stat label="Mean confidence" value={h.mean_confidence == null ? "—" : `${Math.round(h.mean_confidence * 100)}%`} sub={`dispersion ${h.mean_dispersion_bps ?? "—"} bps`} />
26+ <Stat label="Observations / s" value={h.observations_per_sec.toFixed(1)} sub={`queue ${h.queue_depth}`} />
27+ </div>
28+ <div className="mt-3 flex flex-wrap gap-3 text-xs text-ink-2">
29+ <span className="text-positive">{c.healthy} healthy</span>
30+ <span className="text-warning">{c.degraded} degraded / stale</span>
31+ <span className="text-accent">{c.recovering} recovering</span>
32+ <span className="text-negative">{c.failed} failed</span>
33+ <span className="text-stale">{c.paused} paused</span>
34+ </div>
35+ {h.history.length > 1 && (
36+ <Section title="Last 48 hours" hint="hourly averages from connector health snapshots">
37+ <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 rounded-md border border-rule bg-surface p-4 sm:grid-cols-3">
38+ <Trend label="Healthy ratio" values={h.history.map((x) => x.healthy_ratio)} fmt={(v) => `${Math.round(v * 100)}%`} />
39+ <Trend label="Messages / hour" values={h.history.map((x) => x.messages)} fmt={(v) => Math.round(v).toLocaleString("en-US")} />
40+ <Trend label="Median latency" values={h.history.map((x) => x.latency_ms ?? 0)} fmt={(v) => formatDuration(v)} />
41+ </div>
42+ </Section>
43+ )}
44+ <Section title="Connectors" href="/connectors">
45+ <div className="overflow-x-auto rounded-md border border-rule bg-surface">
46+ <table className="table-dense">
47+ <thead>
48+ <tr>
49+ <th>Connector</th>
50+ <th>State</th>
51+ <th className="text-right">Msg / min</th>
52+ <th className="text-right">Median latency</th>
53+ <th className="text-right">Instruments</th>
54+ <th className="text-right">Reliability</th>
55+ <th>Last message</th>
56+ <th>Last error</th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ {h.by_connector.map((r) => (
61+ <tr key={r.id}>
62+ <td className="mono">{r.id}</td>
63+ <td>
64+ <StatusBadge status={r.state} />
65+ </td>
66+ <td className="num">{r.messages_1m}</td>
67+ <td className="num">{r.median_latency_ms == null ? "—" : formatDuration(r.median_latency_ms)}</td>
68+ <td className="num">{r.instruments}</td>
69+ <td className="num">{r.reliability_score ?? "—"}</td>
70+ <td className="text-xs text-ink-3">{r.last_message_at ? <RelativeTime value={r.last_message_at} /> : "—"}</td>
71+ <td className="max-w-[320px] truncate text-xs text-ink-3" title={r.last_error ?? undefined}>
72+ {r.last_error ?? "—"}
73+ </td>
74+ </tr>
75+ ))}
76+ </tbody>
77+ </table>
78+ </div>
79+ </Section>
80+ <Section title="Incidents · 7 days" hint="source failures, recoveries, schema drift, divergence">
81+ {h.incidents.length ? (
82+ <ul className="divide-y divide-rule rounded-md border border-rule bg-surface px-3">
83+ {h.incidents.map((i) => (
84+ <li key={i.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
85+ <span className="mono text-xs text-ink-3">{formatDateTime(i.ts, { seconds: true })}</span>
86+ <StatusBadge status={i.severity} />
87+ <Link href={`/events/${i.id}`} className="hover:underline">
88+ {i.title}
89+ </Link>
90+ <span className="mono text-[11px] text-ink-3">{i.type}</span>
91+ </li>
92+ ))}
93+ </ul>
94+ ) : (
95+ <Empty>No source incidents in the last 7 days.</Empty>
96+ )}
97+ </Section>
98+ </Page>
99+ );
100+}
101+
102+function Trend({ label, values, fmt }: { label: string; values: number[]; fmt: (v: number) => string }) {
103+ const last = values[values.length - 1] ?? 0;
104+ return (
105+ <div>
106+ <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">{label}</div>
107+ <div className="mono mt-0.5 text-xl font-semibold tnum">{fmt(last)}</div>
108+ <Sparkline values={values} width={220} height={40} stroke="var(--accent)" className="mt-1 w-full" />
109+ </div>
110+ );
111+}
added apps/web/src/app/developers/page.tsx +149 −0
@@ -0,0 +1,149 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Page, PageHeader } from "@/components/ui/section";
4+import { SITE_URL } from "@/lib/site";
5+
6+export const metadata: Metadata = { title: "Developers", description: "Market Atlas API: REST endpoints, WebSocket and SSE streams, envelopes, errors and rate limits.", alternates: { canonical: "/developers" } };
7+
8+const REST: Array<[string, string]> = [
9+ ["GET /v1/stats", "Live telemetry (instruments, connectors, observations today, events, freshness)."],
10+ ["GET /v1/markets", "Global overview: featured indices, crypto, FX, rates, commodities, equities, movers, exchanges with state, breadth."],
11+ ["GET /v1/instruments?asset_class=&exchange=&country=&q=&quoted=1&sort=&limit=&offset=", "Instrument master with canonical quotes. sort ∈ symbol | change | -change | volume | name."],
12+ ["GET /v1/instruments/{id|SYMBOL|VENUE:SYMBOL}", "Instrument detail: company, exchange + status, quote, aliases, observing sources, related instruments."],
13+ ["GET /v1/quotes/{id}", "One canonical quote."],
14+ ["GET /v1/quotes?ids=a,b,c", "Up to 200 canonical quotes."],
15+ ["GET /v1/quotes/{id}/provenance", "“Why this price?” — every contribution with weight, age, inclusion and reason."],
16+ ["GET /v1/history/{id}?resolution=1m|5m|15m|1h|1d&from=&to=&limit=", "OHLCV bars (t, o, h, l, c, v, n sources, p producer)."],
17+ ["GET /v1/events?type=&instrument=&country=&asset_class=&severity=&since=&before=&limit=", "Canonical events; GET /v1/events/{id} for one."],
18+ ["GET /v1/filings?form=&cik=&q=&limit=&offset=", "Regulatory filings observed on EDGAR."],
19+ ["GET /v1/exchanges · GET /v1/exchanges/{id}", "Venues with session state, holidays, breadth, movers, events."],
20+ ["GET /v1/countries · GET /v1/countries/{code}", "Country atlas: exchanges, indices, rates, FX, equities, events."],
21+ ["GET /v1/search?q=", "Hybrid search grouped by asset class, exchange, country."],
22+ ["GET /v1/compare?ids=&resolution=", "Rebased series, return/volatility/drawdown, pairwise correlation."],
23+ ["GET /v1/changes?window=1m|5m|15m|1h|1d", "What changed: movers > 1 %, events, filings, by type."],
24+ ["GET /v1/breadth?scope=world|US|xnas", "Advancers/decliners/highs/lows for a scope."],
25+ ["GET /v1/sources · GET /v1/connectors · GET /v1/data-health · GET /v1/status · GET /v1/health · GET /v1/metrics", "Provenance directory, connector operations, health, public status, liveness, Prometheus metrics."],
26+];
27+
28+export default function DevelopersPage() {
29+ const base = SITE_URL;
30+ const ws = base.replace(/^http/, "ws");
31+ return (
32+ <Page>
33+ <PageHeader kicker="API v1" title="Developers" lead="Public, read-only API over the same canonical data the site shows. Every response carries provenance metadata; values whose data rights forbid redistribution are withheld rather than silently omitted." />
34+ <div className="prose-ma max-w-3xl">
35+ <h2>Base URL & envelope</h2>
36+ <pre>
37+ <code>{`${base}/v1
38+
39+{ "data": …, "meta": { "request_id": "…", "timestamp": "2026-09-12T14:31:42.512Z", "data_status": "DELAYED" } }`}</code>
40+ </pre>
41+ <p>
42+ Errors are structured: <code>{`{ "error": { "code": "INSTRUMENT_NOT_FOUND", "message": "instrument not found" } }`}</code> with the matching HTTP status. No stack traces.
43+ </p>
44+ <h2>Rate limits</h2>
45+ <p>
46+ 240 requests per minute sustained per IP (burst 60) on REST; <code>429</code> with <code>Retry-After</code> when exceeded. Streams are not counted. Higher limits and API keys will come with the developer accounts.
47+ </p>
48+ <h2>REST endpoints</h2>
49+ <table>
50+ <thead>
51+ <tr>
52+ <th>Endpoint</th>
53+ <th>Description</th>
54+ </tr>
55+ </thead>
56+ <tbody>
57+ {REST.map(([e, d]) => (
58+ <tr key={e}>
59+ <td>
60+ <code>{e}</code>
61+ </td>
62+ <td>{d}</td>
63+ </tr>
64+ ))}
65+ </tbody>
66+ </table>
67+ <h3>Example</h3>
68+ <pre>
69+ <code>{`curl ${base}/v1/quotes/BTC-USD
70+
71+{
72+ "data": {
73+ "instrument_id": "crypto_btc_usd", "symbol": "BTC-USD", "price": 77326.37, "change_percent": 0.0275,
74+ "bid": 77326.36, "ask": 77326.37, "currency": "USD",
75+ "source_count": 2, "dispersion_bps": 0.02, "confidence": 0.906, "freshness_ms": 1552,
76+ "data_status": "REALTIME", "market_state": null, "rights_status": "PUBLIC_ATTRIBUTED", "withheld": false,
77+ "updated_at": "2026-09-12T08:42:34.479Z", "source_timestamp": "2026-09-12T08:42:34.358Z"
78+ },
79+ "meta": { "request_id": "…", "timestamp": "…", "data_status": "REALTIME" }
80+}`}</code>
81+ </pre>
82+ <h2>Quote fields</h2>
83+ <ul>
84+ <li>
85+ <code>data_status</code>: <code>REALTIME</code> · <code>DELAYED</code> · <code>AT_CLOSE</code> · <code>END_OF_DAY</code> · <code>STALE</code> · <code>WITHHELD</code>. Never treat anything but <code>REALTIME</code> as live.
86+ </li>
87+ <li>
88+ <code>source_count</code> = independent source families included; <code>dispersion_bps</code> = spread between included sources; <code>confidence</code> ∈ [0, 0.995].
89+ </li>
90+ <li>
91+ <code>freshness_ms</code> = age of the newest included observation at response time; <code>source_timestamp</code> is the source's own time when published.
92+ </li>
93+ </ul>
94+ <h2>WebSocket stream</h2>
95+ <pre>
96+ <code>{`${ws}/v1/stream
97+
98+→ { "action": "subscribe", "channels": ["quotes:BTC-USD", "quotes:AAPL", "events:*", "market:xnas"] }
99+← { "type": "hello", "version": 1, "server_seq": 1024, "ts": 1789202146812 }
100+← { "type": "subscriptions", "channels": ["quotes:crypto_btc_usd", "quotes:eq_us_xnas_aapl", "events:*", "market:xnas"] }
101+← { "type": "batch", "seq": 1, "ts": 1789202146921, "messages": [
102+ { "type": "quote", "instrument_id": "crypto_btc_usd", "symbol": "BTC-USD", "price": 77326.37, "change": 21.27, "change_pct": 0.0275,
103+ "bid": 77326.36, "ask": 77326.37, "volume": 6634.6, "currency": "USD", "timestamp": 1789202146563, "received": 1789202146819,
104+ "confidence": 0.906, "sources": 2, "status": "REALTIME" },
105+ { "type": "event", "event": { "id": "evt_…", "type": "TRADING_HALT", "title": "…", "severity": "WARNING", "confidence": 1, "source_count": 1, "sources": ["nasdaq-trader"], "instrument_ids": [], "data": { … } } },
106+ { "type": "market_state", "exchangeId": "xnas", "state": "OPEN", "at": 1789392600000 }
107+ ] }
108+← { "type": "heartbeat", "ts": …, "seq": 42 } (every 25 s)
109+→ { "action": "ping", "id": 1 } ← { "type": "pong", "id": 1, "ts": … }
110+→ { "action": "unsubscribe", "channels": ["quotes:AAPL"] }`}</code>
111+ </pre>
112+ <p>Channels:</p>
113+ <ul>
114+ <li>
115+ <code>quotes:&lt;SYMBOL|id&gt;</code> one instrument · <code>quotes:*</code> everything (throttled to 2 updates/s per instrument) · <code>quotes:class:CRYPTO</code> one asset class · <code>tape</code> compact price changes.
116+ </li>
117+ <li>
118+ <code>events:*</code> · <code>events:&lt;SYMBOL|id&gt;</code> · <code>events:US</code> (country) · <code>events:type:TRADING_HALT</code>.
119+ </li>
120+ <li>
121+ <code>market:&lt;exchange&gt;</code> or <code>market:*</code> for session-state changes.
122+ </li>
123+ </ul>
124+ <p>
125+ Frames are batched (≈ 10 per second per client) and carry a per-client <code>seq</code>; a gap means messages were dropped under backpressure. Up to 500 channels per connection.
126+ </p>
127+ <h2>Server-Sent Events</h2>
128+ <pre>
129+ <code>{`curl -N "${base}/v1/sse?channels=quotes:BTC-USD,events:*"
130+
131+event: hello
132+data: {"version":1,"channels":["quotes:crypto_btc_usd","events:*"],"ts":…}
133+
134+event: batch
135+id: 58
136+data: {"type":"batch","seq":1,"ts":…,"messages":[{"type":"quote",…}]}`}</code>
137+ </pre>
138+ <h2>Identifiers</h2>
139+ <p>
140+ Stable public ids: <code>eq_us_xnas_aapl</code>, <code>etf_us_arcx_spy</code>, <code>index_us_spx</code>, <code>crypto_btc_usd</code>, <code>fx_eur_usd</code>, <code>rate_us_us10y</code>, <code>cmd_xcme_gc_f</code>. Endpoints accepting <code>{"{id}"}</code> also accept a bare symbol (<code>AAPL</code>, <code>BTC-USD</code>) or <code>VENUE:SYMBOL</code>.
141+ </p>
142+ <h2>Attribution & terms</h2>
143+ <p>
144+ Data carries the rights status of its sources; see <Link href="/licensing">data rights</Link>. Cboe values are 15-minute delayed and must be displayed as such; venue and official-source attributions must be preserved when redistributing. Market Atlas is an informational platform, not investment advice.
145+ </p>
146+ </div>
147+ </Page>
148+ );
149+}
added apps/web/src/app/error.tsx +23 −0
@@ -0,0 +1,23 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { Page } from "@/components/ui/section";
5+
6+export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
7+ return (
8+ <Page className="py-20 text-center">
9+ <p className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Temporarily unavailable</p>
10+ <h1 className="mt-2 text-3xl font-semibold tracking-tight">The atlas could not load this view</h1>
11+ <p className="mx-auto mt-2 max-w-md text-sm text-ink-2">The market-data service did not answer in time. Live streaming may continue while this page recovers.</p>
12+ {error.digest && <p className="mono mt-2 text-[11px] text-ink-3">ref {error.digest}</p>}
13+ <div className="mt-6 flex justify-center gap-2">
14+ <button type="button" onClick={reset} className="inline-flex h-11 items-center rounded-md bg-ink px-4 text-sm font-medium text-canvas">
15+ Try again
16+ </button>
17+ <Link href="/status" className="inline-flex h-11 items-center rounded-md border border-rule px-4 text-sm">
18+ System status
19+ </Link>
20+ </div>
21+ </Page>
22+ );
23+}
added apps/web/src/app/etfs/[symbol]/page.tsx +7 −0
@@ -0,0 +1,7 @@
1+import { permanentRedirect } from "next/navigation";
2+
3+/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */
4+export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) {
5+ const { symbol } = await params;
6+ permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`);
7+}
added apps/web/src/app/etfs/page.tsx +9 −0
@@ -0,0 +1,9 @@
1+import type { Metadata } from "next";
2+import { ClassPage } from "@/components/market/class-page";
3+
4+export const metadata: Metadata = { title: "ETFs", description: "Exchange-traded funds with canonical quotes and provenance.", alternates: { canonical: "/etfs" } };
5+export const dynamic = "force-dynamic";
6+
7+export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
8+ return <ClassPage classes={["ETF", "ETN"]} kicker="Funds" title="ETFs" lead="Exchange-traded funds and notes listed in the US. Quotes are delayed 15 minutes (Cboe) with end-of-day history." basePath="/etfs" searchParams={searchParams} />;
9+}
added apps/web/src/app/events/[id]/page.tsx +84 −0
@@ -0,0 +1,84 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Kv, Page, PageHeader } from "@/components/ui/section";
4+import { StatusBadge } from "@/components/ui/status-badge";
5+import { api } from "@/lib/api";
6+import { EVENT_TYPE_LABEL, formatDateTime, instrumentHref } from "@/lib/format";
7+import type { MarketEvent } from "@/lib/types";
8+
9+export const dynamic = "force-dynamic";
10+
11+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
12+ const { id } = await params;
13+ try {
14+ const e = await api<MarketEvent>(`/v1/events/${encodeURIComponent(id)}`);
15+ return { title: e.title, description: e.summary ?? `${EVENT_TYPE_LABEL[e.type] ?? e.type} event recorded by Market Atlas.`, alternates: { canonical: `/events/${e.id}` }, robots: { index: false } };
16+ } catch {
17+ return { title: "Event" };
18+ }
19+}
20+
21+export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
22+ const { id } = await params;
23+ const e = await api<MarketEvent>(`/v1/events/${encodeURIComponent(id)}`);
24+ const url = typeof e.data.url === "string" ? (e.data.url as string) : null;
25+ return (
26+ <Page>
27+ <PageHeader
28+ kicker={
29+ <Link href={`/events?type=${e.type}`} className="hover:text-ink">
30+ Events · {EVENT_TYPE_LABEL[e.type] ?? e.type}
31+ </Link>
32+ }
33+ title={e.title}
34+ lead={e.summary ?? undefined}
35+ actions={<StatusBadge status={e.severity} />}
36+ />
37+ <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_320px]">
38+ <div className="rounded-md border border-rule bg-surface p-4">
39+ <h2 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Event data</h2>
40+ <Kv
41+ cols={1}
42+ className="mt-2"
43+ items={Object.entries(e.data)
44+ .filter(([, v]) => v != null && typeof v !== "object")
45+ .map(([k, v]) => [k.replace(/_/g, " "), String(v)] as [string, string])}
46+ />
47+ {Object.values(e.data).some((v) => v && typeof v === "object") && <pre className="mono mt-3 overflow-x-auto rounded bg-surface-2 p-3 text-xs">{JSON.stringify(Object.fromEntries(Object.entries(e.data).filter(([, v]) => v && typeof v === "object")), null, 2)}</pre>}
48+ {url && (
49+ <a href={url} target="_blank" rel="noopener noreferrer" className="mt-3 inline-block text-sm text-accent hover:underline">
50+ Open source document ↗
51+ </a>
52+ )}
53+ </div>
54+ <aside className="space-y-4">
55+ <div className="rounded-md border border-rule bg-surface p-4">
56+ <h2 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Provenance</h2>
57+ <Kv cols={1} className="mt-2" items={[["Time", formatDateTime(e.timestamp, { seconds: true })], ["Confidence", `${Math.round(e.confidence * 100)}%`], ["Independent sources", String(e.source_count)], ["Confirmed", e.confirmed_at ? formatDateTime(e.confirmed_at, { seconds: true }) : "single observation"], ["Event id", e.id]]} />
58+ <div className="mt-2 flex flex-wrap gap-1.5">
59+ {e.sources.map((s) => (
60+ <Link key={s} href={`/sources#${s}`} className="mono rounded border border-rule px-1.5 py-0.5 text-xs text-ink-2 hover:border-rule-strong">
61+ {s}
62+ </Link>
63+ ))}
64+ </div>
65+ </div>
66+ {e.instruments.length > 0 && (
67+ <div className="rounded-md border border-rule bg-surface p-4">
68+ <h2 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Instruments</h2>
69+ <ul className="mt-2 divide-y divide-rule text-sm">
70+ {e.instruments.map((i) => (
71+ <li key={i.id} className="py-1.5">
72+ <Link href={instrumentHref(i.id)} className="hover:underline">
73+ <span className="mono font-medium">{i.symbol ?? i.id}</span> {i.name && <span className="text-xs text-ink-3">{i.name}</span>}
74+ </Link>
75+ </li>
76+ ))}
77+ </ul>
78+ </div>
79+ )}
80+ </aside>
81+ </div>
82+ </Page>
83+ );
84+}
added apps/web/src/app/events/loading.tsx +15 −0
@@ -0,0 +1,15 @@
1+export default function Loading() {
2+ return (
3+ <div className="mx-auto max-w-[1440px] animate-pulse px-3 py-8 sm:px-5" aria-busy="true" aria-label="Loading">
4+ <div className="h-3 w-24 rounded bg-surface-3" />
5+ <div className="mt-3 h-8 w-72 rounded bg-surface-3" />
6+ <div className="mt-2 h-4 w-full max-w-xl rounded bg-surface-2" />
7+ <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
8+ {Array.from({ length: 4 }).map((_, i) => (
9+ <div key={i} className="h-16 rounded-md border border-rule bg-surface" />
10+ ))}
11+ </div>
12+ <div className="mt-8 h-64 rounded-md border border-rule bg-surface" />
13+ </div>
14+ );
15+}
added apps/web/src/app/events/page.tsx +87 −0
@@ -0,0 +1,87 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { EventRow } from "@/components/market/event-row";
4+import { Empty, Page, PageHeader } from "@/components/ui/section";
5+import { api } from "@/lib/api";
6+import { cx, EVENT_TYPE_LABEL } from "@/lib/format";
7+import type { MarketEvent } from "@/lib/types";
8+
9+export const metadata: Metadata = { title: "Events", description: "Canonical market events: halts, filings, price moves, volatility, market opens and source incidents.", alternates: { canonical: "/events" } };
10+export const dynamic = "force-dynamic";
11+
12+const TYPES = ["PRICE_CHANGE", "SESSION_HIGH", "SESSION_LOW", "VOLATILITY_SPIKE", "TRADING_HALT", "TRADING_RESUME", "FILING_PUBLISHED", "MARKET_OPEN", "MARKET_CLOSE", "SOURCE_DIVERGENCE", "SOURCE_FAILURE", "SCHEMA_DRIFT", "DOCUMENT_CHANGED"];
13+const SEVS = ["INFO", "NOTICE", "WARNING", "CRITICAL"];
14+
15+export default async function EventsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
16+ const sp = await searchParams;
17+ const type = typeof sp.type === "string" ? sp.type : "";
18+ const severity = typeof sp.severity === "string" ? sp.severity : "";
19+ const instrument = typeof sp.instrument === "string" ? sp.instrument : "";
20+ const country = typeof sp.country === "string" ? sp.country : "";
21+ const before = typeof sp.before === "string" ? sp.before : "";
22+ const q = new URLSearchParams({ limit: "100" });
23+ if (type) q.set("type", type);
24+ if (severity) q.set("severity", severity);
25+ if (instrument) q.set("instrument", instrument);
26+ if (country) q.set("country", country);
27+ if (before) q.set("before", before);
28+ const events = await api<MarketEvent[]>(`/v1/events?${q}`);
29+ const link = (patch: Record<string, string>) => {
30+ const u = new URLSearchParams();
31+ for (const [k, v] of Object.entries({ type, severity, instrument, country, ...patch })) if (v) u.set(k, v);
32+ const s = u.toString();
33+ return s ? `/events?${s}` : "/events";
34+ };
35+ const last = events[events.length - 1];
36+ const lastTs = last ? (typeof last.timestamp === "number" ? new Date(last.timestamp).toISOString() : String(last.timestamp).replace(" ", "T")) : null;
37+ return (
38+ <Page wide>
39+ <PageHeader kicker="Events" title="Market events" lead="Deduplicated, source-attributed events derived from observations (price moves, session extremes, volatility), venue feeds (halts), regulators (filings) and Market Atlas' own monitoring (source incidents)." actions={<Link href="/live" className="text-sm text-accent hover:underline">Watch live →</Link>} />
40+ <div className="mb-3 flex flex-wrap gap-1.5">
41+ <Link href={link({ type: "" })} className={cx("inline-flex h-8 items-center rounded-full border px-3 text-xs", !type ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}>
42+ All types
43+ </Link>
44+ {TYPES.map((t) => (
45+ <Link key={t} href={link({ type: t })} className={cx("inline-flex h-8 items-center rounded-full border px-3 text-xs", type === t ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}>
46+ {EVENT_TYPE_LABEL[t] ?? t}
47+ </Link>
48+ ))}
49+ </div>
50+ <div className="mb-4 flex flex-wrap items-center gap-1.5 text-xs">
51+ <span className="text-ink-3">Severity:</span>
52+ <Link href={link({ severity: "" })} className={cx("rounded-full border px-2.5 py-1", !severity ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2")}>
53+ any
54+ </Link>
55+ {SEVS.map((s) => (
56+ <Link key={s} href={link({ severity: s })} className={cx("rounded-full border px-2.5 py-1", severity === s ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2")}>
57+ {s.toLowerCase()}
58+ </Link>
59+ ))}
60+ {(instrument || country) && (
61+ <span className="ml-2 text-ink-3">
62+ filtered by {instrument ? `instrument ${instrument}` : `country ${country}`} ·{" "}
63+ <Link href={link({ instrument: "", country: "" })} className="text-accent hover:underline">
64+ clear
65+ </Link>
66+ </span>
67+ )}
68+ </div>
69+ {events.length ? (
70+ <ul className="rounded-md border border-rule bg-surface px-3">
71+ {events.map((e) => (
72+ <EventRow key={e.id} e={e} />
73+ ))}
74+ </ul>
75+ ) : (
76+ <Empty>No events match these filters.</Empty>
77+ )}
78+ {events.length >= 100 && lastTs && (
79+ <div className="mt-3 text-right text-xs">
80+ <Link href={link({ before: lastTs })} className="text-accent hover:underline">
81+ Older →
82+ </Link>
83+ </div>
84+ )}
85+ </Page>
86+ );
87+}
added apps/web/src/app/exchanges/[id]/page.tsx +140 −0
@@ -0,0 +1,140 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { EventRow } from "@/components/market/event-row";
4+import { InstrumentTable } from "@/components/market/instrument-table";
5+import { Empty, Kv, Page, PageHeader, Section, Stat } from "@/components/ui/section";
6+import { StatusBadge } from "@/components/ui/status-badge";
7+import { api } from "@/lib/api";
8+import { formatDateTime } from "@/lib/format";
9+import type { Breadth, Exchange, ExchangeStatus, InstrumentWithQuote, MarketEvent } from "@/lib/types";
10+
11+interface Detail {
12+ exchange: Exchange;
13+ status: ExchangeStatus;
14+ holidays: Array<{ date: string; name: string; kind: string; closeTime: string | null }>;
15+ instruments_tracked: number;
16+ instruments_quoted: number;
17+ breadth: Breadth;
18+ gainers: InstrumentWithQuote[];
19+ losers: InstrumentWithQuote[];
20+ most_active: InstrumentWithQuote[];
21+ events: MarketEvent[];
22+}
23+
24+export const dynamic = "force-dynamic";
25+
26+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
27+ const { id } = await params;
28+ try {
29+ const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);
30+ return { title: `${d.exchange.name} — status, hours, movers`, description: `${d.exchange.name} (${d.exchange.mic ?? d.exchange.id}): session state, local time, holidays, breadth and the largest moves among instruments Market Atlas tracks.`, alternates: { canonical: `/exchanges/${d.exchange.id}` } };
31+ } catch {
32+ return { title: "Exchange" };
33+ }
34+}
35+
36+export default async function ExchangePage({ params }: { params: Promise<{ id: string }> }) {
37+ const { id } = await params;
38+ const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);
39+ const e = d.exchange;
40+ const sessions = e.sessions;
41+ const sessionText = sessions.continuous ? "Continuous (24/7)" : sessions.regular.map((s) => `${s.open}–${s.close}`).join(", ");
42+ return (
43+ <Page wide>
44+ <PageHeader
45+ kicker={
46+ <span className="flex items-center gap-2">
47+ <Link href="/exchanges" className="hover:text-ink">
48+ Exchanges
49+ </Link>
50+ <span>·</span>
51+ <Link href={`/countries/${e.country}`} className="hover:text-ink">
52+ {e.country}
53+ </Link>
54+ </span>
55+ }
56+ title={e.name}
57+ lead={`${e.operator ? `${e.operator} · ` : ""}${e.city ?? ""} · ${e.timezone}${e.website ? "" : ""}`}
58+ actions={
59+ <div className="flex items-center gap-2 text-sm">
60+ <StatusBadge status={d.status.state} />
61+ <span className="mono text-ink-2">local {d.status.localTime}</span>
62+ {e.website && (
63+ <a href={e.website} target="_blank" rel="noopener noreferrer" className="text-xs text-accent hover:underline">
64+ website ↗
65+ </a>
66+ )}
67+ </div>
68+ }
69+ />
70+ <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4">
71+ <Stat label="Session" value={d.status.state.toLowerCase()} sub={d.status.isHoliday ? d.status.holidayName ?? "holiday" : sessionText} />
72+ <Stat label="Next transition" value={d.status.nextTransition ? d.status.nextTransition.state.toLowerCase() : "—"} sub={d.status.nextTransition ? formatDateTime(d.status.nextTransition.at, { tz: e.timezone }) : sessions.continuous ? "never closes" : ""} />
73+ <Stat label="Instruments tracked" value={d.instruments_tracked.toLocaleString("en-US")} sub={`${d.instruments_quoted} with a canonical quote`} />
74+ <Stat label="Breadth" value={d.breadth.instruments ? `${d.breadth.advancers} ▲ ${d.breadth.decliners} ▼` : "—"} sub={d.breadth.instruments ? `median ${d.breadth.median_change_percent?.toFixed(2)}%` : "no quoted names"} tone={d.breadth.advancers > d.breadth.decliners ? "pos" : d.breadth.decliners > d.breadth.advancers ? "neg" : undefined} />
75+ </div>
76+ <div className="mt-6 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_320px]">
77+ <div>
78+ <Section title="Largest gains" hint="instruments tracked on this venue">
79+ <InstrumentTable rows={d.gainers} compact showExchange={false} emptyText="No quoted instruments on this venue yet." />
80+ </Section>
81+ <Section title="Largest losses">
82+ <InstrumentTable rows={d.losers} compact showExchange={false} defaultSort="change" emptyText="No quoted instruments on this venue yet." />
83+ </Section>
84+ <Section title="Most active">
85+ <InstrumentTable rows={d.most_active} compact showExchange={false} defaultSort="volume" emptyText="No volume observed yet." />
86+ </Section>
87+ <Section title="Events" hint="market open/close, halts, moves">
88+ {d.events.length ? (
89+ <ul className="rounded-md border border-rule bg-surface px-3">
90+ {d.events.map((ev) => (
91+ <EventRow key={ev.id} e={ev} />
92+ ))}
93+ </ul>
94+ ) : (
95+ <Empty>No events recorded for this venue yet.</Empty>
96+ )}
97+ </Section>
98+ </div>
99+ <aside className="space-y-6">
100+ <div className="rounded-md border border-rule bg-surface p-4">
101+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Venue</h3>
102+ <Kv cols={1} className="mt-2" items={[["MIC", e.mic ?? "—"], ["Operator", e.operator ?? "—"], ["Country", e.country], ["Time zone", e.timezone], ["Currency", e.currency ?? "—"], ["Asset classes", (e.asset_classes ?? []).join(", ").toLowerCase() || "—"]]} />
103+ </div>
104+ <div className="rounded-md border border-rule bg-surface p-4">
105+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Trading sessions (local)</h3>
106+ <Kv
107+ cols={1}
108+ className="mt-2"
109+ items={[
110+ ...(sessions.pre ? ([["Pre-market", `${sessions.pre.open}–${sessions.pre.close}`]] as Array<[string, string]>) : []),
111+ ...sessions.regular.map((s, i) => [`Regular${sessions.regular.length > 1 ? ` ${i + 1}` : ""}`, `${s.open}–${s.close}`] as [string, string]),
112+ ...(sessions.post ? ([["After hours", `${sessions.post.open}–${sessions.post.close}`]] as Array<[string, string]>) : []),
113+ ...(sessions.continuous ? ([["Schedule", "24 hours, every day"]] as Array<[string, string]>) : []),
114+ ...(sessions.weekdays ? ([["Trading days", sessions.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(" ")]] as Array<[string, string]>) : []),
115+ ]}
116+ />
117+ </div>
118+ <div className="rounded-md border border-rule bg-surface p-4">
119+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Upcoming holidays & early closes</h3>
120+ {d.holidays.length ? (
121+ <ul className="mt-2 divide-y divide-rule text-sm">
122+ {d.holidays.map((h) => (
123+ <li key={h.date} className="flex items-baseline justify-between gap-2 py-1.5">
124+ <span className="min-w-0 truncate">{h.name}</span>
125+ <span className="mono shrink-0 text-xs text-ink-3">
126+ {h.date}
127+ {h.kind === "EARLY_CLOSE" && h.closeTime ? ` · closes ${h.closeTime}` : ""}
128+ </span>
129+ </li>
130+ ))}
131+ </ul>
132+ ) : (
133+ <p className="mt-2 text-xs text-ink-3">No holiday calendar loaded for this venue — weekdays are assumed to trade.</p>
134+ )}
135+ </div>
136+ </aside>
137+ </div>
138+ </Page>
139+ );
140+}
added apps/web/src/app/exchanges/page.tsx +76 −0
@@ -0,0 +1,76 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { WorldMap } from "@/components/market/world-map";
4+import { ChangeCell } from "@/components/ui/price";
5+import { Page, PageHeader, Section } from "@/components/ui/section";
6+import { StatusBadge } from "@/components/ui/status-badge";
7+import { api } from "@/lib/api";
8+import type { Breadth, Exchange, ExchangeStatus } from "@/lib/types";
9+
10+type Row = Exchange & { status: ExchangeStatus; instruments_tracked: number; instruments_quoted: number; breadth: Breadth | null };
11+
12+export const metadata: Metadata = { title: "Exchanges", description: "World exchanges with live session state, local time, instruments tracked and breadth.", alternates: { canonical: "/exchanges" } };
13+export const dynamic = "force-dynamic";
14+
15+const ORDER = ["OPEN", "PRE", "POST", "AUCTION", "HALTED", "CLOSED", "UNKNOWN"];
16+
17+export default async function ExchangesPage() {
18+ const rows = await api<Row[]>("/v1/exchanges");
19+ const sorted = [...rows].sort((a, b) => ORDER.indexOf(a.status.state) - ORDER.indexOf(b.status.state) || b.instruments_tracked - a.instruments_tracked || a.name.localeCompare(b.name));
20+ const open = rows.filter((r) => r.status.state === "OPEN").length;
21+ const mapRows = rows.map((r) => ({ id: r.id, name: r.name, mic: r.mic, country: r.country, city: r.city, timezone: r.timezone, lat: r.lat, lon: r.lon, status: r.status, asset_classes: r.asset_classes ?? [], breadth: r.breadth }));
22+ return (
23+ <Page wide>
24+ <PageHeader kicker="World" title="Exchanges" lead={`${rows.length} venues in the atlas · ${open} trading right now. Session states come from the market-hours engine (time zones, holidays, early closes); instrument counts are what Market Atlas tracks, not the venue's full listing.`} />
25+ <WorldMap exchanges={mapRows} />
26+ <Section title="All venues" hint="sorted by session state, then coverage">
27+ <div className="overflow-x-auto rounded-md border border-rule bg-surface">
28+ <table className="table-dense">
29+ <thead>
30+ <tr>
31+ <th>Exchange</th>
32+ <th>MIC</th>
33+ <th>Country</th>
34+ <th>State</th>
35+ <th>Local time</th>
36+ <th className="hidden md:table-cell">Next</th>
37+ <th className="text-right">Tracked</th>
38+ <th className="text-right">Quoted</th>
39+ <th className="hidden text-right lg:table-cell">Adv / Dec</th>
40+ <th className="hidden text-right lg:table-cell">Median</th>
41+ </tr>
42+ </thead>
43+ <tbody>
44+ {sorted.map((r) => (
45+ <tr key={r.id}>
46+ <td>
47+ <Link href={`/exchanges/${r.id}`} className="font-medium hover:underline">
48+ {r.name}
49+ </Link>
50+ <span className="block text-[11px] text-ink-3">{r.city ?? r.operator ?? ""}</span>
51+ </td>
52+ <td className="mono text-ink-2">{r.mic ?? "—"}</td>
53+ <td>
54+ <Link href={`/countries/${r.country}`} className="hover:underline">
55+ {r.country}
56+ </Link>
57+ </td>
58+ <td>
59+ <StatusBadge status={r.status.state} />
60+ {r.status.isHoliday && r.status.holidayName && <span className="ml-1 text-[11px] text-ink-3">{r.status.holidayName}</span>}
61+ </td>
62+ <td className="mono text-ink-2">{r.status.localTime}</td>
63+ <td className="mono hidden text-xs text-ink-3 md:table-cell">{r.status.nextTransition ? `${r.status.nextTransition.state.toLowerCase()} · ${new Date(r.status.nextTransition.at).toLocaleString("en-GB", { timeZone: r.timezone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })}` : r.sessions.continuous ? "24/7" : "—"}</td>
64+ <td className="num">{r.instruments_tracked.toLocaleString("en-US")}</td>
65+ <td className="num">{r.instruments_quoted}</td>
66+ <td className="num hidden lg:table-cell">{r.breadth ? `${r.breadth.advancers} / ${r.breadth.decliners}` : "—"}</td>
67+ <td className="num hidden lg:table-cell">{r.breadth ? <ChangeCell value={r.breadth.median_change_percent} /> : "—"}</td>
68+ </tr>
69+ ))}
70+ </tbody>
71+ </table>
72+ </div>
73+ </Section>
74+ </Page>
75+ );
76+}
added apps/web/src/app/filings/loading.tsx +15 −0
@@ -0,0 +1,15 @@
1+export default function Loading() {
2+ return (
3+ <div className="mx-auto max-w-[1440px] animate-pulse px-3 py-8 sm:px-5" aria-busy="true" aria-label="Loading">
4+ <div className="h-3 w-24 rounded bg-surface-3" />
5+ <div className="mt-3 h-8 w-72 rounded bg-surface-3" />
6+ <div className="mt-2 h-4 w-full max-w-xl rounded bg-surface-2" />
7+ <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
8+ {Array.from({ length: 4 }).map((_, i) => (
9+ <div key={i} className="h-16 rounded-md border border-rule bg-surface" />
10+ ))}
11+ </div>
12+ <div className="mt-8 h-64 rounded-md border border-rule bg-surface" />
13+ </div>
14+ );
15+}
added apps/web/src/app/filings/page.tsx +133 −0
@@ -0,0 +1,133 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Empty, Page, PageHeader } from "@/components/ui/section";
4+import { apiEnvelope } from "@/lib/api";
5+import { cx, formatDateTime, instrumentHref } from "@/lib/format";
6+import type { Filing } from "@/lib/types";
7+
8+export const metadata: Metadata = { title: "Filings", description: "Regulatory filings observed on SEC EDGAR, linked to instruments by CIK.", alternates: { canonical: "/filings" } };
9+export const dynamic = "force-dynamic";
10+
11+const FORMS = ["8-K", "10-K", "10-Q", "6-K", "S-1", "SC 13D", "SC 13G", "DEF 14A", "4", "424B4", "13F-HR"];
12+
13+export default async function FilingsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
14+ const sp = await searchParams;
15+ const form = typeof sp.form === "string" ? sp.form : "";
16+ const q = typeof sp.q === "string" ? sp.q : "";
17+ const cik = typeof sp.cik === "string" ? sp.cik : "";
18+ const page = Math.max(1, Number(sp.page ?? 1) || 1);
19+ const params = new URLSearchParams({ limit: "100", offset: String((page - 1) * 100) });
20+ if (form) params.set("form", form);
21+ if (q) params.set("q", q);
22+ if (cik) params.set("cik", cik);
23+ const env = await apiEnvelope<Filing[]>(`/v1/filings?${params}`);
24+ const rows = env.data;
25+ const link = (patch: Record<string, string | number>) => {
26+ const u = new URLSearchParams();
27+ for (const [k, v] of Object.entries({ form, q, cik, page: page > 1 ? page : "", ...patch })) if (v !== "" && v !== 0) u.set(k, String(v));
28+ const s = u.toString();
29+ return s ? `/filings?${s}` : "/filings";
30+ };
31+ return (
32+ <Page wide>
33+ <PageHeader kicker="Regulatory" title="Filings" lead={`Documents filed on SEC EDGAR as Market Atlas observes them (every 2 minutes). ${Number(env.meta.last_7_days ?? 0).toLocaleString("en-US")} filings in the last 7 days. Material forms (8-K, 10-K/Q, 6-K, S-1, 13D/G…) also appear as events.`} />
34+ <div className="mb-3 flex flex-wrap items-center gap-1.5">
35+ <Link href={link({ form: "", page: "" })} className={cx("inline-flex h-8 items-center rounded-full border px-3 text-xs", !form ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}>
36+ All forms
37+ </Link>
38+ {FORMS.map((f) => (
39+ <Link key={f} href={link({ form: f, page: "" })} className={cx("mono inline-flex h-8 items-center rounded-full border px-3 text-xs", form === f ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}>
40+ {f}
41+ </Link>
42+ ))}
43+ <form action="/filings" className="ml-auto flex gap-2">
44+ {form && <input type="hidden" name="form" value={form} />}
45+ <input name="q" defaultValue={q} placeholder="Company name" className="h-9 w-48 rounded-md border border-rule bg-surface px-2 text-sm outline-none focus:border-rule-strong" />
46+ </form>
47+ </div>
48+ {cik && (
49+ <p className="mb-2 text-xs text-ink-3">
50+ Filtered by CIK {cik} ·{" "}
51+ <Link href={link({ cik: "", page: "" })} className="text-accent hover:underline">
52+ clear
53+ </Link>
54+ </p>
55+ )}
56+ {rows.length ? (
57+ <div className="overflow-x-auto rounded-md border border-rule bg-surface">
58+ <table className="table-dense">
59+ <thead>
60+ <tr>
61+ <th>Filed (ET)</th>
62+ <th>Form</th>
63+ <th>Filer</th>
64+ <th className="hidden md:table-cell">CIK</th>
65+ <th className="hidden md:table-cell">Role</th>
66+ <th>Instruments</th>
67+ <th>Document</th>
68+ </tr>
69+ </thead>
70+ <tbody>
71+ {rows.map((f) => (
72+ <tr key={f.id}>
73+ <td className="mono text-ink-2">{formatDateTime(f.filed_at, { tz: "America/New_York" })}</td>
74+ <td className="mono font-medium">
75+ {f.form_type}
76+ {f.metadata.material === true && <span className="ml-1 text-[10px] uppercase text-accent">material</span>}
77+ </td>
78+ <td>
79+ <Link href={link({ q: f.company_name, page: "" })} className="hover:underline">
80+ {f.company_name}
81+ </Link>
82+ </td>
83+ <td className="mono hidden text-ink-3 md:table-cell">
84+ {f.cik ? (
85+ <Link href={link({ cik: f.cik, page: "" })} className="hover:underline">
86+ {f.cik}
87+ </Link>
88+ ) : (
89+ "—"
90+ )}
91+ </td>
92+ <td className="hidden text-xs text-ink-3 md:table-cell">{String(f.metadata.role ?? "—")}</td>
93+ <td>
94+ {f.instrument_ids.length ? (
95+ f.instrument_ids.slice(0, 3).map((id) => (
96+ <Link key={id} href={instrumentHref(id)} className="mono mr-1 text-xs text-accent hover:underline">
97+ {id.split("_").pop()?.toUpperCase()}
98+ </Link>
99+ ))
100+ ) : (
101+ <span className="text-xs text-ink-3">—</span>
102+ )}
103+ </td>
104+ <td>
105+ <a href={f.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
106+ EDGAR ↗
107+ </a>
108+ </td>
109+ </tr>
110+ ))}
111+ </tbody>
112+ </table>
113+ </div>
114+ ) : (
115+ <Empty>No filings match.</Empty>
116+ )}
117+ <div className="mt-3 flex justify-between text-xs">
118+ {page > 1 ? (
119+ <Link href={link({ page: page - 1 })} className="text-accent hover:underline">
120+ ← Newer
121+ </Link>
122+ ) : (
123+ <span />
124+ )}
125+ {rows.length === 100 && (
126+ <Link href={link({ page: page + 1 })} className="text-accent hover:underline">
127+ Older →
128+ </Link>
129+ )}
130+ </div>
131+ </Page>
132+ );
133+}
added apps/web/src/app/forex/[symbol]/page.tsx +7 −0
@@ -0,0 +1,7 @@
1+import { permanentRedirect } from "next/navigation";
2+
3+/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */
4+export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) {
5+ const { symbol } = await params;
6+ permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`);
7+}
added apps/web/src/app/forex/page.tsx +9 −0
@@ -0,0 +1,9 @@
1+import type { Metadata } from "next";
2+import { ClassPage } from "@/components/market/class-page";
3+
4+export const metadata: Metadata = { title: "Forex", description: "Currency pairs from official reference rates (ECB, Bank of Canada) and daily history.", alternates: { canonical: "/forex" } };
5+export const dynamic = "force-dynamic";
6+
7+export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
8+ return <ClassPage classes={["FOREX"]} kicker="Currencies" title="Forex" lead="Major and minor pairs. Current values are official end-of-day reference rates (ECB ~16:00 CET, Bank of Canada ~16:30 ET) — indicative, not tradable quotes." basePath="/forex" searchParams={searchParams} showExchange={false} defaultSort="symbol" />;
9+}
modified apps/web/src/app/globals.css +9 −0
@@ -271,6 +271,15 @@ body {
271 width: 100%; 271 width: 100%;
272 font-size: 0.85rem; 272 font-size: 0.85rem;
273 margin: 0.5rem 0 1rem; 273 margin: 0.5rem 0 1rem;
274+ display: block;
275+ overflow-x: auto;
276+}
277+.prose-ma table code {
278+ white-space: nowrap;
279+}
280+.prose-ma td code {
281+ white-space: normal;
282+ word-break: break-word;
274 } 283 }
275 .prose-ma th, 284 .prose-ma th,
276 .prose-ma td { 285 .prose-ma td {
added apps/web/src/app/halts/page.tsx +19 −0
@@ -0,0 +1,19 @@
1+import type { Metadata } from "next";
2+import { LiveEvents } from "@/components/market/live-events";
3+import { Page, PageHeader } from "@/components/ui/section";
4+import { api } from "@/lib/api";
5+import type { MarketEvent } from "@/lib/types";
6+
7+export const metadata: Metadata = { title: "Trading halts", description: "Trading halts and resumptions across US listed markets, from the Nasdaq Trader halts feed.", alternates: { canonical: "/halts" } };
8+export const dynamic = "force-dynamic";
9+
10+export default async function HaltsPage() {
11+ const events = await api<MarketEvent[]>("/v1/events?type=TRADING_HALT,TRADING_RESUME&limit=200");
12+ const halts = events.filter((e) => e.type === "TRADING_HALT").length;
13+ return (
14+ <Page>
15+ <PageHeader kicker="Events" title="Trading halts" lead={`Halts and resumptions published by Nasdaq for all US listing markets (reason codes decoded: news pending, LULD volatility pause, SEC suspension…). ${halts} halt${halts === 1 ? "" : "s"} in the current window; new ones stream in live.`} />
16+ <LiveEvents initial={events} channel="events:type:TRADING_HALT" max={200} types={["TRADING_HALT", "TRADING_RESUME"]} />
17+ </Page>
18+ );
19+}
added apps/web/src/app/indices/[symbol]/page.tsx +7 −0
@@ -0,0 +1,7 @@
1+import { permanentRedirect } from "next/navigation";
2+
3+/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */
4+export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) {
5+ const { symbol } = await params;
6+ permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`);
7+}
added apps/web/src/app/indices/page.tsx +9 −0
@@ -0,0 +1,9 @@
1+import type { Metadata } from "next";
2+import { ClassPage } from "@/components/market/class-page";
3+
4+export const metadata: Metadata = { title: "Indices", description: "Equity and volatility indices: S&P 500, Nasdaq-100, Dow Jones, Russell 2000, VIX.", alternates: { canonical: "/indices" } };
5+export const dynamic = "force-dynamic";
6+
7+export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
8+ return <ClassPage classes={["INDEX"]} kicker="Benchmarks" title="Indices" lead="Equity and volatility benchmarks. Index levels are delayed 15 minutes during the US session and shown at close otherwise." basePath="/indices" searchParams={searchParams} showExchange={false} />;
9+}
added apps/web/src/app/instruments/[id]/page.tsx +224 −0
@@ -0,0 +1,224 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { notFound } from "next/navigation";
4+import { PriceChart } from "@/components/market/chart";
5+import { EventRow } from "@/components/market/event-row";
6+import { InstrumentHeader } from "@/components/market/instrument-header";
7+import { InstrumentStats } from "@/components/market/instrument-stats";
8+import { ProvenancePanel } from "@/components/market/provenance-panel";
9+import { RightsBadge, StatusBadge } from "@/components/ui/status-badge";
10+import { Empty, Kv, Section } from "@/components/ui/section";
11+import { api, apiOptional } from "@/lib/api";
12+import { ASSET_CLASS_LABEL, formatDateTime, instrumentHref } from "@/lib/format";
13+import type { Exchange, Filing, Instrument, MarketEvent, Quote } from "@/lib/types";
14+
15+export const dynamic = "force-dynamic";
16+
17+interface Detail {
18+ instrument: Instrument;
19+ company: { id: string; name: string; cik: string | null; country: string | null; sector: string | null; industry: string | null; website: string | null } | null;
20+ exchange: (Exchange & { status: NonNullable<Exchange["status"]> }) | null;
21+ quote: Quote | null;
22+ aliases: Array<{ alias: string; source_id: string | null }>;
23+ sources: string[];
24+ related: Instrument[];
25+}
26+
27+async function load(id: string) {
28+ return api<Detail>(`/v1/instruments/${encodeURIComponent(id)}`);
29+}
30+
31+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
32+ const { id } = await params;
33+ try {
34+ const d = await load(id);
35+ const i = d.instrument;
36+ const title = `${i.symbol} · ${i.name}`;
37+ const desc = `${i.name} (${i.symbol}) — ${ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class}${d.exchange ? ` on ${d.exchange.name}` : ""}. Canonical price, sources, confidence, history and events on Market Atlas.`;
38+ return { title, description: desc, alternates: { canonical: instrumentHref(i.id) }, openGraph: { title, description: desc, type: "website" } };
39+ } catch {
40+ return { title: "Instrument" };
41+ }
42+}
43+
44+export default async function InstrumentPage({ params }: { params: Promise<{ id: string }> }) {
45+ const { id } = await params;
46+ const d = await load(id);
47+ if (!d?.instrument) notFound();
48+ const i = d.instrument;
49+ const [events, filings] = await Promise.all([
50+ apiOptional<MarketEvent[]>(`/v1/events?instrument=${encodeURIComponent(i.id)}&limit=25`),
51+ d.company?.cik ? apiOptional<Filing[]>(`/v1/filings?cik=${encodeURIComponent(d.company.cik)}&limit=15`) : Promise.resolve(null),
52+ ]);
53+ const isRate = i.asset_class === "TREASURY" || i.asset_class === "INTEREST_RATE" || i.asset_class === "BOND";
54+ const jsonLd = {
55+ "@context": "https://schema.org",
56+ "@type": "FinancialProduct",
57+ name: i.name,
58+ tickerSymbol: i.symbol,
59+ category: ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class,
60+ url: `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.market-atlas.co"}${instrumentHref(i.id)}`,
61+ ...(d.exchange ? { provider: { "@type": "Organization", name: d.exchange.name } } : {}),
62+ };
63+ return (
64+ <div>
65+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
66+ <InstrumentHeader instrument={i} quote={d.quote} exchange={d.exchange} />
67+ <div className="mx-auto max-w-[1440px] px-3 py-5 sm:px-5">
68+ <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_360px]">
69+ <div className="min-w-0">
70+ <PriceChart instrumentId={i.id} assetClass={i.asset_class} defaultRange={i.asset_class === "CRYPTO" ? "1D" : "3M"} />
71+ <Section title="Key statistics" hint={isRate ? "percent" : d.quote?.currency ?? undefined}>
72+ <InstrumentStats instrument={i} quote={d.quote} />
73+ </Section>
74+ <Section title="Why this price?" hint="provenance, weights and consensus method">
75+ <ProvenancePanel instrumentId={i.id} assetClass={i.asset_class} defaultOpen />
76+ </Section>
77+ <Section title="Recent events" href={`/events?instrument=${encodeURIComponent(i.id)}`} hint="canonical events touching this instrument">
78+ {events?.length ? (
79+ <ul className="rounded-md border border-rule bg-surface px-3">
80+ {events.map((e) => (
81+ <EventRow key={e.id} e={e} />
82+ ))}
83+ </ul>
84+ ) : (
85+ <Empty>No canonical events for {i.symbol} yet. Events are derived from observed moves, halts, filings and source incidents.</Empty>
86+ )}
87+ </Section>
88+ {d.company?.cik && (
89+ <Section title="Regulatory filings" href={`/filings?cik=${d.company.cik}`} hint="SEC EDGAR · linked by CIK">
90+ {filings?.length ? (
91+ <div className="overflow-x-auto rounded-md border border-rule bg-surface">
92+ <table className="table-dense">
93+ <thead>
94+ <tr>
95+ <th>Filed</th>
96+ <th>Form</th>
97+ <th>Filer</th>
98+ <th>Document</th>
99+ </tr>
100+ </thead>
101+ <tbody>
102+ {filings.map((f) => (
103+ <tr key={f.id}>
104+ <td className="mono text-ink-2">{formatDateTime(f.filed_at, { tz: "America/New_York" })}</td>
105+ <td className="mono font-medium">{f.form_type}</td>
106+ <td>{f.company_name}</td>
107+ <td>
108+ <a href={f.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
109+ EDGAR ↗
110+ </a>
111+ </td>
112+ </tr>
113+ ))}
114+ </tbody>
115+ </table>
116+ </div>
117+ ) : (
118+ <Empty>No filings observed for CIK {d.company.cik} since Market Atlas started listening.</Empty>
119+ )}
120+ </Section>
121+ )}
122+ </div>
123+ <aside className="space-y-6">
124+ <div className="rounded-md border border-rule bg-surface p-4">
125+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Instrument</h3>
126+ <Kv
127+ cols={1}
128+ className="mt-2"
129+ items={[
130+ ["Market Atlas id", i.id],
131+ ["Class", ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class],
132+ ["Security type", i.security_type?.replace(/_/g, " ").toLowerCase() ?? "—"],
133+ ["Currency", i.currency ?? "—"],
134+ ["Country", i.country ?? "—"],
135+ ...(i.base ? ([["Base / quote", `${i.base} / ${i.quote ?? "—"}`]] as Array<[string, string]>) : []),
136+ ["MIC", i.mic ?? "—"],
137+ ]}
138+ />
139+ {d.quote && (
140+ <div className="mt-3 flex flex-wrap gap-1.5">
141+ <StatusBadge status={d.quote.data_status} />
142+ <RightsBadge status={d.quote.rights_status} />
143+ </div>
144+ )}
145+ </div>
146+ {d.exchange && (
147+ <div className="rounded-md border border-rule bg-surface p-4">
148+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Venue</h3>
149+ <Link href={`/exchanges/${d.exchange.id}`} className="mt-1 block font-medium hover:underline">
150+ {d.exchange.name}
151+ </Link>
152+ <div className="mt-1 flex items-center gap-2 text-sm text-ink-2">
153+ <StatusBadge status={d.exchange.status.state} /> local {d.exchange.status.localTime} · {d.exchange.timezone}
154+ {d.exchange.status.isHoliday && d.exchange.status.holidayName ? ` · ${d.exchange.status.holidayName}` : ""}
155+ </div>
156+ {d.exchange.status.nextTransition && (
157+ <div className="mt-1 text-xs text-ink-3">
158+ Next: {d.exchange.status.nextTransition.state.toLowerCase()} at {formatDateTime(d.exchange.status.nextTransition.at, { tz: d.exchange.timezone })}
159+ </div>
160+ )}
161+ </div>
162+ )}
163+ {d.company && (
164+ <div className="rounded-md border border-rule bg-surface p-4">
165+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Company</h3>
166+ <div className="mt-1 font-medium">{d.company.name}</div>
167+ <Kv cols={1} className="mt-1" items={[["CIK", d.company.cik ?? "—"], ["Country", d.company.country ?? "—"], ["Sector", d.company.sector ?? "—"]]} />
168+ {d.company.cik && (
169+ <a href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${d.company.cik}`} target="_blank" rel="noopener noreferrer" className="mt-2 inline-block text-xs text-accent hover:underline">
170+ EDGAR company page ↗
171+ </a>
172+ )}
173+ </div>
174+ )}
175+ <div className="rounded-md border border-rule bg-surface p-4">
176+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Observed by</h3>
177+ {d.sources.length ? (
178+ <ul className="mt-1 flex flex-wrap gap-1.5">
179+ {d.sources.map((s) => (
180+ <li key={s}>
181+ <Link href={`/sources#${s}`} className="mono rounded border border-rule px-1.5 py-0.5 text-xs text-ink-2 hover:border-rule-strong">
182+ {s}
183+ </Link>
184+ </li>
185+ ))}
186+ </ul>
187+ ) : (
188+ <p className="mt-1 text-xs text-ink-3">No source has published a value for this instrument in the current process yet.</p>
189+ )}
190+ {d.aliases.length > 0 && (
191+ <>
192+ <h3 className="mt-3 text-[11px] font-medium uppercase tracking-wide text-ink-3">Aliases</h3>
193+ <ul className="mt-1 flex flex-wrap gap-1.5">
194+ {d.aliases.slice(0, 12).map((a) => (
195+ <li key={`${a.alias}-${a.source_id}`} className="mono rounded bg-surface-2 px-1.5 py-0.5 text-xs text-ink-2" title={a.source_id ? `alias used by ${a.source_id}` : "global alias"}>
196+ {a.alias}
197+ {a.source_id ? <span className="text-ink-3"> · {a.source_id}</span> : null}
198+ </li>
199+ ))}
200+ </ul>
201+ </>
202+ )}
203+ </div>
204+ {d.related.length > 0 && (
205+ <div className="rounded-md border border-rule bg-surface p-4">
206+ <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Related instruments</h3>
207+ <ul className="mt-1 divide-y divide-rule text-sm">
208+ {d.related.map((r) => (
209+ <li key={r.id} className="flex items-center justify-between py-1.5">
210+ <Link href={instrumentHref(r.id)} className="min-w-0 truncate hover:underline">
211+ <span className="mono font-medium">{r.symbol}</span> <span className="text-xs text-ink-3">{r.name}</span>
212+ </Link>
213+ <span className="text-[10.5px] uppercase text-ink-3">{r.exchange_id ?? r.asset_class}</span>
214+ </li>
215+ ))}
216+ </ul>
217+ </div>
218+ )}
219+ </aside>
220+ </div>
221+ </div>
222+ </div>
223+ );
224+}
added apps/web/src/app/licensing/page.tsx +106 −0
@@ -0,0 +1,106 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Page, PageHeader } from "@/components/ui/section";
4+import { RightsBadge } from "@/components/ui/status-badge";
5+import { apiOptional } from "@/lib/api";
6+import { DISCLAIMER } from "@/lib/site";
7+import type { Source } from "@/lib/types";
8+
9+export const metadata: Metadata = { title: "Data rights & licensing", description: "How Market Atlas classifies the rights attached to every source and what each status allows.", alternates: { canonical: "/licensing" } };
10+export const dynamic = "force-dynamic";
11+
12+const STATUSES: Array<[string, string]> = [
13+ ["PUBLIC_OPEN", "Public data with no usage restriction known to us."],
14+ ["PUBLIC_ATTRIBUTED", "Public data displayed with attribution to the source (venue feeds, reference files). Not for raw-feed commercial redistribution."],
15+ ["OFFICIAL_OPEN_DATA", "Government or central-bank open data (public domain or open licence with attribution)."],
16+ ["LICENSED", "Data licensed to Market Atlas (including our sister platform HF Market Data) — redistribution within the licence."],
17+ ["DELAYED", "Delayed market data that may be displayed publicly with attribution and a delay notice (Cboe: 15 minutes)."],
18+ ["INDICATIVE", "Indicative values (reference fixings, averages) — informational, not tradable."],
19+ ["PUBLIC_RESTRICTED_REDISTRIBUTION", "Publicly accessible but redistribution is restricted: used internally for validation only; public values are withheld."],
20+ ["RESEARCH_ONLY", "Usable for internal research; never displayed."],
21+ ["INTERNAL_ONLY", "Internal signals (Market Atlas' own calendar/monitoring) or values built only from restricted sources."],
22+ ["UNKNOWN", "Rights not yet classified — never exposed publicly until reviewed."],
23+];
24+
25+export default async function LicensingPage() {
26+ const sources = (await apiOptional<Source[]>("/v1/sources"))?.filter((s) => s.category !== "INTERNAL") ?? [];
27+ return (
28+ <Page>
29+ <PageHeader kicker="Legal" title="Data rights & licensing" lead="Technical accessibility does not imply redistribution rights. Every connector declares a rights status; the platform enforces it — quotes built only from restricted sources are withheld, while their provenance metadata stays visible." />
30+ <div className="prose-ma max-w-3xl">
31+ <h2>Rights statuses</h2>
32+ <table>
33+ <thead>
34+ <tr>
35+ <th>Status</th>
36+ <th>What it means</th>
37+ </tr>
38+ </thead>
39+ <tbody>
40+ {STATUSES.map(([s, d]) => (
41+ <tr key={s}>
42+ <td>
43+ <RightsBadge status={s} />
44+ <code className="ml-2">{s}</code>
45+ </td>
46+ <td>{d}</td>
47+ </tr>
48+ ))}
49+ </tbody>
50+ </table>
51+ <h2>What Market Atlas never does</h2>
52+ <ul>
53+ <li>Bypass authentication, paywalls, CAPTCHAs or access controls; impersonate users; use leaked credentials or private sessions.</li>
54+ <li>Reverse-engineer protected feeds for unauthorized redistribution or evade a source's restrictions.</li>
55+ <li>Present delayed, end-of-day or stale values as real time.</li>
56+ </ul>
57+ <h2>Attributions</h2>
58+ {sources.length ? (
59+ <table>
60+ <thead>
61+ <tr>
62+ <th>Source</th>
63+ <th>Rights</th>
64+ <th>Notes</th>
65+ </tr>
66+ </thead>
67+ <tbody>
68+ {sources.map((s) => (
69+ <tr key={s.id}>
70+ <td>
71+ {s.homepage ? (
72+ <a href={s.homepage} target="_blank" rel="noopener noreferrer">
73+ {s.name}
74+ </a>
75+ ) : (
76+ s.name
77+ )}
78+ <div className="text-xs text-ink-3">{s.organization}</div>
79+ </td>
80+ <td>
81+ <RightsBadge status={s.rights_status} />
82+ </td>
83+ <td>
84+ {s.rights_notes}
85+ {s.terms_url && (
86+ <>
87+ {" "}
88+ <a href={s.terms_url} target="_blank" rel="noopener noreferrer">
89+ terms ↗
90+ </a>
91+ </>
92+ )}
93+ </td>
94+ </tr>
95+ ))}
96+ </tbody>
97+ </table>
98+ ) : (
99+ <p>Source attributions are listed on the <Link href="/sources">sources</Link> page.</p>
100+ )}
101+ <h2>Disclaimer</h2>
102+ <p>{DISCLAIMER} Final legal language will be reviewed before any commercial launch.</p>
103+ </div>
104+ </Page>
105+ );
106+}
added apps/web/src/app/live/page.tsx +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/manifest.ts +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/markets/loading.tsx +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/markets/page.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/methodology/page.tsx +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/not-found.tsx +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/page.tsx +104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/rates/page.tsx +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/robots.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/page.tsx +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sources/page.tsx +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/status/page.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/stocks/[symbol]/page.tsx +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/stocks/page.tsx +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/admin-console.tsx +583 −0
Large diff (583 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/site-footer.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/site-header.tsx +1 −2
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/changes-panel.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/class-page.tsx +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/compare-view.tsx +218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/health-panel.tsx +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/instrument-header.tsx +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/instrument-stats.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/market/live-events.tsx +3 −3
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/live-feed.tsx +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/provenance-panel.tsx +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/market/pulse-grid.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/market/tape.tsx +2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/format.ts +6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/tsconfig.json +28 −5
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/hfmarketdata/index.ts +5 −2
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/nasdaq-market-calendar/index.ts +3 −1
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added docs/FRONTEND.md +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +3 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme