TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { clamp, round } from '@rareindex/shared';23/** Liquidity Score 0–100 (§121). Null when there is no evidence at all. */4export function liquidityScore(input: { salesPerMonth: number; activeListings: number; sources: number; medianDaysBetweenSales: number | null; askSoldSpread: number | null }): number | null {5 if (input.salesPerMonth === 0 && input.activeListings === 0) return null;6 const s1 = clamp(Math.log10(1 + input.salesPerMonth) / Math.log10(1 + 30), 0, 1); // 30 sales/month → 17 const s2 = clamp(Math.log10(1 + input.activeListings) / Math.log10(1 + 50), 0, 1);8 const s3 = clamp((input.sources - 1) / 4, 0, 1);9 const s4 = input.medianDaysBetweenSales === null ? 0 : clamp(1 - input.medianDaysBetweenSales / 90, 0, 1);10 const s5 = input.askSoldSpread === null ? 0.5 : clamp(1 - Math.abs(input.askSoldSpread) / 0.5, 0, 1);11 return round(100 * (0.4 * s1 + 0.15 * s2 + 0.1 * s3 + 0.2 * s4 + 0.15 * s5), 1);12}1314/**15 * Rarity Score 0–100 (§120). Requires at least one supply signal (population, production quantity16 * or observed listing/sale frequency over a meaningful horizon); otherwise null — never fabricated.17 */18export function rarityScore(input: { population: number | null; productionQuantity: number | null; listingsPerYear: number | null; salesPerYear: number | null; populationGrowthPct: number | null }): number | null {19 const parts: Array<{ v: number; w: number }> = [];20 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 → 021 if (input.productionQuantity !== null && input.productionQuantity > 0) parts.push({ v: clamp(1 - Math.log10(input.productionQuantity) / 7, 0, 1), w: 0.3 });22 if (input.salesPerYear !== null) parts.push({ v: clamp(1 - Math.log10(1 + input.salesPerYear) / 3, 0, 1), w: 0.2 }); // 1000 sales/yr → 023 if (input.listingsPerYear !== null) parts.push({ v: clamp(1 - Math.log10(1 + input.listingsPerYear) / 3, 0, 1), w: 0.1 });24 if (parts.length === 0) return null;25 const w = parts.reduce((a, p) => a + p.w, 0);26 let score = parts.reduce((a, p) => a + p.v * p.w, 0) / w;27 if (input.populationGrowthPct !== null && input.populationGrowthPct > 0.2) score *= 0.9; // rapidly expanding supply28 return round(100 * clamp(score, 0, 1), 1);29}3031/** Momentum: blend of price change and volume acceleration for a horizon; -1..1 scaled to -100..100. */32export function momentumScore(input: { priceChange: number | null; volumeNow: number; volumePrev: number }): number | null {33 if (input.priceChange === null && input.volumeNow === 0 && input.volumePrev === 0) return null;34 const p = input.priceChange === null ? 0 : clamp(input.priceChange / 0.5, -1, 1); // ±50% → ±135 const v = input.volumePrev === 0 && input.volumeNow === 0 ? 0 : clamp((input.volumeNow - input.volumePrev) / Math.max(1, input.volumePrev + input.volumeNow), -1, 1);36 return round(100 * (0.7 * p + 0.3 * v), 1);37}3839// ---------- Ask vs RIV: sign convention, gates and the anomaly guard (§83–§84, §174, §196) ----------40//41// Everywhere in RareIndex `discount_to_riv` / `value_opportunity` = (ask − RIV) / RIV.42// negative → the ask is BELOW the valuation (a discount) e.g. −0.167 = 16.7 % below RIV43// positive → the ask is ABOVE the valuation (a premium)44// A number is only produced when the comparison is legitimate: same variant, transaction-based RIV45// with enough evidence, plausible ratio. Anything else is `null` + a verdict, never a "deal".4647/** An ask below 10 % or above 10× the valuation is almost always a mismatch (variant, lot, currency, typo). */48export const ASK_ANOMALY_LOW_RATIO = 0.1;49export const ASK_ANOMALY_HIGH_RATIO = 10;50/** Minimum valuation quality for an ask to be compared at all. */51export const ASK_MIN_CONFIDENCE = 0.5;52export const ASK_MIN_SAMPLE = 5;53/** Minimum identification confidence of the listing→asset match. */54export const ASK_MIN_MATCH_CONFIDENCE = 0.7;55/** Threshold below/above which an ask is called a deal / a premium. */56export const DEAL_THRESHOLD = -0.1;57export const PREMIUM_THRESHOLD = 0.1;58/** 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. */59export const DEAL_REVIEW_THRESHOLD = -0.5;6061export type AskVerdict = 'deal' | 'fair' | 'premium' | 'review' | 'anomaly' | 'ungated';6263export interface AskAssessment {64 /** (ask − RIV) / RIV, 4 decimals; null when ungated or anomalous */65 discount: number | null;66 /** raw ratio ask / RIV, kept for diagnostics even when anomalous */67 ratio: number | null;68 verdict: AskVerdict;69 reasons: string[];70}7172export interface AskAssessmentInput {73 askUsd: number | null | undefined;74 rivUsd: number | null | undefined;75 confidence: number | null | undefined;76 sampleSize?: number | null;77 /** valuation basis; only transaction-based valuations may qualify an ask */78 basis?: 'transactions' | 'comps' | 'guide' | 'none' | null;79 /** identification confidence of the listing (0–1) */80 matchConfidence?: number | null;81 /** true when the listing's variant is the one the RIV was computed for */82 sameVariant?: boolean;83 /** buyer premium / shipping / fees to add to the ask before comparing (USD) */84 feesUsd?: number | null;85}8687/** Classify an ask against a valuation. Never returns a discount that failed a gate. */88export function assessAsk(i: AskAssessmentInput): AskAssessment {89 const reasons: string[] = [];90 const ask = i.askUsd ?? null;91 const riv = i.rivUsd ?? null;92 if (ask === null || !(ask > 0)) reasons.push('no_ask');93 if (riv === null || !(riv > 0)) reasons.push('no_riv');94 if (reasons.length) return { discount: null, ratio: null, verdict: 'ungated', reasons };95 const allIn = ask! + Math.max(0, i.feesUsd ?? 0);96 const ratio = allIn / riv!;97 if (i.sameVariant === false) reasons.push('variant_mismatch');98 if (i.basis && i.basis !== 'transactions') reasons.push(`riv_basis_${i.basis}`);99 if ((i.confidence ?? 0) < ASK_MIN_CONFIDENCE) reasons.push('riv_confidence');100 if (i.sampleSize !== undefined && i.sampleSize !== null && i.sampleSize < ASK_MIN_SAMPLE) reasons.push('riv_sample');101 if (i.matchConfidence !== undefined && i.matchConfidence !== null && i.matchConfidence < ASK_MIN_MATCH_CONFIDENCE) reasons.push('match_confidence');102 if (reasons.length) return { discount: null, ratio: round(ratio, 4), verdict: 'ungated', reasons };103 if (ratio < ASK_ANOMALY_LOW_RATIO || ratio > ASK_ANOMALY_HIGH_RATIO) {104 return { discount: null, ratio: round(ratio, 4), verdict: 'anomaly', reasons: [ratio < 1 ? 'ask_implausibly_low' : 'ask_implausibly_high'] };105 }106 const discount = round(ratio - 1, 4);107 if (discount < DEAL_REVIEW_THRESHOLD) return { discount, ratio: round(ratio, 4), verdict: 'review', reasons: ['discount_exceeds_review_threshold'] };108 return { discount, ratio: round(ratio, 4), verdict: discount <= DEAL_THRESHOLD ? 'deal' : discount >= PREMIUM_THRESHOLD ? 'premium' : 'fair', reasons };109}110111/**112 * Value opportunity (§123): (ask − RIV) / RIV for a gated, plausible comparison; null otherwise.113 * negative = below fair value. Thin wrapper over assessAsk kept for existing callers.114 */115export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null, sampleSize?: number | null): number | null {116 const a = assessAsk({ askUsd, rivUsd: riv, confidence, sampleSize: sampleSize ?? undefined });117 return a.verdict === 'anomaly' || a.verdict === 'ungated' || a.verdict === 'review' ? null : a.discount;118}119120/**121 * Deal Score 0–100 (§83): how actionable a below-RIV ask is once valuation quality, match quality122 * and liquidity are considered. 0 for anything that is not a gated discount. Components are123 * multiplicative so one weak leg (e.g. an illiquid asset) cannot be compensated by a huge discount.124 */125export function dealScore(input: { discount: number | null; confidence: number | null; sampleSize: number | null; liquidity: number | null; matchConfidence?: number | null }): number {126 if (input.discount === null || input.discount >= 0 || input.discount < DEAL_REVIEW_THRESHOLD) return 0;127 const depth = clamp(-input.discount / 0.5, 0, 1) ** 0.7; // 50 % below → 1, concave so 10 % is already meaningful128 const conf = 0.4 + 0.6 * clamp(input.confidence ?? 0, 0, 1);129 const sample = 0.5 + 0.5 * clamp(Math.log10(1 + (input.sampleSize ?? 0)) / Math.log10(41), 0, 1); // 40 sales → 1130 const liq = 0.5 + 0.5 * clamp((input.liquidity ?? 30) / 100, 0, 1);131 const match = clamp(input.matchConfidence ?? 0.8, 0, 1);132 return round(100 * depth * conf * sample * liq * match, 1);133}134135/**136 * Plausibility gate for displayed percentage moves (§143, §196): anything beyond ±500 % on a137 * valuation change, or beyond the anomaly ratios on an ask, is a data/identity anomaly, not a signal.138 */139export const MAX_PLAUSIBLE_CHANGE = 5;140export function isPlausibleChange(value: number | null | undefined): boolean {141 return value !== null && value !== undefined && Number.isFinite(value) && Math.abs(value) <= MAX_PLAUSIBLE_CHANGE;142}143/** A discount that may be shown as a deal (not an anomaly, not beyond the review threshold). */144export function isActionableDiscount(value: number | null | undefined): boolean {145 return value !== null && value !== undefined && Number.isFinite(value) && value >= DEAL_REVIEW_THRESHOLD && isPlausibleDiscount(value);146}147export function isPlausibleDiscount(value: number | null | undefined): boolean {148 if (value === null || value === undefined || !Number.isFinite(value)) return false;149 const ratio = 1 + value;150 return ratio >= ASK_ANOMALY_LOW_RATIO && ratio <= ASK_ANOMALY_HIGH_RATIO;151}152153/** Trending (§152): geometric blend of normalised momenta (0..1 each) → 0–100. */154export function trendingScore(input: { priceMomentum: number | null; volumeMomentum: number | null; searchMomentum: number | null; listingMomentum: number | null; newsMomentum: number | null }): number | null {155 const vals = [input.priceMomentum, input.volumeMomentum, input.searchMomentum, input.listingMomentum, input.newsMomentum].filter((x): x is number => x !== null);156 if (vals.length < 2) return null;157 const norm = vals.map((v) => clamp((v + 100) / 200, 0.01, 1)); // -100..100 → 0..1158 const geo = Math.exp(norm.reduce((a, x) => a + Math.log(x), 0) / norm.length);159 return round(100 * geo, 1);160}161162/** Data quality for an asset record (§150). */163export function assetDataQuality(input: { fieldsPresent: number; fieldsTotal: number; sourceTrustAvg: number | null; identificationConfidence: number | null; hasImage: boolean; salesCount: number }): number {164 const completeness = input.fieldsTotal ? input.fieldsPresent / input.fieldsTotal : 0;165 const evidence = clamp(Math.log10(1 + input.salesCount) / 2, 0, 1);166 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);167}168