2026-07-23 18:02:24 -04:00
// 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'
2026-08-10 13:36:26 -04:00
| 'asymmetry_warning'
| 'fund_capture'
| 'fund_13f'
| 'mirror_diff'
| 'vix_level' ;
2026-07-23 18:02:24 -04:00
// ─── 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 < string , unknown >;
}
// ─── Alert Dedup Store ───────────────────────────────────────────────────────
export class DedupStore {
private seen = new Set < string >();
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' :
2026-08-10 13:36:26 -04:00
case 'fund_capture' :
case 'fund_13f' :
case 'mirror_diff' :
case 'vix_level' :
2026-07-23 18:02:24 -04:00
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` ;
2026-08-10 13:36:26 -04:00
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` ;
2026-07-23 18:02:24 -04:00
}
}
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' :
2026-08-10 13:36:26 -04:00
return `An institutional investor reported a new position ${ symbolTag ( symbol ) } .` ;
2026-07-23 18:02:24 -04:00
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.` ;
2026-08-10 13:36:26 -04:00
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.` ;
2026-07-23 18:02:24 -04:00
}
})();
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 < string , unknown >,
) : 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 < Alert [] >;
persist : ( alert : Alert ) => Promise < Alert >;
listAlerts : ( userId : string , limit? : number ) => Promise < Alert [] >;
acknowledge : ( alertId : string , userId : string ) => Promise < boolean >;
}
export class AlertEngine {
private deps : AlertEngineDeps ;
private pollingIntervalMs : number ;
private pollTimer : ReturnType < typeof setInterval > | 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 < string , unknown >,
) : Promise < Alert | null > {
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 < Alert [] > {
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 < Alert [] > {
return this . deps . listAlerts ( userId , limit );
}
async acknowledge ( alertId : string , userId : string ) : Promise < boolean > {
return this . deps . acknowledge ( alertId , userId );
}
}
// ─── Throttle Configuration ──────────────────────────────────────────────────
export const ALERT_THROTTLE : Record < AlertType , { maxPerHour : number }> = {
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 },
2026-08-10 13:36:26 -04:00
fund_capture : { maxPerHour : 3 },
fund_13f : { maxPerHour : 3 },
mirror_diff : { maxPerHour : 3 },
vix_level : { maxPerHour : 1 },
2026-07-23 18:02:24 -04:00
};