SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
11 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.1 KB · 180 lines typescript
Raw Blame History
1/**2 * Pure alert evaluation (no I/O). The runner loads the state an alert needs, calls `evaluateAlert`,3 * and persists notifications/e-mails. Kept pure so the rules are unit-testable.4 */56export interface AlertRow {7  id: string;8  userId: string;9  alertType: string;10  targetType: string; // asset | category | index11  targetId: string;12  threshold: number | null;13  active: boolean;14  lastTriggeredAt: Date | null;15  cooldownMinutes: number;16  name: string | null;17  channel: string;18}1920export interface AssetState {21  title: string;22  slug: string;23  rivUsd: number | null;24  rivConfidence: number | null;25  rivSampleSize: number;26  athUsd: number | null;27  latestSaleUsd: number | null;28  latestSaleAt: Date | null;29  sales30d: number;30  /** average sales per 30 days over the prior 90 days */31  baselineSales30d: number | null;32  newListings: Array<{ id: string; priceUsd: number | null; sourceId: string; firstSeenAt: Date }>;33  newAuctionLots: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string }>;34  endingLots: Array<{ id: string; title: string; endsAt: Date; auctionHouse: string }>;35  /** live lots whose buyer-pays bid is ≥ 10 % below the variant RIV (assessment verdict 'deal') */36  belowRivLots?: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string; allInBidUsd: number; rivUsd: number; bidVsRiv: number; feeBasis: string | null }>;37  newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null;38  populationChange: { grader: string; from: number; to: number; date: string } | null;39}4041export interface CategoryState {42  name: string;43  slug: string;44  change1d: number | null;45  newRecordSale: { assetTitle: string; assetSlug: string; priceUsd: number; saleDate: Date } | null;46  radarFindings: Array<{ assetTitle: string; assetSlug: string; kind: string; score: number }>;47  newAuctionLots: number;48  endingLots: number;49  sales30d: number;50  baselineSales30d: number | null;51}5253export interface IndexState {54  ticker: string;55  name: string;56  change1d: number | null;57  value: number | null;58}5960export interface Trigger {61  title: string;62  body: string;63  href: string;64  facts: Array<[string, string]>;65  kind: 'alert';66}6768const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v);69const pct = (v: number) => `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;7071export function inCooldown(alert: AlertRow, now: Date): boolean {72  if (!alert.lastTriggeredAt) return false;73  return now.getTime() - alert.lastTriggeredAt.getTime() < alert.cooldownMinutes * 60_000;74}7576export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Date()): Trigger | null {77  if (!alert.active || inCooldown(alert, now)) return null;78  const href = `/asset/${s.slug}`;79  const title = alert.name ?? s.title;80  switch (alert.alertType) {81    case 'price_below':82      if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd <= alert.threshold) return { kind: 'alert', title: `${title}: RIV below ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };83      return null;84    case 'price_above':85      if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd >= alert.threshold) return { kind: 'alert', title: `${title}: RIV above ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };86      return null;87    case 'new_listing': {88      if (!s.newListings.length) return null;89      const cheapest = s.newListings.filter((l) => l.priceUsd !== null).sort((a, b) => (a.priceUsd ?? 0) - (b.priceUsd ?? 0))[0];90      return { kind: 'alert', title: `${title}: ${s.newListings.length} new listing${s.newListings.length === 1 ? '' : 's'}`, body: cheapest?.priceUsd ? `Lowest new ask ${usd(cheapest.priceUsd)} on ${cheapest.sourceId}${s.rivUsd ? ` · RIV ${usd(s.rivUsd)}` : ''}.` : 'New listings observed.', href: `${href}?tab=listings`, facts: [['New listings', String(s.newListings.length)], ...(cheapest?.priceUsd ? [['Lowest ask', usd(cheapest.priceUsd)] as [string, string]] : []), ...(s.rivUsd ? [['RIV', usd(s.rivUsd)] as [string, string]] : [])] };91    }92    case 'new_auction': {93      if (!s.newAuctionLots.length) return null;94      const l = s.newAuctionLots[0]!;95      return { kind: 'alert', title: `${title}: new auction lot at ${l.auctionHouse}`, body: `${l.title}${l.endsAt ? ` · ends ${l.endsAt.toUTCString()}` : ''}`, href: `${href}?tab=listings`, facts: [['Lots', String(s.newAuctionLots.length)]] };96    }97    case 'auction_ending': {98      const soon = s.endingLots.filter((l) => l.endsAt.getTime() - now.getTime() <= 24 * 3600_000 && l.endsAt.getTime() > now.getTime());99      if (!soon.length) return null;100      const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!;101      return { kind: 'alert', title: `${title}: auction ends ${Math.max(1, Math.round((l.endsAt.getTime() - now.getTime()) / 3600_000))}h from now`, body: `${l.title} at ${l.auctionHouse}.`, href: `${href}?tab=listings`, facts: [['Ends', l.endsAt.toUTCString()]] };102    }103    case 'auction_below_riv': {104      const lots = (s.belowRivLots ?? []).filter((l) => !l.endsAt || l.endsAt.getTime() > now.getTime());105      if (!lots.length) return null;106      const l = lots.sort((a, b) => a.bidVsRiv - b.bidVsRiv)[0]!;107      return { kind: 'alert', title: `${title}: auction bid ${pct(l.bidVsRiv)} vs RIV (all-in)`, body: `${l.title} at ${l.auctionHouse}: current bid + buyer premium ≈ ${usd(l.allInBidUsd)} against a RIV of ${usd(l.rivUsd)}${l.feeBasis?.startsWith('added_') ? ' (premium estimated; taxes and shipping excluded)' : ''}. Analytical data, not advice.`, href: `${href}?tab=listings`, facts: [['All-in bid', usd(l.allInBidUsd)], ['RIV', usd(l.rivUsd)], ['Gap', pct(l.bidVsRiv)], ...(l.endsAt ? [['Ends', l.endsAt.toUTCString()] as [string, string]] : [])] };108    }109    case 'record_sale':110      if (!s.newRecordSale) return null;111      return { kind: 'alert', title: `${title}: new record sale ${usd(s.newRecordSale.priceUsd)}`, body: `Highest verified sale to date, on ${s.newRecordSale.sourceId} (${s.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `${href}?tab=sales`, facts: [['Record', usd(s.newRecordSale.priceUsd)], ...(s.athUsd ? [['Previous high', usd(s.athUsd)] as [string, string]] : [])] };112    case 'unusual_volume': {113      if (alert.threshold === null || s.baselineSales30d === null || s.baselineSales30d <= 0) return null;114      const ratio = s.sales30d / s.baselineSales30d - 1;115      if (ratio * 100 < alert.threshold || s.sales30d < 5) return null;116      return { kind: 'alert', title: `${title}: sales volume ${pct(ratio)} vs 90-day average`, body: `${s.sales30d} sales in 30 days against a baseline of ${s.baselineSales30d.toFixed(1)}.`, href: `${href}?tab=sales`, facts: [['Sales 30d', String(s.sales30d)], ['Baseline', s.baselineSales30d.toFixed(1)]] };117    }118    case 'population_update':119      if (!s.populationChange) return null;120      return { kind: 'alert', title: `${title}: ${s.populationChange.grader.toUpperCase()} population ${s.populationChange.from} → ${s.populationChange.to}`, body: `Population report dated ${s.populationChange.date}.`, href: `${href}?tab=population`, facts: [['Change', `${s.populationChange.to - s.populationChange.from > 0 ? '+' : ''}${s.populationChange.to - s.populationChange.from}`]] };121    default:122      return null;123  }124}125126export function evaluateCategoryAlert(alert: AlertRow, c: CategoryState, now = new Date()): Trigger | null {127  if (!alert.active || inCooldown(alert, now)) return null;128  const href = `/markets/${c.slug}`;129  const title = alert.name ?? c.name;130  switch (alert.alertType) {131    case 'market_move':132      if (alert.threshold === null || c.change1d === null || Math.abs(c.change1d) * 100 < alert.threshold) return null;133      return { kind: 'alert', title: `${title} moved ${pct(c.change1d)} today`, body: `Daily move beyond your ${alert.threshold}% threshold.`, href, facts: [['1d', pct(c.change1d)]] };134    case 'record_sale':135      if (!c.newRecordSale) return null;136      return { kind: 'alert', title: `${title}: record sale ${usd(c.newRecordSale.priceUsd)}`, body: `${c.newRecordSale.assetTitle} (${c.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `/asset/${c.newRecordSale.assetSlug}`, facts: [['Price', usd(c.newRecordSale.priceUsd)]] };137    case 'rare_item': {138      if (!c.radarFindings.length) return null;139      const f = c.radarFindings.sort((a, b) => b.score - a.score)[0]!;140      return { kind: 'alert', title: `${title}: rare item on the radar`, body: `${f.assetTitle} — ${f.kind.replace(/_/g, ' ')}.`, href: `/asset/${f.assetSlug}`, facts: [['Findings', String(c.radarFindings.length)]] };141    }142    case 'new_auction':143      if (!c.newAuctionLots) return null;144      return { kind: 'alert', title: `${title}: ${c.newAuctionLots} new auction lot${c.newAuctionLots === 1 ? '' : 's'}`, body: 'New lots catalogued in the last cycle.', href: `/auctions?category=${c.slug}`, facts: [['Lots', String(c.newAuctionLots)]] };145    case 'auction_ending':146      if (!c.endingLots) return null;147      return { kind: 'alert', title: `${title}: ${c.endingLots} lot${c.endingLots === 1 ? '' : 's'} ending within 24h`, body: 'Check the auction calendar.', href: `/auctions/calendar?category=${c.slug}`, facts: [['Ending', String(c.endingLots)]] };148    case 'unusual_volume': {149      if (alert.threshold === null || c.baselineSales30d === null || c.baselineSales30d <= 0) return null;150      const ratio = c.sales30d / c.baselineSales30d - 1;151      if (ratio * 100 < alert.threshold) return null;152      return { kind: 'alert', title: `${title}: volume ${pct(ratio)} vs 90-day average`, body: `${c.sales30d} sales in 30 days.`, href, facts: [['Sales 30d', String(c.sales30d)]] };153    }154    default:155      return null;156  }157}158159export function evaluateIndexAlert(alert: AlertRow, i: IndexState, now = new Date()): Trigger | null {160  if (!alert.active || inCooldown(alert, now)) return null;161  if (alert.alertType !== 'market_move' || alert.threshold === null || i.change1d === null) return null;162  if (Math.abs(i.change1d) * 100 < alert.threshold) return null;163  return { kind: 'alert', title: `${i.ticker} moved ${pct(i.change1d)} today`, body: `${i.name} at ${i.value?.toFixed(1) ?? '—'}.`, href: `/rareindex/${i.ticker}`, facts: [['1d', pct(i.change1d)]] };164}165166/** Quiet hours check (UTC hours). Returns true when e-mail should be held. */167export function inQuietHours(prefs: { quietStart?: number | null; quietEnd?: number | null }, now = new Date()): boolean {168  const s = prefs.quietStart;169  const e = prefs.quietEnd;170  if (s === null || s === undefined || e === null || e === undefined) return false;171  const h = now.getUTCHours();172  return s <= e ? h >= s && h < e : h >= s || h < e;173}174175/** Price target hit check. */176export function targetHit(direction: 'above' | 'below', targetUsd: number, rivUsd: number | null): boolean {177  if (rivUsd === null) return false;178  return direction === 'above' ? rivUsd >= targetUsd : rivUsd <= targetUsd;179}180