TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { Suspense } from 'react';4import { PageHeader } from '@/components/ui/page-header';5import { FilterBar } from '@/components/ui/filter-bar';6import { Pagination } from '@/components/ui/pagination';7import { Delta, EmptyState, Skeleton, Table, VsRiv, th, td, tdNum } from '@/components/ui/primitives';8import { Thumb } from '@/components/ui/tile-image';9import { screenAssets, type ScreenerRow } from '@/lib/queries/screener';10import { DEFAULT_SORT, PRESETS, SCREENER_KEYS, isPlausibleChange, parseScreenerParams, type ScreenerSort, type SortDir } from '@/lib/screener';11import { catName } from '@/lib/taxonomy';12import { cn, confidenceLabel, fmtMoney, fmtNum, fmtPct } from '@/lib/format';13import { CATEGORIES, GRADERS } from '@rareindex/taxonomy';14import { pick, sp1, withParams, type SP } from '@/lib/search-params';1516export 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};2021const PCT_TITLE_HELD = 'Implausible move held back (beyond ±500 %): a data or identity artefact, not a market signal.';2223interface Col {24 id: ScreenerSort | 'asset';25 label: string;26 title: string;27 numeric?: boolean;28}29const 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];4243export 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 <PageHeader54 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 compact58 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 CSV62 </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 JSON65 </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 valued73 </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 <FilterBar83 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 Center114 </Link>{' '}115 and coverage in{' '}116 <Link href="/data" className="underline-offset-4 hover:text-fg hover:underline">117 Data118 </Link>119 .120 </p>121 </div>122 );123}124125function 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}149150function 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}155156function 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}168169async 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}201202function 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}268