- Add await ctx.cache.subscribe() to addSymbol mutation so symbols added via the sidebar get registered in symbol_demand and yfinance jobs are queued immediately - Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue - Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false to quoteSummary() to handle Yahoo schema drift - Add error detail logging for analyst ratings schema failures - Update .gitignore with common ignores
246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
// Tests — AlertEngine (Slice 17). Pure, no network.
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
|
|
import {
|
|
AlertEngine,
|
|
DedupStore,
|
|
createAlert,
|
|
alertTitle,
|
|
alertDescription,
|
|
defaultSeverity,
|
|
buildDedupKey,
|
|
ALERT_THROTTLE,
|
|
type Alert,
|
|
type AlertType,
|
|
} from '../AlertEngine.ts';
|
|
|
|
// ─── Pure utility tests ──────────────────────────────────────────────────────
|
|
|
|
test('defaultSeverity returns expected severity for each alert type', () => {
|
|
assert.equal(defaultSeverity('drawdown_halt'), 'critical');
|
|
assert.equal(defaultSeverity('thesis_broken'), 'warning');
|
|
assert.equal(defaultSeverity('cluster_breach'), 'warning');
|
|
assert.equal(defaultSeverity('asymmetry_warning'), 'warning');
|
|
assert.equal(defaultSeverity('informed_buy'), 'info');
|
|
assert.equal(defaultSeverity('informed_sell'), 'info');
|
|
assert.equal(defaultSeverity('new_13da'), 'info');
|
|
assert.equal(defaultSeverity('rotation_incipient'), 'info');
|
|
assert.equal(defaultSeverity('regime_shift'), 'info');
|
|
assert.equal(defaultSeverity('conviction_unlock'), 'info');
|
|
assert.equal(defaultSeverity('thesis_weakening'), 'info');
|
|
});
|
|
|
|
test('alertTitle returns expected title for each alert type', () => {
|
|
assert.equal(alertTitle('informed_buy'), 'Insider bought');
|
|
assert.equal(alertTitle('informed_sell', 'NVDA'), 'Insider sold for NVDA');
|
|
assert.equal(alertTitle('regime_shift'), 'Market regime changed');
|
|
assert.equal(alertTitle('drawdown_halt'), 'Drawdown tolerance breached');
|
|
assert.equal(alertTitle('informed_buy', 'AAPL'), 'Insider bought for AAPL');
|
|
});
|
|
|
|
test('alertDescription never contains trade verbs', () => {
|
|
const types: AlertType[] = [
|
|
'informed_buy', 'informed_sell', 'new_13da', 'rotation_incipient',
|
|
'regime_shift', 'conviction_unlock', 'thesis_broken', 'thesis_weakening',
|
|
'cluster_breach', 'drawdown_halt', 'asymmetry_warning',
|
|
];
|
|
const forbidden = ['buy ', 'sell ', 'cut ', 'trim ', 'buy.', 'sell.', 'cut.', 'trim.'];
|
|
for (const type of types) {
|
|
const desc = alertDescription(type);
|
|
const lower = desc.toLowerCase();
|
|
for (const word of forbidden) {
|
|
assert.ok(!lower.includes(word),
|
|
`"${type}" description should not contain "${word}": "${desc.substring(0, 60)}..."`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('alertDescription includes ADR-0007 compliant language', () => {
|
|
const desc = alertDescription('drawdown_halt');
|
|
assert.ok(desc.includes('circuit breaker'), 'Should mention circuit breaker');
|
|
assert.ok(desc.includes('Existing positions continue unaffected'),
|
|
'Should mention existing positions unaffected');
|
|
assert.ok(!desc.includes('cut'), 'Should not contain "cut"');
|
|
});
|
|
|
|
test('buildDedupKey produces consistent keys', () => {
|
|
assert.equal(buildDedupKey('informed_buy'), 'informed_buy');
|
|
assert.equal(buildDedupKey('informed_buy', 'NVDA'), 'informed_buy:NVDA');
|
|
assert.equal(buildDedupKey('informed_buy', 'NVDA', 'filing-123'),
|
|
'informed_buy:NVDA:filing-123');
|
|
assert.equal(buildDedupKey('drawdown_halt'), 'drawdown_halt');
|
|
});
|
|
|
|
// ─── Alert factory tests ─────────────────────────────────────────────────────
|
|
|
|
test('createAlert produces a complete Alert object', () => {
|
|
const alert = createAlert('id-1', 'user-1', 'informed_buy', 'NVDA');
|
|
assert.equal(alert.id, 'id-1');
|
|
assert.equal(alert.userId, 'user-1');
|
|
assert.equal(alert.type, 'informed_buy');
|
|
assert.equal(alert.symbol, 'NVDA');
|
|
assert.equal(alert.severity, 'info');
|
|
assert.ok(alert.title.startsWith('Insider bought'));
|
|
assert.ok(alert.description.length > 20);
|
|
assert.equal(alert.acknowledged, false);
|
|
assert.ok(alert.dedupKey.startsWith('informed_buy:NVDA'));
|
|
assert.ok(alert.createdAt.length > 0);
|
|
assert.ok(alert.payload);
|
|
});
|
|
|
|
test('createAlert includes eventId in dedupKey and payload', () => {
|
|
const alert = createAlert('id-2', 'user-1', 'informed_sell', 'AAPL',
|
|
'details', 'evt-456');
|
|
assert.ok(alert.dedupKey.endsWith(':evt-456'));
|
|
assert.equal(alert.payload.eventId, 'evt-456');
|
|
});
|
|
|
|
test('createAlert with details appends to description', () => {
|
|
const alert = createAlert('id-3', 'user-1', 'new_13da', 'TSLA',
|
|
'Additional context about the filing.');
|
|
assert.ok(alert.description.includes('Additional context about the filing.'));
|
|
});
|
|
|
|
// ─── DedupStore tests ────────────────────────────────────────────────────────
|
|
|
|
test('DedupStore tracks seen keys', () => {
|
|
const store = new DedupStore();
|
|
assert.ok(!store.isDuplicate('test-key'));
|
|
store.mark('test-key');
|
|
assert.ok(store.isDuplicate('test-key'));
|
|
});
|
|
|
|
test('DedupStore can be reset', () => {
|
|
const store = new DedupStore();
|
|
store.mark('key-1');
|
|
assert.ok(store.isDuplicate('key-1'));
|
|
store.reset();
|
|
assert.ok(!store.isDuplicate('key-1'));
|
|
});
|
|
|
|
// ─── AlertEngine tests ───────────────────────────────────────────────────────
|
|
|
|
test('AlertEngine.fireAndForget creates alert on first call, dedups on second',
|
|
async () => {
|
|
const persisted: Alert[] = [];
|
|
const engine = new AlertEngine({
|
|
dedup: new DedupStore(),
|
|
poll: async () => [],
|
|
persist: async (a) => { persisted.push(a); return a; },
|
|
listAlerts: async () => [],
|
|
acknowledge: async () => true,
|
|
});
|
|
|
|
const first = await engine.fireAndForget('informed_buy', 'user-1', 'NVDA',
|
|
undefined, 'filing-1');
|
|
assert.ok(first !== null, 'First call should create alert');
|
|
assert.equal(persisted.length, 1);
|
|
|
|
const second = await engine.fireAndForget('informed_buy', 'user-1', 'NVDA',
|
|
undefined, 'filing-1');
|
|
assert.equal(second, null, 'Duplicate should return null');
|
|
assert.equal(persisted.length, 1, 'Should not persist duplicate');
|
|
});
|
|
|
|
test('AlertEngine.fireAndForget different eventIds are not duplicates',
|
|
async () => {
|
|
const persisted: Alert[] = [];
|
|
const engine = new AlertEngine({
|
|
dedup: new DedupStore(),
|
|
poll: async () => [],
|
|
persist: async (a) => { persisted.push(a); return a; },
|
|
listAlerts: async () => [],
|
|
acknowledge: async () => true,
|
|
});
|
|
|
|
const first = await engine.fireAndForget('informed_buy', 'user-1', 'NVDA',
|
|
undefined, 'filing-1');
|
|
assert.ok(first !== null);
|
|
const second = await engine.fireAndForget('informed_buy', 'user-1', 'NVDA',
|
|
undefined, 'filing-2');
|
|
assert.ok(second !== null, 'Different eventId should not be duplicate');
|
|
assert.equal(persisted.length, 2);
|
|
});
|
|
|
|
test('AlertEngine.pollCycle dedupes within same cycle', async () => {
|
|
// If the poll function returns two alerts with the same dedup key,
|
|
// only the first should be persisted.
|
|
const persisted: Alert[] = [];
|
|
const engine = new AlertEngine({
|
|
dedup: new DedupStore(),
|
|
poll: async () => {
|
|
const a = createAlert('dup-a', 'user-1', 'asymmetry_warning', 'NVDA');
|
|
const b = createAlert('dup-b', 'user-1', 'asymmetry_warning', 'NVDA');
|
|
return [a, b];
|
|
},
|
|
persist: async (a) => { persisted.push(a); return a; },
|
|
listAlerts: async () => [],
|
|
acknowledge: async () => true,
|
|
});
|
|
|
|
const created = await engine.pollCycle();
|
|
assert.equal(created.length, 1, 'Only one of the duplicates should be created');
|
|
assert.equal(persisted.length, 1, 'Only one should be persisted');
|
|
});
|
|
|
|
test('AlertEngine.pollCycle skips already-known dedup keys', async () => {
|
|
// If a dedup key was already marked (e.g. from a prior event-driven fire),
|
|
// the poll cycle should skip it.
|
|
const persisted: Alert[] = [];
|
|
const dedupStore = new DedupStore();
|
|
dedupStore.mark('asymmetry_warning:NVDA'); // already seen
|
|
|
|
const engine = new AlertEngine({
|
|
dedup: dedupStore,
|
|
poll: async () => [
|
|
createAlert('poll-1', 'user-1', 'asymmetry_warning', 'NVDA'),
|
|
],
|
|
persist: async (a) => { persisted.push(a); return a; },
|
|
listAlerts: async () => [],
|
|
acknowledge: async () => true,
|
|
});
|
|
|
|
const created = await engine.pollCycle();
|
|
assert.equal(created.length, 0, 'Known dedup keys should be skipped');
|
|
assert.equal(persisted.length, 0, 'Nothing should be persisted');
|
|
});
|
|
|
|
test('AlertEngine start/stop polling', () => {
|
|
const engine = new AlertEngine({
|
|
dedup: new DedupStore(),
|
|
poll: async () => [],
|
|
persist: async (a) => a,
|
|
listAlerts: async () => [],
|
|
acknowledge: async () => true,
|
|
}, 1000);
|
|
|
|
engine.start();
|
|
// Should be idempotent
|
|
engine.start();
|
|
engine.stop();
|
|
// Should be safe to stop twice
|
|
engine.stop();
|
|
});
|
|
|
|
// ─── Throttle tests ──────────────────────────────────────────────────────────
|
|
|
|
test('ALERT_THROTTLE defines limits for all alert types', () => {
|
|
const types: AlertType[] = [
|
|
'informed_buy', 'informed_sell', 'new_13da', 'rotation_incipient',
|
|
'regime_shift', 'conviction_unlock', 'thesis_broken', 'thesis_weakening',
|
|
'cluster_breach', 'drawdown_halt', 'asymmetry_warning',
|
|
];
|
|
for (const type of types) {
|
|
assert.ok(ALERT_THROTTLE[type], `Throttle config exists for ${type}`);
|
|
assert.ok(ALERT_THROTTLE[type].maxPerHour > 0,
|
|
`maxPerHour > 0 for ${type}`);
|
|
}
|
|
});
|
|
|
|
test('Critical alerts have stricter throttle', () => {
|
|
assert.equal(ALERT_THROTTLE['drawdown_halt'].maxPerHour, 1);
|
|
assert.equal(ALERT_THROTTLE['regime_shift'].maxPerHour, 1);
|
|
assert.equal(ALERT_THROTTLE['conviction_unlock'].maxPerHour, 1);
|
|
});
|