// Investor Flow — AlertEngine (Slice 17): hybrid event-driven + polling alert system. // // ADR-0007: Alert text says "something changed" not "action needed". Never // imperative (no "buy" / "sell" / "cut" / "trim"). Every alert is a // notification of a change in state, not a recommendation to act. // // Pure/cache-deterministic core: no I/O in the pure functions below. // Database reads happen in the tRPC layer, not here. // ─── Alert Types ───────────────────────────────────────────────────────────── export type AlertType = | 'informed_buy' | 'informed_sell' | 'new_13da' | 'rotation_incipient' | 'regime_shift' | 'conviction_unlock' | 'thesis_broken' | 'thesis_weakening' | 'cluster_breach' | 'drawdown_halt' | 'asymmetry_warning' | 'fund_capture' | 'fund_13f' | 'mirror_diff' | 'vix_level'; // ─── Alert Severity ────────────────────────────────────────────────────────── export type AlertSeverity = 'info' | 'warning' | 'critical'; // ─── Alert Payload ─────────────────────────────────────────────────────────── export interface Alert { id: string; userId: string; type: AlertType; severity: AlertSeverity; title: string; description: string; symbol?: string; createdAt: string; acknowledged: boolean; dedupKey: string; payload: Record; } // ─── Alert Dedup Store ─────────────────────────────────────────────────────── export class DedupStore { private seen = new Set(); isDuplicate(key: string): boolean { return this.seen.has(key); } mark(key: string): void { this.seen.add(key); } reset(): void { this.seen.clear(); } } // ─── Alert Severity Mapping ────────────────────────────────────────────────── export function defaultSeverity(type: AlertType): AlertSeverity { switch (type) { case 'drawdown_halt': return 'critical'; case 'thesis_broken': case 'cluster_breach': case 'asymmetry_warning': return 'warning'; case 'informed_buy': case 'informed_sell': case 'new_13da': case 'rotation_incipient': case 'regime_shift': case 'conviction_unlock': case 'thesis_weakening': case 'fund_capture': case 'fund_13f': case 'mirror_diff': case 'vix_level': return 'info'; } } // ─── Alert Title / Description Builders ────────────────────────────────────── function symbolTag(symbol?: string): string { return symbol ? ` for ${symbol}` : ''; } export function alertTitle(type: AlertType, symbol?: string): string { switch (type) { case 'informed_buy': return `Insider bought${symbolTag(symbol)}`; case 'informed_sell': return `Insider sold${symbolTag(symbol)}`; case 'new_13da': return `New institutional position${symbolTag(symbol)}`; case 'rotation_incipient': return `Sector rotation signal detected`; case 'regime_shift': return `Market regime changed`; case 'conviction_unlock': return `Conviction tier unlocked`; case 'thesis_broken': return `Thesis invalidation criteria met${symbolTag(symbol)}`; case 'thesis_weakening': return `Thesis showing signs of weakening${symbolTag(symbol)}`; case 'cluster_breach': return `Cluster exposure limit reached${symbolTag(symbol)}`; case 'drawdown_halt': return `Drawdown tolerance breached`; case 'asymmetry_warning': return `Portfolio asymmetry below threshold`; case 'fund_capture': return `New position update from tracked fund${symbolTag(symbol)}`; case 'fund_13f': return `New 13F from tracked fund`; case 'mirror_diff': return `Mirror target changed${symbolTag(symbol)}`; case 'vix_level': return `Volatility index level changed`; } } export function alertDescription(type: AlertType, details?: string, symbol?: string): string { const base = (() => { switch (type) { case 'informed_buy': return `A company insider purchased shares${symbolTag(symbol)}. This filing was not part of a 10b5-1 trading plan.`; case 'informed_sell': return `A company insider sold shares${symbolTag(symbol)}. This filing was not part of a 10b5-1 trading plan.`; case 'new_13da': return `An institutional investor reported a new position${symbolTag(symbol)}.`; case 'rotation_incipient': return `The sector rotation detector identified an incipient rotation signal. Capital may be moving between sectors.`; case 'regime_shift': return `The market regime has changed. This affects portfolio-level risk assessments.`; case 'conviction_unlock': return `A new conviction tier is now available based on your trading history.`; case 'thesis_broken': return `The invalidation criteria for your thesis${symbolTag(symbol)} have been met. Consider reviewing your thesis.`; case 'thesis_weakening': return `Some signals suggest your thesis${symbolTag(symbol)} may be weakening, but invalidation criteria are not yet met.`; case 'cluster_breach': return `Your exposure in this cluster has exceeded the recommended cap${symbolTag(symbol)}.`; case 'drawdown_halt': return `Your portfolio drawdown has exceeded the tolerance threshold. The circuit breaker has paused new entries for 24 hours. Existing positions continue unaffected.`; case 'asymmetry_warning': return `Your portfolio's reward-to-risk ratio has fallen below 1.0, meaning risk outweighs expected reward across your positions.`; case 'fund_capture': return `A tracked fund posted a position update${symbolTag(symbol)}. This is a disclosure, not advice.`; case 'fund_13f': return `A tracked fund filed a new 13F. This is a disclosure, not advice.`; case 'mirror_diff': return `The mirror target changed${symbolTag(symbol)}. Showing the arithmetic delta; it is not advice.`; case 'vix_level': return `The VIX, a market-wide measure of expected near-term volatility, has moved into a new level that historically mattered to market participants.`; } })(); if (details) { return `${base}\n\n${details}`; } return base; } // ─── Dedup Key Builder ─────────────────────────────────────────────────────── export function buildDedupKey( type: AlertType, symbol?: string, eventId?: string, ): string { const parts: string[] = [type]; if (symbol) parts.push(symbol); if (eventId) parts.push(eventId); return parts.join(':'); } // ─── Alert Factory ─────────────────────────────────────────────────────────── export function createAlert( id: string, userId: string, type: AlertType, symbol?: string, details?: string, eventId?: string, extraPayload?: Record, ): Alert { return { id, userId, type, severity: defaultSeverity(type), title: alertTitle(type, symbol), description: alertDescription(type, details, symbol), symbol, createdAt: new Date().toISOString(), acknowledged: false, dedupKey: buildDedupKey(type, symbol, eventId), payload: { ...extraPayload, ...(eventId ? { eventId } : {}), }, }; } // ─── AlertEngine ───────────────────────────────────────────────────────────── export interface AlertEngineDeps { dedup: DedupStore; poll: () => Promise; persist: (alert: Alert) => Promise; listAlerts: (userId: string, limit?: number) => Promise; acknowledge: (alertId: string, userId: string) => Promise; } export class AlertEngine { private deps: AlertEngineDeps; private pollingIntervalMs: number; private pollTimer: ReturnType | null = null; constructor(deps: AlertEngineDeps, pollingIntervalMs = 5 * 60 * 1000) { this.deps = deps; this.pollingIntervalMs = pollingIntervalMs; } async fireAndForget( type: AlertType, userId: string, symbol?: string, details?: string, eventId?: string, extraPayload?: Record, ): Promise { const dedupKey = buildDedupKey(type, symbol, eventId); if (this.deps.dedup.isDuplicate(dedupKey)) return null; const id = crypto.randomUUID(); const alert = createAlert(id, userId, type, symbol, details, eventId, extraPayload); this.deps.dedup.mark(dedupKey); await this.deps.persist(alert); return alert; } async pollCycle(): Promise { const candidates = await this.deps.poll(); const created: Alert[] = []; for (const candidate of candidates) { if (!this.deps.dedup.isDuplicate(candidate.dedupKey)) { this.deps.dedup.mark(candidate.dedupKey); await this.deps.persist(candidate); created.push(candidate); } } return created; } start(): void { if (this.pollTimer) return; this.pollTimer = setInterval(() => { this.pollCycle().catch((err) => { console.error('[AlertEngine] poll cycle failed:', err); }); }, this.pollingIntervalMs); } stop(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } async listAlerts(userId: string, limit?: number): Promise { return this.deps.listAlerts(userId, limit); } async acknowledge(alertId: string, userId: string): Promise { return this.deps.acknowledge(alertId, userId); } } // ─── Throttle Configuration ────────────────────────────────────────────────── export const ALERT_THROTTLE: Record = { informed_buy: { maxPerHour: 5 }, informed_sell: { maxPerHour: 5 }, new_13da: { maxPerHour: 3 }, rotation_incipient: { maxPerHour: 2 }, regime_shift: { maxPerHour: 1 }, conviction_unlock: { maxPerHour: 1 }, thesis_broken: { maxPerHour: 3 }, thesis_weakening: { maxPerHour: 3 }, cluster_breach: { maxPerHour: 2 }, drawdown_halt: { maxPerHour: 1 }, asymmetry_warning: { maxPerHour: 2 }, fund_capture: { maxPerHour: 3 }, fund_13f: { maxPerHour: 3 }, mirror_diff: { maxPerHour: 3 }, vix_level: { maxPerHour: 1 }, };