feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
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
This commit is contained in:
@@ -20,7 +20,11 @@ export type AlertType =
|
||||
| 'thesis_weakening'
|
||||
| 'cluster_breach'
|
||||
| 'drawdown_halt'
|
||||
| 'asymmetry_warning';
|
||||
| 'asymmetry_warning'
|
||||
| 'fund_capture'
|
||||
| 'fund_13f'
|
||||
| 'mirror_diff'
|
||||
| 'vix_level';
|
||||
|
||||
// ─── Alert Severity ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,6 +81,10 @@ export function defaultSeverity(type: AlertType): AlertSeverity {
|
||||
case 'regime_shift':
|
||||
case 'conviction_unlock':
|
||||
case 'thesis_weakening':
|
||||
case 'fund_capture':
|
||||
case 'fund_13f':
|
||||
case 'mirror_diff':
|
||||
case 'vix_level':
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
@@ -111,6 +119,14 @@ export function alertTitle(type: AlertType, symbol?: string): string {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +138,7 @@ export function alertDescription(type: AlertType, details?: string, symbol?: str
|
||||
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.`;
|
||||
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':
|
||||
@@ -139,6 +155,14 @@ export function alertDescription(type: AlertType, details?: string, symbol?: str
|
||||
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.`;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -280,4 +304,8 @@ export const ALERT_THROTTLE: Record<AlertType, { maxPerHour: number }> = {
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ test('alertDescription never contains trade verbs', () => {
|
||||
const types: AlertType[] = [
|
||||
'informed_buy', 'informed_sell', 'new_13da', 'rotation_incipient',
|
||||
'regime_shift', 'conviction_unlock', 'thesis_broken', 'thesis_weakening',
|
||||
'cluster_breach', 'drawdown_halt', 'asymmetry_warning',
|
||||
'cluster_breach', 'drawdown_halt', 'asymmetry_warning', 'vix_level',
|
||||
];
|
||||
const forbidden = ['buy ', 'sell ', 'cut ', 'trim ', 'buy.', 'sell.', 'cut.', 'trim.'];
|
||||
for (const type of types) {
|
||||
@@ -229,7 +229,7 @@ test('ALERT_THROTTLE defines limits for all alert types', () => {
|
||||
const types: AlertType[] = [
|
||||
'informed_buy', 'informed_sell', 'new_13da', 'rotation_incipient',
|
||||
'regime_shift', 'conviction_unlock', 'thesis_broken', 'thesis_weakening',
|
||||
'cluster_breach', 'drawdown_halt', 'asymmetry_warning',
|
||||
'cluster_breach', 'drawdown_halt', 'asymmetry_warning', 'vix_level',
|
||||
];
|
||||
for (const type of types) {
|
||||
assert.ok(ALERT_THROTTLE[type], `Throttle config exists for ${type}`);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Mirror Portfolio (M21) — alert producers tests (sub-task 6).
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { fundCaptureProducer, fund13FRecordProducer, mirrorDiffProducer } from '../producers/mirrorAlertProducers.ts';
|
||||
import { upsertTrackedFund, insertPositionRecord } from '../../db/fundRepository.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
|
||||
db.exec(SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function subscribe(db: DatabaseSync, userId: string, type: string, symbol: string | null) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO users (id, email, pw_hash, created_at)
|
||||
VALUES (?, ?, '', ?)`,
|
||||
).run(userId, `${userId}@t.local`, new Date().toISOString());
|
||||
db.prepare(
|
||||
`INSERT INTO alerts (id, owner_id, symbol, alert_type, enabled, params, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, '{}', ?)`,
|
||||
).run(`sub-${userId}-${type}-${symbol ?? 'x'}`, userId, symbol, type, new Date().toISOString());
|
||||
}
|
||||
|
||||
const hasAdvice = (s: string) => /you should\b|^\s*(buy|sell)\s/i.test(s);
|
||||
|
||||
test('fund_capture: fires once per subscribed user, dedupes, mechanical framing', async () => {
|
||||
const db = freshDb();
|
||||
const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' });
|
||||
subscribe(db, 'u1', 'fund_capture', 'IREN');
|
||||
subscribe(db, 'u2', 'fund_capture', 'IREN');
|
||||
|
||||
insertPositionRecord(db, {
|
||||
fund_id: fund.id, symbol: 'IREN', shares: 750000, cost_basis: 22.4,
|
||||
as_of: '2026-02-10', source: 'capture', evidence_url: 'https://x.com/mikealfred/status/1',
|
||||
});
|
||||
|
||||
const first = await fundCaptureProducer.check(db);
|
||||
assert.equal(first.length, 2);
|
||||
for (const a of first) assert.ok(!hasAdvice(a.description), a.description);
|
||||
assert.match(first[0].description, /shares/);
|
||||
|
||||
const second = await fundCaptureProducer.check(db);
|
||||
assert.equal(second.length, 0); // no new post → no re-fire
|
||||
});
|
||||
|
||||
test('mirror_diff: fires on a book change, mechanical framing, dedupes', async () => {
|
||||
const db = freshDb();
|
||||
const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Fund X', manager_name: 'M' });
|
||||
subscribe(db, 'u1', 'mirror_diff', 'IREN');
|
||||
insertPositionRecord(db, { fund_id: fund.id, symbol: 'IREN', shares: 1000, as_of: '2026-02-01', source: 'capture', evidence_url: 'p1' });
|
||||
const out = await mirrorDiffProducer.check(db);
|
||||
assert.equal(out.length, 1);
|
||||
assert.match(out[0].description, /delta/i);
|
||||
assert.ok(!hasAdvice(out[0].description), out[0].description);
|
||||
assert.equal((await mirrorDiffProducer.check(db)).length, 0);
|
||||
});
|
||||
|
||||
test('fund_13f: fires on a new quarter for the tracked fund CIK', async () => {
|
||||
const db = freshDb();
|
||||
upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Fund Coin', manager_name: 'A' });
|
||||
subscribe(db, 'u1', 'fund_13f', 'IREN');
|
||||
db.prepare(
|
||||
`INSERT INTO institution_filings (filer_cik, filer_name, symbol, form, shares, reported_quarter, filed_at, fetched_at, accession)
|
||||
VALUES ('0002096493','Fund Coin','IREN','13F-HR',100,'2026-Q1','2026-02-15','2026-02-15','ACC-1')`,
|
||||
).run();
|
||||
const out = await fund13FRecordProducer.check(db);
|
||||
assert.equal(out.length, 1);
|
||||
assert.match(out[0].description, /13F/i);
|
||||
assert.equal((await fund13FRecordProducer.check(db)).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// new_13da producer regression tests — institutional 13D/13G filing alerts.
|
||||
//
|
||||
// Guards the "never broadcast to non-subscribers" rule: a new 13D/13G filing for a
|
||||
// symbol must only alert users who explicitly subscribed (ticker-level or global).
|
||||
// Regression for: alert fired for CDNS/HOOD with zero subscriptions because the
|
||||
// producer fell back to "all users" when getSubscribedUsers returned empty.
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { new13daProducer } from '../producers/new13daProducer.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
|
||||
db.exec(SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function addUser(db: DatabaseSync, id: string) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO users (id, email, pw_hash, created_at)
|
||||
VALUES (?, ?, '', ?)`,
|
||||
).run(id, `${id}@t.local`, new Date().toISOString());
|
||||
}
|
||||
|
||||
function subscribe(db: DatabaseSync, userId: string, symbol: string | null) {
|
||||
db.prepare(
|
||||
`INSERT INTO alerts (id, owner_id, symbol, alert_type, enabled, params, created_at)
|
||||
VALUES (?, ?, ?, 'new_13da', 1, '{}', ?)`,
|
||||
).run(`sub-${userId}-${symbol ?? 'x'}`, userId, symbol, new Date().toISOString());
|
||||
}
|
||||
|
||||
function seedFiling(db: DatabaseSync, symbol: string, accession: string, quarter: string, filedAt = new Date().toISOString().slice(0, 10)) {
|
||||
db.prepare(
|
||||
`INSERT INTO institution_filings
|
||||
(filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, accession, fetched_at)
|
||||
VALUES (?, ?, ?, '13D', NULL, NULL, ?, ?, ?, ?)`,
|
||||
).run('0009999999', 'The Vanguard Group', symbol, quarter, filedAt, accession, filedAt);
|
||||
}
|
||||
|
||||
test('new_13da: zero subscriptions → NO alert (no broadcast to all users)', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
addUser(db, 'u2');
|
||||
seedFiling(db, 'CDNS', 'acc-1', '2026-Q1');
|
||||
|
||||
const out = await new13daProducer.check(db);
|
||||
assert.equal(out.length, 0, 'no subscribers → no alerts, regardless of users present');
|
||||
const events = db.prepare('SELECT COUNT(*) AS n FROM alert_events WHERE type = ?').get('new_13da') as { n: number };
|
||||
assert.equal(events.n, 0);
|
||||
});
|
||||
|
||||
test('new_13da: ticker-level subscriber gets the alert, other users do not', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
addUser(db, 'u2');
|
||||
subscribe(db, 'u1', 'CDNS');
|
||||
seedFiling(db, 'CDNS', 'acc-1', '2026-Q1');
|
||||
|
||||
const out = await new13daProducer.check(db);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].userId, 'u1');
|
||||
assert.equal(out[0].symbol, 'CDNS');
|
||||
assert.match(out[0].description, /2026-Q1/);
|
||||
|
||||
const recipients = db.prepare('SELECT DISTINCT user_id FROM alert_events').all() as Array<{ user_id: string }>;
|
||||
assert.deepEqual(recipients.map((r) => r.user_id).sort(), ['u1']);
|
||||
});
|
||||
|
||||
test('new_13da: global subscriber (symbol IS NULL) gets alerts for any symbol', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
addUser(db, 'u2');
|
||||
subscribe(db, 'u1', null); // global subscription
|
||||
seedFiling(db, 'HOOD', 'acc-2', '2026-Q1');
|
||||
|
||||
const out = await new13daProducer.check(db);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].userId, 'u1');
|
||||
});
|
||||
|
||||
test('new_13da: same accession never re-alerts (comparison state + dedup)', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
subscribe(db, 'u1', 'CDNS');
|
||||
seedFiling(db, 'CDNS', 'acc-1', '2026-Q1');
|
||||
|
||||
const first = await new13daProducer.check(db);
|
||||
assert.equal(first.length, 1);
|
||||
const second = await new13daProducer.check(db);
|
||||
assert.equal(second.length, 0, 'same latest accession → no re-fire');
|
||||
});
|
||||
|
||||
test('new_13da: second filing with newer accession alerts once each', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
subscribe(db, 'u1', 'IREN');
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
|
||||
seedFiling(db, 'IREN', 'acc-a', '2026-Q2', daysAgo(3));
|
||||
const first = await new13daProducer.check(db);
|
||||
assert.equal(first.length, 1);
|
||||
|
||||
seedFiling(db, 'IREN', 'acc-b', '2026-Q2', daysAgo(1));
|
||||
const second = await new13daProducer.check(db);
|
||||
assert.equal(second.length, 1);
|
||||
assert.match(second[0].description, /acc-b|The Vanguard Group/);
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
// Producer tests for the Phase-4.5 alert producers:
|
||||
// rotation_incipient / regime_shift / conviction_unlock / thesis_broken /
|
||||
// thesis_weakening / cluster_breach / drawdown_halt / asymmetry_warning
|
||||
//
|
||||
// Guards the core contracts:
|
||||
// - Watermarks prevent re-firing on repeated checks of the same state.
|
||||
// - First observation of a baseline is silent (regime, unlock, thesis) or
|
||||
// fires once and never again (rotation incipient signals).
|
||||
// - Owners of an unlock/thesis/portfolio are always notified of their own
|
||||
// state even without an explicit subscription.
|
||||
// - Portfolio risk alerts fire on the breach transition, not continuously.
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { rotationIncipientProducer, regimeShiftProducer } from '../producers/rotationProducer.ts';
|
||||
import { convictionUnlockProducer } from '../producers/unlockProducer.ts';
|
||||
import { thesisBrokenProducer, thesisWeakeningProducer } from '../producers/thesisProducer.ts';
|
||||
import { clusterBreachProducer, drawdownHaltProducer, asymmetryWarningProducer } from '../producers/portfolioRiskProducer.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
|
||||
db.exec(SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function addUser(db: DatabaseSync, id: string, opts: { complexity?: string; drawdownTolerance?: number } = {}) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO users (id, email, pw_hash, complexity, drawdown_tolerance, created_at)
|
||||
VALUES (?, ?, '', ?, ?, ?)`,
|
||||
).run(id, `${id}@t.local`, opts.complexity ?? 'beginner', opts.drawdownTolerance ?? 20, new Date().toISOString());
|
||||
}
|
||||
|
||||
function subscribeGlobal(db: DatabaseSync, userId: string, alertType: string) {
|
||||
db.prepare(
|
||||
`INSERT INTO alerts (id, owner_id, symbol, alert_type, enabled, params, created_at)
|
||||
VALUES (?, ?, NULL, ?, 1, '{}', ?)`,
|
||||
).run(`sub-${userId}-${alertType}`, userId, alertType, new Date().toISOString());
|
||||
}
|
||||
|
||||
function alertCount(db: DatabaseSync, type: string): number {
|
||||
const r = db.prepare('SELECT COUNT(*) AS n FROM alert_events WHERE type = ?').get(type) as { n: number };
|
||||
return r.n;
|
||||
}
|
||||
|
||||
function comparisonState(db: DatabaseSync, symbol: string, alertType: string): Record<string, unknown> {
|
||||
const r = db.prepare(
|
||||
'SELECT state FROM alert_comparison_state WHERE symbol = ? AND alert_type = ?',
|
||||
).get(symbol, alertType) as { state: string } | undefined;
|
||||
return r ? JSON.parse(r.state) : {};
|
||||
}
|
||||
|
||||
// ─── rotation_incipient ──────────────────────────────────────────────────────
|
||||
|
||||
function seedRotation(db: DatabaseSync, sector: string, phase: string, detectedAt: string, confidence = 0.8) {
|
||||
db.prepare(
|
||||
`INSERT INTO rotation_signals (id, sector, detected_at, phase, confidence, price_resolved, inst_resolved)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0)`,
|
||||
).run(`${sector}-${detectedAt}`, sector, detectedAt, phase, confidence);
|
||||
}
|
||||
|
||||
test('rotation_incipient: fires for a new accumulation signal, watermarks so it never re-fires', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
subscribeGlobal(db, 'u1', 'rotation_incipient');
|
||||
seedRotation(db, 'XLK', 'accumulation', '2026-04-01T10:00:00Z', 0.85);
|
||||
|
||||
const out1 = await rotationIncipientProducer.check(db);
|
||||
assert.equal(out1.length, 1);
|
||||
assert.equal(out1[0].type, 'rotation_incipient');
|
||||
assert.equal(out1[0].symbol, 'XLK');
|
||||
assert.equal(alertCount(db, 'rotation_incipient'), 1);
|
||||
|
||||
// Second check: same signal → silent.
|
||||
const out2 = await rotationIncipientProducer.check(db);
|
||||
assert.equal(out2.length, 0);
|
||||
assert.equal(alertCount(db, 'rotation_incipient'), 1);
|
||||
|
||||
// New signal on the same sector → fires again.
|
||||
seedRotation(db, 'XLK', 'expansion', '2026-04-02T10:00:00Z');
|
||||
const out3 = await rotationIncipientProducer.check(db);
|
||||
assert.equal(out3.length, 1);
|
||||
assert.equal(alertCount(db, 'rotation_incipient'), 2);
|
||||
});
|
||||
|
||||
test('rotation_incipient: distribution phase is watermarked but never fires', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
subscribeGlobal(db, 'u1', 'rotation_incipient');
|
||||
seedRotation(db, 'XLV', 'distribution', '2026-04-01T10:00:00Z');
|
||||
|
||||
const out = await rotationIncipientProducer.check(db);
|
||||
assert.equal(out.length, 0);
|
||||
assert.equal(alertCount(db, 'rotation_incipient'), 0);
|
||||
assert.ok(comparisonState(db, 'XLV', 'rotation_incipient').lastDetectedAt);
|
||||
});
|
||||
|
||||
// ─── regime_shift ────────────────────────────────────────────────────────────
|
||||
|
||||
function seedRegime(db: DatabaseSync, asOf: string, regime: string) {
|
||||
db.prepare(
|
||||
`INSERT INTO macro_regimes (as_of, regime, signals) VALUES (?, ?, '{}')`,
|
||||
).run(asOf, regime);
|
||||
}
|
||||
|
||||
test('regime_shift: first observation is baseline (silent), change fires once', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
subscribeGlobal(db, 'u1', 'regime_shift');
|
||||
seedRegime(db, '2026-04-01', 'trending_up');
|
||||
|
||||
const out1 = await regimeShiftProducer.check(db);
|
||||
assert.equal(out1.length, 0, 'baseline observation must not page anyone');
|
||||
assert.equal(alertCount(db, 'regime_shift'), 0);
|
||||
|
||||
// Same regime, new as_of → still silent.
|
||||
seedRegime(db, '2026-04-02', 'trending_up');
|
||||
const out2 = await regimeShiftProducer.check(db);
|
||||
assert.equal(out2.length, 0);
|
||||
|
||||
// Regime change → fires.
|
||||
seedRegime(db, '2026-04-03', 'range_bound');
|
||||
const out3 = await regimeShiftProducer.check(db);
|
||||
assert.equal(out3.length, 1);
|
||||
assert.equal(out3[0].type, 'regime_shift');
|
||||
assert.equal(alertCount(db, 'regime_shift'), 1);
|
||||
|
||||
// Repeated check → no re-fire.
|
||||
const out4 = await regimeShiftProducer.check(db);
|
||||
assert.equal(out4.length, 0);
|
||||
});
|
||||
|
||||
// ─── conviction_unlock ───────────────────────────────────────────────────────
|
||||
|
||||
function seedUnlock(db: DatabaseSync, userId: string, unlock: string, earnedAt: string) {
|
||||
db.prepare(
|
||||
`INSERT INTO sizing_unlocks (user_id, unlock, earned_at, evidence)
|
||||
VALUES (?, ?, ?, '{"winRate":0.62}')`,
|
||||
).run(userId, unlock, earnedAt);
|
||||
}
|
||||
|
||||
test('conviction_unlock: baseline silent, new unlock notifies the earning user directly', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
seedUnlock(db, 'u1', 'tier_a', '2026-04-01T00:00:00Z');
|
||||
|
||||
const out1 = await convictionUnlockProducer.check(db);
|
||||
assert.equal(out1.length, 0, 'existing unlock at boot must not page anyone');
|
||||
assert.equal(alertCount(db, 'conviction_unlock'), 0);
|
||||
|
||||
// Update earned_at (re-earned) → fires, recipient includes the owner even
|
||||
// though u1 never subscribed.
|
||||
seedUnlock(db, 'u1', 'tier_a_star', '2026-04-02T00:00:00Z');
|
||||
const out2 = await convictionUnlockProducer.check(db);
|
||||
assert.equal(out2.length, 1);
|
||||
assert.equal(out2[0].userId, 'u1');
|
||||
assert.equal(out2[0].payload?.unlock, 'tier_a_star');
|
||||
|
||||
const out3 = await convictionUnlockProducer.check(db);
|
||||
assert.equal(out3.length, 0, 'same unlock must not re-fire');
|
||||
});
|
||||
|
||||
// ─── thesis_broken / thesis_weakening ────────────────────────────────────────
|
||||
|
||||
function seedThesis(db: DatabaseSync, id: string, userId: string, symbol: string, status = 'intact') {
|
||||
db.prepare(
|
||||
`INSERT INTO theses (id, user_id, symbol, statement, invalidation_criteria, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 't', '[]', ?, ?, ?)`,
|
||||
).run(id, userId, symbol, status, new Date().toISOString(), new Date().toISOString());
|
||||
}
|
||||
|
||||
function setThesisStatus(db: DatabaseSync, id: string, status: string, updatedAt: string) {
|
||||
db.prepare('UPDATE theses SET status = ?, updated_at = ? WHERE id = ?').run(status, updatedAt, id);
|
||||
}
|
||||
|
||||
test('thesis_broken: baseline silent, status transition fires for the owner', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
seedThesis(db, 'th1', 'u1', 'AAPL', 'intact');
|
||||
|
||||
// Baseline: intact → nothing.
|
||||
const out0 = await thesisBrokenProducer.check(db);
|
||||
assert.equal(out0.length, 0);
|
||||
|
||||
// Transition intact → broken.
|
||||
setThesisStatus(db, 'th1', 'broken', '2026-04-05T00:00:00Z');
|
||||
const out1 = await thesisBrokenProducer.check(db);
|
||||
assert.equal(out1.length, 1);
|
||||
assert.equal(out1[0].type, 'thesis_broken');
|
||||
assert.equal(out1[0].symbol, 'AAPL');
|
||||
assert.equal(out1[0].userId, 'u1', 'owner notified without an explicit subscription');
|
||||
assert.equal(alertCount(db, 'thesis_broken'), 1);
|
||||
|
||||
const out2 = await thesisBrokenProducer.check(db);
|
||||
assert.equal(out2.length, 0, 'persisted broken status must not re-fire');
|
||||
});
|
||||
|
||||
test('thesis_weakening: fires on transition to weakening, distinct from broken', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1');
|
||||
seedThesis(db, 'th2', 'u1', 'MSFT', 'intact');
|
||||
await thesisWeakeningProducer.check(db); // baseline
|
||||
|
||||
setThesisStatus(db, 'th2', 'weakening', '2026-04-06T00:00:00Z');
|
||||
const out1 = await thesisWeakeningProducer.check(db);
|
||||
assert.equal(out1.length, 1);
|
||||
assert.equal(out1[0].type, 'thesis_weakening');
|
||||
assert.equal(alertCount(db, 'thesis_weakening'), 1);
|
||||
assert.equal(alertCount(db, 'thesis_broken'), 0, 'weakening must not fire broken');
|
||||
});
|
||||
|
||||
// ─── cluster_breach / drawdown_halt / asymmetry_warning ─────────────────────
|
||||
|
||||
function seedHolding(db: DatabaseSync, id: string, owner: string, symbol: string, qty: number, avgCost: number) {
|
||||
db.prepare(
|
||||
`INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'open')`,
|
||||
).run(id, owner, symbol, qty, avgCost, new Date().toISOString());
|
||||
}
|
||||
|
||||
function seedTrade(db: DatabaseSync, owner: string, symbol: string, stopLoss: number, targets: Array<{ price: number }>) {
|
||||
db.prepare(
|
||||
`INSERT INTO trades (id, owner_id, trade_id, symbol, date, status, tier, stop_loss, targets)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', 'B', ?, ?)`,
|
||||
).run(`t-${owner}-${symbol}`, owner, `tid-${owner}-${symbol}`, symbol, new Date().toISOString().slice(0, 10), stopLoss, JSON.stringify(targets));
|
||||
}
|
||||
|
||||
test('cluster_breach: fires when uncategorized exposure exceeds the beginner cap', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1', { complexity: 'beginner', drawdownTolerance: 20 });
|
||||
// $10,000 equity, all in one symbol → 100% in "uncategorized" cluster.
|
||||
seedHolding(db, 'h1', 'u1', 'AAPL', 100, 100);
|
||||
|
||||
const out = await clusterBreachProducer.check(db);
|
||||
assert.equal(out.length, 1, '100% of equity in one cluster exceeds the 25% beginner cap');
|
||||
assert.equal(out[0].type, 'cluster_breach');
|
||||
assert.equal(out[0].userId, 'u1');
|
||||
assert.equal(alertCount(db, 'cluster_breach'), 1);
|
||||
|
||||
// Repeated check with same exposure → no re-fire (watermarked).
|
||||
const out2 = await clusterBreachProducer.check(db);
|
||||
assert.equal(out2.length, 0);
|
||||
assert.equal(alertCount(db, 'cluster_breach'), 1);
|
||||
});
|
||||
|
||||
test('drawdown_halt: fires on the breach transition only', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1', { complexity: 'beginner', drawdownTolerance: 20 });
|
||||
seedHolding(db, 'h1', 'u1', 'AAPL', 100, 100);
|
||||
|
||||
// First check: baseline peak equity = current equity → no breach.
|
||||
const out1 = await drawdownHaltProducer.check(db);
|
||||
assert.equal(out1.length, 0);
|
||||
|
||||
// Mark drops to $50 → equity $5,000, 50% drawdown vs 20% tolerance → breach.
|
||||
db.prepare(
|
||||
`INSERT INTO quotes (symbol, price, observed_at) VALUES ('AAPL', 50, ?)`,
|
||||
).run(new Date().toISOString());
|
||||
const out2 = await drawdownHaltProducer.check(db);
|
||||
assert.equal(out2.length, 1);
|
||||
assert.equal(out2[0].type, 'drawdown_halt');
|
||||
assert.equal(alertCount(db, 'drawdown_halt'), 1);
|
||||
|
||||
// Still breached → silent (already alerted).
|
||||
const out3 = await drawdownHaltProducer.check(db);
|
||||
assert.equal(out3.length, 0);
|
||||
assert.equal(alertCount(db, 'drawdown_halt'), 1);
|
||||
});
|
||||
|
||||
test('asymmetry_warning: fires when reward:risk is < 1 and watermarks', async () => {
|
||||
const db = freshDb();
|
||||
addUser(db, 'u1', { complexity: 'beginner', drawdownTolerance: 20 });
|
||||
seedHolding(db, 'h1', 'u1', 'AAPL', 100, 100);
|
||||
// Reward 5/share (105 target) vs risk 50/share (50 stop) → asymmetry 0.1.
|
||||
seedTrade(db, 'u1', 'AAPL', 50, [{ price: 105 }]);
|
||||
|
||||
const out = await asymmetryWarningProducer.check(db);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].type, 'asymmetry_warning');
|
||||
assert.ok((out[0].payload?.asymmetry as number) < 1, 'payload carries the aggregate asymmetry ratio');
|
||||
assert.equal(alertCount(db, 'asymmetry_warning'), 1);
|
||||
|
||||
const out2 = await asymmetryWarningProducer.check(db);
|
||||
assert.equal(out2.length, 0, 'same asymmetry bucket must not re-fire');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// VIX level producer tests.
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { vixLevelProducer, vixZoneFor, VIX_SYMBOL } from '../producers/vixLevelProducer.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
|
||||
db.exec(SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function subscribe(db: DatabaseSync, userId: string, type: string, symbol: string | null) {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO users (id, email, pw_hash, created_at)
|
||||
VALUES (?, ?, '', ?)`,
|
||||
).run(userId, `${userId}@t.local`, new Date().toISOString());
|
||||
db.prepare(
|
||||
`INSERT INTO alerts (id, owner_id, symbol, alert_type, enabled, params, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, '{}', ?)`,
|
||||
).run(`sub-${userId}-${type}-${symbol ?? 'x'}`, userId, symbol, type, new Date().toISOString());
|
||||
}
|
||||
|
||||
function setVix(db: DatabaseSync, price: number) {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO quotes (symbol, price, observed_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
).run(VIX_SYMBOL, price, new Date().toISOString());
|
||||
}
|
||||
|
||||
test('vixZoneFor maps levels to named bands', () => {
|
||||
assert.equal(vixZoneFor(62).zone, 'extreme');
|
||||
assert.equal(vixZoneFor(50).zone, 'stress');
|
||||
assert.equal(vixZoneFor(38).zone, 'elevated');
|
||||
assert.equal(vixZoneFor(20).zone, 'watch');
|
||||
assert.equal(vixZoneFor(12).zone, 'calm');
|
||||
assert.equal(vixZoneFor(80).zone, 'extreme');
|
||||
});
|
||||
|
||||
test('vix_level: first observation seeds watermark without firing', async () => {
|
||||
const db = freshDb();
|
||||
subscribe(db, 'u1', 'vix_level', null);
|
||||
setVix(db, 20);
|
||||
const out = await vixLevelProducer.check(db);
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
|
||||
test('vix_level: fires once when the band changes, dedups within band', async () => {
|
||||
const db = freshDb();
|
||||
subscribe(db, 'u1', 'vix_level', null);
|
||||
subscribe(db, 'u2', 'vix_level', null);
|
||||
|
||||
setVix(db, 20);
|
||||
await vixLevelProducer.check(db);
|
||||
|
||||
setVix(db, 40);
|
||||
const first = await vixLevelProducer.check(db);
|
||||
assert.equal(first.length, 2);
|
||||
for (const a of first) {
|
||||
assert.equal(a.type, 'vix_level');
|
||||
assert.ok(!/you should|^\s*(buy|sell)\b/i.test(a.description), a.description);
|
||||
assert.match(a.description, /VIX is at 40.0/);
|
||||
assert.match(a.description, /elevated/);
|
||||
}
|
||||
|
||||
// Same band, higher price — no re-fire.
|
||||
setVix(db, 42);
|
||||
const same = await vixLevelProducer.check(db);
|
||||
assert.equal(same.length, 0);
|
||||
});
|
||||
|
||||
test('vix_level: only subscribed users receive the alert', async () => {
|
||||
const db = freshDb();
|
||||
subscribe(db, 'u1', 'vix_level', null);
|
||||
subscribe(db, 'u2', 'some_other_type', null);
|
||||
|
||||
setVix(db, 20);
|
||||
await vixLevelProducer.check(db);
|
||||
setVix(db, 65);
|
||||
const out = await vixLevelProducer.check(db);
|
||||
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].userId, 'u1');
|
||||
assert.equal(out[0].payload.zone, 'extreme');
|
||||
});
|
||||
|
||||
test('vix_level: no quote means no alert', async () => {
|
||||
const db = freshDb();
|
||||
subscribe(db, 'u1', 'vix_level', null);
|
||||
const out = await vixLevelProducer.check(db);
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
@@ -11,6 +11,15 @@ export function registerProducer(p: AlertProducer): void {
|
||||
producers.push(p);
|
||||
}
|
||||
|
||||
// M21 Mirror Portfolio producers (registered lazily on first run).
|
||||
export function registerMirrorProducers(): void {
|
||||
import('./mirrorAlertProducers.ts').then((m) => {
|
||||
registerProducer(m.fundCaptureProducer);
|
||||
registerProducer(m.fund13FRecordProducer);
|
||||
registerProducer(m.mirrorDiffProducer);
|
||||
}).catch(() => { /* offline */ });
|
||||
}
|
||||
|
||||
/** Run all producers for a given frequency tier. */
|
||||
export async function runProducers(
|
||||
db: DatabaseSync,
|
||||
@@ -20,17 +29,43 @@ export async function runProducers(
|
||||
const tier = producers.filter((p) => p.frequency === frequency);
|
||||
|
||||
for (const producer of tier) {
|
||||
const started = Date.now();
|
||||
let alerts: Alert[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const alerts = await producer.check(db);
|
||||
alerts = await producer.check(db);
|
||||
results.push(...alerts);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[alert:${producer.alertType}] check failed:`, err);
|
||||
}
|
||||
writeProducerRun(db, producer.alertType, frequency, {
|
||||
eventsFired: alerts.length,
|
||||
durationMs: Date.now() - started,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function writeProducerRun(
|
||||
db: DatabaseSync,
|
||||
producer: string,
|
||||
frequency: ProducerFrequency,
|
||||
run: { eventsFired: number; durationMs: number; error: string | null },
|
||||
): void {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO producer_run_log
|
||||
(id, producer, frequency, events_fired, inspected, duration_ms, error, run_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?, ?)`,
|
||||
).run(crypto.randomUUID(), producer, frequency, run.eventsFired, run.durationMs, run.error, new Date().toISOString());
|
||||
} catch {
|
||||
/* producer_run_log may not exist until migration runs on old DBs */
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all registered producer types. */
|
||||
export function registeredAlertTypes(): string[] {
|
||||
return producers.map((p) => p.alertType);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Investor Flow — Mirror Portfolio (M21) alert producers.
|
||||
//
|
||||
// Three batched producers wired into the existing alert system (10-min batch,
|
||||
// subscriptions keyed by alert type + symbol, dedup):
|
||||
// 1. fund_capture — a new capture/claim was recorded for a tracked fund.
|
||||
// 2. fund_13f — a new 13F quarter landed from a tracked fund.
|
||||
// 3. mirror_diff — the live book behind the user's mirror changed materially.
|
||||
//
|
||||
// ADR-0010: every payload string is MECHANICAL ("to match your mirror target, the
|
||||
// delta is ..."). No advice-shaped sentence. Lint-enforced in sub-task 9.
|
||||
|
||||
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';
|
||||
import { liveBook } from '../../db/fundRepository.ts';
|
||||
|
||||
const rnd = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
// ─── 1. fund_capture — new capture/claim recorded ───────────────────────────
|
||||
export const fundCaptureProducer: AlertProducer = {
|
||||
alertType: 'fund_capture',
|
||||
frequency: 'batched',
|
||||
async check(db: DatabaseSync): Promise<import('../AlertEngine.ts').Alert[]> {
|
||||
const alerts: import('../AlertEngine.ts').Alert[] = [];
|
||||
const funds = db.prepare('SELECT id, fund_name FROM tracked_funds WHERE enabled = 1').all() as
|
||||
{ id: string; fund_name: string }[];
|
||||
for (const fund of funds) {
|
||||
const book = liveBook(db, fund.id);
|
||||
for (const pos of book) {
|
||||
if (pos.source !== 'capture' && pos.source !== 'claim') continue;
|
||||
const key = `fund_capture:${fund.id}:${pos.symbol}`;
|
||||
const prev = readComparisonState(db, key, 'fund_capture');
|
||||
const prevId = prev ? (JSON.parse(prev.state).latestKey as string | undefined) : null;
|
||||
// Use evidence_url as the stable id (post id) — each new post is a new capture.
|
||||
const latestKey = pos.evidence_url ?? `${pos.as_of}:${pos.shares}`;
|
||||
if (latestKey === prevId) continue;
|
||||
const users = getSubscribedUsers(db, 'fund_capture', pos.symbol);
|
||||
for (const userId of users) {
|
||||
const dedup = `fund_capture:${userId}:${fund.id}:${latestKey}`;
|
||||
if (isDuplicate(db, dedup)) continue;
|
||||
const shares = pos.shares != null ? `${rnd(pos.shares).toLocaleString('en-US')} shares` : 'a disclosed position';
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(), userId, 'fund_capture', pos.symbol,
|
||||
`${fund.fund_name} posted a new position update: ${pos.symbol} ${shares} as of ${pos.as_of}.`,
|
||||
dedup, { fund: fund.id, symbol: pos.symbol, shares: pos.shares, as_of: pos.as_of, source: pos.source },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
writeComparisonState(db, key, 'fund_capture', { latestKey });
|
||||
}
|
||||
}
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 2. fund_13f — new 13F quarter landed ───────────────────────────────────
|
||||
export const fund13FRecordProducer: AlertProducer = {
|
||||
alertType: 'fund_13f',
|
||||
frequency: 'batched',
|
||||
async check(db): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
const funds = db.prepare('SELECT id, ci_key, fund_name FROM tracked_funds WHERE enabled = 1').all() as
|
||||
{ id: string; ci_key: string; fund_name: string }[];
|
||||
for (const f of funds) {
|
||||
// Newest 13F-HR accession for the fund's CIK.
|
||||
const latest = db.prepare(
|
||||
`SELECT accession, reported_quarter FROM institution_filings
|
||||
WHERE filer_cik = ? AND form = '13F-HR'
|
||||
ORDER BY reported_quarter DESC LIMIT 1`,
|
||||
).get(f.ci_key) as { accession?: string; reported_quarter?: string } | undefined;
|
||||
if (!latest?.accession) continue;
|
||||
const key = `fund_13f:${f.id}`;
|
||||
const prev = readComparisonState(db, key, 'fund_13f');
|
||||
const prevAcc = prev ? (JSON.parse(prev.state).latestAccession as string | undefined) : undefined;
|
||||
if (latest.accession === prevAcc) continue;
|
||||
// The fund's disclosed symbols — subscriptions are symbol-cued.
|
||||
const symbols = db.prepare(
|
||||
`SELECT DISTINCT symbol FROM institution_filings WHERE filer_cik = ? AND form = '13F-HR'`,
|
||||
).all(f.ci_key) as { symbol: string }[];
|
||||
const symbolSet = symbols.map((s) => s.symbol);
|
||||
const userSet = new Map<string, string>();
|
||||
for (const sym of symbolSet) {
|
||||
for (const u of getSubscribedUsers(db, 'fund_13f', sym)) userSet.set(u, sym);
|
||||
}
|
||||
for (const [userId, sym] of userSet) {
|
||||
const dedup = `fund_13f:${userId}:${f.id}:${latest.accession}`;
|
||||
if (isDuplicate(db, dedup)) continue;
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(), userId, 'fund_13f', sym,
|
||||
`${f.fund_name} filed its latest 13F for ${latest.reported_quarter}.`,
|
||||
dedup, { fund: f.fund_name, quarter: latest.reported_quarter },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
writeComparisonState(db, key, 'fund_13f', { latestAccession: latest.accession });
|
||||
}
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 3. mirror_diff — target changed materially (mechanical) ────────────────
|
||||
export const mirrorDiffProducer: AlertProducer = {
|
||||
alertType: 'mirror_diff',
|
||||
frequency: 'batched',
|
||||
async check(db): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
const funds = db.prepare('SELECT id, fund_name FROM tracked_funds WHERE enabled = 1').all() as
|
||||
{ id: string; fund_name: string }[];
|
||||
for (const fund of funds) {
|
||||
const book = liveBook(db, fund.id);
|
||||
for (const pos of book) {
|
||||
const userIds = getSubscribedUsers(db, 'mirror_diff', pos.symbol);
|
||||
if (userIds.length === 0) continue;
|
||||
// Signal: disclosed shares + as-of for this position.
|
||||
const sig = pos.shares != null ? `${pos.as_of}:${pos.shares}` : `${pos.as_of}`;
|
||||
const key = `mirror_diff:${fund.id}:${pos.symbol}`;
|
||||
const prev = readComparisonState(db, key, 'mirror_diff');
|
||||
const prevSig = prev ? (JSON.parse(prev.state).sig as string | undefined) : undefined;
|
||||
if (sig === prevSig) continue;
|
||||
for (const userId of userIds) {
|
||||
const dedup = `mirror_diff:${userId}:${fund.id}:${pos.symbol}:${sig}`;
|
||||
if (isDuplicate(db, dedup)) continue;
|
||||
const delta = pos.shares != null
|
||||
? `To match your mirror target for ${pos.symbol}, the delta is ${rnd(pos.shares).toLocaleString('en-US')} shares as disclosed ${pos.as_of}.`
|
||||
: `The ${pos.symbol} position in the mirror target updated as of ${pos.as_of}.`;
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(), userId, 'mirror_diff', pos.symbol,
|
||||
`${fund.fund_name} updated its ${pos.symbol} book (${pos.as_of}). ${delta}`,
|
||||
dedup, { fund: fund.fund_name, symbol: pos.symbol, as_of: pos.as_of, source: pos.source },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
writeComparisonState(db, key, 'mirror_diff', { sig });
|
||||
}
|
||||
}
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
@@ -2,59 +2,280 @@ 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';
|
||||
import {
|
||||
readComparisonState,
|
||||
writeComparisonState,
|
||||
getSubscribedUsers,
|
||||
persistAlert,
|
||||
isDuplicate,
|
||||
} from './types.ts';
|
||||
import { seedAdminDefaultAlertSubscriptions } from '../../db/alertSubscriptionRepository.ts';
|
||||
|
||||
/**
|
||||
* Alert on new 13D / 13G (and amendments) as they land.
|
||||
*
|
||||
* Watermark: set of seen accessions per symbol (not "one latest by quarter"),
|
||||
* so multiple same-quarter filings each fire once. Ordered by filed_at so
|
||||
* the newest SEC file date is what drives discovery.
|
||||
*
|
||||
* Also seeds a global default subscription (all catalog types, enabled) for
|
||||
* admin users (is_admin=1) so operators receive everything by default.
|
||||
* Regular users opt in via the per-type toggle list on the alerts page.
|
||||
*/
|
||||
export const new13daProducer: AlertProducer = {
|
||||
alertType: 'new_13da',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
seedAdminDefaultAlertSubscriptions(db);
|
||||
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
const symbols = db.prepare(
|
||||
`SELECT DISTINCT symbol FROM institution_filings WHERE form LIKE '13D%' OR form LIKE '13G%'`,
|
||||
`SELECT DISTINCT symbol FROM institution_filings
|
||||
WHERE form LIKE '13D%' OR form LIKE '13G%' OR form IN ('13D','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;
|
||||
let prevState: { latestAccession?: string | null; seenAccessions?: string[] } = {};
|
||||
try {
|
||||
prevState = prev ? JSON.parse(prev.state) : {};
|
||||
} catch {
|
||||
prevState = {};
|
||||
}
|
||||
const seen = new Set<string>(prevState.seenAccessions ?? []);
|
||||
// Migrate legacy single-accession watermark into the set
|
||||
if (prevState.latestAccession) seen.add(prevState.latestAccession);
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT accession, reported_quarter, filer_name, put_call, form, filed_at
|
||||
FROM institution_filings
|
||||
WHERE symbol = ?
|
||||
AND (form LIKE '13D%' OR form LIKE '13G%' OR form IN ('13D','13G'))
|
||||
AND accession IS NOT NULL AND accession != ''
|
||||
ORDER BY filed_at DESC
|
||||
LIMIT 50`,
|
||||
).all(symbol) as Array<{
|
||||
accession: string;
|
||||
reported_quarter: string;
|
||||
filer_name: string;
|
||||
put_call: string | null;
|
||||
form: string;
|
||||
filed_at: string;
|
||||
}>;
|
||||
|
||||
if (rows.length === 0) 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 isFirstObservation = seen.size === 0;
|
||||
let newestAccession = prevState.latestAccession ?? rows[0].accession;
|
||||
let firstRunAlerts = 0;
|
||||
|
||||
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);
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.accession)) continue;
|
||||
|
||||
// Skip stale filings (>90 days). Historical bulk ingest should not page users.
|
||||
const filedDate = new Date(row.filed_at);
|
||||
const ageDays = (Date.now() - filedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (!Number.isFinite(ageDays) || ageDays > 90) {
|
||||
seen.add(row.accession);
|
||||
continue;
|
||||
}
|
||||
|
||||
// First-ever watermark: only alert on very fresh filings (≤7d), cap 5,
|
||||
// so bulk seed does not page users with a full SC history.
|
||||
if (isFirstObservation) {
|
||||
if (ageDays > 7 || firstRunAlerts >= 5) {
|
||||
seen.add(row.accession);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of users) {
|
||||
const dedupKey = `new_13da:${userId}:${row.accession}`;
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const accessionNoDashes = row.accession.replace(/-/g, '');
|
||||
const filerCik = padAccessionCik(row.accession);
|
||||
const filingUrl = `https://www.sec.gov/Archives/edgar/data/${filerCik}/${accessionNoDashes}/`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'new_13da',
|
||||
symbol,
|
||||
`${row.filer_name} reported a new position (${row.form})${row.put_call ? ` (${row.put_call})` : ''} for ${row.reported_quarter} (filed ${String(row.filed_at).slice(0, 10)}).`,
|
||||
row.accession,
|
||||
{
|
||||
filer: row.filer_name,
|
||||
quarter: row.reported_quarter,
|
||||
form: row.form,
|
||||
putCall: row.put_call,
|
||||
filedAt: row.filed_at,
|
||||
url: filingUrl,
|
||||
},
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
firstRunAlerts += 1;
|
||||
}
|
||||
|
||||
seen.add(row.accession);
|
||||
if (
|
||||
!newestAccession ||
|
||||
row.filed_at >= (rows.find((r) => r.accession === newestAccession)?.filed_at ?? '')
|
||||
) {
|
||||
newestAccession = row.accession;
|
||||
}
|
||||
}
|
||||
|
||||
writeComparisonState(db, symbol, 'new_13da', { latestAccession: row.accession });
|
||||
// On first observation still record every accession as seen (including skipped history).
|
||||
if (isFirstObservation) {
|
||||
for (const r of rows) seen.add(r.accession);
|
||||
}
|
||||
|
||||
writeComparisonState(db, symbol, 'new_13da', {
|
||||
latestAccession: newestAccession,
|
||||
seenAccessions: [...seen].slice(0, 200),
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* New 13F-HR accessions for demand-set equities (the "as they come in" feed).
|
||||
* Shares new_13da subscriptions so one institutional opt-in covers SC + 13F.
|
||||
*/
|
||||
export const new13fFilingProducer: AlertProducer = {
|
||||
alertType: 'new_13da', // same subscription type / UX label "institutional filing"
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
seedAdminDefaultAlertSubscriptions(db);
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Only symbols users actually follow (demand set), not entire filings table.
|
||||
let symbols: string[] = [];
|
||||
try {
|
||||
symbols = (
|
||||
db.prepare(
|
||||
`SELECT DISTINCT symbol FROM symbol_demand
|
||||
WHERE COALESCE(ticker_kind,'equity') NOT IN ('crypto','fx','index')
|
||||
ORDER BY symbol`,
|
||||
).all() as Array<{ symbol: string }>
|
||||
).map((r) => r.symbol);
|
||||
} catch {
|
||||
symbols = (
|
||||
db.prepare(
|
||||
`SELECT DISTINCT symbol FROM institution_filings WHERE form = '13F-HR' LIMIT 80`,
|
||||
).all() as Array<{ symbol: string }>
|
||||
).map((r) => r.symbol);
|
||||
}
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const prev = readComparisonState(db, symbol, 'new_13f_filing');
|
||||
let prevState: { seenAccessions?: string[] } = {};
|
||||
try {
|
||||
prevState = prev ? JSON.parse(prev.state) : {};
|
||||
} catch {
|
||||
prevState = {};
|
||||
}
|
||||
const seen = new Set<string>(prevState.seenAccessions ?? []);
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT accession, reported_quarter, filer_name, shares, filed_at
|
||||
FROM institution_filings
|
||||
WHERE symbol = ? AND form = '13F-HR'
|
||||
AND accession IS NOT NULL AND accession != ''
|
||||
ORDER BY filed_at DESC
|
||||
LIMIT 40`,
|
||||
).all(symbol) as Array<{
|
||||
accession: string;
|
||||
reported_quarter: string;
|
||||
filer_name: string;
|
||||
shares: number | null;
|
||||
filed_at: string;
|
||||
}>;
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const users = getSubscribedUsers(db, 'new_13da', symbol);
|
||||
const isFirstObservation = seen.size === 0;
|
||||
let firstRunAlerts = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.accession)) continue;
|
||||
|
||||
const filedDate = new Date(row.filed_at);
|
||||
const ageDays = (Date.now() - filedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
// 13F wave is ~45 days after quarter end; older = backfill noise
|
||||
if (!Number.isFinite(ageDays) || ageDays > 45) {
|
||||
seen.add(row.accession);
|
||||
continue;
|
||||
}
|
||||
|
||||
// First watermark: only page for very fresh filings (≤3d), cap 10 per symbol.
|
||||
if (isFirstObservation) {
|
||||
if (ageDays > 3 || firstRunAlerts >= 10) {
|
||||
seen.add(row.accession);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of users) {
|
||||
const dedupKey = `new_13f:${userId}:${row.accession}`;
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const accessionNoDashes = row.accession.replace(/-/g, '');
|
||||
const filerCik = padAccessionCik(row.accession);
|
||||
const filingUrl = `https://www.sec.gov/Archives/edgar/data/${filerCik}/${accessionNoDashes}/`;
|
||||
const sh =
|
||||
row.shares != null && Number.isFinite(row.shares)
|
||||
? ` (${Math.round(row.shares).toLocaleString()} sh)`
|
||||
: '';
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'new_13da',
|
||||
symbol,
|
||||
`${row.filer_name} filed 13F-HR for ${symbol}${sh} — ${row.reported_quarter} (filed ${String(row.filed_at).slice(0, 10)}).`,
|
||||
row.accession,
|
||||
{
|
||||
filer: row.filer_name,
|
||||
quarter: row.reported_quarter,
|
||||
form: '13F-HR',
|
||||
filedAt: row.filed_at,
|
||||
shares: row.shares,
|
||||
url: filingUrl,
|
||||
kind: '13f_holder',
|
||||
},
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
firstRunAlerts += 1;
|
||||
}
|
||||
|
||||
seen.add(row.accession);
|
||||
}
|
||||
|
||||
if (isFirstObservation) {
|
||||
for (const r of rows) seen.add(r.accession);
|
||||
}
|
||||
|
||||
writeComparisonState(db, symbol, 'new_13f_filing', {
|
||||
seenAccessions: [...seen].slice(0, 300),
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
function padAccessionCik(accession: string): string {
|
||||
const digits = accession.replace(/\D/g, '').slice(0, 10);
|
||||
return digits.padStart(10, '0');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// Investor Flow — Portfolio risk alert producers.
|
||||
//
|
||||
// cluster_breach / drawdown_halt / asymmetry_warning fire when the RiskEngine
|
||||
// posture for a user crosses a threshold:
|
||||
// - cluster_breach: a correlated cluster's exposure exceeds its cap
|
||||
// - drawdown_halt: portfolio drawdown breaches the user's tolerance
|
||||
// - asymmetry_warning: aggregate reward:risk ratio < 1.0
|
||||
//
|
||||
// Watermarks keep repeated checks silent (fires on state transitions, not on
|
||||
// every 10-minute tick). ADR-0007: observations and math, never directives.
|
||||
// Portfolio-level alerts go to global subscribers of each type (no ticker
|
||||
// makes sense for a whole-portfolio observation).
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, persistAlert, isDuplicate } from './types.ts';
|
||||
import { assessRisk, type RiskEngineInput, type RiskPosture } from '../../risk/RiskEngine.ts';
|
||||
import { listHoldings } from '../../db/portfolioRepository.ts';
|
||||
|
||||
/** Global-only subscribers of a portfolio-level alert type. */
|
||||
function globalSubscribers(db: DatabaseSync, alertType: string): string[] {
|
||||
const rows = 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 Array<{ owner_id: string }>;
|
||||
return rows.map((r) => r.owner_id);
|
||||
}
|
||||
|
||||
/** Global-only subscribers (portfolio-level alerts have no ticker), plus the owner. */
|
||||
function recipientsFor(db: DatabaseSync, alertType: string, ownerId: string): string[] {
|
||||
const users = new Set(globalSubscribers(db, alertType));
|
||||
users.add(ownerId);
|
||||
return Array.from(users);
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
complexity: 'beginner' | 'intermediate' | 'advanced';
|
||||
drawdownTolerancePct: number;
|
||||
}
|
||||
|
||||
function profileFor(db: DatabaseSync, userId: string): UserProfile {
|
||||
const row = db.prepare(
|
||||
'SELECT complexity, drawdown_tolerance FROM users WHERE id=?',
|
||||
).get(userId) as { complexity: string | null; drawdown_tolerance: number | null } | undefined;
|
||||
const complexity = (row?.complexity ?? 'beginner') as UserProfile['complexity'];
|
||||
const drawdownTolerancePct = Math.abs(row?.drawdown_tolerance ?? 20);
|
||||
return { complexity, drawdownTolerancePct };
|
||||
}
|
||||
|
||||
/** Current marks for a symbol set: live quote price, else cost basis at call site. */
|
||||
function marksFor(db: DatabaseSync, symbols: string[]): Map<string, number> {
|
||||
const marks = new Map<string, number>();
|
||||
for (let i = 0; i < symbols.length; i += 50) {
|
||||
const chunk = symbols.slice(i, i + 50);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT symbol, price FROM quotes WHERE symbol IN (${placeholders})`,
|
||||
).all(...chunk) as Array<{ symbol: string; price: number | null }>;
|
||||
for (const r of rows) {
|
||||
if (typeof r.price === 'number' && Number.isFinite(r.price)) marks.set(r.symbol, r.price);
|
||||
}
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
interface BuiltPosture {
|
||||
posture: RiskPosture;
|
||||
equity: number;
|
||||
peakEquity: number;
|
||||
marks: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reward/stop intent per symbol from the user's active trades: stop_loss +
|
||||
* first target. Gives aggregate asymmetry a real basis (downside vs upside)
|
||||
* instead of defaulting to cost basis on both sides.
|
||||
*/
|
||||
function tradeIntents(db: DatabaseSync, userId: string, symbols: string[]): Map<string, { rewardTarget?: number; stopPrice?: number }> {
|
||||
const intents = new Map<string, { rewardTarget?: number; stopPrice?: number }>();
|
||||
for (let i = 0; i < symbols.length; i += 50) {
|
||||
const chunk = symbols.slice(i, i + 50);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT t.symbol, t.stop_loss, t.targets
|
||||
FROM trades t
|
||||
JOIN (SELECT symbol, MAX(date) AS max_date
|
||||
FROM trades
|
||||
WHERE owner_id = ? AND status = 'active'
|
||||
AND symbol IN (${placeholders})
|
||||
GROUP BY symbol) m
|
||||
ON t.symbol = m.symbol AND t.date = m.max_date
|
||||
WHERE t.owner_id = ? AND t.status = 'active'
|
||||
AND t.symbol IN (${placeholders})`,
|
||||
).all(userId, ...chunk, userId, ...chunk) as Array<{ symbol: string; stop_loss: number | null; targets: string | null }>;
|
||||
for (const r of rows) {
|
||||
const intent: { rewardTarget?: number; stopPrice?: number } = {};
|
||||
if (typeof r.stop_loss === 'number' && Number.isFinite(r.stop_loss)) intent.stopPrice = r.stop_loss;
|
||||
if (r.targets) {
|
||||
try {
|
||||
const targets = JSON.parse(r.targets) as Array<{ price: number }>;
|
||||
const first = targets.find((t) => typeof t?.price === 'number');
|
||||
if (first) intent.rewardTarget = first.price;
|
||||
} catch { /* malformed targets JSON — treat as none */ }
|
||||
}
|
||||
intents.set(r.symbol, intent);
|
||||
}
|
||||
}
|
||||
return intents;
|
||||
}
|
||||
|
||||
function buildPosture(db: DatabaseSync, userId: string): BuiltPosture | null {
|
||||
const holdings = listHoldings(db, userId);
|
||||
if (holdings.length === 0) return null;
|
||||
|
||||
const { complexity, drawdownTolerancePct } = profileFor(db, userId);
|
||||
const marks = marksFor(db, holdings.map((h) => h.symbol));
|
||||
const intents = tradeIntents(db, userId, holdings.map((h) => h.symbol));
|
||||
|
||||
const portfolio = holdings.map((h) => {
|
||||
const intent = intents.get(h.symbol);
|
||||
return {
|
||||
symbol: h.symbol,
|
||||
shares: h.shares,
|
||||
avgCost: h.avg_cost,
|
||||
cluster: 'uncategorized' as const,
|
||||
rewardTarget: intent?.rewardTarget,
|
||||
stopPrice: intent?.stopPrice,
|
||||
};
|
||||
});
|
||||
const equity = portfolio.reduce((sum, p) => {
|
||||
const mark = marks.get(p.symbol) ?? p.avgCost;
|
||||
return sum + p.shares * mark;
|
||||
}, 0);
|
||||
if (!Number.isFinite(equity) || equity <= 0) return null;
|
||||
|
||||
const prev = readComparisonState(db, userId, 'drawdown_halt');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { peakEquity: equity, halted: false };
|
||||
const peakEquity = Math.max(Number(prevState.peakEquity) || equity, equity);
|
||||
|
||||
const defaultCap = complexity === 'beginner' ? equity * 0.25
|
||||
: complexity === 'intermediate' ? equity * 0.40
|
||||
: null;
|
||||
const clusterCaps = defaultCap !== null ? { uncategorized: defaultCap } : null;
|
||||
|
||||
const input: RiskEngineInput = {
|
||||
portfolio,
|
||||
account: { equity, drawdownTolerancePct, complexity },
|
||||
peakEquity,
|
||||
sizingContext: { clusterCaps },
|
||||
};
|
||||
const posture = assessRisk(input);
|
||||
return { posture, equity, peakEquity, marks };
|
||||
}
|
||||
|
||||
// ─── cluster_breach ──────────────────────────────────────────────────────────
|
||||
|
||||
export const clusterBreachProducer: AlertProducer = {
|
||||
alertType: 'cluster_breach',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
const users = db.prepare(
|
||||
`SELECT DISTINCT owner_id FROM portfolio_holdings WHERE status='open'`,
|
||||
).all() as Array<{ owner_id: string }>;
|
||||
|
||||
for (const { owner_id: userId } of users) {
|
||||
const built = buildPosture(db, userId);
|
||||
if (!built) continue;
|
||||
const { posture } = built;
|
||||
|
||||
const prev = readComparisonState(db, userId, 'cluster_breach');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { breaches: {} as Record<string, number> };
|
||||
|
||||
for (const [cluster, exposure] of Object.entries(posture.clusterExposure)) {
|
||||
const cap = posture.clusterCaps?.[cluster];
|
||||
if (cap === undefined || exposure <= cap) {
|
||||
if (prevState.breaches[cluster] !== undefined) delete prevState.breaches[cluster];
|
||||
continue;
|
||||
}
|
||||
const breachPct = ((exposure - cap) / cap) * 100;
|
||||
const prevPct = prevState.breaches[cluster];
|
||||
if (typeof prevPct === 'number' && prevPct >= breachPct - 5) continue;
|
||||
|
||||
for (const uid of recipientsFor(db, 'cluster_breach', userId)) {
|
||||
const eventId = `${userId}:${cluster}:${Math.round(exposure)}`;
|
||||
const dedupKey = buildDedupKey('cluster_breach', undefined, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description =
|
||||
`Exposure in the "${cluster}" cluster is $${Math.round(exposure)} against a cap of ` +
|
||||
`$${Math.round(cap)} (${breachPct.toFixed(1)}% over). Correlated positions share this driver.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
uid,
|
||||
'cluster_breach',
|
||||
undefined,
|
||||
description,
|
||||
eventId,
|
||||
{ userId, cluster, exposureUsd: Math.round(exposure), capUsd: Math.round(cap), breachPct: Number(breachPct.toFixed(1)) },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
prevState.breaches[cluster] = breachPct;
|
||||
}
|
||||
|
||||
writeComparisonState(db, userId, 'cluster_breach', prevState);
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── drawdown_halt ───────────────────────────────────────────────────────────
|
||||
|
||||
export const drawdownHaltProducer: AlertProducer = {
|
||||
alertType: 'drawdown_halt',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
const users = db.prepare(
|
||||
`SELECT DISTINCT owner_id FROM portfolio_holdings WHERE status='open'`,
|
||||
).all() as Array<{ owner_id: string }>;
|
||||
|
||||
for (const { owner_id: userId } of users) {
|
||||
const built = buildPosture(db, userId);
|
||||
if (!built) continue;
|
||||
const { posture, peakEquity } = built;
|
||||
const { status, currentDrawdownPct, tolerancePct } = posture.drawdownVsTolerance;
|
||||
|
||||
const prev = readComparisonState(db, userId, 'drawdown_halt');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { peakEquity, halted: false };
|
||||
const wasHalted = Boolean(prevState.halted);
|
||||
const nowHalted = status === 1;
|
||||
|
||||
if (nowHalted && !wasHalted) {
|
||||
for (const uid of recipientsFor(db, 'drawdown_halt', userId)) {
|
||||
const eventId = `${userId}:halt:${Math.round(currentDrawdownPct * 10)}`;
|
||||
const dedupKey = buildDedupKey('drawdown_halt', undefined, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description =
|
||||
`Portfolio drawdown of ${currentDrawdownPct.toFixed(1)}% has breached the ` +
|
||||
`${tolerancePct}% tolerance. The gentle-halt circuit breaker is now active for this account ` +
|
||||
`(no new entries for 24 hours; existing positions unaffected).`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
uid,
|
||||
'drawdown_halt',
|
||||
undefined,
|
||||
description,
|
||||
eventId,
|
||||
{ userId, drawdownPct: Number(currentDrawdownPct.toFixed(1)), tolerancePct, peakEquity: Math.round(peakEquity) },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
writeComparisonState(db, userId, 'drawdown_halt', {
|
||||
peakEquity,
|
||||
halted: nowHalted,
|
||||
drawdownPct: Number(currentDrawdownPct.toFixed(1)),
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── asymmetry_warning ───────────────────────────────────────────────────────
|
||||
|
||||
export const asymmetryWarningProducer: AlertProducer = {
|
||||
alertType: 'asymmetry_warning',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
const users = db.prepare(
|
||||
`SELECT DISTINCT owner_id FROM portfolio_holdings WHERE status='open'`,
|
||||
).all() as Array<{ owner_id: string }>;
|
||||
|
||||
for (const { owner_id: userId } of users) {
|
||||
const built = buildPosture(db, userId);
|
||||
if (!built) continue;
|
||||
const { posture } = built;
|
||||
const asymmetry = posture.asymmetry;
|
||||
if (!(asymmetry < 1) || !Number.isFinite(asymmetry)) continue;
|
||||
|
||||
// Watermark: bucket the ratio so 0.85 fires once, 0.72 re-fires when it
|
||||
// gets meaningfully worse.
|
||||
const bucket = Math.round(asymmetry * 20) / 20; // 0.05 steps
|
||||
const prev = readComparisonState(db, userId, 'asymmetry_warning');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { bucket: null as number | null };
|
||||
if (prevState.bucket !== null && prevState.bucket <= bucket) continue;
|
||||
|
||||
for (const uid of recipientsFor(db, 'asymmetry_warning', userId)) {
|
||||
const eventId = `${userId}:asym:${bucket}`;
|
||||
const dedupKey = buildDedupKey('asymmetry_warning', undefined, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description =
|
||||
`Aggregate reward:risk asymmetry is ${asymmetry.toFixed(2)} (< 1.0), so expected downside ` +
|
||||
`outweighs expected upside across this portfolio.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
uid,
|
||||
'asymmetry_warning',
|
||||
undefined,
|
||||
description,
|
||||
eventId,
|
||||
{ userId, asymmetry: Number(asymmetry.toFixed(2)) },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
writeComparisonState(db, userId, 'asymmetry_warning', { bucket });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
// Investor Flow — Sector rotation + regime shift alert producers.
|
||||
//
|
||||
// rotation_incipient: fires when the rotation detector records a new signal
|
||||
// (accumulation/expansion phase) for a sector. Watermark = last signal per
|
||||
// sector, so repeat observations of the same signal stay silent.
|
||||
// regime_shift: fires when the market regime classification changes. Watermark
|
||||
// = last as_of + regime, so a persisted regime never re-fires.
|
||||
//
|
||||
// ADR-0007: observations, never directives. A rotation signal or regime change
|
||||
// is a state change, not a call to action.
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
||||
|
||||
// ─── rotation_incipient ──────────────────────────────────────────────────────
|
||||
|
||||
/** Phases that count as "incipient" (capital moving in). */
|
||||
const INCIPIENT_PHASES = new Set(['accumulation', 'expansion']);
|
||||
|
||||
export const rotationIncipientProducer: AlertProducer = {
|
||||
alertType: 'rotation_incipient',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Newest signal per sector (a sector may appear once per detected event).
|
||||
const rows = db.prepare(
|
||||
`SELECT r.sector, r.detected_at, r.phase, r.confidence
|
||||
FROM rotation_signals r
|
||||
JOIN (SELECT sector, MAX(detected_at) AS max_detected
|
||||
FROM rotation_signals
|
||||
GROUP BY sector) m
|
||||
ON r.sector = m.sector AND r.detected_at = m.max_detected
|
||||
ORDER BY r.detected_at DESC`,
|
||||
).all() as Array<{ sector: string; detected_at: string; phase: string; confidence: number | null }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const sector = row.sector;
|
||||
const prev = readComparisonState(db, sector, 'rotation_incipient');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { lastDetectedAt: null, lastPhase: null };
|
||||
|
||||
// Already seen this exact signal.
|
||||
if (prevState.lastDetectedAt === row.detected_at && prevState.lastPhase === row.phase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fire only for incipient phases. Still watermark distribution/markdown
|
||||
// observations so they don't re-fire later.
|
||||
if (INCIPIENT_PHASES.has(row.phase)) {
|
||||
const users = getSubscribedUsers(db, 'rotation_incipient', sector);
|
||||
for (const userId of users) {
|
||||
const eventId = `${sector}:${row.detected_at}:${row.phase}`;
|
||||
const dedupKey = buildDedupKey('rotation_incipient', sector, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const conf = row.confidence != null ? Math.round(row.confidence * 100) : null;
|
||||
const description =
|
||||
`The rotation detector flagged ${sector} in a ${row.phase} phase` +
|
||||
(conf != null ? ` with ${conf}% confidence` : '') +
|
||||
` (detected ${row.detected_at}). Capital may be rotating into this sector.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'rotation_incipient',
|
||||
sector,
|
||||
description,
|
||||
eventId,
|
||||
{ sector, phase: row.phase, confidence: row.confidence, detectedAt: row.detected_at },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
writeComparisonState(db, sector, 'rotation_incipient', {
|
||||
lastDetectedAt: row.detected_at,
|
||||
lastPhase: row.phase,
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── regime_shift ────────────────────────────────────────────────────────────
|
||||
|
||||
export const regimeShiftProducer: AlertProducer = {
|
||||
alertType: 'regime_shift',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Most recent regime classification.
|
||||
const row = db.prepare(
|
||||
'SELECT as_of, regime FROM macro_regimes ORDER BY as_of DESC LIMIT 1',
|
||||
).get() as { as_of: string; regime: string } | undefined;
|
||||
if (!row) return alerts;
|
||||
|
||||
const prev = readComparisonState(db, 'MARKET', 'regime_shift');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { lastAsOf: null, lastRegime: null };
|
||||
|
||||
// Same classification persisted (same regime, even if as_of refreshed) → silent.
|
||||
if (prevState.lastRegime === row.regime) {
|
||||
writeComparisonState(db, 'MARKET', 'regime_shift', {
|
||||
lastAsOf: row.as_of,
|
||||
lastRegime: row.regime,
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
// First observation records the watermark without firing (no baseline to
|
||||
// compare against — backfill of the current regime shouldn't page anyone).
|
||||
if (prevState.lastRegime === null) {
|
||||
writeComparisonState(db, 'MARKET', 'regime_shift', {
|
||||
lastAsOf: row.as_of,
|
||||
lastRegime: row.regime,
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
// Regime actually changed → fire for global subscribers.
|
||||
const users = getSubscribedUsers(db, 'regime_shift', 'MARKET');
|
||||
for (const userId of users) {
|
||||
const eventId = `${prevState.lastRegime}->${row.regime}:${row.as_of}`;
|
||||
const dedupKey = buildDedupKey('regime_shift', undefined, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description =
|
||||
`The market regime changed from "${prevState.lastRegime}" to "${row.regime}" ` +
|
||||
`(classified ${row.as_of}). Portfolio-level risk assessments now use the new regime.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'regime_shift',
|
||||
undefined,
|
||||
description,
|
||||
eventId,
|
||||
{ from: prevState.lastRegime, to: row.regime, asOf: row.as_of },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
writeComparisonState(db, 'MARKET', 'regime_shift', {
|
||||
lastAsOf: row.as_of,
|
||||
lastRegime: row.regime,
|
||||
});
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
// Investor Flow — Thesis status alert producers.
|
||||
//
|
||||
// thesis_broken / thesis_weakening: fire when a stored thesis transitions into
|
||||
// the broken or weakening state. The producer tracks the last seen status for
|
||||
// EVERY thesis (not just target-status ones), so intact → broken is a real
|
||||
// transition, not a "first observation". A thesis that is already broken at
|
||||
// boot watermarks silently (baseline).
|
||||
//
|
||||
// ADR-0007: "criteria met — consider reviewing" never "sell". The description
|
||||
// frames the observation and invites the user to review the thesis.
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
||||
|
||||
interface ThesisRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
symbol: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const thesisBrokenProducer: AlertProducer = {
|
||||
alertType: 'thesis_broken',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
return checkThesisTransitions(db, 'broken', 'thesis_broken');
|
||||
},
|
||||
};
|
||||
|
||||
export const thesisWeakeningProducer: AlertProducer = {
|
||||
alertType: 'thesis_weakening',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
return checkThesisTransitions(db, 'weakening', 'thesis_weakening');
|
||||
},
|
||||
};
|
||||
|
||||
async function checkThesisTransitions(
|
||||
db: DatabaseSync,
|
||||
targetStatus: string,
|
||||
alertType: 'thesis_broken' | 'thesis_weakening',
|
||||
): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// All theses, all statuses — the watermark must see intact states too, or
|
||||
// an intact → broken transition would be misread as a baseline.
|
||||
const rows = db.prepare(
|
||||
`SELECT id, user_id, symbol, status, updated_at FROM theses`,
|
||||
).all() as unknown as ThesisRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const prev = readComparisonState(db, row.id, alertType);
|
||||
const prevState = prev ? JSON.parse(prev.state) : { lastStatus: null, lastUpdatedAt: null };
|
||||
|
||||
// First observation of this thesis: watermark, never fire. Existing
|
||||
// broken/weakening theses at boot must not page anyone.
|
||||
if (prevState.lastStatus === null) {
|
||||
writeComparisonState(db, row.id, alertType, {
|
||||
lastStatus: row.status,
|
||||
lastUpdatedAt: row.updated_at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fire only on the transition INTO the target status (from any other).
|
||||
const transitionedInto = row.status === targetStatus && row.status !== prevState.lastStatus;
|
||||
|
||||
// Always refresh the watermark to the current state so later transitions
|
||||
// are detected against the right baseline.
|
||||
writeComparisonState(db, row.id, alertType, {
|
||||
lastStatus: row.status,
|
||||
lastUpdatedAt: row.updated_at,
|
||||
});
|
||||
|
||||
if (!transitionedInto) continue;
|
||||
|
||||
const users = new Set(getSubscribedUsers(db, alertType, row.symbol));
|
||||
users.add(row.user_id);
|
||||
for (const userId of users) {
|
||||
const eventId = `${row.id}:${row.status}:${row.updated_at}`;
|
||||
const dedupKey = buildDedupKey(alertType, row.symbol, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const description = alertType === 'thesis_broken'
|
||||
? `The invalidation criteria for your thesis on ${row.symbol} have been met (updated ${row.updated_at}). Consider reviewing your thesis.`
|
||||
: `Some signals suggest your thesis on ${row.symbol} may be weakening (updated ${row.updated_at}), but invalidation criteria are not yet met.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
alertType,
|
||||
row.symbol,
|
||||
description,
|
||||
eventId,
|
||||
{ thesisId: row.id, status: row.status, updatedAt: row.updated_at },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
|
||||
export type ProducerFrequency = 'per-fetch' | 'batched';
|
||||
export type ProducerFrequency = 'realtime' | 'per-fetch' | 'batched';
|
||||
|
||||
export interface AlertProducer {
|
||||
readonly alertType: string;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Investor Flow — Conviction tier unlock alert producer.
|
||||
//
|
||||
// conviction_unlock: fires when a user earns a new sizing unlock (tier_a,
|
||||
// tier_a_star, etc.) from accumulated trading history. Watermark = last
|
||||
// earned_at per user, so re-reading the same unlock stays silent.
|
||||
//
|
||||
// ADR-0007: an unlock is an earned capability, not a buy signal.
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../AlertEngine.ts';
|
||||
import { createAlert, buildDedupKey } from '../AlertEngine.ts';
|
||||
import type { AlertProducer } from './types.ts';
|
||||
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
|
||||
|
||||
/** Human-readable tier names for the payload/unlock display. */
|
||||
const UNLOCK_LABELS: Record<string, string> = {
|
||||
tier_a: 'A tier',
|
||||
tier_a_star: 'A* tier',
|
||||
covered_income: 'covered income strategies',
|
||||
uncovered_income: 'uncovered income strategies',
|
||||
};
|
||||
|
||||
export const convictionUnlockProducer: AlertProducer = {
|
||||
alertType: 'conviction_unlock',
|
||||
frequency: 'batched',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Newest unlock per user.
|
||||
const rows = db.prepare(
|
||||
`SELECT s.user_id, s.unlock, s.earned_at, s.evidence
|
||||
FROM sizing_unlocks s
|
||||
JOIN (SELECT user_id, MAX(earned_at) AS max_earned
|
||||
FROM sizing_unlocks
|
||||
GROUP BY user_id) m
|
||||
ON s.user_id = m.user_id AND s.earned_at = m.max_earned
|
||||
ORDER BY s.earned_at DESC`,
|
||||
).all() as Array<{ user_id: string; unlock: string; earned_at: string; evidence: string | null }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const userId = row.user_id;
|
||||
const prev = readComparisonState(db, userId, 'conviction_unlock');
|
||||
const prevState = prev ? JSON.parse(prev.state) : { lastEarnedAt: null, lastUnlock: null };
|
||||
|
||||
if (prevState.lastEarnedAt === row.earned_at && prevState.lastUnlock === row.unlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First observation records the watermark without firing (existing
|
||||
// unlocks at boot shouldn't page anyone).
|
||||
if (prevState.lastUnlock === null) {
|
||||
writeComparisonState(db, userId, 'conviction_unlock', {
|
||||
lastEarnedAt: row.earned_at,
|
||||
lastUnlock: row.unlock,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// A genuinely new unlock → notify the earning user first (they are the
|
||||
// primary subscriber; a global subscription is honored too).
|
||||
const users = new Set(getSubscribedUsers(db, 'conviction_unlock', userId));
|
||||
users.add(userId);
|
||||
for (const uid of users) {
|
||||
const eventId = `${userId}:${row.unlock}:${row.earned_at}`;
|
||||
const dedupKey = buildDedupKey('conviction_unlock', undefined, eventId);
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const label = UNLOCK_LABELS[row.unlock] ?? row.unlock;
|
||||
const description =
|
||||
`Your trading history now qualifies for the ${label} conviction tier` +
|
||||
(row.evidence ? `. Evidence: ${row.evidence}` : '') +
|
||||
`. Sizing at this tier uses the higher conviction multiplier.`;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
uid,
|
||||
'conviction_unlock',
|
||||
undefined,
|
||||
description,
|
||||
eventId,
|
||||
{ unlock: row.unlock, earnedAt: row.earned_at, evidence: row.evidence },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
writeComparisonState(db, userId, 'conviction_unlock', {
|
||||
lastEarnedAt: row.earned_at,
|
||||
lastUnlock: row.unlock,
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
// Investor Flow — VIX level alert producer.
|
||||
//
|
||||
// Fires a `vix_level` alert when the VIX shifts into a new historical band.
|
||||
// Bands come from the "buy when it's scary / evaluate when it's calm" reading
|
||||
// of the index (calm < 15, elevated ≥ 35, stress ≥ 45, extreme ≥ 60).
|
||||
//
|
||||
// ADR-0007: alert text says "the level changed", never "you should act".
|
||||
// No trade verbs. A zone crossing is a state observation, not a directive.
|
||||
|
||||
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 VIX_SYMBOL = '^VIX';
|
||||
|
||||
/** VIX bands (inclusive lower bound). */
|
||||
export const VIX_BANDS: Array<{ min: number; zone: string; label: string; note: string }> = [
|
||||
{ min: 60, zone: 'extreme', label: 'extreme', note: 'an extreme reading; historically a rare, high-fear zone.' },
|
||||
{ min: 45, zone: 'stress', label: 'stress', note: 'a high-fear zone that historically has been brief.' },
|
||||
{ min: 35, zone: 'elevated', label: 'elevated', note: 'a fear-elevated zone.' },
|
||||
{ min: 15, zone: 'watch', label: 'watch', note: 'above the calm zone but below the elevated fear zone.' },
|
||||
{ min: 0, zone: 'calm', label: 'calm', note: 'a low-volatility zone.' },
|
||||
];
|
||||
|
||||
export type VixBand = { min: number; zone: string; label: string; note: string };
|
||||
|
||||
export function vixZoneFor(level: number): VixBand {
|
||||
for (const b of VIX_BANDS) {
|
||||
if (level >= b.min) return b;
|
||||
}
|
||||
return VIX_BANDS[VIX_BANDS.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert on VIX zone transitions.
|
||||
*
|
||||
* Watermark: last known zone. Fires only when the VIX moves from one named
|
||||
* band to another, so a quiet drift within a band produces silence. The first
|
||||
* observation records the watermark without firing, so a bulk backfill never
|
||||
* pages subscribers for a level they are already sitting at.
|
||||
*/
|
||||
export const vixLevelProducer: AlertProducer = {
|
||||
alertType: 'vix_level',
|
||||
frequency: 'realtime',
|
||||
|
||||
async check(db: DatabaseSync): Promise<Alert[]> {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
let price: number | null = null;
|
||||
try {
|
||||
const row = db.prepare('SELECT price FROM quotes WHERE symbol = ?').get(VIX_SYMBOL) as
|
||||
{ price: number | null } | undefined;
|
||||
price = row?.price ?? null;
|
||||
} catch { /* quotes table may not exist in some schema contexts */ }
|
||||
|
||||
if (price == null || !Number.isFinite(price)) {
|
||||
// No cached VIX yet — nothing to compare, stay silent.
|
||||
return alerts;
|
||||
}
|
||||
|
||||
const band = vixZoneFor(price);
|
||||
|
||||
const prev = readComparisonState(db, VIX_SYMBOL, 'vix_level');
|
||||
let prevZone: string | null = null;
|
||||
try {
|
||||
prevZone = prev ? ((JSON.parse(prev.state).zone as string | undefined) ?? null) : null;
|
||||
} catch {
|
||||
prevZone = null;
|
||||
}
|
||||
|
||||
// First observation: seed the watermark, do not page.
|
||||
if (prevZone === null) {
|
||||
writeComparisonState(db, VIX_SYMBOL, 'vix_level', { zone: band.zone, price });
|
||||
return alerts;
|
||||
}
|
||||
|
||||
if (prevZone === band.zone) {
|
||||
// Still in the same band — silence.
|
||||
writeComparisonState(db, VIX_SYMBOL, 'vix_level', { zone: band.zone, price });
|
||||
return alerts;
|
||||
}
|
||||
|
||||
const movedUp = band.min >= 35; // entering elevated/stress/extreme
|
||||
const direction = band.min >= 45 ? 'surged into' : band.min >= 35 ? 'climbed into' : band.min >= 15 ? 'moved into' : 'fallen to';
|
||||
const why = movedUp
|
||||
? 'Fear, as priced by the VIX, is elevated. This is an observation of a state change, not a directive.'
|
||||
: 'Fear, as priced by the VIX, is low. This is an observation of a state change, not a directive.';
|
||||
|
||||
const users = getSubscribedUsers(db, 'vix_level', VIX_SYMBOL);
|
||||
|
||||
for (const userId of users) {
|
||||
const dedupKey = `vix_level:${userId}:${band.zone}`;
|
||||
if (isDuplicate(db, dedupKey)) continue;
|
||||
|
||||
const alert = createAlert(
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'vix_level',
|
||||
VIX_SYMBOL,
|
||||
`The VIX is at ${price.toFixed(1)}, having ${direction} the ${band.label} band. ${band.note}\n\n${why}.`,
|
||||
band.zone,
|
||||
{ level: price, zone: band.zone, label: band.label },
|
||||
);
|
||||
persistAlert(db, alert);
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
writeComparisonState(db, VIX_SYMBOL, 'vix_level', { zone: band.zone, price });
|
||||
return alerts;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user