From 835f3bb2bbf360c43cf2555a08c61c42d26bbc27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:13:24 +0000 Subject: [PATCH] feat: alert evaluation logic (target/atl/drop) with dedup --- src/lib/alerts/evaluate.ts | 62 +++++++++++++++++++ tests/alerts/evaluate.test.ts | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/lib/alerts/evaluate.ts create mode 100644 tests/alerts/evaluate.test.ts diff --git a/src/lib/alerts/evaluate.ts b/src/lib/alerts/evaluate.ts new file mode 100644 index 0000000..920c2fc --- /dev/null +++ b/src/lib/alerts/evaluate.ts @@ -0,0 +1,62 @@ +export type AlertType = 'target_price' | 'all_time_low' | 'percent_drop' + +export interface AlertInput { + type: AlertType + config: Record + lastTriggeredAt: Date | null +} + +export interface SnapshotInput { + price: number + scrapedAt: Date +} + +export interface EvalInput { + alert: AlertInput + currentPrice: number + history: SnapshotInput[] +} + +export interface EvalResult { + triggered: boolean + context: Record +} + +const DEDUP_HOURS = 24 + +export function evaluateAlert(input: EvalInput): EvalResult { + const { alert, currentPrice, history } = input + + if (alert.lastTriggeredAt) { + const ageMs = Date.now() - alert.lastTriggeredAt.getTime() + if (ageMs < DEDUP_HOURS * 60 * 60 * 1000) { + return { triggered: false, context: { reason: 'dedup-cooldown' } } + } + } + + switch (alert.type) { + case 'target_price': { + const threshold = Number(alert.config.threshold) + if (!Number.isFinite(threshold)) return { triggered: false, context: { reason: 'bad-config' } } + return { triggered: currentPrice <= threshold, context: { threshold, currentPrice } } + } + case 'all_time_low': { + if (history.length === 0) return { triggered: false, context: { reason: 'no-history' } } + const prevMin = Math.min(...history.map((s) => s.price)) + return { triggered: currentPrice < prevMin, context: { prevMin, currentPrice } } + } + case 'percent_drop': { + const lookbackDays = Number(alert.config.lookback_days) + const percent = Number(alert.config.percent) + if (!Number.isFinite(lookbackDays) || !Number.isFinite(percent)) { + return { triggered: false, context: { reason: 'bad-config' } } + } + const cutoff = Date.now() - lookbackDays * 86400_000 + const window = history.filter((s) => s.scrapedAt.getTime() >= cutoff) + if (window.length === 0) return { triggered: false, context: { reason: 'no-history-in-window' } } + const avg = window.reduce((s, x) => s + x.price, 0) / window.length + const dropPct = ((avg - currentPrice) / avg) * 100 + return { triggered: dropPct >= percent, context: { avg, percent: Number(dropPct.toFixed(2)), currentPrice } } + } + } +} diff --git a/tests/alerts/evaluate.test.ts b/tests/alerts/evaluate.test.ts new file mode 100644 index 0000000..4919474 --- /dev/null +++ b/tests/alerts/evaluate.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate' + +function snap(price: number, daysAgo: number): SnapshotInput { + const d = new Date() + d.setDate(d.getDate() - daysAgo) + return { price, scrapedAt: d } +} + +describe('evaluateAlert', () => { + describe('target_price', () => { + it('triggers when current price <= threshold', () => { + const r = evaluateAlert({ + alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null }, + currentPrice: 99, + history: [], + }) + expect(r.triggered).toBe(true) + }) + + it('does not trigger when price > threshold', () => { + const r = evaluateAlert({ + alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null }, + currentPrice: 101, + history: [], + }) + expect(r.triggered).toBe(false) + }) + }) + + describe('all_time_low', () => { + it('triggers when current is below all previous successful prices', () => { + const r = evaluateAlert({ + alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null }, + currentPrice: 50, + history: [snap(60, 1), snap(70, 5), snap(80, 10)], + }) + expect(r.triggered).toBe(true) + expect(r.context.prevMin).toBe(60) + }) + + it('does not trigger when current equals previous min', () => { + const r = evaluateAlert({ + alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null }, + currentPrice: 60, + history: [snap(60, 1)], + }) + expect(r.triggered).toBe(false) + }) + + it('does not trigger with no history (first scrape)', () => { + const r = evaluateAlert({ + alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null }, + currentPrice: 50, + history: [], + }) + expect(r.triggered).toBe(false) + }) + }) + + describe('percent_drop', () => { + it('triggers when price drops >= percent vs lookback avg', () => { + const r = evaluateAlert({ + alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null }, + currentPrice: 80, + history: [snap(100, 1), snap(100, 3), snap(100, 6), snap(50, 30)], + }) + expect(r.triggered).toBe(true) + expect(r.context.percent).toBeGreaterThanOrEqual(10) + }) + + it('does not trigger when drop is smaller than threshold', () => { + const r = evaluateAlert({ + alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null }, + currentPrice: 95, + history: [snap(100, 1), snap(100, 3)], + }) + expect(r.triggered).toBe(false) + }) + + it('does not trigger with no history in lookback window', () => { + const r = evaluateAlert({ + alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null }, + currentPrice: 50, + history: [snap(100, 30)], + }) + expect(r.triggered).toBe(false) + }) + }) + + describe('dedup', () => { + it('does not trigger if last triggered within 24h', () => { + const recent = new Date(Date.now() - 1000 * 60 * 60 * 12) + const r = evaluateAlert({ + alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: recent }, + currentPrice: 50, + history: [], + }) + expect(r.triggered).toBe(false) + }) + + it('triggers if last triggered > 24h ago', () => { + const old = new Date(Date.now() - 1000 * 60 * 60 * 25) + const r = evaluateAlert({ + alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: old }, + currentPrice: 50, + history: [], + }) + expect(r.triggered).toBe(true) + }) + }) +})