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>
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 > 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'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 > 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 · 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'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'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'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 | |