SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
14.7 KB · 268 lines tsx
Raw Blame History
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