SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
8.6 KB · 114 lines tsx
Raw Blame History
1import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_THRESHOLD, PREMIUM_THRESHOLD } from '@rareindex/valuation';2import type { AssetDetail, VariantRow } from '@/lib/queries/assets';3import { confidenceLabel, fmtMoney, fmtNum, fmtPct, fmtRelative } from '@/lib/format';4import { ScoreExplainer } from '@/components/ui/score-explainer';56/*7 * Asset-specific explainers (§204). Every component mirrors packages/valuation/src/{scores,valuation}.ts;8 * weights and scales are the ones in code. Inputs the platform does not persist per asset (dispersion of9 * log prices, median days between sales, source trust) are shown as "not available" rather than guessed.10 */1112const DAY = 86_400_000;13const pct = (v: number) => `${Math.round(v * 100)} %`;1415export function LiquidityExplainer({ asset, variant, className }: { asset: AssetDetail; variant: VariantRow | null; className?: string }) {16  const score = variant ? variant.liquidityScore : asset.liquidityScore;17  const salesPerMonth = variant ? null : asset.sales1y / 12;18  const activeListings = variant ? variant.activeListings : asset.activeListings;19  const minAsk = variant ? variant.minAskUsd : asset.minAskUsd;20  const riv = variant ? variant.rivUsd : asset.rivUsd;21  const spread = minAsk !== null && riv !== null && riv > 0 ? (minAsk - riv) / riv : null;22  return (23    <ScoreExplainer24      className={className}25      title={`Liquidity Score ${score === null ? '—' : `${Math.round(score)}/100`}`}26      anchor="liquidity"27      formula="100 × (0.40·sales pace + 0.15·listing depth + 0.10·source breadth + 0.20·sale spacing + 0.15·ask–RIV spread); each component clamped to 0–1"28      rows={[29        { label: 'Sales per month (12-month average)', value: salesPerMonth === null ? null : fmtNum(salesPerMonth, { digits: 1 }), weight: '40 %', note: 'log-scaled, 30 / month → 1' },30        { label: 'Active listings', value: fmtNum(activeListings), weight: '15 %', note: 'log-scaled, 50 listings → 1' },31        { label: 'Distinct sources with evidence', value: variant ? null : fmtNum(asset.sourcesCount), weight: '10 %', note: '(sources − 1) / 4' },32        { label: 'Median days between sales', value: null, weight: '20 %', note: '1 − days / 90 (0 when unknown)' },33        { label: 'Lowest ask vs RIV', value: spread === null ? null : fmtPct(spread, 1), weight: '15 %', note: '1 − |spread| / 50 % (0.5 when no ask)' },34      ]}35      footnote={variant ? 'Variant-level liquidity uses the sales of this variant over the last three years; days between sales and sources are computed but not stored per variant.' : score === null ? 'No sales and no listings observed: the score is not computed rather than set to 0.' : undefined}36    />37  );38}3940export function RarityExplainer({ asset, population, className }: { asset: AssetDetail; population?: number | null; className?: string }) {41  return (42    <ScoreExplainer43      className={className}44      title={`Rarity Score ${asset.rarityScore === null ? '—' : `${Math.round(asset.rarityScore)}/100`}`}45      anchor="rarity"46      formula="weighted mean of the available supply signals (weights renormalised over the signals present); ×0.9 when the graded population grew > 20 % since the previous report"47      rows={[48        { label: 'Graded population (latest report)', value: population === null || population === undefined ? null : fmtNum(population), weight: '40 %', note: '1 − log10(1 + pop) / 5 · 100 k → 0' },49        { label: 'Documented production quantity', value: asset.productionQuantity === null ? null : fmtNum(asset.productionQuantity), weight: '30 %', note: '1 − log10(qty) / 7' },50        { label: 'Sales in the last 12 months', value: asset.sales1y || asset.salesCount ? fmtNum(asset.sales1y) : null, weight: '20 %', note: '1 − log10(1 + sales) / 3 · 1 000 / yr → 0' },51        { label: 'Listings per year (active × 4)', value: asset.activeListings > 0 || asset.sales1y > 0 ? fmtNum(asset.activeListings * 4) : null, weight: '10 %', note: '1 − log10(1 + listings) / 3' },52      ]}53      footnote="Rarity is null when no supply signal exists at all — it is never guessed. Population figures come only from published grading-company reports."54    />55  );56}5758export function ConfidenceExplainer({ asset, variant, windowDays }: { asset: AssetDetail; variant: VariantRow | null; windowDays?: number | null }) {59  const conf = variant ? variant.rivConfidence : asset.rivConfidence;60  const n = variant ? variant.rivSampleSize : asset.rivSampleSize;61  const latestAt = variant ? variant.latestSaleAt : asset.latestSaleAt;62  // recency is measured at the valuation's own computation time (asset_stats.updated_at), keeping the render pure63  const asOf = asset.updatedAt ? new Date(asset.updatedAt).getTime() : null;64  const ageDays = latestAt && asOf ? Math.max(0, (asOf - new Date(latestAt).getTime()) / DAY) : null;65  const sizeScore = n > 0 ? Math.min(1, Math.log2(n + 1) / Math.log2(41)) : 0;66  return (67    <ScoreExplainer68      title={`RIV confidence ${conf === null ? '—' : `${(conf * 100).toFixed(0)} % · ${confidenceLabel(conf)}`}`}69      anchor="confidence"70      formula="0.35·sample size + 0.30·(1 − dispersion) + 0.20·recency + 0.15·source trust; labels High ≥ 75 %, Medium ≥ 50 %, Low > 20 %"71      rows={[72        { label: 'Transactions used', value: `${fmtNum(n)} → ${pct(sizeScore)}`, weight: '35 %', note: 'log2(n + 1) / log2(41) · 40 sales → 1' },73        { label: 'Dispersion of log prices (robust MAD)', value: null, weight: '30 %', note: '1 − σ / 0.6 · 60 % dispersion → 0' },74        { label: 'Age of the latest sale', value: ageDays === null ? null : `${Math.round(ageDays)} d → ${pct(Math.max(0, 1 - ageDays / 365))}`, weight: '20 %', note: '1 − days / 365' },75        { label: 'Source trust × identification confidence', value: null, weight: '15 %', note: 'mean over the sales used' },76      ]}77      footnote={78        windowDays && windowDays > 36579          ? `Fewer than five sales in the last year: the window was extended to ${windowDays} days and confidence is capped at 70 %.`80          : n > 0 && n < 381            ? 'Fewer than three transactions: RIV rests on 1–2 sales (confidence ≤ 30 %) or on grade-adjusted comparables / guide prices (≤ 50 %).'82            : 'Comps-only and guide-only valuations are capped at 50 % and 45 % respectively.'83      }84    />85  );86}8788export function AskVsRivExplainer({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {89  const riv = variant ? variant.rivUsd : asset.rivUsd;90  const conf = variant ? variant.rivConfidence : asset.rivConfidence;91  const n = variant ? variant.rivSampleSize : asset.rivSampleSize;92  const minAsk = variant ? variant.minAskUsd : asset.minAskUsd;93  const best = variant ? null : asset.valueOpportunity;94  const gated = riv !== null && riv > 0 && (conf ?? 0) >= ASK_MIN_CONFIDENCE && n >= ASK_MIN_SAMPLE;95  const verdict = best === null ? (gated ? (minAsk === null ? 'no active ask' : 'no ask passed the gate') : 'valuation does not qualify') : best <= DEAL_THRESHOLD ? 'below RIV' : best >= PREMIUM_THRESHOLD ? 'above RIV' : 'near RIV';96  return (97    <ScoreExplainer98      summary="Why is (or isn't) an ask compared with RIV?"99      title={`Ask vs RIV · ${verdict}`}100      anchor="ask-vs-riv"101      formula="(ask − RIV) / RIV, per variant, only when every gate below passes; asks outside the plausibility band are labelled Data/identity anomaly"102      rows={[103        { label: 'RIV rests on transactions (not comps / guide)', value: riv === null ? null : 'see valuation breakdown', note: 'required' },104        { label: `RIV confidence ≥ ${pct(ASK_MIN_CONFIDENCE)}`, value: conf === null ? null : `${(conf * 100).toFixed(0)} % ${conf >= ASK_MIN_CONFIDENCE ? '✓' : '✗'}`, note: 'required' },105        { label: `Transactions used ≥ ${ASK_MIN_SAMPLE}`, value: `${fmtNum(n)} ${n >= ASK_MIN_SAMPLE ? '✓' : '✗'}`, note: 'required' },106        { label: `Listing identification confidence ≥ ${pct(ASK_MIN_MATCH_CONFIDENCE)}`, value: 'per listing', note: 'required; slabs with an unreadable grade never compare against raw' },107        { label: `Plausibility band ${ASK_ANOMALY_LOW_RATIO}× – ${ASK_ANOMALY_HIGH_RATIO}× RIV`, value: minAsk !== null && riv ? `lowest ask ${fmtMoney(minAsk)} = ${fmtNum(minAsk / riv, { digits: 2 })}× RIV` : null, note: 'outside → anomaly, not a deal' },108        { label: 'Best gated ask vs RIV', value: best === null ? null : fmtPct(best, 1), note: `deal ≤ ${fmtPct(DEAL_THRESHOLD, 0)} · premium ≥ ${fmtPct(PREMIUM_THRESHOLD, 0, false)}` },109      ]}110      footnote={`Analytical data, not advice. ${asset.latestSaleAt ? `Latest sale ${fmtRelative(asset.latestSaleAt)}.` : ''}`}111    />112  );113}114