fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- 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
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+283
View File
@@ -0,0 +1,283 @@
// 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';
// ─── 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':
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`;
}
}
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)} in a 13F filing.`;
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.`;
}
})();
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 },
};