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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user