Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
UI: - /alerts page: event history with acknowledge, subscription create/manage with toggle - /admin/smtp: SMTP config form (host, port, auth, test) - Watchlist sidebar: dropdown selector for multiple watchlists, create/delete - Sidebar: alerts count badge, SMTP link under admin - Mobile tab nav: alerts tab added - Client trpc.ts: all new API methods + types Backend: - watchlists.listByWatchlist procedure + listSymbolsByWatchlist repo fn - yfinance min-interval 1500->2000ms to reduce Edge 429s - Fixed e.date.slice error in yfinance-adjustments with typeof guard - Removed defunct BITF from watchlist+queue - Cleared 83 failed + 12 backoff queue jobs Docs: - FUNCTIONAL_DESIGN.md: alerts + multiple watchlists + SMTP documented - TECH_DESIGN.md: new modules, tRPC procs, routes updated
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import type { AlertProducer, ProducerFrequency } from './types.ts';
|
||||
|
||||
export type { AlertProducer, ProducerFrequency } from './types.ts';
|
||||
|
||||
const producers: AlertProducer[] = [];
|
||||
|
||||
/** Register a producer (called once at startup). */
|
||||
export function registerProducer(p: AlertProducer): void {
|
||||
producers.push(p);
|
||||
}
|
||||
|
||||
/** Run all producers for a given frequency tier. */
|
||||
export async function runProducers(
|
||||
db: DatabaseSync,
|
||||
frequency: ProducerFrequency,
|
||||
): Promise<Alert[]> {
|
||||
const results: Alert[] = [];
|
||||
const tier = producers.filter((p) => p.frequency === frequency);
|
||||
|
||||
for (const producer of tier) {
|
||||
try {
|
||||
const alerts = await producer.check(db);
|
||||
results.push(...alerts);
|
||||
} catch (err) {
|
||||
console.error(`[alert:${producer.alertType}] check failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Get all registered producer types. */
|
||||
export function registeredAlertTypes(): string[] {
|
||||
return producers.map((p) => p.alertType);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
||||
|
||||
interface InsiderTx {
|
||||
symbol: string;
|
||||
tx_date: string;
|
||||
classification: string;
|
||||
shares: number;
|
||||
}
|
||||
|
||||
function createProducer(alertType: 'informed_buy' | 'informed_sell', classification: string): AlertProducer {
|
||||
return {
|
||||
alertType,
|
||||
frequency: 'batched' as const,
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Find all symbols with new insider transactions.
|
||||
const symbols = db.prepare(
|
||||
`SELECT DISTINCT symbol FROM insider_transactions WHERE classification = ?`,
|
||||
).all(classification) as { symbol: string }[];
|
||||
|
||||
for (const { symbol } of symbols) {
|
||||
const prev = readComparisonState(db, symbol, alertType);
|
||||
const prevState = prev ? JSON.parse(prev.state) : { lastTxDate: null, lastSeenIds: [] as string[] };
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT symbol, tx_date, classification, shares
|
||||
FROM insider_transactions
|
||||
WHERE symbol = ? AND classification = ?
|
||||
ORDER BY tx_date DESC
|
||||
LIMIT 10`,
|
||||
).all(symbol, classification) as unknown as InsiderTx[];
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const latestDate = rows[0].tx_date;
|
||||
const newIds = rows.map((r) => `${r.tx_date}:${r.shares}`);
|
||||
const prevIds = new Set<string>(prevState.lastSeenIds as string[]);
|
||||
|
||||
if (prevState.lastTxDate === latestDate && newIds.every((id) => prevIds.has(id))) {
|
||||
writeComparisonState(db, symbol, alertType, { lastTxDate: latestDate, lastSeenIds: newIds });
|
||||
continue;
|
||||
}
|
||||
|
||||
const newRows = prevState.lastTxDate === null
|
||||
? rows
|
||||
: rows.filter((r) => r.tx_date > prevState.lastTxDate || (r.tx_date === prevState.lastTxDate && !prevIds.has(`${r.tx_date}:${r.shares}`)));
|
||||
|
||||
for (const row of newRows) {
|
||||
const users = getSubscribedUsers(db, alertType, symbol);
|
||||
for (const userId of users) {
|
||||
const eventId = `${symbol}:${row.tx_date}:${row.shares}`;
|
||||
const dedupKey = `${alertType}:${userId}:${eventId}`;
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description = classification === 'P'
|
||||
? `An insider purchased ${row.shares} shares on ${row.tx_date}.`
|
||||
: `An insider sold ${Math.abs(row.shares)} shares on ${row.tx_date}.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
alertType,
|
||||
symbol,
|
||||
description,
|
||||
eventId,
|
||||
{ shares: row.shares, txDate: row.tx_date },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
writeComparisonState(db, symbol, alertType, { lastTxDate: latestDate, lastSeenIds: newIds });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const informedBuyProducer = createProducer('informed_buy', 'P');
|
||||
export const informedSellProducer = createProducer('informed_sell', 'S');
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
||||
|
||||
export const new13daProducer: AlertProducer = {
|
||||
alertType: 'new_13da',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
const symbols = db.prepare(
|
||||
`SELECT DISTINCT symbol FROM institution_filings WHERE form LIKE '13D%' OR form LIKE '13G%'`,
|
||||
).all() as { symbol: string }[];
|
||||
|
||||
for (const { symbol } of symbols) {
|
||||
const prev = readComparisonState(db, symbol, 'new_13da');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { latestAccession: null };
|
||||
|
||||
const row = db.prepare(
|
||||
`SELECT accession, reported_quarter, filer_name, put_call
|
||||
FROM institution_filings
|
||||
WHERE symbol = ? AND (form LIKE '13D%' OR form LIKE '13G%')
|
||||
ORDER BY reported_quarter DESC
|
||||
LIMIT 1`,
|
||||
).get(symbol) as { accession: string; reported_quarter: string; filer_name: string; put_call: string | null } | undefined;
|
||||
|
||||
if (!row) continue;
|
||||
|
||||
if (row.accession === prevState.latestAccession) {
|
||||
writeComparisonState(db, symbol, 'new_13da', { latestAccession: row.accession });
|
||||
continue;
|
||||
}
|
||||
|
||||
const users = getSubscribedUsers(db, 'new_13da', symbol);
|
||||
for (const userId of users) {
|
||||
const dedupKey = `new_13da:${userId}:${row.accession}`;
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'new_13da',
|
||||
symbol,
|
||||
`${row.filer_name} reported a new position${row.put_call ? ` (${row.put_call})` : ''} for ${row.reported_quarter}.`,
|
||||
row.accession,
|
||||
{ filer: row.filer_name, quarter: row.reported_quarter, putCall: row.put_call },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
writeComparisonState(db, symbol, 'new_13da', { latestAccession: row.accession });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
|
||||
export type ProducerFrequency = 'per-fetch' | 'batched';
|
||||
|
||||
export interface AlertProducer {
|
||||
readonly alertType: string;
|
||||
readonly frequency: ProducerFrequency;
|
||||
check(db: DatabaseSync): Promise<Alert[]>;
|
||||
}
|
||||
|
||||
export interface ComparisonState {
|
||||
symbol: string;
|
||||
alertType: string;
|
||||
state: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function readComparisonState(
|
||||
db: DatabaseSync,
|
||||
symbol: string,
|
||||
alertType: string,
|
||||
): ComparisonState | null {
|
||||
const row = db.prepare(
|
||||
`SELECT symbol, alert_type, state, updated_at
|
||||
FROM alert_comparison_state
|
||||
WHERE symbol = ? AND alert_type = ?`,
|
||||
).get(symbol, alertType) as Record<string, unknown> | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
symbol: row.symbol as string,
|
||||
alertType: row.alert_type as string,
|
||||
state: row.state as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeComparisonState(
|
||||
db: DatabaseSync,
|
||||
symbol: string,
|
||||
alertType: string,
|
||||
state: Record<string, unknown>,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO alert_comparison_state (symbol, alert_type, state, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(symbol, alertType, JSON.stringify(state), new Date().toISOString());
|
||||
}
|
||||
|
||||
export function getSubscribedUsers(
|
||||
db: DatabaseSync,
|
||||
alertType: string,
|
||||
symbol: string,
|
||||
): string[] {
|
||||
// Ticker-level subscriptions first.
|
||||
const tickerRows = db.prepare(
|
||||
`SELECT owner_id FROM alerts
|
||||
WHERE alert_type = ? AND symbol = ? AND enabled = 1`,
|
||||
).all(alertType, symbol) as { owner_id: string }[];
|
||||
|
||||
// Also find users with global subscriptions.
|
||||
const globalRows = db.prepare(
|
||||
`SELECT owner_id FROM alerts
|
||||
WHERE alert_type = ? AND symbol IS NULL AND watchlist_id IS NULL AND enabled = 1`,
|
||||
).all(alertType) as { owner_id: string }[];
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const r of tickerRows) userIds.add(r.owner_id);
|
||||
for (const r of globalRows) userIds.add(r.owner_id);
|
||||
return Array.from(userIds);
|
||||
}
|
||||
|
||||
export function persistAlert(db: DatabaseSync, alert: Alert): void {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO alert_events
|
||||
(id, user_id, type, severity, title, description, symbol, created_at, acknowledged, dedup_key, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
).run(
|
||||
alert.id,
|
||||
alert.userId,
|
||||
alert.type,
|
||||
alert.severity,
|
||||
alert.title,
|
||||
alert.description,
|
||||
alert.symbol ?? null,
|
||||
alert.createdAt,
|
||||
alert.dedupKey,
|
||||
JSON.stringify(alert.payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function isDuplicate(db: DatabaseSync, dedupKey: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT 1 FROM alert_events WHERE dedup_key = ? LIMIT 1',
|
||||
).get(dedupKey) as Record<string, unknown> | undefined;
|
||||
return !!row;
|
||||
}
|
||||
Reference in New Issue
Block a user