diff --git a/packages/shared/src/alerts/engine.test.ts b/packages/shared/src/alerts/engine.test.ts index b58040b..8e70870 100644 --- a/packages/shared/src/alerts/engine.test.ts +++ b/packages/shared/src/alerts/engine.test.ts @@ -247,3 +247,62 @@ describe('AlertEngine rating change across ticks', () => { expect(events[0].payload).toEqual({ previous: 'strong_buy@302', current: 'hold@280' }); }); }); + +describe('AlertEngine portfolio drawdown persistence', () => { + it('uses the initial peak after recreating the engine and repository', async () => { + const rootDir = mkdtempSync(join(tmpdir(), 'folio-alerts-drawdown-')); + const store = new JsonFileStore(rootDir); + let nowMs = 1000; + const clock = () => nowMs; + const repository = new AlertRuleRepository(store, clock); + await repository.save({ + id: 'drawdown-1', + createdAt: nowMs, + enabled: true, + cooldownMinutes: 30, + type: 'portfolio_drawdown', + threshold: 0.1, + }); + + let totalAssets = 100; + const registry = makeRegistry({ + 'portfolio.summary': () => ({ baseCurrency: 'USD', totalAssets, holdings: [], accounts: [], fetchedAt: 0 }), + 'market.status': openStatus(), + }); + const events: AlertTriggerEvent[] = []; + const engine = new AlertEngine({ + registry, + repository, + eventLog: new AlertEventLog(store), + now: clock, + onTrigger: (event) => events.push(event), + }); + + await engine.tick(); + expect(events).toHaveLength(0); + + const reloadedStore = new JsonFileStore(rootDir); + const reloadedRepository = new AlertRuleRepository(reloadedStore, clock); + const reloadedEventLog = new AlertEventLog(reloadedStore); + const reloadedEngine = new AlertEngine({ + registry, + repository: reloadedRepository, + eventLog: reloadedEventLog, + now: clock, + onTrigger: (event) => events.push(event), + }); + + nowMs += 60_000; + totalAssets = 80; + await reloadedEngine.tick(); + + expect(events).toHaveLength(1); + expect(events[0].payload).toEqual({ drawdown: 0.2, peak: 100, currency: 'USD' }); + expect(await reloadedRepository.getRuleSnapshot('drawdown-1')).toEqual({ peakValue: 100 }); + expect(await reloadedRepository.get('drawdown-1')).toMatchObject({ + lastCheckedAt: nowMs, + lastTriggeredAt: nowMs, + }); + expect(await reloadedEventLog.list()).toEqual(events); + }); +}); diff --git a/packages/shared/src/alerts/evaluators.test.ts b/packages/shared/src/alerts/evaluators.test.ts index 056b3ef..d9488b5 100644 --- a/packages/shared/src/alerts/evaluators.test.ts +++ b/packages/shared/src/alerts/evaluators.test.ts @@ -227,6 +227,25 @@ describe('position_weight', () => { }); }); describe('portfolio_drawdown', () => { + it('records the first portfolio value and detects a subsequent drawdown', async () => { + let totalAssets = 100; + const registry = makeRegistry({ + 'portfolio.summary': () => ({ baseCurrency: 'USD', totalAssets, holdings: [], accounts: [], fetchedAt: 0 }), + }); + const r = rule({}, { type: 'portfolio_drawdown', threshold: 0.1 }); + const snapshots = makeSnapshotContext(); + const context = { now, ...snapshots }; + + expect(await evaluateRule(r, registry, context)).toBeNull(); + expect(await snapshots.getRuleSnapshot(r.id)).toEqual({ peakValue: 100 }); + + totalAssets = 80; + const event = await evaluateRule(r, registry, context); + expect(event?.payload?.drawdown).toBeCloseTo(0.2); + expect(event?.payload?.peak).toBe(100); + expect(await snapshots.getRuleSnapshot(r.id)).toEqual({ peakValue: 100 }); + }); + it('triggers when drawdown exceeds the threshold', async () => { const registry = makeRegistry({ 'portfolio.summary': { baseCurrency: 'USD', totalAssets: 80, holdings: [], accounts: [], fetchedAt: 0 } }); const r = rule({}, { type: 'portfolio_drawdown', threshold: 0.1 }); @@ -236,13 +255,35 @@ describe('portfolio_drawdown', () => { expect(event?.payload?.drawdown).toBeCloseTo(0.2); expect(event?.payload?.peak).toBe(100); }); - it('does not trigger below the threshold (and resets peak on a new high)', async () => { + it('does not trigger below the threshold', async () => { const registry = makeRegistry({ 'portfolio.summary': { baseCurrency: 'USD', totalAssets: 95, holdings: [], accounts: [], fetchedAt: 0 } }); const r = rule({}, { type: 'portfolio_drawdown', threshold: 0.1 }); const snapshots = makeSnapshotContext({ [r.id]: { peakValue: 100 } }); expect(await evaluateRule(r, registry, { now, ...snapshots })).toBeNull(); }); + it('records a new high and measures subsequent drawdowns from it', async () => { + let totalAssets = 120; + const registry = makeRegistry({ + 'portfolio.summary': () => ({ baseCurrency: 'USD', totalAssets, holdings: [], accounts: [], fetchedAt: 0 }), + }); + const r = rule({}, { type: 'portfolio_drawdown', threshold: 0.1 }); + const snapshots = makeSnapshotContext({ [r.id]: { peakValue: 100 } }); + const context = { now, ...snapshots }; + + expect(await evaluateRule(r, registry, context)).toBeNull(); + expect(await snapshots.getRuleSnapshot(r.id)).toEqual({ peakValue: 120 }); + + totalAssets = 115; + expect(await evaluateRule(r, registry, context)).toBeNull(); + expect(await snapshots.getRuleSnapshot(r.id)).toEqual({ peakValue: 120 }); + + totalAssets = 100; + const event = await evaluateRule(r, registry, context); + expect(event?.payload?.drawdown).toBeCloseTo(1 / 6); + expect(event?.payload?.peak).toBe(120); + }); + it('returns null when the portfolio is missing', async () => { const registry = makeRegistry({}); const r = rule({}, { type: 'portfolio_drawdown', threshold: 0.1 }); diff --git a/packages/shared/src/alerts/evaluators.ts b/packages/shared/src/alerts/evaluators.ts index f8bb6b3..0fd4b8f 100644 --- a/packages/shared/src/alerts/evaluators.ts +++ b/packages/shared/src/alerts/evaluators.ts @@ -336,9 +336,9 @@ async function evaluateDrawdown( if (!summary || typeof summary.totalAssets !== 'number' || summary.totalAssets <= 0) return null; const current = summary.totalAssets; const snapshot = await ctx.getRuleSnapshot(rule.id); - let peak = snapshot.peakValue ?? current; - if (current > peak) { - // New high-water mark — reset the peak, no drawdown. + const peak = snapshot.peakValue; + if (peak === undefined || current > peak) { + // First observation or a new high-water mark — persist the peak, no drawdown. await ctx.patchRuleSnapshot(rule.id, { peakValue: current }); return null; }