/** * Pure alert evaluation (no I/O). The runner loads the state an alert needs, calls `evaluateAlert`, * and persists notifications/e-mails. Kept pure so the rules are unit-testable. */ export interface AlertRow { id: string; userId: string; alertType: string; targetType: string; // asset | category | index targetId: string; threshold: number | null; active: boolean; lastTriggeredAt: Date | null; cooldownMinutes: number; name: string | null; channel: string; } export interface AssetState { title: string; slug: string; rivUsd: number | null; rivConfidence: number | null; rivSampleSize: number; athUsd: number | null; latestSaleUsd: number | null; latestSaleAt: Date | null; sales30d: number; /** average sales per 30 days over the prior 90 days */ baselineSales30d: number | null; newListings: Array<{ id: string; priceUsd: number | null; sourceId: string; firstSeenAt: Date }>; newAuctionLots: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string }>; endingLots: Array<{ id: string; title: string; endsAt: Date; auctionHouse: string }>; /** live lots whose buyer-pays bid is ≥ 10 % below the variant RIV (assessment verdict 'deal') */ belowRivLots?: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string; allInBidUsd: number; rivUsd: number; bidVsRiv: number; feeBasis: string | null }>; newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null; populationChange: { grader: string; from: number; to: number; date: string } | null; } export interface CategoryState { name: string; slug: string; change1d: number | null; newRecordSale: { assetTitle: string; assetSlug: string; priceUsd: number; saleDate: Date } | null; radarFindings: Array<{ assetTitle: string; assetSlug: string; kind: string; score: number }>; newAuctionLots: number; endingLots: number; sales30d: number; baselineSales30d: number | null; } export interface IndexState { ticker: string; name: string; change1d: number | null; value: number | null; } export interface Trigger { title: string; body: string; href: string; facts: Array<[string, string]>; kind: 'alert'; } const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v); const pct = (v: number) => `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`; export function inCooldown(alert: AlertRow, now: Date): boolean { if (!alert.lastTriggeredAt) return false; return now.getTime() - alert.lastTriggeredAt.getTime() < alert.cooldownMinutes * 60_000; } export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Date()): Trigger | null { if (!alert.active || inCooldown(alert, now)) return null; const href = `/asset/${s.slug}`; const title = alert.name ?? s.title; switch (alert.alertType) { case 'price_below': 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)]] }; return null; case 'price_above': 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)]] }; return null; case 'new_listing': { if (!s.newListings.length) return null; const cheapest = s.newListings.filter((l) => l.priceUsd !== null).sort((a, b) => (a.priceUsd ?? 0) - (b.priceUsd ?? 0))[0]; 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]] : [])] }; } case 'new_auction': { if (!s.newAuctionLots.length) return null; const l = s.newAuctionLots[0]!; 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)]] }; } case 'auction_ending': { const soon = s.endingLots.filter((l) => l.endsAt.getTime() - now.getTime() <= 24 * 3600_000 && l.endsAt.getTime() > now.getTime()); if (!soon.length) return null; const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!; 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()]] }; } case 'auction_below_riv': { const lots = (s.belowRivLots ?? []).filter((l) => !l.endsAt || l.endsAt.getTime() > now.getTime()); if (!lots.length) return null; const l = lots.sort((a, b) => a.bidVsRiv - b.bidVsRiv)[0]!; 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]] : [])] }; } case 'record_sale': if (!s.newRecordSale) return null; 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]] : [])] }; case 'unusual_volume': { if (alert.threshold === null || s.baselineSales30d === null || s.baselineSales30d <= 0) return null; const ratio = s.sales30d / s.baselineSales30d - 1; if (ratio * 100 < alert.threshold || s.sales30d < 5) return null; 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)]] }; } case 'population_update': if (!s.populationChange) return null; 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}`]] }; default: return null; } } export function evaluateCategoryAlert(alert: AlertRow, c: CategoryState, now = new Date()): Trigger | null { if (!alert.active || inCooldown(alert, now)) return null; const href = `/markets/${c.slug}`; const title = alert.name ?? c.name; switch (alert.alertType) { case 'market_move': if (alert.threshold === null || c.change1d === null || Math.abs(c.change1d) * 100 < alert.threshold) return null; return { kind: 'alert', title: `${title} moved ${pct(c.change1d)} today`, body: `Daily move beyond your ${alert.threshold}% threshold.`, href, facts: [['1d', pct(c.change1d)]] }; case 'record_sale': if (!c.newRecordSale) return null; 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)]] }; case 'rare_item': { if (!c.radarFindings.length) return null; const f = c.radarFindings.sort((a, b) => b.score - a.score)[0]!; 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)]] }; } case 'new_auction': if (!c.newAuctionLots) return null; 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)]] }; case 'auction_ending': if (!c.endingLots) return null; 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)]] }; case 'unusual_volume': { if (alert.threshold === null || c.baselineSales30d === null || c.baselineSales30d <= 0) return null; const ratio = c.sales30d / c.baselineSales30d - 1; if (ratio * 100 < alert.threshold) return null; 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)]] }; } default: return null; } } export function evaluateIndexAlert(alert: AlertRow, i: IndexState, now = new Date()): Trigger | null { if (!alert.active || inCooldown(alert, now)) return null; if (alert.alertType !== 'market_move' || alert.threshold === null || i.change1d === null) return null; if (Math.abs(i.change1d) * 100 < alert.threshold) return null; 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)]] }; } /** Quiet hours check (UTC hours). Returns true when e-mail should be held. */ export function inQuietHours(prefs: { quietStart?: number | null; quietEnd?: number | null }, now = new Date()): boolean { const s = prefs.quietStart; const e = prefs.quietEnd; if (s === null || s === undefined || e === null || e === undefined) return false; const h = now.getUTCHours(); return s <= e ? h >= s && h < e : h >= s || h < e; } /** Price target hit check. */ export function targetHit(direction: 'above' | 'below', targetUsd: number, rivUsd: number | null): boolean { if (rivUsd === null) return false; return direction === 'above' ? rivUsd >= targetUsd : rivUsd <= targetUsd; }