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 newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null;36 populationChange: { grader: string; from: number; to: number; date: string } | null;37}3839export interface CategoryState {40 name: string;41 slug: string;42 change1d: number | null;43 newRecordSale: { assetTitle: string; assetSlug: string; priceUsd: number; saleDate: Date } | null;44 radarFindings: Array<{ assetTitle: string; assetSlug: string; kind: string; score: number }>;45 newAuctionLots: number;46 endingLots: number;47 sales30d: number;48 baselineSales30d: number | null;49}5051export interface IndexState {52 ticker: string;53 name: string;54 change1d: number | null;55 value: number | null;56}5758export interface Trigger {59 title: string;60 body: string;61 href: string;62 facts: Array<[string, string]>;63 kind: 'alert';64}6566const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v);67const pct = (v: number) => `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;6869export function inCooldown(alert: AlertRow, now: Date): boolean {70 if (!alert.lastTriggeredAt) return false;71 return now.getTime() - alert.lastTriggeredAt.getTime() < alert.cooldownMinutes * 60_000;72}7374export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Date()): Trigger | null {75 if (!alert.active || inCooldown(alert, now)) return null;76 const href = `/asset/${s.slug}`;77 const title = alert.name ?? s.title;78 switch (alert.alertType) {79 case 'price_below':80 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)]] };81 return null;82 case 'price_above':83 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)]] };84 return null;85 case 'new_listing': {86 if (!s.newListings.length) return null;87 const cheapest = s.newListings.filter((l) => l.priceUsd !== null).sort((a, b) => (a.priceUsd ?? 0) - (b.priceUsd ?? 0))[0];88 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]] : [])] };89 }90 case 'new_auction': {91 if (!s.newAuctionLots.length) return null;92 const l = s.newAuctionLots[0]!;93 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)]] };94 }95 case 'auction_ending': {96 const soon = s.endingLots.filter((l) => l.endsAt.getTime() - now.getTime() <= 24 * 3600_000 && l.endsAt.getTime() > now.getTime());97 if (!soon.length) return null;98 const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!;99 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()]] };100 }101 case 'record_sale':102 if (!s.newRecordSale) return null;103 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]] : [])] };104 case 'unusual_volume': {105 if (alert.threshold === null || s.baselineSales30d === null || s.baselineSales30d <= 0) return null;106 const ratio = s.sales30d / s.baselineSales30d - 1;107 if (ratio * 100 < alert.threshold || s.sales30d < 5) return null;108 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)]] };109 }110 case 'population_update':111 if (!s.populationChange) return null;112 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}`]] };113 default:114 return null;115 }116}117118export function evaluateCategoryAlert(alert: AlertRow, c: CategoryState, now = new Date()): Trigger | null {119 if (!alert.active || inCooldown(alert, now)) return null;120 const href = `/markets/${c.slug}`;121 const title = alert.name ?? c.name;122 switch (alert.alertType) {123 case 'market_move':124 if (alert.threshold === null || c.change1d === null || Math.abs(c.change1d) * 100 < alert.threshold) return null;125 return { kind: 'alert', title: `${title} moved ${pct(c.change1d)} today`, body: `Daily move beyond your ${alert.threshold}% threshold.`, href, facts: [['1d', pct(c.change1d)]] };126 case 'record_sale':127 if (!c.newRecordSale) return null;128 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)]] };129 case 'rare_item': {130 if (!c.radarFindings.length) return null;131 const f = c.radarFindings.sort((a, b) => b.score - a.score)[0]!;132 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)]] };133 }134 case 'new_auction':135 if (!c.newAuctionLots) return null;136 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)]] };137 case 'auction_ending':138 if (!c.endingLots) return null;139 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)]] };140 case 'unusual_volume': {141 if (alert.threshold === null || c.baselineSales30d === null || c.baselineSales30d <= 0) return null;142 const ratio = c.sales30d / c.baselineSales30d - 1;143 if (ratio * 100 < alert.threshold) return null;144 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)]] };145 }146 default:147 return null;148 }149}150151export function evaluateIndexAlert(alert: AlertRow, i: IndexState, now = new Date()): Trigger | null {152 if (!alert.active || inCooldown(alert, now)) return null;153 if (alert.alertType !== 'market_move' || alert.threshold === null || i.change1d === null) return null;154 if (Math.abs(i.change1d) * 100 < alert.threshold) return null;155 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)]] };156}157158/** Quiet hours check (UTC hours). Returns true when e-mail should be held. */159export function inQuietHours(prefs: { quietStart?: number | null; quietEnd?: number | null }, now = new Date()): boolean {160 const s = prefs.quietStart;161 const e = prefs.quietEnd;162 if (s === null || s === undefined || e === null || e === undefined) return false;163 const h = now.getUTCHours();164 return s <= e ? h >= s && h < e : h >= s || h < e;165}166167/** Price target hit check. */168export function targetHit(direction: 'above' | 'below', targetUsd: number, rivUsd: number | null): boolean {169 if (rivUsd === null) return false;170 return direction === 'above' ? rivUsd >= targetUsd : rivUsd <= targetUsd;171}172