SPB Git

spb/rareindex

Public

Terminal features: screener, methodology center, market depth & time-to-sell, sale verification labels, homepage hierarchy

- /screener: filter builder (category, grader/grade, RIV, confidence, liquidity, rarity,
  1M/1Y change, sales, listings, spread, drawdown), 6 presets, sortable dense table,
  CSV/JSON export (5 000 rows, as_of + attribution), plausibility gates on every column
- /methodology: 15-section center wired to the valuation constants; ScoreExplainer on
  asset pages (liquidity, rarity, confidence, ask vs RIV) with the asset's real inputs
- market depth (±5/10/20 % bands, best ask vs RIV), days on market and time-to-sale by
  ask band from observed listing lifecycles (category-level fallback, labelled), fair
  buy / fast-typical-patient sell ladder; GET /v1/assets/:id/depth
- sales verification badge (verified / likely / unverified / excluded, heuristic, documented)
- ticker shows only published indices; 'in development' indices moved to a collapsed line;
  homepage reordered (RARE → snapshot → trending → records → radar → opportunities → auctions
  → latest → categories); trending rail quality gates; /indices and /portfolio redirects
- worker: ATH/ATL/drawdown scoped to the representative variant
- catawiki: staleness judged at observation time (fixes a date-dependent test)
- docs: stale PENDING-SCHEMA files removed; METHODOLOGY.md + API.md updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 9f86a54 Browse files

43 changed files +2,087 −156

