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 | MA_RAW_SAMPLE_RATE=0.02 # share of streaming frames archived at L0 (bulk/poll payloads are always archived) | 14 | MA_RAW_SAMPLE_RATE=0.02 # share of streaming frames archived at L0 (bulk/poll payloads are always archived) | ||
| 15 | MA_OBSERVATION_RETENTION_DAYS=45 | 15 | MA_OBSERVATION_RETENTION_DAYS=45 | ||
| 16 | MA_TICK_PERSIST_INTERVAL_MS=2000 # store at most one REALTIME tick per source/instrument/field per 2 s (consensus sees every tick) | 16 | MA_TICK_PERSIST_INTERVAL_MS=2000 # store at most one REALTIME tick per source/instrument/field per 2 s (consensus sees every tick) | ||
| 17 | +MA_WEB_UPSTREAM= # production edge mode: proxy every non-/v1 path to the Next.js app (http://127.0.0.1:8382) | ||||
modified
.gitignore
+2 −0
@@ -17,3 +17,5 @@ deploy/.hfmd-key | |||||
| 17 | deploy/rendered/ | 17 | deploy/rendered/ | ||
| 18 | apps/web/qa/screens/ | 18 | apps/web/qa/screens/ | ||
| 19 | coverage/ | 19 | coverage/ | ||
| 20 | +apps/web/AGENTS.md | ||||
| 21 | +apps/web/CLAUDE.md | ||||
modified
apps/api/src/api/routes/admin.ts
+2 −0
@@ -79,6 +79,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { | |||||
| 79 | symbols: rt.symbols, | 79 | symbols: rt.symbols, | ||
| 80 | schedule: rt.def.schedule ?? null, | 80 | schedule: rt.def.schedule ?? null, | ||
| 81 | state: redactObject(rt.stateCache), | 81 | state: redactObject(rt.stateCache), | ||
| 82 | + consecutive_failures: rt.consecutiveFailures, | ||||
| 83 | + drift_strikes: rt.driftStrikes, | ||||
| 82 | schema_changes: changes.rows, | 84 | schema_changes: changes.rows, | ||
| 83 | health_history: healthHistory.rows, | 85 | health_history: healthHistory.rows, | ||
| 84 | recent_observations: recent.rows, | 86 | recent_observations: recent.rows, | ||
modified
apps/api/src/db/pool.ts
+1 −2
@@ -3,8 +3,7 @@ import { config } from "../config.js"; | |||||
| 3 | import { logger } from "../logger.js"; | 3 | import { logger } from "../logger.js"; | ||
| 4 | 4 | ||||
| 5 | const { Pool, types } = pg; | 5 | const { Pool, types } = pg; | ||
| 6 | −// Keep timestamptz as ISO strings → we convert to ms explicitly; numeric/int8 as numbers where safe. | 6 | +// timestamptz stays a Date (pg default → ISO in JSON); numeric/int8 as numbers where safe. | ||
| 7 | −types.setTypeParser(1184, (v) => v); | ||||
| 8 | types.setTypeParser(20, (v) => Number(v)); | 7 | types.setTypeParser(20, (v) => Number(v)); | ||
| 9 | types.setTypeParser(1700, (v) => Number(v)); | 8 | types.setTypeParser(1700, (v) => Number(v)); | ||
| 10 | 9 | ||||
modified
apps/web/package.json
+1 −0
@@ -28,6 +28,7 @@ | |||||
| 28 | "@types/react": "^19", | 28 | "@types/react": "^19", | ||
| 29 | "@types/react-dom": "^19", | 29 | "@types/react-dom": "^19", | ||
| 30 | "@types/topojson-client": "^3.1.5", | 30 | "@types/topojson-client": "^3.1.5", | ||
| 31 | + "@types/topojson-specification": "^1.0.5", | ||||
| 31 | "tailwindcss": "^4", | 32 | "tailwindcss": "^4", | ||
| 32 | "typescript": "^5.9.3" | 33 | "typescript": "^5.9.3" | ||
| 33 | } | 34 | } | ||
added
apps/web/qa/screens.mjs
+45 −0
@@ -0,0 +1,45 @@ | |||||
| 1 | +/** | ||||
| 2 | + * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot. | ||||
| 3 | + * Run: node qa/screens.mjs [BASE_URL] (default http://localhost:8390) | ||||
| 4 | + * Playwright is resolved from the uqo-eval project on this machine (see import below); adjust if needed. | ||||
| 5 | + */ | ||||
| 6 | +import { chromium } from "/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs"; | ||||
| 7 | +import { mkdirSync } from "node:fs"; | ||||
| 8 | + | ||||
| 9 | +const BASE = process.argv[2] ?? "http://localhost:8390"; | ||||
| 10 | +const OUT = new URL("./screens/", import.meta.url).pathname; | ||||
| 11 | +mkdirSync(OUT, { recursive: true }); | ||||
| 12 | + | ||||
| 13 | +const PAGES = ["/", "/live", "/markets", "/crypto", "/stocks", "/indices", "/forex", "/rates", "/commodities", "/instruments/crypto_btc_usd", "/instruments/AAPL", "/instruments/US10Y", "/exchanges", "/exchanges/xnas", "/countries", "/countries/CA", "/events", "/halts", "/filings", "/sources", "/connectors", "/data-health", "/search?q=bitcoin", "/compare?ids=crypto_btc_usd,crypto_eth_usd", "/status", "/methodology", "/developers", "/licensing", "/admin", "/does-not-exist"]; | ||||
| 14 | +const WIDTHS = [390, 1440]; | ||||
| 15 | +const THEMES = ["dark", "light"]; | ||||
| 16 | + | ||||
| 17 | +const browser = await chromium.launch(); | ||||
| 18 | +let failures = 0; | ||||
| 19 | +for (const theme of THEMES) { | ||||
| 20 | + for (const width of WIDTHS) { | ||||
| 21 | + const ctx = await browser.newContext({ viewport: { width, height: width < 600 ? 844 : 900 }, deviceScaleFactor: 1 }); | ||||
| 22 | + await ctx.addInitScript((t) => localStorage.setItem("ma-theme", t), theme); | ||||
| 23 | + for (const path of PAGES) { | ||||
| 24 | + const page = await ctx.newPage(); | ||||
| 25 | + const errors = []; | ||||
| 26 | + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); | ||||
| 27 | + page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`)); | ||||
| 28 | + const res = await page.goto(BASE + path, { waitUntil: "networkidle", timeout: 60_000 }).catch((e) => ({ status: () => `ERR ${e.message}` })); | ||||
| 29 | + await page.waitForTimeout(1200); | ||||
| 30 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); | ||||
| 31 | + const status = res.status(); | ||||
| 32 | + const expected = path === "/does-not-exist" ? 404 : 200; | ||||
| 33 | + const errs = errors.filter((e) => !/favicon|404 \(Not Found\)|hydrat/i.test(e) || /Hydration failed|did not match/.test(e)); | ||||
| 34 | + const ok = status === expected && !overflow && errs.length === 0; | ||||
| 35 | + if (!ok) failures++; | ||||
| 36 | + console.log(`${ok ? "OK " : "FAIL"} ${theme.padEnd(5)} ${String(width).padEnd(4)} ${String(status).padEnd(4)} ${overflow ? "OVERFLOW " : ""}${path}${errs.length ? `\n ${errs.slice(0, 3).join("\n ")}` : ""}`); | ||||
| 37 | + await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "home"}.png`, fullPage: width >= 600 }); | ||||
| 38 | + await page.close(); | ||||
| 39 | + } | ||||
| 40 | + await ctx.close(); | ||||
| 41 | + } | ||||
| 42 | +} | ||||
| 43 | +await browser.close(); | ||||
| 44 | +console.log(failures ? `\n${failures} failing checks` : "\nall checks passed"); | ||||
| 45 | +process.exit(failures ? 1 : 0); | ||||
added
apps/web/src/app/admin/page.tsx
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { AdminConsole } from "@/components/admin/admin-console"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { title: "Admin", robots: { index: false, follow: false } }; | ||||
| 5 | + | ||||
| 6 | +export default function AdminPage() { | ||||
| 7 | + return <AdminConsole />; | ||||
| 8 | +} | ||||
added
apps/web/src/app/commodities/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { ClassPage } from "@/components/market/class-page"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { title: "Commodities & futures", description: "Continuous futures for gold, silver, crude oil, natural gas, copper, grains and index futures.", alternates: { canonical: "/commodities" } }; | ||||
| 5 | +export const dynamic = "force-dynamic"; | ||||
| 6 | + | ||||
| 7 | +export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | ||||
| 8 | + return <ClassPage classes={["COMMODITY", "FUTURE"]} kicker="Derivatives" title="Commodities & futures" lead="CME continuous front-month series (volume roll, unadjusted) from the HF Market Data lake — end-of-day settlements, labelled accordingly." basePath="/commodities" searchParams={searchParams} showExchange={false} defaultSort="symbol" />; | ||||
| 9 | +} | ||||
added
apps/web/src/app/compare/page.tsx
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { CompareView } from "@/components/market/compare-view"; | ||||
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | ||||
| 4 | + | ||||
| 5 | +export const metadata: Metadata = { title: "Compare", description: "Compare instruments across asset classes: normalized performance, volatility, drawdown and correlation.", alternates: { canonical: "/compare" } }; | ||||
| 6 | +export const dynamic = "force-dynamic"; | ||||
| 7 | + | ||||
| 8 | +export default async function ComparePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | ||||
| 9 | + const sp = await searchParams; | ||||
| 10 | + const ids = (typeof sp.ids === "string" ? sp.ids : "crypto_btc_usd,crypto_eth_usd,eq_us_xnas_aapl,index_us_spx").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 8); | ||||
| 11 | + const resolution = typeof sp.resolution === "string" && ["1m", "1h", "1d"].includes(sp.resolution) ? (sp.resolution as "1m" | "1h" | "1d") : "1d"; | ||||
| 12 | + return ( | ||||
| 13 | + <Page wide> | ||||
| 14 | + <PageHeader kicker="Analysis" title="Compare" lead="Rebased performance (first point = 100), realized volatility, maximum drawdown and pairwise correlation of returns on aligned timestamps. Derived metrics computed by Market Atlas from canonical bars." /> | ||||
| 15 | + <CompareView initialIds={ids} initialResolution={resolution} /> | ||||
| 16 | + </Page> | ||||
| 17 | + ); | ||||
| 18 | +} | ||||
added
apps/web/src/app/connectors/page.tsx
+76 −0
@@ -0,0 +1,76 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { RelativeTime } from "@/components/ui/freshness"; | ||||
| 4 | +import { Page, PageHeader } from "@/components/ui/section"; | ||||
| 5 | +import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; | ||||
| 6 | +import { api } from "@/lib/api"; | ||||
| 7 | +import { formatDuration } from "@/lib/format"; | ||||
| 8 | +import type { ConnectorPublic } from "@/lib/types"; | ||||
| 9 | + | ||||
| 10 | +export const metadata: Metadata = { title: "Connectors", description: "Operational status of every Market Atlas connector: type, rights, message rate, latency, coverage and reliability.", alternates: { canonical: "/connectors" } }; | ||||
| 11 | +export const dynamic = "force-dynamic"; | ||||
| 12 | + | ||||
| 13 | +export default async function ConnectorsPage() { | ||||
| 14 | + const rows = await api<ConnectorPublic[]>("/v1/connectors"); | ||||
| 15 | + const healthy = rows.filter((r) => r.status === "HEALTHY").length; | ||||
| 16 | + return ( | ||||
| 17 | + <Page wide> | ||||
| 18 | + <PageHeader kicker="Operations" title="Connectors" lead={`${rows.length} connectors, ${healthy} healthy. A connector is one technical integration of a source (WebSocket feed, JSON endpoint, official file, RSS, HTML page…). The framework proves it generalizes across ${new Set(rows.map((r) => r.source_type)).size} transport types.`} actions={<Link href="/data-health" className="text-sm text-accent hover:underline">Data health →</Link>} /> | ||||
| 19 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | ||||
| 20 | + <table className="table-dense"> | ||||
| 21 | + <thead> | ||||
| 22 | + <tr> | ||||
| 23 | + <th>Connector</th> | ||||
| 24 | + <th>Status</th> | ||||
| 25 | + <th>Type</th> | ||||
| 26 | + <th>Rights</th> | ||||
| 27 | + <th className="hidden md:table-cell">Real time</th> | ||||
| 28 | + <th className="text-right">Msg / min</th> | ||||
| 29 | + <th className="text-right">Total</th> | ||||
| 30 | + <th className="hidden text-right lg:table-cell">p50 / p95 latency</th> | ||||
| 31 | + <th className="hidden text-right lg:table-cell">Parse</th> | ||||
| 32 | + <th className="text-right">Instruments</th> | ||||
| 33 | + <th className="text-right">Score</th> | ||||
| 34 | + <th>Last message</th> | ||||
| 35 | + <th className="hidden xl:table-cell">Next poll</th> | ||||
| 36 | + </tr> | ||||
| 37 | + </thead> | ||||
| 38 | + <tbody> | ||||
| 39 | + {rows.map((r) => ( | ||||
| 40 | + <tr key={r.id}> | ||||
| 41 | + <td className="max-w-[340px] whitespace-normal"> | ||||
| 42 | + <div className="font-medium">{r.name}</div> | ||||
| 43 | + <div className="mono text-[11px] text-ink-3"> | ||||
| 44 | + {r.id} · v{r.version} ·{" "} | ||||
| 45 | + <Link href={`/sources#${r.source_id}`} className="hover:underline"> | ||||
| 46 | + {r.source_id} | ||||
| 47 | + </Link> | ||||
| 48 | + </div> | ||||
| 49 | + <div className="mt-0.5 text-xs text-ink-2">{r.description}</div> | ||||
| 50 | + </td> | ||||
| 51 | + <td> | ||||
| 52 | + <StatusBadge status={r.status} /> | ||||
| 53 | + </td> | ||||
| 54 | + <td className="mono text-xs">{r.source_type}</td> | ||||
| 55 | + <td> | ||||
| 56 | + <RightsBadge status={r.rights_status} /> | ||||
| 57 | + </td> | ||||
| 58 | + <td className="hidden text-xs text-ink-2 md:table-cell">{r.realtime_status.replace(/_/g, " ").toLowerCase()}</td> | ||||
| 59 | + <td className="num">{r.messages_1m}</td> | ||||
| 60 | + <td className="num">{r.messages_total.toLocaleString("en-US")}</td> | ||||
| 61 | + <td className="num hidden lg:table-cell"> | ||||
| 62 | + {r.median_latency_ms == null ? "—" : formatDuration(r.median_latency_ms)} / {r.p95_latency_ms == null ? "—" : formatDuration(r.p95_latency_ms)} | ||||
| 63 | + </td> | ||||
| 64 | + <td className="num hidden lg:table-cell">{r.parse_success_rate == null ? "—" : `${Math.round(r.parse_success_rate * 100)}%`}</td> | ||||
| 65 | + <td className="num">{r.instruments_covered}</td> | ||||
| 66 | + <td className="num">{r.reliability_score ?? "—"}</td> | ||||
| 67 | + <td className="text-xs text-ink-3">{r.last_message_at ? <RelativeTime value={r.last_message_at} /> : "—"}</td> | ||||
| 68 | + <td className="hidden text-xs text-ink-3 xl:table-cell">{r.next_poll_at ? <RelativeTime value={r.next_poll_at} /> : r.supports_streaming ? "streaming" : "—"}</td> | ||||
| 69 | + </tr> | ||||
| 70 | + ))} | ||||
| 71 | + </tbody> | ||||
| 72 | + </table> | ||||
| 73 | + </div> | ||||
| 74 | + </Page> | ||||
| 75 | + ); | ||||
| 76 | +} | ||||
added
apps/web/src/app/countries/[code]/page.tsx
+103 −0
@@ -0,0 +1,103 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { EventRow } from "@/components/market/event-row"; | ||||
| 4 | +import { InstrumentTable } from "@/components/market/instrument-table"; | ||||
| 5 | +import { Empty, Page, PageHeader, Section, Stat } from "@/components/ui/section"; | ||||
| 6 | +import { StatusBadge } from "@/components/ui/status-badge"; | ||||
| 7 | +import { api } from "@/lib/api"; | ||||
| 8 | +import type { Breadth, Country, Exchange, ExchangeStatus, InstrumentWithQuote, MarketEvent } from "@/lib/types"; | ||||
| 9 | + | ||||
| 10 | +interface Detail { | ||||
| 11 | + country: Country; | ||||
| 12 | + exchanges: Array<Exchange & { status: ExchangeStatus }>; | ||||
| 13 | + indices: InstrumentWithQuote[]; | ||||
| 14 | + rates: InstrumentWithQuote[]; | ||||
| 15 | + fx: InstrumentWithQuote[]; | ||||
| 16 | + equities: InstrumentWithQuote[]; | ||||
| 17 | + breadth: Breadth | null; | ||||
| 18 | + events: MarketEvent[]; | ||||
| 19 | + instruments_tracked: number; | ||||
| 20 | +} | ||||
| 21 | + | ||||
| 22 | +export const dynamic = "force-dynamic"; | ||||
| 23 | + | ||||
| 24 | +export async function generateMetadata({ params }: { params: Promise<{ code: string }> }): Promise<Metadata> { | ||||
| 25 | + const { code } = await params; | ||||
| 26 | + try { | ||||
| 27 | + const d = await api<Detail>(`/v1/countries/${encodeURIComponent(code)}`); | ||||
| 28 | + return { title: `${d.country.name} — markets`, description: `${d.country.name}: exchanges, indices, rates, ${d.country.currency ?? "currency"} pairs and the equities Market Atlas observes.`, alternates: { canonical: `/countries/${d.country.code}` } }; | ||||
| 29 | + } catch { | ||||
| 30 | + return { title: "Country" }; | ||||
| 31 | + } | ||||
| 32 | +} | ||||
| 33 | + | ||||
| 34 | +export default async function CountryPage({ params }: { params: Promise<{ code: string }> }) { | ||||
| 35 | + const { code } = await params; | ||||
| 36 | + const d = await api<Detail>(`/v1/countries/${encodeURIComponent(code)}`); | ||||
| 37 | + const c = d.country; | ||||
| 38 | + const open = d.exchanges.filter((e) => e.status.state === "OPEN").length; | ||||
| 39 | + return ( | ||||
| 40 | + <Page wide> | ||||
| 41 | + <PageHeader | ||||
| 42 | + kicker={ | ||||
| 43 | + <Link href="/countries" className="hover:text-ink"> | ||||
| 44 | + Countries · {c.region} | ||||
| 45 | + </Link> | ||||
| 46 | + } | ||||
| 47 | + title={c.name} | ||||
| 48 | + lead={`${c.currency ? `Currency ${c.currency} · ` : ""}${d.exchanges.length} venue${d.exchanges.length === 1 ? "" : "s"} in the atlas · ${d.instruments_tracked.toLocaleString("en-US")} instruments tracked.`} | ||||
| 49 | + /> | ||||
| 50 | + <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4"> | ||||
| 51 | + <Stat label="Venues open" value={`${open} / ${d.exchanges.length}`} /> | ||||
| 52 | + <Stat label="Indices" value={d.indices.length} sub="with canonical level" /> | ||||
| 53 | + <Stat label="Rates & yields" value={d.rates.length} sub="official series" /> | ||||
| 54 | + <Stat label="Equity breadth" value={d.breadth ? `${d.breadth.advancers} ▲ ${d.breadth.decliners} ▼` : "—"} sub={d.breadth ? `${d.breadth.instruments} quoted` : "no quoted equities"} /> | ||||
| 55 | + </div> | ||||
| 56 | + <Section title="Exchanges" href="/exchanges"> | ||||
| 57 | + {d.exchanges.length ? ( | ||||
| 58 | + <ul className="grid grid-cols-1 [&>*]:min-w-0 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | ||||
| 59 | + {d.exchanges.map((e) => ( | ||||
| 60 | + <li key={e.id} className="rounded-md border border-rule bg-surface px-3 py-2.5"> | ||||
| 61 | + <Link href={`/exchanges/${e.id}`} className="font-medium hover:underline"> | ||||
| 62 | + {e.name} | ||||
| 63 | + </Link> | ||||
| 64 | + <div className="mt-1 flex items-center gap-2 text-xs text-ink-3"> | ||||
| 65 | + <StatusBadge status={e.status.state} /> <span className="mono">{e.status.localTime}</span> local · {e.mic ?? e.id} | ||||
| 66 | + </div> | ||||
| 67 | + </li> | ||||
| 68 | + ))} | ||||
| 69 | + </ul> | ||||
| 70 | + ) : ( | ||||
| 71 | + <Empty>No venue of {c.name} is in the atlas yet.</Empty> | ||||
| 72 | + )} | ||||
| 73 | + </Section> | ||||
| 74 | + {d.indices.length > 0 && ( | ||||
| 75 | + <Section title="Indices" href="/indices"> | ||||
| 76 | + <InstrumentTable rows={d.indices} compact showExchange={false} /> | ||||
| 77 | + </Section> | ||||
| 78 | + )} | ||||
| 79 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-2"> | ||||
| 80 | + <Section title="Rates & yields" href="/rates" hint="percent · official"> | ||||
| 81 | + <InstrumentTable rows={d.rates} compact showExchange={false} defaultSort="symbol" emptyText={`No official rate series for ${c.name} yet.`} /> | ||||
| 82 | + </Section> | ||||
| 83 | + <Section title={`${c.currency ?? "Currency"} pairs`} href="/forex" hint="reference rates"> | ||||
| 84 | + <InstrumentTable rows={d.fx} compact showExchange={false} defaultSort="symbol" emptyText="No currency pairs yet." /> | ||||
| 85 | + </Section> | ||||
| 86 | + </div> | ||||
| 87 | + <Section title="Equities" href={`/stocks`} hint="most active among tracked names"> | ||||
| 88 | + <InstrumentTable rows={d.equities} compact emptyText={`No quoted equities for ${c.name} yet — the instrument master lists them but no source publishes their prices.`} /> | ||||
| 89 | + </Section> | ||||
| 90 | + <Section title="Events"> | ||||
| 91 | + {d.events.length ? ( | ||||
| 92 | + <ul className="rounded-md border border-rule bg-surface px-3"> | ||||
| 93 | + {d.events.map((e) => ( | ||||
| 94 | + <EventRow key={e.id} e={e} /> | ||||
| 95 | + ))} | ||||
| 96 | + </ul> | ||||
| 97 | + ) : ( | ||||
| 98 | + <Empty>No events touching {c.name} instruments yet.</Empty> | ||||
| 99 | + )} | ||||
| 100 | + </Section> | ||||
| 101 | + </Page> | ||||
| 102 | + ); | ||||
| 103 | +} | ||||
added
apps/web/src/app/countries/page.tsx
+49 −0
@@ -0,0 +1,49 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { Page, PageHeader, Section } from "@/components/ui/section"; | ||||
| 4 | +import { StatusBadge } from "@/components/ui/status-badge"; | ||||
| 5 | +import { api } from "@/lib/api"; | ||||
| 6 | +import type { Country } from "@/lib/types"; | ||||
| 7 | + | ||||
| 8 | +type Row = Country & { exchanges: Array<{ id: string; name: string; status: string }>; instruments_tracked: number }; | ||||
| 9 | + | ||||
| 10 | +export const metadata: Metadata = { title: "Countries", description: "Market atlas by country: exchanges, indices, rates and currencies observed by Market Atlas.", alternates: { canonical: "/countries" } }; | ||||
| 11 | +export const dynamic = "force-dynamic"; | ||||
| 12 | + | ||||
| 13 | +export default async function CountriesPage() { | ||||
| 14 | + const rows = await api<Row[]>("/v1/countries"); | ||||
| 15 | + const regions = [...new Set(rows.map((r) => r.region))].sort(); | ||||
| 16 | + return ( | ||||
| 17 | + <Page wide> | ||||
| 18 | + <PageHeader kicker="World" title="Countries" lead="Every country in the atlas with its venues and what Market Atlas currently tracks there. Coverage is a measure of Market Atlas observation, not of market size." /> | ||||
| 19 | + {regions.map((region) => ( | ||||
| 20 | + <Section key={region} title={region}> | ||||
| 21 | + <ul className="grid grid-cols-1 [&>*]:min-w-0 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> | ||||
| 22 | + {rows | ||||
| 23 | + .filter((r) => r.region === region) | ||||
| 24 | + .sort((a, b) => b.instruments_tracked - a.instruments_tracked || a.name.localeCompare(b.name)) | ||||
| 25 | + .map((r) => ( | ||||
| 26 | + <li key={r.code} className="rounded-md border border-rule bg-surface px-3 py-2.5 hover:border-rule-strong"> | ||||
| 27 | + <Link href={`/countries/${r.code}`} className="flex items-baseline justify-between gap-2"> | ||||
| 28 | + <span className="font-medium">{r.name}</span> | ||||
| 29 | + <span className="mono text-xs text-ink-3"> | ||||
| 30 | + {r.code} · {r.currency ?? "—"} | ||||
| 31 | + </span> | ||||
| 32 | + </Link> | ||||
| 33 | + <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3"> | ||||
| 34 | + {r.exchanges.slice(0, 3).map((e) => ( | ||||
| 35 | + <span key={e.id} className="inline-flex items-center gap-1"> | ||||
| 36 | + <StatusBadge status={e.status} label={e.status.toLowerCase()} /> {e.name} | ||||
| 37 | + </span> | ||||
| 38 | + ))} | ||||
| 39 | + {!r.exchanges.length && <span>no venue in the atlas</span>} | ||||
| 40 | + </div> | ||||
| 41 | + <div className="mono mt-1 text-[11px] text-ink-3">{r.instruments_tracked.toLocaleString("en-US")} instruments tracked</div> | ||||
| 42 | + </li> | ||||
| 43 | + ))} | ||||
| 44 | + </ul> | ||||
| 45 | + </Section> | ||||
| 46 | + ))} | ||||
| 47 | + </Page> | ||||
| 48 | + ); | ||||
| 49 | +} | ||||
added
apps/web/src/app/crypto/[symbol]/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +import { permanentRedirect } from "next/navigation"; | ||||
| 2 | + | ||||
| 3 | +/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */ | ||||
| 4 | +export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) { | ||||
| 5 | + const { symbol } = await params; | ||||
| 6 | + permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`); | ||||
| 7 | +} | ||||
added
apps/web/src/app/crypto/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { ClassPage } from "@/components/market/class-page"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { title: "Crypto", description: "Live crypto prices as a consensus of Coinbase, Kraken, Binance and OKX public feeds.", alternates: { canonical: "/crypto" } }; | ||||
| 5 | +export const dynamic = "force-dynamic"; | ||||
| 6 | + | ||||
| 7 | +export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | ||||
| 8 | + return <ClassPage classes={["CRYPTO"]} kicker="Digital assets" title="Crypto" lead="Real-time spot prices formed from independent venue feeds (Coinbase, Kraken, Binance, OKX). USD and USDT pairs are distinct instruments; the dispersion between venues is visible in every provenance panel." basePath="/crypto" searchParams={searchParams} defaultSort="volume" />; | ||||
| 9 | +} | ||||
added
apps/web/src/app/data-health/page.tsx
+111 −0
@@ -0,0 +1,111 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { RelativeTime } from "@/components/ui/freshness"; | ||||
| 4 | +import { Empty, Page, PageHeader, Section, Stat } from "@/components/ui/section"; | ||||
| 5 | +import { Sparkline } from "@/components/ui/sparkline"; | ||||
| 6 | +import { StatusBadge } from "@/components/ui/status-badge"; | ||||
| 7 | +import { api } from "@/lib/api"; | ||||
| 8 | +import { formatDateTime, formatDuration } from "@/lib/format"; | ||||
| 9 | +import type { DataHealth } from "@/lib/types"; | ||||
| 10 | + | ||||
| 11 | +export const metadata: Metadata = { title: "Data health", description: "Live health of Market Atlas' observation network: connector states, freshness, multi-source coverage, confidence and incidents.", alternates: { canonical: "/data-health" } }; | ||||
| 12 | +export const dynamic = "force-dynamic"; | ||||
| 13 | + | ||||
| 14 | +export default async function DataHealthPage() { | ||||
| 15 | + const h = await api<DataHealth>("/v1/data-health"); | ||||
| 16 | + const c = h.connectors; | ||||
| 17 | + return ( | ||||
| 18 | + <Page wide> | ||||
| 19 | + <PageHeader kicker="Transparency" title="Data health" lead="How well Market Atlas is observing the market right now. These are operational metrics about our own network — freshness, redundancy, agreement, incidents — not statements about market quality." actions={<Link href="/status" className="text-sm text-accent hover:underline">Public status →</Link>} /> | ||||
| 20 | + <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-3 lg:grid-cols-6"> | ||||
| 21 | + <Stat label="Connectors healthy" value={h.healthy_ratio == null ? "—" : `${Math.round(h.healthy_ratio * 100)}%`} sub={`${c.healthy} / ${c.total}`} tone={h.healthy_ratio != null && h.healthy_ratio < 0.8 ? "warn" : "pos"} /> | ||||
| 22 | + <Stat label="Median freshness" value={formatDuration(h.median_freshness_ms)} sub={`p95 ${formatDuration(h.p95_freshness_ms)} · live quotes`} /> | ||||
| 23 | + <Stat label="Public quotes" value={h.quotes_public} sub={`${h.quotes_realtime} real time`} /> | ||||
| 24 | + <Stat label="Multi-source" value={h.quotes_public ? `${Math.round((h.multi_source_quotes / h.quotes_public) * 100)}%` : "—"} sub={`${h.multi_source_quotes} quotes with ≥ 2 independent sources`} /> | ||||
| 25 | + <Stat label="Mean confidence" value={h.mean_confidence == null ? "—" : `${Math.round(h.mean_confidence * 100)}%`} sub={`dispersion ${h.mean_dispersion_bps ?? "—"} bps`} /> | ||||
| 26 | + <Stat label="Observations / s" value={h.observations_per_sec.toFixed(1)} sub={`queue ${h.queue_depth}`} /> | ||||
| 27 | + </div> | ||||
| 28 | + <div className="mt-3 flex flex-wrap gap-3 text-xs text-ink-2"> | ||||
| 29 | + <span className="text-positive">{c.healthy} healthy</span> | ||||
| 30 | + <span className="text-warning">{c.degraded} degraded / stale</span> | ||||
| 31 | + <span className="text-accent">{c.recovering} recovering</span> | ||||
| 32 | + <span className="text-negative">{c.failed} failed</span> | ||||
| 33 | + <span className="text-stale">{c.paused} paused</span> | ||||
| 34 | + </div> | ||||
| 35 | + {h.history.length > 1 && ( | ||||
| 36 | + <Section title="Last 48 hours" hint="hourly averages from connector health snapshots"> | ||||
| 37 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 rounded-md border border-rule bg-surface p-4 sm:grid-cols-3"> | ||||
| 38 | + <Trend label="Healthy ratio" values={h.history.map((x) => x.healthy_ratio)} fmt={(v) => `${Math.round(v * 100)}%`} /> | ||||
| 39 | + <Trend label="Messages / hour" values={h.history.map((x) => x.messages)} fmt={(v) => Math.round(v).toLocaleString("en-US")} /> | ||||
| 40 | + <Trend label="Median latency" values={h.history.map((x) => x.latency_ms ?? 0)} fmt={(v) => formatDuration(v)} /> | ||||
| 41 | + </div> | ||||
| 42 | + </Section> | ||||
| 43 | + )} | ||||
| 44 | + <Section title="Connectors" href="/connectors"> | ||||
| 45 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | ||||
| 46 | + <table className="table-dense"> | ||||
| 47 | + <thead> | ||||
| 48 | + <tr> | ||||
| 49 | + <th>Connector</th> | ||||
| 50 | + <th>State</th> | ||||
| 51 | + <th className="text-right">Msg / min</th> | ||||
| 52 | + <th className="text-right">Median latency</th> | ||||
| 53 | + <th className="text-right">Instruments</th> | ||||
| 54 | + <th className="text-right">Reliability</th> | ||||
| 55 | + <th>Last message</th> | ||||
| 56 | + <th>Last error</th> | ||||
| 57 | + </tr> | ||||
| 58 | + </thead> | ||||
| 59 | + <tbody> | ||||
| 60 | + {h.by_connector.map((r) => ( | ||||
| 61 | + <tr key={r.id}> | ||||
| 62 | + <td className="mono">{r.id}</td> | ||||
| 63 | + <td> | ||||
| 64 | + <StatusBadge status={r.state} /> | ||||
| 65 | + </td> | ||||
| 66 | + <td className="num">{r.messages_1m}</td> | ||||
| 67 | + <td className="num">{r.median_latency_ms == null ? "—" : formatDuration(r.median_latency_ms)}</td> | ||||
| 68 | + <td className="num">{r.instruments}</td> | ||||
| 69 | + <td className="num">{r.reliability_score ?? "—"}</td> | ||||
| 70 | + <td className="text-xs text-ink-3">{r.last_message_at ? <RelativeTime value={r.last_message_at} /> : "—"}</td> | ||||
| 71 | + <td className="max-w-[320px] truncate text-xs text-ink-3" title={r.last_error ?? undefined}> | ||||
| 72 | + {r.last_error ?? "—"} | ||||
| 73 | + </td> | ||||
| 74 | + </tr> | ||||
| 75 | + ))} | ||||
| 76 | + </tbody> | ||||
| 77 | + </table> | ||||
| 78 | + </div> | ||||
| 79 | + </Section> | ||||
| 80 | + <Section title="Incidents · 7 days" hint="source failures, recoveries, schema drift, divergence"> | ||||
| 81 | + {h.incidents.length ? ( | ||||
| 82 | + <ul className="divide-y divide-rule rounded-md border border-rule bg-surface px-3"> | ||||
| 83 | + {h.incidents.map((i) => ( | ||||
| 84 | + <li key={i.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm"> | ||||
| 85 | + <span className="mono text-xs text-ink-3">{formatDateTime(i.ts, { seconds: true })}</span> | ||||
| 86 | + <StatusBadge status={i.severity} /> | ||||
| 87 | + <Link href={`/events/${i.id}`} className="hover:underline"> | ||||
| 88 | + {i.title} | ||||
| 89 | + </Link> | ||||
| 90 | + <span className="mono text-[11px] text-ink-3">{i.type}</span> | ||||
| 91 | + </li> | ||||
| 92 | + ))} | ||||
| 93 | + </ul> | ||||
| 94 | + ) : ( | ||||
| 95 | + <Empty>No source incidents in the last 7 days.</Empty> | ||||
| 96 | + )} | ||||
| 97 | + </Section> | ||||
| 98 | + </Page> | ||||
| 99 | + ); | ||||
| 100 | +} | ||||
| 101 | + | ||||
| 102 | +function Trend({ label, values, fmt }: { label: string; values: number[]; fmt: (v: number) => string }) { | ||||
| 103 | + const last = values[values.length - 1] ?? 0; | ||||
| 104 | + return ( | ||||
| 105 | + <div> | ||||
| 106 | + <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">{label}</div> | ||||
| 107 | + <div className="mono mt-0.5 text-xl font-semibold tnum">{fmt(last)}</div> | ||||
| 108 | + <Sparkline values={values} width={220} height={40} stroke="var(--accent)" className="mt-1 w-full" /> | ||||
| 109 | + </div> | ||||
| 110 | + ); | ||||
| 111 | +} | ||||
added
apps/web/src/app/developers/page.tsx
+149 −0
@@ -0,0 +1,149 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | ||||
| 4 | +import { SITE_URL } from "@/lib/site"; | ||||
| 5 | + | ||||
| 6 | +export const metadata: Metadata = { title: "Developers", description: "Market Atlas API: REST endpoints, WebSocket and SSE streams, envelopes, errors and rate limits.", alternates: { canonical: "/developers" } }; | ||||
| 7 | + | ||||
| 8 | +const REST: Array<[string, string]> = [ | ||||
| 9 | + ["GET /v1/stats", "Live telemetry (instruments, connectors, observations today, events, freshness)."], | ||||
| 10 | + ["GET /v1/markets", "Global overview: featured indices, crypto, FX, rates, commodities, equities, movers, exchanges with state, breadth."], | ||||
| 11 | + ["GET /v1/instruments?asset_class=&exchange=&country=&q="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 | width: 100%; | 271 | width: 100%; | ||
| 272 | font-size: 0.85rem; | 272 | font-size: 0.85rem; | ||
| 273 | margin: 0.5rem 0 1rem; | 273 | margin: 0.5rem 0 1rem; | ||
| 274 | + display: block; | ||||
| 275 | + overflow-x: auto; | ||||
| 276 | +} | ||||
| 277 | +.prose-ma table code { | ||||
| 278 | + white-space: nowrap; | ||||
| 279 | +} | ||||
| 280 | +.prose-ma td code { | ||||
| 281 | + white-space: normal; | ||||
| 282 | + word-break: break-word; | ||||
| 274 | } | 283 | } | ||
| 275 | .prose-ma th, | 284 | .prose-ma th, | ||
| 276 | .prose-ma td { | 285 | .prose-ma td { | ||
added
apps/web/src/app/halts/page.tsx
+19 −0
@@ -0,0 +1,19 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { LiveEvents } from "@/components/market/live-events"; | ||||
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | ||||
| 4 | +import { api } from "@/lib/api"; | ||||
| 5 | +import type { MarketEvent } from "@/lib/types"; | ||||
| 6 | + | ||||
| 7 | +export const metadata: Metadata = { title: "Trading halts", description: "Trading halts and resumptions across US listed markets, from the Nasdaq Trader halts feed.", alternates: { canonical: "/halts" } }; | ||||
| 8 | +export const dynamic = "force-dynamic"; | ||||
| 9 | + | ||||
| 10 | +export default async function HaltsPage() { | ||||
| 11 | + const events = await api<MarketEvent[]>("/v1/events?type=TRADING_HALT,TRADING_RESUME&limit=200"); | ||||
| 12 | + const halts = events.filter((e) => e.type === "TRADING_HALT").length; | ||||
| 13 | + return ( | ||||
| 14 | + <Page> | ||||
| 15 | + <PageHeader kicker="Events" title="Trading halts" lead={`Halts and resumptions published by Nasdaq for all US listing markets (reason codes decoded: news pending, LULD volatility pause, SEC suspension…). ${halts} halt${halts === 1 ? "" : "s"} in the current window; new ones stream in live.`} /> | ||||
| 16 | + <LiveEvents initial={events} channel="events:type:TRADING_HALT" max={200} types={["TRADING_HALT", "TRADING_RESUME"]} /> | ||||
| 17 | + </Page> | ||||
| 18 | + ); | ||||
| 19 | +} | ||||
added
apps/web/src/app/indices/[symbol]/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +import { permanentRedirect } from "next/navigation"; | ||||
| 2 | + | ||||
| 3 | +/** Friendly alias: /<class>/<SYMBOL> → canonical instrument page (the API resolves symbols and ids alike). */ | ||||
| 4 | +export default async function Alias({ params }: { params: Promise<{ symbol: string }> }) { | ||||
| 5 | + const { symbol } = await params; | ||||
| 6 | + permanentRedirect(`/instruments/${encodeURIComponent(symbol)}`); | ||||
| 7 | +} | ||||
added
apps/web/src/app/indices/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import { ClassPage } from "@/components/market/class-page"; | ||||
| 3 | + | ||||
| 4 | +export const metadata: Metadata = { title: "Indices", description: "Equity and volatility indices: S&P 500, Nasdaq-100, Dow Jones, Russell 2000, VIX.", alternates: { canonical: "/indices" } }; | ||||
| 5 | +export const dynamic = "force-dynamic"; | ||||
| 6 | + | ||||
| 7 | +export default function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { | ||||
| 8 | + return <ClassPage classes={["INDEX"]} kicker="Benchmarks" title="Indices" lead="Equity and volatility benchmarks. Index levels are delayed 15 minutes during the US session and shown at close otherwise." basePath="/indices" searchParams={searchParams} showExchange={false} />; | ||||
| 9 | +} | ||||
added
apps/web/src/app/instruments/[id]/page.tsx
+224 −0
@@ -0,0 +1,224 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { notFound } from "next/navigation"; | ||||
| 4 | +import { PriceChart } from "@/components/market/chart"; | ||||
| 5 | +import { EventRow } from "@/components/market/event-row"; | ||||
| 6 | +import { InstrumentHeader } from "@/components/market/instrument-header"; | ||||
| 7 | +import { InstrumentStats } from "@/components/market/instrument-stats"; | ||||
| 8 | +import { ProvenancePanel } from "@/components/market/provenance-panel"; | ||||
| 9 | +import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; | ||||
| 10 | +import { Empty, Kv, Section } from "@/components/ui/section"; | ||||
| 11 | +import { api, apiOptional } from "@/lib/api"; | ||||
| 12 | +import { ASSET_CLASS_LABEL, formatDateTime, instrumentHref } from "@/lib/format"; | ||||
| 13 | +import type { Exchange, Filing, Instrument, MarketEvent, Quote } from "@/lib/types"; | ||||
| 14 | + | ||||
| 15 | +export const dynamic = "force-dynamic"; | ||||
| 16 | + | ||||
| 17 | +interface Detail { | ||||
| 18 | + instrument: Instrument; | ||||
| 19 | + company: { id: string; name: string; cik: string | null; country: string | null; sector: string | null; industry: string | null; website: string | null } | null; | ||||
| 20 | + exchange: (Exchange & { status: NonNullable<Exchange["status"]> }) | null; | ||||
| 21 | + quote: Quote | null; | ||||
| 22 | + aliases: Array<{ alias: string; source_id: string | null }>; | ||||
| 23 | + sources: string[]; | ||||
| 24 | + related: Instrument[]; | ||||
| 25 | +} | ||||
| 26 | + | ||||
| 27 | +async function load(id: string) { | ||||
| 28 | + return api<Detail>(`/v1/instruments/${encodeURIComponent(id)}`); | ||||
| 29 | +} | ||||
| 30 | + | ||||
| 31 | +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { | ||||
| 32 | + const { id } = await params; | ||||
| 33 | + try { | ||||
| 34 | + const d = await load(id); | ||||
| 35 | + const i = d.instrument; | ||||
| 36 | + const title = `${i.symbol} · ${i.name}`; | ||||
| 37 | + const desc = `${i.name} (${i.symbol}) — ${ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class}${d.exchange ? ` on ${d.exchange.name}` : ""}. Canonical price, sources, confidence, history and events on Market Atlas.`; | ||||
| 38 | + return { title, description: desc, alternates: { canonical: instrumentHref(i.id) }, openGraph: { title, description: desc, type: "website" } }; | ||||
| 39 | + } catch { | ||||
| 40 | + return { title: "Instrument" }; | ||||
| 41 | + } | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +export default async function InstrumentPage({ params }: { params: Promise<{ id: string }> }) { | ||||
| 45 | + const { id } = await params; | ||||
| 46 | + const d = await load(id); | ||||
| 47 | + if (!d?.instrument) notFound(); | ||||
| 48 | + const i = d.instrument; | ||||
| 49 | + const [events, filings] = await Promise.all([ | ||||
| 50 | + apiOptional<MarketEvent[]>(`/v1/events?instrument=${encodeURIComponent(i.id)}&limit=25`), | ||||
| 51 | + d.company?.cik ? apiOptional<Filing[]>(`/v1/filings?cik=${encodeURIComponent(d.company.cik)}&limit=15`) : Promise.resolve(null), | ||||
| 52 | + ]); | ||||
| 53 | + const isRate = i.asset_class === "TREASURY" || i.asset_class === "INTEREST_RATE" || i.asset_class === "BOND"; | ||||
| 54 | + const jsonLd = { | ||||
| 55 | + "@context": "https://schema.org", | ||||
| 56 | + "@type": "FinancialProduct", | ||||
| 57 | + name: i.name, | ||||
| 58 | + tickerSymbol: i.symbol, | ||||
| 59 | + category: ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class, | ||||
| 60 | + url: `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.market-atlas.co"}${instrumentHref(i.id)}`, | ||||
| 61 | + ...(d.exchange ? { provider: { "@type": "Organization", name: d.exchange.name } } : {}), | ||||
| 62 | + }; | ||||
| 63 | + return ( | ||||
| 64 | + <div> | ||||
| 65 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> | ||||
| 66 | + <InstrumentHeader instrument={i} quote={d.quote} exchange={d.exchange} /> | ||||
| 67 | + <div className="mx-auto max-w-[1440px] px-3 py-5 sm:px-5"> | ||||
| 68 | + <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_360px]"> | ||||
| 69 | + <div className="min-w-0"> | ||||
| 70 | + <PriceChart instrumentId={i.id} assetClass={i.asset_class} defaultRange={i.asset_class === "CRYPTO" ? "1D" : "3M"} /> | ||||
| 71 | + <Section title="Key statistics" hint={isRate ? "percent" : d.quote?.currency ?? undefined}> | ||||
| 72 | + <InstrumentStats instrument={i} quote={d.quote} /> | ||||
| 73 | + </Section> | ||||
| 74 | + <Section title="Why this price?" hint="provenance, weights and consensus method"> | ||||
| 75 | + <ProvenancePanel instrumentId={i.id} assetClass={i.asset_class} defaultOpen /> | ||||
| 76 | + </Section> | ||||
| 77 | + <Section title="Recent events" href={`/events?instrument=${encodeURIComponent(i.id)}`} hint="canonical events touching this instrument"> | ||||
| 78 | + {events?.length ? ( | ||||
| 79 | + <ul className="rounded-md border border-rule bg-surface px-3"> | ||||
| 80 | + {events.map((e) => ( | ||||
| 81 | + <EventRow key={e.id} e={e} /> | ||||
| 82 | + ))} | ||||
| 83 | + </ul> | ||||
| 84 | + ) : ( | ||||
| 85 | + <Empty>No canonical events for {i.symbol} yet. Events are derived from observed moves, halts, filings and source incidents.</Empty> | ||||
| 86 | + )} | ||||
| 87 | + </Section> | ||||
| 88 | + {d.company?.cik && ( | ||||
| 89 | + <Section title="Regulatory filings" href={`/filings?cik=${d.company.cik}`} hint="SEC EDGAR · linked by CIK"> | ||||
| 90 | + {filings?.length ? ( | ||||
| 91 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> | ||||
| 92 | + <table className="table-dense"> | ||||
| 93 | + <thead> | ||||
| 94 | + <tr> | ||||
| 95 | + <th>Filed</th> | ||||
| 96 | + <th>Form</th> | ||||
| 97 | + <th>Filer</th> | ||||
| 98 | + <th>Document</th> | ||||
| 99 | + </tr> | ||||
| 100 | + </thead> | ||||
| 101 | + <tbody> | ||||
| 102 | + {filings.map((f) => ( | ||||
| 103 | + <tr key={f.id}> | ||||
| 104 | + <td className="mono text-ink-2">{formatDateTime(f.filed_at, { tz: "America/New_York" })}</td> | ||||
| 105 | + <td className="mono font-medium">{f.form_type}</td> | ||||
| 106 | + <td>{f.company_name}</td> | ||||
| 107 | + <td> | ||||
| 108 | + <a href={f.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"> | ||||
| 109 | + EDGAR ↗ | ||||
| 110 | + </a> | ||||
| 111 | + </td> | ||||
| 112 | + </tr> | ||||
| 113 | + ))} | ||||
| 114 | + </tbody> | ||||
| 115 | + </table> | ||||
| 116 | + </div> | ||||
| 117 | + ) : ( | ||||
| 118 | + <Empty>No filings observed for CIK {d.company.cik} since Market Atlas started listening.</Empty> | ||||
| 119 | + )} | ||||
| 120 | + </Section> | ||||
| 121 | + )} | ||||
| 122 | + </div> | ||||
| 123 | + <aside className="space-y-6"> | ||||
| 124 | + <div className="rounded-md border border-rule bg-surface p-4"> | ||||
| 125 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Instrument</h3> | ||||
| 126 | + <Kv | ||||
| 127 | + cols={1} | ||||
| 128 | + className="mt-2" | ||||
| 129 | + items={[ | ||||
| 130 | + ["Market Atlas id", i.id], | ||||
| 131 | + ["Class", ASSET_CLASS_LABEL[i.asset_class] ?? i.asset_class], | ||||
| 132 | + ["Security type", i.security_type?.replace(/_/g, " ").toLowerCase() ?? "—"], | ||||
| 133 | + ["Currency", i.currency ?? "—"], | ||||
| 134 | + ["Country", i.country ?? "—"], | ||||
| 135 | + ...(i.base ? ([["Base / quote", `${i.base} / ${i.quote ?? "—"}`]] as Array<[string, string]>) : []), | ||||
| 136 | + ["MIC", i.mic ?? "—"], | ||||
| 137 | + ]} | ||||
| 138 | + /> | ||||
| 139 | + {d.quote && ( | ||||
| 140 | + <div className="mt-3 flex flex-wrap gap-1.5"> | ||||
| 141 | + <StatusBadge status={d.quote.data_status} /> | ||||
| 142 | + <RightsBadge status={d.quote.rights_status} /> | ||||
| 143 | + </div> | ||||
| 144 | + )} | ||||
| 145 | + </div> | ||||
| 146 | + {d.exchange && ( | ||||
| 147 | + <div className="rounded-md border border-rule bg-surface p-4"> | ||||
| 148 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Venue</h3> | ||||
| 149 | + <Link href={`/exchanges/${d.exchange.id}`} className="mt-1 block font-medium hover:underline"> | ||||
| 150 | + {d.exchange.name} | ||||
| 151 | + </Link> | ||||
| 152 | + <div className="mt-1 flex items-center gap-2 text-sm text-ink-2"> | ||||
| 153 | + <StatusBadge status={d.exchange.status.state} /> local {d.exchange.status.localTime} · {d.exchange.timezone} | ||||
| 154 | + {d.exchange.status.isHoliday && d.exchange.status.holidayName ? ` · ${d.exchange.status.holidayName}` : ""} | ||||
| 155 | + </div> | ||||
| 156 | + {d.exchange.status.nextTransition && ( | ||||
| 157 | + <div className="mt-1 text-xs text-ink-3"> | ||||
| 158 | + Next: {d.exchange.status.nextTransition.state.toLowerCase()} at {formatDateTime(d.exchange.status.nextTransition.at, { tz: d.exchange.timezone })} | ||||
| 159 | + </div> | ||||
| 160 | + )} | ||||
| 161 | + </div> | ||||
| 162 | + )} | ||||
| 163 | + {d.company && ( | ||||
| 164 | + <div className="rounded-md border border-rule bg-surface p-4"> | ||||
| 165 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Company</h3> | ||||
| 166 | + <div className="mt-1 font-medium">{d.company.name}</div> | ||||
| 167 | + <Kv cols={1} className="mt-1" items={[["CIK", d.company.cik ?? "—"], ["Country", d.company.country ?? "—"], ["Sector", d.company.sector ?? "—"]]} /> | ||||
| 168 | + {d.company.cik && ( | ||||
| 169 | + <a href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${d.company.cik}`} target="_blank" rel="noopener noreferrer" className="mt-2 inline-block text-xs text-accent hover:underline"> | ||||
| 170 | + EDGAR company page ↗ | ||||
| 171 | + </a> | ||||
| 172 | + )} | ||||
| 173 | + </div> | ||||
| 174 | + )} | ||||
| 175 | + <div className="rounded-md border border-rule bg-surface p-4"> | ||||
| 176 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Observed by</h3> | ||||
| 177 | + {d.sources.length ? ( | ||||
| 178 | + <ul className="mt-1 flex flex-wrap gap-1.5"> | ||||
| 179 | + {d.sources.map((s) => ( | ||||
| 180 | + <li key={s}> | ||||
| 181 | + <Link href={`/sources#${s}`} className="mono rounded border border-rule px-1.5 py-0.5 text-xs text-ink-2 hover:border-rule-strong"> | ||||
| 182 | + {s} | ||||
| 183 | + </Link> | ||||
| 184 | + </li> | ||||
| 185 | + ))} | ||||
| 186 | + </ul> | ||||
| 187 | + ) : ( | ||||
| 188 | + <p className="mt-1 text-xs text-ink-3">No source has published a value for this instrument in the current process yet.</p> | ||||
| 189 | + )} | ||||
| 190 | + {d.aliases.length > 0 && ( | ||||
| 191 | + <> | ||||
| 192 | + <h3 className="mt-3 text-[11px] font-medium uppercase tracking-wide text-ink-3">Aliases</h3> | ||||
| 193 | + <ul className="mt-1 flex flex-wrap gap-1.5"> | ||||
| 194 | + {d.aliases.slice(0, 12).map((a) => ( | ||||
| 195 | + <li key={`${a.alias}-${a.source_id}`} className="mono rounded bg-surface-2 px-1.5 py-0.5 text-xs text-ink-2" title={a.source_id ? `alias used by ${a.source_id}` : "global alias"}> | ||||
| 196 | + {a.alias} | ||||
| 197 | + {a.source_id ? <span className="text-ink-3"> · {a.source_id}</span> : null} | ||||
| 198 | + </li> | ||||
| 199 | + ))} | ||||
| 200 | + </ul> | ||||
| 201 | + </> | ||||
| 202 | + )} | ||||
| 203 | + </div> | ||||
| 204 | + {d.related.length > 0 && ( | ||||
| 205 | + <div className="rounded-md border border-rule bg-surface p-4"> | ||||
| 206 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Related instruments</h3> | ||||
| 207 | + <ul className="mt-1 divide-y divide-rule text-sm"> | ||||
| 208 | + {d.related.map((r) => ( | ||||
| 209 | + <li key={r.id} className="flex items-center justify-between py-1.5"> | ||||
| 210 | + <Link href={instrumentHref(r.id)} className="min-w-0 truncate hover:underline"> | ||||
| 211 | + <span className="mono font-medium">{r.symbol}</span> <span className="text-xs text-ink-3">{r.name}</span> | ||||
| 212 | + </Link> | ||||
| 213 | + <span className="text-[10.5px] uppercase text-ink-3">{r.exchange_id ?? r.asset_class}</span> | ||||
| 214 | + </li> | ||||
| 215 | + ))} | ||||
| 216 | + </ul> | ||||
| 217 | + </div> | ||||
| 218 | + )} | ||||
| 219 | + </aside> | ||||
| 220 | + </div> | ||||
| 221 | + </div> | ||||
| 222 | + </div> | ||||
| 223 | + ); | ||||
| 224 | +} | ||||
added
apps/web/src/app/licensing/page.tsx
+106 −0
@@ -0,0 +1,106 @@ | |||||
| 1 | +import type { Metadata } from "next"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { Page, PageHeader } from "@/components/ui/section"; | ||||
| 4 | +import { RightsBadge } from "@/components/ui/status-badge"; | ||||
| 5 | +import { apiOptional } from "@/lib/api"; | ||||
| 6 | +import { DISCLAIMER } from "@/lib/site"; | ||||
| 7 | +import type { Source } from "@/lib/types"; | ||||
| 8 | + | ||||
| 9 | +export const metadata: Metadata = { title: "Data rights & licensing", description: "How Market Atlas classifies the rights attached to every source and what each status allows.", alternates: { canonical: "/licensing" } }; | ||||
| 10 | +export const dynamic = "force-dynamic"; | ||||
| 11 | + | ||||
| 12 | +const STATUSES: Array<[string, string]> = [ | ||||
| 13 | + ["PUBLIC_OPEN", "Public data with no usage restriction known to us."], | ||||
| 14 | + ["PUBLIC_ATTRIBUTED", "Public data displayed with attribution to the source (venue feeds, reference files). Not for raw-feed commercial redistribution."], | ||||
| 15 | + ["OFFICIAL_OPEN_DATA", "Government or central-bank open data (public domain or open licence with attribution)."], | ||||
| 16 | + ["LICENSED", "Data licensed to Market Atlas (including our sister platform HF Market Data) — redistribution within the licence."], | ||||
| 17 | + ["DELAYED", "Delayed market data that may be displayed publicly with attribution and a delay notice (Cboe: 15 minutes)."], | ||||
| 18 | + ["INDICATIVE", "Indicative values (reference fixings, averages) — informational, not tradable."], | ||||
| 19 | + ["PUBLIC_RESTRICTED_REDISTRIBUTION", "Publicly accessible but redistribution is restricted: used internally for validation only; public values are withheld."], | ||||
| 20 | + ["RESEARCH_ONLY", "Usable for internal research; never displayed."], | ||||
| 21 | + ["INTERNAL_ONLY", "Internal signals (Market Atlas' own calendar/monitoring) or values built only from restricted sources."], | ||||
| 22 | + ["UNKNOWN", "Rights not yet classified — never exposed publicly until reviewed."], | ||||
| 23 | +]; | ||||
| 24 | + | ||||
| 25 | +export default async function LicensingPage() { | ||||
| 26 | + const sources = (await apiOptional<Source[]>("/v1/sources"))?.filter((s) => s.category !== "INTERNAL") ?? []; | ||||
| 27 | + return ( | ||||
| 28 | + <Page> | ||||
| 29 | + <PageHeader kicker="Legal" title="Data rights & licensing" lead="Technical accessibility does not imply redistribution rights. Every connector declares a rights status; the platform enforces it — quotes built only from restricted sources are withheld, while their provenance metadata stays visible." /> | ||||
| 30 | + <div className="prose-ma max-w-3xl"> | ||||
| 31 | + <h2>Rights statuses</h2> | ||||
| 32 | + <table> | ||||
| 33 | + <thead> | ||||
| 34 | + <tr> | ||||
| 35 | + <th>Status</th> | ||||
| 36 | + <th>What it means</th> | ||||
| 37 | + </tr> | ||||
| 38 | + </thead> | ||||
| 39 | + <tbody> | ||||
| 40 | + {STATUSES.map(([s, d]) => ( | ||||
| 41 | + <tr key={s}> | ||||
| 42 | + <td> | ||||
| 43 | + <RightsBadge status={s} /> | ||||
| 44 | + <code className="ml-2">{s}</code> | ||||
| 45 | + </td> | ||||
| 46 | + <td>{d}</td> | ||||
| 47 | + </tr> | ||||
| 48 | + ))} | ||||
| 49 | + </tbody> | ||||
| 50 | + </table> | ||||
| 51 | + <h2>What Market Atlas never does</h2> | ||||
| 52 | + <ul> | ||||
| 53 | + <li>Bypass authentication, paywalls, CAPTCHAs or access controls; impersonate users; use leaked credentials or private sessions.</li> | ||||
| 54 | + <li>Reverse-engineer protected feeds for unauthorized redistribution or evade a source's restrictions.</li> | ||||
| 55 | + <li>Present delayed, end-of-day or stale values as real time.</li> | ||||
| 56 | + </ul> | ||||
| 57 | + <h2>Attributions</h2> | ||||
| 58 | + {sources.length ? ( | ||||
| 59 | + <table> | ||||
| 60 | + <thead> | ||||
| 61 | + <tr> | ||||
| 62 | + <th>Source</th> | ||||
| 63 | + <th>Rights</th> | ||||
| 64 | + <th>Notes</th> | ||||
| 65 | + </tr> | ||||
| 66 | + </thead> | ||||
| 67 | + <tbody> | ||||
| 68 | + {sources.map((s) => ( | ||||
| 69 | + <tr key={s.id}> | ||||
| 70 | + <td> | ||||
| 71 | + {s.homepage ? ( | ||||
| 72 | + <a href={s.homepage} target="_blank" rel="noopener noreferrer"> | ||||
| 73 | + {s.name} | ||||
| 74 | + </a> | ||||
| 75 | + ) : ( | ||||
| 76 | + s.name | ||||
| 77 | + )} | ||||
| 78 | + <div className="text-xs text-ink-3">{s.organization}</div> | ||||
| 79 | + </td> | ||||
| 80 | + <td> | ||||
| 81 | + <RightsBadge status={s.rights_status} /> | ||||
| 82 | + </td> | ||||
| 83 | + <td> | ||||
| 84 | + {s.rights_notes} | ||||
| 85 | + {s.terms_url && ( | ||||
| 86 | + <> | ||||
| 87 | + {" "} | ||||
| 88 | + <a href={s.terms_url} target="_blank" rel="noopener noreferrer"> | ||||
| 89 | + terms ↗ | ||||
| 90 | + </a> | ||||
| 91 | + </> | ||||
| 92 | + )} | ||||
| 93 | + </td> | ||||
| 94 | + </tr> | ||||
| 95 | + ))} | ||||
| 96 | + </tbody> | ||||
| 97 | + </table> | ||||
| 98 | + ) : ( | ||||
| 99 | + <p>Source attributions are listed on the <Link href="/sources">sources</Link> page.</p> | ||||
| 100 | + )} | ||||
| 101 | + <h2>Disclaimer</h2> | ||||
| 102 | + <p>{DISCLAIMER} Final legal language will be reviewed before any commercial launch.</p> | ||||
| 103 | + </div> | ||||
| 104 | + </Page> | ||||
| 105 | + ); | ||||
| 106 | +} | ||||
added
apps/web/src/app/live/page.tsx
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/manifest.ts
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/markets/loading.tsx
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/markets/page.tsx
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/methodology/page.tsx
+110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/not-found.tsx
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/page.tsx
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/rates/page.tsx
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/robots.ts
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/search/page.tsx
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sitemap.ts
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sources/page.tsx
+96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/status/page.tsx
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/stocks/[symbol]/page.tsx
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/stocks/page.tsx
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/admin/admin-console.tsx
+583 −0
Large diff (583 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/layout/site-footer.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/layout/site-header.tsx
+1 −2
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/changes-panel.tsx
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/class-page.tsx
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/compare-view.tsx
+218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/health-panel.tsx
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/instrument-header.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/instrument-stats.tsx
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/market/live-events.tsx
+3 −3
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/live-feed.tsx
+172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/market/provenance-panel.tsx
+111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/market/pulse-grid.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/market/tape.tsx
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/format.ts
+6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/tsconfig.json
+28 −5
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified
connectors/src/hfmarketdata/index.ts
+5 −2
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
connectors/src/nasdaq-market-calendar/index.ts
+3 −1
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
docs/FRONTEND.md
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
modified
pnpm-lock.yaml
+3 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff