CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
// 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<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':
|
|
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<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 },
|
|
fund_capture: { maxPerHour: 3 },
|
|
fund_13f: { maxPerHour: 3 },
|
|
mirror_diff: { maxPerHour: 3 },
|
|
vix_level: { maxPerHour: 1 },
|
|
};
|