web: Source Mesh v2 UI — source coverage matrix (types, Δ bp, roles, reasons), coverage badges, homepage redundancy block, /coverage page (queue, tiers, lineage), source roles, methodology & developers docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 changed files +471 −68
modified
apps/web/qa/screens.mjs
+1 −1
@@ -10,7 +10,7 @@ const BASE = process.argv[2] ?? "http://localhost:8390"; | ||
| 10 | 10 | const OUT = new URL("./screens/", import.meta.url).pathname; |
| 11 | 11 | mkdirSync(OUT, { recursive: true }); |
| 12 | 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"]; | |
| 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", "/coverage", "/coverage?tier=A", "/connectors", "/data-health", "/search?q=bitcoin", "/compare?ids=crypto_btc_usd,crypto_eth_usd", "/status", "/methodology", "/developers", "/licensing", "/admin", "/does-not-exist"]; | |
| 14 | 14 | const WIDTHS = [390, 1440]; |
| 15 | 15 | const THEMES = ["dark", "light"]; |
| 16 | 16 | |
modified
apps/web/src/app/developers/page.tsx
+10 −4
@@ -12,7 +12,9 @@ const REST: Array<[string, string]> = [ | ||
| 12 | 12 | ["GET /v1/instruments/{id|SYMBOL|VENUE:SYMBOL}", "Instrument detail: company, exchange + status, quote, aliases, observing sources, related instruments."], |
| 13 | 13 | ["GET /v1/quotes/{id}", "One canonical quote."], |
| 14 | 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."], | |
| 15 | + ["GET /v1/quotes/{id}/provenance", "“Why this price?” — source coverage matrix: every contribution with observation_type, delta_bps vs consensus, weight, age, inclusion and reason; plus comparability, proxy/validator confirmations, coverage and shared_upstream_pairs."], | |
| 16 | + ["GET /v1/coverage?asset_class=&tier=A|B|C|D&limit=", "Source Coverage Engine: summary (quoted, multiSource, byFamilies ≥5/≥3/≥2/1/0, weightedScore, tiers attainment, byAssetClass, proxiesConfirming, validatorsConfirming), targets, source-expansion queue, inferred shared upstreams (lineage)."], | |
| 17 | + ["GET /v1/coverage/{id}", "Coverage of one instrument: tier, target, families, observations, proxies, validators, live/delayed/official counts, score, status, gap."], | |
| 16 | 18 | ["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 | 19 | ["GET /v1/events?type=&instrument=&country=&asset_class=&severity=&since=&before=&limit=", "Canonical events; GET /v1/events/{id} for one."], |
| 18 | 20 | ["GET /v1/filings?form=&cik=&q=&limit=&offset=", "Regulatory filings observed on EDGAR."], |
@@ -43,7 +45,7 @@ export default function DevelopersPage() { | ||
| 43 | 45 | </p> |
| 44 | 46 | <h2>Rate limits</h2> |
| 45 | 47 | <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. | |
| 48 | + 20 requests per second sustained per IP (burst 300) on <code>/v1</code> REST; <code>429</code> with <code>Retry-After</code> when exceeded. Streams and web pages are not counted. Higher limits and API keys will come with the developer accounts. | |
| 47 | 49 | </p> |
| 48 | 50 | <h2>REST endpoints</h2> |
| 49 | 51 | <table> |
@@ -72,7 +74,8 @@ export default function DevelopersPage() { | ||
| 72 | 74 | "data": { |
| 73 | 75 | "instrument_id": "crypto_btc_usd", "symbol": "BTC-USD", "price": 77326.37, "change_percent": 0.0275, |
| 74 | 76 | "bid": 77326.36, "ask": 77326.37, "currency": "USD", |
| 75 | − "source_count": 2, "dispersion_bps": 0.02, "confidence": 0.906, "freshness_ms": 1552, | |
| 77 | + "source_count": 4, "observation_count": 7, "proxy_count": 0, "validator_count": 0, "comparability": "LIVE", | |
| 78 | + "dispersion_bps": 2.13, "confidence": 0.958, "freshness_ms": 1552, | |
| 76 | 79 | "data_status": "REALTIME", "market_state": null, "rights_status": "PUBLIC_ATTRIBUTED", "withheld": false, |
| 77 | 80 | "updated_at": "2026-09-12T08:42:34.479Z", "source_timestamp": "2026-09-12T08:42:34.358Z" |
| 78 | 81 | }, |
@@ -85,7 +88,10 @@ export default function DevelopersPage() { | ||
| 85 | 88 | <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 | 89 | </li> |
| 87 | 90 | <li> |
| 88 | − <code>source_count</code> = independent source families included; <code>dispersion_bps</code> = spread between included sources; <code>confidence</code> ∈ [0, 0.995]. | |
| 91 | + <code>source_count</code> = independent <em>voting</em> source families; <code>observation_count</code> = fresh observations of any role; <code>proxy_count</code> = stablecoin/derived proxies agreeing within 50 bp; <code>validator_count</code> = restricted-rights sources agreeing within 25 bp; <code>comparability</code> ∈ <code>LIVE</code> · <code>FIX</code> · <code>EOD</code> = class of the canonical value; <code>dispersion_bps</code> = spread between voting sources; <code>confidence</code> ∈ [0, 0.995]. | |
| 92 | + </li> | |
| 93 | + <li> | |
| 94 | + Provenance contributions: <code>observation_type</code> ∈ TRADE · MID · QUOTE · INDEX_VALUE · INDICATIVE · STABLECOIN_PROXY · DERIVED · OFFICIAL_FIX · REFERENCE_RATE · SETTLEMENT · EOD_CLOSE · NAV; <code>delta_bps</code> = distance to the canonical value; <code>reason</code> ∈ stale · not_comparable · temporal_mismatch · validation_only · validator_disagrees · outlier. Validator values are <code>null</code> (withheld). | |
| 89 | 95 | </li> |
| 90 | 96 | <li> |
| 91 | 97 | <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. |
modified
apps/web/src/app/instruments/[id]/page.tsx
+7 −4
@@ -5,12 +5,13 @@ import { PriceChart } from "@/components/market/chart"; | ||
| 5 | 5 | import { EventRow } from "@/components/market/event-row"; |
| 6 | 6 | import { InstrumentHeader } from "@/components/market/instrument-header"; |
| 7 | 7 | import { InstrumentStats } from "@/components/market/instrument-stats"; |
| 8 | +import { CoverageBadge } from "@/components/market/coverage-badge"; | |
| 8 | 9 | import { ProvenancePanel } from "@/components/market/provenance-panel"; |
| 9 | 10 | import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; |
| 10 | 11 | import { Empty, Kv, Section } from "@/components/ui/section"; |
| 11 | 12 | import { api, apiOptional } from "@/lib/api"; |
| 12 | 13 | import { ASSET_CLASS_LABEL, formatDateTime, instrumentHref } from "@/lib/format"; |
| 13 | −import type { Exchange, Filing, Instrument, MarketEvent, Quote } from "@/lib/types"; | |
| 14 | +import type { Exchange, Filing, Instrument, InstrumentCoverage, MarketEvent, Quote } from "@/lib/types"; | |
| 14 | 15 | |
| 15 | 16 | export const dynamic = "force-dynamic"; |
| 16 | 17 | |
@@ -46,9 +47,10 @@ export default async function InstrumentPage({ params }: { params: Promise<{ id: | ||
| 46 | 47 | const d = await load(id); |
| 47 | 48 | if (!d?.instrument) notFound(); |
| 48 | 49 | const i = d.instrument; |
| 49 | − const [events, filings] = await Promise.all([ | |
| 50 | + const [events, filings, coverage] = await Promise.all([ | |
| 50 | 51 | apiOptional<MarketEvent[]>(`/v1/events?instrument=${encodeURIComponent(i.id)}&limit=25`), |
| 51 | 52 | d.company?.cik ? apiOptional<Filing[]>(`/v1/filings?cik=${encodeURIComponent(d.company.cik)}&limit=15`) : Promise.resolve(null), |
| 53 | + apiOptional<InstrumentCoverage>(`/v1/coverage/${encodeURIComponent(i.id)}`), | |
| 52 | 54 | ]); |
| 53 | 55 | const isRate = i.asset_class === "TREASURY" || i.asset_class === "INTEREST_RATE" || i.asset_class === "BOND"; |
| 54 | 56 | const jsonLd = { |
@@ -63,7 +65,7 @@ export default async function InstrumentPage({ params }: { params: Promise<{ id: | ||
| 63 | 65 | return ( |
| 64 | 66 | <div> |
| 65 | 67 | <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> |
| 66 | − <InstrumentHeader instrument={i} quote={d.quote} exchange={d.exchange} /> | |
| 68 | + <InstrumentHeader instrument={i} quote={d.quote} exchange={d.exchange} coverage={coverage ?? undefined} /> | |
| 67 | 69 | <div className="mx-auto max-w-[1440px] px-3 py-5 sm:px-5"> |
| 68 | 70 | <div className="grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_360px]"> |
| 69 | 71 | <div className="min-w-0"> |
@@ -71,7 +73,7 @@ export default async function InstrumentPage({ params }: { params: Promise<{ id: | ||
| 71 | 73 | <Section title="Key statistics" hint={isRate ? "percent" : d.quote?.currency ?? undefined}> |
| 72 | 74 | <InstrumentStats instrument={i} quote={d.quote} /> |
| 73 | 75 | </Section> |
| 74 | − <Section title="Why this price?" hint="provenance, weights and consensus method"> | |
| 76 | + <Section title="Why this price?" hint="source coverage matrix · every observation, its type, its role and its distance to the consensus" badge={coverage ? <CoverageBadge coverage={coverage} compact className="ml-1" /> : undefined}> | |
| 75 | 77 | <ProvenancePanel instrumentId={i.id} assetClass={i.asset_class} defaultOpen /> |
| 76 | 78 | </Section> |
| 77 | 79 | <Section title="Recent events" href={`/events?instrument=${encodeURIComponent(i.id)}`} hint="canonical events touching this instrument"> |
@@ -174,6 +176,7 @@ export default async function InstrumentPage({ params }: { params: Promise<{ id: | ||
| 174 | 176 | )} |
| 175 | 177 | <div className="rounded-md border border-rule bg-surface p-4"> |
| 176 | 178 | <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Observed by</h3> |
| 179 | + {coverage && <CoverageBadge coverage={coverage} className="mt-1.5" />} | |
| 177 | 180 | {d.sources.length ? ( |
| 178 | 181 | <ul className="mt-1 flex flex-wrap gap-1.5"> |
| 179 | 182 | {d.sources.map((s) => ( |
modified
apps/web/src/app/methodology/page.tsx
+41 −15
@@ -17,20 +17,42 @@ export default function MethodologyPage() { | ||
| 17 | 17 | <p> |
| 18 | 18 | A ticker alone never identifies a security. Each source symbol (<code>BTC-USD</code>, <code>BTC/USD</code>, <code>BTCUSD</code>, <code>BRK.B</code>, <code>BRK-B</code>…) is resolved through a per-source alias table to one stable Market Atlas id such as <code>eq_us_xnas_aapl</code>, <code>crypto_btc_usd</code>, <code>fx_eur_usd</code> or <code>index_us_spx</code>. USD and USDT crypto pairs are different instruments. Listed US equities come from the Nasdaq Trader symbol directories and the SEC company directory (CIK), which is how filings link to instruments. |
| 19 | 19 | </p> |
| 20 | − <h2>3. Consensus</h2> | |
| 20 | + <h2 id="source-mesh">3. Source Mesh: observation types, comparability, roles</h2> | |
| 21 | + <p> | |
| 22 | + <strong>One instrument → many observers → one canonical market state.</strong> Every observation carries an <em>observation type</em> that says what the value <em>is</em>: <code>TRADE</code> (last trade on a venue), <code>MID</code>, <code>QUOTE</code> (bid/ask), <code>INDEX_VALUE</code>, <code>INDICATIVE</code>, <code>STABLECOIN_PROXY</code> (a fiat rate implied by a USDT/USDC market), <code>DERIVED</code>, <code>OFFICIAL_FIX</code> (central-bank fixing), <code>REFERENCE_RATE</code> (policy rate, par yield), <code>SETTLEMENT</code>, <code>EOD_CLOSE</code>, <code>NAV</code>. Types map to three <em>comparability classes</em>: <strong>LIVE</strong> (trades, mids, quotes, index values, proxies), <strong>FIX</strong> (official fixings, reference rates) and <strong>EOD</strong> (settlements, daily closes). Values of different classes are never compared: an ECB fixing at 16:00 CET is not a live EUR/USD mid, and last week's close is not divergent from today's. The canonical value uses the best class available — live real markets first, then official fixings, then end-of-day closes, then live proxies alone (labelled <em>indicative</em>) — and other classes are reported in the matrix as <code>not_comparable</code>. Inside a class, observations must also be close in time (live: within two minutes of the freshest; fixings/closes: same UTC day), otherwise <code>temporal_mismatch</code>. | |
| 23 | + </p> | |
| 24 | + <p>Each fresh, comparable observation then plays one of three roles:</p> | |
| 25 | + <ul> | |
| 26 | + <li> | |
| 27 | + <strong>Votes</strong> — observations from real markets and official publishers set the canonical value (weighted median). | |
| 28 | + </li> | |
| 29 | + <li> | |
| 30 | + <strong>Confirms</strong> — proxies (stablecoin markets, derived crosses, indicative values) join the median with reduced weight (35 % when a real market exists) and <em>never</em> count as an independent family; the number of proxies agreeing within 50 bp is shown as <code>proxy_count</code>. | |
| 31 | + </li> | |
| 32 | + <li> | |
| 33 | + <strong>Validates</strong> — sources whose data rights forbid redistribution (for example nasdaq.com's delayed quote) are compared to the consensus but never vote and are never displayed (<code>validation_only</code>); agreement within 25 bp raises confidence, disagreement beyond 50 bp is flagged <code>validator_disagrees</code>. | |
| 34 | + </li> | |
| 35 | + </ul> | |
| 36 | + <p> | |
| 37 | + <strong>Redundancy targets.</strong> Instruments are tiered: <em>A</em> (majors — AAPL, SPX, BTC, EUR/USD, gold…) target ≥ 3 independent families and 5 observations; <em>B</em> (liquid crypto, FX, indices, ETFs) ≥ 2 families and 3 observations; <em>C</em> (long tail) 1 family and 2 observations; <em>D</em> (official reference series such as Treasury par yields or policy rates) one official source is the truth. The <Link href="/coverage">source coverage</Link> page scores every quoted instrument against its target (65 % family attainment + 35 % observation attainment) and lists the expansion queue. | |
| 38 | + </p> | |
| 39 | + <p> | |
| 40 | + <strong>Statistical lineage.</strong> Two sources whose last prices coincide (identical value within two seconds) on at least 92 % of 120+ aligned samples very likely redistribute the same upstream vendor. Market Atlas then treats them as one family — even without contractual knowledge of the vendor — and publishes the pair on the coverage page. Hidden commercial relationships are never asserted; the similarity is shown as what it is. | |
| 41 | + </p> | |
| 42 | + <h2>4. Consensus</h2> | |
| 21 | 43 | <p>For every instrument and field, the engine keeps the latest observation of each source, then computes:</p> |
| 22 | 44 | <ul> |
| 23 | 45 | <li> |
| 24 | − <strong>Freshness window.</strong> Real-time observations count for 15 seconds, delayed feeds for 30 minutes, indicative values for one hour, end-of-day values for three days. While a venue is closed, the last session value stays valid for up to four days and is reported as <em>at close</em>. Anything older is excluded and marked <em>stale</em>. | |
| 46 | + <strong>Freshness window.</strong> Real-time observations count for 15 seconds, delayed feeds for 30 minutes, indicative values for one hour, end-of-day values for ten days (weekly-refreshed official datasets). While a venue is closed, the last session value stays valid for up to four days and is reported as <em>at close</em>. Anything older is excluded and marked <em>stale</em>. | |
| 25 | 47 | </li> |
| 26 | 48 | <li> |
| 27 | − <strong>Tiering.</strong> When a real-time or delayed observation exists, end-of-day and indicative values for the same instrument are superseded (kept visible in the provenance table, marked <code>superseded_by_live</code>). | |
| 49 | + <strong>Comparability.</strong> Only observations of the canonical class (see section 3) that are close in time are compared; other classes are reported as <code>not_comparable</code> or <code>temporal_mismatch</code>, never mixed and never counted as divergence. | |
| 28 | 50 | </li> |
| 29 | 51 | <li> |
| 30 | − <strong>Weights.</strong> weight = source reliability × timestamp quality (exchange 1.0, source 0.9, connector 0.75) × real-time class (real time 1.0, delayed 0.6, indicative 0.5, end of day 0.4) × official-source bonus × freshness decay. | |
| 52 | + <strong>Weights.</strong> weight = source reliability × timestamp quality (exchange 1.0, source 0.9, connector 0.75) × real-time class (real time 1.0, delayed 0.6, indicative 0.5, end of day 0.4) × official-source bonus (×1.6 for rates and yields, ×1.15 otherwise) × freshness decay. For crypto, a venue's weight also scales with its share of the 24-hour notional volume among voters, so a thin venue never outweighs the deepest markets. | |
| 31 | 53 | </li> |
| 32 | 54 | <li> |
| 33 | − <strong>Source families.</strong> Sources believed to share an upstream (for example several sites redistributing the same vendor) count as one vote; extra members of a family receive a quarter of their weight. The number of <em>independent sources</em> shown everywhere is the number of distinct families included. | |
| 55 | + <strong>Source families.</strong> Sources believed to share an upstream — declared, or inferred statistically — count as one vote; extra members of a family receive a quarter of their weight. The number of <em>independent sources</em> shown everywhere is the number of distinct <em>voting</em> families; proxies and validators are counted separately. | |
| 34 | 56 | </li> |
| 35 | 57 | <li> |
| 36 | 58 | <strong>Outliers.</strong> With three or more candidates, values more than 2 % away from the weighted median are excluded (reason <code>outlier</code>). |
@@ -39,11 +61,11 @@ export default function MethodologyPage() { | ||
| 39 | 61 | <strong>Canonical value.</strong> The weighted median of the remaining candidates. Dispersion is the spread between the highest and lowest included value, in basis points. |
| 40 | 62 | </li> |
| 41 | 63 | </ul> |
| 42 | − <h2>4. Confidence</h2> | |
| 64 | + <h2>5. Confidence</h2> | |
| 43 | 65 | <p> |
| 44 | − Confidence is a 0–1 score of Market Atlas' own agreement, redundancy and freshness — never a prediction or a view on the instrument: <code>0.15 + 0.35 × agreement + 0.25 × redundancy + 0.10 × freshness + 0.15 × mean reliability</code>, capped at 0.995 so the interface never claims false precision. Agreement falls to zero at 1 % dispersion; redundancy is <code>1 − e^(−families/2)</code> (one family ≈ 0.39, three ≈ 0.78, five ≈ 0.92); freshness decays with the age of the newest observation (fixed at 1 while a venue is closed). A single stale observation yields confidence 0. | |
| 66 | + Confidence is a 0–1 score of Market Atlas' own agreement, redundancy and freshness — never a prediction or a view on the instrument: <code>0.15 + 0.35 × agreement + 0.25 × redundancy + 0.10 × freshness + 0.15 × mean reliability</code>, capped at 0.995 so the interface never claims false precision. Agreement falls to zero at 1 % dispersion; redundancy is <code>1 − e^(−(families + ½ proxies + ½ validators)/2)</code> (one family ≈ 0.39, three ≈ 0.78, five ≈ 0.92); freshness decays with the age of the newest observation (fixed at 1 while a venue is closed). A single stale observation yields confidence 0. | |
| 45 | 67 | </p> |
| 46 | − <h2>5. Data status labels</h2> | |
| 68 | + <h2>6. Data status labels</h2> | |
| 47 | 69 | <table> |
| 48 | 70 | <thead> |
| 49 | 71 | <tr> |
@@ -68,6 +90,10 @@ export default function MethodologyPage() { | ||
| 68 | 90 | <td>End of day</td> |
| 69 | 91 | <td>Official or licensed daily value (ECB/Bank of Canada fixings, Treasury par yields, daily bars).</td> |
| 70 | 92 | </tr> |
| 93 | + <tr> | |
| 94 | + <td>Indicative</td> | |
| 95 | + <td>Only proxies (stablecoin markets, derived crosses) are fresh — the value is a live approximation, not a quote from a real market for the instrument.</td> | |
| 96 | + </tr> | |
| 71 | 97 | <tr> |
| 72 | 98 | <td>Stale</td> |
| 73 | 99 | <td>No source within its window; the last known value is displayed with its age and confidence 0.</td> |
@@ -78,26 +104,26 @@ export default function MethodologyPage() { | ||
| 78 | 104 | </tr> |
| 79 | 105 | </tbody> |
| 80 | 106 | </table> |
| 81 | − <h2>6. Source reliability</h2> | |
| 107 | + <h2>7. Source reliability</h2> | |
| 82 | 108 | <p> |
| 83 | 109 | Each connector carries an operational score from 0 to 100: 40 % availability (share of minutes healthy), 20 % parse success, 15 % latency (p95 under 1 s = full marks), 15 % connection stability, 10 % error rate. It measures how well the integration behaves, not the quality of the venue. The best score across a source's connectors feeds the consensus weight. |
| 84 | 110 | </p> |
| 85 | − <h2>7. Events</h2> | |
| 111 | + <h2>8. Events</h2> | |
| 86 | 112 | <p> |
| 87 | − Ticks are noisy; events are meaningful. Derived events use per-instrument baselines: a <em>price move</em> fires when the change since the last event exceeds max(0.5 %, 8 × the typical tick move); <em>session highs/lows</em> require 200 observations and a 0.1 % improvement; a <em>volatility spike</em> compares realized volatility over the last 30 ticks with an exponentially weighted baseline (≥ 4×); <em>source divergence</em> fires when included sources disagree by more than 50 bps. Venue and regulator events (halts, filings) are deduplicated on their native identifiers; additional sources confirming the same event increase its source count instead of creating a duplicate. | |
| 113 | + Ticks are noisy; events are meaningful. Derived events use per-instrument baselines: a <em>price move</em> fires when the change since the last event exceeds max(0.5 %, 8 × the typical tick move); <em>session highs/lows</em> require 200 observations and a 0.1 % improvement; a <em>volatility spike</em> compares realized volatility over the last 30 ticks with an exponentially weighted baseline (≥ 4×); <em>source divergence</em> fires when voting sources of the same comparability class disagree by more than 100 bps (at most once an hour per instrument). Venue and regulator events (halts, filings) are deduplicated on their native identifiers; additional sources confirming the same event increase its source count instead of creating a duplicate. | |
| 88 | 114 | </p> |
| 89 | − <h2>8. Bars and derived metrics</h2> | |
| 115 | + <h2>9. Bars and derived metrics</h2> | |
| 90 | 116 | <p> |
| 91 | 117 | One-minute bars are aggregated from canonical prices and rolled up to 5m/15m/1h/1d; daily history for equities, indices, FX, crypto and continuous futures comes from licensed end-of-day bars. Market Atlas never overwrites an official daily bar with a derived one. Returns, volatility, drawdown, correlation, breadth and “what changed” counts are computed from these bars and labelled <em>derived</em>. |
| 92 | 118 | </p> |
| 93 | − <h2>9. Market hours</h2> | |
| 119 | + <h2>10. Market hours</h2> | |
| 94 | 120 | <p> |
| 95 | 121 | Session states (pre, open, post, closed, holiday, early close) come from a calendar engine using IANA time zones, per-venue sessions and holiday tables; US holidays are refreshed from the Nasdaq Trader schedule page by an HTML change-detection connector. Nothing is hard-coded to 09:30–16:00. |
| 96 | 122 | </p> |
| 97 | − <h2>10. Limitations</h2> | |
| 123 | + <h2>11. Limitations</h2> | |
| 98 | 124 | <ul> |
| 99 | 125 | <li>Equity and index quotes are delayed 15 minutes; Market Atlas does not hold real-time US equity redistribution rights.</li> |
| 100 | − <li>FX and rates are official reference values, published once a day — indicative, not tradable.</li> | |
| 126 | + <li>Rates and yields are official reference values, published once a day. Major FX pairs are live from Kraken's and Bitstamp's fiat markets (real trades, keyless) with stablecoin-market proxies as confirmations; central-bank fixings remain the FIX-class reference and are never mixed with live values.</li> | |
| 101 | 127 | <li>Intraday history starts when Market Atlas first observed an instrument live; gaps are never interpolated.</li> |
| 102 | 128 | <li>Coverage measures Market Atlas observation, not market size or quality.</li> |
| 103 | 129 | </ul> |
modified
apps/web/src/app/page.tsx
+17 −1
@@ -4,6 +4,7 @@ import { HealthPanel } from "@/components/market/health-panel"; | ||
| 4 | 4 | import { InstrumentTable } from "@/components/market/instrument-table"; |
| 5 | 5 | import { LiveEvents } from "@/components/market/live-events"; |
| 6 | 6 | import { PulseRow } from "@/components/market/pulse-grid"; |
| 7 | +import { RedundancyPanel } from "@/components/market/redundancy-panel"; | |
| 7 | 8 | import { LiveTape } from "@/components/market/tape"; |
| 8 | 9 | import { TelemetryStrip } from "@/components/market/telemetry-strip"; |
| 9 | 10 | import { WorldMap } from "@/components/market/world-map"; |
@@ -54,6 +55,21 @@ export default async function HomePage() { | ||
| 54 | 55 | <PulseRow title="Equities" href="/stocks" items={markets.equities} className="border-b-0" /> |
| 55 | 56 | </Section> |
| 56 | 57 | |
| 58 | + {/* Source redundancy — the mesh is the product */} | |
| 59 | + {stats.coverage && ( | |
| 60 | + <div className="mt-8 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_1.1fr]"> | |
| 61 | + <RedundancyPanel coverage={stats.coverage} prominent className="!mt-0" /> | |
| 62 | + <div className="flex flex-col justify-center rounded-md border border-rule bg-surface p-4 text-sm text-ink-2"> | |
| 63 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">One instrument → many observers → one canonical state</h3> | |
| 64 | + <p className="mt-2">Market Atlas does not trust a quote. Each instrument is observed through independent paths — exchange feeds, official publishers, public quote pages — and only comparable observations are compared: a live trade is never measured against an official fixing or last week's close.</p> | |
| 65 | + <p className="mt-2">Real markets <span className="font-medium text-ink">vote</span>; stablecoin markets and crosses <span className="font-medium text-ink">confirm</span>; restricted-rights sources <span className="font-medium text-ink">validate</span> without ever setting or showing a value. Sources that statistically mirror each other are counted as one family.</p> | |
| 66 | + <p className="mt-3 text-xs text-ink-3"> | |
| 67 | + Targets: tier A majors ≥ 3 independent families and 5 observations · tier B ≥ 2 / 3 · tier C 1 / 2 · tier D one official source. <Link href="/coverage" className="text-accent hover:underline">See the expansion queue →</Link> | |
| 68 | + </p> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + | |
| 57 | 73 | {/* Tape + events */} |
| 58 | 74 | <div className="mt-8 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1.1fr_1fr]"> |
| 59 | 75 | <Section title="Live tape" hint="every canonical price change" className="!mt-0"> |
@@ -88,7 +104,7 @@ export default async function HomePage() { | ||
| 88 | 104 | <section className="mt-12 grid grid-cols-1 [&>*]:min-w-0 gap-6 border-t border-rule pt-8 text-sm text-ink-2 md:grid-cols-3"> |
| 89 | 105 | <div> |
| 90 | 106 | <h3 className="font-semibold text-ink">Multi-source by design</h3> |
| 91 | − <p className="mt-1">Prices are a weighted median of independent observations, one vote per upstream family. Open “Why this price?” on any instrument to see every contribution.</p> | |
| 107 | + <p className="mt-1">Prices are a weighted median of comparable, independent observations — one vote per upstream family, proxies and validators as confirmations. Open “Why this price?” on any instrument to see the full source coverage matrix.</p> | |
| 92 | 108 | </div> |
| 93 | 109 | <div> |
| 94 | 110 | <h3 className="font-semibold text-ink">Honest freshness</h3> |
modified
apps/web/src/app/sitemap.ts
+1 −1
@@ -26,7 +26,7 @@ export default async function sitemap({ id: rawId }: { id: string | Promise<stri | ||
| 26 | 26 | const id = String(await rawId); // Next 16 passes the segment param as a promise |
| 27 | 27 | const now = new Date(); |
| 28 | 28 | if (id === "static") { |
| 29 | − return ["", "/markets", "/live", "/stocks", "/etfs", "/indices", "/crypto", "/forex", "/rates", "/commodities", "/exchanges", "/countries", "/events", "/halts", "/filings", "/sources", "/connectors", "/data-health", "/status", "/compare", "/methodology", "/developers", "/licensing"].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === "" || p === "/live" ? "always" : "hourly", priority: p === "" ? 1 : 0.7 })); | |
| 29 | + return ["", "/markets", "/live", "/stocks", "/etfs", "/indices", "/crypto", "/forex", "/rates", "/commodities", "/exchanges", "/countries", "/events", "/halts", "/filings", "/sources", "/coverage", "/connectors", "/data-health", "/status", "/compare", "/methodology", "/developers", "/licensing"].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === "" || p === "/live" ? "always" : "hourly", priority: p === "" ? 1 : 0.7 })); | |
| 30 | 30 | } |
| 31 | 31 | if (id === "exchanges") { |
| 32 | 32 | const xs = (await get<Array<{ id: string }>>("/v1/exchanges")) ?? []; |
modified
apps/web/src/app/sources/page.tsx
+28 −2
@@ -18,7 +18,16 @@ export default async function SourcesPage() { | ||
| 18 | 18 | const cats = [...new Set(sources.map((s) => s.category))]; |
| 19 | 19 | return ( |
| 20 | 20 | <Page wide> |
| 21 | − <PageHeader kicker="Provenance" title="Sources" lead={`${sources.length} organizations feed the atlas through ${sources.reduce((a, s) => a + s.connectors, 0)} connectors. Each source declares how its data may be used and how fresh it is; endpoints are not published to discourage abuse.`} actions={<Link href="/connectors" className="text-sm text-accent hover:underline">Connector operations →</Link>} /> | |
| 21 | + <PageHeader kicker="Provenance" title="Sources" lead={`${sources.length} organizations feed the atlas through ${sources.reduce((a, s) => a + s.connectors, 0)} connectors. Each source declares how its data may be used and how fresh it is; endpoints are not published to discourage abuse.`} actions={ | |
| 22 | + <> | |
| 23 | + <Link href="/coverage" className="text-sm text-accent hover:underline"> | |
| 24 | + Source coverage → | |
| 25 | + </Link> | |
| 26 | + <Link href="/connectors" className="text-sm text-accent hover:underline"> | |
| 27 | + Connector operations → | |
| 28 | + </Link> | |
| 29 | + </> | |
| 30 | + } /> | |
| 22 | 31 | {cats.map((cat) => ( |
| 23 | 32 | <Section key={cat} title={CATEGORY_LABEL[cat] ?? cat}> |
| 24 | 33 | <div className="overflow-x-auto rounded-md border border-rule bg-surface"> |
@@ -27,6 +36,7 @@ export default async function SourcesPage() { | ||
| 27 | 36 | <tr> |
| 28 | 37 | <th>Source</th> |
| 29 | 38 | <th>Status</th> |
| 39 | + <th>Role</th> | |
| 30 | 40 | <th>Type</th> |
| 31 | 41 | <th>Rights</th> |
| 32 | 42 | <th>Freshness</th> |
@@ -73,6 +83,22 @@ export default async function SourcesPage() { | ||
| 73 | 83 | <td> |
| 74 | 84 | <StatusBadge status={s.status} /> |
| 75 | 85 | </td> |
| 86 | + <td className="text-xs"> | |
| 87 | + {s.role === "validator" ? ( | |
| 88 | + <span className="inline-flex h-5 items-center rounded-[3px] bg-surface-3 px-1.5 text-[10.5px] font-medium uppercase tracking-wide text-ink-2" title="Restricted rights: compared to the consensus, never sets the price, never displayed"> | |
| 89 | + validator | |
| 90 | + </span> | |
| 91 | + ) : ( | |
| 92 | + <span className="inline-flex h-5 items-center rounded-[3px] bg-positive-soft px-1.5 text-[10.5px] font-medium uppercase tracking-wide text-positive" title="Votes in the consensus and is redistributed with attribution"> | |
| 93 | + contributor | |
| 94 | + </span> | |
| 95 | + )} | |
| 96 | + {s.likely_shared_upstream_with && s.likely_shared_upstream_with.length > 0 && ( | |
| 97 | + <div className="mt-1 max-w-[180px] whitespace-normal text-[10.5px] text-warning"> | |
| 98 | + likely shares an upstream with {s.likely_shared_upstream_with.join(", ")} — counted as one family | |
| 99 | + </div> | |
| 100 | + )} | |
| 101 | + </td> | |
| 76 | 102 | <td className="mono text-xs text-ink-2">{s.source_type}</td> |
| 77 | 103 | <td> |
| 78 | 104 | <RightsBadge status={s.rights_status} /> |
@@ -89,7 +115,7 @@ export default async function SourcesPage() { | ||
| 89 | 115 | </Section> |
| 90 | 116 | ))} |
| 91 | 117 | <p className="mt-6 text-xs text-ink-3"> |
| 92 | − Coverage = instruments this source has published a value for since the current process started. Reliability = operational score (availability, latency, parse success, stability), see <Link href="/methodology" className="text-accent hover:underline">methodology</Link>. Rights statuses are explained on the <Link href="/licensing" className="text-accent hover:underline">data rights</Link> page. | |
| 118 | + Role: <strong>contributors</strong> vote in the consensus and are redistributed with attribution; <strong>validators</strong> are sources whose data rights forbid redistribution — Market Atlas compares them to the canonical value to confirm or contest it, but they never set the price and are never displayed. Sources that statistically mirror each other (identical values within seconds) are counted as one family; see <Link href="/coverage" className="text-accent hover:underline">source coverage</Link>. Coverage = instruments this source has published a value for in the last 24 hours. Reliability = operational score (availability, latency, parse success, stability), see <Link href="/methodology" className="text-accent hover:underline">methodology</Link>. Rights statuses are explained on the <Link href="/licensing" className="text-accent hover:underline">data rights</Link> page. | |
| 93 | 119 | </p> |
| 94 | 120 | </Page> |
| 95 | 121 | ); |
modified
apps/web/src/components/layout/site-footer.tsx
+1 −1
@@ -5,7 +5,7 @@ import { Wordmark } from "./logo"; | ||
| 5 | 5 | const COLS: Array<{ title: string; links: Array<[string, string]> }> = [ |
| 6 | 6 | { title: "Markets", links: [["/stocks", "Stocks"], ["/etfs", "ETFs"], ["/indices", "Indices"], ["/crypto", "Crypto"], ["/forex", "Forex"], ["/rates", "Rates"], ["/commodities", "Commodities"]] }, |
| 7 | 7 | { title: "Atlas", links: [["/live", "Live feed"], ["/exchanges", "Exchanges"], ["/countries", "Countries"], ["/events", "Events"], ["/halts", "Halts"], ["/filings", "Filings"], ["/compare", "Compare"]] }, |
| 8 | − { title: "Data", links: [["/sources", "Sources"], ["/connectors", "Connectors"], ["/data-health", "Data health"], ["/status", "Status"], ["/methodology", "Methodology"], ["/licensing", "Data rights"], ["/developers", "Developers"]] }, | |
| 8 | + { title: "Data", links: [["/sources", "Sources"], ["/coverage", "Source coverage"], ["/connectors", "Connectors"], ["/data-health", "Data health"], ["/status", "Status"], ["/methodology", "Methodology"], ["/licensing", "Data rights"], ["/developers", "Developers"]] }, | |
| 9 | 9 | ]; |
| 10 | 10 | |
| 11 | 11 | export function SiteFooter() { |
modified
apps/web/src/components/layout/site-header.tsx
+1 −1
@@ -14,7 +14,7 @@ const NAV: Array<{ href: string; label: string; match?: RegExp }> = [ | ||
| 14 | 14 | { href: "/live", label: "Live" }, |
| 15 | 15 | { href: "/events", label: "Events", match: /^\/(events|halts|filings)/ }, |
| 16 | 16 | { href: "/exchanges", label: "World", match: /^\/(exchanges|countries)/ }, |
| 17 | − { href: "/sources", label: "Sources", match: /^\/(sources|connectors|data-health|status)/ }, | |
| 17 | + { href: "/sources", label: "Sources", match: /^\/(sources|connectors|data-health|status|coverage)/ }, | |
| 18 | 18 | { href: "/compare", label: "Compare" }, |
| 19 | 19 | ]; |
| 20 | 20 | |
added
apps/web/src/components/market/coverage-badge.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { COVERAGE_STATUS_LABEL, COVERAGE_STATUS_TONE, TIER_LABEL, TIER_TARGET } from "@/lib/coverage"; | |
| 3 | +import { cx } from "@/lib/format"; | |
| 4 | +import type { InstrumentCoverage } from "@/lib/types"; | |
| 5 | + | |
| 6 | +/** "N / T independent sources" + tier chip + coverage status. Works in server and client components. */ | |
| 7 | +export function CoverageBadge({ coverage: c, className, compact }: { coverage: InstrumentCoverage; className?: string; compact?: boolean }) { | |
| 8 | + const met = c.families >= c.target.families; | |
| 9 | + return ( | |
| 10 | + <span className={cx("inline-flex flex-wrap items-center gap-1.5 text-xs", className)} title={`${TIER_LABEL[c.tier]} — target ${TIER_TARGET[c.tier]}. ${c.observations} fresh observation(s), ${c.proxies} proxy confirmation(s), ${c.validators} validator(s).`}> | |
| 11 | + <span className={cx("mono inline-flex h-5 items-center rounded-[3px] px-1.5 font-medium tnum", met ? "bg-positive-soft text-positive" : "bg-surface-3 text-ink-2")}> | |
| 12 | + {c.families} / {c.target.families} independent source{c.target.families === 1 ? "" : "s"} | |
| 13 | + </span> | |
| 14 | + <Link href={`/coverage?tier=${c.tier}`} className="inline-flex h-5 items-center rounded-[3px] border border-rule px-1.5 text-[10.5px] font-medium uppercase tracking-wide text-ink-2 hover:border-rule-strong" title={TIER_TARGET[c.tier]}> | |
| 15 | + tier {c.tier} | |
| 16 | + </Link> | |
| 17 | + {!compact && <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[c.status])}>{COVERAGE_STATUS_LABEL[c.status]}</span>} | |
| 18 | + {!compact && (c.proxies > 0 || c.validators > 0) && ( | |
| 19 | + <span className="text-[11px] text-ink-3"> | |
| 20 | + {c.proxies > 0 ? `+${c.proxies} prox${c.proxies === 1 ? "y" : "ies"}` : ""} | |
| 21 | + {c.proxies > 0 && c.validators > 0 ? " · " : ""} | |
| 22 | + {c.validators > 0 ? `+${c.validators} validator${c.validators === 1 ? "" : "s"}` : ""} | |
| 23 | + </span> | |
| 24 | + )} | |
| 25 | + </span> | |
| 26 | + ); | |
| 27 | +} | |
modified
apps/web/src/components/market/instrument-header.tsx
+5 −3
@@ -7,10 +7,11 @@ import { LivePrice } from "@/components/ui/price"; | ||
| 7 | 7 | import { StatusBadge } from "@/components/ui/status-badge"; |
| 8 | 8 | import { ASSET_CLASS_LABEL } from "@/lib/format"; |
| 9 | 9 | import { useLiveQuote, useMarketStream } from "@/lib/stream"; |
| 10 | −import type { Exchange, Instrument, Quote } from "@/lib/types"; | |
| 10 | +import type { Exchange, Instrument, InstrumentCoverage, Quote } from "@/lib/types"; | |
| 11 | +import { CoverageBadge } from "@/components/market/coverage-badge"; | |
| 11 | 12 | |
| 12 | 13 | /** Sticky quote header: symbol, name, live price + change, status badge, freshness, confidence. */ |
| 13 | −export function InstrumentHeader({ instrument: i, quote, exchange }: { instrument: Instrument; quote: Quote | null; exchange: (Exchange & { status?: Exchange["status"] }) | null }) { | |
| 14 | +export function InstrumentHeader({ instrument: i, quote, exchange, coverage }: { instrument: Instrument; quote: Quote | null; exchange: (Exchange & { status?: Exchange["status"] }) | null; coverage?: InstrumentCoverage }) { | |
| 14 | 15 | useMarketStream([`quotes:${i.id}`, `events:${i.id}`]); |
| 15 | 16 | const live = useLiveQuote(i.id); |
| 16 | 17 | const sources = live && quote && live.received >= Date.parse(quote.updated_at) ? live.sources : quote?.source_count; |
@@ -53,7 +54,8 @@ export function InstrumentHeader({ instrument: i, quote, exchange }: { instrumen | ||
| 53 | 54 | </div> |
| 54 | 55 | <div className="flex flex-wrap items-center gap-3"> |
| 55 | 56 | <FreshnessLabel quote={quote} exchangeTz={exchange?.timezone} /> |
| 56 | − <ConfidenceMeter confidence={conf} sources={sources} /> | |
| 57 | + <ConfidenceMeter confidence={conf} sources={sources} proxies={quote?.proxy_count} validators={quote?.validator_count} /> | |
| 58 | + {coverage && <CoverageBadge coverage={coverage} compact className="hidden sm:inline-flex" />} | |
| 57 | 59 | </div> |
| 58 | 60 | </div> |
| 59 | 61 | </div> |
modified
apps/web/src/components/market/instrument-table.tsx
+1 −1
@@ -52,7 +52,7 @@ export function InstrumentTable({ rows, liveClass, showClass, showExchange, defa | ||
| 52 | 52 | { key: "price", header: "Price", num: true, sort: (r) => r.quote?.price, cell: (r) => <LivePrice instrumentId={r.id} quote={r.quote} assetClass={r.asset_class} showChange={false} /> }, |
| 53 | 53 | { key: "change", header: "Chg %", num: true, sort: (r) => r.quote?.change_percent, cell: (r) => <LiveChange row={r} /> }, |
| 54 | 54 | ...(compact ? [] : [{ key: "volume", header: "Volume", num: true, hideBelow: "md" as const, sort: (r: InstrumentWithQuote) => r.quote?.volume, cell: (r: InstrumentWithQuote) => <LiveVolume row={r} /> }]), |
| 55 | − { key: "conf", header: "Confidence", hideBelow: "sm", sort: (r) => r.quote?.confidence, cell: (r) => <ConfidenceMeter confidence={r.quote?.confidence} sources={r.quote?.source_count} /> }, | |
| 55 | + { key: "conf", header: "Confidence", hideBelow: "sm", sort: (r) => r.quote?.confidence, cell: (r) => <ConfidenceMeter confidence={r.quote?.confidence} sources={r.quote?.source_count} proxies={r.quote?.proxy_count} validators={r.quote?.validator_count} /> }, | |
| 56 | 56 | { key: "status", header: "Status", sort: (r) => r.quote?.data_status, cell: (r) => <StatusBadge status={r.quote?.data_status} /> }, |
| 57 | 57 | ...(compact ? [] : [{ key: "fresh", header: "Freshness", hideBelow: "lg" as const, cell: (r: InstrumentWithQuote) => <FreshnessLabel quote={r.quote} /> }]), |
| 58 | 58 | ]; |
modified
apps/web/src/components/market/provenance-panel.tsx
+79 −29
@@ -3,12 +3,19 @@ | ||
| 3 | 3 | import { ChevronDown } from "lucide-react"; |
| 4 | 4 | import Link from "next/link"; |
| 5 | 5 | import { useEffect, useState } from "react"; |
| 6 | +import { CoverageBadge } from "@/components/market/coverage-badge"; | |
| 6 | 7 | import { RightsBadge, StatusBadge } from "@/components/ui/status-badge"; |
| 7 | 8 | import { clientApi } from "@/lib/client-api"; |
| 9 | +import { COMPARABILITY_LABEL, OBSERVATION_TYPE_LABEL, ROLE_LABEL, deltaTone, formatDeltaBps, reasonLabel, roleOf } from "@/lib/coverage"; | |
| 8 | 10 | import { cx, formatBps, formatDateTime, formatDuration, formatQuoteValue } from "@/lib/format"; |
| 9 | 11 | import type { Provenance } from "@/lib/types"; |
| 10 | 12 | |
| 11 | −/** "Why this price?" — every contribution behind the canonical value, included or not, with the reason. */ | |
| 13 | +const ROLE_TONE: Record<string, string> = { votes: "bg-positive-soft text-positive", confirms: "bg-accent-soft text-accent", validates: "bg-surface-3 text-ink-2", excluded: "bg-stale-soft text-stale" }; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * "Why this price?" — the Source Coverage Matrix: every contribution behind the canonical value with its | |
| 17 | + * observation type, its distance to the consensus and its role (votes / confirms / validates / excluded). | |
| 18 | + */ | |
| 12 | 19 | export function ProvenancePanel({ instrumentId, assetClass, defaultOpen = false, className }: { instrumentId: string; assetClass?: string | null; defaultOpen?: boolean; className?: string }) { |
| 13 | 20 | const [open, setOpen] = useState(defaultOpen); |
| 14 | 21 | const [data, setData] = useState<Provenance | null>(null); |
@@ -27,12 +34,15 @@ export function ProvenancePanel({ instrumentId, assetClass, defaultOpen = false, | ||
| 27 | 34 | clearInterval(t); |
| 28 | 35 | }; |
| 29 | 36 | }, [open, instrumentId]); |
| 37 | + const canonicalClass = data?.comparability ?? null; | |
| 38 | + const included = data?.contributions.filter((c) => c.included) ?? []; | |
| 39 | + const voters = included.filter((c) => roleOf(c) === "votes"); | |
| 30 | 40 | return ( |
| 31 | 41 | <div className={cx("rounded-md border border-rule bg-surface", className)}> |
| 32 | 42 | <button type="button" onClick={() => setOpen((o) => !o)} className="flex h-12 w-full items-center justify-between px-4 text-left" aria-expanded={open}> |
| 33 | 43 | <span className="text-sm font-semibold">Why this price?</span> |
| 34 | 44 | <span className="flex items-center gap-2 text-xs text-ink-3"> |
| 35 | − provenance & consensus | |
| 45 | + source coverage matrix | |
| 36 | 46 | <ChevronDown size={16} className={cx("transition-transform", open && "rotate-180")} /> |
| 37 | 47 | </span> |
| 38 | 48 | </button> |
@@ -42,55 +52,95 @@ export function ProvenancePanel({ instrumentId, assetClass, defaultOpen = false, | ||
| 42 | 52 | {!data && !err && <div className="text-sm text-ink-3">Loading provenance…</div>} |
| 43 | 53 | {data && ( |
| 44 | 54 | <> |
| 55 | + {data.coverage && ( | |
| 56 | + <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> | |
| 57 | + <CoverageBadge coverage={data.coverage} /> | |
| 58 | + <Link href="/coverage" className="text-xs text-accent hover:underline"> | |
| 59 | + Source coverage → | |
| 60 | + </Link> | |
| 61 | + </div> | |
| 62 | + )} | |
| 45 | 63 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
| 46 | − <Fact label="Canonical price" value={data.canonical_price == null ? "withheld" : formatQuoteValue(data.canonical_price, assetClass, data.currency)} /> | |
| 47 | − <Fact label="Independent sources" value={`${data.independent_sources}`} sub={`${data.contributions.filter((c) => c.included).length} observations included`} /> | |
| 48 | − <Fact label="Dispersion" value={formatBps(data.dispersion_bps)} sub="spread between included sources" /> | |
| 64 | + <Fact label="Canonical price" value={data.canonical_price == null ? "withheld" : formatQuoteValue(data.canonical_price, assetClass, data.currency)} sub={canonicalClass ? `= ${COMPARABILITY_LABEL[canonicalClass]}` : undefined} /> | |
| 65 | + <Fact label="Independent sources" value={`${data.independent_sources}`} sub={`${voters.length} voting · ${data.proxy_confirmations ?? 0} confirming · ${data.validator_confirmations ?? 0} validating`} /> | |
| 66 | + <Fact label="Dispersion" value={formatBps(data.dispersion_bps)} sub="spread between voting sources" /> | |
| 49 | 67 | <Fact label="Confidence" value={`${Math.round(data.confidence * 100)}%`} sub={`newest observation ${formatDuration(data.freshness_ms)} old`} /> |
| 50 | 68 | </div> |
| 51 | 69 | <p className="mt-3 text-xs leading-relaxed text-ink-3"> |
| 52 | 70 | <span className="font-medium text-ink-2">Method (consensus v{data.consensus_version}):</span> {data.method}. Status <StatusBadge status={data.realtime_status} /> · rights <RightsBadge status={data.rights_status} /> · computed {formatDateTime(data.computed_at, { seconds: true })}. |
| 53 | 71 | </p> |
| 72 | + {data.shared_upstream_pairs && data.shared_upstream_pairs.length > 0 && ( | |
| 73 | + <p className="mt-2 rounded border border-rule bg-warning-soft px-2.5 py-1.5 text-xs text-ink-2"> | |
| 74 | + <span className="font-medium">Likely shared upstream:</span>{" "} | |
| 75 | + {data.shared_upstream_pairs.map((p) => `${p.sourceA} ≈ ${p.sourceB} (similarity ${(p.similarity * 100).toFixed(1)} % over ${p.samples} samples)`).join("; ")} — these sources are counted as one family. | |
| 76 | + </p> | |
| 77 | + )} | |
| 54 | 78 | <div className="mt-3 overflow-x-auto"> |
| 55 | 79 | <table className="table-dense"> |
| 56 | 80 | <thead> |
| 57 | 81 | <tr> |
| 58 | 82 | <th>Source</th> |
| 83 | + <th>Type</th> | |
| 59 | 84 | <th className="text-right">Value</th> |
| 60 | 85 | <th className="text-right">Age</th> |
| 86 | + <th className="text-right">Δ consensus</th> | |
| 87 | + <th>Role</th> | |
| 61 | 88 | <th className="text-right">Weight</th> |
| 62 | − <th>Included</th> | |
| 63 | − <th>Class</th> | |
| 64 | 89 | <th className="text-right">Reliability</th> |
| 65 | 90 | </tr> |
| 66 | 91 | </thead> |
| 67 | 92 | <tbody> |
| 68 | − {data.contributions.map((c) => ( | |
| 69 | − <tr key={`${c.source_id}-${c.connector_id}`} className={cx(!c.included && "text-ink-3")}> | |
| 70 | − <td> | |
| 71 | − <Link href={`/sources#${c.source_id}`} className="hover:underline"> | |
| 72 | − {c.source?.name ?? c.source_id} | |
| 73 | − </Link> | |
| 74 | − <span className="mono block text-[10.5px] text-ink-3"> | |
| 75 | − {c.connector_id} | |
| 76 | − {c.source?.family && c.source.family !== c.source_id ? ` · family ${c.source.family}` : ""} | |
| 77 | − </span> | |
| 78 | − </td> | |
| 79 | − <td className="num">{c.value == null ? "withheld" : formatQuoteValue(c.value, assetClass, data.currency)}</td> | |
| 80 | − <td className="num">{formatDuration(c.age_ms)}</td> | |
| 81 | − <td className="num">{c.weight.toFixed(3)}</td> | |
| 82 | − <td>{c.included ? <span className="text-positive">yes</span> : <span className="text-stale">no · {c.reason ?? "excluded"}</span>}</td> | |
| 83 | − <td> | |
| 84 | − <StatusBadge status={c.realtime_status} /> | |
| 85 | − </td> | |
| 86 | − <td className="num">{Math.round(c.reliability_score * 100)}</td> | |
| 87 | − </tr> | |
| 88 | − ))} | |
| 93 | + {data.contributions.map((c) => { | |
| 94 | + const role = roleOf(c); | |
| 95 | + const withheld = c.value == null; | |
| 96 | + return ( | |
| 97 | + <tr key={`${c.source_id}-${c.connector_id}`} className={cx(role === "excluded" && "text-ink-3")}> | |
| 98 | + <td> | |
| 99 | + <Link href={`/sources#${c.source_id}`} className="hover:underline"> | |
| 100 | + {c.source?.name ?? c.source_id} | |
| 101 | + </Link> | |
| 102 | + <span className="mono block text-[10.5px] text-ink-3"> | |
| 103 | + {c.connector_id} | |
| 104 | + {c.source?.family && c.source.family !== c.source_id ? ` · family ${c.source.family}` : ""} | |
| 105 | + </span> | |
| 106 | + </td> | |
| 107 | + <td className="whitespace-nowrap text-xs"> | |
| 108 | + {c.observation_type ? OBSERVATION_TYPE_LABEL[c.observation_type] : "—"} | |
| 109 | + <span className="block text-[10.5px] text-ink-3"> | |
| 110 | + <StatusBadge status={c.realtime_status} /> | |
| 111 | + </span> | |
| 112 | + </td> | |
| 113 | + <td className="num"> | |
| 114 | + {withheld ? ( | |
| 115 | + <span className="inline-flex items-center gap-1 text-ink-3"> | |
| 116 | + — <span className="rounded-[3px] bg-surface-3 px-1 text-[10px] uppercase tracking-wide">validator</span> | |
| 117 | + </span> | |
| 118 | + ) : ( | |
| 119 | + formatQuoteValue(c.value, assetClass, data.currency) | |
| 120 | + )} | |
| 121 | + </td> | |
| 122 | + <td className="num">{formatDuration(c.age_ms)}</td> | |
| 123 | + <td className={cx("num", deltaTone(c.delta_bps))}> | |
| 124 | + {withheld && role === "validates" ? ( | |
| 125 | + <span title={formatDeltaBps(c.delta_bps)}>{c.reason === "validator_disagrees" ? "disagrees" : c.delta_bps == null ? "—" : "agrees"}</span> | |
| 126 | + ) : ( | |
| 127 | + formatDeltaBps(c.delta_bps) | |
| 128 | + )} | |
| 129 | + </td> | |
| 130 | + <td> | |
| 131 | + <span className={cx("inline-flex h-5 items-center rounded-[3px] px-1.5 text-[10.5px] font-medium uppercase tracking-wide", ROLE_TONE[role])}>{ROLE_LABEL[role]}</span> | |
| 132 | + {!c.included && <span className="block max-w-[260px] whitespace-normal text-[10.5px] leading-tight text-ink-3">{reasonLabel(c.reason, c.observation_type, canonicalClass)}</span>} | |
| 133 | + </td> | |
| 134 | + <td className="num">{c.included ? c.weight.toFixed(3) : "—"}</td> | |
| 135 | + <td className="num">{Math.round(c.reliability_score * 100)}</td> | |
| 136 | + </tr> | |
| 137 | + ); | |
| 138 | + })} | |
| 89 | 139 | </tbody> |
| 90 | 140 | </table> |
| 91 | 141 | </div> |
| 92 | 142 | <p className="mt-2 text-[11px] text-ink-3"> |
| 93 | − Reliability is the operational score of the connector (availability, latency, parse success, stability), not a judgement of the venue. See <Link href="/methodology" className="text-accent hover:underline">methodology</Link>. | |
| 143 | + Votes set the canonical value; proxies (stablecoin markets, derived crosses) confirm it with reduced weight and never count as independent families; validators (restricted rights) are compared but never displayed. Observations of another class (official fixing vs live market, last close vs today) are reported, never mixed. Reliability is the operational score of the connector, not a judgement of the venue. See <Link href="/methodology#source-mesh" className="text-accent hover:underline">methodology</Link>. | |
| 94 | 144 | </p> |
| 95 | 145 | </> |
| 96 | 146 | )} |
added
apps/web/src/components/market/redundancy-panel.tsx
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import { cx } from "@/lib/format"; | |
| 3 | +import type { CoverageSummary } from "@/lib/types"; | |
| 4 | + | |
| 5 | +/** Source Redundancy block (server component). Numbers come from the coverage engine, never hard-coded. */ | |
| 6 | +export function RedundancyPanel({ coverage: c, className, prominent }: { coverage: CoverageSummary; className?: string; prominent?: boolean }) { | |
| 7 | + const f = c.byFamilies; | |
| 8 | + const bars: Array<[string, number, string]> = [ | |
| 9 | + ["≥ 5 sources", f[">=5"], "bg-positive"], | |
| 10 | + ["≥ 3", f[">=3"] - f[">=5"], "bg-accent"], | |
| 11 | + ["2", f[">=2"] - f[">=3"], "bg-warning"], | |
| 12 | + ["single source", f["1"], "bg-stale"], | |
| 13 | + ]; | |
| 14 | + const pct = (n: number) => (c.quoted ? (n / c.quoted) * 100 : 0); | |
| 15 | + const tierA = c.tiers.A; | |
| 16 | + return ( | |
| 17 | + <div className={cx("rounded-md border border-rule bg-surface p-4", className)}> | |
| 18 | + <div className="flex items-baseline justify-between gap-3"> | |
| 19 | + <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Source redundancy</h3> | |
| 20 | + <Link href="/coverage" className="text-xs text-accent hover:underline"> | |
| 21 | + Source coverage → | |
| 22 | + </Link> | |
| 23 | + </div> | |
| 24 | + <div className={cx("mt-1 flex flex-wrap items-baseline gap-x-2", prominent && "gap-x-3")}> | |
| 25 | + <span className={cx("mono font-semibold tnum", prominent ? "text-3xl" : "text-2xl")}>{c.multiSource}</span> | |
| 26 | + <span className="text-sm text-ink-2"> | |
| 27 | + of <span className="mono tnum">{c.quoted}</span> quoted instruments observed through ≥ 2 independent source families | |
| 28 | + </span> | |
| 29 | + </div> | |
| 30 | + <div className="mt-3 flex h-2 overflow-hidden rounded-full bg-surface-3" aria-hidden> | |
| 31 | + {bars.map(([label, n, cls]) => (n > 0 ? <span key={label} className={cls} style={{ width: `${pct(n)}%` }} title={`${n} ${label}`} /> : null))} | |
| 32 | + </div> | |
| 33 | + <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11.5px] sm:grid-cols-4"> | |
| 34 | + {bars.map(([label, n, cls]) => ( | |
| 35 | + <span key={label} className="inline-flex items-center gap-1.5 text-ink-2"> | |
| 36 | + <span className={cx("inline-block h-1.5 w-1.5 rounded-full", cls)} /> | |
| 37 | + <span className="mono tnum">{n}</span> {label} | |
| 38 | + </span> | |
| 39 | + ))} | |
| 40 | + </div> | |
| 41 | + <div className="mt-3 border-t border-rule pt-3"> | |
| 42 | + <div className="flex items-baseline justify-between text-xs"> | |
| 43 | + <span className="text-ink-3">Weighted redundancy score</span> | |
| 44 | + <span className="mono tnum font-medium">{c.weightedScore.toFixed(1)}%</span> | |
| 45 | + </div> | |
| 46 | + <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-3"> | |
| 47 | + <span className="block h-full rounded-full bg-accent" style={{ width: `${Math.min(100, c.weightedScore)}%` }} /> | |
| 48 | + </div> | |
| 49 | + <dl className="mt-2 grid grid-cols-2 gap-x-4 text-xs"> | |
| 50 | + <Row k="Tier A majors covered" v={`${tierA.covered} / ${tierA.instruments}`} sub={`${tierA.attainment.toFixed(0)}% · target ≥ 3 families`} /> | |
| 51 | + <Row k="Tier B liquid covered" v={`${c.tiers.B.covered} / ${c.tiers.B.instruments}`} sub={`${c.tiers.B.attainment.toFixed(0)}% · target ≥ 2 families`} /> | |
| 52 | + <Row k="Proxies confirming" v={`${c.proxiesConfirming}`} sub="stablecoin markets, crosses" /> | |
| 53 | + <Row k="Validators confirming" v={`${c.validatorsConfirming}`} sub="restricted-rights cross-checks" /> | |
| 54 | + </dl> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + ); | |
| 58 | +} | |
| 59 | + | |
| 60 | +function Row({ k, v, sub }: { k: string; v: string; sub?: string }) { | |
| 61 | + return ( | |
| 62 | + <div className="border-b border-rule py-1"> | |
| 63 | + <div className="flex items-baseline justify-between"> | |
| 64 | + <dt className="text-ink-3">{k}</dt> | |
| 65 | + <dd className="mono tnum">{v}</dd> | |
| 66 | + </div> | |
| 67 | + {sub && <div className="text-[10.5px] text-ink-3">{sub}</div>} | |
| 68 | + </div> | |
| 69 | + ); | |
| 70 | +} | |
modified
apps/web/src/components/ui/confidence.tsx
+5 −2
@@ -1,18 +1,21 @@ | ||
| 1 | 1 | import { cx } from "@/lib/format"; |
| 2 | 2 | |
| 3 | 3 | /** Sources + confidence meter. Confidence is Market Atlas' consensus confidence (0–1), never a market prediction. */ |
| 4 | −export function ConfidenceMeter({ confidence, sources, className, compact }: { confidence: number | null | undefined; sources: number | null | undefined; className?: string; compact?: boolean }) { | |
| 4 | +export function ConfidenceMeter({ confidence, sources, proxies, validators, className, compact }: { confidence: number | null | undefined; sources: number | null | undefined; proxies?: number | null; validators?: number | null; className?: string; compact?: boolean }) { | |
| 5 | 5 | const c = confidence ?? 0; |
| 6 | 6 | const pct = Math.round(c * 100); |
| 7 | 7 | const tone = c >= 0.85 ? "bg-positive" : c >= 0.6 ? "bg-accent" : c > 0 ? "bg-warning" : "bg-stale"; |
| 8 | + const extra = (proxies ?? 0) + (validators ?? 0); | |
| 9 | + const extraTitle = extra ? ` · confirmed by ${proxies ?? 0} prox${proxies === 1 ? "y" : "ies"} and ${validators ?? 0} validator${validators === 1 ? "" : "s"}` : ""; | |
| 8 | 10 | return ( |
| 9 | − <span className={cx("inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-ink-2", className)} title={`Consensus confidence ${pct}% · ${sources ?? 0} independent source${sources === 1 ? "" : "s"}`}> | |
| 11 | + <span className={cx("inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-ink-2", className)} title={`Consensus confidence ${pct}% · ${sources ?? 0} independent source${sources === 1 ? "" : "s"}${extraTitle}`}> | |
| 10 | 12 | <span className="inline-block h-1.5 w-10 overflow-hidden rounded-full bg-surface-3"> |
| 11 | 13 | <span className={cx("block h-full rounded-full", tone)} style={{ width: `${pct}%` }} /> |
| 12 | 14 | </span> |
| 13 | 15 | {!compact && ( |
| 14 | 16 | <span className="mono tnum"> |
| 15 | 17 | {pct}% · {sources ?? 0} src |
| 18 | + {extra > 0 && <span className="text-accent" aria-label={`plus ${extra} confirmations`}>{`+${extra}`}</span>} | |
| 16 | 19 | </span> |
| 17 | 20 | )} |
| 18 | 21 | </span> |
added
apps/web/src/lib/coverage.ts
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +// Labels and helpers for Source Mesh v2: observation types, comparability classes, roles, coverage tiers. | |
| 2 | +import type { Comparability, CoverageStatus, CoverageTier, ObservationType } from "./types"; | |
| 3 | + | |
| 4 | +export const OBSERVATION_TYPE_LABEL: Record<ObservationType, string> = { | |
| 5 | + TRADE: "Trade", | |
| 6 | + MID: "Mid", | |
| 7 | + QUOTE: "Quote", | |
| 8 | + INDEX_VALUE: "Index value", | |
| 9 | + INDICATIVE: "Indicative", | |
| 10 | + STABLECOIN_PROXY: "Stablecoin proxy", | |
| 11 | + DERIVED: "Derived", | |
| 12 | + OFFICIAL_FIX: "Official fix", | |
| 13 | + REFERENCE_RATE: "Reference rate", | |
| 14 | + SETTLEMENT: "Settlement", | |
| 15 | + EOD_CLOSE: "EOD close", | |
| 16 | + NAV: "NAV", | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export const COMPARABILITY_LABEL: Record<Comparability, string> = { | |
| 20 | + LIVE: "live market consensus", | |
| 21 | + FIX: "official fixing", | |
| 22 | + EOD: "end-of-day close", | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export const COMPARABILITY_OF: Record<ObservationType, Comparability> = { | |
| 26 | + TRADE: "LIVE", | |
| 27 | + MID: "LIVE", | |
| 28 | + QUOTE: "LIVE", | |
| 29 | + INDEX_VALUE: "LIVE", | |
| 30 | + INDICATIVE: "LIVE", | |
| 31 | + STABLECOIN_PROXY: "LIVE", | |
| 32 | + DERIVED: "LIVE", | |
| 33 | + OFFICIAL_FIX: "FIX", | |
| 34 | + REFERENCE_RATE: "FIX", | |
| 35 | + SETTLEMENT: "EOD", | |
| 36 | + EOD_CLOSE: "EOD", | |
| 37 | + NAV: "EOD", | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export const PROXY_TYPES = new Set<ObservationType>(["STABLECOIN_PROXY", "DERIVED", "INDICATIVE"]); | |
| 41 | + | |
| 42 | +/** Readable copy for exclusion reasons. `canonical` is the comparability class of the canonical value. */ | |
| 43 | +export function reasonLabel(reason: string | null | undefined, type?: ObservationType, canonical?: Comparability | null): string { | |
| 44 | + switch (reason) { | |
| 45 | + case "stale": | |
| 46 | + return "stale — outside its freshness window"; | |
| 47 | + case "not_comparable": { | |
| 48 | + const own = type ? COMPARABILITY_LABEL[COMPARABILITY_OF[type]] : null; | |
| 49 | + const can = canonical ? COMPARABILITY_LABEL[canonical] : null; | |
| 50 | + return own && can ? `not comparable: ${own} vs ${can}` : "not comparable with the canonical class"; | |
| 51 | + } | |
| 52 | + case "temporal_mismatch": | |
| 53 | + return "temporal mismatch — too far from the freshest observation"; | |
| 54 | + case "validation_only": | |
| 55 | + return "validation only — restricted rights, never votes"; | |
| 56 | + case "validator_disagrees": | |
| 57 | + return "validator disagrees with the consensus"; | |
| 58 | + case "outlier": | |
| 59 | + return "outlier — more than 2 % from the median"; | |
| 60 | + case "superseded_by_live": | |
| 61 | + return "superseded by a live source"; | |
| 62 | + default: | |
| 63 | + return reason ?? "excluded"; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +export type Role = "votes" | "confirms" | "validates" | "excluded"; | |
| 68 | + | |
| 69 | +export function roleOf(c: { included: boolean; reason: string | null; observation_type?: ObservationType; rights_status: string }): Role { | |
| 70 | + if (c.included) return c.observation_type && PROXY_TYPES.has(c.observation_type) ? "confirms" : "votes"; | |
| 71 | + if (c.reason === "validation_only" || c.reason === "validator_disagrees") return "validates"; | |
| 72 | + return "excluded"; | |
| 73 | +} | |
| 74 | + | |
| 75 | +export const ROLE_LABEL: Record<Role, string> = { votes: "votes", confirms: "confirms (proxy)", validates: "validates", excluded: "excluded" }; | |
| 76 | + | |
| 77 | +export const TIER_LABEL: Record<CoverageTier, string> = { A: "Tier A · major", B: "Tier B · liquid", C: "Tier C · long tail", D: "Tier D · official reference" }; | |
| 78 | +export const TIER_TARGET: Record<CoverageTier, string> = { A: "≥ 3 independent families, 5 observations", B: "≥ 2 families, 3 observations", C: "1 family, 2 observations", D: "one official source is the truth" }; | |
| 79 | + | |
| 80 | +export const COVERAGE_STATUS_LABEL: Record<CoverageStatus, string> = { COVERED: "covered", UNDERCOVERED: "under-covered", SINGLE_SOURCE: "single source", NO_DATA: "no data" }; | |
| 81 | +export const COVERAGE_STATUS_TONE: Record<CoverageStatus, string> = { | |
| 82 | + COVERED: "bg-positive-soft text-positive", | |
| 83 | + UNDERCOVERED: "bg-warning-soft text-warning", | |
| 84 | + SINGLE_SOURCE: "bg-warning-soft text-warning", | |
| 85 | + NO_DATA: "bg-stale-soft text-stale", | |
| 86 | +}; | |
| 87 | + | |
| 88 | +export function formatDeltaBps(v: number | null | undefined): string { | |
| 89 | + if (v == null || !Number.isFinite(v)) return "—"; | |
| 90 | + const a = Math.abs(v); | |
| 91 | + const s = a < 10 ? a.toFixed(2) : a < 100 ? a.toFixed(1) : Math.round(a).toString(); | |
| 92 | + return `${v > 0 ? "+" : v < 0 ? "−" : ""}${s} bp`; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function deltaTone(v: number | null | undefined): string { | |
| 96 | + if (v == null) return "text-ink-3"; | |
| 97 | + const a = Math.abs(v); | |
| 98 | + return a <= 5 ? "text-positive" : a <= 25 ? "text-ink-2" : a <= 100 ? "text-warning" : "text-negative"; | |
| 99 | +} | |
modified
apps/web/src/lib/types.ts
+67 −0
@@ -3,6 +3,58 @@ | ||
| 3 | 3 | export type DataStatus = "REALTIME" | "DELAYED" | "END_OF_DAY" | "AT_CLOSE" | "STALE" | "WITHHELD" | "INDICATIVE" | "UNKNOWN"; |
| 4 | 4 | export type MarketState = "PRE" | "OPEN" | "HALTED" | "CLOSED" | "POST" | "AUCTION" | "UNKNOWN"; |
| 5 | 5 | |
| 6 | +export type Comparability = "LIVE" | "FIX" | "EOD"; | |
| 7 | +export type ObservationType = "TRADE" | "MID" | "QUOTE" | "INDEX_VALUE" | "INDICATIVE" | "STABLECOIN_PROXY" | "DERIVED" | "OFFICIAL_FIX" | "REFERENCE_RATE" | "SETTLEMENT" | "EOD_CLOSE" | "NAV"; | |
| 8 | +export type CoverageTier = "A" | "B" | "C" | "D"; | |
| 9 | +export type CoverageStatus = "COVERED" | "UNDERCOVERED" | "SINGLE_SOURCE" | "NO_DATA"; | |
| 10 | + | |
| 11 | +export interface InstrumentCoverage { | |
| 12 | + instrumentId: string; | |
| 13 | + symbol: string; | |
| 14 | + name: string; | |
| 15 | + assetClass: string; | |
| 16 | + tier: CoverageTier; | |
| 17 | + target: { tier: CoverageTier; families: number; observations: number }; | |
| 18 | + families: number; | |
| 19 | + observations: number; | |
| 20 | + proxies: number; | |
| 21 | + validators: number; | |
| 22 | + liveSources: number; | |
| 23 | + delayedSources: number; | |
| 24 | + officialSources: number; | |
| 25 | + comparability: Comparability | null; | |
| 26 | + score: number; | |
| 27 | + status: CoverageStatus; | |
| 28 | + gap: number; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface CoverageSummary { | |
| 32 | + quoted: number; | |
| 33 | + multiSource: number; | |
| 34 | + byFamilies: { ">=5": number; ">=3": number; ">=2": number; "1": number; "0": number }; | |
| 35 | + weightedScore: number; | |
| 36 | + tiers: Record<CoverageTier, { instruments: number; covered: number; attainment: number }>; | |
| 37 | + byAssetClass: Record<string, { instruments: number; multiSource: number; meanFamilies: number }>; | |
| 38 | + proxiesConfirming: number; | |
| 39 | + validatorsConfirming: number; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface LineagePair { | |
| 43 | + sourceA: string; | |
| 44 | + sourceB: string; | |
| 45 | + similarity: number; | |
| 46 | + samples: number; | |
| 47 | + instruments: number; | |
| 48 | + likelySharedUpstream: boolean; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export interface CoverageReport { | |
| 52 | + summary: CoverageSummary; | |
| 53 | + targets: Record<CoverageTier, string>; | |
| 54 | + queue: InstrumentCoverage[]; | |
| 55 | + lineage: LineagePair[]; | |
| 56 | +} | |
| 57 | + | |
| 6 | 58 | export interface Envelope<T> { |
| 7 | 59 | data: T; |
| 8 | 60 | meta: Record<string, unknown> & { request_id?: string; timestamp?: string; total?: number }; |
@@ -27,6 +79,10 @@ export interface Quote { | ||
| 27 | 79 | ask: number | null; |
| 28 | 80 | currency: string | null; |
| 29 | 81 | source_count: number; |
| 82 | + observation_count?: number; | |
| 83 | + proxy_count?: number; | |
| 84 | + validator_count?: number; | |
| 85 | + comparability?: Comparability | null; | |
| 30 | 86 | dispersion_bps: number | null; |
| 31 | 87 | confidence: number; |
| 32 | 88 | freshness_ms: number | null; |
@@ -127,6 +183,7 @@ export interface Stats { | ||
| 127 | 183 | events_per_min: number; |
| 128 | 184 | filings_24h: number; |
| 129 | 185 | median_freshness_ms: number | null; |
| 186 | + coverage?: CoverageSummary; | |
| 130 | 187 | stream_clients: number; |
| 131 | 188 | uptime_s: number; |
| 132 | 189 | } |
@@ -167,6 +224,12 @@ export interface Provenance { | ||
| 167 | 224 | confidence: number; |
| 168 | 225 | dispersion_bps: number | null; |
| 169 | 226 | independent_sources: number; |
| 227 | + observations?: number; | |
| 228 | + proxy_confirmations?: number; | |
| 229 | + validator_confirmations?: number; | |
| 230 | + comparability?: Comparability | null; | |
| 231 | + coverage?: InstrumentCoverage; | |
| 232 | + shared_upstream_pairs?: LineagePair[]; | |
| 170 | 233 | freshness_ms: number | null; |
| 171 | 234 | realtime_status: string; |
| 172 | 235 | rights_status: string; |
@@ -184,6 +247,8 @@ export interface Provenance { | ||
| 184 | 247 | weight: number; |
| 185 | 248 | included: boolean; |
| 186 | 249 | reason: string | null; |
| 250 | + observation_type?: ObservationType; | |
| 251 | + delta_bps?: number | null; | |
| 187 | 252 | realtime_status: string; |
| 188 | 253 | rights_status: string; |
| 189 | 254 | reliability_score: number; |
@@ -222,6 +287,8 @@ export interface Source { | ||
| 222 | 287 | description: string | null; |
| 223 | 288 | rights_notes: string | null; |
| 224 | 289 | terms_url: string | null; |
| 290 | + role?: "contributor" | "validator"; | |
| 291 | + likely_shared_upstream_with?: string[]; | |
| 225 | 292 | } |
| 226 | 293 | |
| 227 | 294 | export interface ConnectorPublic { |
modified
docs/FRONTEND.md
+11 −3
@@ -29,11 +29,12 @@ Environment (root `.env` is loaded by `next.config.ts`): `API_URL` (server-side | ||
| 29 | 29 | | `/` | `/v1/stats`, `/v1/markets`, `/v1/events`, `/v1/data-health`, `/v1/changes?window=1h` | hero + telemetry strip (polls stats every 5 s, animated counters), pulse rows, live tape (`tape`), live events (`events:*`), world map, movers, source health, what changed | |
| 30 | 30 | | `/live` | `/v1/events?limit=100` + stream `events:*`, `tape` | filters: asset class, country, event types, severity, min confidence, include quotes; pause/resume freezes the list | |
| 31 | 31 | | `/markets`, `/stocks`, `/etfs`, `/indices`, `/crypto`, `/forex`, `/rates`, `/commodities` | `/v1/markets`, `/v1/instruments?asset_class=…"ed=1&sort=…` | `ClassPage` (server) → `InstrumentTable` (client, sortable, live via `quotes:class:<CLASS>`); sort/page/q/all in search params | |
| 32 | −| `/instruments/[id]` (+ `/stocks/[symbol]`, `/crypto/[symbol]`, `/etfs/…`, `/indices/…`, `/forex/…` redirects) | `/v1/instruments/:id`, `/v1/history/:id`, `/v1/quotes/:id/provenance`, `/v1/events?instrument=`, `/v1/filings?cik=` | sticky live header, lightweight-charts (1D/5D intraday from consensus bars + live updates; 1M…MAX daily), key stats, **Why this price?** panel (refreshes every 5 s), events, filings, venue, company, aliases, related; JSON-LD `FinancialProduct` | | |
| 32 | +| `/instruments/[id]` (+ `/stocks/[symbol]`, `/crypto/[symbol]`, `/etfs/…`, `/indices/…`, `/forex/…` redirects) | `/v1/instruments/:id`, `/v1/history/:id`, `/v1/quotes/:id/provenance`, `/v1/events?instrument=`, `/v1/filings?cik=` | sticky live header, lightweight-charts (1D/5D intraday from consensus bars + live updates; 1M…MAX daily), key stats, **Why this price?** = Source Coverage Matrix (refreshes every 5 s: observation type, Δ vs consensus in bp, role votes/confirms/validates/excluded with readable exclusion reasons, comparability class, shared-upstream note), `CoverageBadge` "N / T independent sources · tier · status" in the sticky header, section badge and "Observed by" card (`/v1/coverage/:id`), events, filings, venue, company, aliases, related; JSON-LD `FinancialProduct` | | |
| 33 | 33 | | `/exchanges`, `/exchanges/[id]` | `/v1/exchanges`, `/v1/exchanges/:id` | map + table; detail: state, next transition, sessions, holidays, breadth, gainers/losers/most active, events | |
| 34 | 34 | | `/countries`, `/countries/[code]` | `/v1/countries`, `/v1/countries/:code` | atlas by region; detail: venues, indices, rates, FX pairs, equities, events | |
| 35 | 35 | | `/events`, `/events/[id]`, `/halts`, `/filings` | `/v1/events`, `/v1/events/:id`, `/v1/filings` | server-filtered tables (type, severity, instrument, country, before-cursor); halts page streams `events:type:TRADING_HALT` | |
| 36 | −| `/sources`, `/connectors`, `/data-health`, `/status` | `/v1/sources`, `/v1/connectors`, `/v1/data-health`, `/v1/status` | provenance directory (rights + real-time badges, no endpoints), operations table, health with 48 h sparklines and incidents, public status | | |
| 36 | +| `/sources`, `/connectors`, `/data-health`, `/status` | `/v1/sources`, `/v1/connectors`, `/v1/data-health`, `/v1/status` | provenance directory (rights + real-time badges, **role** contributor/validator, "likely shares an upstream with …", no endpoints), operations table, health with 48 h sparklines and incidents, public status | | |
| 37 | +| `/coverage` (`?tier=A|B|C|D&asset_class=`) | `/v1/coverage` | **Source Mesh v2**: redundancy histogram (≥5/≥3/≥2/single), weighted score, tier targets & attainment, by-asset-class table, **source expansion queue** with tier/class pill filters, likely shared upstreams (lineage) | | |
| 37 | 38 | | `/search?q=` + ⌘K palette | `/v1/search` | grouped results; palette debounced 140 ms, keyboard navigation | |
| 38 | 39 | | `/compare?ids=&resolution=` | `/v1/compare` | rebased lines (lightweight-charts), stats table, correlation matrix, instrument picker via search | |
| 39 | 40 | | `/methodology`, `/developers`, `/licensing` | static (+ `/v1/sources` for attributions) | copy consistent with `apps/api/src/core/consensus.ts` | |
@@ -44,7 +45,7 @@ Environment (root `.env` is loaded by `next.config.ts`): `API_URL` (server-side | ||
| 44 | 45 | |
| 45 | 46 | - `components/layout`: `SiteHeader` (desktop nav, ⌘K button, stream indicator, theme toggle, market clock strip NY/London/Frankfurt/Tokyo/HK/Sydney with session dots), `MobileTabBar` (Markets · Live · Search · Events · World), `SiteFooter` (disclaimer, attributions), `SearchProvider`/`SearchDialog`, `ThemeToggle` (persists `ma-theme`; `THEME_SCRIPT` applies it before paint), `LogoMark`/`Wordmark`. |
| 46 | 47 | - `components/ui`: `StatusBadge` (data status / connector state / market state / severity tones), `RightsBadge`, `LivePrice`/`ChangeCell`, `FreshnessLabel` ("Updated 320 ms ago" · "Delayed 15 min · 12s ago" · "At close · Fri 16:00 EDT" · "End of day · Sep 11" · "Stale · last update 4h ago" · "Withheld"), `RelativeTime`, `ConfidenceMeter`, `DataTable` (client, sortable, sticky header, responsive hide), `Sparkline`, `Page`/`PageHeader`/`Section`/`Stat`/`Kv`/`Pill`/`Empty`. |
| 47 | −- `components/market`: `TelemetryStrip`, `PulseRow`, `LiveTape`, `LiveEvents`, `EventRow`, `LiveFeed`, `InstrumentTable`, `ClassPage`, `InstrumentHeader`, `InstrumentStats`, `PriceChart`, `ProvenancePanel`, `CompareView`, `WorldMap` (d3-geo Natural Earth + world-atlas countries-110m, loaded lazily), `HealthPanel`, `ChangesPanel`. | |
| 48 | +- `components/market`: `TelemetryStrip`, `PulseRow`, `LiveTape`, `LiveEvents`, `EventRow`, `LiveFeed`, `InstrumentTable`, `ClassPage`, `InstrumentHeader`, `InstrumentStats`, `PriceChart`, `ProvenancePanel` (coverage matrix), `CoverageBadge`, `RedundancyPanel` (homepage Source Redundancy block from `/v1/stats.coverage`), `CompareView`, `WorldMap` (d3-geo Natural Earth + world-atlas countries-110m, loaded lazily), `HealthPanel`, `ChangesPanel`. | |
| 48 | 49 | - `components/admin/AdminConsole`. |
| 49 | 50 | |
| 50 | 51 | ## Conventions |
@@ -54,3 +55,10 @@ Environment (root `.env` is loaded by `next.config.ts`): `API_URL` (server-side | ||
| 54 | 55 | - Never present stale as live: `data_status` drives every badge and freshness label; `withheld` quotes render provenance but no value. |
| 55 | 56 | - Responsive grids must declare base columns (`grid grid-cols-1 [&>*]:min-w-0 lg:grid-cols-…`) — implicit `auto` columns caused horizontal overflow at 390 px. QA script asserts `scrollWidth ≤ clientWidth`. |
| 56 | 57 | - Server components never import `stream.ts`; client components never import `lib/api.ts` (`server-only`). |
| 58 | + | |
| 59 | +## Source Mesh v2 (2026-09-13) | |
| 60 | + | |
| 61 | +- `lib/coverage.ts`: labels for observation types (TRADE, QUOTE, OFFICIAL_FIX, EOD_CLOSE, STABLECOIN_PROXY…), comparability classes (LIVE / FIX / EOD), `roleOf()` (votes · confirms · validates · excluded), readable `reasonLabel()` (stale, not comparable, temporal mismatch, validation only, validator disagrees, outlier), tier labels/targets, `formatDeltaBps` / `deltaTone`. | |
| 62 | +- `ConfidenceMeter` accepts `proxies` / `validators` and shows a small `+N` marker when a quote is confirmed by proxies or validators (instrument tables, header). | |
| 63 | +- Validator contributions arrive with `value: null` (withheld) and are rendered as "— validator" with agrees/disagrees from `delta_bps`; they are never shown as values. | |
| 64 | +- Homepage: `RedundancyPanel` (prominent) + mesh explainer; footer/sitemap/header include `/coverage`. | |
| 57 | 65 | |