SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

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 13 days ago (Sep 11, 2026) parent 9f86a54

43 changed files +2,087 −156

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
@@ -17,6 +17,7 @@ export const NAV: NavItem[] = [
17 17 { label: 'Markets', href: '/markets', description: 'Category markets, movers and volume', group: 'markets', primary: true },
18 18 { label: 'Categories', href: '/categories', description: 'The RareIndex taxonomy', group: 'discover' },
19 19 { label: 'RareIndex', href: '/rareindex', description: 'RARE and its subindices', group: 'markets', primary: true },
20 + { label: 'Screener', href: '/screener', description: 'Filter and rank assets by valuation, liquidity, returns and supply', group: 'markets' },
20 21 { label: 'Trending', href: '/trending', description: 'What collectors are moving into', group: 'markets' },
21 22 { label: 'Sales', href: '/sales', description: 'Latest observed transactions', group: 'markets', primary: true },
22 23 { label: 'Listings', href: '/listings', description: 'Live asks across marketplaces', group: 'markets' },
@@ -31,7 +32,8 @@ export const NAV: NavItem[] = [
31 32 { label: 'Grading', href: '/grading', description: 'Graders, populations and premiums', group: 'tools' },
32 33 { label: 'Auction Calendar', href: '/auctions/calendar', description: 'Upcoming auctions worldwide', group: 'markets' },
33 34 { label: 'News', href: '/news', description: 'Market news and record sales', group: 'discover' },
34 − { label: 'Data', href: '/data', description: 'Methodology, coverage and exports', group: 'company' },
35 + { label: 'Data', href: '/data', description: 'Coverage, sources and exports', group: 'company' },
36 + { label: 'Methodology', href: '/methodology', description: 'How RIV, confidence, scores and indices are computed', group: 'company' },
35 37 { label: 'API', href: '/api-docs', description: 'Programmatic access to RareIndex data', group: 'company' },
36 38 { label: 'About', href: '/about', description: 'Mission, disclaimers and contact', group: 'company' },
37 39 ];
modified apps/web/src/lib/queries/assets.ts +16 −2
@@ -3,6 +3,7 @@ import { cache } from 'react';
3 3 import type { SQL } from 'drizzle-orm';
4 4 import { descendants } from '@rareindex/taxonomy';
5 5 import { rows, one, sql, num, int, str, date, joinAnd, textArray } from './_util';
6 +import { classifySale, type SaleVerification } from '@rareindex/valuation';
6 7
7 8 export interface AssetCard {
8 9 id: string;
@@ -192,7 +193,9 @@ export async function rankedAssets(kind: RankedKind, opts: { scope?: string[]; l
192 193 const scope = opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``;
193 194 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`;
194 195 const spec: Record<typeof kind, { where: SQL; order: SQL }> = {
195 − trending: { where: sql`s.trending_score IS NOT NULL`, order: sql`s.trending_score DESC` },
196 + // Trending quality gates (§140): canonical identity with a transaction-based valuation, a real
197 + // 30-day market and a plausible price history — noisy titles with one odd sale must not lead.
198 + trending: { where: sql`s.trending_score IS NOT NULL AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_30d, 0) >= 2 AND (s.change_30d IS NULL OR abs(s.change_30d) <= 5)`, order: sql`s.trending_score DESC` },
196 199 // Movers need a valuation that can move: ≥ 5 transactions, medium confidence, a plausible move (≤ ±500 %) and
197 200 // at least one sale in the last year (a stale series cannot "move").
198 201 gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND ${chg} <= 5 AND s.riv_sample_size >= 5 AND s.riv_confidence >= 0.5 AND coalesce(s.sales_1y, 0) >= 3`, order: sql`${chg} DESC` },
@@ -363,6 +366,11 @@ export interface SaleRow {
363 366 flags: string[];
364 367 variantId: string | null;
365 368 buyerPremiumIncluded: boolean | null;
369 + /** source metadata used by the heuristic verification label (§39) */
370 + sourceType: string | null;
371 + sourceTrust: number | null;
372 + verification: SaleVerification;
373 + verificationReason: string;
366 374 assetSlug?: string;
367 375 assetTitle?: string;
368 376 categorySlug?: string;
@@ -393,6 +401,12 @@ export function toSaleRow(x: Record<string, unknown>): SaleRow {
393 401 flags: (x.flags as string[]) ?? [],
394 402 variantId: str(x.variant_id),
395 403 buyerPremiumIncluded: x.buyer_premium_included === null || x.buyer_premium_included === undefined ? null : Boolean(x.buyer_premium_included),
404 + sourceType: str(x.source_type),
405 + sourceTrust: num(x.trust_score),
406 + ...(() => {
407 + const v = classifySale({ status: str(x.status), confidence: num(x.confidence), flags: (x.flags as string[]) ?? [], sourceType: str(x.source_type), saleType: str(x.sale_type), trust: num(x.trust_score) });
408 + return { verification: v.label, verificationReason: v.reason };
409 + })(),
396 410 assetSlug: x.asset_slug ? String(x.asset_slug) : undefined,
397 411 assetTitle: x.asset_title ? String(x.asset_title) : undefined,
398 412 categorySlug: x.category_slug ? String(x.category_slug) : undefined,
@@ -400,7 +414,7 @@ export function toSaleRow(x: Record<string, unknown>): SaleRow {
400 414 };
401 415 }
402 416
403 −export const SALE_SELECT = sql`s.id, s.sale_date, s.price, s.currency, s.price_usd, s.sale_type, s.grader, s.grade, s.condition, s.certification_number, s.source_id, src.name AS source_name, s.source_url, s.auction_house, s.location, s.raw_title, s.image_urls, s.confidence, s.status, s.flags, s.variant_id, s.buyer_premium_included`;
417 +export const SALE_SELECT = sql`s.id, s.sale_date, s.price, s.currency, s.price_usd, s.sale_type, s.grader, s.grade, s.condition, s.certification_number, s.source_id, src.name AS source_name, s.source_url, s.auction_house, s.location, s.raw_title, s.image_urls, s.confidence, s.status, s.flags, s.variant_id, s.buyer_premium_included, src.source_type, src.trust_score`;
404 418
405 419 export async function getAssetSales(assetId: string, opts: { variantId?: string | null; limit?: number; offset?: number; includeFlagged?: boolean } = {}): Promise<{ items: SaleRow[]; total: number }> {
406 420 const limit = opts.limit ?? 50;
added apps/web/src/lib/queries/depth.ts +87 −0
@@ -0,0 +1,87 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import { fairPrices, liquidationModel, marketDepth, type FairPrices, type LiquidationModel, type ListingLifecycle, type MarketDepth } from '@rareindex/valuation';
4 +import { rows, one, sql, num, int } from './_util';
5 +
6 +/**
7 + * Market depth (§25–§26): current asks of the representative variant against its RIV. Falls back to
8 + * all variants only when the asset has a single variant (then the comparison is unambiguous).
9 + */
10 +export interface AssetDepth {
11 + depth: MarketDepth;
12 + riv: number;
13 + variantId: string | null;
14 + scope: 'variant' | 'asset';
15 +}
16 +
17 +export const getAssetDepth = cache(async (assetId: string, variantId: string | null): Promise<AssetDepth | null> => {
18 + const stats = await one<Record<string, unknown>>(sql`
19 + SELECT s.riv_variant_id, s.riv_usd, (SELECT count(*) FROM asset_variants v WHERE v.asset_id = ${assetId}) AS variants,
20 + vs.riv_usd AS variant_riv
21 + FROM asset_stats s LEFT JOIN variant_stats vs ON vs.variant_id = ${variantId ?? sql`s.riv_variant_id`}
22 + WHERE s.asset_id = ${assetId}`);
23 + if (!stats) return null;
24 + const target = variantId ?? (stats.riv_variant_id ? String(stats.riv_variant_id) : null);
25 + const riv = variantId ? num(stats.variant_riv) : num(stats.variant_riv) ?? num(stats.riv_usd);
26 + if (riv === null) return null;
27 + const single = int(stats.variants) <= 1;
28 + const scope: AssetDepth['scope'] = target && !single ? 'variant' : 'asset';
29 + const asks = await rows<{ price_usd: unknown }>(sql`
30 + SELECT l.price_usd FROM listings l
31 + WHERE l.asset_id = ${assetId} AND l.availability = 'available' AND l.price_usd > 0
32 + ${scope === 'variant' ? sql`AND l.variant_id = ${target}` : sql``}
33 + AND NOT (l.grader IS NOT NULL AND l.grade IS NULL)`);
34 + const depth = marketDepth(asks.map((a) => num(a.price_usd)), riv);
35 + return depth ? { depth, riv, variantId: scope === 'variant' ? target : null, scope } : null;
36 +});
37 +
38 +/**
39 + * Days on market / time-to-sale (§24, §30, §214) from observed listing lifecycles. Only listings the
40 + * connector marked `sold` are sale outcomes; `ended` / `removed` are censored. With fewer than 10 own
41 + * lifecycles the category's pooled lifecycles (last 365 days) are used and labelled as such.
42 + */
43 +export interface AssetLiquidation {
44 + model: LiquidationModel;
45 + fair: FairPrices;
46 + level: 'asset' | 'category';
47 + lifecycles: number;
48 +}
49 +
50 +const LIFECYCLE_SELECT = sql`l.first_seen_at, l.last_seen_at, l.availability,
51 + 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`;
52 +
53 +function toLifecycle(x: Record<string, unknown>): ListingLifecycle | null {
54 + const first = x.first_seen_at instanceof Date ? x.first_seen_at : new Date(String(x.first_seen_at));
55 + const last = x.last_seen_at instanceof Date ? x.last_seen_at : new Date(String(x.last_seen_at));
56 + const outcome = String(x.availability);
57 + if (Number.isNaN(first.getTime()) || Number.isNaN(last.getTime())) return null;
58 + if (outcome !== 'sold' && outcome !== 'ended' && outcome !== 'removed') return null;
59 + return { firstSeen: first, lastSeen: last, outcome, askToRiv: num(x.ask_to_riv) };
60 +}
61 +
62 +export const getAssetLiquidation = cache(async (asset: { id: string; categorySlug: string; rivUsd: number | null; rivLowUsd: number | null; rivHighUsd: number | null }): Promise<AssetLiquidation | null> => {
63 + const own = await rows<Record<string, unknown>>(sql`
64 + SELECT ${LIFECYCLE_SELECT} FROM listings l
65 + LEFT JOIN variant_stats vs ON vs.variant_id = l.variant_id
66 + LEFT JOIN asset_stats s ON s.asset_id = l.asset_id
67 + WHERE l.asset_id = ${asset.id} AND l.availability IN ('sold', 'ended', 'removed') AND l.last_seen_at > l.first_seen_at
68 + ORDER BY l.last_seen_at DESC LIMIT 2000`);
69 + let level: AssetLiquidation['level'] = 'asset';
70 + let raw = own;
71 + if (own.length < 10) {
72 + level = 'category';
73 + raw = await rows<Record<string, unknown>>(sql`
74 + SELECT ${LIFECYCLE_SELECT} FROM listings l
75 + JOIN assets a ON a.id = l.asset_id
76 + LEFT JOIN variant_stats vs ON vs.variant_id = l.variant_id
77 + LEFT JOIN asset_stats s ON s.asset_id = l.asset_id
78 + WHERE a.category_slug = ${asset.categorySlug} AND l.availability IN ('sold', 'ended', 'removed')
79 + AND l.last_seen_at > l.first_seen_at AND l.last_seen_at >= now() - interval '365 days'
80 + ORDER BY l.last_seen_at DESC LIMIT 5000`);
81 + }
82 + const lifecycles = raw.map(toLifecycle).filter((x): x is ListingLifecycle => x !== null);
83 + if (!lifecycles.length) return null;
84 + const model = liquidationModel(lifecycles);
85 + const fair = fairPrices({ riv: asset.rivUsd, low: asset.rivLowUsd, high: asset.rivHighUsd }, model);
86 + return { model, fair, level, lifecycles: lifecycles.length };
87 +});
added apps/web/src/lib/queries/screener.ts +172 −0
@@ -0,0 +1,172 @@
1 +import 'server-only';
2 +import { sql, type SQL } from 'drizzle-orm';
3 +import { categoryScope } from '@/lib/queries/assets';
4 +import { int, joinAnd, num, rows, str } from '@/lib/queries/_util';
5 +import { SCREENER_EXPORT_MAX, type ScreenerFilters, type ScreenerSort } from '@/lib/screener';
6 +
7 +/**
8 + * Market Screener rows (§158). Every derived metric is computed once in the inner query so filters,
9 + * sorts and the output agree exactly:
10 + * - drawdown = RIV / ATH − 1 (≤ 0, null without an ATH)
11 + * - spread = (lowest ask of the representative variant − RIV) / RIV, only when the valuation is
12 + * transaction-grade (≥ 5 sales, confidence ≥ 0.5) and the ratio is plausible (0.1×–10×)
13 + * - accel = sales_30d / max(sales_1y / 12, 1) (volume acceleration vs the trailing year)
14 + * Data-quality gates always on: a valuation exists and rests on ≥ 3 sales (§143, §196).
15 + */
16 +export interface ScreenerRow {
17 + id: string;
18 + slug: string;
19 + title: string;
20 + categorySlug: string;
21 + familySlug: string;
22 + heroImageUrl: string | null;
23 + year: number | null;
24 + brand: string | null;
25 + setName: string | null;
26 + rivUsd: number;
27 + rivLowUsd: number | null;
28 + rivHighUsd: number | null;
29 + rivConfidence: number | null;
30 + rivSampleSize: number;
31 + change30d: number | null;
32 + change1y: number | null;
33 + liquidityScore: number | null;
34 + rarityScore: number | null;
35 + sales30d: number;
36 + sales1y: number;
37 + salesCount: number;
38 + activeListings: number;
39 + minAskUsd: number | null;
40 + spread: number | null;
41 + valueOpportunity: number | null;
42 + athUsd: number | null;
43 + drawdown: number | null;
44 + volume30dUsd: number | null;
45 + accel: number | null;
46 + updatedAt: Date | null;
47 +}
48 +
49 +const SORT_COL: Record<ScreenerSort, SQL> = {
50 + riv: sql`riv_usd`,
51 + confidence: sql`riv_confidence`,
52 + change30d: sql`change_30d`,
53 + change1y: sql`change_1y`,
54 + liquidity: sql`liquidity_score`,
55 + rarity: sql`rarity_score`,
56 + sales30d: sql`sales_30d`,
57 + sales1y: sql`sales_1y`,
58 + listings: sql`active_listings`,
59 + spread: sql`spread`,
60 + drawdown: sql`drawdown`,
61 + volume30d: sql`volume_30d_usd`,
62 + opportunity: sql`value_opportunity`,
63 + name: sql`title`,
64 +};
65 +
66 +const INNER = sql`
67 + SELECT a.id, a.slug, a.title, a.category_slug, a.family_slug, a.hero_image_url, a.year, a.brand, a.set_name, a.set_slug,
68 + s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, coalesce(s.riv_sample_size, 0) AS riv_sample_size,
69 + s.change_30d, s.change_1y, s.liquidity_score, s.rarity_score,
70 + coalesce(s.sales_30d, 0) AS sales_30d, coalesce(s.sales_1y, 0) AS sales_1y, coalesce(s.sales_count, 0) AS sales_count,
71 + coalesce(s.active_listings, 0) AS active_listings, s.min_ask_usd, s.value_opportunity, s.ath_usd, s.volume_30d_usd, s.updated_at,
72 + CASE WHEN s.ath_usd > 0 THEN s.riv_usd / s.ath_usd - 1 END AS drawdown,
73 + CASE WHEN s.min_ask_usd > 0 AND s.riv_usd > 0 AND coalesce(s.riv_sample_size, 0) >= 5 AND s.riv_confidence >= 0.5
74 + AND s.min_ask_usd / s.riv_usd BETWEEN 0.1 AND 10
75 + THEN s.min_ask_usd / s.riv_usd - 1 END AS spread,
76 + coalesce(s.sales_30d, 0)::float / greatest(coalesce(s.sales_1y, 0) / 12.0, 1) AS accel
77 + FROM assets a JOIN asset_stats s ON s.asset_id = a.id
78 + WHERE s.riv_usd IS NOT NULL AND coalesce(s.riv_sample_size, 0) >= 3`;
79 +
80 +function whereFor(f: ScreenerFilters): SQL {
81 + const w: SQL[] = [];
82 + if (f.category) w.push(sql`category_slug IN ${categoryScope(f.category)}`);
83 + if (f.brand) w.push(sql`lower(brand) = lower(${f.brand})`);
84 + if (f.set) w.push(sql`set_slug = ${f.set}`);
85 + if (f.rivMin !== null) w.push(sql`riv_usd >= ${f.rivMin}`);
86 + if (f.rivMax !== null) w.push(sql`riv_usd <= ${f.rivMax}`);
87 + if (f.confidenceMin !== null) w.push(sql`riv_confidence >= ${f.confidenceMin}`);
88 + if (f.liquidityMin !== null) w.push(sql`liquidity_score >= ${f.liquidityMin}`);
89 + if (f.rarityMin !== null) w.push(sql`rarity_score >= ${f.rarityMin}`);
90 + // change filters only consider plausible moves (|Δ| ≤ 500 %); artefacts never match a screen
91 + if (f.change30dMin !== null) w.push(sql`change_30d >= ${f.change30dMin} AND abs(change_30d) <= 5`);
92 + if (f.change30dMax !== null) w.push(sql`change_30d <= ${f.change30dMax} AND abs(change_30d) <= 5`);
93 + if (f.change1yMin !== null) w.push(sql`change_1y >= ${f.change1yMin} AND abs(change_1y) <= 5`);
94 + if (f.change1yMax !== null) w.push(sql`change_1y <= ${f.change1yMax} AND abs(change_1y) <= 5`);
95 + if (f.sales30dMin !== null) w.push(sql`sales_30d >= ${f.sales30dMin}`);
96 + if (f.sales1yMin !== null) w.push(sql`sales_1y >= ${f.sales1yMin}`);
97 + if (f.listingsMin !== null) w.push(sql`active_listings >= ${f.listingsMin}`);
98 + if (f.drawdownMin !== null) w.push(sql`drawdown >= ${f.drawdownMin}`);
99 + if (f.drawdownMax !== null) w.push(sql`drawdown <= ${f.drawdownMax}`);
100 + if (f.spreadMin !== null) w.push(sql`spread >= ${f.spreadMin}`);
101 + if (f.spreadMax !== null) w.push(sql`spread <= ${f.spreadMax}`);
102 + if (f.volumeAccelMin !== null) w.push(sql`accel >= ${f.volumeAccelMin}`);
103 + if (f.yearFrom !== null) w.push(sql`year >= ${f.yearFrom}`);
104 + if (f.yearTo !== null) w.push(sql`year <= ${f.yearTo}`);
105 + if (f.grader) w.push(sql`EXISTS (SELECT 1 FROM asset_variants v WHERE v.asset_id = x.id AND v.grader = ${f.grader} ${f.grade ? sql`AND v.grade = ${f.grade}` : sql``})`);
106 + if (f.q && f.q.trim().length >= 2) {
107 + const q = f.q.trim();
108 + w.push(sql`(title ILIKE ${'%' + q + '%'} OR title % ${q} OR set_name ILIKE ${'%' + q + '%'})`);
109 + }
110 + // the "Potentially Underpriced" sort/preset must never surface an ungated or implausible discount
111 + if (f.sort === 'opportunity') w.push(sql`value_opportunity IS NOT NULL AND value_opportunity >= -0.9 AND value_opportunity <= -0.1 AND riv_sample_size >= 5 AND riv_confidence >= 0.5`);
112 + return joinAnd(w);
113 +}
114 +
115 +function orderFor(f: ScreenerFilters): SQL {
116 + const col = SORT_COL[f.sort];
117 + const dir = f.dir === 'asc' ? sql`ASC` : sql`DESC`;
118 + return sql`${col} ${dir} NULLS LAST, riv_usd DESC NULLS LAST, id ASC`;
119 +}
120 +
121 +function toRow(x: Record<string, unknown>): ScreenerRow {
122 + return {
123 + id: String(x.id),
124 + slug: String(x.slug),
125 + title: String(x.title),
126 + categorySlug: String(x.category_slug),
127 + familySlug: String(x.family_slug),
128 + heroImageUrl: str(x.hero_image_url),
129 + year: num(x.year),
130 + brand: str(x.brand),
131 + setName: str(x.set_name),
132 + rivUsd: num(x.riv_usd) ?? 0,
133 + rivLowUsd: num(x.riv_low_usd),
134 + rivHighUsd: num(x.riv_high_usd),
135 + rivConfidence: num(x.riv_confidence),
136 + rivSampleSize: int(x.riv_sample_size),
137 + change30d: num(x.change_30d),
138 + change1y: num(x.change_1y),
139 + liquidityScore: num(x.liquidity_score),
140 + rarityScore: num(x.rarity_score),
141 + sales30d: int(x.sales_30d),
142 + sales1y: int(x.sales_1y),
143 + salesCount: int(x.sales_count),
144 + activeListings: int(x.active_listings),
145 + minAskUsd: num(x.min_ask_usd),
146 + spread: num(x.spread),
147 + valueOpportunity: num(x.value_opportunity),
148 + athUsd: num(x.ath_usd),
149 + drawdown: num(x.drawdown),
150 + volume30dUsd: num(x.volume_30d_usd),
151 + accel: num(x.accel),
152 + updatedAt: x.updated_at ? new Date(String(x.updated_at)) : null,
153 + };
154 +}
155 +
156 +export async function screenAssets(f: ScreenerFilters): Promise<{ items: ScreenerRow[]; total: number; page: number; pageSize: number }> {
157 + const pageSize = Math.min(Math.max(f.pageSize, 1), 100);
158 + const page = Math.max(1, f.page);
159 + const r = await rows<Record<string, unknown>>(sql`
160 + SELECT x.*, count(*) OVER() AS total FROM (${INNER}) x
161 + WHERE ${whereFor(f)} ORDER BY ${orderFor(f)} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
162 + `);
163 + return { items: r.map(toRow), total: r.length ? int(r[0]!.total) : 0, page, pageSize };
164 +}
165 +
166 +/** Export variant: no window count, hard cap (§160). */
167 +export async function screenAssetsExport(f: ScreenerFilters, limit = SCREENER_EXPORT_MAX): Promise<ScreenerRow[]> {
168 + const r = await rows<Record<string, unknown>>(sql`
169 + SELECT x.* FROM (${INNER}) x WHERE ${whereFor(f)} ORDER BY ${orderFor(f)} LIMIT ${Math.min(limit, SCREENER_EXPORT_MAX)}
170 + `);
171 + return r.map(toRow);
172 +}
added apps/web/src/lib/screener.test.ts +62 −0
@@ -0,0 +1,62 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { PRESETS, csvCell, isPlausibleChange, parseScreenerParams, toCsv } from './screener';
3 +
4 +describe('parseScreenerParams', () => {
5 + it('reads percent inputs as fractions and applies sort defaults', () => {
6 + const f = parseScreenerParams({ c30min: '10', spmax: '-15', ddmax: '-30', conf: '75', min: '1,000', size: '500' });
7 + expect(f.change30dMin).toBeCloseTo(0.1);
8 + expect(f.spreadMax).toBeCloseTo(-0.15);
9 + expect(f.drawdownMax).toBeCloseTo(-0.3);
10 + expect(f.confidenceMin).toBe(0.75); // "75" → 0.75
11 + expect(f.rivMin).toBe(1000);
12 + expect(f.pageSize).toBe(100); // capped
13 + expect(f.sort).toBe('riv');
14 + expect(f.dir).toBe('desc');
15 + });
16 + it('spread and drawdown sort ascending by default (most below first)', () => {
17 + expect(parseScreenerParams({ sort: 'spread' }).dir).toBe('asc');
18 + expect(parseScreenerParams({ sort: 'drawdown' }).dir).toBe('asc');
19 + expect(parseScreenerParams({ sort: 'spread', dir: 'desc' }).dir).toBe('desc');
20 + expect(parseScreenerParams({ sort: 'nonsense' }).sort).toBe('riv');
21 + });
22 + it('applies preset filters without overriding explicit values', () => {
23 + const f = parseScreenerParams({ preset: 'underpriced' });
24 + expect(f.sort).toBe('opportunity');
25 + expect(f.dir).toBe('asc');
26 + expect(f.spreadMax).toBe(-0.1);
27 + expect(f.confidenceMin).toBe(0.5);
28 + const g = parseScreenerParams({ preset: 'underpriced', conf: '0.75', sort: 'liquidity' });
29 + expect(g.confidenceMin).toBe(0.75);
30 + expect(g.sort).toBe('liquidity');
31 + expect(g.dir).toBe('desc');
32 + });
33 + it('every preset has a valid sort and a description', () => {
34 + for (const p of PRESETS) {
35 + expect(p.description.length).toBeGreaterThan(10);
36 + expect(parseScreenerParams({ preset: p.id }).sort).toBe(p.filters.sort);
37 + }
38 + expect(PRESETS.map((p) => p.id)).not.toContain('supply'); // not derivable honestly yet, see PRESETS comment
39 + });
40 +});
41 +
42 +describe('plausibility', () => {
43 + it('holds back artefact-sized moves', () => {
44 + expect(isPlausibleChange(0.42)).toBe(true);
45 + expect(isPlausibleChange(-0.99)).toBe(true);
46 + expect(isPlausibleChange(85.17)).toBe(false); // the −8,516 % class of value
47 + expect(isPlausibleChange(null)).toBe(false);
48 + });
49 +});
50 +
51 +describe('csv', () => {
52 + it('escapes quotes, commas and newlines', () => {
53 + expect(csvCell('Charizard, 1st Ed "Holo"')).toBe('"Charizard, 1st Ed ""Holo"""');
54 + expect(csvCell(null)).toBe('');
55 + expect(csvCell(12.5)).toBe('12.5');
56 + expect(csvCell(new Date('2026-09-11T00:00:00Z'))).toBe('2026-09-11T00:00:00.000Z');
57 + });
58 + it('writes comment lines, a header and CRLF rows', () => {
59 + const out = toCsv(['a', 'b'], [[1, 'x,y'], [null, 'z']], ['as_of=now']);
60 + expect(out).toBe('# as_of=now\r\na,b\r\n1,"x,y"\r\n,z\r\n');
61 + });
62 +});
added apps/web/src/lib/screener.ts +171 −0
@@ -0,0 +1,171 @@
1 +/**
2 + * Market Screener — pure helpers shared by the page, the export route and tests (§157–§160).
3 + * No server-only imports here; the SQL lives in `lib/queries/screener.ts`.
4 + *
5 + * Conventions (same as the rest of RareIndex, §83–§84, §196):
6 + * - changes are fractions ((v_t / v_0) − 1); anything beyond ±500 % is an artefact and is held back;
7 + * - spread = (min ask − RIV) / RIV, negative = below the valuation, only for gated valuations;
8 + * - drawdown = RIV / all-time-high sale − 1 (≤ 0), only when an ATH exists.
9 + */
10 +import { sp1, spEnum, spInt, spNum, type SP } from '@/lib/search-params';
11 +
12 +export const SCREENER_SORTS = ['riv', 'confidence', 'change30d', 'change1y', 'liquidity', 'rarity', 'sales30d', 'sales1y', 'listings', 'spread', 'drawdown', 'volume30d', 'opportunity', 'name'] as const;
13 +export type ScreenerSort = (typeof SCREENER_SORTS)[number];
14 +export type SortDir = 'asc' | 'desc';
15 +
16 +export const SCREENER_PAGE_SIZE = 50;
17 +export const SCREENER_MAX_PAGE_SIZE = 100;
18 +export const SCREENER_EXPORT_MAX = 5000;
19 +
20 +/** Plausibility bound for displayed valuation moves (fraction). */
21 +export const MAX_PLAUSIBLE_CHANGE = 5;
22 +
23 +export interface ScreenerFilters {
24 + category: string | null;
25 + grader: string | null;
26 + grade: string | null;
27 + rivMin: number | null;
28 + rivMax: number | null;
29 + /** 0.5 = medium+, 0.75 = high */
30 + confidenceMin: number | null;
31 + liquidityMin: number | null;
32 + rarityMin: number | null;
33 + change30dMin: number | null;
34 + change30dMax: number | null;
35 + change1yMin: number | null;
36 + change1yMax: number | null;
37 + sales30dMin: number | null;
38 + sales1yMin: number | null;
39 + listingsMin: number | null;
40 + /** drawdown from ATH, fraction ≤ 0; "at most this deep" e.g. −0.05 for near ATH */
41 + drawdownMin: number | null;
42 + /** "at least this deep" e.g. −0.3 for largest drawdowns */
43 + drawdownMax: number | null;
44 + /** spread = (min ask − RIV)/RIV; e.g. spreadMax −0.1 = asks at least 10 % below RIV */
45 + spreadMin: number | null;
46 + spreadMax: number | null;
47 + /** sales_30d ≥ k × (sales_1y / 12) */
48 + volumeAccelMin: number | null;
49 + yearFrom: number | null;
50 + yearTo: number | null;
51 + brand: string | null;
52 + set: string | null;
53 + q: string | null;
54 + sort: ScreenerSort;
55 + dir: SortDir;
56 + page: number;
57 + pageSize: number;
58 +}
59 +
60 +/** URL keys read by the screener (kept short; they are what users share). */
61 +export const SCREENER_KEYS = ['preset', 'category', 'grader', 'grade', 'min', 'max', 'conf', 'liq', 'rar', 'c30min', 'c30max', 'c1ymin', 'c1ymax', 's30', 's1y', 'lst', 'ddmin', 'ddmax', 'spmin', 'spmax', 'vacc', 'from', 'to', 'brand', 'set', 'q', 'sort', 'dir', 'page', 'size'];
62 +
63 +export const DEFAULT_SORT: Record<ScreenerSort, SortDir> = {
64 + riv: 'desc',
65 + confidence: 'desc',
66 + change30d: 'desc',
67 + change1y: 'desc',
68 + liquidity: 'desc',
69 + rarity: 'desc',
70 + sales30d: 'desc',
71 + sales1y: 'desc',
72 + listings: 'desc',
73 + spread: 'asc', // most below RIV first
74 + drawdown: 'asc', // deepest first
75 + volume30d: 'desc',
76 + opportunity: 'asc',
77 + name: 'asc',
78 +};
79 +
80 +/** Percent inputs in the URL (e.g. `c30min=10`) are turned into fractions. */
81 +const pctParam = (sp: SP, key: string): number | null => {
82 + const v = spNum(sp, key);
83 + return v === null ? null : v / 100;
84 +};
85 +
86 +export function parseScreenerParams(sp: SP): ScreenerFilters {
87 + const preset = sp1(sp, 'preset');
88 + const presetFilters = preset ? (PRESETS.find((p) => p.id === preset)?.filters ?? {}) : {};
89 + const sort = spEnum(sp, 'sort', SCREENER_SORTS, presetFilters.sort ?? 'riv');
90 + const dir = spEnum<SortDir>(sp, 'dir', ['asc', 'desc'] as const, presetFilters.sort === sort && presetFilters.dir ? presetFilters.dir : DEFAULT_SORT[sort]);
91 + const base: ScreenerFilters = {
92 + category: sp1(sp, 'category') ?? null,
93 + grader: sp1(sp, 'grader') ?? null,
94 + grade: sp1(sp, 'grade') ?? null,
95 + rivMin: spNum(sp, 'min'),
96 + rivMax: spNum(sp, 'max'),
97 + confidenceMin: spNum(sp, 'conf'),
98 + liquidityMin: spNum(sp, 'liq'),
99 + rarityMin: spNum(sp, 'rar'),
100 + change30dMin: pctParam(sp, 'c30min'),
101 + change30dMax: pctParam(sp, 'c30max'),
102 + change1yMin: pctParam(sp, 'c1ymin'),
103 + change1yMax: pctParam(sp, 'c1ymax'),
104 + sales30dMin: spNum(sp, 's30'),
105 + sales1yMin: spNum(sp, 's1y'),
106 + listingsMin: spNum(sp, 'lst'),
107 + drawdownMin: pctParam(sp, 'ddmin'),
108 + drawdownMax: pctParam(sp, 'ddmax'),
109 + spreadMin: pctParam(sp, 'spmin'),
110 + spreadMax: pctParam(sp, 'spmax'),
111 + volumeAccelMin: spNum(sp, 'vacc'),
112 + yearFrom: spNum(sp, 'from'),
113 + yearTo: spNum(sp, 'to'),
114 + brand: sp1(sp, 'brand') ?? null,
115 + set: sp1(sp, 'set') ?? null,
116 + q: sp1(sp, 'q') ?? null,
117 + sort,
118 + dir,
119 + page: spInt(sp, 'page'),
120 + pageSize: Math.min(SCREENER_MAX_PAGE_SIZE, Math.max(10, spInt(sp, 'size', SCREENER_PAGE_SIZE))),
121 + };
122 + // Preset values apply only where the user has not set an explicit value.
123 + const merged: ScreenerFilters = { ...base };
124 + for (const [k, v] of Object.entries(presetFilters) as Array<[keyof ScreenerFilters, unknown]>) {
125 + if (k === 'sort' || k === 'dir') continue;
126 + if (merged[k] === null || merged[k] === undefined) (merged as unknown as Record<string, unknown>)[k] = v;
127 + }
128 + if (merged.confidenceMin !== null && merged.confidenceMin > 1) merged.confidenceMin = merged.confidenceMin / 100; // "75" → 0.75
129 + return merged;
130 +}
131 +
132 +export interface ScreenerPreset {
133 + id: string;
134 + label: string;
135 + description: string;
136 + filters: Partial<ScreenerFilters>;
137 +}
138 +
139 +/**
140 + * §159 presets. Each is a plain set of filters + sort so it stays shareable and explainable.
141 + * "Supply Shrinking" is intentionally absent: asset_stats holds only the current listing count, and
142 + * price_snapshots.listings_count is written only on days an asset is revalued, so a 30-day listing
143 + * delta cannot be computed honestly for the whole universe yet. It will be added with listing-history
144 + * aggregates (§28, §137).
145 + */
146 +export const PRESETS: ScreenerPreset[] = [
147 + { id: 'liquid', label: 'Most Liquid', description: 'Highest Liquidity Score (sales frequency, depth, sources, spread).', filters: { sort: 'liquidity', dir: 'desc', sales1yMin: 3 } },
148 + { id: 'traded', label: 'Most Traded', description: 'Most verified sales in the last 30 days.', filters: { sort: 'sales30d', dir: 'desc', sales30dMin: 1 } },
149 + { id: 'drawdown', label: 'Largest Drawdowns', description: 'RIV furthest below the all-time-high verified sale (≥ 5 sales, medium+ confidence).', filters: { sort: 'drawdown', dir: 'asc', drawdownMax: -0.3, confidenceMin: 0.5 } },
150 + { id: 'ath', label: 'Near ATH', description: 'RIV within 5 % of the all-time-high verified sale.', filters: { sort: 'riv', dir: 'desc', drawdownMin: -0.05, confidenceMin: 0.5 } },
151 + { id: 'volume', label: 'Increasing Volume', description: 'Sales in the last 30 days at least 2× the trailing 12-month monthly average.', filters: { sort: 'sales30d', dir: 'desc', volumeAccelMin: 2, sales1yMin: 6 } },
152 + { id: 'underpriced', label: 'Potentially Underpriced', description: 'Best gated ask 10–90 % below a transaction-based RIV (≥ 5 sales, medium+ confidence). Analytical data, not advice.', filters: { sort: 'opportunity', dir: 'asc', spreadMax: -0.1, confidenceMin: 0.5 } },
153 +];
154 +
155 +export function isPlausibleChange(v: number | null | undefined): v is number {
156 + return v !== null && v !== undefined && Number.isFinite(v) && Math.abs(v) <= MAX_PLAUSIBLE_CHANGE;
157 +}
158 +
159 +/** RFC 4180-ish CSV cell escaping. */
160 +export function csvCell(v: unknown): string {
161 + if (v === null || v === undefined) return '';
162 + const s = v instanceof Date ? v.toISOString() : String(v);
163 + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
164 +}
165 +
166 +export function toCsv(header: string[], rows: unknown[][], comments: string[] = []): string {
167 + const lines = comments.map((c) => `# ${c}`);
168 + lines.push(header.map(csvCell).join(','));
169 + for (const r of rows) lines.push(r.map(csvCell).join(','));
170 + return lines.join('\r\n') + '\r\n';
171 +}
modified connectors/scrapfly/catawiki/index.ts +6 −3
@@ -404,7 +404,7 @@ export class CatawikiConnector extends BaseConnector {
404 404 };
405 405 const endsAt = toDate(lot.bidding.biddingEndTime);
406 406 if (lot.bidding.closed && lot.bidding.sold && lot.bidding.finalBidEur && endsAt) {
407 − if (endsAt.getTime() > Date.now() + 86_400_000) return [];
407 + if (endsAt.getTime() > new Date(raw.fetchedAt).getTime() + 86_400_000) return []; // a 'closed' lot ending after observation is inconsistent
408 408 return [
409 409 NormalizedSaleSchema.parse({
410 410 ...base,
@@ -451,7 +451,10 @@ export class CatawikiConnector extends BaseConnector {
451 451 const a = page.auction;
452 452 const closeAt = a.closeAt ? new Date(a.closeAt) : null;
453 453 const startAt = a.startAt ? new Date(a.startAt) : null;
454 − if (closeAt && closeAt.getTime() < Date.now()) return []; // stale listing page
454 + // staleness is judged at observation time (raw.fetchedAt), not at normalisation time: fixtures and
455 + // delayed normalisation must not silently drop lots that were live when crawled
456 + const observedAtMs = new Date(raw.fetchedAt).getTime();
457 + if (closeAt && closeAt.getTime() < observedAtMs) return []; // stale listing page
455 458 const catLabel = a.categories[a.categories.length - 1] ?? a.categories[0] ?? null;
456 459 for (const l of page.lots) {
457 460 const text = `${l.title} ${l.subtitle ?? ''}`.trim();
@@ -483,7 +486,7 @@ export class CatawikiConnector extends BaseConnector {
483 486 estimateHigh: null,
484 487 currentBid: null,
485 488 currency: 'EUR',
486 − status: startAt && startAt.getTime() <= Date.now() ? 'live' : 'upcoming',
489 + status: startAt && startAt.getTime() <= observedAtMs ? 'live' : 'upcoming',
487 490 location: null,
488 491 }),
489 492 );
modified docs/API.md +1 −0
@@ -46,6 +46,7 @@ carries `x-request-id`. Usage is counted per key/day/endpoint in `api_usage`.
46 46 | `GET /v1/assets/{id}/sales?variant&include_flagged` | Observed sales (valid only by default) |
47 47 | `GET /v1/assets/{id}/listings?availability` | Listings (asks) with `discount_to_riv` |
48 48 | `GET /v1/assets/{id}/history?variant&from&to` | Daily series: RIV, latest sale, median, sales count, volume, listings, min ask |
49 +| `GET /v1/assets/{id}/depth?variant` | Market depth (asks within ±5/10/20 % of RIV, below/above, best & median ask, RareIndex spread), days on market (sold vs withdrawn), median time-to-sale by asking band (≤ 90 %, 90–100 %, 100–110 %, > 110 % of RIV; null under 5 observations), fair buy/sell ladder. `liquidation.level` is `category` when the asset has < 10 completed lifecycles. Model estimates, not advice |
49 50 | `GET /v1/categories` | Taxonomy with tracked asset counts |
50 51 | `GET /v1/indices` | RARE + subindices: latest value, 1d/7d/30d/YTD/1y changes, breadth, market cap estimate + confidence; `published=false` until breadth threshold |
51 52 | `GET /v1/indices/{ticker}/history?from&to` | Index history |
modified docs/METHODOLOGY.md +26 −4
@@ -77,12 +77,32 @@ equivalent; premiums are only ever derived from paired evidence.
77 77 sale/listing frequency. Rarity is *null* when no supply signal exists — it is never guessed.
78 78 - **Momentum (−100…100)** over 7d/30d/90d/1y: price change against the RIV history blended with
79 79 transaction-volume acceleration.
80 −- **Value opportunity**: discount of an active ask against RIV, shown only when RIV confidence ≥ 0.5.
81 − Analytical information, not investment advice.
80 +- **Value opportunity**: `(ask − RIV) / RIV` of an active ask against the RIV of its **own variant**
81 + (negative = below the valuation), shown only when the gate below passes. Analytical information,
82 + not investment advice.
82 83 - **Data quality (0–100)**: attribute completeness, source trust, identification confidence, images
83 84 and transaction evidence.
84 85 - **Trending**: geometric blend of price, volume and listing momenta.
85 86
87 +### Ask vs RIV and the anomaly gate
88 +
89 +An asking price is compared with a valuation only when the comparison is legitimate (the live
90 +constants are exported from `packages/valuation/src/scores.ts` and rendered on `/methodology`):
91 +
92 +- same variant — a slab whose grade could not be read is kept in a "grader · grade unknown" variant
93 + and is never compared against the raw valuation;
94 +- the RIV rests on transactions (comps-only and guide-only estimates never qualify an ask);
95 +- RIV confidence ≥ 0.5 and ≥ 5 transactions used; listing identification confidence ≥ 0.7;
96 +- plausibility band: an ask below 0.1× or above 10× RIV is flagged `riv_anomaly` and shown as
97 + **Data/identity anomaly** — a wrong variant, lot, currency or identity until reviewed — never as a
98 + deal.
99 +
100 +Verdicts: **deal** ≤ −10 %, **fair** between −10 % and +10 %, **premium** ≥ +10 %. The **Deal Score**
101 +(0–100) multiplies discount depth, RIV confidence, sample size, liquidity and match confidence, so a
102 +large discount on an illiquid or poorly identified asset cannot score high. Discounts are recomputed
103 +from scratch on every valuation pass; a stale discount never survives a changed valuation. Period
104 +changes beyond ±500 % are treated as data artefacts and are not published as movers.
105 +
86 106 ## 5. Indices
87 107
88 108 Each subindex (RARE-TCG, RARE-WATCH, …) is a **chain-linked** index of daily RIV changes across its
@@ -91,9 +111,11 @@ twelve months (one variant per asset, the most traded). Each day the index moves
91 111 equal-weight average of constituents' log returns; composition can change daily without breaking
92 112 the level. Base = 1000 on 2024-01-01. An index publishes nothing until it has its minimum number of
93 113 constituents (10 for subindices, 25 for RARE), and every point stores its constituent count and
94 −coverage.
114 +coverage. Indices that have not met their minimum are listed as *in development* outside the primary
115 +ticker.
95 116
96 −**RARE**, the flagship index, is the transaction-weighted average of subindex daily returns.
117 +**RARE**, the flagship index, is the transaction-weighted average of subindex daily returns and
118 +publishes only once at least 25 constituents exist across the published subindices.
97 119
98 120 A **repeat-sales** estimator (pairs of sales of the same variant, monthly periods, least squares)
99 121 is provided for research views; it is less exposed to composition bias but requires many pairs.
deleted docs/PENDING-SCHEMA-account.md +0 −41
@@ -1,41 +0,0 @@
1 −# Pending schema changes — member accounts (agent F1)
2 −
3 −Applied locally with `drizzle-kit push` on the `rareindex_account` database; **no migration file was
4 −generated** (per instructions). Generate one migration after merging (`pnpm db:generate`).
5 −
6 −## Modified tables (`packages/database/src/schema/users.ts`)
7 −
8 −- `users`: + `handle text unique`, `avatar_url text`, `bio text`, `mfa_enabled boolean not null default false`,
9 − `totp_secret_enc text` (AES-256-GCM, key derived from `SESSION_SECRET`), `always_ask_code boolean not null default true`,
10 − `password_changed_at timestamptz`, `pending_email text`, `deleted_at timestamptz`, `purge_after timestamptz`.
11 −- `sessions`: + `ip text`, `last_seen_at timestamptz`, `revoked_at timestamptz`.
12 −- `collections`: + `kind text not null default 'collection'` (collection | wishlist | vault | sold), `budget_usd numeric(18,4)`, `color text`.
13 −- `collection_items`: + `tags jsonb not null default '[]'`, `condition text`, `manual_value_usd numeric(18,4)`, `sold_at date`, `sold_price_usd numeric(18,4)`.
14 −- `watchlist_items`: + `label text`, `note text`, `target_price_usd numeric(18,4)`, `baseline_usd numeric(18,4)`.
15 −- `alerts`: + `name text`, `cooldown_minutes integer not null default 1440`, `trigger_count integer not null default 0`; `channel` now accepts `both`.
16 −
17 −## New tables (`packages/database/src/schema/account.ts`, exported from `schema/index.ts`)
18 −
19 −| table | purpose |
20 −|---|---|
21 −| `auth_codes` | hashed one-time codes: verify_email, mfa_email, password_reset, change_email, new_device (expiry, attempts, consumed_at, payload) |
22 −| `recovery_codes` | 10 single-use MFA recovery codes per user (hashed) |
23 −| `trusted_devices` | 30-day second-factor skip per browser (hashed token, label, ip, expiry, revoked_at) |
24 −| `login_events` | sign-in history (outcome, method, ip, user agent) |
25 −| `rate_limits` | fixed-window counters keyed by string (works across PM2 instances) |
26 −| `notifications` | in-app inbox (kind, title, body, href, payload, read_at, emailed_at) |
27 −| `saved_searches` | named search URLs, notify flag, last run/count |
28 −| `price_targets` | per-user buy/sell RIV targets with baseline, hit_at, notified_at |
29 −| `uploads` | member files (avatars, item photos) stored under `RI_DATA_DIR/uploads/<userId>/` |
30 −| `user_badges` | data-derived badges with evidence (recomputed daily) |
31 −
32 −## Notes for the merge
33 −
34 −- `apps/web/src/lib/db.ts` now re-exports tables explicitly from the `schema` namespace (Turbopack could
35 − not enumerate `export *` through the packages' `.js`-suffixed re-exports) and exports `Database` type.
36 −- `apps/web/next.config.ts`: `@rareindex/notify` added to `transpilePackages`; root `.env` loaded via
37 − `process.loadEnvFile`; `webpack.resolve.extensionAlias` so `next dev/build --webpack` resolves
38 − `./x.js` → `x.ts` inside workspace packages. **Turbopack cannot resolve those imports** — build with
39 − `next build --webpack` or drop the `.js` suffixes in packages (tsconfig `moduleResolution: bundler`).
40 −- `packages/notify/src/index.ts` uses explicit named exports for the same reason.
41 −- `workers/package.json`: + `@rareindex/database`, `@rareindex/notify`, `drizzle-orm`; `workers/tsconfig.json` added.
deleted docs/PENDING-SCHEMA-ai.md +0 −14
@@ -1,14 +0,0 @@
1 −# Pending schema changes — agent F2 (ai / scanner / research)
2 −
3 −New file `packages/database/src/schema/ai.ts`, exported from `schema/index.ts`. Applied locally with
4 −`drizzle-kit push` on `rareindex_ai`; **no migration generated** (to be folded into the next
5 −`pnpm db:generate` at integration).
6 −
7 −| Table | Purpose |
8 −|---|---|
9 −| `scanner_sessions` | Scanner submissions (photo/url/text), model guess + confidence, candidates shown, user pick (feedback loop), thumbnails, cost |
10 −| `research_sessions` | AI Research threads keyed by anonymous cookie (`anon_id`) or `user_id` |
11 −| `research_messages` | Messages with transparent tool-call trace, usage and cost |
12 −| `ai_quotas` | Per-day counters for anonymous AI features (key = ip hash or user id) |
13 −
14 −No changes to existing tables. AI spend is written to the existing `costs` table (`kind='ai'`).
deleted docs/PENDING-SCHEMA-images.md +0 −18
@@ -1,18 +0,0 @@
1 −# Pending schema changes — images pipeline (agent R, 2026-09-07)
2 −
3 −Applied locally with `drizzle-kit push`; the parent generates the migration.
4 −
5 −Table `images` — new columns and indexes:
6 −
7 −| column | type | note |
8 −|---|---|---|
9 −| `status` | text not null default `'unchecked'` | `unchecked` · `ok` · `dead` (404/410/not an image) · `blocked` (400/401/403/429) · `error` (network/timeout, retried after 6 h) |
10 −| `checked_at` | timestamptz | last validation |
11 −| `bytes` | integer | original size |
12 −| `content_type` | text | original MIME |
13 −| `cache_key` | text | `sha1(url)`; key of `RI_DATA_DIR/images/{orig,w96,w192,w384,w768,w1200}/<k[0:2]>/<k>.*` |
14 −| `error` | text | last failure reason (≤200 chars) |
15 −
16 −Indexes: `images_cache_key_idx (cache_key)`, `images_status_idx (status, checked_at)`.
17 −
18 −No other tables touched. `workers/lib/queue.ts` gained the job name `images.process`.
deleted docs/PENDING-SCHEMA.md +0 −11
@@ -1,11 +0,0 @@
1 −# Pending schema changes (to be folded into the next generated migration)
2 −
3 −Applied locally with `drizzle-kit push`; regenerate one migration at integration with `pnpm db:generate`.
4 −
5 −## packages/database/src/schema/ingestion.ts — `normalized_records`
6 −- **added** `seq integer not null default 0` — position of the record within `normalize(raw)` output (one raw row commonly yields a catalog item + several price observations).
7 −- **changed** unique index `normalized_records_raw_uq`: `(raw_record_id)` → `(raw_record_id, kind, seq)`.
8 −
9 −## Notes
10 −- No other table/column changes were needed by the pipeline; the `assets_title_trgm` GIN index declared in the schema was missing from the local DB and got created by the push (it is already in migration 0000).
11 −- pg-boss creates and manages its own schema `pgboss` at worker start (not part of Drizzle migrations).
added packages/valuation/src/depth.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { median, round } from '@rareindex/shared';
2 +
3 +/**
4 + * Market depth (§25) and RareIndex spread (§26): how the current asks of ONE variant sit around its
5 + * valuation. Pure arithmetic on observed asking prices — asks are not transactions and never feed RIV.
6 + */
7 +export interface MarketDepth {
8 + asks: number;
9 + /** asks within ±5 % / ±10 % / ±20 % of RIV (cumulative bands) */
10 + within5: number;
11 + within10: number;
12 + within20: number;
13 + belowRiv: number;
14 + aboveRiv: number;
15 + lowestAsk: number | null;
16 + medianAsk: number | null;
17 + /** (best ask − RIV) / RIV — negative = the cheapest ask is below the valuation; null without asks */
18 + askRivSpread: number | null;
19 +}
20 +
21 +export function marketDepth(asks: Array<number | null | undefined>, riv: number | null | undefined): MarketDepth | null {
22 + const a = asks.filter((x): x is number => typeof x === 'number' && Number.isFinite(x) && x > 0);
23 + if (riv === null || riv === undefined || !(riv > 0)) return null;
24 + const rel = a.map((x) => (x - riv) / riv);
25 + const within = (b: number) => rel.filter((r) => Math.abs(r) <= b).length;
26 + const lowest = a.length ? Math.min(...a) : null;
27 + return {
28 + asks: a.length,
29 + within5: within(0.05),
30 + within10: within(0.1),
31 + within20: within(0.2),
32 + belowRiv: rel.filter((r) => r < 0).length,
33 + aboveRiv: rel.filter((r) => r > 0).length,
34 + lowestAsk: lowest,
35 + medianAsk: median(a),
36 + askRivSpread: lowest === null ? null : round((lowest - riv) / riv, 4),
37 + };
38 +}
modified packages/valuation/src/index.ts +3 −0
@@ -2,3 +2,6 @@ export * from './valuation.js';
2 2 export * from './outliers.js';
3 3 export * from './premiums.js';
4 4 export * from './scores.js';
5 +export * from './depth.js';
6 +export * from './liquidation.js';
7 +export * from './verification.js';
added packages/valuation/src/liquidation.ts +113 −0
@@ -0,0 +1,113 @@
1 +import { mean, median, quantile, round } from '@rareindex/shared';
2 +
3 +/**
4 + * Days on market and time-to-liquidate (§24, §30, §213–§214).
5 + *
6 + * Inputs are observed listing lifecycles: first seen → last seen, with the outcome the connector
7 + * reported. Only `sold` is a sale outcome; `ended` / `removed` are censored (withdrawn or expired) and
8 + * are reported separately, never counted as time-to-sale. Everything here is a MODEL ESTIMATE from
9 + * observed listings — it is labelled as such by the callers and is not advice.
10 + */
11 +export type LifecycleOutcome = 'sold' | 'ended' | 'removed';
12 +
13 +export interface ListingLifecycle {
14 + firstSeen: Date;
15 + lastSeen: Date;
16 + outcome: LifecycleOutcome;
17 + /** ask / RIV at the time (1 = at valuation); undefined when no valuation was available */
18 + askToRiv?: number | null;
19 +}
20 +
21 +export type AskBand = 'le90' | 'b90_100' | 'b100_110' | 'gt110';
22 +export const ASK_BANDS: ReadonlyArray<{ key: AskBand; label: string; lo: number; hi: number; /** representative ask/RIV ratio used to derive a price */ ratio: number }> = [
23 + { key: 'le90', label: '≤ 90 % of RIV', lo: 0, hi: 0.9, ratio: 0.9 },
24 + { key: 'b90_100', label: '90 – 100 %', lo: 0.9, hi: 1.0, ratio: 1.0 },
25 + { key: 'b100_110', label: '100 – 110 %', lo: 1.0, hi: 1.1, ratio: 1.1 },
26 + { key: 'gt110', label: '> 110 %', lo: 1.1, hi: Infinity, ratio: 1.1 },
27 +];
28 +export const MIN_BAND_OBSERVATIONS = 5;
29 +
30 +export interface DaysOnMarket {
31 + n: number;
32 + median: number | null;
33 + mean: number | null;
34 + p25: number | null;
35 + p75: number | null;
36 +}
37 +
38 +export interface BandEstimate {
39 + band: AskBand;
40 + label: string;
41 + /** sold lifecycles in this band */
42 + n: number;
43 + /** median days to sale; null when n < MIN_BAND_OBSERVATIONS ("Not enough data") */
44 + medianDays: number | null;
45 +}
46 +
47 +export interface LiquidationModel {
48 + sold: DaysOnMarket;
49 + /** censored lifecycles (ended / removed without a sale) */
50 + withdrawn: DaysOnMarket;
51 + bands: BandEstimate[];
52 + /** share of lifecycles that ended in a sale, null without evidence */
53 + sellThrough: number | null;
54 +}
55 +
56 +const DAY = 86_400_000;
57 +const days = (l: ListingLifecycle) => Math.max(0, (l.lastSeen.getTime() - l.firstSeen.getTime()) / DAY);
58 +
59 +function summary(ds: number[]): DaysOnMarket {
60 + const r = (v: number | null) => (v === null ? null : round(v, 1));
61 + return { n: ds.length, median: r(median(ds)), mean: r(mean(ds)), p25: r(quantile(ds, 0.25)), p75: r(quantile(ds, 0.75)) };
62 +}
63 +
64 +export function bandFor(askToRiv: number): AskBand {
65 + return (ASK_BANDS.find((b) => askToRiv > b.lo && askToRiv <= b.hi) ?? ASK_BANDS[ASK_BANDS.length - 1]!).key;
66 +}
67 +
68 +export function liquidationModel(lifecycles: ListingLifecycle[]): LiquidationModel {
69 + const valid = lifecycles.filter((l) => l.lastSeen.getTime() >= l.firstSeen.getTime());
70 + const sold = valid.filter((l) => l.outcome === 'sold');
71 + const withdrawn = valid.filter((l) => l.outcome !== 'sold');
72 + const bands: BandEstimate[] = ASK_BANDS.map((b) => {
73 + const ds = sold.filter((l) => l.askToRiv !== null && l.askToRiv !== undefined && l.askToRiv > 0 && bandFor(l.askToRiv) === b.key).map(days);
74 + return { band: b.key, label: b.label, n: ds.length, medianDays: ds.length >= MIN_BAND_OBSERVATIONS ? round(median(ds)!, 1) : null };
75 + });
76 + return {
77 + sold: summary(sold.map(days)),
78 + withdrawn: summary(withdrawn.map(days)),
79 + bands,
80 + sellThrough: valid.length ? round(sold.length / valid.length, 4) : null,
81 + };
82 +}
83 +
84 +export interface FairPrices {
85 + /** aggressive buy = RIV low band; fair buy = RIV */
86 + buyAggressive: number | null;
87 + buyFair: number | null;
88 + /** fast sale: max(low, price of the ask band with the shortest observed median days); typical = RIV; patient = high */
89 + sellFast: number | null;
90 + sellFastDays: number | null;
91 + sellTypical: number | null;
92 + sellTypicalDays: number | null;
93 + sellPatient: number | null;
94 + sellPatientDays: number | null;
95 +}
96 +
97 +/**
98 + * Fair buy / sell prices (§213–§214) derived ONLY from the valuation band and the observed time-to-sale
99 + * by ask band. Any component without evidence is null.
100 + */
101 +export function fairPrices(riv: { riv: number | null; low: number | null; high: number | null }, model: LiquidationModel | null): FairPrices {
102 + const out: FairPrices = { buyAggressive: riv.low ?? null, buyFair: riv.riv ?? null, sellFast: null, sellFastDays: null, sellTypical: riv.riv ?? null, sellTypicalDays: null, sellPatient: riv.high ?? null, sellPatientDays: null };
103 + if (!riv.riv || !model) return out;
104 + const known = model.bands.filter((b) => b.medianDays !== null);
105 + if (!known.length) return out;
106 + const fastest = [...known].sort((a, b) => a.medianDays! - b.medianDays!)[0]!;
107 + const def = ASK_BANDS.find((b) => b.key === fastest.band)!;
108 + out.sellFast = round(Math.max(riv.low ?? 0, riv.riv * def.ratio), 2);
109 + out.sellFastDays = fastest.medianDays;
110 + out.sellTypicalDays = model.bands.find((b) => b.band === 'b90_100')?.medianDays ?? model.bands.find((b) => b.band === 'b100_110')?.medianDays ?? null;
111 + out.sellPatientDays = model.bands.find((b) => b.band === 'gt110')?.medianDays ?? null;
112 + return out;
113 +}
added packages/valuation/src/microstructure.test.ts +89 −0
@@ -0,0 +1,89 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { marketDepth } from './depth.js';
3 +import { bandFor, fairPrices, liquidationModel, type ListingLifecycle } from './liquidation.js';
4 +import { classifySale, countVerification } from './verification.js';
5 +
6 +describe('marketDepth (§25–§26)', () => {
7 + it('counts asks around RIV and derives the spread from the best ask', () => {
8 + const d = marketDepth([950, 1000, 1040, 1150, 1300, 700], 1000)!;
9 + expect(d.asks).toBe(6);
10 + expect(d.within5).toBe(3); // 950, 1000, 1040
11 + expect(d.within10).toBe(3);
12 + expect(d.within20).toBe(4); // + 1150
13 + expect(d.belowRiv).toBe(2);
14 + expect(d.aboveRiv).toBe(3);
15 + expect(d.lowestAsk).toBe(700);
16 + expect(d.medianAsk).toBe(1020);
17 + expect(d.askRivSpread).toBeCloseTo(-0.3, 4);
18 + });
19 + it('returns null without a valuation and no spread without asks', () => {
20 + expect(marketDepth([100], null)).toBeNull();
21 + expect(marketDepth([], 100)).toMatchObject({ asks: 0, askRivSpread: null, lowestAsk: null, medianAsk: null });
22 + expect(marketDepth([null, -5, 0], 100)!.asks).toBe(0);
23 + });
24 +});
25 +
26 +const lc = (days: number, outcome: ListingLifecycle['outcome'], askToRiv?: number): ListingLifecycle => ({ firstSeen: new Date('2026-01-01T00:00:00Z'), lastSeen: new Date(Date.UTC(2026, 0, 1 + days)), outcome, askToRiv });
27 +
28 +describe('liquidationModel (§24, §30)', () => {
29 + it('separates sold from withdrawn and needs 5 observations per band', () => {
30 + const rows = [...Array(6)].map((_, i) => lc(3 + i, 'sold', 0.85)).concat([...Array(6)].map((_, i) => lc(20 + i, 'sold', 1.05)), [lc(9, 'sold', 0.95), lc(11, 'sold', 0.97)], [lc(40, 'ended', 1.2), lc(35, 'removed', 1.3)]);
31 + const m = liquidationModel(rows);
32 + expect(m.sold.n).toBe(14);
33 + expect(m.withdrawn.n).toBe(2);
34 + expect(m.withdrawn.median).toBe(37.5);
35 + expect(m.sellThrough).toBeCloseTo(14 / 16, 3);
36 + const byBand = Object.fromEntries(m.bands.map((b) => [b.band, b]));
37 + expect(byBand.le90!.medianDays).toBe(5.5);
38 + expect(byBand.b100_110!.medianDays).toBe(22.5);
39 + expect(byBand.b90_100!.n).toBe(2);
40 + expect(byBand.b90_100!.medianDays).toBeNull(); // not enough data
41 + expect(byBand.gt110!.medianDays).toBeNull(); // withdrawn never count as sales
42 + });
43 + it('bands by ask / RIV ratio', () => {
44 + expect(bandFor(0.5)).toBe('le90');
45 + expect(bandFor(0.9)).toBe('le90');
46 + expect(bandFor(0.95)).toBe('b90_100');
47 + expect(bandFor(1.0)).toBe('b90_100');
48 + expect(bandFor(1.1)).toBe('b100_110');
49 + expect(bandFor(2)).toBe('gt110');
50 + });
51 +});
52 +
53 +describe('fairPrices (§213–§214)', () => {
54 + it('uses only the valuation band and observed time-to-sale', () => {
55 + const m = liquidationModel([...Array(5)].map((_, i) => lc(4 + i, 'sold', 0.88)).concat([...Array(5)].map((_, i) => lc(15 + i, 'sold', 0.98))));
56 + const f = fairPrices({ riv: 1000, low: 880, high: 1120 }, m);
57 + expect(f.buyAggressive).toBe(880);
58 + expect(f.buyFair).toBe(1000);
59 + expect(f.sellFast).toBe(900); // max(low 880, 0.9 × RIV)
60 + expect(f.sellFastDays).toBe(6);
61 + expect(f.sellTypical).toBe(1000);
62 + expect(f.sellTypicalDays).toBe(17);
63 + expect(f.sellPatient).toBe(1120);
64 + expect(f.sellPatientDays).toBeNull();
65 + });
66 + it('leaves time components null without band evidence', () => {
67 + const f = fairPrices({ riv: 1000, low: 900, high: 1100 }, liquidationModel([lc(3, 'sold', 0.9)]));
68 + expect(f.sellFast).toBeNull();
69 + expect(f.sellFastDays).toBeNull();
70 + expect(f.sellTypical).toBe(1000);
71 + expect(fairPrices({ riv: null, low: null, high: null }, null)).toMatchObject({ buyFair: null, sellPatient: null });
72 + });
73 +});
74 +
75 +describe('classifySale (§39)', () => {
76 + it('labels by status, confidence, source and sale type', () => {
77 + expect(classifySale({ status: 'excluded', confidence: 0.9, flags: ['bundle'] }).label).toBe('excluded');
78 + expect(classifySale({ status: 'flagged', confidence: 0.9, flags: ['abnormally_high_price'] })).toMatchObject({ label: 'unverified' });
79 + expect(classifySale({ status: 'valid', confidence: 0.5, trust: 0.9 }).label).toBe('unverified');
80 + expect(classifySale({ status: 'valid', confidence: 0.9, trust: 0.3 }).label).toBe('unverified');
81 + expect(classifySale({ status: 'valid', confidence: 0.7, sourceType: 'auction_house', trust: 0.7 }).label).toBe('verified');
82 + expect(classifySale({ status: 'valid', confidence: 0.85, saleType: 'auction', sourceType: 'marketplace', trust: 0.6 }).label).toBe('verified');
83 + expect(classifySale({ status: 'valid', confidence: 0.9, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.85 }).label).toBe('verified');
84 + expect(classifySale({ status: 'valid', confidence: 0.8, sourceType: 'marketplace', saleType: 'fixed_price', trust: 0.6 }).label).toBe('likely');
85 + });
86 + it('counts labels', () => {
87 + expect(countVerification(['verified', 'likely', 'likely', 'excluded'])).toEqual({ verified: 1, likely: 2, unverified: 0, excluded: 1 });
88 + });
89 +});
added packages/valuation/src/verification.ts +40 −0
@@ -0,0 +1,40 @@
1 +/**
2 + * Sales verification labels (§39, §205). A HEURISTIC over fields RareIndex already stores — it never
3 + * claims a transaction was independently confirmed. Labels:
4 + * verified — an auction-house / grading-company result, an auction with a confident match, or a
5 + * high-trust source with a very confident match
6 + * likely — a valid, confidently matched completed listing from an ordinary source
7 + * unverified — flagged by outlier detection, weak identification, or a low-trust source
8 + * excluded — bundles, zero prices and other records the pipeline excluded from valuation
9 + */
10 +export type SaleVerification = 'verified' | 'likely' | 'unverified' | 'excluded';
11 +
12 +export interface SaleVerificationInput {
13 + status: string | null | undefined; // valid | flagged | excluded
14 + confidence: number | null | undefined; // 0–1 identification confidence
15 + flags?: string[] | null;
16 + sourceType?: string | null; // marketplace | auction_house | grading_company | dealer | …
17 + saleType?: string | null; // auction | fixed_price | best_offer | private | dealer | unknown
18 + trust?: number | null; // 0–1 source trust score
19 +}
20 +
21 +export const VERIFIED_SOURCE_TYPES = new Set(['auction_house', 'grading_company']);
22 +
23 +export function classifySale(i: SaleVerificationInput): { label: SaleVerification; reason: string } {
24 + const conf = i.confidence ?? 0;
25 + const trust = i.trust ?? 0.5;
26 + if (i.status === 'excluded') return { label: 'excluded', reason: (i.flags ?? []).join(', ') || 'excluded by the pipeline' };
27 + if (i.status === 'flagged') return { label: 'unverified', reason: `flagged: ${(i.flags ?? []).join(', ') || 'outlier'}` };
28 + if (conf < 0.6) return { label: 'unverified', reason: 'weak identification' };
29 + if (trust < 0.5) return { label: 'unverified', reason: 'low-trust source' };
30 + if (i.sourceType && VERIFIED_SOURCE_TYPES.has(i.sourceType)) return { label: 'verified', reason: `${i.sourceType.replace('_', ' ')} result` };
31 + if (i.saleType === 'auction' && conf >= 0.8) return { label: 'verified', reason: 'auction result, confident match' };
32 + if (trust >= 0.8 && conf >= 0.85) return { label: 'verified', reason: 'high-trust source, confident match' };
33 + return { label: 'likely', reason: 'completed listing, confident match' };
34 +}
35 +
36 +export function countVerification(labels: SaleVerification[]): Record<SaleVerification, number> {
37 + const out: Record<SaleVerification, number> = { verified: 0, likely: 0, unverified: 0, excluded: 0 };
38 + for (const l of labels) out[l]++;
39 + return out;
40 +}
modified pnpm-lock.yaml +3 −0
@@ -47,6 +47,9 @@ importers:
47 47 '@rareindex/taxonomy':
48 48 specifier: workspace:*
49 49 version: link:../../packages/taxonomy
50 + '@rareindex/valuation':
51 + specifier: workspace:*
52 + version: link:../../packages/valuation
50 53 fastify:
51 54 specifier: ^5.6.0
52 55 version: 5.12.3
modified workers/valuation/run.ts +9 −4
@@ -180,6 +180,11 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis
180 180 const riv = rep?.out.riv ?? null;
181 181 const activeAll = await activeListingStats(assetId, null);
182 182 const repActive = rep?.activeL ?? null;
183 + // ATH / ATL / drawdown belong to the same series as the headline RIV: the representative variant.
184 + // (An asset-wide ATH mixed a sealed copy's record with a loose copy's valuation → −99 % "drawdowns".)
185 + const extremes = rep
186 + ? (await db().select({ n: sql<number>`count(*)::int`, min: sql<number>`min(${sales.priceUsd})`, max: sql<number>`max(${sales.priceUsd})`, minAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} asc))[1]`, maxAt: sql<Date>`(array_agg(${sales.saleDate} order by ${sales.priceUsd} desc))[1]` }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.variantId, rep.variantId), eq(sales.status, 'valid'), sql`${sales.quantity} = 1`, sql`not ${sales.isBundle}`)))[0]!
187 + : allSalesCount;
183 188 const salesCount = allSalesCount.n;
184 189 const sales30d = validSales.filter((s) => now.getTime() - s.saleDate.getTime() <= 30 * DAY).length;
185 190 const sales1y = validSales.filter((s) => now.getTime() - s.saleDate.getTime() <= 365 * DAY).length;
@@ -216,10 +221,10 @@ export async function valueAsset(assetId: string, opts: { now?: Date; rebuildHis
216 221 change30d: ch.d30,
217 222 change90d: ch.d90,
218 223 change1y: ch.y1,
219 − athUsd: salesCount ? Number(allSalesCount.max) : null,
220 − athAt: salesCount ? asDate(allSalesCount.maxAt) : null,
221 − atlUsd: salesCount ? Number(allSalesCount.min) : null,
222 − atlAt: salesCount ? asDate(allSalesCount.minAt) : null,
224 + athUsd: extremes.n ? Number(extremes.max) : null,
225 + athAt: extremes.n ? asDate(extremes.maxAt) : null,
226 + atlUsd: extremes.n ? Number(extremes.min) : null,
227 + atlAt: extremes.n ? asDate(extremes.minAt) : null,
223 228 salesCount,
224 229 sales30d,
225 230 sales1y,
226 231