|
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 |
+} |