| |
1 | +import type { Metadata } from "next"; |
| |
2 | +import Link from "next/link"; |
| |
3 | +import { RedundancyPanel } from "@/components/market/redundancy-panel"; |
| |
4 | +import { Empty, Page, PageHeader, Pill, Section, Stat } from "@/components/ui/section"; |
| |
5 | +import { api } from "@/lib/api"; |
| |
6 | +import { COVERAGE_STATUS_LABEL, COVERAGE_STATUS_TONE, TIER_LABEL, TIER_TARGET } from "@/lib/coverage"; |
| |
7 | +import { ASSET_CLASS_LABEL, cx, instrumentHref } from "@/lib/format"; |
| |
8 | +import type { CoverageReport, CoverageTier } from "@/lib/types"; |
| |
9 | + |
| |
10 | +export const metadata: Metadata = { |
| |
11 | + title: "Source coverage", |
| |
12 | + description: "How many independent source families observe each instrument, redundancy targets by tier, the source-expansion queue and statistically inferred shared upstreams.", |
| |
13 | + alternates: { canonical: "/coverage" }, |
| |
14 | +}; |
| |
15 | +export const dynamic = "force-dynamic"; |
| |
16 | + |
| |
17 | +const TIERS: CoverageTier[] = ["A", "B", "C", "D"]; |
| |
18 | +const CLASSES = ["EQUITY", "ETF", "INDEX", "CRYPTO", "FOREX", "COMMODITY", "FUTURE", "TREASURY", "BOND", "INTEREST_RATE"]; |
| |
19 | + |
| |
20 | +export default async function CoveragePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { |
| |
21 | + const sp = await searchParams; |
| |
22 | + const tier = typeof sp.tier === "string" && TIERS.includes(sp.tier as CoverageTier) ? (sp.tier as CoverageTier) : undefined; |
| |
23 | + const cls = typeof sp.asset_class === "string" && CLASSES.includes(sp.asset_class) ? sp.asset_class : undefined; |
| |
24 | + const qs = new URLSearchParams({ limit: "200" }); |
| |
25 | + if (tier) qs.set("tier", tier); |
| |
26 | + if (cls) qs.set("asset_class", cls); |
| |
27 | + const [report, global] = await Promise.all([api<CoverageReport>(`/v1/coverage?${qs}`), tier || cls ? api<CoverageReport>("/v1/coverage?limit=1") : null]); |
| |
28 | + const s = report.summary; |
| |
29 | + const g = (global ?? report).summary; |
| |
30 | + const href = (t?: CoverageTier, c?: string) => { |
| |
31 | + const p = new URLSearchParams(); |
| |
32 | + if (t) p.set("tier", t); |
| |
33 | + if (c) p.set("asset_class", c); |
| |
34 | + const q = p.toString(); |
| |
35 | + return `/coverage${q ? `?${q}` : ""}`; |
| |
36 | + }; |
| |
37 | + const byClass = Object.entries(s.byAssetClass).sort((a, b) => b[1].instruments - a[1].instruments); |
| |
38 | + return ( |
| |
39 | + <Page wide> |
| |
40 | + <PageHeader |
| |
41 | + kicker="Source Mesh" |
| |
42 | + title="Source coverage" |
| |
43 | + lead="One instrument → many observers → one canonical market state. Market Atlas does not trust a quote: it observes the market through independent paths, counts source families (not URLs), and publishes the consensus with the evidence. This page measures that redundancy and lists where it is still thin." |
| |
44 | + actions={ |
| |
45 | + <Link href="/methodology#source-mesh" className="text-sm text-accent hover:underline"> |
| |
46 | + How it is computed → |
| |
47 | + </Link> |
| |
48 | + } |
| |
49 | + /> |
| |
50 | + <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]"> |
| |
51 | + <RedundancyPanel coverage={g} prominent /> |
| |
52 | + <div className="rounded-md border border-rule bg-surface p-4"> |
| |
53 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Redundancy targets by tier</h3> |
| |
54 | + <div className="mt-2 divide-y divide-rule"> |
| |
55 | + {TIERS.map((t) => { |
| |
56 | + const tt = g.tiers[t]; |
| |
57 | + return ( |
| |
58 | + <div key={t} className="grid grid-cols-[auto_1fr_auto] items-center gap-3 py-2 text-sm"> |
| |
59 | + <Link href={href(t, cls)} className={cx("mono inline-flex h-7 w-7 items-center justify-center rounded border text-xs font-semibold", tier === t ? "border-ink bg-ink text-canvas" : "border-rule text-ink-2 hover:border-rule-strong")}> |
| |
60 | + {t} |
| |
61 | + </Link> |
| |
62 | + <div className="min-w-0"> |
| |
63 | + <div className="font-medium">{TIER_LABEL[t].replace(/^Tier [A-D] · /, "")}</div> |
| |
64 | + <div className="truncate text-[11.5px] text-ink-3">{report.targets[t] ?? TIER_TARGET[t]}</div> |
| |
65 | + <div className="mt-1 h-1 overflow-hidden rounded-full bg-surface-3"> |
| |
66 | + <span className={cx("block h-full rounded-full", tt.attainment >= 90 ? "bg-positive" : tt.attainment >= 50 ? "bg-accent" : "bg-warning")} style={{ width: `${Math.min(100, tt.attainment)}%` }} /> |
| |
67 | + </div> |
| |
68 | + </div> |
| |
69 | + <div className="text-right"> |
| |
70 | + <div className="mono tnum"> |
| |
71 | + {tt.covered} / {tt.instruments} |
| |
72 | + </div> |
| |
73 | + <div className="text-[11px] text-ink-3">{tt.attainment.toFixed(1)}% covered</div> |
| |
74 | + </div> |
| |
75 | + </div> |
| |
76 | + ); |
| |
77 | + })} |
| |
78 | + </div> |
| |
79 | + </div> |
| |
80 | + </div> |
| |
81 | + |
| |
82 | + <Section title="By asset class" hint="quoted instruments · share observed by ≥ 2 families · mean independent families"> |
| |
83 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> |
| |
84 | + <table className="table-dense"> |
| |
85 | + <thead> |
| |
86 | + <tr> |
| |
87 | + <th>Asset class</th> |
| |
88 | + <th className="text-right">Quoted</th> |
| |
89 | + <th className="text-right">Multi-source</th> |
| |
90 | + <th className="text-right">Share</th> |
| |
91 | + <th className="text-right">Mean families</th> |
| |
92 | + <th /> |
| |
93 | + </tr> |
| |
94 | + </thead> |
| |
95 | + <tbody> |
| |
96 | + {byClass.map(([k, v]) => ( |
| |
97 | + <tr key={k}> |
| |
98 | + <td className="font-medium">{ASSET_CLASS_LABEL[k] ?? k}</td> |
| |
99 | + <td className="num">{v.instruments}</td> |
| |
100 | + <td className="num">{v.multiSource}</td> |
| |
101 | + <td className="num"> |
| |
102 | + <span className="inline-flex items-center gap-2"> |
| |
103 | + <span className="inline-block h-1.5 w-16 overflow-hidden rounded-full bg-surface-3"> |
| |
104 | + <span className={cx("block h-full rounded-full", v.instruments && v.multiSource / v.instruments >= 0.9 ? "bg-positive" : v.multiSource ? "bg-accent" : "bg-stale")} style={{ width: `${v.instruments ? (v.multiSource / v.instruments) * 100 : 0}%` }} /> |
| |
105 | + </span> |
| |
106 | + {v.instruments ? `${Math.round((v.multiSource / v.instruments) * 100)}%` : "—"} |
| |
107 | + </span> |
| |
108 | + </td> |
| |
109 | + <td className="num">{v.meanFamilies.toFixed(2)}</td> |
| |
110 | + <td className="text-right"> |
| |
111 | + <Link href={href(tier, k)} className="text-xs text-accent hover:underline"> |
| |
112 | + queue → |
| |
113 | + </Link> |
| |
114 | + </td> |
| |
115 | + </tr> |
| |
116 | + ))} |
| |
117 | + </tbody> |
| |
118 | + </table> |
| |
119 | + </div> |
| |
120 | + </Section> |
| |
121 | + |
| |
122 | + <Section |
| |
123 | + title="Source expansion queue" |
| |
124 | + hint="instruments below their redundancy target, most valuable gaps first (tier weight × missing families)" |
| |
125 | + badge={ |
| |
126 | + <span className="mono rounded bg-surface-3 px-1.5 py-0.5 text-[10.5px] text-ink-2 tnum"> |
| |
127 | + {report.queue.length} |
| |
128 | + {report.queue.length >= 200 ? "+" : ""} |
| |
129 | + </span> |
| |
130 | + } |
| |
131 | + > |
| |
132 | + <div className="mb-3 flex flex-wrap items-center gap-2"> |
| |
133 | + <span className="text-xs text-ink-3">Tier</span> |
| |
134 | + <Pill href={href(undefined, cls)} active={!tier}> |
| |
135 | + all |
| |
136 | + </Pill> |
| |
137 | + {TIERS.map((t) => ( |
| |
138 | + <Pill key={t} href={href(t, cls)} active={tier === t}> |
| |
139 | + {t} |
| |
140 | + </Pill> |
| |
141 | + ))} |
| |
142 | + <span className="ml-2 text-xs text-ink-3">Class</span> |
| |
143 | + <Pill href={href(tier)} active={!cls}> |
| |
144 | + all |
| |
145 | + </Pill> |
| |
146 | + {CLASSES.filter((c) => s.byAssetClass[c] || c === cls).map((c) => ( |
| |
147 | + <Pill key={c} href={href(tier, c)} active={cls === c}> |
| |
148 | + {ASSET_CLASS_LABEL[c] ?? c} |
| |
149 | + </Pill> |
| |
150 | + ))} |
| |
151 | + </div> |
| |
152 | + {report.queue.length ? ( |
| |
153 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> |
| |
154 | + <table className="table-dense"> |
| |
155 | + <thead> |
| |
156 | + <tr> |
| |
157 | + <th>#</th> |
| |
158 | + <th>Instrument</th> |
| |
159 | + <th>Tier</th> |
| |
160 | + <th className="text-right">Families / target</th> |
| |
161 | + <th className="text-right">Missing</th> |
| |
162 | + <th className="text-right">Observations</th> |
| |
163 | + <th className="text-right">Proxies · validators</th> |
| |
164 | + <th>Class</th> |
| |
165 | + <th>Status</th> |
| |
166 | + <th className="text-right">Score</th> |
| |
167 | + </tr> |
| |
168 | + </thead> |
| |
169 | + <tbody> |
| |
170 | + {report.queue.map((q, i) => ( |
| |
171 | + <tr key={q.instrumentId}> |
| |
172 | + <td className="mono text-ink-3">{i + 1}</td> |
| |
173 | + <td> |
| |
174 | + <Link href={instrumentHref(q.instrumentId)} className="hover:underline"> |
| |
175 | + <span className="mono font-medium">{q.symbol}</span> |
| |
176 | + <span className="block max-w-[260px] truncate text-[11.5px] text-ink-3">{q.name}</span> |
| |
177 | + </Link> |
| |
178 | + </td> |
| |
179 | + <td> |
| |
180 | + <span className="mono rounded border border-rule px-1.5 text-xs" title={TIER_TARGET[q.tier]}> |
| |
181 | + {q.tier} |
| |
182 | + </span> |
| |
183 | + </td> |
| |
184 | + <td className="num"> |
| |
185 | + {q.families} / {q.target.families} |
| |
186 | + </td> |
| |
187 | + <td className="num text-warning">{q.gap > 0 ? `−${q.gap}` : "—"}</td> |
| |
188 | + <td className="num">{q.observations}</td> |
| |
189 | + <td className="num text-ink-2"> |
| |
190 | + {q.proxies} · {q.validators} |
| |
191 | + </td> |
| |
192 | + <td className="text-xs text-ink-2"> |
| |
193 | + {ASSET_CLASS_LABEL[q.assetClass] ?? q.assetClass} |
| |
194 | + {q.comparability ? <span className="block text-[10.5px] text-ink-3">{q.comparability.toLowerCase()} class</span> : null} |
| |
195 | + </td> |
| |
196 | + <td> |
| |
197 | + <span className={cx("inline-flex h-5 items-center rounded-[3px] px-1.5 text-[10.5px] font-medium uppercase tracking-wide", COVERAGE_STATUS_TONE[q.status])}>{COVERAGE_STATUS_LABEL[q.status]}</span> |
| |
198 | + </td> |
| |
199 | + <td className="num">{Math.round(q.score * 100)}</td> |
| |
200 | + </tr> |
| |
201 | + ))} |
| |
202 | + </tbody> |
| |
203 | + </table> |
| |
204 | + </div> |
| |
205 | + ) : ( |
| |
206 | + <Empty>Every instrument in this selection meets its redundancy target.</Empty> |
| |
207 | + )} |
| |
208 | + <p className="mt-2 text-xs text-ink-3"> |
| |
209 | + A <em>family</em> is a group of sources believed to share an upstream (declared, or inferred statistically). Proxies (stablecoin markets, crosses) and validators (restricted-rights sources) confirm a value but never count as independent families. Score = 65 % family attainment + 35 % observation attainment. |
| |
210 | + </p> |
| |
211 | + </Section> |
| |
212 | + |
| |
213 | + <Section title="Likely shared upstreams" hint="pairs of sources whose values coincide within 2 s on ≥ 92 % of ≥ 120 aligned samples — counted as one family"> |
| |
214 | + {report.lineage.length ? ( |
| |
215 | + <div className="overflow-x-auto rounded-md border border-rule bg-surface"> |
| |
216 | + <table className="table-dense"> |
| |
217 | + <thead> |
| |
218 | + <tr> |
| |
219 | + <th>Source A</th> |
| |
220 | + <th>Source B</th> |
| |
221 | + <th className="text-right">Similarity</th> |
| |
222 | + <th className="text-right">Aligned samples</th> |
| |
223 | + <th className="text-right">Instruments</th> |
| |
224 | + </tr> |
| |
225 | + </thead> |
| |
226 | + <tbody> |
| |
227 | + {report.lineage.map((p) => ( |
| |
228 | + <tr key={`${p.sourceA}|${p.sourceB}`}> |
| |
229 | + <td> |
| |
230 | + <Link href={`/sources#${p.sourceA}`} className="mono hover:underline"> |
| |
231 | + {p.sourceA} |
| |
232 | + </Link> |
| |
233 | + </td> |
| |
234 | + <td> |
| |
235 | + <Link href={`/sources#${p.sourceB}`} className="mono hover:underline"> |
| |
236 | + {p.sourceB} |
| |
237 | + </Link> |
| |
238 | + </td> |
| |
239 | + <td className="num">{(p.similarity * 100).toFixed(1)}%</td> |
| |
240 | + <td className="num">{p.samples.toLocaleString("en-US")}</td> |
| |
241 | + <td className="num">{p.instruments}</td> |
| |
242 | + </tr> |
| |
243 | + ))} |
| |
244 | + </tbody> |
| |
245 | + </table> |
| |
246 | + </div> |
| |
247 | + ) : ( |
| |
248 | + <Empty>No pair of sources currently looks like a shared upstream. The lineage engine recomputes every five minutes from live observations.</Empty> |
| |
249 | + )} |
| |
250 | + </Section> |
| |
251 | + |
| |
252 | + <div className="mt-8 grid grid-cols-2 gap-4 border-t border-rule pt-6 sm:grid-cols-4"> |
| |
253 | + <Stat label="Quoted instruments" value={g.quoted.toLocaleString("en-US")} /> |
| |
254 | + <Stat label="≥ 3 families" value={g.byFamilies[">=3"]} sub="Tier-A target" /> |
| |
255 | + <Stat label="≥ 5 families" value={g.byFamilies[">=5"]} sub="most observed" /> |
| |
256 | + <Stat label="No fresh source" value={g.byFamilies["0"]} tone={g.byFamilies["0"] ? "warn" : "muted"} sub="last known value shown as stale" /> |
| |
257 | + </div> |
| |
258 | + </Page> |
| |
259 | + ); |
| |
260 | +} |