feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
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:
Investor Flow Build
2026-08-10 13:36:26 -04:00
parent 04fc11b2fd
commit ac94acf9e3
229 changed files with 32617 additions and 3934 deletions
+30 -2
View File
@@ -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);
});
+36 -1
View File
@@ -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 -1
View File
@@ -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;
},
};