spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { ChevronDown } from "lucide-react";4import Link from "next/link";5import { useEffect, useState } from "react";6import { CoverageBadge } from "@/components/market/coverage-badge";7import { RightsBadge, StatusBadge } from "@/components/ui/status-badge";8import { clientApi } from "@/lib/client-api";9import { COMPARABILITY_LABEL, OBSERVATION_TYPE_LABEL, ROLE_LABEL, deltaTone, formatDeltaBps, reasonLabel, roleOf } from "@/lib/coverage";10import { cx, formatBps, formatDateTime, formatDuration, formatQuoteValue } from "@/lib/format";11import type { Provenance } from "@/lib/types";1213const 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" };1415/**16 * "Why this price?" — the Source Coverage Matrix: every contribution behind the canonical value with its17 * observation type, its distance to the consensus and its role (votes / confirms / validates / excluded).18 */19export function ProvenancePanel({ instrumentId, assetClass, defaultOpen = false, className }: { instrumentId: string; assetClass?: string | null; defaultOpen?: boolean; className?: string }) {20 const [open, setOpen] = useState(defaultOpen);21 const [data, setData] = useState<Provenance | null>(null);22 const [err, setErr] = useState<string | null>(null);23 useEffect(() => {24 if (!open) return;25 let alive = true;26 const load = () =>27 clientApi<Provenance>(`/v1/quotes/${encodeURIComponent(instrumentId)}/provenance`)28 .then((d) => alive && setData(d))29 .catch((e) => alive && setErr(e instanceof Error ? e.message : "unavailable"));30 load();31 const t = setInterval(load, 5000);32 return () => {33 alive = false;34 clearInterval(t);35 };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");40 return (41 <div className={cx("rounded-md border border-rule bg-surface", className)}>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}>43 <span className="text-sm font-semibold">Why this price?</span>44 <span className="flex items-center gap-2 text-xs text-ink-3">45 source coverage matrix46 <ChevronDown size={16} className={cx("transition-transform", open && "rotate-180")} />47 </span>48 </button>49 {open && (50 <div className="border-t border-rule px-4 pb-4 pt-3">51 {err && <div className="text-sm text-negative">{err}</div>}52 {!data && !err && <div className="text-sm text-ink-3">Loading provenance…</div>}53 {data && (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 )}63 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">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" />67 <Fact label="Confidence" value={`${Math.round(data.confidence * 100)}%`} sub={`newest observation ${formatDuration(data.freshness_ms)} old`} />68 </div>69 <p className="mt-3 text-xs leading-relaxed text-ink-3">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 })}.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 )}78 <div className="mt-3 overflow-x-auto">79 <table className="table-dense">80 <thead>81 <tr>82 <th>Source</th>83 <th>Type</th>84 <th className="text-right">Value</th>85 <th className="text-right">Age</th>86 <th className="text-right">Δ consensus</th>87 <th>Role</th>88 <th className="text-right">Weight</th>89 <th className="text-right">Reliability</th>90 </tr>91 </thead>92 <tbody>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 })}139 </tbody>140 </table>141 </div>142 <p className="mt-2 text-[11px] text-ink-3">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>.144 </p>145 </>146 )}147 </div>148 )}149 </div>150 );151}152153function Fact({ label, value, sub }: { label: string; value: string; sub?: string }) {154 return (155 <div>156 <div className="text-[10.5px] font-medium uppercase tracking-wide text-ink-3">{label}</div>157 <div className="mono text-lg font-semibold tnum">{value}</div>158 {sub && <div className="text-[11px] text-ink-3">{sub}</div>}159 </div>160 );161}162