import { clamp, 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); } // ---------- Ask vs RIV: sign convention, gates and the anomaly guard (§83–§84, §174, §196) ---------- // // Everywhere in RareIndex `discount_to_riv` / `value_opportunity` = (ask − RIV) / RIV. // negative → the ask is BELOW the valuation (a discount) e.g. −0.167 = 16.7 % below RIV // positive → the ask is ABOVE the valuation (a premium) // A number is only produced when the comparison is legitimate: same variant, transaction-based RIV // with enough evidence, plausible ratio. Anything else is `null` + a verdict, never a "deal". /** An ask below 10 % or above 10× the valuation is almost always a mismatch (variant, lot, currency, typo). */ export const ASK_ANOMALY_LOW_RATIO = 0.1; export const ASK_ANOMALY_HIGH_RATIO = 10; /** Minimum valuation quality for an ask to be compared at all. */ export const ASK_MIN_CONFIDENCE = 0.5; export const ASK_MIN_SAMPLE = 5; /** Minimum identification confidence of the listing→asset match. */ export const ASK_MIN_MATCH_CONFIDENCE = 0.7; /** Threshold below/above which an ask is called a deal / a premium. */ export const DEAL_THRESHOLD = -0.1; export const PREMIUM_THRESHOLD = 0.1; /** An ask more than 50 % below RIV is almost always a mismatch (§174): kept as a number, surfaced as "needs review", never as a deal. */ export const DEAL_REVIEW_THRESHOLD = -0.5; export type AskVerdict = 'deal' | 'fair' | 'premium' | 'review' | 'anomaly' | 'ungated'; export interface AskAssessment { /** (ask − RIV) / RIV, 4 decimals; null when ungated or anomalous */ discount: number | null; /** raw ratio ask / RIV, kept for diagnostics even when anomalous */ ratio: number | null; verdict: AskVerdict; reasons: string[]; } export interface AskAssessmentInput { askUsd: number | null | undefined; rivUsd: number | null | undefined; confidence: number | null | undefined; sampleSize?: number | null; /** valuation basis; only transaction-based valuations may qualify an ask */ basis?: 'transactions' | 'comps' | 'guide' | 'none' | null; /** identification confidence of the listing (0–1) */ matchConfidence?: number | null; /** true when the listing's variant is the one the RIV was computed for */ sameVariant?: boolean; /** buyer premium / shipping / fees to add to the ask before comparing (USD) */ feesUsd?: number | null; } /** Classify an ask against a valuation. Never returns a discount that failed a gate. */ export function assessAsk(i: AskAssessmentInput): AskAssessment { const reasons: string[] = []; const ask = i.askUsd ?? null; const riv = i.rivUsd ?? null; if (ask === null || !(ask > 0)) reasons.push('no_ask'); if (riv === null || !(riv > 0)) reasons.push('no_riv'); if (reasons.length) return { discount: null, ratio: null, verdict: 'ungated', reasons }; const allIn = ask! + Math.max(0, i.feesUsd ?? 0); const ratio = allIn / riv!; if (i.sameVariant === false) reasons.push('variant_mismatch'); if (i.basis && i.basis !== 'transactions') reasons.push(`riv_basis_${i.basis}`); if ((i.confidence ?? 0) < ASK_MIN_CONFIDENCE) reasons.push('riv_confidence'); if (i.sampleSize !== undefined && i.sampleSize !== null && i.sampleSize < ASK_MIN_SAMPLE) reasons.push('riv_sample'); if (i.matchConfidence !== undefined && i.matchConfidence !== null && i.matchConfidence < ASK_MIN_MATCH_CONFIDENCE) reasons.push('match_confidence'); if (reasons.length) return { discount: null, ratio: round(ratio, 4), verdict: 'ungated', reasons }; if (ratio < ASK_ANOMALY_LOW_RATIO || ratio > ASK_ANOMALY_HIGH_RATIO) { return { discount: null, ratio: round(ratio, 4), verdict: 'anomaly', reasons: [ratio < 1 ? 'ask_implausibly_low' : 'ask_implausibly_high'] }; } const discount = round(ratio - 1, 4); if (discount < DEAL_REVIEW_THRESHOLD) return { discount, ratio: round(ratio, 4), verdict: 'review', reasons: ['discount_exceeds_review_threshold'] }; return { discount, ratio: round(ratio, 4), verdict: discount <= DEAL_THRESHOLD ? 'deal' : discount >= PREMIUM_THRESHOLD ? 'premium' : 'fair', reasons }; } /** * Value opportunity (§123): (ask − RIV) / RIV for a gated, plausible comparison; null otherwise. * negative = below fair value. Thin wrapper over assessAsk kept for existing callers. */ export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null, sampleSize?: number | null): number | null { const a = assessAsk({ askUsd, rivUsd: riv, confidence, sampleSize: sampleSize ?? undefined }); return a.verdict === 'anomaly' || a.verdict === 'ungated' || a.verdict === 'review' ? null : a.discount; } /** * Deal Score 0–100 (§83): how actionable a below-RIV ask is once valuation quality, match quality * and liquidity are considered. 0 for anything that is not a gated discount. Components are * multiplicative so one weak leg (e.g. an illiquid asset) cannot be compensated by a huge discount. */ export function dealScore(input: { discount: number | null; confidence: number | null; sampleSize: number | null; liquidity: number | null; matchConfidence?: number | null }): number { if (input.discount === null || input.discount >= 0 || input.discount < DEAL_REVIEW_THRESHOLD) return 0; const depth = clamp(-input.discount / 0.5, 0, 1) ** 0.7; // 50 % below → 1, concave so 10 % is already meaningful const conf = 0.4 + 0.6 * clamp(input.confidence ?? 0, 0, 1); const sample = 0.5 + 0.5 * clamp(Math.log10(1 + (input.sampleSize ?? 0)) / Math.log10(41), 0, 1); // 40 sales → 1 const liq = 0.5 + 0.5 * clamp((input.liquidity ?? 30) / 100, 0, 1); const match = clamp(input.matchConfidence ?? 0.8, 0, 1); return round(100 * depth * conf * sample * liq * match, 1); } /** * Plausibility gate for displayed percentage moves (§143, §196): anything beyond ±500 % on a * valuation change, or beyond the anomaly ratios on an ask, is a data/identity anomaly, not a signal. */ export const MAX_PLAUSIBLE_CHANGE = 5; export function isPlausibleChange(value: number | null | undefined): boolean { return value !== null && value !== undefined && Number.isFinite(value) && Math.abs(value) <= MAX_PLAUSIBLE_CHANGE; } /** A discount that may be shown as a deal (not an anomaly, not beyond the review threshold). */ export function isActionableDiscount(value: number | null | undefined): boolean { return value !== null && value !== undefined && Number.isFinite(value) && value >= DEAL_REVIEW_THRESHOLD && isPlausibleDiscount(value); } export function isPlausibleDiscount(value: number | null | undefined): boolean { if (value === null || value === undefined || !Number.isFinite(value)) return false; const ratio = 1 + value; return ratio >= ASK_ANOMALY_LOW_RATIO && ratio <= ASK_ANOMALY_HIGH_RATIO; } /** 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); }