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%
7.7 KB · 172 lines typescript
Raw Blame History
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