feat: alert evaluation logic (target/atl/drop) with dedup
This commit is contained in:
62
src/lib/alerts/evaluate.ts
Normal file
62
src/lib/alerts/evaluate.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type AlertType = 'target_price' | 'all_time_low' | 'percent_drop'
|
||||
|
||||
export interface AlertInput {
|
||||
type: AlertType
|
||||
config: Record<string, unknown>
|
||||
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<string, number | string>
|
||||
}
|
||||
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
112
tests/alerts/evaluate.test.ts
Normal file
112
tests/alerts/evaluate.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user