SPB Git

spb/rareindex

Public
4.6 KB · 60 lines typescript
1import { clamp, pctChange, 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/** Value opportunity (§123): discount of an ask vs RIV; null when RIV confidence is insufficient. */40export function valueOpportunity(askUsd: number | null, riv: number | null, confidence: number | null): number | null {41  if (askUsd === null || riv === null || confidence === null || confidence < 0.5 || riv <= 0) return null;42  return round(pctChange(riv, askUsd) ?? 0, 4); // negative = below fair value43}4445/** Trending (§152): geometric blend of normalised momenta (0..1 each) → 0–100. */46export function trendingScore(input: { priceMomentum: number | null; volumeMomentum: number | null; searchMomentum: number | null; listingMomentum: number | null; newsMomentum: number | null }): number | null {47  const vals = [input.priceMomentum, input.volumeMomentum, input.searchMomentum, input.listingMomentum, input.newsMomentum].filter((x): x is number => x !== null);48  if (vals.length < 2) return null;49  const norm = vals.map((v) => clamp((v + 100) / 200, 0.01, 1)); // -100..100 → 0..150  const geo = Math.exp(norm.reduce((a, x) => a + Math.log(x), 0) / norm.length);51  return round(100 * geo, 1);52}5354/** Data quality for an asset record (§150). */55export function assetDataQuality(input: { fieldsPresent: number; fieldsTotal: number; sourceTrustAvg: number | null; identificationConfidence: number | null; hasImage: boolean; salesCount: number }): number {56  const completeness = input.fieldsTotal ? input.fieldsPresent / input.fieldsTotal : 0;57  const evidence = clamp(Math.log10(1 + input.salesCount) / 2, 0, 1);58  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);59}60
Files · 78cf9f7f3b34154bdac4f0af831574da262502f2
Menu

Theme