43 changed files
modified apps/api/package.json +1 −0
@@ -20,6 +20,7 @@
20 20 "@rareindex/database": "workspace:*",
21 21 "@rareindex/shared": "workspace:*",
22 22 "@rareindex/taxonomy": "workspace:*",
23 + "@rareindex/valuation": "workspace:*",
23 24 "fastify": "^5.6.0",
24 25 "fastify-plugin": "^6.0.0",
25 26 "zod": "^4.0.0"
modified apps/api/src/lib/queries.ts +60 −0
@@ -1,4 +1,5 @@
1 1 import { getDb, sql } from '@rareindex/database';
2 +import { fairPrices, liquidationModel, marketDepth, type ListingLifecycle } from '@rareindex/valuation';
2 3
3 4 /**
4 5 * Read-only query helpers for the public API. Plain SQL through drizzle's `sql` tag: every
@@ -154,3 +155,62 @@ export async function platformStats() {
154 155 (select count(*)::int from categories where active) as categories`);
155 156 return r;
156 157 }
158 +
159 +/**
160 + * Market depth (§25–§26) + time-to-sell model (§24, §30, §213–§214) for one asset. Depth uses the
161 + * current asks of the representative variant (all variants when the asset has one); lifecycles use
162 + * the asset's own completed listings, or the category's pooled last-365-day lifecycles when fewer
163 + * than 10 exist (`level: "category"`). Model estimates from observed listings, not advice.
164 + */
165 +export async function assetDepth(assetId: string, variantId?: string) {
166 + const [st] = await rows(sql`select s.riv_variant_id, s.riv_usd, s.riv_low_usd, s.riv_high_usd, a.category_slug,
167 + (select count(*)::int from asset_variants v where v.asset_id = a.id) as variants,
168 + vs.riv_usd as variant_riv, vs.riv_low_usd as variant_low, vs.riv_high_usd as variant_high
169 + from assets a left join asset_stats s on s.asset_id = a.id
170 + left join variant_stats vs on vs.variant_id = ${variantId ?? sql`s.riv_variant_id`}
171 + where a.id = ${assetId}`);
172 + if (!st) return null;
173 + const target = variantId ?? (st.riv_variant_id ? String(st.riv_variant_id) : null);
174 + const riv = variantId ? n(st.variant_riv) : n(st.variant_riv) ?? n(st.riv_usd);
175 + const low = variantId ? n(st.variant_low) : n(st.variant_low) ?? n(st.riv_low_usd);
176 + const high = variantId ? n(st.variant_high) : n(st.variant_high) ?? n(st.riv_high_usd);
177 + const scope = target && Number(st.variants) > 1 ? 'variant' : 'asset';
178 + const asks = await rows(sql`select price_usd from listings l where l.asset_id = ${assetId} and l.availability = 'available' and l.price_usd > 0
179 + ${scope === 'variant' ? sql`and l.variant_id = ${target}` : sql``} and not (l.grader is not null and l.grade is null)`);
180 + const depth = marketDepth(asks.map((a) => n(a.price_usd)), riv);
181 + const lifecycleSelect = sql`l.first_seen_at, l.last_seen_at, l.availability,
182 + case when l.price_usd > 0 and coalesce(vs.riv_usd, s.riv_usd) > 0 then l.price_usd / coalesce(vs.riv_usd, s.riv_usd) end as ask_to_riv`;
183 + let own = await rows(sql`select ${lifecycleSelect} from listings l left join variant_stats vs on vs.variant_id = l.variant_id left join asset_stats s on s.asset_id = l.asset_id
184 + where l.asset_id = ${assetId} and l.availability in ('sold','ended','removed') and l.last_seen_at > l.first_seen_at order by l.last_seen_at desc limit 2000`);
185 + let level: 'asset' | 'category' = 'asset';
186 + if (own.length < 10) {
187 + level = 'category';
188 + own = await rows(sql`select ${lifecycleSelect} from listings l join assets a on a.id = l.asset_id left join variant_stats vs on vs.variant_id = l.variant_id left join asset_stats s on s.asset_id = l.asset_id
189 + where a.category_slug = ${String(st.category_slug)} and l.availability in ('sold','ended','removed') and l.last_seen_at > l.first_seen_at and l.last_seen_at >= now() - interval '365 days'
190 + order by l.last_seen_at desc limit 5000`);
191 + }
192 + const lifecycles = own
193 + .map((x): ListingLifecycle | null => {
194 + const outcome = String(x.availability);
195 + if (outcome !== 'sold' && outcome !== 'ended' && outcome !== 'removed') return null;
196 + return { firstSeen: new Date(String(x.first_seen_at)), lastSeen: new Date(String(x.last_seen_at)), outcome, askToRiv: n(x.ask_to_riv) };
197 + })
198 + .filter((x): x is ListingLifecycle => x !== null);
199 + const model = lifecycles.length ? liquidationModel(lifecycles) : null;
200 + return {
201 + asset_id: assetId,
202 + variant_id: scope === 'variant' ? target : null,
203 + scope,
204 + riv_usd: riv,
205 + depth,
206 + liquidation: model ? { level, lifecycles: lifecycles.length, ...model } : null,
207 + fair_prices: riv !== null ? fairPrices({ riv, low, high }, model) : null,
208 + note: 'Model estimates from observed listings and the published valuation band. Asks are not transactions; only listings marked sold count as sales.',
209 + };
210 +}
211 +
212 +function n(v: unknown): number | null {
213 + if (v === null || v === undefined) return null;
214 + const x = Number(v);
215 + return Number.isFinite(x) ? x : null;
216 +}
modified apps/api/src/openapi.ts +2 −0
@@ -50,6 +50,7 @@ export function openapiDocument() {
50 50 '/v1/assets/{id}': { get: { tags: ['assets'], summary: 'Asset detail with variants, latest valuation and sources', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Asset id (rare_…) or slug' }], responses: { ...ok('#/components/schemas/AssetDetail', false), ...errors } } },
51 51 '/v1/assets/{id}/sales': { get: { tags: ['assets', 'sales'], summary: 'Observed sales for an asset', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, { name: 'include_flagged', in: 'query', schema: { type: 'boolean' } }, ...paging], responses: { ...ok('#/components/schemas/Sale'), ...errors } } },
52 52 '/v1/assets/{id}/listings': { get: { tags: ['assets'], summary: 'Listings for an asset (asks, not transactions)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'availability', in: 'query', schema: { type: 'string', enum: ['available', 'sold', 'ended', 'removed'], default: 'available' } }, ...paging], responses: { ...ok('#/components/schemas/Listing'), ...errors } } },
53 + '/v1/assets/{id}/depth': { get: { tags: ['assets'], summary: 'Market depth (asks around RIV, ±5/10/20 %, RareIndex spread), days on market, time-to-sale by asking band and fair buy/sell ladder — model estimates', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }], responses: { ...ok('#/components/schemas/AssetDepth', false), ...errors } } },
53 54 '/v1/assets/{id}/history': { get: { tags: ['assets'], summary: 'Daily price history (RIV, latest sale, median, volume, listings)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, ...range], responses: { ...ok('#/components/schemas/PricePoint'), ...errors } } },
54 55 '/v1/categories': { get: { tags: ['reference'], summary: 'Taxonomy', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Category'), ...errors } } },
55 56 '/v1/indices': { get: { tags: ['indices'], summary: 'RARE and subindices with latest values and changes', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Index'), ...errors } } },
@@ -83,6 +84,7 @@ export function openapiDocument() {
83 84 Index: { type: 'object', properties: { ticker: { type: 'string' }, name: { type: 'string' }, is_flagship: { type: 'boolean' }, as_of: { type: ['string', 'null'] }, value: { type: ['number', 'null'] }, published: { type: 'boolean' }, change_1d: { type: ['number', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_ytd: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, constituents_count: { type: ['integer', 'null'] }, transactions: { type: ['integer', 'null'] }, market_cap_est_usd: { type: ['number', 'null'] }, market_cap_confidence: { type: ['string', 'null'] } } },
84 85 IndexPoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, value: { type: 'number' }, constituents_count: { type: 'integer' }, transactions: { type: 'integer' }, volume_usd: { type: ['number', 'null'] } } },
85 86 Market: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, as_of: { type: ['string', 'null'] }, index_value: { type: ['number', 'null'] }, tracked_assets: { type: ['integer', 'null'] }, sales: { type: ['integer', 'null'] }, volume_usd: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] } } },
87 + AssetDepth: { type: 'object', properties: { asset_id: { type: 'string' }, variant_id: { type: ['string', 'null'] }, scope: { type: 'string', enum: ['variant', 'asset'] }, riv_usd: { type: ['number', 'null'] }, depth: { type: ['object', 'null'], properties: { asks: { type: 'integer' }, within5: { type: 'integer' }, within10: { type: 'integer' }, within20: { type: 'integer' }, belowRiv: { type: 'integer' }, aboveRiv: { type: 'integer' }, lowestAsk: { type: ['number', 'null'] }, medianAsk: { type: ['number', 'null'] }, askRivSpread: { type: ['number', 'null'], description: '(best ask − RIV) / RIV' } } }, liquidation: { type: ['object', 'null'], properties: { level: { type: 'string', enum: ['asset', 'category'] }, lifecycles: { type: 'integer' }, sold: { type: 'object' }, withdrawn: { type: 'object' }, bands: { type: 'array', items: { type: 'object' } }, sellThrough: { type: ['number', 'null'] } } }, fair_prices: { type: ['object', 'null'] }, note: { type: 'string' } } },
86 88 MarketDetail: { type: 'object', properties: { category: { type: 'object' }, snapshot: { type: ['object', 'null'] }, counts: { type: 'object' }, gainers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, losers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_valuable: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_liquid: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, recent_sales: { type: 'array', items: { type: 'object' } }, history: { type: 'array', items: { type: 'object' } } } },
87 89 Stats: { type: 'object', properties: { assets: { type: 'integer' }, sales: { type: 'integer' }, listings: { type: 'integer' }, sources: { type: 'integer' }, connectors: { type: 'integer' }, categories: { type: 'integer' } } },
88 90 },
modified apps/api/src/routes/v1.ts +11 −0
@@ -55,6 +55,17 @@ export async function v1Routes(app: FastifyInstance) {
55 55 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'Listing prices are asks, not confirmed transactions.' }, req.query as Record<string, unknown>);
56 56 });
57 57
58 + app.get<{ Params: { id: string } }>('/v1/assets/:id/depth', async (req, reply) => {
59 + const p = parse(z.object({ variant: z.string().optional() }), req.query);
60 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
61 + const asset = await q.getAsset(req.params.id);
62 + if (!asset) return problem(reply, 404, 'Asset not found');
63 + const data = await q.assetDepth(asset.id as string, p.data.variant);
64 + if (!data) return problem(reply, 404, 'Asset not found');
65 + reply.header('cache-control', 'public, max-age=120, s-maxage=300');
66 + return reply.send(envelope(data, { asset_id: asset.id }));
67 + });
68 +
58 69 app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {
59 70 const p = parse(Range.extend({ variant: z.string().optional() }), req.query);
60 71 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
modified apps/web/src/app/asset/[slug]/page.tsx +5 −3
@@ -5,7 +5,7 @@ import { Tabs } from '@/components/ui/tabs';
5 5 import { Skeleton } from '@/components/ui/primitives';
6 6 import { AssetHeader } from '@/components/asset/asset-header';
7 7 import { ASSET_TABS, AnalysisTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs';
8 −import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, isWatchedBy } from '@/lib/queries/assets';
8 +import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, getPopulation, isWatchedBy } from '@/lib/queries/assets';
9 9 import { getCurrentUser } from '@/lib/auth/session';
10 10 import { catName, categoryPath } from '@/lib/taxonomy';
11 11 import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
@@ -37,7 +37,9 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
37 37 const found = await getAssetBySlug(slug);
38 38 if (!found) notFound();
39 39 const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview');
40 − const [variants, live, imgs, user] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null)]);
40 + const [variants, live, imgs, user, popReports] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null), getPopulation(found.id)]);
41 + // latest published population total across graders (for the rarity explainer); null when no report exists
42 + const population = popReports.length ? popReports.reduce((a, r) => (r.reportDate > a.reportDate ? r : a)).total : null;
41 43 const watched = await isWatchedBy(user?.id ?? null, found.id);
42 44 // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables.
43 45 const asset = {
@@ -96,7 +98,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
96 98 return (
97 99 <div className="pb-16 md:pb-0">
98 100 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
99 − <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} images={images} watched={watched} />
101 + <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} images={images} watched={watched} population={population} />
100 102 <div className="sticky top-[var(--ri-header-h,56px)] z-30 -mx-4 bg-bg/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-bg/80 sm:static sm:mx-0 sm:bg-transparent sm:px-0 sm:backdrop-blur-none">
101 103 <Tabs
102 104 className="mt-6 mb-4"
modified apps/web/src/app/data/page.tsx +1 −0
@@ -37,6 +37,7 @@ function Markdown({ text }: { text: string }) {
37 37 if (!t) return null;
38 38 if (t.startsWith('# ')) return <h2 key={i} className="text-lg font-semibold text-fg">{t.slice(2)}</h2>;
39 39 if (t.startsWith('## ')) return <h3 key={i} id={t.slice(3).toLowerCase().replace(/[^a-z0-9]+/g, '-')} className="pt-2 text-sm font-semibold text-fg">{t.slice(3)}</h3>;
40 + if (t.startsWith('### ')) return <h4 key={i} id={t.slice(4).toLowerCase().replace(/[^a-z0-9]+/g, '-')} className="pt-1 text-[12px] font-semibold uppercase tracking-wider text-subtle">{t.slice(4)}</h4>;
40 41 if (t.startsWith('- ')) return <ul key={i} className="list-disc space-y-1 pl-5">{t.split('\n').map((l, j) => <li key={j} dangerouslySetInnerHTML={{ __html: inline(l.replace(/^- /, '')) }} />)}</ul>;
41 42 return <p key={i} dangerouslySetInnerHTML={{ __html: inline(t.replace(/\n/g, ' ')) }} />;
42 43 })}
added apps/web/src/app/indices/page.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { redirect } from 'next/navigation';
2 +
3 +/** `/indices` is the conventional path; the RARE index family lives at `/rareindex`. */
4 +export default function IndicesRedirect() {
5 + redirect('/rareindex');
6 +}
added apps/web/src/app/methodology/page.tsx +372 −0
@@ -0,0 +1,372 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import type { ReactNode } from 'react';
4 +import { ASK_ANOMALY_HIGH_RATIO, ASK_ANOMALY_LOW_RATIO, ASK_MIN_CONFIDENCE, ASK_MIN_MATCH_CONFIDENCE, ASK_MIN_SAMPLE, DEAL_THRESHOLD, MAX_PLAUSIBLE_CHANGE, PREMIUM_THRESHOLD } from '@rareindex/valuation';
5 +import { PageHeader } from '@/components/ui/page-header';
6 +import { Badge, Card } from '@/components/ui/primitives';
7 +import { cn } from '@/lib/format';
8 +
9 +export const metadata: Metadata = {
10 + title: 'Methodology Center',
11 + description: 'How every RareIndex number is produced: RareIndex Valuation (RIV), confidence, value range, comparables, the ask-vs-RIV anomaly gate, liquidity, rarity, momentum, outliers, sales verification, indices, FX and data-honesty rules.',
12 +};
13 +export const revalidate = 3600;
14 +
15 +/*
16 + * §203 Methodology Center. Constants that gate numbers on the site are imported from @rareindex/valuation so
17 + * this page cannot drift from the implementation. Constants that live as literals in the engine files are
18 + * quoted with their file so a reader can verify them (valuation.ts, scores.ts, outliers.ts, premiums.ts,
19 + * indices/chain.ts, workers/indices/run.ts).
20 + */
21 +
22 +const pct = (v: number, digits = 0) => `${(v * 100).toFixed(digits)} %`;
23 +
24 +const SECTIONS: Array<{ id: string; title: string }> = [
25 + { id: 'principles', title: 'Data honesty' },
26 + { id: 'riv', title: 'RIV — RareIndex Valuation' },
27 + { id: 'confidence', title: 'Confidence' },
28 + { id: 'range', title: 'Value range' },
29 + { id: 'distribution', title: 'Price distribution' },
30 + { id: 'comparables', title: 'Comparables & grade premiums' },
31 + { id: 'ask-vs-riv', title: 'Ask vs RIV & the anomaly gate' },
32 + { id: 'liquidity', title: 'Liquidity Score' },
33 + { id: 'rarity', title: 'Rarity Score' },
34 + { id: 'momentum', title: 'Momentum & Trending' },
35 + { id: 'outliers', title: 'Outliers' },
36 + { id: 'verification', title: 'Sales verification' },
37 + { id: 'indices', title: 'Indices' },
38 + { id: 'fx', title: 'Currencies' },
39 + { id: 'freshness', title: 'Freshness & limits' },
40 +];
41 +
42 +export default function MethodologyPage() {
43 + return (
44 + <div>
45 + <PageHeader
46 + kicker="Transparency"
47 + title="Methodology Center"
48 + description="Every number on RareIndex is either observed data, a model estimate, user data or an external asking price — and is labelled as such. This page documents how each estimate is produced, what evidence it rests on and the exact thresholds that gate it. Thresholds are read from the valuation engine, not copied by hand."
49 + meta={
50 + <>
51 + <Link href="/data" className="hover:text-fg">
52 + Coverage & sources →
53 + </Link>
54 + <Link href="/rareindex" className="hover:text-fg">
55 + Indices →
56 + </Link>
57 + <Link href="/api-docs" className="hover:text-fg">
58 + API →
59 + </Link>
60 + </>
61 + }
62 + />
63 + <div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start">
64 + <nav aria-label="Methodology sections" className="hidden lg:block lg:sticky lg:top-[calc(var(--ri-header-h)+16px)]">
65 + <ol className="border-l border-border text-[12px]">
66 + {SECTIONS.map((s, i) => (
67 + <li key={s.id}>
68 + <a href={`#${s.id}`} className="-ml-px flex items-baseline gap-2 border-l border-transparent py-1 pl-3 text-muted hover:border-fg hover:text-fg">
69 + <span className="num w-4 shrink-0 text-[10px] text-subtle">{i + 1}</span>
70 + {s.title}
71 + </a>
72 + </li>
73 + ))}
74 + </ol>
75 + </nav>
76 + <div className="grid gap-4">
77 + <div className="lg:hidden">
78 + <details className="card p-3 text-[12px]">
79 + <summary className="cursor-pointer font-medium text-fg">Sections</summary>
80 + <ol className="mt-2 grid gap-1 pl-4 text-muted">
81 + {SECTIONS.map((s) => (
82 + <li key={s.id}>
83 + <a href={`#${s.id}`} className="hover:text-fg">
84 + {s.title}
85 + </a>
86 + </li>
87 + ))}
88 + </ol>
89 + </details>
90 + </div>
91 +
92 + <Section id="principles" n={1} title="Data honesty" lead="Four kinds of numbers appear on the platform. They are never mixed, and a missing number is shown as such (§205–§206).">
93 + <div className="grid gap-2 sm:grid-cols-2">
94 + <Kind tone="gain" name="Observed data" desc="A sale, a listing, an auction lot, a population count or a guide price captured from a public source, stored immutably with its URL, capture time, connector and parser version. Shown with its source link." />
95 + <Kind tone="index" name="Model estimate" desc="RIV, its range and confidence, scores (liquidity, rarity, momentum, trending, deal), indices and returns. Always displayed with sample size, confidence and update time." />
96 + <Kind tone="rarity" name="User data" desc="Purchase prices, quantities, manual valuations and notes in collections and watchlists. Private by default; never used to compute market estimates." />
97 + <Kind tone="alert" name="External ask" desc="An asking price on a marketplace. Never a market value: it is compared with RIV only through the gate in §7 and labelled as an ask everywhere." />
98 + </div>
99 + <P>
100 + RareIndex never fabricates transactions, valuations, bids, populations, sales, historical prices or certification data. Where evidence is missing the interface shows <em>Data unavailable</em> or <em>Not enough data</em>. Valuations are estimates from observed public sales, not offers or appraisals; RareIndex does not authenticate items.
101 + </P>
102 + </Section>
103 +
104 + <Section id="riv" n={2} title="RIV — RareIndex Valuation" lead="RIV estimates the current fair market value of a variant (an asset in one grade/condition) from verified transactions. It is never an arithmetic mean alone.">
105 + <H>Evidence and window</H>
106 + <P>
107 + Inputs are valid transactions of the variant: status <Code>valid</Code>, price &gt; 0, single item (bundles and multi-quantity lots excluded). The default window is <strong>365 days</strong>; when fewer than five recent sales exist and older ones do, it is extended to <strong>3 years</strong> and confidence is capped at 70 %.
108 + </P>
109 + <H>Estimators</H>
110 + <Table
111 + head={['Estimator', 'Definition']}
112 + rows={[
113 + ['Latest', 'most recent transaction in the window'],
114 + ['Median 5 / 10 / 20', 'median of the last 5, 10 and 20 transactions'],
115 + ['Trimmed mean', '10 % trimmed each tail; requires ≥ 5 sales'],
116 + ['EW mean', 'exponentially weighted mean, 90-day half-life'],
117 + ['Weighted mean', 'weight = source trust × identification confidence × 0.5^(age / 90 d)'],
118 + ['Comps-adjusted', 'median of other-grade sales × empirical grade premium (≥ 3 comps) — see §6'],
119 + ['Guide', 'median of the 5 most recent price-guide observations ≤ 60 days old (market / mid / trend kinds preferred)'],
120 + ]}
121 + />
122 + <H>Basis ladder</H>
123 + <Table
124 + head={['Basis', 'When', 'RIV', 'Confidence']}
125 + rows={[
126 + ['transactions', '≥ 3 sales in window', 'median of {EW mean, median 10 (or 5), weighted mean, trimmed mean (or median 5)}', 'computed (§3)'],
127 + ['transactions', '1–2 sales + comps or guide', 'median of {latest, comps or guide}', '0.45 (comps) · 0.40 (guide)'],
128 + ['transactions', '1–2 sales only', 'median of the two, or the single sale', '0.30 · 0.20'],
129 + ['comps', 'no sales, ≥ 3 grade-adjusted comps', 'comps-adjusted median', '0.25 + 0.03 × comps, ≤ 0.50'],
130 + ['guide', 'no sales, guide observations only', 'guide median', '0.20 + 0.05 × obs, ≤ 0.45'],
131 + ['none', 'nothing usable', 'Data unavailable', '—'],
132 + ]}
133 + />
134 + <P>
135 + A <strong>guide</strong> value is a price published by a price guide or marketplace (e.g. a “market price” cell). It is an observation, not a transaction; in the interface it appears as <em>Guide · source</em>, never as RIV, and guide-based RIVs are labelled with a capped confidence. The headline RIV of an asset is the valuation of its representative variant: the default (raw / base) variant when it has a transaction-based RIV with ≥ {ASK_MIN_SAMPLE} sales and confidence ≥ {pct(ASK_MIN_CONFIDENCE)}, otherwise the qualifying variant with the most sales. Period changes are measured on that variant&apos;s own series so a change of representative never shows as a price move.
136 + </P>
137 + <Ref>packages/valuation/src/valuation.ts · workers/valuation/run.ts</Ref>
138 + </Section>
139 +
140 + <Section id="confidence" n={3} title="Confidence" lead="Every RIV carries a 0–1 confidence and a label. Users must be able to see how uncertain a valuation is.">
141 + <Formula>confidence = 0.35 · size + 0.30 · (1 − dispersion) + 0.20 · recency + 0.15 · trust</Formula>
142 + <Table
143 + head={['Term', 'Definition', 'Scale']}
144 + rows={[
145 + ['size', 'log2(n + 1) / log2(41)', '40 transactions → 1'],
146 + ['dispersion', 'robust σ of log prices = 1.4826 × MAD, divided by 0.6', '60 % log-dispersion → 0'],
147 + ['recency', '1 − age of the latest sale / 365 d', 'a year-old latest sale → 0'],
148 + ['trust', 'mean of source trust × identification confidence over the sales used', '0–1'],
149 + ]}
150 + />
151 + <P>
152 + Labels: <Badge tone="gain">High ≥ 75 %</Badge> <Badge tone="index">Medium ≥ 50 %</Badge> <Badge tone="alert">Low &gt; 20 %</Badge> <Badge>Insufficient</Badge>. Window extended to three years → capped at 70 %; comps-only ≤ 50 %; guide-only ≤ 45 %.
153 + </P>
154 + <Ref>packages/valuation/src/valuation.ts (confidence, confidenceLabel)</Ref>
155 + </Section>
156 +
157 + <Section id="range" n={4} title="Value range — low / fair / high" lead="A point estimate is never presented as exact. Each RIV comes with an expected market range.">
158 + <Table
159 + head={['Sample', 'Low', 'High']}
160 + rows={[
161 + ['≥ 5 sales', 'min(P25 of the sales used, RIV × 0.95)', 'max(P75, RIV × 1.05)'],
162 + ['2–4 sales', 'RIV × e^(−max(σ, 0.1)) with σ = sd of log prices', 'RIV × e^(+max(σ, 0.1))'],
163 + ['1 sale / fallback', 'RIV × (1 − 20 %) — 30 % for guide-based', 'RIV × (1 + 20 %) — 30 % for guide-based'],
164 + ]}
165 + />
166 + <P>The band is always at least ±5 % around RIV. On asset pages it is drawn as the shaded band around the RIV line; the current RIV and latest sale are marked on the range meter.</P>
167 + </Section>
168 +
169 + <Section id="distribution" n={5} title="Price distribution" lead="Beyond the point estimate, the dispersion of the transactions themselves is shown.">
170 + <P>For the variant and window, RareIndex reports count, minimum, P25, median, P75, maximum and the 10 % trimmed mean of verified sale prices in USD at historical FX. Flagged outliers (§11) are shown in sales tables for audit but excluded from these statistics.</P>
171 + </Section>
172 +
173 + <Section id="comparables" n={6} title="Comparables & grade premiums" lead="When a variant has too few sales, sales of other grades of the same asset are used — adjusted, never mixed as-is.">
174 + <P>
175 + For each category, grader and grade, the <strong>grade premium</strong> is the median, across assets, of median(graded price) / median(raw price) of the <em>same asset</em>, from at least <strong>8 paired assets</strong>. A comparable from grade A to grade B is adjusted by premium(B) / premium(A). Premiums exist only where paired evidence exists: PSA 10, BGS 10 and CGC 10 are never assumed equivalent, and no cross-grader factor is applied without data.
176 + </P>
177 + <P>Comparable sales on asset pages carry a transparent similarity score (same set, same grade, same variant, price proximity). Grade curves (price by grade) and premiums are published on the Grades tab and on /grading.</P>
178 + <Ref>packages/valuation/src/premiums.ts (computeGradePremiums, adjustmentFactor)</Ref>
179 + </Section>
180 +
181 + <Section id="ask-vs-riv" n={7} title="Ask vs RIV & the anomaly gate" lead="An asking price is compared with a valuation only when the comparison is legitimate. An implausible ask is a data or identity problem, never a deal.">
182 + <Formula>discount = (ask + fees − RIV) / RIV &nbsp;·&nbsp; negative = below the valuation</Formula>
183 + <H>Gates — all must pass</H>
184 + <Table
185 + head={['Gate', 'Threshold']}
186 + rows={[
187 + ['Same variant', 'the ask is compared with the RIV of its own grade/condition variant; a slab whose grade could not be read is kept in a “grade unknown” variant and never compared against raw'],
188 + ['Valuation basis', 'transactions only — comps-only and guide-only RIVs never qualify an ask'],
189 + ['RIV confidence', `≥ ${pct(ASK_MIN_CONFIDENCE)}`],
190 + ['Transactions used', `≥ ${ASK_MIN_SAMPLE}`],
191 + ['Listing identification confidence', `≥ ${pct(ASK_MIN_MATCH_CONFIDENCE)}`],
192 + ['Plausibility band', `ask between ${ASK_ANOMALY_LOW_RATIO}× and ${ASK_ANOMALY_HIGH_RATIO}× RIV — outside it the listing is flagged riv_anomaly and shown as “Data/identity anomaly”`],
193 + ]}
194 + />
195 + <H>Verdicts</H>
196 + <Table
197 + head={['Verdict', 'Rule']}
198 + rows={[
199 + ['Deal', `discount ≤ ${pct(DEAL_THRESHOLD)} (ask at least ${pct(-DEAL_THRESHOLD)} below RIV)`],
200 + ['Fair', `between ${pct(DEAL_THRESHOLD)} and ${pct(PREMIUM_THRESHOLD)}`],
201 + ['Premium', `discount ≥ ${pct(PREMIUM_THRESHOLD)}`],
202 + ['Data/identity anomaly', `ratio outside ${ASK_ANOMALY_LOW_RATIO}×–${ASK_ANOMALY_HIGH_RATIO}× — wrong variant, lot, currency, typo or mismatch until reviewed`],
203 + ['Not compared', 'one of the gates failed; the reason is shown (e.g. riv_sample, riv_basis_guide)'],
204 + ]}
205 + />
206 + <H>Deal Score (0–100)</H>
207 + <Formula>score = 100 · depth^0.7 · (0.4 + 0.6 · confidence) · (0.5 + 0.5 · sample) · (0.5 + 0.5 · liquidity / 100) · match</Formula>
208 + <P>
209 + depth = min(1, −discount / 50 %), sample = log10(1 + n) / log10(41). The components are multiplicative: a huge discount on an illiquid or poorly identified asset cannot score high. The score is 0 for anything that is not a gated discount. Period changes of a valuation beyond ±{MAX_PLAUSIBLE_CHANGE * 100} % are likewise treated as data artefacts and not published as movers.
210 + </P>
211 + <P className="text-subtle">Value opportunities and Deal Radar are analytical data, not investment advice: a discount can also mean a misidentified, damaged or incomplete item.</P>
212 + <Ref>packages/valuation/src/scores.ts (assessAsk, dealScore, isPlausibleChange) · workers/valuation/run.ts</Ref>
213 + </Section>
214 +
215 + <Section id="liquidity" n={8} title="Liquidity Score (0–100)" lead="How readily a variant trades. Null when there is no evidence at all — never 0 by default.">
216 + <Formula>liquidity = 100 · (0.40 · s₁ + 0.15 · s₂ + 0.10 · s₃ + 0.20 · s₄ + 0.15 · s₅)</Formula>
217 + <Table
218 + head={['Component', 'Input', 'Scale (clamped 0–1)', 'Weight']}
219 + rows={[
220 + ['s₁ sales pace', 'sales per month (12-month average at asset level; 36-month at variant level)', 'log10(1 + x) / log10(31) · 30 / month → 1', '40 %'],
221 + ['s₂ listing depth', 'active listings', 'log10(1 + x) / log10(51) · 50 → 1', '15 %'],
222 + ['s₃ source breadth', 'distinct sources with evidence', '(sources − 1) / 4', '10 %'],
223 + ['s₄ sale spacing', 'median days between sales', '1 − days / 90 · 0 when unknown', '20 %'],
224 + ['s₅ ask–RIV spread', '(lowest ask of the representative variant − RIV) / RIV', '1 − |spread| / 50 % · 0.5 when there is no ask', '15 %'],
225 + ]}
226 + />
227 + <P>Labels used in the interface: Highly liquid ≥ 75, Moderately liquid ≥ 50, Illiquid ≥ 25, Extremely illiquid below.</P>
228 + <Ref>packages/valuation/src/scores.ts (liquidityScore)</Ref>
229 + </Section>
230 +
231 + <Section id="rarity" n={9} title="Rarity Score (0–100)" lead="A multi-dimensional supply score. It distinguishes documented supply (population, production) from market supply (how often the object appears).">
232 + <Table
233 + head={['Signal', 'Scale (clamped 0–1)', 'Weight']}
234 + rows={[
235 + ['Graded population (latest published report)', '1 − log10(1 + pop) / 5 · 100 000 → 0', '40 %'],
236 + ['Documented production quantity', '1 − log10(qty) / 7', '30 %'],
237 + ['Sales per year', '1 − log10(1 + sales) / 3 · 1 000 → 0', '20 %'],
238 + ['Listings per year (active × 4)', '1 − log10(1 + listings) / 3', '10 %'],
239 + ]}
240 + />
241 + <P>Weights are renormalised over the signals that exist for the asset; a population growing more than 20 % between reports multiplies the score by 0.9. With no signal at all the score is null. Population figures come only from published grading-company reports and are shown with their report date.</P>
242 + <Ref>packages/valuation/src/scores.ts (rarityScore)</Ref>
243 + </Section>
244 +
245 + <Section id="momentum" n={10} title="Momentum & Trending" lead="Momentum measures how a valuation and its activity are changing; Trending blends momenta into one discovery signal.">
246 + <Formula>momentum(h) = 100 · (0.7 · clamp(Δprice / 50 %, ±1) + 0.3 · (volume_h − volume_prev) / max(1, volume_h + volume_prev))</Formula>
247 + <P>Horizons 7 d / 30 d / 90 d / 1 y; Δprice is the change of the representative variant&apos;s RIV against its own history. Trending is the geometric mean of the available momenta mapped to 0–1 (price 30 d, transaction volume, listing depth); it requires at least two components. Movers rails require ≥ {ASK_MIN_SAMPLE} transactions, confidence ≥ {pct(ASK_MIN_CONFIDENCE)}, ≥ 3 sales in the last year and a plausible move (≤ ±{MAX_PLAUSIBLE_CHANGE * 100} %).</P>
248 + <Ref>packages/valuation/src/scores.ts (momentumScore, trendingScore)</Ref>
249 + </Section>
250 +
251 + <Section id="outliers" n={11} title="Outliers" lead="Abnormal prices are flagged, never deleted, and every flag is auditable.">
252 + <P>
253 + Within a variant with at least five valid sales, the modified z-score of each log price, 0.6745 · (x − median) / MAD, is computed; sales beyond <strong>3.5</strong> are flagged <Code>abnormally_high_price</Code> / <Code>abnormally_low_price</Code> and set to status <Code>flagged</Code> (when MAD is 0, anything beyond 3× the median is flagged). Bundles and multi-quantity lots are <Code>excluded</Code> at ingestion. Flagged and excluded sales stay visible in sales tables for audit and are written to the audit log with their reason; they do not enter RIV, distributions or indices.
254 + </P>
255 + <Ref>packages/valuation/src/outliers.ts · workers/valuation/run.ts</Ref>
256 + </Section>
257 +
258 + <Section id="verification" n={12} title="Sales verification" lead="What “verified” means today — and what it does not.">
259 + <P>
260 + A sale is a completed-transaction record captured from a public source (auction result page, completed marketplace listing, price-guide sold feed). Each sale stores its <strong>status</strong> (<Code>valid</Code>, <Code>flagged</Code>, <Code>excluded</Code>), an <strong>identification confidence</strong> (0–1: the weaker of the record&apos;s own confidence and the entity-resolution confidence; deterministic identifiers 0.99, canonical key 0.96, fuzzy match ≤ 0.90), the <strong>trust score</strong> of its source (0–1, weighting valuations), <strong>flags</strong> (<Code>bundle</Code>, <Code>zero_price</Code>, <Code>low_identification_confidence</Code>, outlier reasons) and whether the buyer premium is known to be included.
261 + </P>
262 + <P>
263 + Record-sale and “verified transactions” views use status <Code>valid</Code> with confidence ≥ 0.8. Duplicate captures are collapsed by source, external id or URL, date and price. RareIndex does not yet publish a separate “likely / unverified” verification ladder or cross-source duplicate merging of the same physical transaction; where a transaction is only reported second-hand it appears as a price observation, not a sale.
264 + </P>
265 + <Ref>workers/entity-resolution/writers.ts · docs/METHODOLOGY.md §1–2</Ref>
266 + </Section>
267 +
268 + <Section id="indices" n={13} title="Indices" lead="RARE and its subindices are chain-linked indices of daily RIV changes. They publish only once enough constituents exist.">
269 + <Table
270 + head={['Rule', 'Value']}
271 + rows={[
272 + ['Constituent eligibility', 'valuation confidence ≥ 0.4 and ≥ 3 valid sales in the last 12 months; one variant per asset (the most traded)'],
273 + ['Daily return', 'weighted mean of constituents’ log(RIVₜ / RIV_last observed); equal weights by default, liquidity weights (12-month sales) where configured'],
274 + ['Robustness', 'top and bottom 5 % of daily constituent returns trimmed when ≥ 20 returns; a single constituent’s daily return is capped at ±ln 2.5'],
275 + ['Publication', 'a day is published only when ≥ 10 constituents have a value that day (level carried otherwise); RARE requires ≥ 25 constituents across its published subindices'],
276 + ['Base', '1 000 on 2024-01-01'],
277 + ['RARE', 'transaction-weighted mean (weight = 1 + transactions) of the daily log returns of published subindices'],
278 + ['Composition', 'membership can change daily without breaking the level; additions and removals are stored with their reason'],
279 + ['Market capitalisation', 'Σ (graded population × RIV) only for assets with a published population report, with a coverage-based confidence'],
280 + ]}
281 + />
282 + <P>Indices without a published value are listed as <em>in development</em> with their constituent count against the minimum, outside the primary ticker. Every daily value stores its constituent count and coverage. A repeat-sales estimator exists for research and is not a published index.</P>
283 + <Ref>packages/indices/src/chain.ts · workers/indices/run.ts</Ref>
284 + </Section>
285 +
286 + <Section id="fx" n={14} title="Currencies" lead="Native prices are never overwritten; every conversion is reproducible.">
287 + <P>
288 + Each sale stores <Code>price</Code>, <Code>currency</Code>, <Code>price_usd</Code>, <Code>fx_rate</Code> and <Code>fx_date</Code>. Rates are ECB reference rates (via frankfurter), base USD, applied at the <strong>transaction date</strong> (previous business day for weekends and holidays); historical sales are never converted at today&apos;s rate. A missing rate blocks the record rather than approximating it. Display currencies (USD, CAD, EUR, GBP, JPY, …) are converted at view time from the stored USD value.
289 + </P>
290 + <Ref>workers/fx.ts · workers/lib/fx.ts</Ref>
291 + </Section>
292 +
293 + <Section id="freshness" n={15} title="Freshness & limits" lead="Coverage is uneven by design; the interface shows what it knows and when it learned it.">
294 + <P>Connectors run on schedules tuned to each source; every statistic shows its last update and sample size. Categories with few public transactions show fewer valuations and no index rather than a fabricated one. Listing prices are not confirmed transactions; past performance does not guarantee future results; RareIndex does not authenticate items and its valuations are not appraisals.</P>
295 + <P>
296 + <Link href="/data" className="text-fg underline-offset-4 hover:underline">
297 + Live coverage, connectors and FX ranges →
298 + </Link>
299 + </P>
300 + </Section>
301 + </div>
302 + </div>
303 + </div>
304 + );
305 +}
306 +
307 +function Section({ id, n, title, lead, children }: { id: string; n: number; title: string; lead: string; children: ReactNode }) {
308 + return (
309 + <Card as="article" id={id} className="scroll-mt-16 p-4 sm:p-5">
310 + <header className="mb-3 border-b border-border pb-3">
311 + <p className="t-label">
312 + <span className="num">{String(n).padStart(2, '0')}</span>
313 + </p>
314 + <h2 className="mt-0.5 text-base font-semibold tracking-tight text-fg">{title}</h2>
315 + <p className="mt-1 text-[12.5px] leading-relaxed text-muted">{lead}</p>
316 + </header>
317 + <div className="grid gap-3 text-[13px] leading-relaxed text-muted">{children}</div>
318 + </Card>
319 + );
320 +}
321 +
322 +function H({ children }: { children: ReactNode }) {
323 + return <h3 className="mt-1 text-[11px] font-semibold uppercase tracking-wider text-subtle">{children}</h3>;
324 +}
325 +function P({ children, className }: { children: ReactNode; className?: string }) {
326 + return <p className={cn('max-w-[78ch]', className)}>{children}</p>;
327 +}
328 +function Code({ children }: { children: ReactNode }) {
329 + return <code className="rounded-sm bg-inset px-1 text-[12px] text-fg">{children}</code>;
330 +}
331 +function Formula({ children }: { children: ReactNode }) {
332 + return <p className="mono-num rounded-sm border border-border bg-sunken px-3 py-2 text-[12px] text-fg">{children}</p>;
333 +}
334 +function Ref({ children }: { children: ReactNode }) {
335 + return <p className="mono-num text-[10.5px] text-subtle">Source: {children}</p>;
336 +}
337 +function Kind({ tone, name, desc }: { tone: 'gain' | 'index' | 'rarity' | 'alert'; name: string; desc: string }) {
338 + return (
339 + <div className="rounded-sm border border-border p-3">
340 + <Badge tone={tone}>{name}</Badge>
341 + <p className="mt-1.5 text-[12px] leading-relaxed text-muted">{desc}</p>
342 + </div>
343 + );
344 +}
345 +function Table({ head, rows }: { head: string[]; rows: string[][] }) {
346 + return (
347 + <div className="overflow-x-auto">
348 + <table className="w-full min-w-[520px] border-collapse text-[12px]">
349 + <thead>
350 + <tr className="border-b border-border text-left text-[10px] uppercase tracking-wider text-subtle">
351 + {head.map((h) => (
352 + <th key={h} className="py-1.5 pr-3 font-medium">
353 + {h}
354 + </th>
355 + ))}
356 + </tr>
357 + </thead>
358 + <tbody className="divide-y divide-border">
359 + {rows.map((r, i) => (
360 + <tr key={i}>
361 + {r.map((c, j) => (
362 + <td key={j} className={cn('py-1.5 pr-3 align-top', j === 0 ? 'font-medium text-fg' : 'text-muted')}>
363 + {c}
364 + </td>
365 + ))}
366 + </tr>
367 + ))}
368 + </tbody>
369 + </table>
370 + </div>
371 + );
372 +}
modified apps/web/src/app/page.tsx +36 −16
@@ -26,19 +26,27 @@ export default async function HomePage() {
26 26 <div className="mt-8">
27 27 <IndexTape indices={indices} />
28 28 </div>
29 + {/* §195 hierarchy: hero + search → live stats (in hero) → RARE tape → global index → market snapshot →
30 + trending → record sales → Rare Radar → value opportunities → ending auctions → latest sales → categories → long tail */}
29 31 <div className="flex flex-col gap-10 px-4 pt-8 sm:px-6">
30 − <Suspense fallback={<Skeleton className="h-40" />}>
31 − <Categories />
32 + <Suspense fallback={<Skeleton className="h-72 w-full" />}>
33 + <FlagshipPanel />
34 + </Suspense>
35 + <Suspense fallback={<TableSkeleton rows={8} />}>
36 + <Snapshot />
32 37 </Suspense>
33 38 <Suspense fallback={<RailFallback />}>
34 39 <Discovery />
35 40 </Suspense>
36 − <Suspense fallback={<Skeleton className="h-72 w-full" />}>
37 − <FlagshipPanel />
38 − </Suspense>
39 41 <Suspense fallback={<TableSkeleton rows={10} />}>
40 42 <Latest />
41 43 </Suspense>
44 + <Suspense fallback={<Skeleton className="h-40" />}>
45 + <Categories />
46 + </Suspense>
47 + <Suspense fallback={<RailFallback />}>
48 + <LongTail />
49 + </Suspense>
42 50 </div>
43 51 </div>
44 52 );
@@ -103,7 +111,7 @@ async function FlagshipPanel() {
103 111 <span className="text-[11px] text-subtle">30D</span>
104 112 </p>
105 113 ) : (
106 − <p className="mt-1 text-sm text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p>
114 + <p className="mt-1 text-sm text-muted">Transaction-weighted across published subindices · base 1,000 on 2024-01-01 · <Link href="/methodology#indices" className="underline-offset-4 hover:text-fg hover:underline">methodology</Link></p>
107 115 )}
108 116 </div>
109 117 <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg">
@@ -142,7 +150,7 @@ async function FlagshipPanel() {
142 150 ) : null}
143 151 {building.length ? (
144 152 <div className="mt-3 border-t border-border pt-3">
145 − <p className="t-caption mb-1.5">Building · priced constituents vs. minimum</p>
153 + <p className="t-caption mb-1.5 flex items-baseline justify-between"><span>In development · priced constituents vs. minimum</span><Link href="/methodology#indices" className="text-subtle hover:text-fg">Why →</Link></p>
146 154 <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
147 155 {building.slice(0, 8).map((i) => {
148 156 const pct = Math.min(100, Math.round((i.pricedAssets / Math.max(1, i.minConstituents)) * 100));
@@ -168,23 +176,38 @@ async function FlagshipPanel() {
168 176 );
169 177 }
170 178
179 +/** Market snapshot (§195): the category table right under the global index. */
180 +async function Snapshot() {
181 + const markets = await getMarketRows();
182 + return <MarketInsights rows={markets} />;
183 +}
184 +
185 +/** Long tail below the fold: catalogue additions and community signals. */
186 +async function LongTail() {
187 + const [newest, watched] = await Promise.all([rankedAssets('newest', { limit: 8 }), rankedAssets('watched', { limit: 8 })]);
188 + await attachGuidePrices(newest);
189 + return (
190 + <>
191 + <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />
192 + {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
193 + </>
194 + );
195 +}
196 +
171 197 async function Discovery() {
172 − const [trending, movers, losers, records, radar, lots, watched, newest, markets, opportunities, newestPriced, documented, withSales] = await Promise.all([
198 + const [trending, movers, losers, records, radar, lots, opportunities, newestPriced, documented, withSales] = await Promise.all([
173 199 rankedAssets('trending', { limit: 8 }),
174 200 rankedAssets('gainers', { limit: 8, window: '7d' }),
175 201 rankedAssets('losers', { limit: 8, window: '7d' }),
176 202 getTopSales(8),
177 203 listRadar({ limit: 8 }),
178 204 listLots({ endingWithinHours: 72, status: null, limit: 8 }),
179 − rankedAssets('watched', { limit: 8 }),
180 − rankedAssets('newest', { limit: 8 }),
181 − getMarketRows(),
182 205 rankedAssets('opportunity', { limit: 8 }),
183 206 rankedAssets('newest_priced', { limit: 8 }),
184 207 rankedAssets('documented', { limit: 8 }),
185 208 rankedAssets('with_sales', { limit: 8 }),
186 209 ]);
187 − await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]);
210 + await Promise.all([attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]);
188 211 // Honest fallbacks while valuations are still being computed: each rail states what it shows.
189 212 const trendingRail = trending.length
190 213 ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' }
@@ -203,11 +226,8 @@ async function Discovery() {
203 226 {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null}
204 227 <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty />
205 228 <RadarRail items={radar} hideWhenEmpty />
206 − <LotsRail items={lots} hideWhenEmpty />
207 − <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />
208 229 {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Asks ≥ 10 % below a transaction-based RIV of the same variant (≥ 5 sales, medium+ confidence). Analytical data, not advice; implausible asks are held back as data anomalies." href="/listings?sort=discount&disc=10" items={opportunities} metric="opportunity" /> : null}
209 − {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
210 − <MarketInsights rows={markets} />
230 + <LotsRail items={lots} hideWhenEmpty />
211 231 </>
212 232 );
213 233 }
added apps/web/src/app/portfolio/page.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { redirect } from 'next/navigation';
2 +
3 +/** `/portfolio` is the conventional path; collection portfolios live under `/collections`. */
4 +export default function PortfolioRedirect() {
5 + redirect('/collections');
6 +}
modified apps/web/src/app/rareindex/page.tsx +1 −1
@@ -28,7 +28,7 @@ export default async function RareIndexPage() {
28 28 <span className="num">
29 29 {published.length}/{subs.length} subindices published
30 30 </span>
31 − <Link href="/data#methodology" className="hover:text-fg">
31 + <Link href="/methodology#indices" className="hover:text-fg">
32 32 Methodology →
33 33 </Link>
34 34 </>
added apps/web/src/app/screener/export/route.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { NextResponse } from 'next/server';
2 +import { screenAssetsExport } from '@/lib/queries/screener';
3 +import { SCREENER_EXPORT_MAX, isPlausibleChange, parseScreenerParams, toCsv } from '@/lib/screener';
4 +import type { SP } from '@/lib/search-params';
5 +
6 +export const runtime = 'nodejs';
7 +export const dynamic = 'force-dynamic';
8 +
9 +const ATTRIBUTION = 'Source: RareIndex (www.rareindex.io). Valuations are model estimates with stated confidence; not investment advice.';
10 +
11 +const HEADER = ['asset_id', 'slug', 'title', 'category', 'year', 'brand', 'set', 'riv_usd', 'riv_low_usd', 'riv_high_usd', 'riv_confidence', 'riv_sample_size', 'change_30d', 'change_1y', 'liquidity_score', 'rarity_score', 'sales_30d', 'sales_1y', 'sales_total', 'active_listings', 'min_ask_usd', 'spread_vs_riv', 'value_opportunity', 'ath_usd', 'drawdown', 'volume_30d_usd', 'stats_updated_at', 'url'];
12 +
13 +/** GET /screener/export?…same filters…[&format=json] — current screen, capped at 5 000 rows (§160). */
14 +export async function GET(req: Request) {
15 + const url = new URL(req.url);
16 + const sp: SP = Object.fromEntries(url.searchParams.entries());
17 + const filters = parseScreenerParams(sp);
18 + const items = await screenAssetsExport(filters, SCREENER_EXPORT_MAX);
19 + const asOf = new Date().toISOString();
20 + const held = (v: number | null) => (isPlausibleChange(v) ? v : null); // artefacts are exported as empty, never as numbers
21 + const rows = items.map((a) => [a.id, a.slug, a.title, a.categorySlug, a.year, a.brand, a.setName, a.rivUsd, a.rivLowUsd, a.rivHighUsd, a.rivConfidence, a.rivSampleSize, held(a.change30d), held(a.change1y), a.liquidityScore, a.rarityScore, a.sales30d, a.sales1y, a.salesCount, a.activeListings, a.minAskUsd, a.spread, a.valueOpportunity, a.athUsd, a.drawdown, a.volume30dUsd, a.updatedAt, `https://www.rareindex.io/asset/${a.slug}`]);
22 + if (url.searchParams.get('format') === 'json') {
23 + return NextResponse.json(
24 + { data: rows.map((r) => Object.fromEntries(HEADER.map((h, i) => [h, r[i] ?? null]))), meta: { count: rows.length, as_of: asOf, truncated: items.length >= SCREENER_EXPORT_MAX, attribution: ATTRIBUTION, filters } },
25 + { headers: { 'cache-control': 'private, max-age=60' } },
26 + );
27 + }
28 + const csv = toCsv(HEADER, rows, [`as_of=${asOf}`, ATTRIBUTION, `rows=${rows.length}${items.length >= SCREENER_EXPORT_MAX ? ' (truncated to the export cap)' : ''}`, `filters=${url.search.replace(/^\?/, '') || 'none'}`]);
29 + return new NextResponse(csv, {
30 + headers: {
31 + 'content-type': 'text/csv; charset=utf-8',
32 + 'content-disposition': 'attachment; filename="rareindex-screener.csv"',
33 + 'cache-control': 'private, max-age=60',
34 + },
35 + });
36 +}
added apps/web/src/app/screener/page.tsx +267 −0
@@ -0,0 +1,267 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Suspense } from 'react';
4 +import { PageHeader } from '@/components/ui/page-header';
5 +import { FilterBar } from '@/components/ui/filter-bar';
6 +import { Pagination } from '@/components/ui/pagination';
7 +import { Delta, EmptyState, Skeleton, Table, VsRiv, th, td, tdNum } from '@/components/ui/primitives';
8 +import { Thumb } from '@/components/ui/tile-image';
9 +import { screenAssets, type ScreenerRow } from '@/lib/queries/screener';
10 +import { DEFAULT_SORT, PRESETS, SCREENER_KEYS, isPlausibleChange, parseScreenerParams, type ScreenerSort, type SortDir } from '@/lib/screener';
11 +import { catName } from '@/lib/taxonomy';
12 +import { cn, confidenceLabel, fmtMoney, fmtNum, fmtPct } from '@/lib/format';
13 +import { CATEGORIES, GRADERS } from '@rareindex/taxonomy';
14 +import { pick, sp1, withParams, type SP } from '@/lib/search-params';
15 +
16 +export const metadata: Metadata = {
17 + title: 'Market Screener',
18 + description: 'Screen every valued collectible by RareIndex Valuation, confidence, momentum, liquidity, rarity, sales velocity, listing depth, ask spread and drawdown. Presets for the most liquid, most traded, largest drawdowns, near all-time-high, rising volume and potentially underpriced assets.',
19 +};
20 +
21 +const PCT_TITLE_HELD = 'Implausible move held back (beyond ±500 %): a data or identity artefact, not a market signal.';
22 +
23 +interface Col {
24 + id: ScreenerSort | 'asset';
25 + label: string;
26 + title: string;
27 + numeric?: boolean;
28 +}
29 +const COLUMNS: Col[] = [
30 + { id: 'asset', label: 'Asset', title: 'Canonical asset (category · year · set)' },
31 + { id: 'riv', label: 'RIV', title: 'RareIndex Valuation with confidence label and number of transactions used', numeric: true },
32 + { id: 'change30d', label: '1M', title: 'Change in RIV over 30 days (representative variant)', numeric: true },
33 + { id: 'change1y', label: '1Y', title: 'Change in RIV over 1 year (representative variant)', numeric: true },
34 + { id: 'liquidity', label: 'Liq.', title: 'Liquidity Score 0–100: sales frequency, listing depth, sources, days between sales, ask/sold spread', numeric: true },
35 + { id: 'rarity', label: 'Rarity', title: 'Rarity Score 0–100 from population, production and market appearances (null without a supply signal)', numeric: true },
36 + { id: 'sales30d', label: 'Sales 30D', title: 'Verified sales in the last 30 days', numeric: true },
37 + { id: 'listings', label: 'Listings', title: 'Active listings observed', numeric: true },
38 + { id: 'spread', label: 'Spread', title: 'Lowest ask of the representative variant vs RIV — only for transaction-based valuations (≥ 5 sales, medium+ confidence) within a plausible 0.1×–10× ratio', numeric: true },
39 + { id: 'drawdown', label: 'Drawdown', title: 'RIV (representative variant) vs the all-time-high verified sale of the asset — the ATH may belong to another variant (e.g. a sealed or top-graded copy), so very deep drawdowns often reflect variant spread rather than a price fall', numeric: true },
40 + { id: 'volume30d', label: 'Vol. 30D', title: 'USD volume of verified sales in the last 30 days', numeric: true },
41 +];
42 +
43 +export default async function ScreenerPage({ searchParams }: { searchParams: Promise<SP> }) {
44 + const sp = await searchParams;
45 + const filters = parseScreenerParams(sp);
46 + const params = pick(sp, SCREENER_KEYS);
47 + const preset = sp1(sp, 'preset') ?? null;
48 + const activePreset = PRESETS.find((p) => p.id === preset) ?? null;
49 + const categoryOptions = CATEGORIES.map((c) => ({ value: c.slug, label: `${' '.repeat(c.level)}${c.level ? '↳ ' : ''}${c.name}` }));
50 + const exportHref = withParams('/screener/export', { ...params, page: null, size: null });
51 + return (
52 + <div>
53 + <PageHeader
54 + kicker="Terminal"
55 + title="Market Screener"
56 + description="Every valued collectible (≥ 3 verified sales) filtered and ranked like a securities screen: valuation, confidence, momentum, liquidity, rarity, sales velocity, listing depth, ask spread and drawdown. Filters live in the URL, so any screen is shareable. Analytical data, not investment advice."
57 + compact
58 + actions={
59 + <>
60 + <a href={exportHref} className="btn btn-sm h-8 text-[12px]" rel="nofollow" title="Download the current screen (max 5 000 rows)">
61 + Export CSV
62 + </a>
63 + <a href={withParams('/screener/export', { ...params, page: null, size: null, format: 'json' })} className="btn btn-sm h-8 text-[12px]" rel="nofollow">
64 + JSON
65 + </a>
66 + </>
67 + }
68 + />
69 + <div className="mb-3 flex flex-col gap-2">
70 + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial" role="list" aria-label="Screener presets">
71 + <a href="/screener" className={cn('chip', !preset && 'chip-active')} role="listitem">
72 + All valued
73 + </a>
74 + {PRESETS.map((p) => (
75 + <a key={p.id} href={withParams('/screener', { preset: p.id, category: params.category ?? null })} className={cn('chip', preset === p.id && 'chip-active')} title={p.description} role="listitem">
76 + {p.label}
77 + </a>
78 + ))}
79 + </div>
80 + {activePreset ? <p className="text-[12px] text-muted">{activePreset.description}</p> : null}
81 + <Suspense>
82 + <FilterBar
83 + resetKeys={['preset', 'sort', 'dir']}
84 + fields={[
85 + { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-40' },
86 + { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-48' },
87 + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-28' },
88 + { name: 'grade', label: 'Grade', type: 'text', placeholder: '10', width: 'w-16' },
89 + { name: 'min', label: 'RIV ≥ $', type: 'number', placeholder: '0', width: 'w-24' },
90 + { name: 'max', label: 'RIV ≤ $', type: 'number', placeholder: '∞', width: 'w-24' },
91 + { name: 'conf', label: 'Confidence', type: 'select', options: [{ value: '0.5', label: 'Medium or better' }, { value: '0.75', label: 'High only' }], placeholder: 'Any', width: 'w-36' },
92 + { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
93 + { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
94 + { name: 'c30min', label: '1M ≥ %', type: 'number', placeholder: '-100…', width: 'w-20' },
95 + { name: 'c1ymin', label: '1Y ≥ %', type: 'number', placeholder: '-100…', width: 'w-20' },
96 + { name: 's30', label: 'Sales 30D ≥', type: 'number', placeholder: '0', width: 'w-20' },
97 + { name: 's1y', label: 'Sales 1Y ≥', type: 'number', placeholder: '0', width: 'w-20' },
98 + { name: 'lst', label: 'Listings ≥', type: 'number', placeholder: '0', width: 'w-20' },
99 + { name: 'spmax', label: 'Spread ≤ %', type: 'number', placeholder: 'e.g. -10', width: 'w-24' },
100 + { name: 'ddmax', label: 'Drawdown ≤ %', type: 'number', placeholder: 'e.g. -30', width: 'w-24' },
101 + { name: 'from', label: 'Year ≥', type: 'number', placeholder: '1900', width: 'w-20' },
102 + { name: 'to', label: 'Year ≤', type: 'number', placeholder: '2026', width: 'w-20' },
103 + ]}
104 + />
105 + </Suspense>
106 + </div>
107 + <Suspense fallback={<Skeleton className="h-96" />}>
108 + <Results filters={filters} params={params} />
109 + </Suspense>
110 + <p className="mt-3 max-w-4xl text-[11px] leading-relaxed text-subtle">
111 + Percent moves beyond ±500 % and asks outside 0.1×–10× of the valuation are held back as data/identity anomalies (never shown as signals). Spread and “Potentially Underpriced” use only transaction-based valuations with ≥ 5 sales and medium or better confidence. Scores are explained in the{' '}
112 + <Link href="/methodology" className="underline-offset-4 hover:text-fg hover:underline">
113 + Methodology Center
114 + </Link>{' '}
115 + and coverage in{' '}
116 + <Link href="/data" className="underline-offset-4 hover:text-fg hover:underline">
117 + Data
118 + </Link>
119 + .
120 + </p>
121 + </div>
122 + );
123 +}
124 +
125 +function SortHeader({ col, filters, params }: { col: Col; filters: ReturnType<typeof parseScreenerParams>; params: Record<string, string | undefined> }) {
126 + if (col.id === 'asset') {
127 + const active = filters.sort === 'name';
128 + return (
129 + <th className={th} title={col.title} aria-sort={active ? (filters.dir === 'asc' ? 'ascending' : 'descending') : undefined}>
130 + <a href={withParams('/screener', { ...params, sort: 'name', dir: active && filters.dir === 'asc' ? 'desc' : 'asc', page: null })} className={cn('hover:text-fg', active && 'text-fg')}>
131 + {col.label}
132 + {active ? <span aria-hidden> {filters.dir === 'asc' ? '↑' : '↓'}</span> : null}
133 + </a>
134 + </th>
135 + );
136 + }
137 + const id = col.id as ScreenerSort;
138 + const active = filters.sort === id;
139 + const nextDir: SortDir = active ? (filters.dir === 'asc' ? 'desc' : 'asc') : DEFAULT_SORT[id];
140 + return (
141 + <th className={cn(th, col.numeric && 'text-right')} title={col.title} aria-sort={active ? (filters.dir === 'asc' ? 'ascending' : 'descending') : undefined}>
142 + <a href={withParams('/screener', { ...params, sort: id, dir: nextDir, page: null })} className={cn('hover:text-fg', active && 'text-fg')}>
143 + {col.label}
144 + {active ? <span aria-hidden> {filters.dir === 'asc' ? '↑' : '↓'}</span> : null}
145 + </a>
146 + </th>
147 + );
148 +}
149 +
150 +function Pct({ value, digits = 1 }: { value: number | null; digits?: number }) {
151 + if (value === null || value === undefined) return <span className="text-subtle">—</span>;
152 + if (!isPlausibleChange(value)) return <span className="text-subtle" title={PCT_TITLE_HELD}>—</span>;
153 + return <Delta value={value} digits={digits} />;
154 +}
155 +
156 +function Score({ value }: { value: number | null }) {
157 + if (value === null) return <span className="text-subtle">—</span>;
158 + const v = Math.round(value);
159 + return (
160 + <span className="inline-flex items-center justify-end gap-1.5">
161 + <span className="h-1 w-8 overflow-hidden rounded-full bg-inset" aria-hidden>
162 + <span className="block h-full bg-fg/60" style={{ width: `${Math.max(2, Math.min(100, v))}%` }} />
163 + </span>
164 + <span>{v}</span>
165 + </span>
166 + );
167 +}
168 +
169 +async function Results({ filters, params }: { filters: ReturnType<typeof parseScreenerParams>; params: Record<string, string | undefined> }) {
170 + const res = await screenAssets(filters);
171 + if (!res.items.length) {
172 + return <EmptyState title="No asset matches this screen" description="Loosen a threshold or clear the preset. Only assets with a RareIndex Valuation resting on at least three verified sales are screenable; catalogued-only assets never appear here." action={<a href="/screener" className="btn btn-sm">Reset screen</a>} />;
173 + }
174 + const startRank = (res.page - 1) * res.pageSize;
175 + return (
176 + <>
177 + <p className="mb-2 num text-[11px] text-subtle">
178 + {res.total.toLocaleString('en-US')} assets match · sorted by {filters.sort} {filters.dir === 'asc' ? '↑' : '↓'} · page {res.page}
179 + </p>
180 + <div className="card overflow-hidden">
181 + <Table>
182 + <thead>
183 + <tr>
184 + <th className={cn(th, 'w-8 text-right')}>#</th>
185 + {COLUMNS.map((c) => (
186 + <SortHeader key={c.id} col={c} filters={filters} params={params} />
187 + ))}
188 + </tr>
189 + </thead>
190 + <tbody>
191 + {res.items.map((a, i) => (
192 + <Row key={a.id} a={a} rank={startRank + i + 1} />
193 + ))}
194 + </tbody>
195 + </Table>
196 + </div>
197 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/screener" params={params} />
198 + </>
199 + );
200 +}
201 +
202 +function Row({ a, rank }: { a: ScreenerRow; rank: number }) {
203 + return (
204 + <tr className="hover:bg-sunken">
205 + <td className={cn(tdNum, 'text-subtle')}>{rank}</td>
206 + <td className={cn(td, 'max-w-[320px]')}>
207 + <Link href={`/asset/${a.slug}`} className="flex items-center gap-2.5">
208 + <Thumb src={a.heroImageUrl} alt="" size={32} familySlug={a.familySlug} />
209 + <span className="min-w-0">
210 + <span className="block truncate text-[13px] font-medium text-fg">{a.title}</span>
211 + <span className="block truncate text-[11px] text-muted">
212 + {catName(a.categorySlug)}
213 + {a.year ? ` · ${a.year}` : ''}
214 + {a.setName ? ` · ${a.setName}` : ''}
215 + </span>
216 + </span>
217 + </Link>
218 + </td>
219 + <td className={tdNum}>
220 + <span className="block font-semibold text-fg">{fmtMoney(a.rivUsd)}</span>
221 + <span className="block text-[10px] text-subtle" title={a.rivLowUsd !== null && a.rivHighUsd !== null ? `Range ${fmtMoney(a.rivLowUsd)} – ${fmtMoney(a.rivHighUsd)}` : undefined}>
222 + {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}
223 + </span>
224 + </td>
225 + <td className={tdNum}>
226 + <Pct value={a.change30d} />
227 + </td>
228 + <td className={tdNum}>
229 + <Pct value={a.change1y} />
230 + </td>
231 + <td className={tdNum}>
232 + <Score value={a.liquidityScore} />
233 + </td>
234 + <td className={tdNum}>
235 + <Score value={a.rarityScore} />
236 + </td>
237 + <td className={tdNum}>
238 + {fmtNum(a.sales30d)}
239 + <span className="block text-[10px] text-subtle">{fmtNum(a.sales1y)} / 1Y</span>
240 + </td>
241 + <td className={tdNum}>{fmtNum(a.activeListings)}</td>
242 + <td className={tdNum}>
243 + {a.spread === null ? (
244 + <span className="text-subtle" title={a.minAskUsd === null ? 'No active ask for the representative variant' : 'Ask not compared: valuation below the transaction gate (≥ 5 sales, medium+ confidence) or implausible ratio'}>
245 + —
246 + </span>
247 + ) : (
248 + <span className="inline-flex flex-col items-end leading-tight">
249 + <VsRiv discount={a.spread} showLabel={false} />
250 + {a.minAskUsd !== null ? <span className="text-[10px] text-subtle">ask {fmtMoney(a.minAskUsd)}</span> : null}
251 + </span>
252 + )}
253 + </td>
254 + <td className={tdNum}>
255 + {a.drawdown === null ? (
256 + <span className="text-subtle">—</span>
257 + ) : (
258 + <span className="inline-flex flex-col items-end leading-tight">
259 + <span className={a.drawdown <= -0.0005 ? 'text-loss' : 'text-flat'}>{fmtPct(a.drawdown, 1, false)}</span>
260 + {a.athUsd !== null ? <span className="text-[10px] text-subtle">ATH {fmtMoney(a.athUsd)}</span> : null}
261 + </span>
262 + )}
263 + </td>
264 + <td className={tdNum}>{a.volume30dUsd !== null && a.volume30dUsd > 0 ? fmtMoney(a.volume30dUsd, 'USD', { compact: true }) : <span className="text-subtle">—</span>}</td>
265 + </tr>
266 + );
267 +}
modified apps/web/src/components/asset/asset-header.tsx +14 −3
@@ -5,6 +5,7 @@ import { catName, categoryCrumbs } from '@/lib/taxonomy';
5 5 import { Breadcrumbs } from '@/components/ui/page-header';
6 6 import { Badge, Delta } from '@/components/ui/primitives';
7 7 import { ScoreMeter } from '@/components/ui/evidence';
8 +import { AskVsRivExplainer, ConfidenceExplainer, LiquidityExplainer, RarityExplainer } from './score-explainers';
8 9 import { AssetGallery, type GalleryImage } from './asset-gallery';
9 10 import { imageProps } from '@/lib/images';
10 11 import { glyphForFamily } from '@/lib/image-glyph';
@@ -15,7 +16,7 @@ import { VariantTable } from './variant-table';
15 16 * Asset page header (§126): gallery + identity on the left, the valuation panel on the right.
16 17 * Mobile-first: gallery → title → variant chips → valuation → metrics; desktop: two columns.
17 18 */
18 −export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean }) {
19 +export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false, population = null }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean; /** latest published graded population total, when a report exists */ population?: number | null }) {
19 20 const v = activeVariant;
20 21 const riv = v ? v.rivUsd : asset.rivUsd;
21 22 const low = v ? v.rivLowUsd : asset.rivLowUsd;
@@ -109,6 +110,10 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n
109 110 Range {fmtMoney(low)} – {fmtMoney(high)} · <span className="text-fg">{fmtNum(n)}</span> sales used · updated {fmtRelative(asset.updatedAt)}
110 111 </div>
111 112 <RangeBar low={low} high={high} value={riv} latest={latest} />
113 + <div className="mt-2 flex flex-col gap-1">
114 + <ConfidenceExplainer asset={asset} variant={v} />
115 + <AskVsRivExplainer asset={asset} variant={v} />
116 + </div>
112 117 </>
113 118 ) : guide ? (
114 119 <div className="mt-1">
@@ -143,8 +148,14 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n
143 148 <Metric label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />
144 149 </dl>
145 150 <div className="mt-3 grid grid-cols-1 gap-2.5 border-t border-border pt-3 sm:grid-cols-3 lg:grid-cols-1">
146 − <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />
147 − <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />
151 + <div>
152 + <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />
153 + <LiquidityExplainer asset={asset} variant={v} className="mt-1" />
154 + </div>
155 + <div>
156 + <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />
157 + <RarityExplainer asset={asset} population={population} className="mt-1" />
158 + </div>
148 159 <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" />
149 160 </div>
150 161 <p className="mt-3 text-[10px] leading-relaxed text-subtle">Estimate from observed public sales, not an offer or appraisal. RareIndex does not authenticate items.</p>
modified apps/web/src/components/asset/asset-tabs.tsx +14 −0
@@ -15,6 +15,9 @@ import { BarChart } from '@/components/charts/bar-chart';
15 15 import { SalesTable, ListingsTable } from '@/components/market/sales-table';
16 16 import { SourceLink, Thumb } from '@/components/market/bits';
17 17 import { Evidence } from '@/components/ui/evidence';
18 +import { getAssetDepth, getAssetLiquidation } from '@/lib/queries/depth';
19 +import { DepthLiquidationCard } from './depth-card';
20 +import { countVerification } from '@rareindex/valuation';
18 21
19 22 export const ASSET_TABS = ['overview', 'sales', 'listings', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;
20 23 export type AssetTab = (typeof ASSET_TABS)[number];
@@ -33,6 +36,11 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
33 36 getSimilarAssets(asset, 8).then(attachGuidePrices),
34 37 getValuationHistory(asset.id, variant?.id ?? null),
35 38 ]);
39 + const [depth, liquidation] = await Promise.all([
40 + getAssetDepth(asset.id, variant?.id ?? null),
41 + getAssetLiquidation({ id: asset.id, categorySlug: asset.categorySlug, rivUsd: variant ? variant.rivUsd : asset.rivUsd, rivLowUsd: variant ? variant.rivLowUsd : asset.rivLowUsd, rivHighUsd: variant ? variant.rivHighUsd : asset.rivHighUsd }),
42 + ]);
43 + const verification = countVerification(sales.items.map((s) => s.verification));
36 44 const bandByDate = new Map(valHist.map((h) => [h.date, h]));
37 45 const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id));
38 46 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;
@@ -71,6 +79,11 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
71 79 {valuation ? (
72 80 <div className="p-4">
73 81 <Evidence confidence={valuation.confidence} sampleSize={valuation.sampleSize} updatedAt={valuation.computedAt} />
82 + {sales.items.length ? (
83 + <p className="mt-1 text-[11px] text-subtle" title="Heuristic labels on the recent sales shown below: source type, sale type, match confidence, source trust (§39)">
84 + Recent sales: {verification.verified} verified · {verification.likely} likely · {verification.unverified} unverified{verification.excluded ? ` · ${verification.excluded} excluded` : ''}
85 + </p>
86 + ) : null}
74 87 <Table className="mt-3">
75 88 <tbody>
76 89 {Object.entries(valuation.methods).map(([k, v]) => (
@@ -142,6 +155,7 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
142 155 )}
143 156 </Card>
144 157 </div>
158 + <DepthLiquidationCard depth={depth} liquidation={liquidation} riv={riv} variantLabel={variant?.label ?? null} />
145 159 <div className="grid gap-4 lg:grid-cols-2">
146 160 <Card className="overflow-hidden">
147 161 <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />
added apps/web/src/components/asset/depth-card.tsx +164 −0
@@ -0,0 +1,164 @@
1 +import type { AssetDepth, AssetLiquidation } from '@/lib/queries/depth';
2 +import { fmtMoney, fmtNum, fmtPct, cn } from '@/lib/format';
3 +import { Card, CardHeader, Table, th, td, tdNum, Badge, Unavailable, VsRiv } from '@/components/ui/primitives';
4 +
5 +const fmtDays = (d: number | null) => (d === null ? null : d < 1 ? '< 1 day' : `~${Math.round(d)} day${Math.round(d) === 1 ? '' : 's'}`);
6 +
7 +/**
8 + * Market depth (§25), RareIndex spread (§26), days on market (§30), time-to-liquidate (§24) and the
9 + * fair buy / sell ladder (§213–§214). Server-rendered; every number comes from observed listings and
10 + * the published valuation band, and missing evidence is shown as such.
11 + */
12 +export function DepthLiquidationCard({ depth, liquidation, riv, variantLabel }: { depth: AssetDepth | null; liquidation: AssetLiquidation | null; riv: number | null; variantLabel: string | null }) {
13 + const d = depth?.depth ?? null;
14 + const m = liquidation?.model ?? null;
15 + const f = liquidation?.fair ?? null;
16 + const bands = d
17 + ? [
18 + { label: '±5 %', n: d.within5 },
19 + { label: '±10 %', n: d.within10 },
20 + { label: '±20 %', n: d.within20 },
21 + ]
22 + : [];
23 + const max = d ? Math.max(1, d.asks) : 1;
24 + return (
25 + <Card>
26 + <CardHeader
27 + title="Market depth & time to sell"
28 + subtitle={`Current asks around the valuation${depth?.scope === 'variant' ? ` of ${variantLabel ?? 'the representative variant'}` : ''}, observed days on market and a time-to-sale model by asking band. Model analytics from observed listings and sales, not advice.`}
29 + />
30 + <div className="grid gap-4 p-4 lg:grid-cols-3">
31 + {/* Depth */}
32 + <section>
33 + <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Market depth</h3>
34 + {d && riv !== null ? (
35 + <>
36 + <dl className="mt-2 grid grid-cols-3 gap-2 text-[12px]">
37 + <div>
38 + <dt className="text-[10px] uppercase tracking-wider text-subtle">Asks</dt>
39 + <dd className="num font-medium text-fg">{fmtNum(d.asks)}</dd>
40 + </div>
41 + <div>
42 + <dt className="text-[10px] uppercase tracking-wider text-subtle">Below RIV</dt>
43 + <dd className="num font-medium text-fg">{fmtNum(d.belowRiv)}</dd>
44 + </div>
45 + <div>
46 + <dt className="text-[10px] uppercase tracking-wider text-subtle">Above RIV</dt>
47 + <dd className="num font-medium text-fg">{fmtNum(d.aboveRiv)}</dd>
48 + </div>
49 + </dl>
50 + <ul className="mt-3 grid gap-1.5">
51 + {bands.map((b) => (
52 + <li key={b.label} className="grid grid-cols-[52px_1fr_36px] items-center gap-2 text-[11px]">
53 + <span className="num text-muted">{b.label}</span>
54 + <span className="h-2 overflow-hidden rounded-sm bg-inset" aria-hidden>
55 + <span className="block h-full bg-index" style={{ width: `${Math.round((100 * b.n) / max)}%` }} />
56 + </span>
57 + <span className="num text-right text-fg">{b.n}</span>
58 + </li>
59 + ))}
60 + </ul>
61 + <div className="mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-[12px]">
62 + <span>
63 + <span className="text-[10px] uppercase tracking-wider text-subtle">Best ask </span>
64 + <span className="num font-medium">{d.lowestAsk === null ? <Unavailable reason="No active asks" /> : fmtMoney(d.lowestAsk)}</span>
65 + </span>
66 + <span>
67 + <span className="text-[10px] uppercase tracking-wider text-subtle">Median ask </span>
68 + <span className="num font-medium">{d.medianAsk === null ? <Unavailable reason="No active asks" /> : fmtMoney(d.medianAsk)}</span>
69 + </span>
70 + <span title="RareIndex spread: best ask versus the valuation of the same variant">
71 + <span className="text-[10px] uppercase tracking-wider text-subtle">Spread </span>
72 + <VsRiv discount={d.askRivSpread} className="text-[12px]" />
73 + </span>
74 + </div>
75 + </>
76 + ) : (
77 + <p className="mt-2 text-[12px] text-muted">Not enough data — depth needs a transaction-based valuation for this variant.</p>
78 + )}
79 + </section>
80 +
81 + {/* Days on market + bands */}
82 + <section>
83 + <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">
84 + Days on market{' '}
85 + {liquidation ? (
86 + <Badge tone={liquidation.level === 'category' ? 'alert' : 'neutral'} className="ml-1 normal-case tracking-normal">
87 + Model estimate · {liquidation.level === 'category' ? 'category-level' : 'this asset'}
88 + </Badge>
89 + ) : null}
90 + </h3>
91 + {m ? (
92 + <>
93 + <dl className="mt-2 grid grid-cols-4 gap-2 text-[12px]">
94 + {[
95 + ['Sold', m.sold.n === 0 ? null : fmtNum(m.sold.n)],
96 + ['Median', fmtDays(m.sold.median)],
97 + ['P25 – P75', m.sold.p25 === null || m.sold.p75 === null ? null : `${Math.round(m.sold.p25)} – ${Math.round(m.sold.p75)} d`],
98 + ['Sell-through', m.sellThrough === null ? null : fmtPct(m.sellThrough, 0, false)],
99 + ].map(([k, v]) => (
100 + <div key={k as string}>
101 + <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>
102 + <dd className="num font-medium text-fg">{v ?? <Unavailable reason="Not enough data" />}</dd>
103 + </div>
104 + ))}
105 + </dl>
106 + <p className="mt-1 text-[11px] text-subtle">
107 + {fmtNum(m.withdrawn.n)} withdrawn/expired without a sale{m.withdrawn.median !== null ? ` (median ${fmtDays(m.withdrawn.median)} listed)` : ''} · {fmtNum(liquidation!.lifecycles)} lifecycles observed
108 + </p>
109 + <Table className="mt-3">
110 + <thead>
111 + <tr>
112 + <th className={th}>Asking band</th>
113 + <th className={cn(th, 'text-right')}>Sold</th>
114 + <th className={cn(th, 'text-right')}>Median time to sale</th>
115 + </tr>
116 + </thead>
117 + <tbody>
118 + {m.bands.map((b) => (
119 + <tr key={b.band}>
120 + <td className={cn(td, 'text-muted')}>{b.label}</td>
121 + <td className={tdNum}>{b.n}</td>
122 + <td className={tdNum}>{b.medianDays === null ? <span className="text-subtle" title="Fewer than 5 sold lifecycles in this band">Not enough data</span> : fmtDays(b.medianDays)}</td>
123 + </tr>
124 + ))}
125 + </tbody>
126 + </Table>
127 + </>
128 + ) : (
129 + <p className="mt-2 text-[12px] text-muted">Not enough data — no completed listing lifecycles observed for this asset or its category yet.</p>
130 + )}
131 + </section>
132 +
133 + {/* Fair buy / sell ladder */}
134 + <section>
135 + <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Fair buy · fair sell</h3>
136 + {riv !== null && f ? (
137 + <Table className="mt-2">
138 + <tbody>
139 + {[
140 + ['Aggressive buy', f.buyAggressive, null, 'RIV low band'],
141 + ['Fair buy', f.buyFair, null, 'RIV'],
142 + ['Fast sale', f.sellFast, f.sellFastDays, 'band with the shortest observed time to sale'],
143 + ['Typical sale', f.sellTypical, f.sellTypicalDays, 'RIV'],
144 + ['Patient sale', f.sellPatient, f.sellPatientDays, 'RIV high band'],
145 + ].map(([k, v, days, basis]) => (
146 + <tr key={k as string}>
147 + <td className={cn(td, 'text-muted')} title={`Basis: ${basis}`}>
148 + {k}
149 + </td>
150 + <td className={cn(tdNum, 'font-medium')}>{v === null || v === undefined ? <Unavailable reason="Not enough data" /> : fmtMoney(v as number)}</td>
151 + <td className={cn(tdNum, 'text-[11px] text-subtle')}>{days === null || days === undefined ? '' : fmtDays(days as number)}</td>
152 + </tr>
153 + ))}
154 + </tbody>
155 + </Table>
156 + ) : (
157 + <p className="mt-2 text-[12px] text-muted">Not enough data — the ladder needs a published valuation.</p>
158 + )}
159 + <p className="mt-2 text-[11px] text-subtle">Prices come from the RIV low/high band; days from the observed asking bands above. Model analytics, not advice.</p>
160 + </section>
161 + </div>
162 + </Card>
163 + );
164 +}
modified apps/web/src/components/asset/sales-cards.tsx +3 −3
@@ -1,8 +1,8 @@
1 1 import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 2 import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 3 import { humanize } from '@/lib/taxonomy';
4 −import { Badge, Delta, EmptyState, VsRiv } from '@/components/ui/primitives';
5 −import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
4 +import { EmptyState, VsRiv } from '@/components/ui/primitives';
5 +import { GradeBadge, SourceLink, Thumb, VerificationBadge } from '@/components/market/bits';
6 6
7 7 /** Phone-first card lists for sales and listings (tables stay for md+). */
8 8 export function SalesCards({ items, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; className?: string; emptyDescription?: string }) {
@@ -28,7 +28,7 @@ export function SalesCards({ items, className, emptyDescription = 'Sales appear
28 28 {s.condition ? <span>{humanize(s.condition)}</span> : null}
29 29 <span>{humanize(s.saleType)}</span>
30 30 <SourceLink name={s.sourceName} url={s.sourceUrl} />
31 − {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : null}
31 + <VerificationBadge label={s.verification} reason={s.verificationReason} />
32 32 </div>
33 33 </div>
34 34 </li>
added apps/web/src/components/asset/score-explainers.tsx +113 −0
@@ -0,0 +1,113 @@
1 +import { 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';
2 +import type { AssetDetail, VariantRow } from '@/lib/queries/assets';
3 +import { confidenceLabel, fmtMoney, fmtNum, fmtPct, fmtRelative } from '@/lib/format';
4 +import { ScoreExplainer } from '@/components/ui/score-explainer';
5 +
6 +/*
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 of
9 + * log prices, median days between sales, source trust) are shown as "not available" rather than guessed.
10 + */
11 +
12 +const DAY = 86_400_000;
13 +const pct = (v: number) => `${Math.round(v * 100)} %`;
14 +
15 +export 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 + <ScoreExplainer
24 + 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 +}
39 +
40 +export function RarityExplainer({ asset, population, className }: { asset: AssetDetail; population?: number | null; className?: string }) {
41 + return (
42 + <ScoreExplainer
43 + 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 +}
57 +
58 +export 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 pure
63 + 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 + <ScoreExplainer
68 + 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 > 365
79 + ? `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 < 3
81 + ? '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 +}
87 +
88 +export 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 + <ScoreExplainer
98 + 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 +}
modified apps/web/src/components/home/hero.tsx +1 −1
@@ -32,7 +32,7 @@ export function Hero({ stats }: { stats: SiteStats }) {
32 32 />
33 33 <p className="mt-2 text-center text-[11px] text-subtle">
34 34 Counts are live from the canonical tables · valuations carry confidence and sample size ·{' '}
35 − <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">
35 + <Link href="/methodology" className="underline-offset-2 hover:text-fg hover:underline">
36 36 methodology
37 37 </Link>
38 38 </p>
modified apps/web/src/components/market/bits.tsx +11 −0
@@ -65,3 +65,14 @@ export function AssetTitleCell({ slug, title, categorySlug, image, sub, size = 3
65 65 </span>
66 66 );
67 67 }
68 +
69 +/** Heuristic sale verification label (§39, §205): verified · likely · unverified · excluded. Never a claim of independent confirmation. */
70 +export function VerificationBadge({ label, reason, className }: { label: 'verified' | 'likely' | 'unverified' | 'excluded'; reason?: string; className?: string }) {
71 + const tone = label === 'verified' ? 'gain' : label === 'likely' ? 'neutral' : 'alert';
72 + const text = label === 'verified' ? 'Verified' : label === 'likely' ? 'Likely' : label === 'unverified' ? 'Unverified' : 'Excluded';
73 + return (
74 + <Badge tone={tone} className={className}>
75 + <span title={`${text} sale — heuristic on source type, sale type, match confidence and source trust${reason ? `: ${reason}` : ''}`}>{text}</span>
76 + </Badge>
77 + );
78 +}
modified apps/web/src/components/market/index-strip.tsx +60 −28
@@ -3,46 +3,78 @@ import type { IndexRow } from '@/lib/queries/indices';
3 3 import { fmtNum, cn, fmtPct, deltaClass } from '@/lib/format';
4 4
5 5 /**
6 − * Live tape (§163): a terminal-style ticker of the indices. Published indices show value + daily change;
7 − * indices still building show their constituent progress. Pauses on hover/focus; static under reduced motion.
6 + * Live tape (§163, §194): a terminal-style ticker of the PUBLISHED indices only — value + daily change.
7 + * Indices that have not met their constituent minimum are not in the tape; they are listed in a secondary
8 + * compact "Indices in development" line with their constituent progress. Pauses on hover/focus; static under
9 + * reduced motion.
8 10 */
9 11 export function IndexTape({ indices, className }: { indices: IndexRow[]; className?: string }) {
10 12 if (!indices.length) return null;
11 − const ordered = [...indices].sort((a, b) => Number(Boolean(b.latest)) - Number(Boolean(a.latest)) || (a.isFlagship ? -1 : 0) - (b.isFlagship ? -1 : 0));
12 − const items = ordered.map((i) => {
13 + const published = indices.filter((i) => i.latest).sort((a, b) => Number(b.isFlagship) - Number(a.isFlagship) || a.ticker.localeCompare(b.ticker));
14 + const developing = indices.filter((i) => !i.latest).sort((a, b) => b.pricedAssets / Math.max(1, b.minConstituents) - a.pricedAssets / Math.max(1, a.minConstituents));
15 + const items = published.map((i) => {
13 16 const up = (i.change1d ?? 0) > 0;
14 17 const down = (i.change1d ?? 0) < 0;
15 18 return (
16 − <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="mono-num flex h-9 shrink-0 items-center gap-2.5 border-r border-border px-4 text-[12px] hover:bg-inset" aria-label={`${i.ticker}${i.latest ? ` ${fmtNum(i.latest.value, { digits: 2 })} ${fmtPct(i.change1d)}` : ' building'}`}>
19 + <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="mono-num flex h-9 shrink-0 items-center gap-2.5 border-r border-border px-4 text-[12px] hover:bg-inset" aria-label={`${i.ticker} ${fmtNum(i.latest!.value, { digits: 2 })} ${fmtPct(i.change1d)}`}>
17 20 <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} aria-hidden />
18 21 <span className="font-semibold tracking-wide text-fg">{i.ticker}</span>
19 − {i.latest ? (
20 − <>
21 − <span className="text-muted">{fmtNum(i.latest.value, { digits: 2 })}</span>
22 − <span className={cn('font-medium', deltaClass(i.change1d))}>
23 − {up ? '▲' : down ? '▼' : '■'} {fmtPct(i.change1d)}
24 − </span>
25 − </>
26 − ) : (
27 − <span className="text-subtle">
28 − building {i.pricedAssets}/{i.minConstituents}
29 − </span>
30 − )}
22 + <span className="text-muted">{fmtNum(i.latest!.value, { digits: 2 })}</span>
23 + <span className={cn('font-medium', deltaClass(i.change1d))}>
24 + {up ? '▲' : down ? '▼' : '■'} {fmtPct(i.change1d)}
25 + </span>
31 26 </Link>
32 27 );
33 28 });
34 29 return (
35 − <div className={cn('relative overflow-hidden border-y border-border bg-elevated', className)} aria-label="Index tape" role="marquee">
36 − <div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-elevated to-transparent" aria-hidden />
37 − <div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-elevated to-transparent" aria-hidden />
38 − <div className="tape-track flex w-max">
39 − {items}
40 − {items.map((it, k) => (
41 − <span key={`dup-${k}`} aria-hidden className="contents">
42 − {it}
43 − </span>
44 − ))}
45 − </div>
30 + <div className={className}>
31 + {items.length ? (
32 + <div className="relative overflow-hidden border-y border-border bg-elevated" aria-label="Index tape" role="marquee">
33 + <div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-elevated to-transparent" aria-hidden />
34 + <div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-elevated to-transparent" aria-hidden />
35 + <div className="tape-track flex w-max">
36 + {items}
37 + {items.map((it, k) => (
38 + <span key={`dup-${k}`} aria-hidden className="contents">
39 + {it}
40 + </span>
41 + ))}
42 + </div>
43 + </div>
44 + ) : (
45 + <div className="border-y border-border bg-elevated px-4 py-2 text-[12px] text-muted sm:px-6">No index has met its publication minimum yet — see the indices in development below.</div>
46 + )}
47 + {developing.length ? <DevelopingStrip items={developing} /> : null}
46 48 </div>
47 49 );
48 50 }
51 +
52 +/** Secondary line (§194): indices not yet published, with constituent progress. Compact, no marquee. */
53 +function DevelopingStrip({ items }: { items: IndexRow[] }) {
54 + return (
55 + <details className="group border-b border-border bg-sunken px-4 text-[11px] text-muted sm:px-6">
56 + <summary className="flex h-7 cursor-pointer list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
57 + <span aria-hidden className="inline-block text-subtle transition-transform group-open:rotate-90">▸</span>
58 + <span className="font-medium uppercase tracking-wider text-subtle">Indices in development</span>
59 + <span className="num text-subtle">{items.length}</span>
60 + <span className="hidden text-subtle sm:inline">· publish once each has its minimum number of priced constituents</span>
61 + <Link href="/methodology#indices" className="ml-auto text-subtle hover:text-fg">
62 + Methodology →
63 + </Link>
64 + </summary>
65 + <ul className="scrollbar-none -mx-4 flex gap-x-4 overflow-x-auto px-4 pb-2 sm:mx-0 sm:flex-wrap sm:px-0">
66 + {items.map((i) => (
67 + <li key={i.ticker} className="mono-num shrink-0">
68 + <Link href={`/rareindex/${i.ticker}`} className="inline-flex items-center gap-1.5 hover:text-fg" title={`${i.name}: ${i.pricedAssets} priced constituents of ${i.minConstituents} required`}>
69 + <span className="inline-block h-1.5 w-1.5 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} aria-hidden />
70 + <span className="text-fg">{i.ticker}</span>
71 + <span className="text-subtle">
72 + {i.pricedAssets}/{i.minConstituents}
73 + </span>
74 + </Link>
75 + </li>
76 + ))}
77 + </ul>
78 + </details>
79 + );
80 +}
modified apps/web/src/components/market/sales-table.tsx +4 −3
@@ -2,8 +2,8 @@ import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 2 import type { LotRow } from '@/lib/queries/market-lists';
3 3 import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 4 import { humanize } from '@/lib/taxonomy';
5 −import { Table, th, td, tdNum, EmptyState, Badge, Delta, VsRiv } from '@/components/ui/primitives';
6 −import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';
5 +import { Table, th, td, tdNum, EmptyState, Badge, VsRiv } from '@/components/ui/primitives';
6 +import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb, VerificationBadge } from './bits';
7 7
8 8 /** Card list for sales on small screens (image, title, grade, price, source). */
9 9 export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) {
@@ -92,7 +92,8 @@ export function SalesTable({ items, showAsset = true, className, emptyDescriptio
92 92 <SourceLink name={s.sourceName} url={s.sourceUrl} />
93 93 </td>
94 94 <td className={td}>
95 − {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : <span className="num text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>}
95 + <VerificationBadge label={s.verification} reason={s.verificationReason} />
96 + <span className="num ml-1 text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>
96 97 {s.flags.length ? <span className="ml-1 text-[10px] text-subtle" title={s.flags.join(', ')}>⚑</span> : null}
97 98 </td>
98 99 </tr>
added apps/web/src/components/ui/score-explainer.tsx +60 −0
@@ -0,0 +1,60 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/format';
4 +
5 +export interface ExplainerRow {
6 + /** component name, e.g. "Sales per month" */
7 + label: ReactNode;
8 + /** the asset's actual input, or null when RareIndex does not have it (rendered "not available") */
9 + value: ReactNode | null;
10 + /** weight in the formula, e.g. "40 %" */
11 + weight?: ReactNode;
12 + /** how the input maps to 0–1, e.g. "30 / month → 1" */
13 + note?: ReactNode;
14 +}
15 +
16 +/**
17 + * §204 "Explain every score": a native <details> (no client JS) that opens the components of a score
18 + * with the asset's real inputs and links to the matching Methodology section. Inputs RareIndex does
19 + * not store are shown as "not available" — a component is never filled with a guessed value.
20 + */
21 +export function ScoreExplainer({ summary, title, anchor, formula, rows, footnote, className }: { summary?: ReactNode; title: ReactNode; anchor: string; formula?: ReactNode; rows: ExplainerRow[]; footnote?: ReactNode; className?: string }) {
22 + return (
23 + <details className={cn('group text-[11px]', className)}>
24 + <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-subtle underline-offset-2 hover:text-fg hover:underline [&::-webkit-details-marker]:hidden">
25 + <span aria-hidden className="inline-block transition-transform group-open:rotate-90">▸</span>
26 + {summary ?? 'How is this computed?'}
27 + </summary>
28 + <div className="mt-2 rounded-sm border border-border bg-sunken p-2.5">
29 + <div className="flex items-baseline justify-between gap-2">
30 + <p className="font-semibold text-fg">{title}</p>
31 + <Link href={`/methodology#${anchor}`} className="shrink-0 text-subtle hover:text-fg hover:underline">
32 + Methodology →
33 + </Link>
34 + </div>
35 + {formula ? <p className="mono-num mt-1 text-[10.5px] leading-snug text-muted">{formula}</p> : null}
36 + <table className="mt-2 w-full border-collapse">
37 + <thead>
38 + <tr className="text-left text-[10px] uppercase tracking-wider text-subtle">
39 + <th className="py-0.5 pr-2 font-medium">Component</th>
40 + <th className="py-0.5 pr-2 text-right font-medium">This asset</th>
41 + {rows.some((r) => r.weight !== undefined) ? <th className="py-0.5 pr-2 text-right font-medium">Weight</th> : null}
42 + {rows.some((r) => r.note !== undefined) ? <th className="py-0.5 font-medium">Scale</th> : null}
43 + </tr>
44 + </thead>
45 + <tbody className="divide-y divide-border">
46 + {rows.map((r, i) => (
47 + <tr key={i}>
48 + <td className="py-1 pr-2 text-muted">{r.label}</td>
49 + <td className="num py-1 pr-2 text-right text-fg">{r.value === null || r.value === undefined ? <span className="text-subtle">not available</span> : r.value}</td>
50 + {rows.some((x) => x.weight !== undefined) ? <td className="num py-1 pr-2 text-right text-muted">{r.weight ?? ''}</td> : null}
51 + {rows.some((x) => x.note !== undefined) ? <td className="py-1 text-subtle">{r.note ?? ''}</td> : null}
52 + </tr>
53 + ))}
54 + </tbody>
55 + </table>
56 + {footnote ? <p className="mt-2 leading-snug text-subtle">{footnote}</p> : null}
57 + </div>
58 + </details>
59 + );
60 +}
modified apps/web/src/config/nav.ts +3 −1
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/assets.ts +16 −2
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/depth.ts +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/screener.ts +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/screener.test.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/screener.ts +171 −0
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/scrapfly/catawiki/index.ts +6 −3
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified docs/API.md +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified docs/METHODOLOGY.md +26 −4
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
deleted docs/PENDING-SCHEMA-account.md +0 −41
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
deleted docs/PENDING-SCHEMA-ai.md +0 −14
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
deleted docs/PENDING-SCHEMA-images.md +0 −18
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
deleted docs/PENDING-SCHEMA.md +0 −11
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added packages/valuation/src/depth.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
modified packages/valuation/src/index.ts +3 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added packages/valuation/src/liquidation.ts +113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added packages/valuation/src/microstructure.test.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/valuation/src/verification.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +3 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified workers/valuation/run.ts +9 −4
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme