import { clamp, pctChange, round } from '@rareindex/shared'; /** Liquidity Score 0–100 (§121). Null when there is no evidence at all. */ export function liquidityScore(input: { salesPerMonth: number; activeListings: number; sources: number; medianDaysBetweenSales: number | null; askSoldSpread: number | null }): number | null { if (input.salesPerMonth === 0 && input.activeListings === 0) return null; const s1 = clamp(Math.log10(1 + input.salesPerMonth) / Math.log10(1 + 30), 0, 1); // 30 sales/month → 1 const s2 = clamp(Math.log10(1 + input.activeListings) / Math.log10(1 + 50), 0, 1); const s3 = clamp((input.sources - 1) / 4, 0, 1); const s4 = input.medianDaysBetweenSales === null ? 0 : clamp(1 - input.medianDaysBetweenSales / 90, 0, 1); const s5 = input.askSoldSpread === null ? 0.5 : clamp(1 - Math.abs(input.askSoldSpread) / 0.5, 0, 1); return round(100 * (0.4 * s1 + 0.15 * s2 + 0.1 * s3 + 0.2 * s4 + 0.15 * s5), 1); } /** * Rarity Score 0–100 (§120). Requires at least one supply signal (population, production quantity * or observed listing/sale frequency over a meaningful horizon); otherwise null — never fabricated. */ export function rarityScore(input: { population: number | null; productionQuantity: number | null; listingsPerYear: number | null; salesPerYear: number | null; populationGrowthPct: number | null }): number | null { const parts: Array<{ v: number; w: number }> = []; if (input.population !== null && input.population >= 0) parts.push({ v: clamp(1 - Math.log10(1 + input.population) / 5, 0, 1), w: 0.4 }); // 100k pop → 0 if (input.productionQuantity !== null && input.productionQuantity > 0) parts.push({ v: clamp(1 - Math.log10(input.productionQuantity) / 7, 0, 1), w: 0.3 }); if (input.salesPerYear !== null) parts.push({ v: clamp(1 - Math.log10(1 + input.salesPerYear) / 3, 0, 1), w: 0.2 }); // 1000 sales/yr → 0 if (input.listingsPerYear !== null) parts.push({ v: clamp(1 - Math.log10(1 + input.listingsPerYear) / 3, 0, 1), w: 0.1 }); if (parts.length === 0) return null; const w = parts.reduce((a, p) => a + p.w, 0); let score = parts.reduce((a, p) => a + p.v * p.w, 0) / w; if (input.populationGrowthPct !== null && input.populationGrowthPct > 0.2) score *= 0.9; // rapidly expanding supply return round(100 * clamp(score, 0, 1), 1); } /** Momentum: blend of price change and volume acceleration for a horizon; -1..1 scaled to -100..100. */ export function momentumScore(input: { priceChange: number | null; volumeNow: number; volumePrev: number }): number | null { if (input.priceChange === null && input.volumeNow === 0 && input.volumePrev === 0) return null; const p = input.priceChange === null ? 0 : clamp(input.priceChange / 0.5, -1, 1); // ±50% → ±1 const v = input.volumePrev === 0 && input.volumeNow === 0 ? 0 : clamp((input.volumeNow - input.volumePrev) / Math.max(1, input.volumePrev + input.volumeNow), -1, 1); return round(100 * (0.7 * p + 0.3 * v), 1); } /** Value opportunity (§123): discount of an ask vs RIV; null when RIV confidence is insufficient. */ export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null): number | null { if (askUsd === null || riv === null || confidence === null || confidence < 0.5 || riv <= 0) return null; return round(pctChange(riv, askUsd) ?? 0, 4); // negative = below fair value } /** Trending (§152): geometric blend of normalised momenta (0..1 each) → 0–100. */ export function trendingScore(input: { priceMomentum: number | null; volumeMomentum: number | null; searchMomentum: number | null; listingMomentum: number | null; newsMomentum: number | null }): number | null { const vals = [input.priceMomentum, input.volumeMomentum, input.searchMomentum, input.listingMomentum, input.newsMomentum].filter((x): x is number => x !== null); if (vals.length < 2) return null; const norm = vals.map((v) => clamp((v + 100) / 200, 0.01, 1)); // -100..100 → 0..1 const geo = Math.exp(norm.reduce((a, x) => a + Math.log(x), 0) / norm.length); return round(100 * geo, 1); } /** Data quality for an asset record (§150). */ export function assetDataQuality(input: { fieldsPresent: number; fieldsTotal: number; sourceTrustAvg: number | null; identificationConfidence: number | null; hasImage: boolean; salesCount: number }): number { const completeness = input.fieldsTotal ? input.fieldsPresent / input.fieldsTotal : 0; const evidence = clamp(Math.log10(1 + input.salesCount) / 2, 0, 1); return round(100 * clamp(0.35 * completeness + 0.2 * (input.sourceTrustAvg ?? 0.5) + 0.2 * (input.identificationConfidence ?? 0.5) + 0.1 * (input.hasImage ? 1 : 0) + 0.15 * evidence, 0, 1), 1); }