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>
69 changed files +3,684 −19
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="ed=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:<SYMBOL|id></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:<SYMBOL|id></code> · <code>events:US</code> (country) · <code>events:type:TRADING_HALT</code>. | |
| 119 | + </li> | |
| 120 | + <li> | |
| 121 | + <code>market:<exchange></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
@@ -0,0 +1,18 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { LiveFeed } from "@/components/market/live-feed"; | |
| 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: "Live feed", description: "Global market activity stream: canonical events and price changes across every source Market Atlas observes.", alternates: { canonical: "/live" } }; | |
| 8 | +export const dynamic = "force-dynamic"; | |
| 9 | + | |
| 10 | +export default async function LivePage() { | |
| 11 | + const events = await api<MarketEvent[]>("/v1/events?limit=100"); | |
| 12 | + return ( | |
| 13 | + <Page wide> | |
| 14 | + <PageHeader kicker="Live" title="Global activity stream" lead="Canonical market events and price changes as they are formed — filter by asset class, country, type, severity and confidence. Every item names its sources." /> | |
| 15 | + <LiveFeed initial={events} /> | |
| 16 | + </Page> | |
| 17 | + ); | |
| 18 | +} | |
added
apps/web/src/app/manifest.ts
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { DESCRIPTION, SITE_NAME, THEME_DARK, THEME_LIGHT } from "@/lib/site"; | |
| 3 | + | |
| 4 | +export default function manifest(): MetadataRoute.Manifest { | |
| 5 | + return { | |
| 6 | + name: SITE_NAME, | |
| 7 | + short_name: SITE_NAME, | |
| 8 | + description: DESCRIPTION, | |
| 9 | + start_url: "/", | |
| 10 | + display: "standalone", | |
| 11 | + background_color: THEME_LIGHT, | |
| 12 | + theme_color: THEME_DARK, | |
| 13 | + icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }], | |
| 14 | + }; | |
| 15 | +} | |
added
apps/web/src/app/markets/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/markets/page.tsx
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { InstrumentTable } from "@/components/market/instrument-table"; | |
| 4 | +import { Page, PageHeader, Section, Stat } from "@/components/ui/section"; | |
| 5 | +import { api } from "@/lib/api"; | |
| 6 | +import type { MarketsOverview } from "@/lib/types"; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: "Markets overview", description: "Indices, equities, crypto, forex, rates and commodities — one canonical, multi-source view.", alternates: { canonical: "/markets" } }; | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | + | |
| 11 | +export default async function MarketsPage() { | |
| 12 | + const m = await api<MarketsOverview>("/v1/markets"); | |
| 13 | + const open = m.exchanges.filter((e) => e.status.state === "OPEN"); | |
| 14 | + const b = m.breadth; | |
| 15 | + return ( | |
| 16 | + <Page wide> | |
| 17 | + <PageHeader kicker="Markets" title="Global overview" lead="Canonical quotes across asset classes. Each table streams live where a real-time source exists; otherwise values show their delayed, at-close or end-of-day status." /> | |
| 18 | + <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4"> | |
| 19 | + <Stat label="Venues open now" value={`${open.length} / ${m.exchanges.length}`} sub={open.slice(0, 3).map((e) => e.name.split(" ")[0]).join(", ") || "none trading"} /> | |
| 20 | + <Stat label="US equity breadth" value={b.instruments ? `${b.advancers} ▲ ${b.decliners} ▼` : "—"} sub={b.instruments ? `${b.instruments} quoted · median ${b.median_change_percent?.toFixed(2)}%` : "no session data"} tone={b.advancers > b.decliners ? "pos" : b.decliners > b.advancers ? "neg" : undefined} /> | |
| 21 | + <Stat label="Session highs / lows" value={`${b.new_session_highs} / ${b.new_session_lows}`} sub="live instruments at extremes" /> | |
| 22 | + <Stat label="Sections" value="7" sub="indices · stocks · ETFs · crypto · FX · rates · commodities" /> | |
| 23 | + </div> | |
| 24 | + <Section title="Indices" href="/indices" hint="15-min delayed (Cboe) · at close outside the session"> | |
| 25 | + <InstrumentTable rows={m.indices} compact showExchange={false} /> | |
| 26 | + </Section> | |
| 27 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-2"> | |
| 28 | + <Section title="Top gainers · US equities" href="/stocks?sort=-change"> | |
| 29 | + <InstrumentTable rows={m.gainers} compact /> | |
| 30 | + </Section> | |
| 31 | + <Section title="Top losers · US equities" href="/stocks?sort=change"> | |
| 32 | + <InstrumentTable rows={m.losers} compact defaultSort="change" /> | |
| 33 | + </Section> | |
| 34 | + </div> | |
| 35 | + <Section title="Crypto" href="/crypto" hint="real-time consensus of public venue feeds"> | |
| 36 | + <InstrumentTable rows={m.crypto} liveClass="CRYPTO" compact showExchange /> | |
| 37 | + </Section> | |
| 38 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-2"> | |
| 39 | + <Section title="Forex" href="/forex" hint="official reference rates"> | |
| 40 | + <InstrumentTable rows={m.forex} compact showExchange={false} defaultSort="symbol" /> | |
| 41 | + </Section> | |
| 42 | + <Section title="Rates & yields" href="/rates" hint="percent · end of day"> | |
| 43 | + <InstrumentTable rows={m.rates} compact showExchange={false} defaultSort="symbol" /> | |
| 44 | + </Section> | |
| 45 | + </div> | |
| 46 | + <Section title="Commodities & futures" href="/commodities" hint="continuous front month · settlement"> | |
| 47 | + <InstrumentTable rows={m.commodities} compact showExchange={false} defaultSort="symbol" /> | |
| 48 | + </Section> | |
| 49 | + <p className="mt-6 text-xs text-ink-3"> | |
| 50 | + Prices are aggregations of independent observations. Read the <Link href="/methodology" className="text-accent hover:underline">methodology</Link> and the <Link href="/licensing" className="text-accent hover:underline">data rights</Link> page for what each status means. | |
| 51 | + </p> | |
| 52 | + </Page> | |
| 53 | + ); | |
| 54 | +} | |
added
apps/web/src/app/methodology/page.tsx
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: "Methodology", description: "How Market Atlas forms canonical prices: observations, consensus, freshness semantics, confidence, source families, reliability scores and derived metrics.", alternates: { canonical: "/methodology" } }; | |
| 6 | + | |
| 7 | +export default function MethodologyPage() { | |
| 8 | + return ( | |
| 9 | + <Page> | |
| 10 | + <PageHeader kicker="Transparency" title="Methodology" lead="Market Atlas never shows a value without saying where it came from, how fresh it is, and how many independent observers agree. This page explains each step from a raw payload to a canonical quote." /> | |
| 11 | + <div className="prose-ma max-w-3xl"> | |
| 12 | + <h2>1. Observations, not prices</h2> | |
| 13 | + <p> | |
| 14 | + The atomic unit is an <strong>observation</strong>: one field (last price, bid, ask, volume, yield…) for one instrument, from one source, at one time. Every observation keeps its source timestamp when the source publishes one, the moment Market Atlas received it, its data-rights status, its real-time class and a reference to the raw payload it was extracted from (layers L0 raw → L1 parsed → L2 normalized → L3 canonical → L4 derived). | |
| 15 | + </p> | |
| 16 | + <h2>2. Instrument resolution</h2> | |
| 17 | + <p> | |
| 18 | + A ticker alone never identifies a security. Each source symbol (<code>BTC-USD</code>, <code>BTC/USD</code>, <code>BTCUSD</code>, <code>BRK.B</code>, <code>BRK-B</code>…) is resolved through a per-source alias table to one stable Market Atlas id such as <code>eq_us_xnas_aapl</code>, <code>crypto_btc_usd</code>, <code>fx_eur_usd</code> or <code>index_us_spx</code>. USD and USDT crypto pairs are different instruments. Listed US equities come from the Nasdaq Trader symbol directories and the SEC company directory (CIK), which is how filings link to instruments. | |
| 19 | + </p> | |
| 20 | + <h2>3. Consensus</h2> | |
| 21 | + <p>For every instrument and field, the engine keeps the latest observation of each source, then computes:</p> | |
| 22 | + <ul> | |
| 23 | + <li> | |
| 24 | + <strong>Freshness window.</strong> Real-time observations count for 15 seconds, delayed feeds for 30 minutes, indicative values for one hour, end-of-day values for three days. While a venue is closed, the last session value stays valid for up to four days and is reported as <em>at close</em>. Anything older is excluded and marked <em>stale</em>. | |
| 25 | + </li> | |
| 26 | + <li> | |
| 27 | + <strong>Tiering.</strong> When a real-time or delayed observation exists, end-of-day and indicative values for the same instrument are superseded (kept visible in the provenance table, marked <code>superseded_by_live</code>). | |
| 28 | + </li> | |
| 29 | + <li> | |
| 30 | + <strong>Weights.</strong> weight = source reliability × timestamp quality (exchange 1.0, source 0.9, connector 0.75) × real-time class (real time 1.0, delayed 0.6, indicative 0.5, end of day 0.4) × official-source bonus × freshness decay. | |
| 31 | + </li> | |
| 32 | + <li> | |
| 33 | + <strong>Source families.</strong> Sources believed to share an upstream (for example several sites redistributing the same vendor) count as one vote; extra members of a family receive a quarter of their weight. The number of <em>independent sources</em> shown everywhere is the number of distinct families included. | |
| 34 | + </li> | |
| 35 | + <li> | |
| 36 | + <strong>Outliers.</strong> With three or more candidates, values more than 2 % away from the weighted median are excluded (reason <code>outlier</code>). | |
| 37 | + </li> | |
| 38 | + <li> | |
| 39 | + <strong>Canonical value.</strong> The weighted median of the remaining candidates. Dispersion is the spread between the highest and lowest included value, in basis points. | |
| 40 | + </li> | |
| 41 | + </ul> | |
| 42 | + <h2>4. Confidence</h2> | |
| 43 | + <p> | |
| 44 | + Confidence is a 0–1 score of Market Atlas' own agreement, redundancy and freshness — never a prediction or a view on the instrument: <code>0.15 + 0.35 × agreement + 0.25 × redundancy + 0.10 × freshness + 0.15 × mean reliability</code>, capped at 0.995 so the interface never claims false precision. Agreement falls to zero at 1 % dispersion; redundancy is <code>1 − e^(−families/2)</code> (one family ≈ 0.39, three ≈ 0.78, five ≈ 0.92); freshness decays with the age of the newest observation (fixed at 1 while a venue is closed). A single stale observation yields confidence 0. | |
| 45 | + </p> | |
| 46 | + <h2>5. Data status labels</h2> | |
| 47 | + <table> | |
| 48 | + <thead> | |
| 49 | + <tr> | |
| 50 | + <th>Label</th> | |
| 51 | + <th>Meaning</th> | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + <tr> | |
| 56 | + <td>Live</td> | |
| 57 | + <td>At least one real-time source within the freshness window (crypto venue feeds).</td> | |
| 58 | + </tr> | |
| 59 | + <tr> | |
| 60 | + <td>Delayed</td> | |
| 61 | + <td>Value from a feed that is contractually delayed (Cboe quotes are 15 minutes behind).</td> | |
| 62 | + </tr> | |
| 63 | + <tr> | |
| 64 | + <td>At close</td> | |
| 65 | + <td>The venue is closed; the value is the last session's, shown with the closing time.</td> | |
| 66 | + </tr> | |
| 67 | + <tr> | |
| 68 | + <td>End of day</td> | |
| 69 | + <td>Official or licensed daily value (ECB/Bank of Canada fixings, Treasury par yields, daily bars).</td> | |
| 70 | + </tr> | |
| 71 | + <tr> | |
| 72 | + <td>Stale</td> | |
| 73 | + <td>No source within its window; the last known value is displayed with its age and confidence 0.</td> | |
| 74 | + </tr> | |
| 75 | + <tr> | |
| 76 | + <td>Withheld</td> | |
| 77 | + <td>The value exists but its data rights do not allow public redistribution; provenance metadata is still shown.</td> | |
| 78 | + </tr> | |
| 79 | + </tbody> | |
| 80 | + </table> | |
| 81 | + <h2>6. Source reliability</h2> | |
| 82 | + <p> | |
| 83 | + Each connector carries an operational score from 0 to 100: 40 % availability (share of minutes healthy), 20 % parse success, 15 % latency (p95 under 1 s = full marks), 15 % connection stability, 10 % error rate. It measures how well the integration behaves, not the quality of the venue. The best score across a source's connectors feeds the consensus weight. | |
| 84 | + </p> | |
| 85 | + <h2>7. Events</h2> | |
| 86 | + <p> | |
| 87 | + Ticks are noisy; events are meaningful. Derived events use per-instrument baselines: a <em>price move</em> fires when the change since the last event exceeds max(0.5 %, 8 × the typical tick move); <em>session highs/lows</em> require 200 observations and a 0.1 % improvement; a <em>volatility spike</em> compares realized volatility over the last 30 ticks with an exponentially weighted baseline (≥ 4×); <em>source divergence</em> fires when included sources disagree by more than 50 bps. Venue and regulator events (halts, filings) are deduplicated on their native identifiers; additional sources confirming the same event increase its source count instead of creating a duplicate. | |
| 88 | + </p> | |
| 89 | + <h2>8. Bars and derived metrics</h2> | |
| 90 | + <p> | |
| 91 | + One-minute bars are aggregated from canonical prices and rolled up to 5m/15m/1h/1d; daily history for equities, indices, FX, crypto and continuous futures comes from licensed end-of-day bars. Market Atlas never overwrites an official daily bar with a derived one. Returns, volatility, drawdown, correlation, breadth and “what changed” counts are computed from these bars and labelled <em>derived</em>. | |
| 92 | + </p> | |
| 93 | + <h2>9. Market hours</h2> | |
| 94 | + <p> | |
| 95 | + Session states (pre, open, post, closed, holiday, early close) come from a calendar engine using IANA time zones, per-venue sessions and holiday tables; US holidays are refreshed from the Nasdaq Trader schedule page by an HTML change-detection connector. Nothing is hard-coded to 09:30–16:00. | |
| 96 | + </p> | |
| 97 | + <h2>10. Limitations</h2> | |
| 98 | + <ul> | |
| 99 | + <li>Equity and index quotes are delayed 15 minutes; Market Atlas does not hold real-time US equity redistribution rights.</li> | |
| 100 | + <li>FX and rates are official reference values, published once a day — indicative, not tradable.</li> | |
| 101 | + <li>Intraday history starts when Market Atlas first observed an instrument live; gaps are never interpolated.</li> | |
| 102 | + <li>Coverage measures Market Atlas observation, not market size or quality.</li> | |
| 103 | + </ul> | |
| 104 | + <p> | |
| 105 | + See also: <Link href="/licensing">data rights</Link>, <Link href="/sources">sources</Link>, <Link href="/developers">developer documentation</Link>. | |
| 106 | + </p> | |
| 107 | + </div> | |
| 108 | + </Page> | |
| 109 | + ); | |
| 110 | +} | |
added
apps/web/src/app/not-found.tsx
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { Page } from "@/components/ui/section"; | |
| 3 | +import { SearchButton } from "@/components/layout/search-dialog"; | |
| 4 | + | |
| 5 | +export default function NotFound() { | |
| 6 | + return ( | |
| 7 | + <Page className="py-20 text-center"> | |
| 8 | + <p className="text-[11px] font-medium uppercase tracking-wide text-ink-3">404</p> | |
| 9 | + <h1 className="mt-2 text-3xl font-semibold tracking-tight">Not on the map</h1> | |
| 10 | + <p className="mx-auto mt-2 max-w-md text-sm text-ink-2">This instrument, venue or page is not in the atlas. Try the universal search — symbols, names, exchanges and countries all resolve.</p> | |
| 11 | + <div className="mt-6 flex justify-center gap-2"> | |
| 12 | + <SearchButton /> | |
| 13 | + <Link href="/markets" className="inline-flex h-11 items-center rounded-md bg-ink px-4 text-sm font-medium text-canvas"> | |
| 14 | + Markets overview | |
| 15 | + </Link> | |
| 16 | + </div> | |
| 17 | + </Page> | |
| 18 | + ); | |
| 19 | +} | |
added
apps/web/src/app/page.tsx
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { ChangesPanel } from "@/components/market/changes-panel"; | |
| 3 | +import { HealthPanel } from "@/components/market/health-panel"; | |
| 4 | +import { InstrumentTable } from "@/components/market/instrument-table"; | |
| 5 | +import { LiveEvents } from "@/components/market/live-events"; | |
| 6 | +import { PulseRow } from "@/components/market/pulse-grid"; | |
| 7 | +import { LiveTape } from "@/components/market/tape"; | |
| 8 | +import { TelemetryStrip } from "@/components/market/telemetry-strip"; | |
| 9 | +import { WorldMap } from "@/components/market/world-map"; | |
| 10 | +import { SearchButton } from "@/components/layout/search-dialog"; | |
| 11 | +import { Section } from "@/components/ui/section"; | |
| 12 | +import { api, apiOptional } from "@/lib/api"; | |
| 13 | +import type { Changes, DataHealth, MarketEvent, MarketsOverview, Stats } from "@/lib/types"; | |
| 14 | + | |
| 15 | +export const dynamic = "force-dynamic"; | |
| 16 | + | |
| 17 | +export default async function HomePage() { | |
| 18 | + const [stats, markets, events, health, changes] = await Promise.all([ | |
| 19 | + api<Stats>("/v1/stats"), | |
| 20 | + api<MarketsOverview>("/v1/markets"), | |
| 21 | + apiOptional<MarketEvent[]>("/v1/events?limit=30"), | |
| 22 | + apiOptional<DataHealth>("/v1/data-health"), | |
| 23 | + apiOptional<Changes>("/v1/changes?window=1h"), | |
| 24 | + ]); | |
| 25 | + const movers = [...markets.gainers.slice(0, 5), ...markets.losers.slice(0, 5)]; | |
| 26 | + return ( | |
| 27 | + <div className="mx-auto max-w-[1440px] px-3 sm:px-5"> | |
| 28 | + {/* Hero */} | |
| 29 | + <section className="pb-6 pt-8 sm:pt-12"> | |
| 30 | + <p className="text-[11px] font-medium uppercase tracking-[0.14em] text-ink-3">Market Atlas</p> | |
| 31 | + <h1 className="mt-2 max-w-3xl text-3xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">The live map of global markets.</h1> | |
| 32 | + <p className="mt-3 max-w-2xl text-[15px] text-ink-2 sm:text-base"> | |
| 33 | + Thousands of instruments. Independent sources compared in real time. One continuously updating, provenance-backed market graph — every quote carries its freshness, its sources and its confidence. | |
| 34 | + </p> | |
| 35 | + <div className="mt-5 flex flex-wrap items-center gap-2"> | |
| 36 | + <SearchButton className="h-12 !text-[15px] sm:min-w-[360px]" /> | |
| 37 | + <Link href="/live" className="inline-flex h-12 items-center gap-2 rounded-md bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90"> | |
| 38 | + <span className="live-dot inline-block h-2 w-2 rounded-full bg-positive" /> Open the live feed | |
| 39 | + </Link> | |
| 40 | + <Link href="/methodology" className="inline-flex h-12 items-center px-3 text-sm text-ink-2 hover:text-ink"> | |
| 41 | + How prices are formed → | |
| 42 | + </Link> | |
| 43 | + </div> | |
| 44 | + <TelemetryStrip initial={stats} className="mt-7" /> | |
| 45 | + </section> | |
| 46 | + | |
| 47 | + {/* Pulse */} | |
| 48 | + <Section title="Global market pulse" hint="canonical quotes · live where sources allow, delayed or end-of-day otherwise" href="/markets"> | |
| 49 | + <PulseRow title="Indices" href="/indices" items={markets.indices} /> | |
| 50 | + <PulseRow title="Crypto" href="/crypto" items={markets.crypto} /> | |
| 51 | + <PulseRow title="Forex" href="/forex" items={markets.forex} /> | |
| 52 | + <PulseRow title="Rates & yields" href="/rates" items={markets.rates} /> | |
| 53 | + <PulseRow title="Commodities & futures" href="/commodities" items={markets.commodities} /> | |
| 54 | + <PulseRow title="Equities" href="/stocks" items={markets.equities} className="border-b-0" /> | |
| 55 | + </Section> | |
| 56 | + | |
| 57 | + {/* Tape + events */} | |
| 58 | + <div className="mt-8 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1.1fr_1fr]"> | |
| 59 | + <Section title="Live tape" hint="every canonical price change" className="!mt-0"> | |
| 60 | + <LiveTape /> | |
| 61 | + </Section> | |
| 62 | + <Section title="Live events" hint="halts, filings, moves, source incidents" href="/events" className="!mt-0"> | |
| 63 | + <LiveEvents initial={events ?? []} max={18} dense className="max-h-[470px] overflow-y-auto" /> | |
| 64 | + </Section> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + {/* Map */} | |
| 68 | + <Section title="World market map" hint="venues by session state · hover a marker for local time" href="/exchanges" hrefLabel="All exchanges"> | |
| 69 | + <WorldMap exchanges={markets.exchanges} /> | |
| 70 | + </Section> | |
| 71 | + | |
| 72 | + {/* Movers + health + changes */} | |
| 73 | + <div className="mt-8 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1.3fr_1fr]"> | |
| 74 | + <Section title="Largest moves · US equities" hint="Cboe delayed quotes, 15 min" href="/stocks" className="!mt-0"> | |
| 75 | + <InstrumentTable rows={movers} compact defaultSort="change" emptyText="No equity quotes yet — the US session has not produced observations." /> | |
| 76 | + {markets.breadth.instruments > 0 && ( | |
| 77 | + <p className="mt-2 text-xs text-ink-3"> | |
| 78 | + Breadth: <span className="text-positive">{markets.breadth.advancers} advancing</span> · <span className="text-negative">{markets.breadth.decliners} declining</span> · median {markets.breadth.median_change_percent?.toFixed(2)}% across {markets.breadth.instruments} quoted names. | |
| 79 | + </p> | |
| 80 | + )} | |
| 81 | + </Section> | |
| 82 | + <div className="space-y-4"> | |
| 83 | + {health && <HealthPanel health={health} />} | |
| 84 | + {changes && <ChangesPanel changes={changes} />} | |
| 85 | + </div> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + <section className="mt-12 grid grid-cols-1 [&>*]:min-w-0 gap-6 border-t border-rule pt-8 text-sm text-ink-2 md:grid-cols-3"> | |
| 89 | + <div> | |
| 90 | + <h3 className="font-semibold text-ink">Multi-source by design</h3> | |
| 91 | + <p className="mt-1">Prices are a weighted median of independent observations, one vote per upstream family. Open “Why this price?” on any instrument to see every contribution.</p> | |
| 92 | + </div> | |
| 93 | + <div> | |
| 94 | + <h3 className="font-semibold text-ink">Honest freshness</h3> | |
| 95 | + <p className="mt-1">Live, delayed, at-close, end-of-day or stale — every value is labelled with its status and the age of its newest observation. Nothing stale is ever shown as live.</p> | |
| 96 | + </div> | |
| 97 | + <div> | |
| 98 | + <h3 className="font-semibold text-ink">A growing memory</h3> | |
| 99 | + <p className="mt-1">Every observation, event and filing is recorded with its provenance. The historical dataset grows every second the atlas runs.</p> | |
| 100 | + </div> | |
| 101 | + </section> | |
| 102 | + </div> | |
| 103 | + ); | |
| 104 | +} | |
added
apps/web/src/app/rates/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: "Rates & yields", description: "US Treasury par yield curve, Government of Canada benchmark yields and policy rates from official sources.", alternates: { canonical: "/rates" } }; | |
| 5 | +export const dynamic = "force-dynamic"; | |
| 6 | + | |
| 7 | +export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | |
| 8 | + return <ClassPage classes={["TREASURY", "BOND", "INTEREST_RATE"]} kicker="Fixed income" title="Rates & yields" lead="Daily par yields from the U.S. Treasury, Government of Canada benchmark bond yields and the Bank of Canada policy rate. Values are in percent, published once per business day." basePath="/rates" searchParams={searchParams} showExchange={false} defaultSort="symbol" />; | |
| 9 | +} | |
added
apps/web/src/app/robots.ts
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { SITE_URL } from "@/lib/site"; | |
| 3 | + | |
| 4 | +export default function robots(): MetadataRoute.Robots { | |
| 5 | + return { | |
| 6 | + rules: [{ userAgent: "*", allow: "/", disallow: ["/admin", "/search", "/v1/"] }], | |
| 7 | + sitemap: ["static", "exchanges", "countries", "stocks", "etfs", "indices", "crypto", "forex", "rates", "commodities"].map((id) => `${SITE_URL}/sitemap/${id}.xml`), | |
| 8 | + host: SITE_URL, | |
| 9 | + }; | |
| 10 | +} | |
added
apps/web/src/app/search/page.tsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { InstrumentTable } from "@/components/market/instrument-table"; | |
| 4 | +import { Empty, Page, PageHeader, Section } from "@/components/ui/section"; | |
| 5 | +import { StatusBadge } from "@/components/ui/status-badge"; | |
| 6 | +import { api } from "@/lib/api"; | |
| 7 | +import { ASSET_CLASS_LABEL } from "@/lib/format"; | |
| 8 | +import type { InstrumentWithQuote, SearchResult } from "@/lib/types"; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: "Search", robots: { index: false } }; | |
| 11 | +export const dynamic = "force-dynamic"; | |
| 12 | + | |
| 13 | +export default async function SearchPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | |
| 14 | + const sp = await searchParams; | |
| 15 | + const q = typeof sp.q === "string" ? sp.q.trim() : ""; | |
| 16 | + const res = q ? await api<SearchResult>(`/v1/search?q=${encodeURIComponent(q)}&limit=50`) : null; | |
| 17 | + const groups = res?.groups ?? {}; | |
| 18 | + const instrumentGroups = Object.entries(groups).filter(([g]) => g !== "EXCHANGE" && g !== "COUNTRY"); | |
| 19 | + return ( | |
| 20 | + <Page wide> | |
| 21 | + <PageHeader kicker="Search" title={q ? `Results for “${q}”` : "Search"} lead="Symbols, names, aliases, exchanges and countries. Instruments with a canonical quote rank first." /> | |
| 22 | + <form action="/search" className="mb-6 flex gap-2"> | |
| 23 | + <input name="q" defaultValue={q} placeholder="AAPL, Bitcoin, S&P 500, TSX, Canada, US 10Y…" className="h-12 flex-1 rounded-md border border-rule bg-surface px-3 text-[15px] outline-none focus:border-rule-strong" autoFocus /> | |
| 24 | + <button type="submit" className="h-12 rounded-md bg-ink px-4 text-sm font-medium text-canvas"> | |
| 25 | + Search | |
| 26 | + </button> | |
| 27 | + </form> | |
| 28 | + {q && !res?.results.length && <Empty>No results for “{q}”.</Empty>} | |
| 29 | + {(groups.EXCHANGE as Array<Record<string, string>> | undefined)?.length ? ( | |
| 30 | + <Section title="Exchanges"> | |
| 31 | + <ul className="grid grid-cols-1 [&>*]:min-w-0 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 32 | + {(groups.EXCHANGE as Array<Record<string, string>>).map((e) => ( | |
| 33 | + <li key={e.id} className="rounded-md border border-rule bg-surface px-3 py-2"> | |
| 34 | + <Link href={`/exchanges/${e.id}`} className="font-medium hover:underline"> | |
| 35 | + {e.name} | |
| 36 | + </Link> | |
| 37 | + <div className="mt-0.5 flex items-center gap-2 text-xs text-ink-3"> | |
| 38 | + <StatusBadge status={e.status} /> {e.mic ?? ""} · {e.country} | |
| 39 | + </div> | |
| 40 | + </li> | |
| 41 | + ))} | |
| 42 | + </ul> | |
| 43 | + </Section> | |
| 44 | + ) : null} | |
| 45 | + {(groups.COUNTRY as Array<Record<string, string>> | undefined)?.length ? ( | |
| 46 | + <Section title="Countries"> | |
| 47 | + <ul className="flex flex-wrap gap-2"> | |
| 48 | + {(groups.COUNTRY as Array<Record<string, string>>).map((c) => ( | |
| 49 | + <li key={c.code}> | |
| 50 | + <Link href={`/countries/${c.code}`} className="inline-flex h-9 items-center rounded-md border border-rule bg-surface px-3 text-sm hover:border-rule-strong"> | |
| 51 | + {c.name} <span className="mono ml-2 text-xs text-ink-3">{c.code}</span> | |
| 52 | + </Link> | |
| 53 | + </li> | |
| 54 | + ))} | |
| 55 | + </ul> | |
| 56 | + </Section> | |
| 57 | + ) : null} | |
| 58 | + {instrumentGroups.map(([g, items]) => ( | |
| 59 | + <Section key={g} title={ASSET_CLASS_LABEL[g] ?? g} hint={`${(items as unknown[]).length} match${(items as unknown[]).length === 1 ? "" : "es"}`}> | |
| 60 | + <InstrumentTable rows={items as InstrumentWithQuote[]} compact showExchange /> | |
| 61 | + </Section> | |
| 62 | + ))} | |
| 63 | + </Page> | |
| 64 | + ); | |
| 65 | +} | |
added
apps/web/src/app/sitemap.ts
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import type { MetadataRoute } from "next"; | |
| 2 | +import { API_URL } from "@/lib/api"; | |
| 3 | +import { SITE_URL } from "@/lib/site"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Segmented sitemap: static pages, exchanges, countries, then quoted instruments per asset class | |
| 7 | + * (thin pages without a canonical quote are not listed). | |
| 8 | + */ | |
| 9 | +export async function generateSitemaps() { | |
| 10 | + return [{ id: "static" }, { id: "exchanges" }, { id: "countries" }, { id: "stocks" }, { id: "etfs" }, { id: "indices" }, { id: "crypto" }, { id: "forex" }, { id: "rates" }, { id: "commodities" }]; | |
| 11 | +} | |
| 12 | + | |
| 13 | +const CLASS: Record<string, string[]> = { stocks: ["EQUITY"], etfs: ["ETF", "ETN"], indices: ["INDEX"], crypto: ["CRYPTO"], forex: ["FOREX"], rates: ["TREASURY", "BOND", "INTEREST_RATE"], commodities: ["COMMODITY", "FUTURE"] }; | |
| 14 | + | |
| 15 | +async function get<T>(path: string): Promise<T | null> { | |
| 16 | + try { | |
| 17 | + const r = await fetch(`${API_URL}${path}`, { cache: "no-store" }); | |
| 18 | + if (!r.ok) return null; | |
| 19 | + return ((await r.json()) as { data: T }).data; | |
| 20 | + } catch { | |
| 21 | + return null; | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default async function sitemap({ id: rawId }: { id: string | Promise<string> }): Promise<MetadataRoute.Sitemap> { | |
| 26 | + const id = String(await rawId); // Next 16 passes the segment param as a promise | |
| 27 | + const now = new Date(); | |
| 28 | + if (id === "static") { | |
| 29 | + return ["", "/markets", "/live", "/stocks", "/etfs", "/indices", "/crypto", "/forex", "/rates", "/commodities", "/exchanges", "/countries", "/events", "/halts", "/filings", "/sources", "/connectors", "/data-health", "/status", "/compare", "/methodology", "/developers", "/licensing"].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === "" || p === "/live" ? "always" : "hourly", priority: p === "" ? 1 : 0.7 })); | |
| 30 | + } | |
| 31 | + if (id === "exchanges") { | |
| 32 | + const xs = (await get<Array<{ id: string }>>("/v1/exchanges")) ?? []; | |
| 33 | + return xs.map((x) => ({ url: `${SITE_URL}/exchanges/${x.id}`, lastModified: now, changeFrequency: "hourly", priority: 0.6 })); | |
| 34 | + } | |
| 35 | + if (id === "countries") { | |
| 36 | + const cs = (await get<Array<{ code: string }>>("/v1/countries")) ?? []; | |
| 37 | + return cs.map((c) => ({ url: `${SITE_URL}/countries/${c.code}`, lastModified: now, changeFrequency: "daily", priority: 0.5 })); | |
| 38 | + } | |
| 39 | + const classes = CLASS[id] ?? []; | |
| 40 | + const out: MetadataRoute.Sitemap = []; | |
| 41 | + for (const c of classes) { | |
| 42 | + const rows = (await get<Array<{ id: string }>>(`/v1/instruments?asset_class=${c}"ed=1&limit=500`)) ?? []; | |
| 43 | + out.push(...rows.map((r) => ({ url: `${SITE_URL}/instruments/${encodeURIComponent(r.id)}`, lastModified: now, changeFrequency: "hourly" as const, priority: 0.6 }))); | |
| 44 | + } | |
| 45 | + return out; | |
| 46 | +} | |
added
apps/web/src/app/sources/page.tsx
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { RelativeTime } from "@/components/ui/freshness"; | |
| 4 | +import { Page, PageHeader, Section } from "@/components/ui/section"; | |
| 5 | +import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; | |
| 6 | +import { api } from "@/lib/api"; | |
| 7 | +import { ASSET_CLASS_LABEL } from "@/lib/format"; | |
| 8 | +import type { Source } from "@/lib/types"; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: "Sources", description: "Directory of the exchanges, regulators, central banks and datasets Market Atlas observes, with rights and real-time classification.", alternates: { canonical: "/sources" } }; | |
| 11 | +export const dynamic = "force-dynamic"; | |
| 12 | + | |
| 13 | +const CATEGORY_LABEL: Record<string, string> = { EXCHANGE: "Exchanges", REGULATOR: "Regulators", CENTRAL_BANK: "Central banks", GOVERNMENT: "Government", ISSUER: "Issuer websites", PUBLIC_MARKET_SOURCE: "Public market sources", OFFICIAL_DATASET: "Official & licensed datasets", NEWS: "News", INTERNAL: "Market Atlas internal" }; | |
| 14 | +const RT_LABEL: Record<string, string> = { REALTIME: "real time", DELAYED: "delayed", END_OF_DAY: "end of day", INDICATIVE: "indicative", UNKNOWN: "unknown", STALE: "stale" }; | |
| 15 | + | |
| 16 | +export default async function SourcesPage() { | |
| 17 | + const sources = (await api<Source[]>("/v1/sources")).filter((s) => s.category !== "INTERNAL"); | |
| 18 | + const cats = [...new Set(sources.map((s) => s.category))]; | |
| 19 | + return ( | |
| 20 | + <Page wide> | |
| 21 | + <PageHeader kicker="Provenance" title="Sources" lead={`${sources.length} organizations feed the atlas through ${sources.reduce((a, s) => a + s.connectors, 0)} connectors. Each source declares how its data may be used and how fresh it is; endpoints are not published to discourage abuse.`} actions={<Link href="/connectors" className="text-sm text-accent hover:underline">Connector operations →</Link>} /> | |
| 22 | + {cats.map((cat) => ( | |
| 23 | + <Section key={cat} title={CATEGORY_LABEL[cat] ?? cat}> | |
| 24 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 25 | + <table className="table-dense"> | |
| 26 | + <thead> | |
| 27 | + <tr> | |
| 28 | + <th>Source</th> | |
| 29 | + <th>Status</th> | |
| 30 | + <th>Type</th> | |
| 31 | + <th>Rights</th> | |
| 32 | + <th>Freshness</th> | |
| 33 | + <th className="text-right">Coverage</th> | |
| 34 | + <th className="text-right">Reliability</th> | |
| 35 | + <th>Last observation</th> | |
| 36 | + </tr> | |
| 37 | + </thead> | |
| 38 | + <tbody> | |
| 39 | + {sources | |
| 40 | + .filter((s) => s.category === cat) | |
| 41 | + .map((s) => ( | |
| 42 | + <tr key={s.id} id={s.id}> | |
| 43 | + <td className="max-w-[360px] whitespace-normal"> | |
| 44 | + <div className="font-medium"> | |
| 45 | + {s.homepage ? ( | |
| 46 | + <a href={s.homepage} target="_blank" rel="noopener noreferrer" className="hover:underline"> | |
| 47 | + {s.name} | |
| 48 | + </a> | |
| 49 | + ) : ( | |
| 50 | + s.name | |
| 51 | + )} | |
| 52 | + </div> | |
| 53 | + <div className="text-[11.5px] text-ink-3"> | |
| 54 | + {s.organization} | |
| 55 | + {s.jurisdiction ? ` · ${s.jurisdiction}` : ""} | |
| 56 | + {s.asset_classes.length ? ` · ${s.asset_classes.map((c) => ASSET_CLASS_LABEL[c] ?? c).join(", ")}` : ""} | |
| 57 | + </div> | |
| 58 | + {s.description && <div className="mt-1 text-xs text-ink-2">{s.description}</div>} | |
| 59 | + {s.rights_notes && ( | |
| 60 | + <div className="mt-1 text-[11px] text-ink-3"> | |
| 61 | + {s.rights_notes} | |
| 62 | + {s.terms_url && ( | |
| 63 | + <> | |
| 64 | + {" "} | |
| 65 | + <a href={s.terms_url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"> | |
| 66 | + terms ↗ | |
| 67 | + </a> | |
| 68 | + </> | |
| 69 | + )} | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + </td> | |
| 73 | + <td> | |
| 74 | + <StatusBadge status={s.status} /> | |
| 75 | + </td> | |
| 76 | + <td className="mono text-xs text-ink-2">{s.source_type}</td> | |
| 77 | + <td> | |
| 78 | + <RightsBadge status={s.rights_status} /> | |
| 79 | + </td> | |
| 80 | + <td className="text-xs text-ink-2">{RT_LABEL[s.realtime_status] ?? s.realtime_status}</td> | |
| 81 | + <td className="num">{s.coverage.toLocaleString("en-US")}</td> | |
| 82 | + <td className="num">{s.reliability_score ?? "—"}</td> | |
| 83 | + <td className="text-xs text-ink-3">{s.last_observation ? <RelativeTime value={s.last_observation} /> : "—"}</td> | |
| 84 | + </tr> | |
| 85 | + ))} | |
| 86 | + </tbody> | |
| 87 | + </table> | |
| 88 | + </div> | |
| 89 | + </Section> | |
| 90 | + ))} | |
| 91 | + <p className="mt-6 text-xs text-ink-3"> | |
| 92 | + Coverage = instruments this source has published a value for since the current process started. Reliability = operational score (availability, latency, parse success, stability), see <Link href="/methodology" className="text-accent hover:underline">methodology</Link>. Rights statuses are explained on the <Link href="/licensing" className="text-accent hover:underline">data rights</Link> page. | |
| 93 | + </p> | |
| 94 | + </Page> | |
| 95 | + ); | |
| 96 | +} | |
added
apps/web/src/app/status/page.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | |
| 4 | +import { StatusBadge } from "@/components/ui/status-badge"; | |
| 5 | +import { api } from "@/lib/api"; | |
| 6 | +import type { StatusReport } from "@/lib/types"; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: "Status", description: "Operational status of Market Atlas components.", alternates: { canonical: "/status" } }; | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | + | |
| 11 | +export default async function StatusPage() { | |
| 12 | + const s = await api<StatusReport>("/v1/status"); | |
| 13 | + const worst = s.components.some((c) => c.status === "outage") ? "outage" : s.components.some((c) => c.status === "degraded") ? "degraded" : "operational"; | |
| 14 | + return ( | |
| 15 | + <Page> | |
| 16 | + <PageHeader kicker="Status" title={worst === "operational" ? "All systems operational" : worst === "degraded" ? "Partial degradation" : "Service disruption"} lead={`Market Atlas v${s.version} · ${s.connectors.healthy} of ${s.connectors.total} connectors healthy. Source-group status reflects whether at least one connector of the group is delivering.`} actions={<StatusBadge status={worst} />} /> | |
| 17 | + <ul className="divide-y divide-rule rounded-md border border-rule bg-surface"> | |
| 18 | + {s.components.map((c) => ( | |
| 19 | + <li key={c.id} className="flex items-center justify-between px-4 py-3 text-sm"> | |
| 20 | + <span>{c.name}</span> | |
| 21 | + <StatusBadge status={c.status} /> | |
| 22 | + </li> | |
| 23 | + ))} | |
| 24 | + </ul> | |
| 25 | + <p className="mt-4 text-xs text-ink-3"> | |
| 26 | + Detailed connector metrics and incidents: <Link href="/data-health" className="text-accent hover:underline">data health</Link>. This page intentionally omits infrastructure details. | |
| 27 | + </p> | |
| 28 | + </Page> | |
| 29 | + ); | |
| 30 | +} | |
added
apps/web/src/app/stocks/[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/stocks/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: "Stocks", description: "US-listed equities with multi-source canonical quotes, confidence and freshness.", alternates: { canonical: "/stocks" } }; | |
| 5 | +export const dynamic = "force-dynamic"; | |
| 6 | + | |
| 7 | +export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | |
| 8 | + return <ClassPage classes={["EQUITY"]} kicker="Equities" title="Stocks" lead="Listed equities observed by Market Atlas. US quotes come from Cboe delayed data (15 minutes) with end-of-day reference closes from HF Market Data; every row shows its data status." basePath="/stocks" searchParams={searchParams} note="Delayed and end-of-day values are labelled as such. Consensus confidence reflects agreement between independent sources, not a view on the security." />; | |
| 9 | +} | |
added
apps/web/src/components/admin/admin-console.tsx
+583 −0
@@ -0,0 +1,583 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useState } from "react"; | |
| 4 | +import { Page, PageHeader, Stat } from "@/components/ui/section"; | |
| 5 | +import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; | |
| 6 | +import { RelativeTime } from "@/components/ui/freshness"; | |
| 7 | +import { clientApi } from "@/lib/client-api"; | |
| 8 | +import { cx, formatCompact, formatDateTime, formatDuration } from "@/lib/format"; | |
| 9 | + | |
| 10 | +const TOKEN_KEY = "ma-admin-token"; | |
| 11 | +type Tab = "overview" | "connectors" | "schema" | "divergence" | "storage" | "discovery"; | |
| 12 | + | |
| 13 | +interface Overview { | |
| 14 | + version: string; | |
| 15 | + role: string; | |
| 16 | + uptime_s: number; | |
| 17 | + memory: { rss: number; heapUsed: number }; | |
| 18 | + observations_total: number; | |
| 19 | + events_total: number; | |
| 20 | + queue_depth: number; | |
| 21 | + rates: { observations_per_sec: number; events_per_min: number; http_per_min: number }; | |
| 22 | + tables: Array<{ relname: string; bytes: number }>; | |
| 23 | + rate_limiter: Array<{ host: string; ratePerSec: number; pending: number }>; | |
| 24 | +} | |
| 25 | +interface ConnectorRow { | |
| 26 | + metadata: Record<string, any>; | |
| 27 | + health: Record<string, any>; | |
| 28 | + paused: boolean; | |
| 29 | + running: boolean; | |
| 30 | + symbols: string[]; | |
| 31 | + schedule: Record<string, unknown> | null; | |
| 32 | + consecutive_failures?: number; | |
| 33 | + drift_strikes?: number; | |
| 34 | + sockets: Array<{ url: string; open: boolean; reconnects: number }>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Token-gated admin console (token kept in localStorage, sent as x-ma-admin-token; never rendered). */ | |
| 38 | +export function AdminConsole() { | |
| 39 | + const [token, setToken] = useState<string>(""); | |
| 40 | + const [input, setInput] = useState(""); | |
| 41 | + const [tab, setTab] = useState<Tab>("overview"); | |
| 42 | + const [error, setError] = useState<string | null>(null); | |
| 43 | + useEffect(() => { | |
| 44 | + try { | |
| 45 | + setToken(localStorage.getItem(TOKEN_KEY) ?? ""); | |
| 46 | + } catch { | |
| 47 | + /* ignore */ | |
| 48 | + } | |
| 49 | + }, []); | |
| 50 | + const call = useCallback( | |
| 51 | + async <T,>(path: string, init?: RequestInit) => { | |
| 52 | + try { | |
| 53 | + setError(null); | |
| 54 | + return await clientApi<T>(path, { ...init, adminToken: token }); | |
| 55 | + } catch (e) { | |
| 56 | + const msg = e instanceof Error ? e.message : "request failed"; | |
| 57 | + setError(msg); | |
| 58 | + if (/token|unauthor/i.test(msg)) { | |
| 59 | + localStorage.removeItem(TOKEN_KEY); | |
| 60 | + setToken(""); | |
| 61 | + } | |
| 62 | + throw e; | |
| 63 | + } | |
| 64 | + }, | |
| 65 | + [token], | |
| 66 | + ); | |
| 67 | + if (!token) { | |
| 68 | + return ( | |
| 69 | + <Page> | |
| 70 | + <PageHeader kicker="Admin" title="Market Atlas console" lead="Enter the admin token configured on the API (MA_ADMIN_TOKEN). It is stored in this browser only." /> | |
| 71 | + <form | |
| 72 | + className="flex max-w-md gap-2" | |
| 73 | + onSubmit={(e) => { | |
| 74 | + e.preventDefault(); | |
| 75 | + if (!input.trim()) return; | |
| 76 | + localStorage.setItem(TOKEN_KEY, input.trim()); | |
| 77 | + setToken(input.trim()); | |
| 78 | + }} | |
| 79 | + > | |
| 80 | + <input type="password" value={input} onChange={(e) => setInput(e.target.value)} placeholder="admin token" className="mono h-11 flex-1 rounded-md border border-rule bg-surface px-3 text-sm outline-none focus:border-rule-strong" autoComplete="off" /> | |
| 81 | + <button type="submit" className="h-11 rounded-md bg-ink px-4 text-sm font-medium text-canvas"> | |
| 82 | + Unlock | |
| 83 | + </button> | |
| 84 | + </form> | |
| 85 | + {error && <p className="mt-3 text-sm text-negative">{error}</p>} | |
| 86 | + </Page> | |
| 87 | + ); | |
| 88 | + } | |
| 89 | + return ( | |
| 90 | + <Page wide> | |
| 91 | + <PageHeader | |
| 92 | + kicker="Admin" | |
| 93 | + title="Console" | |
| 94 | + actions={ | |
| 95 | + <button | |
| 96 | + type="button" | |
| 97 | + onClick={() => { | |
| 98 | + localStorage.removeItem(TOKEN_KEY); | |
| 99 | + setToken(""); | |
| 100 | + }} | |
| 101 | + className="h-9 rounded-md border border-rule px-3 text-xs text-ink-2 hover:text-ink" | |
| 102 | + > | |
| 103 | + Lock | |
| 104 | + </button> | |
| 105 | + } | |
| 106 | + /> | |
| 107 | + <nav className="mb-4 flex flex-wrap gap-1 border-b border-rule"> | |
| 108 | + {(["overview", "connectors", "schema", "divergence", "storage", "discovery"] as Tab[]).map((t) => ( | |
| 109 | + <button key={t} type="button" onClick={() => setTab(t)} className={cx("h-10 px-3 text-sm capitalize", tab === t ? "border-b-2 border-ink font-medium text-ink" : "text-ink-2 hover:text-ink")}> | |
| 110 | + {t === "schema" ? "Schema changes" : t} | |
| 111 | + </button> | |
| 112 | + ))} | |
| 113 | + </nav> | |
| 114 | + {error && <p className="mb-3 rounded-md border border-negative/40 bg-negative-soft px-3 py-2 text-sm text-negative">{error}</p>} | |
| 115 | + {tab === "overview" && <OverviewTab call={call} />} | |
| 116 | + {tab === "connectors" && <ConnectorsTab call={call} />} | |
| 117 | + {tab === "schema" && <SchemaTab call={call} />} | |
| 118 | + {tab === "divergence" && <DivergenceTab call={call} />} | |
| 119 | + {tab === "storage" && <StorageTab call={call} />} | |
| 120 | + {tab === "discovery" && <DiscoveryTab call={call} />} | |
| 121 | + </Page> | |
| 122 | + ); | |
| 123 | +} | |
| 124 | + | |
| 125 | +type Call = <T>(path: string, init?: RequestInit) => Promise<T>; | |
| 126 | + | |
| 127 | +function OverviewTab({ call }: { call: Call }) { | |
| 128 | + const [o, setO] = useState<Overview | null>(null); | |
| 129 | + useEffect(() => { | |
| 130 | + const load = () => call<Overview>("/v1/admin/overview").then(setO).catch(() => {}); | |
| 131 | + load(); | |
| 132 | + const t = setInterval(load, 5000); | |
| 133 | + return () => clearInterval(t); | |
| 134 | + }, [call]); | |
| 135 | + if (!o) return <p className="text-sm text-ink-3">Loading…</p>; | |
| 136 | + return ( | |
| 137 | + <div className="space-y-6"> | |
| 138 | + <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"> | |
| 139 | + <Stat label="Version / role" value={`v${o.version}`} sub={o.role} /> | |
| 140 | + <Stat label="Uptime" value={formatDuration(o.uptime_s * 1000)} /> | |
| 141 | + <Stat label="RSS" value={formatCompact(o.memory.rss)} sub={`heap ${formatCompact(o.memory.heapUsed)}`} /> | |
| 142 | + <Stat label="Observations" value={formatCompact(o.observations_total, 2)} sub={`${o.rates.observations_per_sec.toFixed(1)} / s`} /> | |
| 143 | + <Stat label="Events" value={formatCompact(o.events_total)} sub={`${o.rates.events_per_min.toFixed(1)} / min`} /> | |
| 144 | + <Stat label="Queue depth" value={o.queue_depth} sub={`HTTP ${o.rates.http_per_min.toFixed(0)} / min`} tone={o.queue_depth > 50_000 ? "warn" : undefined} /> | |
| 145 | + </div> | |
| 146 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-2"> | |
| 147 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 148 | + <table className="table-dense"> | |
| 149 | + <thead> | |
| 150 | + <tr> | |
| 151 | + <th>Table</th> | |
| 152 | + <th className="text-right">Size</th> | |
| 153 | + </tr> | |
| 154 | + </thead> | |
| 155 | + <tbody> | |
| 156 | + {o.tables.map((t) => ( | |
| 157 | + <tr key={t.relname}> | |
| 158 | + <td className="mono">{t.relname}</td> | |
| 159 | + <td className="num">{formatCompact(t.bytes)}B</td> | |
| 160 | + </tr> | |
| 161 | + ))} | |
| 162 | + </tbody> | |
| 163 | + </table> | |
| 164 | + </div> | |
| 165 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 166 | + <table className="table-dense"> | |
| 167 | + <thead> | |
| 168 | + <tr> | |
| 169 | + <th>Rate-limited host</th> | |
| 170 | + <th className="text-right">req / s</th> | |
| 171 | + <th className="text-right">Pending</th> | |
| 172 | + </tr> | |
| 173 | + </thead> | |
| 174 | + <tbody> | |
| 175 | + {o.rate_limiter.map((r) => ( | |
| 176 | + <tr key={r.host}> | |
| 177 | + <td className="mono">{r.host}</td> | |
| 178 | + <td className="num">{r.ratePerSec}</td> | |
| 179 | + <td className="num">{r.pending}</td> | |
| 180 | + </tr> | |
| 181 | + ))} | |
| 182 | + </tbody> | |
| 183 | + </table> | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + </div> | |
| 187 | + ); | |
| 188 | +} | |
| 189 | + | |
| 190 | +function ConnectorsTab({ call }: { call: Call }) { | |
| 191 | + const [rows, setRows] = useState<ConnectorRow[]>([]); | |
| 192 | + const [sel, setSel] = useState<string | null>(null); | |
| 193 | + const [detail, setDetail] = useState<Record<string, any> | null>(null); | |
| 194 | + const [busy, setBusy] = useState<string | null>(null); | |
| 195 | + const [testResult, setTestResult] = useState<string | null>(null); | |
| 196 | + const load = useCallback(() => call<ConnectorRow[]>("/v1/admin/connectors").then(setRows).catch(() => {}), [call]); | |
| 197 | + useEffect(() => { | |
| 198 | + load(); | |
| 199 | + const t = setInterval(load, 5000); | |
| 200 | + return () => clearInterval(t); | |
| 201 | + }, [load]); | |
| 202 | + useEffect(() => { | |
| 203 | + if (!sel) return; | |
| 204 | + call<Record<string, any>>(`/v1/admin/connectors/${sel}`).then(setDetail).catch(() => {}); | |
| 205 | + }, [sel, call]); | |
| 206 | + const action = async (id: string, a: string) => { | |
| 207 | + setBusy(`${id}:${a}`); | |
| 208 | + setTestResult(null); | |
| 209 | + try { | |
| 210 | + const r = await call<Record<string, unknown>>(`/v1/admin/connectors/${id}/${a}`, { method: "POST", body: "{}" }); | |
| 211 | + if (a === "test") setTestResult(JSON.stringify(r)); | |
| 212 | + await load(); | |
| 213 | + } finally { | |
| 214 | + setBusy(null); | |
| 215 | + } | |
| 216 | + }; | |
| 217 | + return ( | |
| 218 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 xl:grid-cols-[1fr_420px]"> | |
| 219 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 220 | + <table className="table-dense"> | |
| 221 | + <thead> | |
| 222 | + <tr> | |
| 223 | + <th>Connector</th> | |
| 224 | + <th>State</th> | |
| 225 | + <th className="text-right">Msg/min</th> | |
| 226 | + <th className="text-right">Errors</th> | |
| 227 | + <th className="text-right">Score</th> | |
| 228 | + <th>Last message</th> | |
| 229 | + <th>Actions</th> | |
| 230 | + </tr> | |
| 231 | + </thead> | |
| 232 | + <tbody> | |
| 233 | + {rows.map((r) => { | |
| 234 | + const id = r.metadata.id as string; | |
| 235 | + return ( | |
| 236 | + <tr key={id} className={cx(sel === id && "bg-surface-2")}> | |
| 237 | + <td> | |
| 238 | + <button type="button" onClick={() => setSel(id)} className="text-left hover:underline"> | |
| 239 | + <span className="mono font-medium">{id}</span> | |
| 240 | + <span className="block text-[11px] text-ink-3"> | |
| 241 | + {r.metadata.sourceType} · <RightsBadge status={r.metadata.rightsStatus} /> | |
| 242 | + </span> | |
| 243 | + </button> | |
| 244 | + </td> | |
| 245 | + <td> | |
| 246 | + <StatusBadge status={r.health.state} /> | |
| 247 | + {r.paused && <span className="ml-1 text-[10px] text-ink-3">paused</span>} | |
| 248 | + </td> | |
| 249 | + <td className="num">{r.health.messages1m}</td> | |
| 250 | + <td className="num">{r.health.errorsTotal}</td> | |
| 251 | + <td className="num">{r.health.reliabilityScore ?? "—"}</td> | |
| 252 | + <td className="text-xs text-ink-3">{r.health.lastMessageAt ? <RelativeTime value={r.health.lastMessageAt} /> : "—"}</td> | |
| 253 | + <td> | |
| 254 | + <div className="flex gap-1"> | |
| 255 | + {(r.paused ? ["resume"] : ["pause", "restart"]).concat(["test"]).map((a) => ( | |
| 256 | + <button key={a} type="button" disabled={busy != null} onClick={() => action(id, a)} className="h-7 rounded border border-rule px-2 text-[11px] text-ink-2 hover:text-ink disabled:opacity-50"> | |
| 257 | + {busy === `${id}:${a}` ? "…" : a} | |
| 258 | + </button> | |
| 259 | + ))} | |
| 260 | + </div> | |
| 261 | + </td> | |
| 262 | + </tr> | |
| 263 | + ); | |
| 264 | + })} | |
| 265 | + </tbody> | |
| 266 | + </table> | |
| 267 | + {testResult && <pre className="mono m-3 overflow-x-auto rounded bg-surface-2 p-2 text-xs">{testResult}</pre>} | |
| 268 | + </div> | |
| 269 | + <div className="rounded-md border border-rule bg-surface p-4 text-sm"> | |
| 270 | + {!sel && <p className="text-ink-3">Select a connector to inspect health history, schema fingerprints, recent observations and persisted state.</p>} | |
| 271 | + {sel && detail && ( | |
| 272 | + <div className="space-y-4"> | |
| 273 | + <div> | |
| 274 | + <div className="mono text-xs text-ink-3">{sel}</div> | |
| 275 | + <div className="font-medium">{detail.metadata?.name}</div> | |
| 276 | + <div className="mt-1 text-xs text-ink-2">{detail.metadata?.description}</div> | |
| 277 | + </div> | |
| 278 | + <div className="grid grid-cols-2 gap-x-4 text-xs"> | |
| 279 | + {[ | |
| 280 | + ["State", detail.health?.state], | |
| 281 | + ["Messages", detail.health?.messagesTotal], | |
| 282 | + ["Errors", detail.health?.errorsTotal], | |
| 283 | + ["Reconnects", detail.health?.reconnects], | |
| 284 | + ["p50 latency", detail.health?.medianLatencyMs != null ? formatDuration(detail.health.medianLatencyMs) : "—"], | |
| 285 | + ["Instruments", detail.health?.instrumentsCovered], | |
| 286 | + ["Parse success", detail.health?.parseSuccessRate != null ? `${Math.round(detail.health.parseSuccessRate * 100)}%` : "—"], | |
| 287 | + ["Last error", detail.health?.lastError ?? "—"], | |
| 288 | + ].map(([k, v]) => ( | |
| 289 | + <div key={String(k)} className="flex justify-between gap-2 border-b border-rule py-1"> | |
| 290 | + <span className="text-ink-3">{String(k)}</span> | |
| 291 | + <span className="mono truncate">{String(v ?? "—")}</span> | |
| 292 | + </div> | |
| 293 | + ))} | |
| 294 | + </div> | |
| 295 | + <div> | |
| 296 | + <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Schema fingerprints</div> | |
| 297 | + <pre className="mono overflow-x-auto rounded bg-surface-2 p-2 text-[11px]">{JSON.stringify(detail.fingerprints ?? {}, null, 1)}</pre> | |
| 298 | + </div> | |
| 299 | + <div> | |
| 300 | + <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Schema changes</div> | |
| 301 | + {(detail.schema_changes ?? []).length === 0 ? ( | |
| 302 | + <p className="text-xs text-ink-3">None recorded.</p> | |
| 303 | + ) : ( | |
| 304 | + <ul className="space-y-1 text-xs"> | |
| 305 | + {(detail.schema_changes as Array<Record<string, any>>).map((c) => ( | |
| 306 | + <li key={c.id} className="flex items-center justify-between gap-2"> | |
| 307 | + <span className="mono"> | |
| 308 | + {c.kind}: {c.old_fingerprint ?? "∅"} → {c.new_fingerprint} | |
| 309 | + </span> | |
| 310 | + {c.acknowledged ? ( | |
| 311 | + <span className="text-ink-3">ack</span> | |
| 312 | + ) : ( | |
| 313 | + <button type="button" onClick={() => call(`/v1/admin/connectors/${sel}/schema-changes/${c.id}/ack`, { method: "POST", body: "{}" }).then(() => call<Record<string, any>>(`/v1/admin/connectors/${sel}`).then(setDetail))} className="rounded border border-rule px-1.5 text-[10px]"> | |
| 314 | + acknowledge | |
| 315 | + </button> | |
| 316 | + )} | |
| 317 | + </li> | |
| 318 | + ))} | |
| 319 | + </ul> | |
| 320 | + )} | |
| 321 | + </div> | |
| 322 | + <div> | |
| 323 | + <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Recent observations</div> | |
| 324 | + <div className="max-h-48 overflow-auto"> | |
| 325 | + <table className="table-dense"> | |
| 326 | + <tbody> | |
| 327 | + {(detail.recent_observations as Array<Record<string, any>> | undefined)?.map((o, i) => ( | |
| 328 | + <tr key={i}> | |
| 329 | + <td className="mono text-[11px] text-ink-3">{formatDateTime(o.received_at, { seconds: true })}</td> | |
| 330 | + <td className="mono text-[11px]">{o.instrument_id}</td> | |
| 331 | + <td className="mono text-[11px]">{o.field}</td> | |
| 332 | + <td className="num text-[11px]">{o.value}</td> | |
| 333 | + </tr> | |
| 334 | + ))} | |
| 335 | + </tbody> | |
| 336 | + </table> | |
| 337 | + </div> | |
| 338 | + </div> | |
| 339 | + <div> | |
| 340 | + <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Persisted state (redacted)</div> | |
| 341 | + <pre className="mono max-h-40 overflow-auto rounded bg-surface-2 p-2 text-[11px]">{JSON.stringify(detail.state ?? {}, null, 1)}</pre> | |
| 342 | + </div> | |
| 343 | + </div> | |
| 344 | + )} | |
| 345 | + </div> | |
| 346 | + </div> | |
| 347 | + ); | |
| 348 | +} | |
| 349 | + | |
| 350 | +function SchemaTab({ call }: { call: Call }) { | |
| 351 | + const [rows, setRows] = useState<Array<Record<string, any>>>([]); | |
| 352 | + useEffect(() => { | |
| 353 | + call<Array<Record<string, any>>>("/v1/admin/schema-changes").then(setRows).catch(() => {}); | |
| 354 | + }, [call]); | |
| 355 | + return ( | |
| 356 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 357 | + <table className="table-dense"> | |
| 358 | + <thead> | |
| 359 | + <tr> | |
| 360 | + <th>Detected</th> | |
| 361 | + <th>Connector</th> | |
| 362 | + <th>Kind</th> | |
| 363 | + <th>Old</th> | |
| 364 | + <th>New</th> | |
| 365 | + <th /> | |
| 366 | + </tr> | |
| 367 | + </thead> | |
| 368 | + <tbody> | |
| 369 | + {rows.length === 0 && ( | |
| 370 | + <tr> | |
| 371 | + <td colSpan={6} className="py-6 text-center text-ink-3"> | |
| 372 | + No unacknowledged schema changes. | |
| 373 | + </td> | |
| 374 | + </tr> | |
| 375 | + )} | |
| 376 | + {rows.map((r) => ( | |
| 377 | + <tr key={r.id}> | |
| 378 | + <td className="mono text-xs">{formatDateTime(r.detected_at, { seconds: true })}</td> | |
| 379 | + <td className="mono">{r.connector_id}</td> | |
| 380 | + <td>{r.kind}</td> | |
| 381 | + <td className="mono text-xs text-ink-3">{r.old_fingerprint ?? "∅"}</td> | |
| 382 | + <td className="mono text-xs">{r.new_fingerprint}</td> | |
| 383 | + <td> | |
| 384 | + <button type="button" onClick={() => call(`/v1/admin/connectors/${r.connector_id}/schema-changes/${r.id}/ack`, { method: "POST", body: "{}" }).then(() => setRows((x) => x.filter((y) => y.id !== r.id)))} className="h-7 rounded border border-rule px-2 text-[11px]"> | |
| 385 | + acknowledge | |
| 386 | + </button> | |
| 387 | + </td> | |
| 388 | + </tr> | |
| 389 | + ))} | |
| 390 | + </tbody> | |
| 391 | + </table> | |
| 392 | + </div> | |
| 393 | + ); | |
| 394 | +} | |
| 395 | + | |
| 396 | +function DivergenceTab({ call }: { call: Call }) { | |
| 397 | + const [rows, setRows] = useState<Array<Record<string, any>>>([]); | |
| 398 | + useEffect(() => { | |
| 399 | + call<Array<Record<string, any>>>("/v1/admin/divergence").then(setRows).catch(() => {}); | |
| 400 | + }, [call]); | |
| 401 | + return ( | |
| 402 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 403 | + <table className="table-dense"> | |
| 404 | + <thead> | |
| 405 | + <tr> | |
| 406 | + <th>Time</th> | |
| 407 | + <th>Instruments</th> | |
| 408 | + <th>Title</th> | |
| 409 | + <th className="text-right">Dispersion</th> | |
| 410 | + <th>Sources</th> | |
| 411 | + </tr> | |
| 412 | + </thead> | |
| 413 | + <tbody> | |
| 414 | + {rows.length === 0 && ( | |
| 415 | + <tr> | |
| 416 | + <td colSpan={5} className="py-6 text-center text-ink-3"> | |
| 417 | + No divergence events recorded. | |
| 418 | + </td> | |
| 419 | + </tr> | |
| 420 | + )} | |
| 421 | + {rows.map((r) => ( | |
| 422 | + <tr key={r.id}> | |
| 423 | + <td className="mono text-xs">{formatDateTime(r.ts, { seconds: true })}</td> | |
| 424 | + <td className="mono text-xs">{(r.instrument_ids as string[]).join(", ")}</td> | |
| 425 | + <td>{r.title}</td> | |
| 426 | + <td className="num">{r.data?.dispersionBps?.toFixed?.(0) ?? "—"} bps</td> | |
| 427 | + <td className="mono text-[11px] text-ink-3">{(r.data?.contributions as Array<{ source: string; value: number }> | undefined)?.map((c) => `${c.source}=${c.value}`).join(" · ")}</td> | |
| 428 | + </tr> | |
| 429 | + ))} | |
| 430 | + </tbody> | |
| 431 | + </table> | |
| 432 | + </div> | |
| 433 | + ); | |
| 434 | +} | |
| 435 | + | |
| 436 | +function StorageTab({ call }: { call: Call }) { | |
| 437 | + const [s, setS] = useState<Record<string, any> | null>(null); | |
| 438 | + useEffect(() => { | |
| 439 | + call<Record<string, any>>("/v1/admin/storage").then(setS).catch(() => {}); | |
| 440 | + }, [call]); | |
| 441 | + if (!s) return <p className="text-sm text-ink-3">Loading…</p>; | |
| 442 | + return ( | |
| 443 | + <div className="space-y-4"> | |
| 444 | + <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4"> | |
| 445 | + <Stat label="Database" value={`${formatCompact(s.database_bytes)}B`} /> | |
| 446 | + <Stat label="Retention" value={`${s.retention_days} d`} sub="observation partitions" /> | |
| 447 | + <Stat label="Partitions" value={(s.partitions as unknown[]).length} /> | |
| 448 | + <Stat label="Archives" value={(s.archives as unknown[]).length} sub={s.data_dir} /> | |
| 449 | + </div> | |
| 450 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-2"> | |
| 451 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 452 | + <table className="table-dense"> | |
| 453 | + <thead> | |
| 454 | + <tr> | |
| 455 | + <th>Partition</th> | |
| 456 | + <th className="text-right">Size</th> | |
| 457 | + <th className="text-right">≈ rows</th> | |
| 458 | + </tr> | |
| 459 | + </thead> | |
| 460 | + <tbody> | |
| 461 | + {(s.partitions as Array<Record<string, any>>).map((p) => ( | |
| 462 | + <tr key={p.relname}> | |
| 463 | + <td className="mono">{p.relname}</td> | |
| 464 | + <td className="num">{formatCompact(p.bytes)}B</td> | |
| 465 | + <td className="num">{formatCompact(Math.max(0, Number(p.est_rows)))}</td> | |
| 466 | + </tr> | |
| 467 | + ))} | |
| 468 | + </tbody> | |
| 469 | + </table> | |
| 470 | + </div> | |
| 471 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 472 | + <table className="table-dense"> | |
| 473 | + <thead> | |
| 474 | + <tr> | |
| 475 | + <th>Archived day</th> | |
| 476 | + <th className="text-right">Rows</th> | |
| 477 | + <th className="text-right">Bytes</th> | |
| 478 | + </tr> | |
| 479 | + </thead> | |
| 480 | + <tbody> | |
| 481 | + {(s.archives as Array<Record<string, any>>).length === 0 && ( | |
| 482 | + <tr> | |
| 483 | + <td colSpan={3} className="py-6 text-center text-ink-3"> | |
| 484 | + Nothing archived yet. | |
| 485 | + </td> | |
| 486 | + </tr> | |
| 487 | + )} | |
| 488 | + {(s.archives as Array<Record<string, any>>).map((a) => ( | |
| 489 | + <tr key={a.partition_name}> | |
| 490 | + <td className="mono">{String(a.day).slice(0, 10)}</td> | |
| 491 | + <td className="num">{formatCompact(a.rows_archived)}</td> | |
| 492 | + <td className="num">{formatCompact(a.bytes)}B</td> | |
| 493 | + </tr> | |
| 494 | + ))} | |
| 495 | + </tbody> | |
| 496 | + </table> | |
| 497 | + </div> | |
| 498 | + </div> | |
| 499 | + </div> | |
| 500 | + ); | |
| 501 | +} | |
| 502 | + | |
| 503 | +function DiscoveryTab({ call }: { call: Call }) { | |
| 504 | + const [url, setUrl] = useState(""); | |
| 505 | + const [report, setReport] = useState<Record<string, any> | null>(null); | |
| 506 | + const [busy, setBusy] = useState(false); | |
| 507 | + return ( | |
| 508 | + <div className="space-y-4"> | |
| 509 | + <form | |
| 510 | + className="flex gap-2" | |
| 511 | + onSubmit={async (e) => { | |
| 512 | + e.preventDefault(); | |
| 513 | + setBusy(true); | |
| 514 | + setReport(null); | |
| 515 | + try { | |
| 516 | + setReport(await call<Record<string, any>>("/v1/admin/discovery", { method: "POST", body: JSON.stringify({ url }) })); | |
| 517 | + } catch { | |
| 518 | + /* error shown above */ | |
| 519 | + } finally { | |
| 520 | + setBusy(false); | |
| 521 | + } | |
| 522 | + }} | |
| 523 | + > | |
| 524 | + <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/markets/AAPL" className="mono h-11 flex-1 rounded-md border border-rule bg-surface px-3 text-sm outline-none focus:border-rule-strong" /> | |
| 525 | + <button type="submit" disabled={busy || !url} className="h-11 rounded-md bg-ink px-4 text-sm font-medium text-canvas disabled:opacity-50"> | |
| 526 | + {busy ? "Probing…" : "Discover"} | |
| 527 | + </button> | |
| 528 | + </form> | |
| 529 | + <p className="text-xs text-ink-3">Network-level prototype: fetches the public page unauthenticated (SSRF-guarded), inventories referenced JSON/WebSocket/SSE endpoints and embedded state, and scores candidate quote sources. Output is a candidate report for rights review — nothing is activated.</p> | |
| 530 | + {report && ( | |
| 531 | + <div className="space-y-3 rounded-md border border-rule bg-surface p-4 text-sm"> | |
| 532 | + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 533 | + <Stat label="HTTP" value={report.status} sub={report.content_type} /> | |
| 534 | + <Stat label="Bytes" value={formatCompact(report.bytes)} /> | |
| 535 | + <Stat label="WebSocket URLs" value={report.websocket_urls?.length ?? 0} /> | |
| 536 | + <Stat label="JSON candidates" value={report.json_endpoints?.length ?? 0} /> | |
| 537 | + </div> | |
| 538 | + {report.websocket_urls?.length > 0 && ( | |
| 539 | + <div> | |
| 540 | + <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">WebSocket endpoints</div> | |
| 541 | + <ul className="mono text-xs">{(report.websocket_urls as string[]).map((u) => <li key={u}>{u}</li>)}</ul> | |
| 542 | + </div> | |
| 543 | + )} | |
| 544 | + {report.json_endpoints?.length > 0 && ( | |
| 545 | + <div className="overflow-x-auto"> | |
| 546 | + <table className="table-dense"> | |
| 547 | + <thead> | |
| 548 | + <tr> | |
| 549 | + <th>Candidate endpoint</th> | |
| 550 | + <th className="text-right">Score</th> | |
| 551 | + <th>Hints</th> | |
| 552 | + </tr> | |
| 553 | + </thead> | |
| 554 | + <tbody> | |
| 555 | + {(report.json_endpoints as Array<Record<string, any>>).map((e) => ( | |
| 556 | + <tr key={e.url}> | |
| 557 | + <td className="mono max-w-[520px] truncate text-xs">{e.url}</td> | |
| 558 | + <td className="num">{e.score}</td> | |
| 559 | + <td className="text-xs text-ink-2">{(e.hints as string[]).join(", ")}</td> | |
| 560 | + </tr> | |
| 561 | + ))} | |
| 562 | + </tbody> | |
| 563 | + </table> | |
| 564 | + </div> | |
| 565 | + )} | |
| 566 | + {report.embedded_state?.length > 0 && ( | |
| 567 | + <div> | |
| 568 | + <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Embedded state</div> | |
| 569 | + {(report.embedded_state as Array<Record<string, any>>).map((s) => ( | |
| 570 | + <div key={s.kind} className="mt-1 text-xs"> | |
| 571 | + <span className="mono font-medium">{s.kind}</span> · fingerprint <span className="mono">{s.fingerprint}</span> | |
| 572 | + {s.price_like_fields?.length > 0 && <div className="text-ink-2">price-like: {s.price_like_fields.join(", ")}</div>} | |
| 573 | + {s.symbol_like_fields?.length > 0 && <div className="text-ink-2">symbol-like: {s.symbol_like_fields.join(", ")}</div>} | |
| 574 | + </div> | |
| 575 | + ))} | |
| 576 | + </div> | |
| 577 | + )} | |
| 578 | + <ul className="list-disc pl-5 text-xs text-ink-2">{(report.notes as string[]).map((n) => <li key={n}>{n}</li>)}</ul> | |
| 579 | + </div> | |
| 580 | + )} | |
| 581 | + </div> | |
| 582 | + ); | |
| 583 | +} | |
modified
apps/web/src/components/layout/site-footer.tsx
+1 −1
@@ -11,7 +11,7 @@ const COLS: Array<{ title: string; links: Array<[string, string]> }> = [ | ||
| 11 | 11 | export function SiteFooter() { |
| 12 | 12 | return ( |
| 13 | 13 | <footer className="rule-t mt-12 bg-surface"> |
| 14 | − <div className="mx-auto grid max-w-[1440px] gap-8 px-4 py-10 sm:px-5 md:grid-cols-[1.4fr_repeat(3,1fr)]"> | |
| 14 | + <div className="mx-auto grid grid-cols-1 [&>*]:min-w-0 max-w-[1440px] gap-8 px-4 py-10 sm:px-5 md:grid-cols-[1.4fr_repeat(3,1fr)]"> | |
| 15 | 15 | <div> |
| 16 | 16 | <Wordmark /> |
| 17 | 17 | <p className="mt-3 max-w-sm text-xs leading-relaxed text-ink-3">{DISCLAIMER}</p> |
modified
apps/web/src/components/layout/site-header.tsx
+1 −2
@@ -38,8 +38,7 @@ export function SiteHeader() { | ||
| 38 | 38 | </nav> |
| 39 | 39 | <div className="ml-auto flex items-center gap-1.5"> |
| 40 | 40 | <StreamIndicator className="hidden md:flex" /> |
| 41 | − <SearchButton className="hidden sm:inline-flex" /> | |
| 42 | − <SearchButton className="sm:hidden" compact /> | |
| 41 | + <SearchButton /> | |
| 43 | 42 | <ThemeToggle /> |
| 44 | 43 | </div> |
| 45 | 44 | </div> |
added
apps/web/src/components/market/changes-panel.tsx
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { ChangeCell } from "@/components/ui/price"; | |
| 3 | +import { cx, EVENT_TYPE_LABEL, formatInt, instrumentHref } from "@/lib/format"; | |
| 4 | +import type { Changes } from "@/lib/types"; | |
| 5 | + | |
| 6 | +/** "What changed" across markets in a window — counts derived from Market Atlas' own bars/events. */ | |
| 7 | +export function ChangesPanel({ changes, className }: { changes: Changes; className?: string }) { | |
| 8 | + return ( | |
| 9 | + <div className={cx("rounded-md border border-rule bg-surface p-4", className)}> | |
| 10 | + <div className="flex items-baseline justify-between"> | |
| 11 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">What changed · last {changes.window}</h3> | |
| 12 | + <span className="text-[11px] text-ink-3">derived</span> | |
| 13 | + </div> | |
| 14 | + <div className="mt-2 grid grid-cols-3 gap-3"> | |
| 15 | + <Num n={changes.instruments_moved_over_1pct} label="moved >1%" sub={`of ${formatInt(changes.instruments_tracked)} tracked`} /> | |
| 16 | + <Num n={changes.events} label="events" /> | |
| 17 | + <Num n={changes.filings} label="filings" /> | |
| 18 | + </div> | |
| 19 | + {changes.events_by_type.length > 0 && ( | |
| 20 | + <div className="mt-3 flex flex-wrap gap-1.5"> | |
| 21 | + {changes.events_by_type.slice(0, 6).map((t) => ( | |
| 22 | + <Link key={t.type} href={`/events?type=${t.type}`} className="rounded-full border border-rule px-2 py-0.5 text-[11px] text-ink-2 hover:border-rule-strong"> | |
| 23 | + {EVENT_TYPE_LABEL[t.type] ?? t.type} <span className="mono">{t.n}</span> | |
| 24 | + </Link> | |
| 25 | + ))} | |
| 26 | + </div> | |
| 27 | + )} | |
| 28 | + {changes.top_movers.length > 0 && ( | |
| 29 | + <ul className="mt-3 divide-y divide-rule text-sm"> | |
| 30 | + {changes.top_movers.slice(0, 6).map((m) => ( | |
| 31 | + <li key={m.id} className="flex items-center justify-between py-1.5"> | |
| 32 | + <Link href={instrumentHref(m.id)} className="min-w-0"> | |
| 33 | + <span className="mono font-medium">{m.symbol}</span> | |
| 34 | + <span className="ml-2 hidden truncate text-xs text-ink-3 sm:inline">{m.name}</span> | |
| 35 | + </Link> | |
| 36 | + <ChangeCell value={m.window_change_percent} className="text-xs" /> | |
| 37 | + </li> | |
| 38 | + ))} | |
| 39 | + </ul> | |
| 40 | + )} | |
| 41 | + </div> | |
| 42 | + ); | |
| 43 | +} | |
| 44 | + | |
| 45 | +function Num({ n, label, sub }: { n: number; label: string; sub?: string }) { | |
| 46 | + return ( | |
| 47 | + <div> | |
| 48 | + <div className="mono text-xl font-semibold tnum">{formatInt(n)}</div> | |
| 49 | + <div className="text-[11px] text-ink-3">{label}</div> | |
| 50 | + {sub && <div className="text-[10.5px] text-ink-3">{sub}</div>} | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | +} | |
added
apps/web/src/components/market/class-page.tsx
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { InstrumentTable } from "@/components/market/instrument-table"; | |
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | |
| 4 | +import { apiEnvelope } from "@/lib/api"; | |
| 5 | +import { cx } from "@/lib/format"; | |
| 6 | +import type { InstrumentWithQuote } from "@/lib/types"; | |
| 7 | + | |
| 8 | +const SORTS: Array<[string, string]> = [["-change", "Top gainers"], ["change", "Top losers"], ["volume", "Most active"], ["symbol", "A → Z"]]; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Shared server page for one asset-class list (/stocks, /crypto, …). Fetches quoted instruments | |
| 12 | + * from /v1/instruments and renders a live table; pagination and sort via search params. | |
| 13 | + */ | |
| 14 | +export async function ClassPage({ classes, title, kicker, lead, basePath, searchParams, showExchange = true, defaultSort = "-change", note }: { classes: string[]; title: string; kicker?: string; lead: string; basePath: string; searchParams: Promise<Record<string, string | string[] | undefined>>; showExchange?: boolean; defaultSort?: string; note?: React.ReactNode }) { | |
| 15 | + const sp = await searchParams; | |
| 16 | + const sort = typeof sp.sort === "string" ? sp.sort : defaultSort; | |
| 17 | + const page = Math.max(1, Number(sp.page ?? 1) || 1); | |
| 18 | + const q = typeof sp.q === "string" ? sp.q : ""; | |
| 19 | + const all = typeof sp.all === "string"; | |
| 20 | + const limit = 100; | |
| 21 | + const lists = await Promise.all( | |
| 22 | + classes.map((c) => apiEnvelope<InstrumentWithQuote[]>(`/v1/instruments?asset_class=${c}${all ? "" : ""ed=1"}&sort=${encodeURIComponent(sort)}&limit=${limit}&offset=${(page - 1) * limit}${q ? `&q=${encodeURIComponent(q)}` : ""}`)), | |
| 23 | + ); | |
| 24 | + const rows = lists.flatMap((l) => l.data); | |
| 25 | + const total = lists.reduce((a, l) => a + Number(l.meta.total ?? 0), 0); | |
| 26 | + const pages = Math.max(1, Math.ceil(total / limit)); | |
| 27 | + const link = (params: Record<string, string | number | undefined>) => { | |
| 28 | + const u = new URLSearchParams(); | |
| 29 | + const merged = { sort, page, q, ...(all ? { all: "1" } : {}), ...params }; | |
| 30 | + for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== "" && !(k === "page" && v === 1) && !(k === "sort" && v === defaultSort)) u.set(k, String(v)); | |
| 31 | + const s = u.toString(); | |
| 32 | + return s ? `${basePath}?${s}` : basePath; | |
| 33 | + }; | |
| 34 | + return ( | |
| 35 | + <Page wide> | |
| 36 | + <PageHeader kicker={kicker} title={title} lead={lead} /> | |
| 37 | + <div className="mb-3 flex flex-wrap items-center gap-2"> | |
| 38 | + {SORTS.map(([s, label]) => ( | |
| 39 | + <Link key={s} href={link({ sort: s, page: 1 })} className={cx("inline-flex h-8 items-center rounded-full border px-3 text-xs", s === sort ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}> | |
| 40 | + {label} | |
| 41 | + </Link> | |
| 42 | + ))} | |
| 43 | + <form action={basePath} className="ml-auto flex items-center gap-2"> | |
| 44 | + {sort !== defaultSort && <input type="hidden" name="sort" value={sort} />} | |
| 45 | + {all && <input type="hidden" name="all" value="1" />} | |
| 46 | + <input name="q" defaultValue={q} placeholder="Filter symbol or name" className="h-9 w-48 rounded-md border border-rule bg-surface px-2 text-sm outline-none focus:border-rule-strong" /> | |
| 47 | + <Link href={link({ all: all ? undefined : "1", page: 1 })} className="text-xs text-ink-3 hover:text-ink"> | |
| 48 | + {all ? "Quoted only" : "Include unquoted"} | |
| 49 | + </Link> | |
| 50 | + </form> | |
| 51 | + </div> | |
| 52 | + <InstrumentTable rows={rows} liveClass={classes} showExchange={showExchange} showClass={classes.length > 1} defaultSort={sort.replace("-", "") === "change" ? "change" : sort === "volume" ? "volume" : "symbol"} emptyText={q ? `No instruments match “${q}”.` : "No quoted instruments in this class yet."} /> | |
| 53 | + <div className="mt-3 flex items-center justify-between text-xs text-ink-3"> | |
| 54 | + <span> | |
| 55 | + {total.toLocaleString("en-US")} instrument{total === 1 ? "" : "s"} · page {page} of {pages} | |
| 56 | + </span> | |
| 57 | + <span className="flex gap-2"> | |
| 58 | + {page > 1 && ( | |
| 59 | + <Link href={link({ page: page - 1 })} className="text-accent hover:underline"> | |
| 60 | + ← Previous | |
| 61 | + </Link> | |
| 62 | + )} | |
| 63 | + {page < pages && ( | |
| 64 | + <Link href={link({ page: page + 1 })} className="text-accent hover:underline"> | |
| 65 | + Next → | |
| 66 | + </Link> | |
| 67 | + )} | |
| 68 | + </span> | |
| 69 | + </div> | |
| 70 | + {note && <p className="mt-4 text-xs text-ink-3">{note}</p>} | |
| 71 | + </Page> | |
| 72 | + ); | |
| 73 | +} | |
added
apps/web/src/components/market/compare-view.tsx
+218 −0
@@ -0,0 +1,218 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { ColorType, createChart, LineSeries, type IChartApi, type UTCTimestamp } from "lightweight-charts"; | |
| 4 | +import { X } from "lucide-react"; | |
| 5 | +import Link from "next/link"; | |
| 6 | +import { useRouter } from "next/navigation"; | |
| 7 | +import { useEffect, useRef, useState } from "react"; | |
| 8 | +import { clientApi } from "@/lib/client-api"; | |
| 9 | +import { cx, formatPercent, instrumentHref } from "@/lib/format"; | |
| 10 | +import type { Instrument, Quote, SearchResult } from "@/lib/types"; | |
| 11 | + | |
| 12 | +interface Series { | |
| 13 | + instrument: Instrument; | |
| 14 | + quote: Quote | null; | |
| 15 | + points: Array<{ t: number; c: number; n: number | null }>; | |
| 16 | + stats: { return_percent: number | null; annualized_volatility_percent: number | null; max_drawdown_percent: number | null; points: number }; | |
| 17 | +} | |
| 18 | +interface CompareData { | |
| 19 | + resolution: string; | |
| 20 | + series: Series[]; | |
| 21 | + correlations: Array<{ a: string; b: string; rho: number | null }>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const COLORS = ["--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7", "--series-8"]; | |
| 25 | +const cssVar = (n: string) => (typeof window === "undefined" ? "#000" : getComputedStyle(document.documentElement).getPropertyValue(n).trim()); | |
| 26 | + | |
| 27 | +export function CompareView({ initialIds, initialResolution }: { initialIds: string[]; initialResolution: "1m" | "1h" | "1d" }) { | |
| 28 | + const router = useRouter(); | |
| 29 | + const [ids, setIds] = useState(initialIds); | |
| 30 | + const [resolution, setResolution] = useState(initialResolution); | |
| 31 | + const [data, setData] = useState<CompareData | null>(null); | |
| 32 | + const [q, setQ] = useState(""); | |
| 33 | + const [hits, setHits] = useState<Array<{ id: string; symbol: string; name: string }>>([]); | |
| 34 | + const wrap = useRef<HTMLDivElement>(null); | |
| 35 | + const chart = useRef<IChartApi | null>(null); | |
| 36 | + | |
| 37 | + useEffect(() => { | |
| 38 | + const u = new URLSearchParams({ ids: ids.join(","), resolution }); | |
| 39 | + router.replace(`/compare?${u}`, { scroll: false }); | |
| 40 | + if (!ids.length) { | |
| 41 | + setData({ resolution, series: [], correlations: [] }); | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + clientApi<CompareData>(`/v1/compare?${u}&limit=${resolution === "1m" ? 1500 : resolution === "1h" ? 700 : 500}`) | |
| 45 | + .then(setData) | |
| 46 | + .catch(() => setData({ resolution, series: [], correlations: [] })); | |
| 47 | + }, [ids, resolution, router]); | |
| 48 | + | |
| 49 | + useEffect(() => { | |
| 50 | + if (!q.trim()) { | |
| 51 | + setHits([]); | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + const t = setTimeout(() => { | |
| 55 | + clientApi<SearchResult>(`/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`) | |
| 56 | + .then((r) => setHits(r.results.filter((x) => x.group !== "EXCHANGE" && x.group !== "COUNTRY").map((x) => ({ id: String(x.item.id), symbol: String(x.item.symbol), name: String(x.item.name) })))) | |
| 57 | + .catch(() => {}); | |
| 58 | + }, 150); | |
| 59 | + return () => clearTimeout(t); | |
| 60 | + }, [q]); | |
| 61 | + | |
| 62 | + useEffect(() => { | |
| 63 | + if (!wrap.current) return; | |
| 64 | + const c = createChart(wrap.current, { | |
| 65 | + autoSize: true, | |
| 66 | + layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false }, | |
| 67 | + grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } }, | |
| 68 | + rightPriceScale: { borderColor: cssVar("--rule") }, | |
| 69 | + timeScale: { borderColor: cssVar("--rule"), timeVisible: true }, | |
| 70 | + }); | |
| 71 | + chart.current = c; | |
| 72 | + return () => { | |
| 73 | + c.remove(); | |
| 74 | + chart.current = null; | |
| 75 | + }; | |
| 76 | + }, []); | |
| 77 | + | |
| 78 | + useEffect(() => { | |
| 79 | + const c = chart.current; | |
| 80 | + if (!c || !data) return; | |
| 81 | + // Rebuild series: remove all, add one per instrument. | |
| 82 | + // lightweight-charts has no "remove all", so we recreate via a fresh chart instance when the set changes. | |
| 83 | + const el = wrap.current; | |
| 84 | + if (!el) return; | |
| 85 | + c.remove(); | |
| 86 | + const nc = createChart(el, { | |
| 87 | + autoSize: true, | |
| 88 | + layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false }, | |
| 89 | + grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } }, | |
| 90 | + rightPriceScale: { borderColor: cssVar("--rule") }, | |
| 91 | + timeScale: { borderColor: cssVar("--rule"), timeVisible: true }, | |
| 92 | + }); | |
| 93 | + chart.current = nc; | |
| 94 | + data.series.forEach((s, i) => { | |
| 95 | + const line = nc.addSeries(LineSeries, { color: cssVar(COLORS[i % COLORS.length]!), lineWidth: 2, title: s.instrument.symbol, priceFormat: { type: "custom", formatter: (v: number) => v.toFixed(1) } }); | |
| 96 | + line.setData(s.points.filter((p) => p.n != null).map((p) => ({ time: (p.t / 1000) as UTCTimestamp, value: p.n! }))); | |
| 97 | + }); | |
| 98 | + nc.timeScale().fitContent(); | |
| 99 | + }, [data]); | |
| 100 | + | |
| 101 | + const rho = (a: string, b: string) => (a === b ? 1 : data?.correlations.find((c) => (c.a === a && c.b === b) || (c.a === b && c.b === a))?.rho ?? null); | |
| 102 | + return ( | |
| 103 | + <div> | |
| 104 | + <div className="mb-3 flex flex-wrap items-center gap-2"> | |
| 105 | + {data?.series.map((s, i) => ( | |
| 106 | + <span key={s.instrument.id} className="inline-flex h-9 items-center gap-2 rounded-md border border-rule bg-surface pl-2 pr-1 text-sm"> | |
| 107 | + <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: `var(${COLORS[i % COLORS.length]})` }} /> | |
| 108 | + <Link href={instrumentHref(s.instrument.id)} className="mono font-medium hover:underline"> | |
| 109 | + {s.instrument.symbol} | |
| 110 | + </Link> | |
| 111 | + <button type="button" aria-label={`Remove ${s.instrument.symbol}`} onClick={() => setIds((x) => x.filter((id) => id !== s.instrument.id))} className="flex h-7 w-7 items-center justify-center text-ink-3 hover:text-ink"> | |
| 112 | + <X size={13} /> | |
| 113 | + </button> | |
| 114 | + </span> | |
| 115 | + ))} | |
| 116 | + <div className="relative"> | |
| 117 | + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={ids.length >= 8 ? "Maximum 8 instruments" : "Add instrument…"} disabled={ids.length >= 8} className="h-9 w-56 rounded-md border border-rule bg-surface px-2 text-sm outline-none focus:border-rule-strong" /> | |
| 118 | + {hits.length > 0 && ( | |
| 119 | + <ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-md border border-rule bg-surface shadow-lg"> | |
| 120 | + {hits.map((h) => ( | |
| 121 | + <li key={h.id}> | |
| 122 | + <button | |
| 123 | + type="button" | |
| 124 | + onClick={() => { | |
| 125 | + if (!ids.includes(h.id)) setIds((x) => [...x, h.id]); | |
| 126 | + setQ(""); | |
| 127 | + setHits([]); | |
| 128 | + }} | |
| 129 | + className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-2" | |
| 130 | + > | |
| 131 | + <span className="mono font-medium">{h.symbol}</span> | |
| 132 | + <span className="truncate text-xs text-ink-3">{h.name}</span> | |
| 133 | + </button> | |
| 134 | + </li> | |
| 135 | + ))} | |
| 136 | + </ul> | |
| 137 | + )} | |
| 138 | + </div> | |
| 139 | + <div className="ml-auto flex gap-0.5"> | |
| 140 | + {(["1m", "1h", "1d"] as const).map((r) => ( | |
| 141 | + <button key={r} type="button" onClick={() => setResolution(r)} className={cx("mono h-9 rounded px-3 text-xs", r === resolution ? "bg-ink text-canvas" : "text-ink-2 hover:bg-surface-2")}> | |
| 142 | + {r === "1m" ? "Intraday" : r === "1h" ? "Hourly" : "Daily"} | |
| 143 | + </button> | |
| 144 | + ))} | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + <div className="relative h-[380px] rounded-md border border-rule bg-surface"> | |
| 148 | + <div ref={wrap} className="absolute inset-0" /> | |
| 149 | + {data && data.series.every((s) => s.points.length < 2) && <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-ink-3">Not enough history at this resolution yet. Intraday series start when Market Atlas first observes an instrument live; daily history comes from end-of-day sources.</div>} | |
| 150 | + </div> | |
| 151 | + {data && data.series.length > 0 && ( | |
| 152 | + <div className="mt-4 grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-[1fr_auto]"> | |
| 153 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | |
| 154 | + <table className="table-dense"> | |
| 155 | + <thead> | |
| 156 | + <tr> | |
| 157 | + <th>Instrument</th> | |
| 158 | + <th className="text-right">Return</th> | |
| 159 | + <th className="text-right">Ann. volatility</th> | |
| 160 | + <th className="text-right">Max drawdown</th> | |
| 161 | + <th className="text-right">Points</th> | |
| 162 | + </tr> | |
| 163 | + </thead> | |
| 164 | + <tbody> | |
| 165 | + {data.series.map((s, i) => ( | |
| 166 | + <tr key={s.instrument.id}> | |
| 167 | + <td> | |
| 168 | + <span className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: `var(${COLORS[i % COLORS.length]})` }} /> | |
| 169 | + <Link href={instrumentHref(s.instrument.id)} className="mono font-medium hover:underline"> | |
| 170 | + {s.instrument.symbol} | |
| 171 | + </Link> | |
| 172 | + <span className="ml-2 text-xs text-ink-3">{s.instrument.name}</span> | |
| 173 | + </td> | |
| 174 | + <td className={cx("num", (s.stats.return_percent ?? 0) > 0 ? "text-positive" : (s.stats.return_percent ?? 0) < 0 ? "text-negative" : "")}>{formatPercent(s.stats.return_percent)}</td> | |
| 175 | + <td className="num">{s.stats.annualized_volatility_percent == null ? "—" : `${s.stats.annualized_volatility_percent.toFixed(1)}%`}</td> | |
| 176 | + <td className="num text-negative">{formatPercent(s.stats.max_drawdown_percent, 2, false)}</td> | |
| 177 | + <td className="num">{s.stats.points}</td> | |
| 178 | + </tr> | |
| 179 | + ))} | |
| 180 | + </tbody> | |
| 181 | + </table> | |
| 182 | + </div> | |
| 183 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface p-3"> | |
| 184 | + <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Correlation of returns</div> | |
| 185 | + <table className="mono text-xs"> | |
| 186 | + <thead> | |
| 187 | + <tr> | |
| 188 | + <th /> | |
| 189 | + {data.series.map((s) => ( | |
| 190 | + <th key={s.instrument.id} className="px-2 py-1 text-right font-medium text-ink-3"> | |
| 191 | + {s.instrument.symbol} | |
| 192 | + </th> | |
| 193 | + ))} | |
| 194 | + </tr> | |
| 195 | + </thead> | |
| 196 | + <tbody> | |
| 197 | + {data.series.map((a) => ( | |
| 198 | + <tr key={a.instrument.id}> | |
| 199 | + <td className="py-1 pr-2 text-ink-3">{a.instrument.symbol}</td> | |
| 200 | + {data.series.map((b) => { | |
| 201 | + const r = rho(a.instrument.id, b.instrument.id); | |
| 202 | + return ( | |
| 203 | + <td key={b.instrument.id} className="px-2 py-1 text-right tnum" style={r == null ? undefined : { background: r > 0 ? `rgba(29,79,216,${Math.abs(r) * 0.3})` : `rgba(192,50,60,${Math.abs(r) * 0.3})` }}> | |
| 204 | + {r == null ? "—" : r.toFixed(2)} | |
| 205 | + </td> | |
| 206 | + ); | |
| 207 | + })} | |
| 208 | + </tr> | |
| 209 | + ))} | |
| 210 | + </tbody> | |
| 211 | + </table> | |
| 212 | + <p className="mt-2 max-w-[260px] text-[11px] text-ink-3">Pearson correlation of period returns on aligned timestamps; “—” when fewer than 5 overlapping points.</p> | |
| 213 | + </div> | |
| 214 | + </div> | |
| 215 | + )} | |
| 216 | + </div> | |
| 217 | + ); | |
| 218 | +} | |
added
apps/web/src/components/market/health-panel.tsx
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { Sparkline } from "@/components/ui/sparkline"; | |
| 3 | +import { cx, formatDuration } from "@/lib/format"; | |
| 4 | +import type { DataHealth } from "@/lib/types"; | |
| 5 | + | |
| 6 | +/** Compact source-health panel for the homepage (server component; data from /v1/data-health). */ | |
| 7 | +export function HealthPanel({ health, className }: { health: DataHealth; className?: string }) { | |
| 8 | + const c = health.connectors; | |
| 9 | + const ratio = health.healthy_ratio == null ? null : Math.round(health.healthy_ratio * 100); | |
| 10 | + const segs: Array<[number, string, string]> = [ | |
| 11 | + [c.healthy, "bg-positive", "healthy"], | |
| 12 | + [c.degraded, "bg-warning", "degraded"], | |
| 13 | + [c.recovering, "bg-accent", "recovering"], | |
| 14 | + [c.failed, "bg-negative", "failed"], | |
| 15 | + [c.paused, "bg-stale", "paused"], | |
| 16 | + ]; | |
| 17 | + return ( | |
| 18 | + <div className={cx("rounded-md border border-rule bg-surface p-4", className)}> | |
| 19 | + <div className="flex items-baseline justify-between"> | |
| 20 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Source health</h3> | |
| 21 | + <Link href="/data-health" className="text-xs text-accent hover:underline"> | |
| 22 | + Data health → | |
| 23 | + </Link> | |
| 24 | + </div> | |
| 25 | + <div className="mono mt-1 text-2xl font-semibold tnum"> | |
| 26 | + {ratio == null ? "—" : `${ratio}%`} <span className="text-sm font-normal text-ink-3">connectors healthy</span> | |
| 27 | + </div> | |
| 28 | + <div className="mt-2 flex h-2 overflow-hidden rounded-full bg-surface-3"> | |
| 29 | + {segs.map(([n, cls, label]) => (n > 0 ? <span key={label} className={cls} style={{ width: `${(n / Math.max(1, c.total)) * 100}%` }} title={`${n} ${label}`} /> : null))} | |
| 30 | + </div> | |
| 31 | + <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-ink-3"> | |
| 32 | + {segs.map(([n, cls, label]) => ( | |
| 33 | + <span key={label} className="inline-flex items-center gap-1"> | |
| 34 | + <span className={cx("inline-block h-1.5 w-1.5 rounded-full", cls)} /> | |
| 35 | + {n} {label} | |
| 36 | + </span> | |
| 37 | + ))} | |
| 38 | + </div> | |
| 39 | + <dl className="mt-3 grid grid-cols-2 gap-x-4 text-sm"> | |
| 40 | + <Row k="Median freshness" v={formatDuration(health.median_freshness_ms)} /> | |
| 41 | + <Row k="Multi-source quotes" v={`${health.multi_source_quotes} / ${health.quotes_public}`} /> | |
| 42 | + <Row k="Mean confidence" v={health.mean_confidence == null ? "—" : `${Math.round(health.mean_confidence * 100)}%`} /> | |
| 43 | + <Row k="Observations / s" v={health.observations_per_sec.toFixed(1)} /> | |
| 44 | + </dl> | |
| 45 | + {health.history.length > 2 && ( | |
| 46 | + <div className="mt-3 flex items-center justify-between text-[11px] text-ink-3"> | |
| 47 | + <span>48 h · healthy ratio</span> | |
| 48 | + <Sparkline values={health.history.map((h) => h.healthy_ratio)} width={140} height={24} stroke="var(--accent)" /> | |
| 49 | + </div> | |
| 50 | + )} | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | +} | |
| 54 | + | |
| 55 | +function Row({ k, v }: { k: string; v: string }) { | |
| 56 | + return ( | |
| 57 | + <div className="flex items-baseline justify-between border-b border-rule py-1"> | |
| 58 | + <dt className="text-xs text-ink-3">{k}</dt> | |
| 59 | + <dd className="mono text-xs tnum">{v}</dd> | |
| 60 | + </div> | |
| 61 | + ); | |
| 62 | +} | |
added
apps/web/src/components/market/instrument-header.tsx
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { ConfidenceMeter } from "@/components/ui/confidence"; | |
| 5 | +import { FreshnessLabel } from "@/components/ui/freshness"; | |
| 6 | +import { LivePrice } from "@/components/ui/price"; | |
| 7 | +import { StatusBadge } from "@/components/ui/status-badge"; | |
| 8 | +import { ASSET_CLASS_LABEL } from "@/lib/format"; | |
| 9 | +import { useLiveQuote, useMarketStream } from "@/lib/stream"; | |
| 10 | +import type { Exchange, Instrument, Quote } from "@/lib/types"; | |
| 11 | + | |
| 12 | +/** Sticky quote header: symbol, name, live price + change, status badge, freshness, confidence. */ | |
| 13 | +export function InstrumentHeader({ instrument: i, quote, exchange }: { instrument: Instrument; quote: Quote | null; exchange: (Exchange & { status?: Exchange["status"] }) | null }) { | |
| 14 | + useMarketStream([`quotes:${i.id}`, `events:${i.id}`]); | |
| 15 | + const live = useLiveQuote(i.id); | |
| 16 | + const sources = live && quote && live.received >= Date.parse(quote.updated_at) ? live.sources : quote?.source_count; | |
| 17 | + const conf = live && quote && live.received >= Date.parse(quote.updated_at) ? live.confidence : quote?.confidence; | |
| 18 | + return ( | |
| 19 | + <div className="sticky top-[var(--header-h)] z-[60] border-b border-rule bg-canvas/95 backdrop-blur md:top-[calc(var(--header-h)+29px)]"> | |
| 20 | + <div className="mx-auto flex max-w-[1440px] flex-wrap items-end justify-between gap-x-6 gap-y-2 px-3 py-3 sm:px-5"> | |
| 21 | + <div className="min-w-0"> | |
| 22 | + <div className="flex flex-wrap items-center gap-2 text-xs text-ink-3"> | |
| 23 | + <Link href={classHref(i.asset_class)} className="hover:text-ink"> | |
| 24 | + {ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class} | |
| 25 | + </Link> | |
| 26 | + {exchange && ( | |
| 27 | + <> | |
| 28 | + <span>·</span> | |
| 29 | + <Link href={`/exchanges/${exchange.id}`} className="hover:text-ink"> | |
| 30 | + {exchange.name} | |
| 31 | + </Link> | |
| 32 | + {exchange.status && <StatusBadge status={exchange.status.state} />} | |
| 33 | + </> | |
| 34 | + )} | |
| 35 | + {i.country && i.country !== "XX" && ( | |
| 36 | + <> | |
| 37 | + <span>·</span> | |
| 38 | + <Link href={`/countries/${i.country}`} className="hover:text-ink"> | |
| 39 | + {i.country} | |
| 40 | + </Link> | |
| 41 | + </> | |
| 42 | + )} | |
| 43 | + </div> | |
| 44 | + <h1 className="mt-0.5 flex flex-wrap items-baseline gap-x-3"> | |
| 45 | + <span className="mono text-2xl font-semibold tracking-tight sm:text-3xl">{i.symbol}</span> | |
| 46 | + <span className="truncate text-base text-ink-2 sm:text-lg">{i.name}</span> | |
| 47 | + </h1> | |
| 48 | + </div> | |
| 49 | + <div className="flex flex-col items-start gap-1 sm:items-end"> | |
| 50 | + <div className="flex items-center gap-3"> | |
| 51 | + <LivePrice instrumentId={i.id} quote={quote} assetClass={i.asset_class} big /> | |
| 52 | + <StatusBadge status={quote?.data_status ?? "UNKNOWN"} dot /> | |
| 53 | + </div> | |
| 54 | + <div className="flex flex-wrap items-center gap-3"> | |
| 55 | + <FreshnessLabel quote={quote} exchangeTz={exchange?.timezone} /> | |
| 56 | + <ConfidenceMeter confidence={conf} sources={sources} /> | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + ); | |
| 62 | +} | |
| 63 | + | |
| 64 | +function classHref(c: string) { | |
| 65 | + switch (c) { | |
| 66 | + case "EQUITY": | |
| 67 | + return "/stocks"; | |
| 68 | + case "ETF": | |
| 69 | + case "ETN": | |
| 70 | + return "/etfs"; | |
| 71 | + case "INDEX": | |
| 72 | + return "/indices"; | |
| 73 | + case "CRYPTO": | |
| 74 | + return "/crypto"; | |
| 75 | + case "FOREX": | |
| 76 | + return "/forex"; | |
| 77 | + case "TREASURY": | |
| 78 | + case "BOND": | |
| 79 | + case "INTEREST_RATE": | |
| 80 | + return "/rates"; | |
| 81 | + case "COMMODITY": | |
| 82 | + case "FUTURE": | |
| 83 | + return "/commodities"; | |
| 84 | + default: | |
| 85 | + return "/markets"; | |
| 86 | + } | |
| 87 | +} | |
added
apps/web/src/components/market/instrument-stats.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { formatBps, formatCompact, formatDuration, formatQuoteValue } from "@/lib/format"; | |
| 4 | +import { useLiveQuote } from "@/lib/stream"; | |
| 5 | +import type { Instrument, Quote } from "@/lib/types"; | |
| 6 | + | |
| 7 | +/** Key stats grid; bid/ask/volume follow the live stream, session values from the canonical quote. */ | |
| 8 | +export function InstrumentStats({ instrument: i, quote: q }: { instrument: Instrument; quote: Quote | null }) { | |
| 9 | + const live = useLiveQuote(i.id); | |
| 10 | + const fresh = live && q && live.received >= Date.parse(q.updated_at); | |
| 11 | + const f = (v: number | null | undefined) => formatQuoteValue(v, i.asset_class, q?.currency); | |
| 12 | + const bid = fresh ? live!.bid : q?.bid; | |
| 13 | + const ask = fresh ? live!.ask : q?.ask; | |
| 14 | + const spreadBps = bid != null && ask != null && ask > 0 ? ((ask - bid) / ((ask + bid) / 2)) * 10_000 : null; | |
| 15 | + const items: Array<[string, string]> = [ | |
| 16 | + ["Open", f(q?.open)], | |
| 17 | + ["High", f(q?.high)], | |
| 18 | + ["Low", f(q?.low)], | |
| 19 | + ["Previous close", f(q?.previous_close)], | |
| 20 | + ["Bid", f(bid)], | |
| 21 | + ["Ask", f(ask)], | |
| 22 | + ["Spread", formatBps(spreadBps)], | |
| 23 | + ["Volume", formatCompact(fresh ? live!.volume : q?.volume)], | |
| 24 | + ["Session high", f(q?.session_high)], | |
| 25 | + ["Session low", f(q?.session_low)], | |
| 26 | + ["Source dispersion", formatBps(q?.dispersion_bps)], | |
| 27 | + ["Newest observation", q?.freshness_ms == null ? "—" : `${formatDuration(q.freshness_ms)} old at compute`], | |
| 28 | + ]; | |
| 29 | + return ( | |
| 30 | + <dl className="grid grid-cols-2 gap-x-6 rounded-md border border-rule bg-surface px-4 py-2 sm:grid-cols-3 lg:grid-cols-4"> | |
| 31 | + {items.map(([k, v]) => ( | |
| 32 | + <div key={k} className="flex items-baseline justify-between gap-3 border-b border-rule py-1.5 text-sm last:border-0 sm:[&:nth-last-child(-n+3)]:border-0 lg:[&:nth-last-child(-n+4)]:border-0"> | |
| 33 | + <dt className="text-ink-3">{k}</dt> | |
| 34 | + <dd className="mono truncate tnum">{v}</dd> | |
| 35 | + </div> | |
| 36 | + ))} | |
| 37 | + </dl> | |
| 38 | + ); | |
| 39 | +} | |
modified
apps/web/src/components/market/live-events.tsx
+3 −3
@@ -7,7 +7,7 @@ import { useLiveEvents, useMarketStream } from "@/lib/stream"; | ||
| 7 | 7 | import type { MarketEvent } from "@/lib/types"; |
| 8 | 8 | |
| 9 | 9 | /** Initial events (SSR) merged with events arriving on `events:*` (or a narrower channel). */ |
| 10 | −export function LiveEvents({ initial, channel = "events:*", max = 40, dense, className, filter }: { initial: MarketEvent[]; channel?: string; max?: number; dense?: boolean; className?: string; filter?: (e: MarketEvent) => boolean }) { | |
| 10 | +export function LiveEvents({ initial, channel = "events:*", max = 40, dense, className, types }: { initial: MarketEvent[]; channel?: string; max?: number; dense?: boolean; className?: string; types?: string[] }) { | |
| 11 | 11 | useMarketStream([channel]); |
| 12 | 12 | const live = useLiveEvents(max); |
| 13 | 13 | const merged = useMemo(() => { |
@@ -15,13 +15,13 @@ export function LiveEvents({ initial, channel = "events:*", max = 40, dense, cla | ||
| 15 | 15 | const out: MarketEvent[] = []; |
| 16 | 16 | for (const e of [...live, ...initial]) { |
| 17 | 17 | if (seen.has(e.id)) continue; |
| 18 | − if (filter && !filter(e)) continue; | |
| 18 | + if (types && !types.includes(e.type)) continue; | |
| 19 | 19 | seen.add(e.id); |
| 20 | 20 | out.push(e); |
| 21 | 21 | if (out.length >= max) break; |
| 22 | 22 | } |
| 23 | 23 | return out; |
| 24 | − }, [live, initial, max, filter]); | |
| 24 | + }, [live, initial, max, types]); | |
| 25 | 25 | if (!merged.length) return <div className={cx("rounded-md border border-dashed border-rule px-4 py-8 text-center text-sm text-ink-3", className)}>No events yet — the feed fills as sources emit changes.</div>; |
| 26 | 26 | return ( |
| 27 | 27 | <ul className={cx("rounded-md border border-rule bg-surface px-3", className)}> |
added
apps/web/src/components/market/live-feed.tsx
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { Pause, Play } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useEffect, useMemo, useState } from "react"; | |
| 6 | +import { EventRow } from "./event-row"; | |
| 7 | +import { toneOf } from "@/components/ui/price"; | |
| 8 | +import { Pill } from "@/components/ui/section"; | |
| 9 | +import { cx, EVENT_TYPE_LABEL, formatClock, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format"; | |
| 10 | +import { useLiveEvents, useMarketStream, useTape } from "@/lib/stream"; | |
| 11 | +import type { MarketEvent, StreamQuote } from "@/lib/types"; | |
| 12 | + | |
| 13 | +const CLASSES = ["CRYPTO", "EQUITY", "ETF", "INDEX", "FOREX", "TREASURY", "COMMODITY"]; | |
| 14 | +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"]; | |
| 15 | +const SEVERITIES = ["INFO", "NOTICE", "WARNING", "CRITICAL"]; | |
| 16 | + | |
| 17 | +type Item = { kind: "event"; ts: number; e: MarketEvent } | { kind: "quote"; ts: number; q: StreamQuote }; | |
| 18 | + | |
| 19 | +/** Flagship /live view: merged stream of events and price changes with client-side filters and pause. */ | |
| 20 | +export function LiveFeed({ initial }: { initial: MarketEvent[] }) { | |
| 21 | + const [paused, setPaused] = useState(false); | |
| 22 | + const [showQuotes, setShowQuotes] = useState(true); | |
| 23 | + const [cls, setCls] = useState<string | null>(null); | |
| 24 | + const [country, setCountry] = useState(""); | |
| 25 | + const [types, setTypes] = useState<Set<string>>(new Set()); | |
| 26 | + const [sev, setSev] = useState<string | null>(null); | |
| 27 | + const [minConf, setMinConf] = useState(0); | |
| 28 | + const state = useMarketStream(showQuotes ? ["events:*", "tape"] : ["events:*"]); | |
| 29 | + const liveEvents = useLiveEvents(300); | |
| 30 | + const tape = useTape(120); | |
| 31 | + const [frozen, setFrozen] = useState<Item[] | null>(null); | |
| 32 | + | |
| 33 | + const items = useMemo<Item[]>(() => { | |
| 34 | + const seen = new Set<string>(); | |
| 35 | + const out: Item[] = []; | |
| 36 | + for (const e of [...liveEvents, ...initial]) { | |
| 37 | + if (seen.has(e.id)) continue; | |
| 38 | + seen.add(e.id); | |
| 39 | + out.push({ kind: "event", ts: typeof e.timestamp === "number" ? e.timestamp : Date.parse(String(e.timestamp).replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00")), e }); | |
| 40 | + } | |
| 41 | + if (showQuotes) for (const q of tape) out.push({ kind: "quote", ts: q.received, q }); | |
| 42 | + return out | |
| 43 | + .filter((it) => { | |
| 44 | + if (it.kind === "event") { | |
| 45 | + const e = it.e; | |
| 46 | + if (types.size && !types.has(e.type)) return false; | |
| 47 | + if (sev && SEVERITIES.indexOf(e.severity) < SEVERITIES.indexOf(sev)) return false; | |
| 48 | + if (e.confidence < minConf) return false; | |
| 49 | + if (cls && !e.instruments.some((i) => i.asset_class === cls) && e.instruments.length) return false; | |
| 50 | + if (country && !e.instruments.some((i) => i.country === country.toUpperCase())) return false; | |
| 51 | + return true; | |
| 52 | + } | |
| 53 | + if (types.size || sev) return false; | |
| 54 | + if (it.q.confidence < minConf) return false; | |
| 55 | + return true; | |
| 56 | + }) | |
| 57 | + .sort((a, b) => b.ts - a.ts) | |
| 58 | + .slice(0, 300); | |
| 59 | + }, [liveEvents, initial, tape, showQuotes, types, sev, minConf, cls, country]); | |
| 60 | + | |
| 61 | + useEffect(() => { | |
| 62 | + if (paused && !frozen) setFrozen(items); | |
| 63 | + if (!paused && frozen) setFrozen(null); | |
| 64 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 65 | + }, [paused]); | |
| 66 | + const shown = paused && frozen ? frozen : items; | |
| 67 | + const toggleType = (t: string) => | |
| 68 | + setTypes((s) => { | |
| 69 | + const n = new Set(s); | |
| 70 | + if (n.has(t)) n.delete(t); | |
| 71 | + else n.add(t); | |
| 72 | + return n; | |
| 73 | + }); | |
| 74 | + | |
| 75 | + return ( | |
| 76 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-5 lg:grid-cols-[260px_1fr]"> | |
| 77 | + <aside className="space-y-4 lg:sticky lg:top-[calc(var(--header-h)+40px)] lg:self-start"> | |
| 78 | + <div className="flex items-center gap-2"> | |
| 79 | + <button type="button" onClick={() => setPaused((p) => !p)} className={cx("inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm", paused ? "border-warning text-warning" : "border-rule text-ink-2 hover:text-ink")}> | |
| 80 | + {paused ? <Play size={14} /> : <Pause size={14} />} | |
| 81 | + {paused ? "Resume" : "Pause"} | |
| 82 | + </button> | |
| 83 | + <span className={cx("inline-flex items-center gap-1.5 text-xs", state === "open" ? "text-positive" : "text-warning")}> | |
| 84 | + <span className={cx("inline-block h-1.5 w-1.5 rounded-full", state === "open" ? "bg-positive live-dot" : "bg-warning")} /> | |
| 85 | + {state === "open" ? "streaming" : state} | |
| 86 | + </span> | |
| 87 | + </div> | |
| 88 | + <label className="flex items-center gap-2 text-sm text-ink-2"> | |
| 89 | + <input type="checkbox" checked={showQuotes} onChange={(e) => setShowQuotes(e.target.checked)} className="h-4 w-4" /> Include price changes | |
| 90 | + </label> | |
| 91 | + <Filter title="Asset class"> | |
| 92 | + <Pill active={!cls} onClick={() => setCls(null)}> | |
| 93 | + All | |
| 94 | + </Pill> | |
| 95 | + {CLASSES.map((c) => ( | |
| 96 | + <Pill key={c} active={cls === c} onClick={() => setCls(cls === c ? null : c)}> | |
| 97 | + {c.toLowerCase()} | |
| 98 | + </Pill> | |
| 99 | + ))} | |
| 100 | + </Filter> | |
| 101 | + <Filter title="Country (ISO-2)"> | |
| 102 | + <input value={country} onChange={(e) => setCountry(e.target.value.toUpperCase().slice(0, 2))} placeholder="US, CA, XX…" className="mono h-9 w-24 rounded-md border border-rule bg-surface px-2 text-sm uppercase outline-none focus:border-rule-strong" /> | |
| 103 | + </Filter> | |
| 104 | + <Filter title="Event type"> | |
| 105 | + {TYPES.map((t) => ( | |
| 106 | + <Pill key={t} active={types.has(t)} onClick={() => toggleType(t)}> | |
| 107 | + {EVENT_TYPE_LABEL[t] ?? t} | |
| 108 | + </Pill> | |
| 109 | + ))} | |
| 110 | + </Filter> | |
| 111 | + <Filter title="Minimum severity"> | |
| 112 | + <Pill active={!sev} onClick={() => setSev(null)}> | |
| 113 | + Any | |
| 114 | + </Pill> | |
| 115 | + {SEVERITIES.map((s) => ( | |
| 116 | + <Pill key={s} active={sev === s} onClick={() => setSev(sev === s ? null : s)}> | |
| 117 | + {s.toLowerCase()} | |
| 118 | + </Pill> | |
| 119 | + ))} | |
| 120 | + </Filter> | |
| 121 | + <Filter title={`Minimum confidence · ${Math.round(minConf * 100)}%`}> | |
| 122 | + <input type="range" min={0} max={1} step={0.05} value={minConf} onChange={(e) => setMinConf(Number(e.target.value))} className="w-full" /> | |
| 123 | + </Filter> | |
| 124 | + </aside> | |
| 125 | + <div> | |
| 126 | + <div className="mb-2 flex items-center justify-between text-xs text-ink-3"> | |
| 127 | + <span> | |
| 128 | + {shown.length} items{paused ? " · paused" : ""} | |
| 129 | + </span> | |
| 130 | + <Link href="/events" className="text-accent hover:underline"> | |
| 131 | + Browse the event archive → | |
| 132 | + </Link> | |
| 133 | + </div> | |
| 134 | + {shown.length === 0 ? ( | |
| 135 | + <div className="rounded-md border border-dashed border-rule px-4 py-12 text-center text-sm text-ink-3">Nothing matches these filters yet. The feed fills as sources emit changes.</div> | |
| 136 | + ) : ( | |
| 137 | + <ul className="rounded-md border border-rule bg-surface px-3"> | |
| 138 | + {shown.map((it) => | |
| 139 | + it.kind === "event" ? ( | |
| 140 | + <EventRow key={it.e.id} e={it.e} /> | |
| 141 | + ) : ( | |
| 142 | + <li key={`${it.q.instrument_id}-${it.q.received}`} className="flex items-center gap-3 border-b border-rule py-1.5 text-sm last:border-0"> | |
| 143 | + <span className="mono w-[76px] shrink-0 text-right text-[11px] text-ink-3" suppressHydrationWarning> | |
| 144 | + {formatClock(it.q.timestamp)} | |
| 145 | + </span> | |
| 146 | + <span className="text-[10.5px] font-medium uppercase tracking-wide text-ink-3">quote</span> | |
| 147 | + <Link href={instrumentHref(it.q.instrument_id)} className="mono font-medium text-accent hover:underline"> | |
| 148 | + {it.q.symbol} | |
| 149 | + </Link> | |
| 150 | + <span className="mono ml-auto tnum">{formatQuoteValue(it.q.price, undefined, it.q.currency)}</span> | |
| 151 | + <span className={cx("mono w-[70px] text-right tnum", toneOf(it.q.change_pct))}>{formatPercent(it.q.change_pct)}</span> | |
| 152 | + <span className="mono hidden w-[110px] text-right text-[11px] text-ink-3 sm:inline"> | |
| 153 | + {it.q.sources} src · {Math.round(it.q.confidence * 100)}% | |
| 154 | + </span> | |
| 155 | + </li> | |
| 156 | + ), | |
| 157 | + )} | |
| 158 | + </ul> | |
| 159 | + )} | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + ); | |
| 163 | +} | |
| 164 | + | |
| 165 | +function Filter({ title, children }: { title: string; children: React.ReactNode }) { | |
| 166 | + return ( | |
| 167 | + <div> | |
| 168 | + <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ink-3">{title}</div> | |
| 169 | + <div className="flex flex-wrap gap-1.5">{children}</div> | |
| 170 | + </div> | |
| 171 | + ); | |
| 172 | +} | |
added
apps/web/src/components/market/provenance-panel.tsx
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { ChevronDown } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useEffect, useState } from "react"; | |
| 6 | +import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; | |
| 7 | +import { clientApi } from "@/lib/client-api"; | |
| 8 | +import { cx, formatBps, formatDateTime, formatDuration, formatQuoteValue } from "@/lib/format"; | |
| 9 | +import type { Provenance } from "@/lib/types"; | |
| 10 | + | |
| 11 | +/** "Why this price?" — every contribution behind the canonical value, included or not, with the reason. */ | |
| 12 | +export function ProvenancePanel({ instrumentId, assetClass, defaultOpen = false, className }: { instrumentId: string; assetClass?: string | null; defaultOpen?: boolean; className?: string }) { | |
| 13 | + const [open, setOpen] = useState(defaultOpen); | |
| 14 | + const [data, setData] = useState<Provenance | null>(null); | |
| 15 | + const [err, setErr] = useState<string | null>(null); | |
| 16 | + useEffect(() => { | |
| 17 | + if (!open) return; | |
| 18 | + let alive = true; | |
| 19 | + const load = () => | |
| 20 | + clientApi<Provenance>(`/v1/quotes/${encodeURIComponent(instrumentId)}/provenance`) | |
| 21 | + .then((d) => alive && setData(d)) | |
| 22 | + .catch((e) => alive && setErr(e instanceof Error ? e.message : "unavailable")); | |
| 23 | + load(); | |
| 24 | + const t = setInterval(load, 5000); | |
| 25 | + return () => { | |
| 26 | + alive = false; | |
| 27 | + clearInterval(t); | |
| 28 | + }; | |
| 29 | + }, [open, instrumentId]); | |
| 30 | + return ( | |
| 31 | + <div className={cx("rounded-md border border-rule bg-surface", className)}> | |
| 32 | + <button type="button" onClick={() => setOpen((o) => !o)} className="flex h-12 w-full items-center justify-between px-4 text-left" aria-expanded={open}> | |
| 33 | + <span className="text-sm font-semibold">Why this price?</span> | |
| 34 | + <span className="flex items-center gap-2 text-xs text-ink-3"> | |
| 35 | + provenance & consensus | |
| 36 | + <ChevronDown size={16} className={cx("transition-transform", open && "rotate-180")} /> | |
| 37 | + </span> | |
| 38 | + </button> | |
| 39 | + {open && ( | |
| 40 | + <div className="border-t border-rule px-4 pb-4 pt-3"> | |
| 41 | + {err && <div className="text-sm text-negative">{err}</div>} | |
| 42 | + {!data && !err && <div className="text-sm text-ink-3">Loading provenance…</div>} | |
| 43 | + {data && ( | |
| 44 | + <> | |
| 45 | + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> | |
| 46 | + <Fact label="Canonical price" value={data.canonical_price == null ? "withheld" : formatQuoteValue(data.canonical_price, assetClass, data.currency)} /> | |
| 47 | + <Fact label="Independent sources" value={`${data.independent_sources}`} sub={`${data.contributions.filter((c) => c.included).length} observations included`} /> | |
| 48 | + <Fact label="Dispersion" value={formatBps(data.dispersion_bps)} sub="spread between included sources" /> | |
| 49 | + <Fact label="Confidence" value={`${Math.round(data.confidence * 100)}%`} sub={`newest observation ${formatDuration(data.freshness_ms)} old`} /> | |
| 50 | + </div> | |
| 51 | + <p className="mt-3 text-xs leading-relaxed text-ink-3"> | |
| 52 | + <span className="font-medium text-ink-2">Method (consensus v{data.consensus_version}):</span> {data.method}. Status <StatusBadge status={data.realtime_status} /> · rights <RightsBadge status={data.rights_status} /> · computed {formatDateTime(data.computed_at, { seconds: true })}. | |
| 53 | + </p> | |
| 54 | + <div className="mt-3 overflow-x-auto"> | |
| 55 | + <table className="table-dense"> | |
| 56 | + <thead> | |
| 57 | + <tr> | |
| 58 | + <th>Source</th> | |
| 59 | + <th className="text-right">Value</th> | |
| 60 | + <th className="text-right">Age</th> | |
| 61 | + <th className="text-right">Weight</th> | |
| 62 | + <th>Included</th> | |
| 63 | + <th>Class</th> | |
| 64 | + <th className="text-right">Reliability</th> | |
| 65 | + </tr> | |
| 66 | + </thead> | |
| 67 | + <tbody> | |
| 68 | + {data.contributions.map((c) => ( | |
| 69 | + <tr key={`${c.source_id}-${c.connector_id}`} className={cx(!c.included && "text-ink-3")}> | |
| 70 | + <td> | |
| 71 | + <Link href={`/sources#${c.source_id}`} className="hover:underline"> | |
| 72 | + {c.source?.name ?? c.source_id} | |
| 73 | + </Link> | |
| 74 | + <span className="mono block text-[10.5px] text-ink-3"> | |
| 75 | + {c.connector_id} | |
| 76 | + {c.source?.family && c.source.family !== c.source_id ? ` · family ${c.source.family}` : ""} | |
| 77 | + </span> | |
| 78 | + </td> | |
| 79 | + <td className="num">{c.value == null ? "withheld" : formatQuoteValue(c.value, assetClass, data.currency)}</td> | |
| 80 | + <td className="num">{formatDuration(c.age_ms)}</td> | |
| 81 | + <td className="num">{c.weight.toFixed(3)}</td> | |
| 82 | + <td>{c.included ? <span className="text-positive">yes</span> : <span className="text-stale">no · {c.reason ?? "excluded"}</span>}</td> | |
| 83 | + <td> | |
| 84 | + <StatusBadge status={c.realtime_status} /> | |
| 85 | + </td> | |
| 86 | + <td className="num">{Math.round(c.reliability_score * 100)}</td> | |
| 87 | + </tr> | |
| 88 | + ))} | |
| 89 | + </tbody> | |
| 90 | + </table> | |
| 91 | + </div> | |
| 92 | + <p className="mt-2 text-[11px] text-ink-3"> | |
| 93 | + Reliability is the operational score of the connector (availability, latency, parse success, stability), not a judgement of the venue. See <Link href="/methodology" className="text-accent hover:underline">methodology</Link>. | |
| 94 | + </p> | |
| 95 | + </> | |
| 96 | + )} | |
| 97 | + </div> | |
| 98 | + )} | |
| 99 | + </div> | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function Fact({ label, value, sub }: { label: string; value: string; sub?: string }) { | |
| 104 | + return ( | |
| 105 | + <div> | |
| 106 | + <div className="text-[10.5px] font-medium uppercase tracking-wide text-ink-3">{label}</div> | |
| 107 | + <div className="mono text-lg font-semibold tnum">{value}</div> | |
| 108 | + {sub && <div className="text-[11px] text-ink-3">{sub}</div>} | |
| 109 | + </div> | |
| 110 | + ); | |
| 111 | +} | |
modified
apps/web/src/components/market/pulse-grid.tsx
+1 −1
@@ -22,7 +22,7 @@ export function PulseRow({ title, href, items, className }: { title: string; hre | ||
| 22 | 22 | All → |
| 23 | 23 | </Link> |
| 24 | 24 | </div> |
| 25 | − <div className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid sm:grid-cols-3 sm:px-0 lg:grid-cols-6 [&::-webkit-scrollbar]:hidden"> | |
| 25 | + <div className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid grid-cols-1 [&>*]:min-w-0 sm:grid-cols-3 sm:px-0 lg:grid-cols-6 [&::-webkit-scrollbar]:hidden"> | |
| 26 | 26 | {items.slice(0, 6).map((i) => ( |
| 27 | 27 | <Link key={i.id} href={instrumentHref(i.id)} className="min-w-[164px] shrink-0 rounded-md border border-rule bg-surface px-3 py-2 hover:border-rule-strong sm:min-w-0"> |
| 28 | 28 | <div className="flex items-center justify-between gap-2"> |
modified
apps/web/src/components/market/tape.tsx
+2 −2
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import Link from "next/link"; |
| 4 | −import { cx, formatDateTime, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format"; | |
| 4 | +import { cx, formatClock, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format"; | |
| 5 | 5 | import { useMarketStream, useTape } from "@/lib/stream"; |
| 6 | 6 | import { toneOf } from "@/components/ui/price"; |
| 7 | 7 | |
@@ -23,7 +23,7 @@ export function LiveTape({ rows = 24, className }: { rows?: number; className?: | ||
| 23 | 23 | {tape.map((q, i) => ( |
| 24 | 24 | <li key={`${q.instrument_id}-${q.received}-${i}`} className={cx("flex items-center gap-3 border-b border-rule px-3 py-1 last:border-0", i === 0 && "bg-surface-2")}> |
| 25 | 25 | <span className="w-[86px] shrink-0 text-ink-3" suppressHydrationWarning> |
| 26 | − {formatDateTime(q.timestamp, { seconds: true }).replace(/^.*?, /, "").replace(" UTC", "")} | |
| 26 | + {formatClock(q.timestamp)} | |
| 27 | 27 | </span> |
| 28 | 28 | <Link href={instrumentHref(q.instrument_id)} className="w-[84px] shrink-0 truncate font-medium hover:text-accent"> |
| 29 | 29 | {q.symbol} |
modified
apps/web/src/lib/format.ts
+6 −0
@@ -128,6 +128,12 @@ export function formatDateTime(ms: number | string | null | undefined, opts: { t | ||
| 128 | 128 | return f.format(new Date(t)); |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | +const timeFmt = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }); | |
| 132 | +/** "09:11:41" (UTC) for tape rows. */ | |
| 133 | +export function formatClock(ms: number | null | undefined): string { | |
| 134 | + return ms == null ? "—" : timeFmt.format(new Date(ms)); | |
| 135 | +} | |
| 136 | + | |
| 131 | 137 | export const ASSET_CLASS_LABEL: Record<string, string> = { |
| 132 | 138 | EQUITY: "Stock", |
| 133 | 139 | ETF: "ETF", |
modified
apps/web/tsconfig.json
+28 −5
@@ -1,7 +1,11 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "compilerOptions": { |
| 3 | 3 | "target": "ES2022", |
| 4 | − "lib": ["dom", "dom.iterable", "esnext"], | |
| 4 | + "lib": [ | |
| 5 | + "dom", | |
| 6 | + "dom.iterable", | |
| 7 | + "esnext" | |
| 8 | + ], | |
| 5 | 9 | "allowJs": true, |
| 6 | 10 | "skipLibCheck": true, |
| 7 | 11 | "strict": true, |
@@ -14,9 +18,28 @@ | ||
| 14 | 18 | "isolatedModules": true, |
| 15 | 19 | "jsx": "react-jsx", |
| 16 | 20 | "incremental": true, |
| 17 | − "plugins": [{ "name": "next" }], | |
| 18 | − "paths": { "@/*": ["./src/*"] } | |
| 21 | + "plugins": [ | |
| 22 | + { | |
| 23 | + "name": "next" | |
| 24 | + } | |
| 25 | + ], | |
| 26 | + "paths": { | |
| 27 | + "@/*": [ | |
| 28 | + "./src/*" | |
| 29 | + ] | |
| 30 | + } | |
| 19 | 31 | }, |
| 20 | − "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], | |
| 21 | − "exclude": ["node_modules", "qa"] | |
| 32 | + "include": [ | |
| 33 | + "next-env.d.ts", | |
| 34 | + "**/*.ts", | |
| 35 | + "**/*.tsx", | |
| 36 | + ".next/types/**/*.ts", | |
| 37 | + ".next/dev/types/**/*.ts", | |
| 38 | + ".next-build/types/**/*.ts", | |
| 39 | + ".next-build/dev/types/**/*.ts" | |
| 40 | + ], | |
| 41 | + "exclude": [ | |
| 42 | + "node_modules", | |
| 43 | + "qa" | |
| 44 | + ] | |
| 22 | 45 | } |
modified
connectors/src/hfmarketdata/index.ts
+5 −2
@@ -52,7 +52,7 @@ const TARGETS: Target[] = [ | ||
| 52 | 52 | ...US_INDICES.filter((i) => i.hfmd).map((i) => ({ path: `/bars/index/${i.hfmd}?timeframe=1day&limit=400&order=desc`, symbol: i.cboe, hint: indexHint(i), kind: "bars" as const, sessionCloseLocal: "16:00", timezone: ET })), |
| 53 | 53 | ...FX_PAIRS.map((p) => ({ path: `/bars/fx/${p}?timeframe=1day&limit=400&order=desc`, symbol: p, hint: fxHint(p.slice(0, 3), p.slice(3)), kind: "bars" as const, sessionCloseLocal: "17:00", timezone: ET })), |
| 54 | 54 | ...CRYPTO.map((c) => ({ path: `/bars/crypto/${c}?timeframe=1day&limit=400&order=desc`, symbol: `${c}-USD`, hint: cryptoHint(c, "USD", "coinbase"), kind: "bars" as const, sessionCloseLocal: "23:59:59", timezone: "UTC" })), |
| 55 | − ...FUTURES.map(([root, name, ex]) => ({ path: `/futures/${root}/continuous?timeframe=1day&limit=400&roll=volume&adjust=none`, symbol: `${root}=F`, hint: commodityHint(root, name, ex), kind: "continuous" as const, sessionCloseLocal: "17:00", timezone: "America/Chicago" })), | |
| 55 | + ...FUTURES.map(([root, name, ex]) => ({ path: `/futures/${root}/continuous?timeframe=1day&limit=1000&roll=volume&adjust=none`, symbol: `${root}=F`, hint: commodityHint(root, name, ex), kind: "continuous" as const, sessionCloseLocal: "17:00", timezone: "America/Chicago" })), | |
| 56 | 56 | ]; |
| 57 | 57 | |
| 58 | 58 | const seeds: ProposedInstrument[] = FUTURES.map(([root, name, ex]) => ({ symbol: `${root}=F`, hint: commodityHint(root, name, ex), aliases: [root, `${root}1!`] })); |
@@ -94,9 +94,12 @@ export const hfmarketdata = defineConnector({ | ||
| 94 | 94 | const headers: Record<string, string> = key ? { authorization: `Bearer ${key}` } : {}; |
| 95 | 95 | const out: RawObservation[] = []; |
| 96 | 96 | // ~120 requests every 6 h through the 1.5 req/s bucket (≈ 80 s per poll) — far below quotas. |
| 97 | + // Continuous futures return the oldest rows first → ask from ~400 sessions back. | |
| 98 | + const from = new Date(ctx.now() - 560 * 86_400_000).toISOString().slice(0, 10); | |
| 97 | 99 | for (const t of TARGETS) { |
| 98 | 100 | try { |
| 99 | − const { data, response } = await ctx.http.getJson(`${BASE}${t.path}`, { headers, timeoutMs: 45_000 }); | |
| 101 | + const url = t.kind === "continuous" ? `${BASE}${t.path}&from=${from}` : `${BASE}${t.path}`; | |
| 102 | + const { data, response } = await ctx.http.getJson(url, { headers, timeoutMs: 45_000 }); | |
| 100 | 103 | if (response.notModified) continue; |
| 101 | 104 | out.push(raw("hfmarketdata-daily", "hfmarketdata", t.kind, data, { symbol: t.symbol })); |
| 102 | 105 | } catch (err) { |
modified
connectors/src/nasdaq-market-calendar/index.ts
+3 −1
@@ -67,7 +67,9 @@ export const nasdaqMarketCalendar = defineConnector({ | ||
| 67 | 67 | if (early[3]!.toLowerCase() === "p" && h < 12) h += 12; |
| 68 | 68 | closeTime = `${String(h).padStart(2, "0")}:${early[2] ?? "00"}`; |
| 69 | 69 | } |
| 70 | − for (const ex of US_EXCHANGES) holidays.push({ exchangeId: ex, date, name: kind === "EARLY_CLOSE" ? `${name.replace(/early close/i, "").trim() || "Early close"} (early close)` : name, kind, closeTime }); | |
| 70 | + const cleaned = name.replace(/early close/i, "").replace(/^[\s\-–]+|[\s\-–]+$/g, "").trim(); | |
| 71 | + const label = kind === "EARLY_CLOSE" ? (cleaned && !/^u\.s\.?$/i.test(cleaned) ? `${cleaned} (early close)` : "Early close (U.S. markets)") : name; | |
| 72 | + for (const ex of US_EXCHANGES) holidays.push({ exchangeId: ex, date, name: label, kind, closeTime }); | |
| 71 | 73 | } |
| 72 | 74 | } |
| 73 | 75 | const events: ProposedEvent[] = []; |
added
docs/FRONTEND.md
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +# Market Atlas — Frontend (`apps/web`) | |
| 2 | + | |
| 3 | +Next.js 16.3 (App Router, React 19.2, Tailwind v4, TypeScript strict). Package `@market-atlas/web`. | |
| 4 | + | |
| 5 | +## Run | |
| 6 | + | |
| 7 | +```bash | |
| 8 | +# dev (API must run on :8391 — `pnpm dev:api` at the repo root) | |
| 9 | +pnpm --filter @market-atlas/web dev # http://localhost:8390 | |
| 10 | +pnpm --filter @market-atlas/web typecheck | |
| 11 | +pnpm --filter @market-atlas/web build # NEXT_DIST_DIR=.next-build to build while dev runs | |
| 12 | +pnpm --filter @market-atlas/web start # next start -p 8382 -H 127.0.0.1 (production, behind the API edge) | |
| 13 | +node apps/web/qa/screens.mjs [BASE_URL] # Playwright sweep: 30 routes × {390,1440} × {dark,light}: status, console errors, overflow, screenshots | |
| 14 | +``` | |
| 15 | + | |
| 16 | +Environment (root `.env` is loaded by `next.config.ts`): `API_URL` (server-side base, default `http://127.0.0.1:8391`), `NEXT_PUBLIC_SITE_URL` / `MA_SITE_URL` (canonical URLs, default `https://www.market-atlas.co`), optional `NEXT_PUBLIC_WS_URL` (browser WebSocket URL; derived automatically from `API_URL` in dev, same-origin `/v1/stream` in production), `NEXT_DIST_DIR`. | |
| 17 | + | |
| 18 | +## Data flow | |
| 19 | + | |
| 20 | +- **Server components** call `api<T>(path)` / `apiEnvelope<T>` / `apiOptional<T>` from `src/lib/api.ts` against `API_URL` (no-store). A 404 from the API becomes Next `notFound()`; other errors bubble to `error.tsx`. `apiOptional` returns `null` for non-critical widgets (health panel, changes, events on the home page). | |
| 21 | +- **Browser code** always calls same-origin `/v1/...` (`src/lib/client-api.ts`). In dev `next.config.ts` rewrites `/v1/:path*` → `API_URL`; in production the API process is the public edge (`:8380`): it serves `/v1/*` itself and proxies every other path to Next on `127.0.0.1:8382`. | |
| 22 | +- **Live stream** — `src/lib/stream.ts`: one `WebSocket` per tab (`MarketStreamClient` singleton), reference-counted channel subscriptions (`subscribe` / `unsubscribe` messages), reconnect with jittered backoff, batches parsed into a quote store keyed by instrument id plus an events ring buffer and a tape ring buffer. Listeners are notified at most every 100 ms and only for the keys that changed, so a table never re-renders per tick. Hooks: `useMarketStream(channels)` (keeps channels subscribed while mounted; returns connection state), `useLiveQuote(id)` (`useSyncExternalStore` per instrument), `useLiveEvents(max)`, `useTape(max)`, `useNow(ms)`. | |
| 23 | +- Components merge SSR quotes with the stream: a streamed value is used only when its `received` timestamp is newer than the server quote's `updated_at` (`LivePrice`, `InstrumentHeader`, `InstrumentStats`, `InstrumentTable` cells). Price cells flash subtly on change (`.flash-up/.flash-down`, 600 ms, disabled under reduced motion). | |
| 24 | + | |
| 25 | +## Routes | |
| 26 | + | |
| 27 | +| Route | Data | Notes | | |
| 28 | +| --- | --- | --- | | |
| 29 | +| `/` | `/v1/stats`, `/v1/markets`, `/v1/events`, `/v1/data-health`, `/v1/changes?window=1h` | hero + telemetry strip (polls stats every 5 s, animated counters), pulse rows, live tape (`tape`), live events (`events:*`), world map, movers, source health, what changed | | |
| 30 | +| `/live` | `/v1/events?limit=100` + stream `events:*`, `tape` | filters: asset class, country, event types, severity, min confidence, include quotes; pause/resume freezes the list | | |
| 31 | +| `/markets`, `/stocks`, `/etfs`, `/indices`, `/crypto`, `/forex`, `/rates`, `/commodities` | `/v1/markets`, `/v1/instruments?asset_class=…"ed=1&sort=…` | `ClassPage` (server) → `InstrumentTable` (client, sortable, live via `quotes:class:<CLASS>`); sort/page/q/all in search params | | |
| 32 | +| `/instruments/[id]` (+ `/stocks/[symbol]`, `/crypto/[symbol]`, `/etfs/…`, `/indices/…`, `/forex/…` redirects) | `/v1/instruments/:id`, `/v1/history/:id`, `/v1/quotes/:id/provenance`, `/v1/events?instrument=`, `/v1/filings?cik=` | sticky live header, lightweight-charts (1D/5D intraday from consensus bars + live updates; 1M…MAX daily), key stats, **Why this price?** panel (refreshes every 5 s), events, filings, venue, company, aliases, related; JSON-LD `FinancialProduct` | | |
| 33 | +| `/exchanges`, `/exchanges/[id]` | `/v1/exchanges`, `/v1/exchanges/:id` | map + table; detail: state, next transition, sessions, holidays, breadth, gainers/losers/most active, events | | |
| 34 | +| `/countries`, `/countries/[code]` | `/v1/countries`, `/v1/countries/:code` | atlas by region; detail: venues, indices, rates, FX pairs, equities, events | | |
| 35 | +| `/events`, `/events/[id]`, `/halts`, `/filings` | `/v1/events`, `/v1/events/:id`, `/v1/filings` | server-filtered tables (type, severity, instrument, country, before-cursor); halts page streams `events:type:TRADING_HALT` | | |
| 36 | +| `/sources`, `/connectors`, `/data-health`, `/status` | `/v1/sources`, `/v1/connectors`, `/v1/data-health`, `/v1/status` | provenance directory (rights + real-time badges, no endpoints), operations table, health with 48 h sparklines and incidents, public status | | |
| 37 | +| `/search?q=` + ⌘K palette | `/v1/search` | grouped results; palette debounced 140 ms, keyboard navigation | | |
| 38 | +| `/compare?ids=&resolution=` | `/v1/compare` | rebased lines (lightweight-charts), stats table, correlation matrix, instrument picker via search | | |
| 39 | +| `/methodology`, `/developers`, `/licensing` | static (+ `/v1/sources` for attributions) | copy consistent with `apps/api/src/core/consensus.ts` | | |
| 40 | +| `/admin` | `/v1/admin/*` with `x-ma-admin-token` from `localStorage["ma-admin-token"]` | overview, connectors (pause/resume/restart/test, detail with health history/fingerprints/schema-change ack/recent observations/state), schema changes, divergence, storage, discovery probe | | |
| 41 | +| `robots.ts`, `sitemap.ts` (segmented: static, exchanges, countries, per asset class), `manifest.ts`, `icon.svg`, `not-found.tsx`, `error.tsx`, segment `loading.tsx` (markets/events/filings) | | root `loading.tsx` deliberately absent so `notFound()` returns a real 404 status | | |
| 42 | + | |
| 43 | +## Components | |
| 44 | + | |
| 45 | +- `components/layout`: `SiteHeader` (desktop nav, ⌘K button, stream indicator, theme toggle, market clock strip NY/London/Frankfurt/Tokyo/HK/Sydney with session dots), `MobileTabBar` (Markets · Live · Search · Events · World), `SiteFooter` (disclaimer, attributions), `SearchProvider`/`SearchDialog`, `ThemeToggle` (persists `ma-theme`; `THEME_SCRIPT` applies it before paint), `LogoMark`/`Wordmark`. | |
| 46 | +- `components/ui`: `StatusBadge` (data status / connector state / market state / severity tones), `RightsBadge`, `LivePrice`/`ChangeCell`, `FreshnessLabel` ("Updated 320 ms ago" · "Delayed 15 min · 12s ago" · "At close · Fri 16:00 EDT" · "End of day · Sep 11" · "Stale · last update 4h ago" · "Withheld"), `RelativeTime`, `ConfidenceMeter`, `DataTable` (client, sortable, sticky header, responsive hide), `Sparkline`, `Page`/`PageHeader`/`Section`/`Stat`/`Kv`/`Pill`/`Empty`. | |
| 47 | +- `components/market`: `TelemetryStrip`, `PulseRow`, `LiveTape`, `LiveEvents`, `EventRow`, `LiveFeed`, `InstrumentTable`, `ClassPage`, `InstrumentHeader`, `InstrumentStats`, `PriceChart`, `ProvenancePanel`, `CompareView`, `WorldMap` (d3-geo Natural Earth + world-atlas countries-110m, loaded lazily), `HealthPanel`, `ChangesPanel`. | |
| 48 | +- `components/admin/AdminConsole`. | |
| 49 | + | |
| 50 | +## Conventions | |
| 51 | + | |
| 52 | +- Design tokens live in `globals.css` (`--canvas/--surface/--ink/--rule/--accent/--positive/--negative/--warning/--stale`, light + dark on `html[data-theme]`); Tailwind utilities map through `@theme inline` (`bg-surface`, `text-ink-2`, `border-rule`…). Numbers use `.mono`/`.tnum`. Dense tables use `.table-dense`. | |
| 53 | +- Formatting (`src/lib/format.ts`): price precision by magnitude and asset class (FX 4–5 decimals, yields `x.xx%`, crypto sub-cent), compact volumes, signed percents with `−`, `toMs()` parses ISO / epoch / Postgres text timestamps (`2026-09-11 19:50:00-04`). | |
| 54 | +- Never present stale as live: `data_status` drives every badge and freshness label; `withheld` quotes render provenance but no value. | |
| 55 | +- Responsive grids must declare base columns (`grid grid-cols-1 [&>*]:min-w-0 lg:grid-cols-…`) — implicit `auto` columns caused horizontal overflow at 390 px. QA script asserts `scrollWidth ≤ clientWidth`. | |
| 56 | +- Server components never import `stream.ts`; client components never import `lib/api.ts` (`server-only`). | |
modified
pnpm-lock.yaml
+3 −0
@@ -118,6 +118,9 @@ importers: | ||
| 118 | 118 | '@types/topojson-client': |
| 119 | 119 | specifier: ^3.1.5 |
| 120 | 120 | version: 3.1.5 |
| 121 | + '@types/topojson-specification': | |
| 122 | + specifier: ^1.0.5 | |
| 123 | + version: 1.0.5 | |
| 121 | 124 | tailwindcss: |
| 122 | 125 | specifier: ^4 |
| 123 | 126 | version: 4.3.3 |
| 124 | 127 | |