TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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 */10import { sp1, spEnum, spInt, spNum, type SP } from '@/lib/search-params';1112export const SCREENER_SORTS = ['riv', 'confidence', 'change30d', 'change1y', 'liquidity', 'rarity', 'sales30d', 'sales1y', 'listings', 'spread', 'drawdown', 'volume30d', 'opportunity', 'name'] as const;13export type ScreenerSort = (typeof SCREENER_SORTS)[number];14export type SortDir = 'asc' | 'desc';1516export const SCREENER_PAGE_SIZE = 50;17export const SCREENER_MAX_PAGE_SIZE = 100;18export const SCREENER_EXPORT_MAX = 5000;1920/** Plausibility bound for displayed valuation moves (fraction). */21export const MAX_PLAUSIBLE_CHANGE = 5;2223export 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}5960/** URL keys read by the screener (kept short; they are what users share). */61export 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'];6263export 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 first74 drawdown: 'asc', // deepest first75 volume30d: 'desc',76 opportunity: 'asc',77 name: 'asc',78};7980/** Percent inputs in the URL (e.g. `c30min=10`) are turned into fractions. */81const pctParam = (sp: SP, key: string): number | null => {82 const v = spNum(sp, key);83 return v === null ? null : v / 100;84};8586export 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.75129 return merged;130}131132export interface ScreenerPreset {133 id: string;134 label: string;135 description: string;136 filters: Partial<ScreenerFilters>;137}138139/**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, and142 * price_snapshots.listings_count is written only on days an asset is revalued, so a 30-day listing143 * delta cannot be computed honestly for the whole universe yet. It will be added with listing-history144 * aggregates (§28, §137).145 */146export 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–50 % 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];154155export 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}158159/** RFC 4180-ish CSV cell escaping. */160export 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}165166export 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}172