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 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 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 17 deploy/rendered/
18 18 apps/web/qa/screens/
19 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 79 symbols: rt.symbols,
80 80 schedule: rt.def.schedule ?? null,
81 81 state: redactObject(rt.stateCache),
82 + consecutive_failures: rt.consecutiveFailures,
83 + drift_strikes: rt.driftStrikes,
82 84 schema_changes: changes.rows,
83 85 health_history: healthHistory.rows,
84 86 recent_observations: recent.rows,
modified apps/api/src/db/pool.ts +1 −2
@@ -3,8 +3,7 @@ import { config } from "../config.js";
3 3 import { logger } from "../logger.js";
4 4
5 5 const { Pool, types } = pg;
6 −// Keep timestamptz as ISO strings → we convert to ms explicitly; numeric/int8 as numbers where safe.
7 −types.setTypeParser(1184, (v) => v);
6 +// timestamptz stays a Date (pg default → ISO in JSON); numeric/int8 as numbers where safe.
8 7 types.setTypeParser(20, (v) => Number(v));
9 8 types.setTypeParser(1700, (v) => Number(v));
10 9
modified apps/web/package.json +1 −0
@@ -28,6 +28,7 @@
28 28 "@types/react": "^19",
29 29 "@types/react-dom": "^19",
30 30 "@types/topojson-client": "^3.1.5",
31 + "@types/topojson-specification": "^1.0.5",
31 32 "tailwindcss": "^4",
32 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 271 width: 100%;
272 272 font-size: 0.85rem;
273 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 284 .prose-ma th,
276 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