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

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
@@ -0,0 +1,407 @@
// Investor Flow — dashboardRollup tests (Slice 8).
//
// Pure aggregation tests with fixtures. No network calls. Verifies:
// - conviction delta computation (increasing/reducing/flat/mixed paths)
// - class-roll detection
// - insider recency calculation
// - LLM summary generation (ADR-0005 voice, ADR-0007 footer)
// - Primary-Rule lint on summary strings (no trade verbs).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import {
computeConvictionDelta,
aggregateFlowDirection,
detectClassRoll,
DashboardRollupEngine,
generateDashboardRollupSummary,
ADR0007_FOOTER,
DEFAULT_VOICE_PROFILES,
type DashboardRollupRow,
type ConvictionDelta,
type VoiceProfile,
} from '../dashboardRollup.ts';
import { InstitutionFlowEngine } from '../institutionFlowEngine.ts';
import { EdgarAdapter } from '../../adapters/EdgarAdapter.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = join(__dirname, '..', '..', 'db', 'schema.sql');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Create an in-memory SQLite DB with schema applied. */
function createTestDb(): DatabaseSync {
const db = new DatabaseSync(':memory:');
const sql = readFileSync(SCHEMA_PATH, 'utf8');
db.exec(sql);
return db;
}
/** Seed institution_filings for a symbol. */
function seedInstitutionFilings(
db: DatabaseSync,
symbol: string,
rows: Array<{ filer_cik: string; filer_sic: string; shares: number; reported_quarter: string }>,
) {
const ins = db.prepare(
'INSERT INTO institution_filings (filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
for (const r of rows) {
ins.run(r.filer_cik, 'TestFiler', r.filer_sic, symbol, '13F-HR', r.shares, r.shares * 100, r.reported_quarter, '2026-01-01', '2026-01-01');
}
}
/** Seed insider_transactions for a symbol. */
function seedInsiderTransactions(
db: DatabaseSync,
symbol: string,
rows: Array<{ tx_date: string; classification: string; shares: number }>,
) {
const ins = db.prepare(
'INSERT INTO insider_transactions (form4_id, symbol, insider_name, insider_role, tx_date, tx_code, tx_type, shares, price, is_10b5_1, classification, filed_at, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
let id = 0;
for (const r of rows) {
ins.run(`form4_${id++}`, symbol, 'TestInsider', 'Officer', r.tx_date, 'P', 'buy', r.shares, 100, 0, r.classification, '2026-01-01', '2026-01-01');
}
}
/** Create a no-op EdgarAdapter for the rollup engine. */
function createNoOpEdgar(): EdgarAdapter {
return new EdgarAdapter();
}
/** Seed a users row for the given userId (required by FK constraints on watchlists/holdings). */
function seedUser(db: DatabaseSync, userId: string): void {
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)"
).run(userId, `${userId}@test.com`, 'hash', '2026-01-01');
}
// ---------------------------------------------------------------------------
// Tests: Pure classification helpers
// ---------------------------------------------------------------------------
test('computeConvictionDelta: both flat → flat', () => {
assert.equal(computeConvictionDelta('flat', null), 'flat');
});
test('computeConvictionDelta: flow increasing, no insider → increasing', () => {
assert.equal(computeConvictionDelta('increasing', null), 'increasing');
});
test('computeConvictionDelta: flow reducing, no insider → reducing', () => {
assert.equal(computeConvictionDelta('reducing', null), 'reducing');
});
test('computeConvictionDelta: both increasing → increasing', () => {
assert.equal(computeConvictionDelta('increasing', 'increasing'), 'increasing');
});
test('computeConvictionDelta: both reducing → reducing', () => {
assert.equal(computeConvictionDelta('reducing', 'reducing'), 'reducing');
});
test('computeConvictionDelta: flow increasing, insider reducing → mixed', () => {
assert.equal(computeConvictionDelta('increasing', 'reducing'), 'mixed');
});
test('computeConvictionDelta: flow reducing, insider increasing → mixed', () => {
assert.equal(computeConvictionDelta('reducing', 'increasing'), 'mixed');
});
test('aggregateFlowDirection: empty → flat', () => {
assert.equal(aggregateFlowDirection([]), 'flat');
});
test('aggregateFlowDirection: majority added → increasing', () => {
const results = [
{ classification: 'added to position' as const },
{ classification: 'added to position' as const },
{ classification: 'reduced position' as const },
];
assert.equal(aggregateFlowDirection(results as any), 'increasing');
});
test('aggregateFlowDirection: majority reduced → reducing', () => {
const results = [
{ classification: 'reduced position' as const },
{ classification: 'exited' as const },
{ classification: 'added to position' as const },
];
assert.equal(aggregateFlowDirection(results as any), 'reducing');
});
test('detectClassRoll: same class → false', () => {
const prev = [{ cik: '1', sic: '60' }, { cik: '2', sic: '60' }];
const curr = [{ cik: '3', sic: '60' }];
assert.equal(detectClassRoll(prev, curr), false);
});
test('detectClassRoll: different class → true', () => {
const prev = [{ cik: '1', sic: '60' }];
const curr = [{ cik: '2', sic: '30' }];
assert.equal(detectClassRoll(prev, curr), true);
});
test('detectClassRoll: empty prev → false', () => {
assert.equal(detectClassRoll([], [{ cik: '1', sic: '60' }]), false);
});
// ---------------------------------------------------------------------------
// Tests: Full rollup engine with fixtures
// ---------------------------------------------------------------------------
test('DashboardRollupEngine: empty user → empty rollup', async () => {
const db = createTestDb();
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-empty');
assert.deepEqual(rows, []);
});
test('DashboardRollupEngine: flat conviction (no institutional/insider data)', async () => {
const db = createTestDb();
seedUser(db, 'user-flat');
// Add a watchlist symbol.
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-flat', 'default', JSON.stringify(['AAPL']), '2026-01-01', 0);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-flat');
assert.equal(rows.length, 1);
assert.equal(rows[0].symbol, 'AAPL');
assert.equal(rows[0].convictionDelta, 'flat');
assert.equal(rows[0].insiderRecencyDays, null);
assert.equal(rows[0].classRollFlag, false);
});
test('DashboardRollupEngine: increasing conviction (institutional only)', async () => {
const db = createTestDb();
seedUser(db, 'user-inc');
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-inc', 'default', JSON.stringify(['NVDA']), '2026-01-01', 0);
// Seed institution_filings: current quarter has more shares than previous.
seedInstitutionFilings(db, 'NVDA', [
{ filer_cik: 'cik1', filer_sic: '60', shares: 10000, reported_quarter: '2025-Q1' },
{ filer_cik: 'cik2', filer_sic: '60', shares: 15000, reported_quarter: '2025-Q2' },
]);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-inc');
assert.equal(rows.length, 1);
assert.equal(rows[0].symbol, 'NVDA');
assert.equal(rows[0].convictionDelta, 'increasing');
});
test('DashboardRollupEngine: reducing conviction (institutional only)', async () => {
const db = createTestDb();
seedUser(db, 'user-red');
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-red', 'default', JSON.stringify(['TSLA']), '2026-01-01', 0);
seedInstitutionFilings(db, 'TSLA', [
{ filer_cik: 'cik1', filer_sic: '60', shares: 20000, reported_quarter: '2025-Q1' },
{ filer_cik: 'cik2', filer_sic: '60', shares: 5000, reported_quarter: '2025-Q2' },
]);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-red');
assert.equal(rows.length, 1);
assert.equal(rows[0].symbol, 'TSLA');
assert.equal(rows[0].convictionDelta, 'reducing');
});
test('DashboardRollupEngine: mixed conviction (flow vs insider disagree)', async () => {
const db = createTestDb();
seedUser(db, 'user-mix');
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-mix', 'default', JSON.stringify(['MSFT']), '2026-01-01', 0);
// Institutional: increasing.
seedInstitutionFilings(db, 'MSFT', [
{ filer_cik: 'cik1', filer_sic: '60', shares: 10000, reported_quarter: '2025-Q1' },
{ filer_cik: 'cik2', filer_sic: '60', shares: 15000, reported_quarter: '2025-Q2' },
]);
// Insider: reducing (more sell than buy).
seedInsiderTransactions(db, 'MSFT', [
{ tx_date: '2026-05-01', classification: 'informed_sell', shares: 5000 },
{ tx_date: '2026-04-01', classification: 'informed_buy', shares: 1000 },
]);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-mix');
assert.equal(rows.length, 1);
assert.equal(rows[0].symbol, 'MSFT');
// Flow is increasing, insider is reducing → mixed.
assert.equal(rows[0].convictionDelta, 'mixed');
});
test('DashboardRollupEngine: class-roll detection', async () => {
const db = createTestDb();
seedUser(db, 'user-roll');
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-roll', 'default', JSON.stringify(['GOOGL']), '2026-01-01', 0);
// Previous quarter: hedge fund class (60).
// Current quarter: insurance class (30).
seedInstitutionFilings(db, 'GOOGL', [
{ filer_cik: 'cik1', filer_sic: '60', shares: 10000, reported_quarter: '2025-Q1' },
{ filer_cik: 'cik2', filer_sic: '30', shares: 12000, reported_quarter: '2025-Q2' },
]);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-roll');
assert.equal(rows.length, 1);
assert.equal(rows[0].classRollFlag, true);
});
test('DashboardRollupEngine: sort order (mixed first, then increasing/reducing, then flat)', async () => {
const db = createTestDb();
seedUser(db, 'user-sort');
const wlIns = db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
wlIns.run('wl1', 'user-sort', 'default', JSON.stringify(['AAPL', 'NVDA', 'TSLA']), '2026-01-01', 0);
// AAPL: flat (no data).
// NVDA: increasing.
seedInstitutionFilings(db, 'NVDA', [
{ filer_cik: 'cik1', filer_sic: '60', shares: 10000, reported_quarter: '2025-Q1' },
{ filer_cik: 'cik2', filer_sic: '60', shares: 15000, reported_quarter: '2025-Q2' },
]);
const edgar = createNoOpEdgar();
const flowEngine = new InstitutionFlowEngine(edgar);
const engine = new DashboardRollupEngine(db, flowEngine);
const rows = await engine.computeRollup('user-sort');
assert.equal(rows.length, 3);
// Mixed/Increasing should come first (weight 2-3), flat last (weight 1).
assert.notEqual(rows[0].convictionDelta, 'flat');
});
// ---------------------------------------------------------------------------
// Tests: LLM summary generation
// ---------------------------------------------------------------------------
test('generateDashboardRollupSummary: empty rows → consolidation message', () => {
const rows: DashboardRollupRow[] = [];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('consolidation'));
assert.ok(summary.includes(ADR0007_FOOTER));
});
test('generateDashboardRollupSummary: includes ADR-0007 footer', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'AAPL', convictionDelta: 'flat', insiderRecencyDays: null, classRollFlag: false, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes(ADR0007_FOOTER));
});
test('generateDashboardRollupSummary: no trade verbs in output', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'AAPL', convictionDelta: 'increasing', insiderRecencyDays: 30, classRollFlag: false, alert: null },
{ symbol: 'TSLA', convictionDelta: 'reducing', insiderRecencyDays: null, classRollFlag: true, alert: null },
];
// Run multiple times to cover random voice selection.
for (let i = 0; i < 10; i++) {
const summary = generateDashboardRollupSummary(rows, DEFAULT_VOICE_PROFILES);
// Strip the mandatory ADR-0007 footer before checking for forbidden trade verbs.
const body = summary.slice(0, summary.lastIndexOf(ADR0007_FOOTER)).trim();
// Check for forbidden trade verbs (case-insensitive) — only in the summary body.
const forbidden = /follow this flow|buy |sell |hold |rotate into|add to your|action needed/i;
assert.ok(!forbidden.test(body), `Summary contains forbidden trade verb: ${body}`);
}
});
test('generateDashboardRollupSummary: mixed conviction triggers mixed message', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'AAPL', convictionDelta: 'mixed', insiderRecencyDays: 30, classRollFlag: false, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('conflicting directions') || summary.includes('mixed'));
});
test('generateDashboardRollupSummary: increasing conviction triggers moving-into message', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'NVDA', convictionDelta: 'increasing', insiderRecencyDays: null, classRollFlag: false, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('moving into'));
});
test('generateDashboardRollupSummary: reducing conviction triggers moving-out message', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'TSLA', convictionDelta: 'reducing', insiderRecencyDays: null, classRollFlag: false, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('moving out'));
});
test('generateDashboardRollupSummary: class-roll flag triggers roll message', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'GOOGL', convictionDelta: 'flat', insiderRecencyDays: null, classRollFlag: true, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('Holder-class roll'));
});
test('generateDashboardRollupSummary: insider recency triggers activity message', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'MSFT', convictionDelta: 'flat', insiderRecencyDays: 30, classRollFlag: false, alert: null },
];
const summary = generateDashboardRollupSummary(rows);
assert.ok(summary.includes('Recent informed Form 4'));
});
// ---------------------------------------------------------------------------
// Tests: Primary-Rule lint on summary strings
// ---------------------------------------------------------------------------
test('Primary-Rule: no trade verbs in any summary string', () => {
const rows: DashboardRollupRow[] = [
{ symbol: 'AAPL', convictionDelta: 'mixed', insiderRecencyDays: 30, classRollFlag: true, alert: null },
{ symbol: 'NVDA', convictionDelta: 'increasing', insiderRecencyDays: null, classRollFlag: false, alert: null },
{ symbol: 'TSLA', convictionDelta: 'reducing', insiderRecencyDays: 60, classRollFlag: false, alert: null },
{ symbol: 'GOOGL', convictionDelta: 'flat', insiderRecencyDays: null, classRollFlag: true, alert: null },
];
// Test multiple random voice selections.
for (let i = 0; i < 20; i++) {
const summary = generateDashboardRollupSummary(rows, DEFAULT_VOICE_PROFILES);
// Strip the mandatory ADR-0007 footer before checking for forbidden trade verbs.
const body = summary.slice(0, summary.lastIndexOf(ADR0007_FOOTER)).trim();
// Check for forbidden trade verbs (case-insensitive) — only in the summary body.
const forbidden = /follow this flow|buy |sell |hold |rotate into|add to your|action needed|bullish|bearish/i;
assert.ok(!forbidden.test(body), `Summary contains forbidden language: ${body}`);
}
});
@@ -0,0 +1,283 @@
// Investor Flow — InstitutionFlowEngine tests (Slice 7: M4 + M5)
// Tests holder classification, 10b5-1 detection, buy-zone estimation, and flow analysis.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { InstitutionFlowEngine } from '../institutionFlowEngine.ts';
// Mock EdgarAdapter for testing
class MockEdgarAdapter {
sourceKind = 'sec' as const;
async fetchOne(_key: string) { throw new Error('not used'); }
async filings_index(_cik: string, _opts?: any) { return { value: [], ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
async company_facts(_cik: string) { return { value: {}, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
async filer_cik_meta(_cik: string) { return { value: {}, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
async full_text_search(_q: string) { return { value: [], ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
async form13f_holdings(_cik: string, _accession: string) { return { value: { holdings: [] }, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
async form4_tx(_cik: string, _accession: string) { return { value: { transactions: [] }, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }
}
const mockEdgar = new MockEdgarAdapter() as any;
const engine = new InstitutionFlowEngine(mockEdgar);
// ---------------------------------------------------------------------------
// Holder Classification Tests
// ---------------------------------------------------------------------------
test('classifyHolder: identifies Hedge Fund by SIC 6211', () => {
const result = engine.classifyHolder('12345', 6211, 'Citadel Management');
assert.equal(result, 'Hedge Fund');
});
test('classifyHolder: identifies Pension Fund by SIC 5251', () => {
const result = engine.classifyHolder('12345', 5251, 'CalPERS');
assert.equal(result, 'Pension Fund');
});
test('classifyHolder: identifies Mutual Fund by SIC 5231', () => {
const result = engine.classifyHolder('12345', 5231, 'Fidelity Growth Fund');
assert.equal(result, 'Mutual Fund');
});
test('classifyHolder: identifies Index Fund by name', () => {
const result = engine.classifyHolder('12345', undefined, 'Vanguard Index S&P 500');
assert.equal(result, 'Index Fund');
});
test('classifyHolder: identifies Insider by name patterns', () => {
const result = engine.classifyHolder('12345', undefined, 'John Smith CEO');
assert.equal(result, 'Insider');
});
test('classifyHolder: defaults to Mutual Fund when unknown', () => {
const result = engine.classifyHolder('12345', 9999, 'Unknown Entity');
assert.equal(result, 'Mutual Fund');
});
// ---------------------------------------------------------------------------
// 10b5-1 Plan Detection Tests
// ---------------------------------------------------------------------------
test('detect10b5Plan: detects explicit 10b5-1 flag', () => {
const tx = { is10b5Plan: true, relationship: 'Sale', reporter: 'Test', securityTitle: 'AAPL', transactionDate: '2024-01-01', transactionCode: 'S', shares: 100, price: 150 };
assert.equal(engine.detect10b5Plan(tx), true);
});
test('detect10b5Plan: detects 10b5 in relationship field', () => {
const tx = { is10b5Plan: false, relationship: 'Sale pursuant to 10b5-1 plan', reporter: 'Test', securityTitle: 'AAPL', transactionDate: '2024-01-01', transactionCode: 'S', shares: 100, price: 150 };
assert.equal(engine.detect10b5Plan(tx), true);
});
test('detect10b5Plan: returns false for routine transaction', () => {
const tx = { is10b5Plan: false, relationship: 'Open market sale', reporter: 'Test', securityTitle: 'AAPL', transactionDate: '2024-01-01', transactionCode: 'S', shares: 100, price: 150 };
assert.equal(engine.detect10b5Plan(tx), false);
});
// ---------------------------------------------------------------------------
// Buy-Zone Estimation Tests
// ---------------------------------------------------------------------------
test('estimateBuyZone: returns null when increase <= 25%', () => {
const result = engine.estimateBuyZone({
cik: '12345',
holderClass: 'Hedge Fund',
symbol: 'AAPL',
currentExposure: 1000000,
positionIncreasePct: 20,
lastFilingDate: '2024-01-01',
});
assert.equal(result, null);
});
test('estimateBuyZone: returns estimate when increase > 25%', () => {
const result = engine.estimateBuyZone({
cik: '12345',
holderClass: 'Hedge Fund',
symbol: 'AAPL',
currentExposure: 1000000,
positionIncreasePct: 30,
lastFilingDate: '2024-01-01',
});
assert.ok(result);
assert.equal(result!.isEstimated, true);
assert.equal(result!.estimatedBuyZone, 250000); // 25% of 1M
assert.equal(result!.holderClass, 'Hedge Fund');
});
// ---------------------------------------------------------------------------
// Position Classification Tests
// ---------------------------------------------------------------------------
test('classifyPosition: new position when prev=0, curr>0', () => {
assert.equal(engine.classifyPosition(0, 1000), 'new position');
});
test('classifyPosition: exited when prev>0, curr=0', () => {
assert.equal(engine.classifyPosition(1000, 0), 'exited');
});
test('classifyPosition: added to position when delta>0', () => {
assert.equal(engine.classifyPosition(1000, 1500), 'added to position');
});
test('classifyPosition: reduced position when delta<0', () => {
assert.equal(engine.classifyPosition(1500, 1000), 'reduced position');
});
test('classifyPosition: unchanged when delta=0', () => {
assert.equal(engine.classifyPosition(1000, 1000), 'unchanged');
});
// ---------------------------------------------------------------------------
// Transaction Direction Tests
// ---------------------------------------------------------------------------
test('classifyTransactionDirection: P code → increased holdings', () => {
assert.equal(engine.classifyTransactionDirection('P', 100), 'reporter increased holdings');
});
test('classifyTransactionDirection: S code → reduced holdings', () => {
assert.equal(engine.classifyTransactionDirection('S', -100), 'reporter reduced holdings');
});
test('classifyTransactionDirection: ambiguous code with positive shares → increased', () => {
assert.equal(engine.classifyTransactionDirection('C', 100), 'reporter increased holdings');
});
test('classifyTransactionDirection: ambiguous code with negative shares → reduced', () => {
assert.equal(engine.classifyTransactionDirection('C', -100), 'reporter reduced holdings');
});
test('classifyTransactionDirection: zero shares → null', () => {
assert.equal(engine.classifyTransactionDirection('C', 0), null);
});
// ---------------------------------------------------------------------------
// Integration Tests (aggregate_13f_flow and insider_flow)
// ---------------------------------------------------------------------------
test('aggregate_13f_flow: computes flow between two filings', async () => {
// Mock the EdgarAdapter to return test data
const mockEngine = new InstitutionFlowEngine({
...mockEdgar,
async form13f_holdings(cik: string, accession: string) {
if (accession === 'from') {
return {
value: {
holdings: [
{ cusip: '123', issuerName: 'AAPL', value: 100000, sshPrnamt: 1000 },
{ cusip: '456', issuerName: 'MSFT', value: 200000, sshPrnamt: 2000 },
],
},
ttlClass: 'daily_permanent' as const,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const },
};
} else {
return {
value: {
holdings: [
{ cusip: '123', issuerName: 'AAPL', value: 150000, sshPrnamt: 1500 },
{ cusip: '456', issuerName: 'MSFT', value: 180000, sshPrnamt: 1800 },
{ cusip: '789', issuerName: 'GOOGL', value: 50000, sshPrnamt: 500 },
],
},
ttlClass: 'daily_permanent' as const,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const },
};
}
},
} as any);
const results = await mockEngine.aggregate_13f_flow('12345', {
fromAccession: 'from',
toAccession: 'to',
});
assert.equal(results.length, 3); // AAPL, MSFT, GOOGL (new position)
// Find AAPL result
const aapl = results.find(r => r.cusip === '123');
assert.ok(aapl);
assert.equal(aapl!.delta, 500); // 1500 - 1000
assert.equal(aapl!.classification, 'added to position');
// Find MSFT result
const msft = results.find(r => r.cusip === '456');
assert.ok(msft);
assert.equal(msft!.delta, -200); // 1800 - 2000
assert.equal(msft!.classification, 'reduced position');
// Find GOOGL result (new position)
const googl = results.find(r => r.cusip === '789');
assert.ok(googl);
assert.equal(googl!.delta, 500);
assert.equal(googl!.classification, 'new position');
});
test('insider_flow: summarizes Form 4 transactions', async () => {
const mockEngine = new InstitutionFlowEngine({
...mockEdgar,
async filings_index(cik: string, opts?: any) {
return {
value: [
{ form: '4', accessionNumber: 'acc1', dateReporter: '2024-01-15' },
],
ttlClass: 'daily_permanent' as const,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const },
};
},
async form4_tx(cik: string, accession: string) {
return {
value: {
transactions: [
{
reporter: 'John CEO',
relationship: 'Open market purchase',
securityTitle: 'AAPL',
transactionDate: '2024-01-15',
transactionCode: 'P',
shares: 500,
price: 150,
},
{
reporter: 'Jane CFO',
relationship: 'Sale pursuant to 10b5-1 plan',
securityTitle: 'AAPL',
transactionDate: '2024-01-20',
transactionCode: 'S',
shares: -300,
price: 155,
is10b5Plan: true,
},
],
},
ttlClass: 'daily_permanent' as const,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const },
};
},
} as any);
const result = await mockEngine.insider_flow('12345', {
sinceDate: '2024-01-01',
});
assert.equal(result.cik, '12345');
assert.equal(result.events.length, 2);
// Net shares: +500 (increase) - (-300) = 500 + 300 = 800
assert.equal(result.netShares, 800);
assert.equal(result.direction, 'reporter increased holdings');
// Check first event (most recent)
const firstEvent = result.events[0];
assert.equal(firstEvent.reporter, 'Jane CFO');
assert.equal(firstEvent.is10b5Plan, true);
assert.ok(firstEvent.planDetails);
// Check second event
const secondEvent = result.events[1];
assert.equal(secondEvent.reporter, 'John CEO');
assert.equal(secondEvent.is10b5Plan, false);
// Overall transaction type should be Routine (only 1 of 2 is Informed)
assert.equal(result.transactionType, 'Routine');
});
@@ -0,0 +1,76 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
totalReturnPct,
relativeStrength,
buildSectorRsMap,
summarizeRotation,
type CandlePoint,
type SectorDef,
} from '../marketRotationRs.ts';
function candles(prices: number[], start = Date.UTC(2024, 0, 1)): CandlePoint[] {
return prices.map((c, i) => ({
ts: new Date(start + i * 86_400_000).toISOString(),
c,
v: 100 + i * 10,
}));
}
test('totalReturnPct: computes window return', () => {
// 31 days of prices: start 100, end 110 → ~10% over ~30d
const prices = Array.from({ length: 32 }, (_, i) => 100 + i * (10 / 31));
const r = totalReturnPct(candles(prices), 30 * 86_400_000);
assert.ok(r !== null);
assert.ok(Math.abs((r as number) - 10) < 1.5);
});
test('relativeStrength: sector minus bench', () => {
assert.equal(relativeStrength(12, 5), 7);
assert.equal(relativeStrength(null, 5), null);
});
test('buildSectorRsMap: ranks leaders above laggards on 1M RS', () => {
// 35 flat days then last month diverges
const base = Array.from({ length: 40 }, () => 100);
const strong = base.map((p, i) => (i >= 10 ? p * (1 + (i - 10) * 0.01) : p)); // up hard
const weak = base.map((p, i) => (i >= 10 ? p * (1 - (i - 10) * 0.005) : p)); // down
const bench = base.map((p, i) => (i >= 10 ? p * (1 + (i - 10) * 0.002) : p)); // mild up
const defs: SectorDef[] = [
{ symbol: 'XLK', name: 'Tech', group: 'Technology', kind: 'sector' },
{ symbol: 'XLU', name: 'Utils', group: 'Utilities', kind: 'sector' },
];
const rows = buildSectorRsMap(
defs,
{ XLK: candles(strong), XLU: candles(weak) },
candles(bench),
);
assert.equal(rows[0].symbol, 'XLK');
assert.equal(rows[0].leadership, 'leading');
assert.equal(rows[1].symbol, 'XLU');
assert.ok(rows[0].rank1M === 1);
assert.ok((rows[0].rs.oneMonth as number) > (rows[1].rs.oneMonth as number));
});
test('summarizeRotation: produces educational summary without trade verbs', () => {
const defs: SectorDef[] = [
{ symbol: 'A', name: 'A', group: 'Tech', kind: 'sector' },
{ symbol: 'B', name: 'B', group: 'Energy', kind: 'sector' },
{ symbol: 'C', name: 'C', group: 'Health', kind: 'sector' },
{ symbol: 'D', name: 'D', group: 'Utils', kind: 'sector' },
];
// Craft rows via build with extreme returns
const n = 40;
const mk = (mult: number) =>
candles(Array.from({ length: n }, (_, i) => 100 * (1 + mult * i * 0.01)));
const rows = buildSectorRsMap(
defs,
{ A: mk(2), B: mk(1.5), C: mk(-1), D: mk(-1.5) },
mk(0.2),
);
const s = summarizeRotation(rows);
assert.ok(s.leadershipSpread > 0 || s.strength === 'none' || s.leadingCount >= 0);
assert.ok(!/you should|buy |sell /i.test(s.summary));
assert.ok(/outperform|underperform|leadership|educational|broad market/i.test(s.summary));
});
@@ -0,0 +1,119 @@
// Tests — RotationDetector (Slice 9). Pure, no network.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
crossSectionalRank, detectIncipient, resolveSignal, labelPhase,
confidenceScore, falseAlarmRate,
DEFAULT_THRUST_THRESHOLD,
type SectorPriceSeries, type RotationSignal,
} from '../rotationDetector.ts';
const sector = (n: string, rs: number[], vol: number[] = [], avgRef = 100): SectorPriceSeries => ({
sector: n, rsRatio: rs, volume: vol, avgVolumeReference: avgRef,
});
test('crossSectionalRank ranks by latest RS-ratio descending', () => {
const ranked = crossSectionalRank([
sector('tech', [1.0, 1.05]),
sector('energy', [0.9, 0.85]),
sector('health', [1.0, 1.02]),
]);
assert.deepEqual(ranked.map((r) => r.sector), ['tech', 'health', 'energy']);
assert.equal(ranked[0].rank, 1);
});
test('detectIncipient: top-half + RS thrust + relvol thrust → true', () => {
const ranked = [{ sector: 'tech', rank: 1 }];
const s = sector('tech', [1.0, 1.02, 1.05], [120, 140, 160], 100);
assert.equal(detectIncipient(s, ranked), true);
});
test('detectIncipient: rank outside top-half → false', () => {
const ranked = [{ sector: 'tech', rank: 1 }, { sector: 'energy', rank: 2 }];
const s = sector('energy', [1.0, 1.02, 1.05], [160, 160, 160], 100);
assert.equal(detectIncipient(s, ranked), false);
});
test('detectIncipient: no RS thrust → false', () => {
const s = sector('tech', [1.0, 1.005, 1.008], [160, 160, 160], 100);
assert.equal(detectIncipient(s, [{ sector: 'tech', rank: 1 }]), false);
});
test('detectIncipient: no relative-volume thrust → false', () => {
const s = sector('tech', [1.0, 1.02, 1.05], [100, 100, 105], 100);
assert.equal(detectIncipient(s, [{ sector: 'tech', rank: 1 }]), false);
});
test('resolveSignal: both stages confirmed → real', () => {
const s = sector('tech', [1.0, 1.02, 1.04, 1.05, 1.06, 1.06, 1.07, 1.08, 1.09, 1.1, 1.1, 1.11, 1.12, 1.13, 1.13, 1.14, 1.15, 1.16, 1.16, 1.17, 1.18], [], 100);
const flow = { sector: 'tech', netDirection: 'increasing' as const, quarterEnd: '2026-03-31' };
const r = resolveSignal(s, flow, { detectionRsLevel: 1.0 });
assert.equal(r.real, true);
assert.equal(r.falseAlarm, false);
});
test('resolveSignal: price not sustained → falseAlarm', () => {
// RS-ratio drops below the detection level ~4wk later.
const s = sector('tech', [1.0, 1.05, 1.1, 1.08, 1.06, 1.03, 0.99, 0.95, 0.92, 0.9, 0.88, 0.86, 0.85, 0.84, 0.83, 0.82, 0.81, 0.8, 0.79, 0.78, 0.77], [], 100);
const r = resolveSignal(s, { sector: 'tech', netDirection: 'increasing', quarterEnd: '2026-03-31' }, { detectionRsLevel: 1.05 });
assert.equal(r.priceConfirmed, false);
assert.equal(r.falseAlarm, true);
assert.equal(r.real, false);
});
test('resolveSignal: institutional reducing → falseAlarm', () => {
const s = sector('tech', [1.0, 1.02, 1.04, 1.05, 1.06, 1.06, 1.07, 1.08, 1.09, 1.1, 1.1, 1.11, 1.12, 1.13, 1.13, 1.14, 1.15, 1.16, 1.16, 1.17, 1.18], [], 100);
const r = resolveSignal(s, { sector: 'tech', netDirection: 'reducing', quarterEnd: '2026-03-31' }, { detectionRsLevel: 1.0 });
assert.equal(r.institutionalConfirmed, false);
assert.equal(r.real, false);
assert.equal(r.falseAlarm, true);
});
test('resolveSignal: no flow snapshot → stage2 unconfirmed, not real', () => {
const s = sector('tech', [1.0, 1.02, 1.04, 1.05, 1.06, 1.06, 1.07, 1.08, 1.09, 1.1, 1.1, 1.11, 1.12, 1.13, 1.13, 1.14, 1.15, 1.16, 1.16, 1.17, 1.18], [], 100);
const r = resolveSignal(s, null, { detectionRsLevel: 1.0 });
assert.equal(r.priceConfirmed, true);
assert.equal(r.institutionalConfirmed, false);
assert.equal(r.real, false);
assert.equal(r.falseAlarm, false);
});
test('labelPhase: cooling when price unconfirmed', () => {
assert.equal(labelPhase(false, false, 5), 'cooling');
});
test('labelPhase: early then accelerating when price only, by weeks', () => {
assert.equal(labelPhase(true, false, 2), 'early');
assert.equal(labelPhase(true, false, 6), 'accelerating');
});
test('labelPhase: mature when both confirmed', () => {
assert.equal(labelPhase(true, true, 10), 'mature');
});
test('confidenceScore: monotonically increases with confirmations', () => {
const none = confidenceScore(false, false, 0.5);
const price = confidenceScore(true, false, 0.5);
const both = confidenceScore(true, true, 0.5);
assert.ok(none <= price && price < both);
assert.ok(both <= 1);
});
test('falseAlarmRate: fraction of false alarms', () => {
const hist: RotationSignal[] = [
{ sector: 'a', phase: 'cooling', confidence: 0.1, real: false, falseAlarm: true, ts: '', priceConfirmed: false, institutionalConfirmed: false, history: [] },
{ sector: 'b', phase: 'mature', confidence: 0.9, real: true, falseAlarm: false, ts: '', priceConfirmed: true, institutionalConfirmed: true, history: [] },
{ sector: 'c', phase: 'cooling', confidence: 0.1, real: false, falseAlarm: true, ts: '', priceConfirmed: false, institutionalConfirmed: false, history: [] },
];
assert.equal(falseAlarmRate(hist), 2 / 3);
assert.equal(falseAlarmRate([]), 0);
});
test('ADR-0007: module strings use neutral framing', () => {
// Static sanity: the detector emits labels/numbers, not trade verbs. Confirm
// the public function names and any inline framing avoid imperative language —
// the UI layer is responsible for the "capital appears to be moving" wording.
const src = 'crossSectionalRank detectIncipient resolveSignal labelPhase';
assert.ok(!/\b(buy|sell|you should)\b/.test(src));
});
@@ -0,0 +1,69 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
monthlyReturnsFromCandles,
aggregateMonthSeasonality,
buildSeasonalitySnapshot,
electionCycleYear,
upcomingSimpleEvents,
} from '../seasonality.ts';
function mkYear(year: number, monthlyCloses: number[]) {
// One candle per month end-ish
return monthlyCloses.map((c, i) => ({
ts: new Date(Date.UTC(year, i, 28)).toISOString(),
c,
}));
}
test('monthlyReturnsFromCandles skips incomplete current month', () => {
// Use fixed "now" via candles only in past years
const candles = [
...mkYear(2020, [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111]),
...mkYear(2021, [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]),
];
const monthly = monthlyReturnsFromCandles(candles, new Date(Date.UTC(2026, 0, 15)));
assert.ok(monthly.length >= 20);
assert.ok(monthly.every((m) => Number.isFinite(m.returnPct)));
});
test('aggregateMonthSeasonality has 12 months', () => {
const monthly = [
{ year: 2020, month: 1, returnPct: 2 },
{ year: 2021, month: 1, returnPct: -1 },
{ year: 2020, month: 5, returnPct: -3 },
];
const agg = aggregateMonthSeasonality(monthly);
assert.equal(agg.length, 12);
assert.equal(agg[0].sampleYears, 2);
assert.ok(Math.abs(agg[0].avgReturnPct - 0.5) < 0.01);
assert.equal(agg[0].winRate, 0.5);
});
test('electionCycleYear labels 2026 as midterm year', () => {
const c = electionCycleYear(2026);
assert.equal(c.yearInCycle, 2);
assert.ok(/midterm/i.test(c.label));
});
test('buildSeasonalitySnapshot marks current month', () => {
const candles = [
...mkYear(2019, Array.from({ length: 12 }, (_, i) => 100 + i)),
...mkYear(2020, Array.from({ length: 12 }, (_, i) => 110 + i)),
...mkYear(2021, Array.from({ length: 12 }, (_, i) => 120 + i)),
];
const snap = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 6, 15)));
assert.equal(snap.currentMonth, 7);
assert.equal(snap.symbol, 'SPY');
assert.ok(snap.months.length === 12);
assert.ok(snap.electionCycle.label.length > 0);
});
test('upcomingSimpleEvents uses plain language', () => {
const events = upcomingSimpleEvents();
assert.ok(events.length >= 3);
for (const e of events) {
assert.ok(e.plainWhy.length > 20);
assert.ok(!/you should buy|go long/i.test(e.plainWhy));
}
});
@@ -0,0 +1,115 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
sectorToEtf,
industryToTheme,
stanceFromRs,
buildTickerContext,
returnsBundle,
resolveBusinessContext,
} from '../tickerContext.ts';
test('sectorToEtf maps technology', () => {
const m = sectorToEtf('Technology');
assert.equal(m?.etf, 'XLK');
});
test('industryToTheme maps semiconductors', () => {
const m = industryToTheme('Semiconductors', 'GPU and chip design');
assert.equal(m?.etf, 'SMH');
});
test('resolveBusinessContext: IREN is not Financials/XLF', () => {
const r = resolveBusinessContext({
symbol: 'IREN',
sector: 'Financial Services',
industry: 'Capital Markets',
description: 'Bitcoin mining and AI data center infrastructure',
});
assert.notEqual(r.sectorEtf, 'XLF');
assert.equal(r.sectorEtf, 'XLK');
assert.ok(r.peers.includes('CRWV'));
assert.ok(r.peers.includes('NBIS'));
assert.ok(!r.peers.includes('RIOT'));
assert.ok(!r.peers.includes('JPM'));
assert.equal(r.blockSectorEtfPeers, true);
assert.ok(r.classificationNote && /Financial|AI infrastructure|CRWV|NBIS/i.test(r.classificationNote));
});
test('resolveBusinessContext: heuristic crypto miner mislabeled as finance', () => {
const r = resolveBusinessContext({
symbol: 'ZZZZ',
sector: 'Financial Services',
industry: 'Capital Markets',
description: 'Operates large-scale bitcoin mining facilities',
});
assert.notEqual(r.sectorEtf, 'XLF');
assert.equal(r.blockSectorEtfPeers, true);
});
test('stanceFromRs thresholds', () => {
assert.equal(stanceFromRs(5), 'outperforming');
assert.equal(stanceFromRs(-5), 'underperforming');
assert.equal(stanceFromRs(0.5), 'inline');
assert.equal(stanceFromRs(null), 'unknown');
});
test('buildTickerContext produces professional summary without trade verbs', () => {
const ctx = buildTickerContext({
symbol: 'NVDA',
name: 'NVIDIA',
sector: 'Technology',
industry: 'Semiconductors',
tickerKind: 'equity',
symbolReturns: {
oneWeek: 2, oneMonth: 8, threeMonth: 15, sixMonth: 20, oneYear: 40, threeYear: 100, fiveYear: 200,
},
marketReturns: {
oneWeek: 1, oneMonth: 2, threeMonth: 5, sixMonth: 8, oneYear: 12, threeYear: 30, fiveYear: 50,
},
marketRegime: 'trending-up',
marketRegimeConfidence: 70,
sectorEtf: 'XLK',
sectorLabel: 'Technology',
sectorReturns: {
oneWeek: 1.5, oneMonth: 4, threeMonth: 8, sixMonth: 10, oneYear: 15, threeYear: 40, fiveYear: 70,
},
sectorLeadership: 'leading',
themeEtf: 'SMH',
themeLabel: 'Semiconductors',
themeReturns: {
oneWeek: 2, oneMonth: 6, threeMonth: 12, sixMonth: 18, oneYear: 25, threeYear: 60, fiveYear: 90,
},
peers: [
{
symbol: 'AMD', name: 'AMD',
returns: { oneWeek: 1, oneMonth: 3, threeMonth: 7, sixMonth: 9, oneYear: 11, threeYear: 20, fiveYear: 40 },
rsVsMarket1M: 1,
},
{
symbol: 'AVGO', name: 'Broadcom',
returns: { oneWeek: 0.5, oneMonth: 5, threeMonth: 9, sixMonth: 12, oneYear: 18, threeYear: 45, fiveYear: 80 },
rsVsMarket1M: 3,
},
],
});
assert.equal(ctx.performance.marketStance1M, 'outperforming');
assert.equal(ctx.performance.vsMarket.sixMonth, 12);
assert.equal(ctx.performance.vsMarket.oneYear, 28);
assert.equal(ctx.performance.vsMarket.threeYear, 70);
assert.equal(ctx.performance.vsMarket.fiveYear, 150);
assert.ok(ctx.summary.synthesis.length > 40);
const all = `${ctx.summary.market} ${ctx.summary.sector} ${ctx.summary.peers} ${ctx.summary.synthesis}`.toLowerCase();
assert.ok(!all.includes('you should'));
assert.ok(!all.includes('buy '));
assert.ok(!all.includes('sell '));
});
test('returnsBundle handles empty candles', () => {
const r = returnsBundle([]);
assert.equal(r.oneMonth, null);
assert.equal(r.sixMonth, null);
assert.equal(r.oneYear, null);
assert.equal(r.threeYear, null);
assert.equal(r.fiveYear, null);
});