/** * Market Screener — pure helpers shared by the page, the export route and tests (§157–§160). * No server-only imports here; the SQL lives in `lib/queries/screener.ts`. * * Conventions (same as the rest of RareIndex, §83–§84, §196): * - changes are fractions ((v_t / v_0) − 1); anything beyond ±500 % is an artefact and is held back; * - spread = (min ask − RIV) / RIV, negative = below the valuation, only for gated valuations; * - drawdown = RIV / all-time-high sale − 1 (≤ 0), only when an ATH exists. */ import { sp1, spEnum, spInt, spNum, type SP } from '@/lib/search-params'; export const SCREENER_SORTS = ['riv', 'confidence', 'change30d', 'change1y', 'liquidity', 'rarity', 'sales30d', 'sales1y', 'listings', 'spread', 'drawdown', 'volume30d', 'opportunity', 'name'] as const; export type ScreenerSort = (typeof SCREENER_SORTS)[number]; export type SortDir = 'asc' | 'desc'; export const SCREENER_PAGE_SIZE = 50; export const SCREENER_MAX_PAGE_SIZE = 100; export const SCREENER_EXPORT_MAX = 5000; /** Plausibility bound for displayed valuation moves (fraction). */ export const MAX_PLAUSIBLE_CHANGE = 5; export interface ScreenerFilters { category: string | null; grader: string | null; grade: string | null; rivMin: number | null; rivMax: number | null; /** 0.5 = medium+, 0.75 = high */ confidenceMin: number | null; liquidityMin: number | null; rarityMin: number | null; change30dMin: number | null; change30dMax: number | null; change1yMin: number | null; change1yMax: number | null; sales30dMin: number | null; sales1yMin: number | null; listingsMin: number | null; /** drawdown from ATH, fraction ≤ 0; "at most this deep" e.g. −0.05 for near ATH */ drawdownMin: number | null; /** "at least this deep" e.g. −0.3 for largest drawdowns */ drawdownMax: number | null; /** spread = (min ask − RIV)/RIV; e.g. spreadMax −0.1 = asks at least 10 % below RIV */ spreadMin: number | null; spreadMax: number | null; /** sales_30d ≥ k × (sales_1y / 12) */ volumeAccelMin: number | null; yearFrom: number | null; yearTo: number | null; brand: string | null; set: string | null; q: string | null; sort: ScreenerSort; dir: SortDir; page: number; pageSize: number; } /** URL keys read by the screener (kept short; they are what users share). */ 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']; export const DEFAULT_SORT: Record = { riv: 'desc', confidence: 'desc', change30d: 'desc', change1y: 'desc', liquidity: 'desc', rarity: 'desc', sales30d: 'desc', sales1y: 'desc', listings: 'desc', spread: 'asc', // most below RIV first drawdown: 'asc', // deepest first volume30d: 'desc', opportunity: 'asc', name: 'asc', }; /** Percent inputs in the URL (e.g. `c30min=10`) are turned into fractions. */ const pctParam = (sp: SP, key: string): number | null => { const v = spNum(sp, key); return v === null ? null : v / 100; }; export function parseScreenerParams(sp: SP): ScreenerFilters { const preset = sp1(sp, 'preset'); const presetFilters = preset ? (PRESETS.find((p) => p.id === preset)?.filters ?? {}) : {}; const sort = spEnum(sp, 'sort', SCREENER_SORTS, presetFilters.sort ?? 'riv'); const dir = spEnum(sp, 'dir', ['asc', 'desc'] as const, presetFilters.sort === sort && presetFilters.dir ? presetFilters.dir : DEFAULT_SORT[sort]); const base: ScreenerFilters = { category: sp1(sp, 'category') ?? null, grader: sp1(sp, 'grader') ?? null, grade: sp1(sp, 'grade') ?? null, rivMin: spNum(sp, 'min'), rivMax: spNum(sp, 'max'), confidenceMin: spNum(sp, 'conf'), liquidityMin: spNum(sp, 'liq'), rarityMin: spNum(sp, 'rar'), change30dMin: pctParam(sp, 'c30min'), change30dMax: pctParam(sp, 'c30max'), change1yMin: pctParam(sp, 'c1ymin'), change1yMax: pctParam(sp, 'c1ymax'), sales30dMin: spNum(sp, 's30'), sales1yMin: spNum(sp, 's1y'), listingsMin: spNum(sp, 'lst'), drawdownMin: pctParam(sp, 'ddmin'), drawdownMax: pctParam(sp, 'ddmax'), spreadMin: pctParam(sp, 'spmin'), spreadMax: pctParam(sp, 'spmax'), volumeAccelMin: spNum(sp, 'vacc'), yearFrom: spNum(sp, 'from'), yearTo: spNum(sp, 'to'), brand: sp1(sp, 'brand') ?? null, set: sp1(sp, 'set') ?? null, q: sp1(sp, 'q') ?? null, sort, dir, page: spInt(sp, 'page'), pageSize: Math.min(SCREENER_MAX_PAGE_SIZE, Math.max(10, spInt(sp, 'size', SCREENER_PAGE_SIZE))), }; // Preset values apply only where the user has not set an explicit value. const merged: ScreenerFilters = { ...base }; for (const [k, v] of Object.entries(presetFilters) as Array<[keyof ScreenerFilters, unknown]>) { if (k === 'sort' || k === 'dir') continue; if (merged[k] === null || merged[k] === undefined) (merged as unknown as Record)[k] = v; } if (merged.confidenceMin !== null && merged.confidenceMin > 1) merged.confidenceMin = merged.confidenceMin / 100; // "75" → 0.75 return merged; } export interface ScreenerPreset { id: string; label: string; description: string; filters: Partial; } /** * §159 presets. Each is a plain set of filters + sort so it stays shareable and explainable. * "Supply Shrinking" is intentionally absent: asset_stats holds only the current listing count, and * price_snapshots.listings_count is written only on days an asset is revalued, so a 30-day listing * delta cannot be computed honestly for the whole universe yet. It will be added with listing-history * aggregates (§28, §137). */ export const PRESETS: ScreenerPreset[] = [ { id: 'liquid', label: 'Most Liquid', description: 'Highest Liquidity Score (sales frequency, depth, sources, spread).', filters: { sort: 'liquidity', dir: 'desc', sales1yMin: 3 } }, { id: 'traded', label: 'Most Traded', description: 'Most verified sales in the last 30 days.', filters: { sort: 'sales30d', dir: 'desc', sales30dMin: 1 } }, { 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 } }, { 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 } }, { 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 } }, { 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 } }, ]; export function isPlausibleChange(v: number | null | undefined): v is number { return v !== null && v !== undefined && Number.isFinite(v) && Math.abs(v) <= MAX_PLAUSIBLE_CHANGE; } /** RFC 4180-ish CSV cell escaping. */ export function csvCell(v: unknown): string { if (v === null || v === undefined) return ''; const s = v instanceof Date ? v.toISOString() : String(v); return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } export function toCsv(header: string[], rows: unknown[][], comments: string[] = []): string { const lines = comments.map((c) => `# ${c}`); lines.push(header.map(csvCell).join(',')); for (const r of rows) lines.push(r.map(csvCell).join(',')); return lines.join('\r\n') + '\r\n'; }