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);
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
// Investor Flow — dashboardRollup (Slice 8: M4 dashboard rollup).
|
||||
//
|
||||
// Pure aggregation module. Reads across the user's watchlists + portfolio (via
|
||||
// repositories) and for each symbol computes a rollup row by combining:
|
||||
// - 13F institutional flow direction (from Slice 7 InstitutionFlowEngine),
|
||||
// - Form 4 insider flow net direction since last quarter,
|
||||
// - Holder-class roll detection (CIK/SIC-derived class changed between filings).
|
||||
//
|
||||
// All data is read from cache/DB. NO network calls. Fully testable with fixtures.
|
||||
//
|
||||
// ADR-0007 compliance: output uses neutral labels (increasing/reducing/flat/mixed).
|
||||
// No trade verbs (buy/sell/rotate into/follow this flow) anywhere in strings.
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { listSymbols } from '../db/watchlistRepository.ts';
|
||||
import { listHoldings } from '../db/portfolioRepository.ts';
|
||||
import type { InstitutionFlowEngine, CusipFlowResult } from './institutionFlowEngine.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Neutral conviction delta label (ADR-0007: never bullish/bearish). */
|
||||
export type ConvictionDelta = 'increasing' | 'reducing' | 'flat' | 'mixed';
|
||||
|
||||
/** Per-symbol rollup row for the M4 dashboard grid. */
|
||||
export interface DashboardRollupRow {
|
||||
/** Symbol (uppercase). */
|
||||
symbol: string;
|
||||
/** Net active conviction delta — normalized label. */
|
||||
convictionDelta: ConvictionDelta;
|
||||
/** Days since most recent Form 4 event of any kind (buys, sells, or grants). null if none. */
|
||||
insiderRecencyDays: number | null;
|
||||
/** True if the holder mix changed between consecutive 13F filings (new entrants/exits or class shift). */
|
||||
classRollFlag: boolean;
|
||||
/** Derived attention signal. null when conviction is flat (nothing notable). */
|
||||
alert: string | null;
|
||||
}
|
||||
|
||||
/** Internal aggregate used to compute a rollup row. */
|
||||
interface SymbolAgg {
|
||||
symbol: string;
|
||||
/** 13F flow direction across all institutional holders for this symbol. */
|
||||
flowDirection: 'increasing' | 'reducing' | 'flat';
|
||||
/** Form 4 net direction since last quarter. null = no recent insider activity. */
|
||||
insiderDirection: 'increasing' | 'reducing' | null;
|
||||
/** Most recent Form 4 event date of ANY kind (ISO string). null if none. */
|
||||
lastInsiderEvent: string | null;
|
||||
/** Most recent INFORMED Form 4 event date (buys/sells). null if none. */
|
||||
lastInformedEvent: string | null;
|
||||
/** Whether holder class rolled between consecutive filings. */
|
||||
classRolled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification helpers (pure functions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine conviction delta from 13F flow direction + insider net direction.
|
||||
*
|
||||
* Rules:
|
||||
* - If both point the same way → that direction.
|
||||
* - If they disagree → 'mixed'.
|
||||
* - If both flat/null → 'flat'.
|
||||
*
|
||||
* ADR-0007: labels are neutral descriptors, never "bullish" or "bearish".
|
||||
*/
|
||||
export function computeConvictionDelta(
|
||||
flowDir: 'increasing' | 'reducing' | 'flat',
|
||||
insiderDir: 'increasing' | 'reducing' | null,
|
||||
): ConvictionDelta {
|
||||
if (insiderDir === null) {
|
||||
return flowDir;
|
||||
}
|
||||
if (flowDir === insiderDir) {
|
||||
return flowDir;
|
||||
}
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the dominant 13F flow direction for a symbol across all institutional holders.
|
||||
*
|
||||
* Aggregates per-CUSIP deltas: if majority of holders increased, 'increasing';
|
||||
* if majority reduced, 'reducing'; otherwise 'flat'.
|
||||
*/
|
||||
export function aggregateFlowDirection(cusipResults: CusipFlowResult[]): 'increasing' | 'reducing' | 'flat' {
|
||||
if (cusipResults.length === 0) return 'flat';
|
||||
|
||||
let added = 0;
|
||||
let reduced = 0;
|
||||
|
||||
for (const r of cusipResults) {
|
||||
switch (r.classification) {
|
||||
case 'added to position':
|
||||
case 'new position':
|
||||
added += 1;
|
||||
break;
|
||||
case 'reduced position':
|
||||
case 'exited':
|
||||
reduced += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (added > reduced) return 'increasing';
|
||||
if (reduced > added) return 'reducing';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute holder class roll between consecutive 13F filings.
|
||||
*
|
||||
* A "class roll" occurs when the SIC-derived holder class (e.g., "Hedge Fund",
|
||||
* "Insurance Company", "Pension Fund") changes between two consecutive filings
|
||||
* for the same CIK. This is detected by comparing SIC codes of filers.
|
||||
*/
|
||||
export function detectClassRoll(
|
||||
prevFilers: Array<{ cik: string; sic: string | null }>,
|
||||
currFilers: Array<{ cik: string; sic: string | null }>,
|
||||
): boolean {
|
||||
if (prevFilers.length === 0 || currFilers.length === 0) return false;
|
||||
|
||||
const prevClass = classifyHolder(prevFilers);
|
||||
const currClass = classifyHolder(currFilers);
|
||||
|
||||
return prevClass !== currClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a set of filers into a dominant holder class by SIC code.
|
||||
* Returns a canonical class label.
|
||||
*/
|
||||
function classifyHolder(filers: Array<{ cik: string; sic: string | null }>): string {
|
||||
const sicCounts = new Map<string, number>();
|
||||
let unknownCount = 0;
|
||||
|
||||
for (const f of filers) {
|
||||
if (!f.sic || f.sic.length === 0) {
|
||||
unknownCount += 1;
|
||||
continue;
|
||||
}
|
||||
const sicGroup = f.sic.slice(0, Math.min(2, f.sic.length));
|
||||
sicCounts.set(sicGroup, (sicCounts.get(sicGroup) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// If unknowns dominate, return 'unknown'.
|
||||
if (unknownCount > filers.length / 2) return 'unknown';
|
||||
|
||||
// Return the SIC group with the highest count.
|
||||
let maxGroup = 'unknown';
|
||||
let maxCount = 0;
|
||||
for (const [group, count] of sicCounts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
maxGroup = group;
|
||||
}
|
||||
}
|
||||
return maxGroup;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core rollup engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* DashboardRollup — pure aggregation module.
|
||||
*
|
||||
* Reads across the user's watchlists + portfolio, queries 13F flow and Form 4
|
||||
* data from the database (via repositories), and computes a per-symbol rollup row.
|
||||
*
|
||||
* All data comes from the cache/DB layer. No network calls. Fully testable with
|
||||
* fixtures (FakeEdgarAdapter pattern).
|
||||
*/
|
||||
export class DashboardRollupEngine {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly flowEngine: InstitutionFlowEngine;
|
||||
|
||||
constructor(db: DatabaseSync, flowEngine: InstitutionFlowEngine) {
|
||||
this.db = db;
|
||||
this.flowEngine = flowEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute rollup rows for all symbols across the user's watchlists + portfolio.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Collect all unique symbols from watchlists + portfolio.
|
||||
* 2. For each symbol, query 13F flow data (institution_filings table).
|
||||
* 3. For each symbol, query Form 4 insider data (insider_transactions table).
|
||||
* 4. Compute conviction delta, insider recency, and class-roll flag.
|
||||
* 5. Return rows sorted by |convictionDelta| weight (mixed > increasing/reducing > flat).
|
||||
*
|
||||
* Note: conviction is always quarter-over-quarter because 13F data is only
|
||||
* reported quarterly. Form 4 insiders are queried for the last completed quarter.
|
||||
*/
|
||||
async computeRollup(userId: string): Promise<DashboardRollupRow[]> {
|
||||
// Step 1: Collect all unique symbols from watchlists + portfolio.
|
||||
const watchlistSymbols = listSymbols(this.db, userId).map((w) => w.symbol);
|
||||
const portfolioSymbols = listHoldings(this.db, userId).map((h) => h.symbol);
|
||||
const allSymbols = [...new Set([...watchlistSymbols, ...portfolioSymbols])];
|
||||
|
||||
if (allSymbols.length === 0) return [];
|
||||
|
||||
// Step 2: For each symbol, gather 13F flow + insider data.
|
||||
const rows: SymbolAgg[] = [];
|
||||
|
||||
for (const symbol of allSymbols) {
|
||||
const agg = await this.computeSymbolAgg(symbol);
|
||||
rows.push(agg);
|
||||
}
|
||||
|
||||
// Step 3: Compute conviction delta and format output rows.
|
||||
const out: DashboardRollupRow[] = [];
|
||||
|
||||
for (const r of rows) {
|
||||
const convictionDelta = computeConvictionDelta(r.flowDirection, r.insiderDirection);
|
||||
const insiderRecencyDays = r.lastInsiderEvent
|
||||
? Math.max(0, Math.floor((Date.now() - new Date(r.lastInsiderEvent).getTime()) / (1000 * 60 * 60 * 24)))
|
||||
: null;
|
||||
|
||||
// Derived attention signal from conviction (flat = nothing notable).
|
||||
let alert: string | null = null;
|
||||
if (convictionDelta === 'increasing') alert = 'accumulation';
|
||||
else if (convictionDelta === 'reducing') alert = 'distribution';
|
||||
else if (convictionDelta === 'mixed') alert = 'mixed signal';
|
||||
|
||||
out.push({
|
||||
symbol: r.symbol,
|
||||
convictionDelta,
|
||||
insiderRecencyDays,
|
||||
classRollFlag: r.classRolled,
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Sort by |convictionDelta| weight descending (mixed first, then increasing/reducing, then flat).
|
||||
const weight = (d: ConvictionDelta): number => {
|
||||
switch (d) {
|
||||
case 'mixed': return 3;
|
||||
case 'increasing':
|
||||
case 'reducing': return 2;
|
||||
case 'flat': return 1;
|
||||
}
|
||||
};
|
||||
|
||||
out.sort((a, b) => weight(b.convictionDelta) - weight(a.convictionDelta));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the aggregate data for a single symbol (used internally).
|
||||
*/
|
||||
private async computeSymbolAgg(symbol: string): Promise<SymbolAgg> {
|
||||
const d = this.db;
|
||||
|
||||
// 13F flow data: aggregate by quarter so we get complete quarterly totals.
|
||||
const quarters = d.prepare(
|
||||
`SELECT reported_quarter, SUM(shares) AS total_shares, COUNT(*) AS num_filings
|
||||
FROM institution_filings
|
||||
WHERE symbol = ?
|
||||
GROUP BY reported_quarter
|
||||
ORDER BY reported_quarter DESC
|
||||
LIMIT 10`
|
||||
).all(symbol) as Array<{ reported_quarter: string; total_shares: number; num_filings: number }>;
|
||||
|
||||
// Also fetch per-quarter filer info for class-roll detection.
|
||||
const raw = d.prepare(
|
||||
`SELECT filer_cik, filer_sic, shares, reported_quarter
|
||||
FROM institution_filings
|
||||
WHERE symbol = ?
|
||||
ORDER BY reported_quarter DESC
|
||||
LIMIT 200`
|
||||
).all(symbol) as Array<{ filer_cik: string; filer_sic: string | null; shares: number; reported_quarter: string }>;
|
||||
|
||||
const groups = new Map<string, Array<{ cik: string; sic: string | null; shares: number }>>();
|
||||
for (const f of raw) {
|
||||
const q = f.reported_quarter;
|
||||
if (!groups.has(q)) groups.set(q, []);
|
||||
groups.get(q)!.push({ cik: f.filer_cik, sic: f.filer_sic, shares: f.shares });
|
||||
}
|
||||
|
||||
let flowDirection: 'increasing' | 'reducing' | 'flat' = 'flat';
|
||||
|
||||
// Find two complete (non-current) quarters to compare. Skip the most recent
|
||||
// quarter if it has far fewer filers than the one before it — that means the
|
||||
// 13F window (45 days post-quarter-end) hasn't closed yet.
|
||||
let cmpIdx = 0;
|
||||
if (quarters.length >= 3 && quarters[0].num_filings < quarters[1].num_filings * 0.5) {
|
||||
cmpIdx = 1;
|
||||
}
|
||||
if (quarters.length >= cmpIdx + 2) {
|
||||
const currQ = quarters[cmpIdx];
|
||||
const prevQ = quarters[cmpIdx + 1];
|
||||
if (currQ.total_shares > prevQ.total_shares * 1.1) flowDirection = 'increasing';
|
||||
else if (currQ.total_shares < prevQ.total_shares * 0.9) flowDirection = 'reducing';
|
||||
} else if (quarters.length > cmpIdx) {
|
||||
if (quarters[cmpIdx].total_shares > 10000) flowDirection = 'increasing';
|
||||
}
|
||||
|
||||
// Class roll detection: compare filer classes between consecutive quarters.
|
||||
const sortedQ = [...groups.keys()].sort().reverse();
|
||||
let classRolled = false;
|
||||
if (sortedQ.length >= 2) {
|
||||
const prevFilers = groups.get(sortedQ[1])!.map((f) => ({ cik: f.cik, sic: f.sic }));
|
||||
const currFilers = groups.get(sortedQ[0])!.map((f) => ({ cik: f.cik, sic: f.sic }));
|
||||
classRolled = detectClassRoll(prevFilers, currFilers);
|
||||
// Also flag a roll if the set of filers itself changed (new entrants / exits).
|
||||
if (!classRolled) {
|
||||
const prevSet = new Set(prevFilers.map((f) => f.cik));
|
||||
const currSet = new Set(currFilers.map((f) => f.cik));
|
||||
let changed = prevSet.size !== currSet.size;
|
||||
if (!changed) {
|
||||
for (const cik of currSet) {
|
||||
if (!prevSet.has(cik)) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
classRolled = changed;
|
||||
}
|
||||
}
|
||||
|
||||
// Form 4 insider data: query insider_transactions for this symbol.
|
||||
const recentSinceDate = this.fiveYearsAgo();
|
||||
const insiderRows = d.prepare(
|
||||
`SELECT tx_date, classification, shares
|
||||
FROM insider_transactions
|
||||
WHERE symbol = ? AND tx_date >= ? AND (classification = 'informed_buy' OR classification = 'informed_sell')
|
||||
ORDER BY tx_date DESC`
|
||||
).all(symbol, recentSinceDate) as Array<{ tx_date: string; classification: string; shares: number }>;
|
||||
|
||||
// Most recent insider event of ANY kind (for recency) — independent of classification.
|
||||
const lastInsiderRow = d.prepare(
|
||||
`SELECT tx_date FROM insider_transactions WHERE symbol = ? ORDER BY tx_date DESC LIMIT 1`
|
||||
).get(symbol) as { tx_date: string } | undefined;
|
||||
const lastInsiderEvent = lastInsiderRow ? lastInsiderRow.tx_date : null;
|
||||
|
||||
// Filter to informed events only (exclude routine) for conviction direction.
|
||||
const informedEvents = insiderRows.filter(
|
||||
(r) => r.classification === 'informed_buy' || r.classification === 'informed_sell'
|
||||
);
|
||||
|
||||
let insiderDirection: 'increasing' | 'reducing' | null = null;
|
||||
let lastInformedEvent: string | null = null;
|
||||
|
||||
if (informedEvents.length > 0) {
|
||||
// Sort by date descending.
|
||||
informedEvents.sort((a, b) => b.tx_date.localeCompare(a.tx_date));
|
||||
|
||||
// Compute net direction.
|
||||
let buyShares = 0;
|
||||
let sellShares = 0;
|
||||
for (const e of informedEvents) {
|
||||
if (e.classification === 'informed_buy') {
|
||||
buyShares += e.shares;
|
||||
} else {
|
||||
sellShares += e.shares;
|
||||
}
|
||||
}
|
||||
|
||||
if (buyShares > sellShares) insiderDirection = 'increasing';
|
||||
else if (sellShares > buyShares) insiderDirection = 'reducing';
|
||||
|
||||
lastInformedEvent = informedEvents[0].tx_date;
|
||||
}
|
||||
|
||||
return {
|
||||
symbol,
|
||||
flowDirection,
|
||||
insiderDirection,
|
||||
lastInsiderEvent,
|
||||
lastInformedEvent,
|
||||
classRolled,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return a date 5 years ago (for Form 4 lookback). */
|
||||
private fiveYearsAgo(): string {
|
||||
const now = new Date();
|
||||
now.setFullYear(now.getFullYear() - 5);
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM summary generator (ADR-0005 voice, ADR-0007 footer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ADR-0005 voice profiles. */
|
||||
export interface VoiceProfile {
|
||||
name: string;
|
||||
weight: number; // 0-100, higher = more dominant voice
|
||||
}
|
||||
|
||||
/** Default voice mix: Alfred 70% / Druckenmiller 25% / neutral 5%. */
|
||||
export const DEFAULT_VOICE_PROFILES: VoiceProfile[] = [
|
||||
{ name: 'Alfred', weight: 70 },
|
||||
{ name: 'Druckenmiller', weight: 25 },
|
||||
{ name: 'Neutral', weight: 5 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a one-paragraph LLM dashboard_rollup summary.
|
||||
*
|
||||
* ADR-0005 voice (Alfred 70 / Druckenmiller 25), cited to cached sources,
|
||||
* ADR-0007 footer. Frame as "capital appears to be moving" (educational).
|
||||
* Never "follow this flow" — always observational/educational.
|
||||
*/
|
||||
export function generateDashboardRollupSummary(
|
||||
rows: DashboardRollupRow[],
|
||||
voiceProfiles: VoiceProfile[] = DEFAULT_VOICE_PROFILES,
|
||||
): string {
|
||||
if (rows.length === 0) {
|
||||
return formatSummary([], voiceProfiles);
|
||||
}
|
||||
|
||||
return formatSummary(rows, voiceProfiles);
|
||||
}
|
||||
|
||||
function formatSummary(
|
||||
rows: DashboardRollupRow[],
|
||||
voiceProfiles: VoiceProfile[],
|
||||
): string {
|
||||
const activeRows = rows.filter(
|
||||
(r) => r.convictionDelta !== 'flat' || r.insiderRecencyDays !== null || r.classRollFlag
|
||||
);
|
||||
|
||||
if (activeRows.length === 0) {
|
||||
return (
|
||||
"Across the current watchlist and portfolio, institutional capital appears to be moving in place — no net directional shift is observable across 13F holdings or Form 4 insider activity in this window. " +
|
||||
"All symbols show flat conviction deltas with no recent informed insider events or holder-class rolls. " +
|
||||
"This is consistent with a consolidation phase; no material reallocation signals are present. " +
|
||||
ADR0007_FOOTER
|
||||
);
|
||||
}
|
||||
|
||||
const mixed = rows.filter((r) => r.convictionDelta === 'mixed');
|
||||
const increasing = rows.filter((r) => r.convictionDelta === 'increasing');
|
||||
const reducing = rows.filter((r) => r.convictionDelta === 'reducing');
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (mixed.length > 0) {
|
||||
const syms = mixed.map((r) => r.symbol).join(', ');
|
||||
parts.push(
|
||||
`Capital appears to be moving in conflicting directions across ${mixed.length} name${mixed.length === 1 ? '' : 's'} (${syms}) — institutional flow and insider activity are not aligned, producing mixed conviction signals.`
|
||||
);
|
||||
}
|
||||
|
||||
if (increasing.length > 0) {
|
||||
const syms = increasing.map((r) => r.symbol).join(', ');
|
||||
parts.push(
|
||||
`Institutional capital appears to be moving into ${increasing.length} name${increasing.length === 1 ? '' : 's'} (${syms}), as reflected in 13F position increases.`
|
||||
);
|
||||
}
|
||||
|
||||
if (reducing.length > 0) {
|
||||
const syms = reducing.map((r) => r.symbol).join(', ');
|
||||
parts.push(
|
||||
`Institutional capital appears to be moving out of ${reducing.length} name${reducing.length === 1 ? '' : 's'} (${syms}), as reflected in 13F position reductions.`
|
||||
);
|
||||
}
|
||||
|
||||
// Insider recency highlights.
|
||||
const recentInsiders = rows.filter(
|
||||
(r) => r.insiderRecencyDays !== null && r.insiderRecencyDays <= 90
|
||||
);
|
||||
if (recentInsiders.length > 0) {
|
||||
const detail = recentInsiders.map((r) =>
|
||||
`${r.symbol} (${r.insiderRecencyDays}d)`
|
||||
).join('; ');
|
||||
parts.push(`Recent informed Form 4 activity observed: ${detail}.`);
|
||||
}
|
||||
|
||||
// Class-roll flags.
|
||||
const classRolls = rows.filter((r) => r.classRollFlag);
|
||||
if (classRolls.length > 0) {
|
||||
const syms = classRolls.map((r) => r.symbol).join(', ');
|
||||
parts.push(
|
||||
`Holder-class roll detected in ${classRolls.length} name${classRolls.length === 1 ? '' : 's'} (${syms}) — the composition of institutional holders has shifted between consecutive 13F filings.`
|
||||
);
|
||||
}
|
||||
|
||||
const voice = pickVoice(voiceProfiles);
|
||||
return `${voice} ${parts.join(' ')} ` + ADR0007_FOOTER;
|
||||
}
|
||||
|
||||
function pickVoice(profiles: VoiceProfile[]): string {
|
||||
const totalWeight = profiles.reduce((sum, p) => sum + p.weight, 0);
|
||||
let rand = Math.random() * totalWeight;
|
||||
for (const p of profiles) {
|
||||
rand -= p.weight;
|
||||
if (rand <= 0) {
|
||||
switch (p.name) {
|
||||
case 'Alfred':
|
||||
return "Observation:";
|
||||
case 'Druckenmiller':
|
||||
return "Noting that";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** ADR-0007 footer: no trade verbs, purely observational. */
|
||||
export const ADR0007_FOOTER =
|
||||
"— This summary is informational. It describes observed capital flows and does not constitute a recommendation to buy, sell, or hold any security.";
|
||||
@@ -0,0 +1,229 @@
|
||||
// Static top-holdings fallback for Market Outlook sector ETFs.
|
||||
// Used when Yahoo is rate-limited and kv_cache is cold.
|
||||
// Live Yahoo composition (when available) overwrites kv_cache and is preferred.
|
||||
// Weights are fractional (0.12 = 12%). Snapshot from Yahoo quoteSummary topHoldings.
|
||||
|
||||
export type StaticHolding = { symbol: string; holdingName: string; holdingPercent: number };
|
||||
|
||||
export const ETF_TOP_HOLDINGS_FALLBACK: Record<string, StaticHolding[]> = {
|
||||
"EEM": [
|
||||
{ symbol: "2330.TW", holdingName: "Taiwan Semiconductor Manufacturing Co Ltd", holdingPercent: 0.1505295 },
|
||||
{ symbol: "005930.KS", holdingName: "Samsung Electronics Co Ltd", holdingPercent: 0.081435606 },
|
||||
{ symbol: "000660.KS", holdingName: "SK Hynix Inc", holdingPercent: 0.076305196 },
|
||||
{ symbol: "0700.HK", holdingName: "Tencent Holdings Ltd", holdingPercent: 0.027267002 },
|
||||
{ symbol: "9988.HK", holdingName: "Alibaba Group Holding Ltd Ordinary Shares", holdingPercent: 0.0160104 },
|
||||
{ symbol: "2454.TW", holdingName: "MediaTek Inc", holdingPercent: 0.0155354 },
|
||||
{ symbol: "2308.TW", holdingName: "Delta Electronics Inc", holdingPercent: 0.009632 },
|
||||
{ symbol: "005935", holdingName: "Samsung Electronics Co Ltd Participating Preferred", holdingPercent: 0.0088677 },
|
||||
{ symbol: "402340.KS", holdingName: "SK Square", holdingPercent: 0.0081718 },
|
||||
{ symbol: "2317.TW", holdingName: "Hon Hai Precision Industry Co Ltd", holdingPercent: 0.0077757 },
|
||||
],
|
||||
"EFA": [
|
||||
{ symbol: "ASML.AS", holdingName: "ASML Holding NV", holdingPercent: 0.035229 },
|
||||
{ symbol: "HSBA.L", holdingName: "HSBC Holdings PLC", holdingPercent: 0.015048999 },
|
||||
{ symbol: "ROP.SW", holdingName: "Roche Holding AG Ordinary Shares new", holdingPercent: 0.0133673 },
|
||||
{ symbol: "NOVN.SW", holdingName: "Novartis AG Registered Shares", holdingPercent: 0.0132230995 },
|
||||
{ symbol: "AZN.L", holdingName: "AstraZeneca PLC", holdingPercent: 0.0130509995 },
|
||||
{ symbol: "NESN.SW", holdingName: "Nestle SA", holdingPercent: 0.0122103 },
|
||||
{ symbol: "SIE.DE", holdingName: "Siemens AG", holdingPercent: 0.0110133 },
|
||||
{ symbol: "SHEL.L", holdingName: "Shell PLC", holdingPercent: 0.0100819 },
|
||||
{ symbol: "8035.T", holdingName: "Tokyo Electron Ltd", holdingPercent: 0.0097859 },
|
||||
{ symbol: "8306.T", holdingName: "Mitsubishi UFJ Financial Group Inc", holdingPercent: 0.0097196 },
|
||||
],
|
||||
"IWD": [
|
||||
{ symbol: "AMZN", holdingName: "Amazon.com Inc", holdingPercent: 0.059368 },
|
||||
{ symbol: "AAPL", holdingName: "Apple Inc", holdingPercent: 0.053715803 },
|
||||
{ symbol: "MSFT", holdingName: "Microsoft Corp", holdingPercent: 0.0388254 },
|
||||
{ symbol: "BRK-B", holdingName: "Berkshire Hathaway Inc Class B", holdingPercent: 0.0261103 },
|
||||
{ symbol: "JPM", holdingName: "JPMorgan Chase & Co", holdingPercent: 0.0245232 },
|
||||
{ symbol: "INTC", holdingName: "Intel Corp", holdingPercent: 0.0171566 },
|
||||
{ symbol: "JNJ", holdingName: "Johnson & Johnson", holdingPercent: 0.017146999 },
|
||||
{ symbol: "XOM", holdingName: "Exxon Mobil Corp", holdingPercent: 0.0159427 },
|
||||
{ symbol: "CSCO", holdingName: "Cisco Systems Inc", holdingPercent: 0.0130032 },
|
||||
{ symbol: "WMT", holdingName: "Walmart Inc", holdingPercent: 0.012733701 },
|
||||
],
|
||||
"IWF": [
|
||||
{ symbol: "NVDA", holdingName: "NVIDIA Corp", holdingPercent: 0.1383001 },
|
||||
{ symbol: "AAPL", holdingName: "Apple Inc", holdingPercent: 0.0671556 },
|
||||
{ symbol: "GOOGL", holdingName: "Alphabet Inc Class A", holdingPercent: 0.061699 },
|
||||
{ symbol: "AVGO", holdingName: "Broadcom Inc", holdingPercent: 0.052069303 },
|
||||
{ symbol: "GOOG", holdingName: "Alphabet Inc Class C", holdingPercent: 0.049777497 },
|
||||
{ symbol: "MSFT", holdingName: "Microsoft Corp", holdingPercent: 0.0410567 },
|
||||
{ symbol: "MU", holdingName: "Micron Technology Inc", holdingPercent: 0.0385532 },
|
||||
{ symbol: "TSLA", holdingName: "Tesla Inc", holdingPercent: 0.0364054 },
|
||||
{ symbol: "META", holdingName: "Meta Platforms Inc Class A", holdingPercent: 0.0300154 },
|
||||
{ symbol: "LLY", holdingName: "Eli Lilly and Co", holdingPercent: 0.0283729 },
|
||||
],
|
||||
"IWM": [
|
||||
{ symbol: "MOG-A", holdingName: "Moog Inc Class A", holdingPercent: 0.0037575 },
|
||||
{ symbol: "HUT", holdingName: "Hut 8 Corp", holdingPercent: 0.0036912 },
|
||||
{ symbol: "VSAT", holdingName: "Viasat Inc", holdingPercent: 0.0035304 },
|
||||
{ symbol: "BTSG", holdingName: "BrightSpring Health Services Inc", holdingPercent: 0.0035218 },
|
||||
{ symbol: "CYTK", holdingName: "Cytokinetics Inc", holdingPercent: 0.003486 },
|
||||
{ symbol: "MXL", holdingName: "MaxLinear Inc", holdingPercent: 0.0034322 },
|
||||
{ symbol: "AGX", holdingName: "Argan Inc", holdingPercent: 0.0033962 },
|
||||
{ symbol: "UMBF", holdingName: "UMB Financial Corp", holdingPercent: 0.0032640002 },
|
||||
{ symbol: "FROG", holdingName: "JFrog Ltd Ordinary Shares", holdingPercent: 0.0030702 },
|
||||
{ symbol: "RIOT", holdingName: "Riot Platforms Inc", holdingPercent: 0.0030254 },
|
||||
],
|
||||
"SMH": [
|
||||
{ symbol: "NVDA", holdingName: "NVIDIA Corp", holdingPercent: 0.177539 },
|
||||
{ symbol: "TSM", holdingName: "Taiwan Semiconductor Manufacturing Co Ltd ADR", holdingPercent: 0.0919043 },
|
||||
{ symbol: "MU", holdingName: "Micron Technology Inc", holdingPercent: 0.058447197 },
|
||||
{ symbol: "AMAT", holdingName: "Applied Materials Inc", holdingPercent: 0.057252403 },
|
||||
{ symbol: "AMD", holdingName: "Advanced Micro Devices Inc", holdingPercent: 0.0542772 },
|
||||
{ symbol: "AVGO", holdingName: "Broadcom Inc", holdingPercent: 0.0540529 },
|
||||
{ symbol: "KLAC", holdingName: "KLA Corp", holdingPercent: 0.0533983 },
|
||||
{ symbol: "LRCX", holdingName: "Lam Research Corp", holdingPercent: 0.0523072 },
|
||||
{ symbol: "INTC", holdingName: "Intel Corp", holdingPercent: 0.050408803 },
|
||||
{ symbol: "ASML", holdingName: "ASML Holding NV ADR", holdingPercent: 0.049543098 },
|
||||
],
|
||||
"XBI": [
|
||||
{ symbol: "APGE", holdingName: "Apogee Therapeutics Inc", holdingPercent: 0.014881399 },
|
||||
{ symbol: "MRNA", holdingName: "Moderna Inc", holdingPercent: 0.0141217 },
|
||||
{ symbol: "TWST", holdingName: "Twist Bioscience Corp", holdingPercent: 0.0140783 },
|
||||
{ symbol: "ORKA", holdingName: "Oruka Therapeutics Inc", holdingPercent: 0.0137846 },
|
||||
{ symbol: "KYMR", holdingName: "Kymera Therapeutics Inc Ordinary Shares", holdingPercent: 0.0135944 },
|
||||
{ symbol: "VKTX", holdingName: "Viking Therapeutics Inc", holdingPercent: 0.0130375 },
|
||||
{ symbol: "PRAX", holdingName: "Praxis Precision Medicines Inc Ordinary Shares", holdingPercent: 0.012902 },
|
||||
{ symbol: "ERAS", holdingName: "Erasca Inc", holdingPercent: 0.0127081005 },
|
||||
{ symbol: "RVMD", holdingName: "Revolution Medicines Inc Ordinary Shares", holdingPercent: 0.0120453 },
|
||||
{ symbol: "RYTM", holdingName: "Rhythm Pharmaceuticals Inc", holdingPercent: 0.0120141 },
|
||||
],
|
||||
"XLB": [
|
||||
{ symbol: "LIN", holdingName: "Linde PLC", holdingPercent: 0.1404455 },
|
||||
{ symbol: "NEM", holdingName: "Newmont Corp", holdingPercent: 0.0583657 },
|
||||
{ symbol: "FCX", holdingName: "Freeport-McMoRan Inc", holdingPercent: 0.0529213 },
|
||||
{ symbol: "CTVA", holdingName: "Corteva Inc", holdingPercent: 0.049528196 },
|
||||
{ symbol: "SHW", holdingName: "Sherwin-Williams Co", holdingPercent: 0.0493363 },
|
||||
{ symbol: "ECL", holdingName: "Ecolab Inc", holdingPercent: 0.0472095 },
|
||||
{ symbol: "VMC", holdingName: "Vulcan Materials Co", holdingPercent: 0.0471143 },
|
||||
{ symbol: "CRH", holdingName: "CRH PLC", holdingPercent: 0.0466028 },
|
||||
{ symbol: "APD", holdingName: "Air Products and Chemicals Inc", holdingPercent: 0.046171598 },
|
||||
{ symbol: "MLM", holdingName: "Martin Marietta Materials Inc", holdingPercent: 0.0454218 },
|
||||
],
|
||||
"XLC": [
|
||||
{ symbol: "META", holdingName: "Meta Platforms Inc Class A", holdingPercent: 0.19881809 },
|
||||
{ symbol: "GOOGL", holdingName: "Alphabet Inc Class A", holdingPercent: 0.13065991 },
|
||||
{ symbol: "GOOG", holdingName: "Alphabet Inc Class C", holdingPercent: 0.1041273 },
|
||||
{ symbol: "TTWO", holdingName: "Take-Two Interactive Software Inc", holdingPercent: 0.0524315 },
|
||||
{ symbol: "NFLX", holdingName: "Netflix Inc", holdingPercent: 0.0483219 },
|
||||
{ symbol: "CMCSA", holdingName: "Comcast Corp Class A", holdingPercent: 0.0469748 },
|
||||
{ symbol: "WBD", holdingName: "Warner Bros. Discovery Inc Ordinary Shares - Class A", holdingPercent: 0.0466169 },
|
||||
{ symbol: "EA", holdingName: "Electronic Arts Inc", holdingPercent: 0.046279896 },
|
||||
{ symbol: "DIS", holdingName: "The Walt Disney Co", holdingPercent: 0.044767197 },
|
||||
{ symbol: "TMUS", holdingName: "T-Mobile US Inc", holdingPercent: 0.0414606 },
|
||||
],
|
||||
"XLE": [
|
||||
{ symbol: "XOM", holdingName: "Exxon Mobil Corp", holdingPercent: 0.2025335 },
|
||||
{ symbol: "CVX", holdingName: "Chevron Corp", holdingPercent: 0.1437405 },
|
||||
{ symbol: "COP", holdingName: "ConocoPhillips", holdingPercent: 0.0586663 },
|
||||
{ symbol: "SLB", holdingName: "SLB Ltd", holdingPercent: 0.045293197 },
|
||||
{ symbol: "WMB", holdingName: "Williams Companies Inc", holdingPercent: 0.0449115 },
|
||||
{ symbol: "MPC", holdingName: "Marathon Petroleum Corp", holdingPercent: 0.0445644 },
|
||||
{ symbol: "EOG", holdingName: "EOG Resources Inc", holdingPercent: 0.0444317 },
|
||||
{ symbol: "VLO", holdingName: "Valero Energy Corp", holdingPercent: 0.0441841 },
|
||||
{ symbol: "PSX", holdingName: "Phillips 66", holdingPercent: 0.0438871 },
|
||||
{ symbol: "KMI", holdingName: "Kinder Morgan Inc Class P", holdingPercent: 0.043441802 },
|
||||
],
|
||||
"XLF": [
|
||||
{ symbol: "BRK-B", holdingName: "Berkshire Hathaway Inc Class B", holdingPercent: 0.120706104 },
|
||||
{ symbol: "JPM", holdingName: "JPMorgan Chase & Co", holdingPercent: 0.1154039 },
|
||||
{ symbol: "V", holdingName: "Visa Inc Class A", holdingPercent: 0.0749232 },
|
||||
{ symbol: "MA", holdingName: "Mastercard Inc Class A", holdingPercent: 0.0545263 },
|
||||
{ symbol: "BAC", holdingName: "Bank of America Corp", holdingPercent: 0.048948202 },
|
||||
{ symbol: "GS", holdingName: "The Goldman Sachs Group Inc", holdingPercent: 0.0392524 },
|
||||
{ symbol: "WFC", holdingName: "Wells Fargo & Co", holdingPercent: 0.0332747 },
|
||||
{ symbol: "MS", holdingName: "Morgan Stanley", holdingPercent: 0.0329716 },
|
||||
{ symbol: "C", holdingName: "Citigroup Inc", holdingPercent: 0.031409 },
|
||||
{ symbol: "AXP", holdingName: "American Express Co", holdingPercent: 0.0236856 },
|
||||
],
|
||||
"XLI": [
|
||||
{ symbol: "CAT", holdingName: "Caterpillar Inc", holdingPercent: 0.08508819 },
|
||||
{ symbol: "GE", holdingName: "GE Aerospace", holdingPercent: 0.067642696 },
|
||||
{ symbol: "GEV", holdingName: "GE Vernova Inc", holdingPercent: 0.0547685 },
|
||||
{ symbol: "RTX", holdingName: "RTX Corp", holdingPercent: 0.044323802 },
|
||||
{ symbol: "BA", holdingName: "Boeing Co", holdingPercent: 0.029602202 },
|
||||
{ symbol: "ETN", holdingName: "Eaton Corp PLC", holdingPercent: 0.0287034 },
|
||||
{ symbol: "UNP", holdingName: "Union Pacific Corp", holdingPercent: 0.0280144 },
|
||||
{ symbol: "DE", holdingName: "Deere & Co", holdingPercent: 0.0276425 },
|
||||
{ symbol: "UBER", holdingName: "Uber Technologies Inc", holdingPercent: 0.0254815 },
|
||||
{ symbol: "VRT", holdingName: "Vertiv Holdings Co Class A", holdingPercent: 0.0223104 },
|
||||
],
|
||||
"XLK": [
|
||||
{ symbol: "NVDA", holdingName: "NVIDIA Corp", holdingPercent: 0.1264092 },
|
||||
{ symbol: "AAPL", holdingName: "Apple Inc", holdingPercent: 0.110854104 },
|
||||
{ symbol: "MSFT", holdingName: "Microsoft Corp", holdingPercent: 0.072276905 },
|
||||
{ symbol: "AMD", holdingName: "Advanced Micro Devices Inc", holdingPercent: 0.0470794 },
|
||||
{ symbol: "MU", holdingName: "Micron Technology Inc", holdingPercent: 0.0467562 },
|
||||
{ symbol: "AVGO", holdingName: "Broadcom Inc", holdingPercent: 0.0466514 },
|
||||
{ symbol: "INTC", holdingName: "Intel Corp", holdingPercent: 0.042032 },
|
||||
{ symbol: "AMAT", holdingName: "Applied Materials Inc", holdingPercent: 0.0365592 },
|
||||
{ symbol: "LRCX", holdingName: "Lam Research Corp", holdingPercent: 0.034528602 },
|
||||
{ symbol: "CSCO", holdingName: "Cisco Systems Inc", holdingPercent: 0.029561501 },
|
||||
],
|
||||
"XLP": [
|
||||
{ symbol: "WMT", holdingName: "Walmart Inc", holdingPercent: 0.1078668 },
|
||||
{ symbol: "COST", holdingName: "Costco Wholesale Corp", holdingPercent: 0.0901589 },
|
||||
{ symbol: "PG", holdingName: "Procter & Gamble Co", holdingPercent: 0.0741792 },
|
||||
{ symbol: "KO", holdingName: "Coca-Cola Co", holdingPercent: 0.068364 },
|
||||
{ symbol: "PM", holdingName: "Philip Morris International Inc", holdingPercent: 0.0612522 },
|
||||
{ symbol: "CL", holdingName: "Colgate-Palmolive Co", holdingPercent: 0.046898097 },
|
||||
{ symbol: "MO", holdingName: "Altria Group Inc", holdingPercent: 0.0452707 },
|
||||
{ symbol: "MNST", holdingName: "Monster Beverage Corp", holdingPercent: 0.0444723 },
|
||||
{ symbol: "PEP", holdingName: "PepsiCo Inc", holdingPercent: 0.043169096 },
|
||||
{ symbol: "MDLZ", holdingName: "Mondelez International Inc Class A", holdingPercent: 0.0414677 },
|
||||
],
|
||||
"XLRE": [
|
||||
{ symbol: "WELL", holdingName: "Welltower Inc", holdingPercent: 0.109932296 },
|
||||
{ symbol: "PLD", holdingName: "Prologis Inc", holdingPercent: 0.0866608 },
|
||||
{ symbol: "EQIX", holdingName: "Equinix Inc", holdingPercent: 0.0705398 },
|
||||
{ symbol: "AMT", holdingName: "American Tower Corp", holdingPercent: 0.0522868 },
|
||||
{ symbol: "SPG", holdingName: "Simon Property Group Inc", holdingPercent: 0.0497612 },
|
||||
{ symbol: "O", holdingName: "Realty Income Corp", holdingPercent: 0.0454382 },
|
||||
{ symbol: "DLR", holdingName: "Digital Realty Trust Inc", holdingPercent: 0.045244798 },
|
||||
{ symbol: "PSA", holdingName: "Public Storage", holdingPercent: 0.0447665 },
|
||||
{ symbol: "VTR", holdingName: "Ventas Inc", holdingPercent: 0.044660904 },
|
||||
{ symbol: "CBRE", holdingName: "CBRE Group Inc Class A", holdingPercent: 0.0408003 },
|
||||
],
|
||||
"XLU": [
|
||||
{ symbol: "NEE", holdingName: "NextEra Energy Inc", holdingPercent: 0.12862429 },
|
||||
{ symbol: "SO", holdingName: "Southern Co", holdingPercent: 0.075822204 },
|
||||
{ symbol: "DUK", holdingName: "Duke Energy Corp", holdingPercent: 0.0693479 },
|
||||
{ symbol: "CEG", holdingName: "Constellation Energy Corp", holdingPercent: 0.0557973 },
|
||||
{ symbol: "AEP", holdingName: "American Electric Power Co Inc", holdingPercent: 0.052311704 },
|
||||
{ symbol: "SRE", holdingName: "Sempra", holdingPercent: 0.0425889 },
|
||||
{ symbol: "D", holdingName: "Dominion Energy Inc", holdingPercent: 0.0422058 },
|
||||
{ symbol: "ETR", holdingName: "Entergy Corp", holdingPercent: 0.0369595 },
|
||||
{ symbol: "VST", holdingName: "Vistra Corp", holdingPercent: 0.0353326 },
|
||||
{ symbol: "XEL", holdingName: "Xcel Energy Inc", holdingPercent: 0.035227798 },
|
||||
],
|
||||
"XLV": [
|
||||
{ symbol: "LLY", holdingName: "Eli Lilly and Co", holdingPercent: 0.1648407 },
|
||||
{ symbol: "JNJ", holdingName: "Johnson & Johnson", holdingPercent: 0.106212996 },
|
||||
{ symbol: "ABBV", holdingName: "AbbVie Inc", holdingPercent: 0.0772406 },
|
||||
{ symbol: "UNH", holdingName: "UnitedHealth Group Inc", holdingPercent: 0.0655752 },
|
||||
{ symbol: "MRK", holdingName: "Merck & Co Inc", holdingPercent: 0.0551376 },
|
||||
{ symbol: "AMGN", holdingName: "Amgen Inc", holdingPercent: 0.0339539 },
|
||||
{ symbol: "TMO", holdingName: "Thermo Fisher Scientific Inc", holdingPercent: 0.0323688 },
|
||||
{ symbol: "ABT", holdingName: "Abbott Laboratories", holdingPercent: 0.0274585 },
|
||||
{ symbol: "GILD", holdingName: "Gilead Sciences Inc", holdingPercent: 0.027251698 },
|
||||
{ symbol: "ISRG", holdingName: "Intuitive Surgical Inc", holdingPercent: 0.0244691 },
|
||||
],
|
||||
"XLY": [
|
||||
{ symbol: "AMZN", holdingName: "Amazon.com Inc", holdingPercent: 0.2220244 },
|
||||
{ symbol: "TSLA", holdingName: "Tesla Inc", holdingPercent: 0.19623369 },
|
||||
{ symbol: "HD", holdingName: "The Home Depot Inc", holdingPercent: 0.0582148 },
|
||||
{ symbol: "MCD", holdingName: "McDonald's Corp", holdingPercent: 0.041503202 },
|
||||
{ symbol: "TJX", holdingName: "TJX Companies Inc", holdingPercent: 0.039196897 },
|
||||
{ symbol: "BKNG", holdingName: "Booking Holdings Inc", holdingPercent: 0.0343353 },
|
||||
{ symbol: "LOW", holdingName: "Lowe's Companies Inc", holdingPercent: 0.0306992 },
|
||||
{ symbol: "SBUX", holdingName: "Starbucks Corp", holdingPercent: 0.0289536 },
|
||||
{ symbol: "MAR", holdingName: "Marriott International Inc Class A", holdingPercent: 0.020163901 },
|
||||
{ symbol: "RCL", holdingName: "Royal Caribbean Group", holdingPercent: 0.0196888 },
|
||||
],
|
||||
};
|
||||
|
||||
export function staticHoldingsFor(etfSymbol: string): StaticHolding[] {
|
||||
return ETF_TOP_HOLDINGS_FALLBACK[etfSymbol.toUpperCase()] ?? [];
|
||||
}
|
||||
@@ -134,3 +134,53 @@ export function emaFromCandles(
|
||||
}
|
||||
return ema(prices, period);
|
||||
}
|
||||
|
||||
/**
|
||||
* MACD (Moving Average Convergence Divergence).
|
||||
* Returns arrays aligned to `values`, with `undefined` for indices before
|
||||
* the seed period is complete.
|
||||
*
|
||||
* - macdLine: 12-period EMA - 26-period EMA
|
||||
* - signalLine: 9-period EMA of macdLine
|
||||
* - histogram: macdLine - signalLine
|
||||
*/
|
||||
export function macd(
|
||||
values: number[],
|
||||
fastPeriod: number = 12,
|
||||
slowPeriod: number = 26,
|
||||
signalPeriod: number = 9,
|
||||
): { macdLine: (number | undefined)[]; signalLine: (number | undefined)[]; histogram: (number | undefined)[] } {
|
||||
const fastEma = ema(values, fastPeriod);
|
||||
const slowEma = ema(values, slowPeriod);
|
||||
|
||||
const macdLine: (number | undefined)[] = new Array(values.length);
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (fastEma[i] !== undefined && slowEma[i] !== undefined) {
|
||||
macdLine[i] = fastEma[i]! - slowEma[i]!;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal line: 9-period EMA of macdLine (only where macdLine is defined).
|
||||
const validMacd: number[] = [];
|
||||
const validIndices: number[] = [];
|
||||
for (let i = 0; i < macdLine.length; i++) {
|
||||
if (macdLine[i] !== undefined) {
|
||||
validMacd.push(macdLine[i]!);
|
||||
validIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const signalEma = ema(validMacd, signalPeriod);
|
||||
const signalLine: (number | undefined)[] = new Array(values.length);
|
||||
const histogram: (number | undefined)[] = new Array(values.length);
|
||||
|
||||
for (let i = 0; i < signalEma.length; i++) {
|
||||
if (signalEma[i] !== undefined) {
|
||||
const idx = validIndices[i];
|
||||
signalLine[idx] = signalEma[i]!;
|
||||
histogram[idx] = macdLine[idx]! - signalLine[idx]!;
|
||||
}
|
||||
}
|
||||
|
||||
return { macdLine, signalLine, histogram };
|
||||
}
|
||||
|
||||
@@ -1,18 +1,69 @@
|
||||
// Investor Flow — InstitutionFlowEngine (Slice 7: institution-flow-engine M4 + insider-stream M5)
|
||||
//
|
||||
// Pure analysis module. Takes parsed 13F holdings and Form 4 transactions from
|
||||
// EdgarAdapter and produces:
|
||||
// - Per-CUSIP net position changes between two consecutive 13F filings.
|
||||
// - Summarized Form 4 transaction activity for a reporter over a date range.
|
||||
//
|
||||
// ADR-0007 compliance: all output uses neutral language. No imperative trade verbs
|
||||
// (buy/sell/you should/add to your/rotate into/action needed). Classifications
|
||||
// describe observed state, not prescriptions.
|
||||
// Deep module for analyzing institutional ownership and insider activity.
|
||||
// Classifies holders by CIK/SIC metadata, detects 10b5-1 plans, and computes
|
||||
// buy-zone estimates. Pure analysis module — no I/O in core functions.
|
||||
|
||||
import type { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// Types — Holder Classification (5 classes via CIK/SIC metadata)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Five institutional holder classes based on CIK/SIC metadata. */
|
||||
export type HolderClass =
|
||||
| 'Hedge Fund'
|
||||
| 'Pension Fund'
|
||||
| 'Mutual Fund'
|
||||
| 'Insider'
|
||||
| 'Index Fund';
|
||||
|
||||
/** Plain-English description for each holder class. */
|
||||
export const HOLDER_CLASS_DESCRIPTIONS: Record<HolderClass, string> = {
|
||||
'Hedge Fund': 'Active fund managing pooled capital with flexible strategies',
|
||||
'Pension Fund': 'Retirement fund managing assets for employees or public workers',
|
||||
'Mutual Fund': 'Diversified fund pooling investor capital for broad market exposure',
|
||||
'Insider': 'Corporate officer, director, or major shareholder with access to private information',
|
||||
'Index Fund': 'Passive fund tracking a specific market index',
|
||||
};
|
||||
|
||||
/** SIC code ranges for holder classification. */
|
||||
const SIC_RANGES: Record<HolderClass, { min: number; max: number }> = {
|
||||
'Hedge Fund': { min: 6211, max: 6212 },
|
||||
'Pension Fund': { min: 5251, max: 5251 },
|
||||
'Mutual Fund': { min: 5231, max: 5231 },
|
||||
'Insider': { min: 0, max: 0 }, // Special handling — not SIC-based
|
||||
'Index Fund': { min: 6281, max: 6281 },
|
||||
};
|
||||
|
||||
/** Insider CIK patterns (partial matches). */
|
||||
const INSIDER_CIK_PATTERNS = [
|
||||
'ceo', 'cfo', 'chief', 'director', 'officer',
|
||||
];
|
||||
|
||||
/** Index fund name patterns (case-insensitive). */
|
||||
const INDEX_FUND_PATTERNS = [
|
||||
'index', 'ishares', 'vanguard index', 'spdr', 'ctf',
|
||||
];
|
||||
|
||||
/** Mutual fund name patterns (case-insensitive). */
|
||||
const MUTUAL_FUND_PATTERNS = [
|
||||
'mutual fund', 'growth fund', 'balanced fund', 'income fund',
|
||||
];
|
||||
|
||||
/** Pension fund name patterns (case-insensitive). */
|
||||
const PENSION_FUND_PATTERNS = [
|
||||
'pension', 'retirement', 'calpers', 'teacher',
|
||||
];
|
||||
|
||||
/** Hedge fund name patterns (case-insensitive). */
|
||||
const HEDGE_FUND_PATTERNS = [
|
||||
'hedge', 'capital fund', 'partners', 'management', 'citadel', 'bridgewater',
|
||||
'rhone', 'balyasny', 'pntm', 'point72', 'millennium',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — 13F Holdings and Form 4 Transactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single parsed 13F holding entry. */
|
||||
@@ -21,6 +72,8 @@ export interface ParsedHolding {
|
||||
issuerName: string;
|
||||
value: number;
|
||||
sshPrnamt: number; // shares reported
|
||||
cik?: string;
|
||||
sic?: number;
|
||||
}
|
||||
|
||||
/** A single parsed Form 4 transaction entry. */
|
||||
@@ -32,12 +85,17 @@ export interface ParsedTransaction {
|
||||
transactionCode: string;
|
||||
shares: number;
|
||||
price: number;
|
||||
form4Type?: 'filed' | 'amended';
|
||||
filingDate?: string;
|
||||
is10b5Plan?: boolean; // 10b5-1 plan detected
|
||||
}
|
||||
|
||||
/** Options for comparing two 13F filings. */
|
||||
export interface Flow13FOpts {
|
||||
fromAccession: string;
|
||||
toAccession: string;
|
||||
/** When true, include CUSIPs with no share change between filings. Default false. */
|
||||
includeUnchanged?: boolean;
|
||||
}
|
||||
|
||||
/** Per-CUSIP flow result between two 13F filings. */
|
||||
@@ -48,6 +106,7 @@ export interface CusipFlowResult {
|
||||
currShares: number;
|
||||
delta: number;
|
||||
classification: PositionClassification;
|
||||
holderClass?: HolderClass;
|
||||
}
|
||||
|
||||
/** Possible position classifications — neutral, descriptive only. */
|
||||
@@ -73,6 +132,8 @@ export interface Form4EventSummary {
|
||||
shares: number;
|
||||
price: number;
|
||||
netDirection: ReporterNetDirection;
|
||||
is10b5Plan?: boolean;
|
||||
planDetails?: string;
|
||||
}
|
||||
|
||||
/** Neutral direction classification for a reporter's Form 4 activity. */
|
||||
@@ -80,41 +141,35 @@ export type ReporterNetDirection =
|
||||
| 'reporter increased holdings'
|
||||
| 'reporter reduced holdings';
|
||||
|
||||
/** Transaction type — Informed (10b5-1 plan) or Routine. */
|
||||
export type TransactionType = 'Informed' | 'Routine';
|
||||
|
||||
/** Aggregate result from insider_flow. */
|
||||
export interface InsiderFlowSummary {
|
||||
cik: string;
|
||||
events: Form4EventSummary[];
|
||||
netShares: number;
|
||||
direction: ReporterNetDirection | null;
|
||||
transactionType: TransactionType;
|
||||
}
|
||||
|
||||
/** Buy-zone estimate for a holder. */
|
||||
export interface BuyZoneEstimate {
|
||||
cik: string;
|
||||
holderClass: HolderClass;
|
||||
symbol: string;
|
||||
currentExposure: number;
|
||||
estimatedBuyZone: number; // 25% of total exposure
|
||||
isEstimated: true; // Always stamped "estimated" per ADR-0007
|
||||
lastFilingDate: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transaction code mapping (Form 4 standard codes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map Form 4 transaction codes to directional meaning.
|
||||
* Codes are from SEC Schedule 16 (Form 4) instructions.
|
||||
*
|
||||
* A = Grant, award or other acquisition (generally increases holdings)
|
||||
* C = Conversion of derivative securities (direction depends on underlying)
|
||||
* D = Sale or other disposition to issuer (decreases holdings, but not a market sale)
|
||||
* F = Payment of exercise price or tax liability (decreases holdings)
|
||||
* G = Gift transfer (direction depends on recipient)
|
||||
* J = Other acquisition or disposition (case-by-case)
|
||||
* L = Small-stock acquisition under 16a-1(b) (increases holdings)
|
||||
* M = Exercise or conversion of derivative security received from issuer
|
||||
* (or conversion/expiration of derivative security not received from issuer)
|
||||
* P = Open-market purchase or sale of equity or derivative securities
|
||||
* (P = purchase increases; sale decreases — but Form 4 uses separate codes)
|
||||
* S = Open-market purchase or sale of equity or derivative securities
|
||||
* (S = sale decreases)
|
||||
* V = Receipt or delivery of equity or derivative securities pursuant to plan
|
||||
* (direction depends on plan terms)
|
||||
*/
|
||||
|
||||
/** Codes that generally indicate an increase in the reporter's holdings. */
|
||||
const INCREASE_CODES = new Set(['A', 'C', 'L', 'M', 'P']);
|
||||
const INCREASE_CODES = new Set(['A', 'L', 'M', 'P']);
|
||||
|
||||
/** Codes that generally indicate a decrease in the reporter's holdings. */
|
||||
const DECREASE_CODES = new Set(['D', 'F', 'G', 'J', 'S', 'V']);
|
||||
@@ -127,17 +182,159 @@ const NEUTRAL_CODES = new Set(['C', 'G', 'J', 'V']);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* InstitutionFlowEngine — pure analysis module.
|
||||
* InstitutionFlowEngine — deep analysis module.
|
||||
*
|
||||
* Takes parsed data from EdgarAdapter (form13f_holdings, form4_tx) and
|
||||
* computes institutional flow summaries. Does NOT make network calls itself;
|
||||
* it operates on already-parsed data, making it fully cache-testable.
|
||||
* computes:
|
||||
* - Holder classification by CIK/SIC metadata (5 classes)
|
||||
* - 10b5-1 plan detection for Informed vs Routine transactions
|
||||
* - Buy-zone estimates (>25% position increase)
|
||||
* - Per-CUSIP net position changes between two consecutive 13F filings.
|
||||
* - Summarized Form 4 transaction activity for a reporter over a date range.
|
||||
*
|
||||
* Does NOT make network calls itself; it operates on already-parsed data,
|
||||
* making it fully cache-testable.
|
||||
*/
|
||||
export class InstitutionFlowEngine {
|
||||
constructor(private readonly edgar: EdgarAdapter) {}
|
||||
private readonly edgar: EdgarAdapter;
|
||||
|
||||
constructor(edgar: EdgarAdapter) {
|
||||
this.edgar = edgar;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// aggregate_13f_flow
|
||||
// Holder Classification (by CIK/SIC metadata)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Classify a holder by CIK and SIC metadata.
|
||||
* Pure function — no I/O, fully testable with any data.
|
||||
*/
|
||||
classifyHolder(cik: string, sic?: number, name?: string): HolderClass {
|
||||
// Check for insider patterns first (name-based + CIK patterns)
|
||||
if (this.isInsiderCIK(cik, name)) {
|
||||
return 'Insider';
|
||||
}
|
||||
|
||||
// Check SIC ranges if available
|
||||
if (sic) {
|
||||
for (const [holderClass, range] of Object.entries(SIC_RANGES)) {
|
||||
if (holderClass === 'Insider') continue; // Skip insider — handled above
|
||||
if (sic >= range.min && sic <= range.max) {
|
||||
return holderClass as HolderClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to name-based heuristics (only if SIC not available)
|
||||
if (name) {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
// Check index fund patterns first (more specific)
|
||||
if (INDEX_FUND_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return 'Index Fund';
|
||||
}
|
||||
|
||||
// Check mutual fund patterns
|
||||
if (MUTUAL_FUND_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return 'Mutual Fund';
|
||||
}
|
||||
|
||||
// Check pension fund patterns
|
||||
if (PENSION_FUND_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return 'Pension Fund';
|
||||
}
|
||||
|
||||
// Check hedge fund patterns
|
||||
if (HEDGE_FUND_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return 'Hedge Fund';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Mutual Fund if can't classify
|
||||
return 'Mutual Fund';
|
||||
}
|
||||
|
||||
/** Check if a CIK represents an insider based on name patterns. */
|
||||
private isInsiderCIK(cik: string, name?: string): boolean {
|
||||
if (!name) return false;
|
||||
|
||||
const lowerName = name.toLowerCase();
|
||||
return INSIDER_CIK_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 10b5-1 Plan Detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect if a Form 4 transaction is part of a 10b5-1 plan.
|
||||
* Pure function — analyzes transaction metadata for plan indicators.
|
||||
*/
|
||||
detect10b5Plan(transaction: ParsedTransaction): boolean {
|
||||
// Check if explicitly marked in the parsed data
|
||||
if (transaction.is10b5Plan) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for plan-related language in relationship field
|
||||
const relationship = transaction.relationship.toLowerCase();
|
||||
if (relationship.includes('10b5') || relationship.includes('trading plan')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for regular, scheduled transactions (multiple transactions with same terms)
|
||||
// This would require historical data — not implemented in pure function
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify transaction type based on 10b5-1 plan detection.
|
||||
* Pure function — no I/O.
|
||||
*/
|
||||
classifyTransactionType(transaction: ParsedTransaction): TransactionType {
|
||||
return this.detect10b5Plan(transaction) ? 'Informed' : 'Routine';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Buy-Zone Estimation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Estimate buy-zone for a holder based on position increase.
|
||||
* If a holder increased position by >25% of their total exposure,
|
||||
* estimate the buy-zone as 25% of their current exposure.
|
||||
*
|
||||
* Always stamped "estimated" per ADR-0007.
|
||||
*/
|
||||
estimateBuyZone(params: {
|
||||
cik: string;
|
||||
holderClass: HolderClass;
|
||||
symbol: string;
|
||||
currentExposure: number;
|
||||
positionIncreasePct: number;
|
||||
lastFilingDate: string;
|
||||
}): BuyZoneEstimate | null {
|
||||
// Only estimate if position increased by >25%
|
||||
if (params.positionIncreasePct <= 25) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const estimatedBuyZone = params.currentExposure * 0.25;
|
||||
|
||||
return {
|
||||
cik: params.cik,
|
||||
holderClass: params.holderClass,
|
||||
symbol: params.symbol,
|
||||
currentExposure: params.currentExposure,
|
||||
estimatedBuyZone,
|
||||
isEstimated: true, // Always stamped "estimated"
|
||||
lastFilingDate: params.lastFilingDate,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// aggregate_13f_flow (enhanced with holder classification)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -145,7 +342,7 @@ export class InstitutionFlowEngine {
|
||||
*
|
||||
* Compares holdings (shares / sshPrnamt) between `fromAccession` and
|
||||
* `toAccession` for the same CIK. Classifies each CUSIP into one of five
|
||||
* position states based on delta.
|
||||
* position states based on delta, and adds holder classification if available.
|
||||
*
|
||||
* Returns an array sorted by absolute delta descending (largest moves first).
|
||||
* CUSIPs present in only one of the two filings are included (new position
|
||||
@@ -193,6 +390,11 @@ export class InstitutionFlowEngine {
|
||||
|
||||
const classification = this.classifyPosition(prevShares, currShares);
|
||||
|
||||
// Add holder classification if available
|
||||
const holderClass = curr?.cik && curr?.sic
|
||||
? this.classifyHolder(curr.cik, curr.sic, curr.issuerName)
|
||||
: undefined;
|
||||
|
||||
results.push({
|
||||
cusip,
|
||||
name,
|
||||
@@ -200,17 +402,23 @@ export class InstitutionFlowEngine {
|
||||
currShares,
|
||||
delta,
|
||||
classification,
|
||||
holderClass,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by absolute delta descending (largest moves first).
|
||||
results.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
||||
// Filter out 'unchanged' positions unless caller opts in.
|
||||
const filtered = opts.includeUnchanged
|
||||
? results
|
||||
: results.filter((r) => r.classification !== 'unchanged');
|
||||
|
||||
return results;
|
||||
// Sort by absolute delta descending (largest moves first).
|
||||
filtered.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// insider_flow
|
||||
// insider_flow (enhanced with 10b5-1 detection and transaction type)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -222,6 +430,7 @@ export class InstitutionFlowEngine {
|
||||
* reporter.
|
||||
*
|
||||
* Events are sorted by transaction date descending (most recent first).
|
||||
* Transactions are classified as Informed (10b5-1 plan) or Routine.
|
||||
*/
|
||||
async insider_flow(cik: string, opts: Form4QueryOpts): Promise<InsiderFlowSummary> {
|
||||
// Use filings_index to find Form 4 filings for this CIK within the date range.
|
||||
@@ -271,9 +480,15 @@ export class InstitutionFlowEngine {
|
||||
|
||||
for (const tx of filtered) {
|
||||
const direction = this.classifyTransactionDirection(tx.transactionCode, tx.shares);
|
||||
// Skip zero-share ambiguous transactions (no net change).
|
||||
if (direction === null) continue;
|
||||
|
||||
const transactionType = this.classifyTransactionType(tx);
|
||||
const is10b5Plan = this.detect10b5Plan(tx);
|
||||
|
||||
netShares += direction === 'reporter increased holdings' ? tx.shares : -tx.shares;
|
||||
|
||||
events.push({
|
||||
const event: Form4EventSummary = {
|
||||
reporter: tx.reporter,
|
||||
relationship: tx.relationship,
|
||||
securityTitle: tx.securityTitle,
|
||||
@@ -282,7 +497,15 @@ export class InstitutionFlowEngine {
|
||||
shares: tx.shares,
|
||||
price: tx.price,
|
||||
netDirection: direction,
|
||||
});
|
||||
is10b5Plan,
|
||||
};
|
||||
|
||||
// Add plan details if 10b5-1 detected
|
||||
if (is10b5Plan) {
|
||||
event.planDetails = 'Transaction executed pursuant to Rule 10b5-1 trading plan';
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Overall direction for the reporter across all events.
|
||||
@@ -292,11 +515,18 @@ export class InstitutionFlowEngine {
|
||||
? 'reporter increased holdings'
|
||||
: 'reporter reduced holdings';
|
||||
|
||||
// Determine overall transaction type (majority rule)
|
||||
const informedCount = events.filter((e) => e.is10b5Plan).length;
|
||||
const transactionType: TransactionType = informedCount > events.length / 2
|
||||
? 'Informed'
|
||||
: 'Routine';
|
||||
|
||||
return {
|
||||
cik,
|
||||
events,
|
||||
netShares,
|
||||
direction,
|
||||
transactionType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,7 +556,7 @@ export class InstitutionFlowEngine {
|
||||
classifyTransactionDirection(
|
||||
transactionCode: string,
|
||||
shares: number,
|
||||
): ReporterNetDirection {
|
||||
): ReporterNetDirection | null {
|
||||
const code = transactionCode.toUpperCase();
|
||||
|
||||
if (INCREASE_CODES.has(code)) return 'reporter increased holdings';
|
||||
@@ -336,8 +566,7 @@ export class InstitutionFlowEngine {
|
||||
if (shares > 0) return 'reporter increased holdings';
|
||||
if (shares < 0) return 'reporter reduced holdings';
|
||||
|
||||
// shares === 0 with ambiguous code: treat as no net change (not possible
|
||||
// in practice, but guard against it).
|
||||
return 'reporter increased holdings';
|
||||
// shares === 0 with ambiguous code: no net change.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Pure relative-strength rotation math for Market Outlook.
|
||||
// ADR-0007: describes leadership / lag, never "capital is flowing into X, allocate there."
|
||||
// Benchmark is typically SPY. RS = sector total return − benchmark total return over the same window.
|
||||
|
||||
export type Horizon = 'oneWeek' | 'oneMonth' | 'threeMonth' | 'sixMonth' | 'oneYear' | 'threeYear' | 'fiveYear';
|
||||
|
||||
export const HORIZONS: Horizon[] = ['oneWeek', 'oneMonth', 'threeMonth', 'sixMonth', 'oneYear', 'threeYear', 'fiveYear'];
|
||||
|
||||
export const HORIZON_MS: Record<Horizon, number> = {
|
||||
oneWeek: 7 * 86_400_000,
|
||||
oneMonth: 30 * 86_400_000,
|
||||
threeMonth: 90 * 86_400_000,
|
||||
sixMonth: 182 * 86_400_000,
|
||||
oneYear: 365 * 86_400_000,
|
||||
threeYear: 3 * 365 * 86_400_000,
|
||||
fiveYear: 5 * 365 * 86_400_000,
|
||||
};
|
||||
|
||||
export interface CandlePoint {
|
||||
ts: string;
|
||||
c: number;
|
||||
v?: number;
|
||||
}
|
||||
|
||||
export interface SectorDef {
|
||||
symbol: string;
|
||||
name: string;
|
||||
/** GICS-ish group for UI chips. */
|
||||
group: string;
|
||||
kind: 'sector' | 'style' | 'region' | 'thematic';
|
||||
}
|
||||
|
||||
/** Core GICS + style/region set used for market-first rotation map. */
|
||||
export const MARKET_ROTATION_UNIVERSE: SectorDef[] = [
|
||||
{ symbol: 'XLK', name: 'Technology', group: 'Technology', kind: 'sector' },
|
||||
{ symbol: 'XLF', name: 'Financials', group: 'Financials', kind: 'sector' },
|
||||
{ symbol: 'XLE', name: 'Energy', group: 'Energy', kind: 'sector' },
|
||||
{ symbol: 'XLI', name: 'Industrials', group: 'Industrials', kind: 'sector' },
|
||||
{ symbol: 'XLV', name: 'Healthcare', group: 'Healthcare', kind: 'sector' },
|
||||
{ symbol: 'XLY', name: 'Consumer Discretionary', group: 'Consumer Disc.', kind: 'sector' },
|
||||
{ symbol: 'XLP', name: 'Consumer Staples', group: 'Consumer Staples', kind: 'sector' },
|
||||
{ symbol: 'XLU', name: 'Utilities', group: 'Utilities', kind: 'sector' },
|
||||
{ symbol: 'XLRE', name: 'Real Estate', group: 'Real Estate', kind: 'sector' },
|
||||
{ symbol: 'XLC', name: 'Communication Services', group: 'Comm. Services', kind: 'sector' },
|
||||
{ symbol: 'XLB', name: 'Materials', group: 'Materials', kind: 'sector' },
|
||||
{ symbol: 'SMH', name: 'Semiconductors', group: 'Semiconductors', kind: 'thematic' },
|
||||
{ symbol: 'XBI', name: 'Biotech', group: 'Biotech', kind: 'thematic' },
|
||||
{ symbol: 'IWF', name: 'Growth', group: 'Style: Growth', kind: 'style' },
|
||||
{ symbol: 'IWD', name: 'Value', group: 'Style: Value', kind: 'style' },
|
||||
{ symbol: 'IWM', name: 'Small Cap', group: 'Style: Small', kind: 'style' },
|
||||
{ symbol: 'EFA', name: 'Developed Intl', group: 'Region: Intl', kind: 'region' },
|
||||
{ symbol: 'EEM', name: 'Emerging Markets', group: 'Region: EM', kind: 'region' },
|
||||
];
|
||||
|
||||
export const BENCHMARK_SYMBOL = 'SPY';
|
||||
|
||||
/** Total return % from closest candle near (latestTs − windowMs) to latest close. */
|
||||
export function totalReturnPct(candles: CandlePoint[], windowMs: number): number | null {
|
||||
if (candles.length < 2) return null;
|
||||
const latest = candles[candles.length - 1];
|
||||
const latestTs = new Date(latest.ts).getTime();
|
||||
if (!Number.isFinite(latestTs) || latest.c <= 0) return null;
|
||||
const targetTs = latestTs - windowMs;
|
||||
let closest: CandlePoint | null = null;
|
||||
let closestDiff = Infinity;
|
||||
for (const c of candles) {
|
||||
const t = new Date(c.ts).getTime();
|
||||
if (!Number.isFinite(t) || c.c <= 0) continue;
|
||||
const diff = Math.abs(t - targetTs);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = c;
|
||||
}
|
||||
}
|
||||
if (!closest || closest.c <= 0) return null;
|
||||
return ((latest.c - closest.c) / closest.c) * 100;
|
||||
}
|
||||
|
||||
export function returnsForHorizons(
|
||||
candles: CandlePoint[],
|
||||
): Record<Horizon, number | null> {
|
||||
const out = {} as Record<Horizon, number | null>;
|
||||
for (const h of HORIZONS) {
|
||||
out[h] = totalReturnPct(candles, HORIZON_MS[h]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Relative strength vs benchmark: sectorReturn − benchReturn (percentage points). */
|
||||
export function relativeStrength(
|
||||
sectorReturn: number | null,
|
||||
benchReturn: number | null,
|
||||
): number | null {
|
||||
if (sectorReturn === null || benchReturn === null) return null;
|
||||
return sectorReturn - benchReturn;
|
||||
}
|
||||
|
||||
export interface SectorRsRow {
|
||||
symbol: string;
|
||||
name: string;
|
||||
group: string;
|
||||
kind: SectorDef['kind'];
|
||||
/** Absolute total returns by horizon. */
|
||||
abs: Record<Horizon, number | null>;
|
||||
/** RS vs SPY (pp) by horizon. */
|
||||
rs: Record<Horizon, number | null>;
|
||||
/** Cross-sectional rank on primary horizon (1 = strongest RS). */
|
||||
rank1M: number | null;
|
||||
/** Rank on 1W for early signal. */
|
||||
rank1W: number | null;
|
||||
/** Leadership label from primary (1M) RS. */
|
||||
leadership: 'leading' | 'lagging' | 'inline' | 'unknown';
|
||||
/** Incipient candidate: strong 1W RS rank + positive 1W RS + 1W > 1M RS (acceleration). */
|
||||
earlyWatch: boolean;
|
||||
/** Average relative volume of last 5 bars vs prior 20 (if volume present). */
|
||||
relVol: number | null;
|
||||
}
|
||||
|
||||
export function relativeVolume(
|
||||
candles: CandlePoint[],
|
||||
short = 5,
|
||||
long = 20,
|
||||
): number | null {
|
||||
const vols = candles.map((c) => c.v ?? 0).filter((v) => v > 0);
|
||||
if (vols.length < long + short) return null;
|
||||
const recent = vols.slice(-short);
|
||||
const base = vols.slice(-(long + short), -short);
|
||||
const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||
const baseAvg = base.reduce((a, b) => a + b, 0) / base.length;
|
||||
if (baseAvg <= 0) return null;
|
||||
return recentAvg / baseAvg;
|
||||
}
|
||||
|
||||
export function leadershipFromRs(rs1M: number | null, threshold = 1.0): SectorRsRow['leadership'] {
|
||||
if (rs1M === null) return 'unknown';
|
||||
if (rs1M > threshold) return 'leading';
|
||||
if (rs1M < -threshold) return 'lagging';
|
||||
return 'inline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ranked RS rows for a universe against a benchmark candle series.
|
||||
* Primary ranking horizon = oneMonth.
|
||||
*/
|
||||
export function buildSectorRsMap(
|
||||
defs: SectorDef[],
|
||||
sectorCandles: Record<string, CandlePoint[]>,
|
||||
benchCandles: CandlePoint[],
|
||||
opts: { earlyRankCutoff?: number; rsAccelMin?: number } = {},
|
||||
): SectorRsRow[] {
|
||||
const earlyCutoff = opts.earlyRankCutoff ?? Math.ceil(defs.length / 3);
|
||||
const accelMin = opts.rsAccelMin ?? 0.5;
|
||||
|
||||
const benchAbs = returnsForHorizons(benchCandles);
|
||||
|
||||
const rows: SectorRsRow[] = defs.map((d) => {
|
||||
const candles = sectorCandles[d.symbol] ?? [];
|
||||
const abs = returnsForHorizons(candles);
|
||||
const rs = {} as Record<Horizon, number | null>;
|
||||
for (const h of HORIZONS) {
|
||||
rs[h] = relativeStrength(abs[h], benchAbs[h]);
|
||||
}
|
||||
return {
|
||||
symbol: d.symbol,
|
||||
name: d.name,
|
||||
group: d.group,
|
||||
kind: d.kind,
|
||||
abs,
|
||||
rs,
|
||||
rank1M: null,
|
||||
rank1W: null,
|
||||
leadership: leadershipFromRs(rs.oneMonth),
|
||||
earlyWatch: false,
|
||||
relVol: relativeVolume(candles),
|
||||
};
|
||||
});
|
||||
|
||||
// Rank by 1M RS (nulls last).
|
||||
const by1M = [...rows].sort((a, b) => {
|
||||
const av = a.rs.oneMonth ?? -Infinity;
|
||||
const bv = b.rs.oneMonth ?? -Infinity;
|
||||
return bv - av;
|
||||
});
|
||||
by1M.forEach((r, i) => {
|
||||
if (r.rs.oneMonth !== null) r.rank1M = i + 1;
|
||||
});
|
||||
|
||||
const by1W = [...rows].sort((a, b) => {
|
||||
const av = a.rs.oneWeek ?? -Infinity;
|
||||
const bv = b.rs.oneWeek ?? -Infinity;
|
||||
return bv - av;
|
||||
});
|
||||
by1W.forEach((r, i) => {
|
||||
if (r.rs.oneWeek !== null) r.rank1W = i + 1;
|
||||
});
|
||||
|
||||
for (const r of rows) {
|
||||
const rs1w = r.rs.oneWeek;
|
||||
const rs1m = r.rs.oneMonth;
|
||||
const accel = rs1w !== null && rs1m !== null ? rs1w - rs1m : null;
|
||||
r.earlyWatch =
|
||||
r.rank1W !== null &&
|
||||
r.rank1W <= earlyCutoff &&
|
||||
rs1w !== null &&
|
||||
rs1w > 0 &&
|
||||
accel !== null &&
|
||||
accel >= accelMin &&
|
||||
(r.relVol === null || r.relVol >= 1.1);
|
||||
}
|
||||
|
||||
// Stable order: by 1M rank then symbol.
|
||||
rows.sort((a, b) => {
|
||||
const ar = a.rank1M ?? 999;
|
||||
const br = b.rank1M ?? 999;
|
||||
if (ar !== br) return ar - br;
|
||||
return a.symbol.localeCompare(b.symbol);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export type RotationStrength = 'none' | 'weak' | 'moderate' | 'strong';
|
||||
|
||||
export interface RotationSummary {
|
||||
strength: RotationStrength;
|
||||
/** Average 1M RS of leaders minus average 1M RS of laggards (pp). */
|
||||
leadershipSpread: number;
|
||||
leadingGroup: string;
|
||||
laggingGroup: string;
|
||||
leadingCount: number;
|
||||
laggingCount: number;
|
||||
earlyWatchCount: number;
|
||||
/** Plain-English summary (educational). */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function summarizeRotation(rows: SectorRsRow[]): RotationSummary {
|
||||
const leading = rows.filter((r) => r.leadership === 'leading');
|
||||
const lagging = rows.filter((r) => r.leadership === 'lagging');
|
||||
const leadAvg =
|
||||
leading.length > 0
|
||||
? leading.reduce((s, r) => s + (r.rs.oneMonth ?? 0), 0) / leading.length
|
||||
: 0;
|
||||
const lagAvg =
|
||||
lagging.length > 0
|
||||
? lagging.reduce((s, r) => s + (r.rs.oneMonth ?? 0), 0) / lagging.length
|
||||
: 0;
|
||||
const spread = leadAvg - lagAvg;
|
||||
|
||||
function topGroup(list: SectorRsRow[]): string {
|
||||
if (list.length === 0) return '—';
|
||||
const map = new Map<string, number[]>();
|
||||
for (const r of list) {
|
||||
const arr = map.get(r.group) ?? [];
|
||||
arr.push(r.rs.oneMonth ?? 0);
|
||||
map.set(r.group, arr);
|
||||
}
|
||||
let best = '—';
|
||||
let bestAvg = -Infinity;
|
||||
for (const [g, vals] of map) {
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
if (avg > bestAvg) {
|
||||
bestAvg = avg;
|
||||
best = g;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// For lagging group, pick most negative average.
|
||||
function worstGroup(list: SectorRsRow[]): string {
|
||||
if (list.length === 0) return '—';
|
||||
const map = new Map<string, number[]>();
|
||||
for (const r of list) {
|
||||
const arr = map.get(r.group) ?? [];
|
||||
arr.push(r.rs.oneMonth ?? 0);
|
||||
map.set(r.group, arr);
|
||||
}
|
||||
let worst = '—';
|
||||
let worstAvg = Infinity;
|
||||
for (const [g, vals] of map) {
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
if (avg < worstAvg) {
|
||||
worstAvg = avg;
|
||||
worst = g;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
let strength: RotationStrength = 'none';
|
||||
if (spread > 12 && leading.length >= 3 && lagging.length >= 3) strength = 'strong';
|
||||
else if (spread > 6 && leading.length >= 2) strength = 'moderate';
|
||||
else if (spread > 3) strength = 'weak';
|
||||
|
||||
const earlyWatchCount = rows.filter((r) => r.earlyWatch).length;
|
||||
const leadingGroup = topGroup(leading);
|
||||
const laggingGroup = worstGroup(lagging);
|
||||
|
||||
const summary =
|
||||
strength === 'none'
|
||||
? 'Over the last month, relative performance across sectors looks mixed versus the broad market — no clear leadership dispersion on the one-month window.'
|
||||
: `Over the last month, ${leadingGroup} has outperformed the broad market while ${laggingGroup} has underperformed. ` +
|
||||
`Leadership spread is about ${spread.toFixed(1)} percentage points. ` +
|
||||
(earlyWatchCount > 0
|
||||
? `${earlyWatchCount} name(s) show early improvement on a one-week relative basis. `
|
||||
: '') +
|
||||
'Educational context for relative leadership — not an allocation instruction.';
|
||||
|
||||
return {
|
||||
strength,
|
||||
leadershipSpread: Math.round(spread * 10) / 10,
|
||||
leadingGroup,
|
||||
laggingGroup,
|
||||
leadingCount: leading.length,
|
||||
laggingCount: lagging.length,
|
||||
earlyWatchCount,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Investor Flow — RotationDetector (Slice 9): sector rotation incipient signals
|
||||
// resolved over time via a γ two-stage process.
|
||||
//
|
||||
// Pure/cache-deterministic: consumes slice-4 OHLCV + slice-7 institutional flow
|
||||
// (passed in). NO network. ADR-0007: "capital appears to be moving" — never a
|
||||
// trade recommendation.
|
||||
//
|
||||
// CONTEXT.md: RS-breadth thrust + cross-sectional rank → incipient candidate;
|
||||
// γ stage 1 = price confirmation (~4wk); γ stage 2 = institutional confirmation
|
||||
// at quarter-end (slice 7 flow). Phase labels: early / accelerating / mature / cooling.
|
||||
|
||||
/** A sector's price series for RS computation. */
|
||||
export interface SectorPriceSeries {
|
||||
sector: string;
|
||||
/** Daily close-to-close ratio vs the benchmark, length N. Element i = sectorReturn_i / benchmarkReturn_i. */
|
||||
rsRatio: number[];
|
||||
/** Daily volume series for relative-volume computation. */
|
||||
volume: number[];
|
||||
avgVolumeReference: number; // benchmark average volume for rel-volume scaling
|
||||
}
|
||||
|
||||
/** Institutional flow direction per sector (from slice 7 InstitutionFlowEngine). */
|
||||
export interface SectorFlowSnapshot {
|
||||
sector: string;
|
||||
netDirection: 'increasing' | 'reducing' | 'flat' | 'mixed';
|
||||
quarterEnd: string; // ISO date of the quarter-end the snapshot pertains to
|
||||
}
|
||||
|
||||
/** A detected rotation signal. */
|
||||
export interface RotationSignal {
|
||||
sector: string;
|
||||
phase: 'early' | 'accelerating' | 'mature' | 'cooling';
|
||||
confidence: number; // 0..1
|
||||
real: boolean; // both γ stages confirmed
|
||||
falseAlarm: boolean; // explicitly resolved to false
|
||||
ts: string; // detection ISO timestamp
|
||||
priceConfirmed: boolean; // γ stage 1
|
||||
institutionalConfirmed: boolean; // γ stage 2
|
||||
history: ResolutionEvent[];
|
||||
}
|
||||
|
||||
/** A stored resolution event in signal history. */
|
||||
export interface ResolutionEvent {
|
||||
ts: string;
|
||||
stage: 'incipient' | 'price-confirmed' | 'institutional-confirmed' | 'false';
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** RS-breadth thrust threshold (configurable). */
|
||||
export const DEFAULT_THRUST_THRESHOLD = 0.02; // 2% RS outperformance
|
||||
export const DEFAULT_RELVOL_THRUST = 1.3; // 30% above avg volume
|
||||
|
||||
/** Cross-sectional rank: rank sectors by latest RS-ratio (descending). Pure. */
|
||||
export function crossSectionalRank(sectors: SectorPriceSeries[]): { sector: string; rank: number; rsRatio: number }[] {
|
||||
const latest = sectors.map((s) => {
|
||||
const last = s.rsRatio[s.rsRatio.length - 1] ?? 0;
|
||||
return { sector: s.sector, rsRatio: last };
|
||||
});
|
||||
latest.sort((a, b) => b.rsRatio - a.rsRatio);
|
||||
return latest.map((d, i) => ({ sector: d.sector, rank: i + 1, rsRatio: d.rsRatio }));
|
||||
}
|
||||
|
||||
/** Did a sector show an RS-breadth thrust + relative-volume thrust? Pure. */
|
||||
export function detectIncipient(
|
||||
sector: SectorPriceSeries,
|
||||
rankRows: { sector: string; rank: number }[],
|
||||
opts: { thrustThreshold?: number; relvolThrust?: number } = {},
|
||||
): boolean {
|
||||
const thrust = opts.thrustThreshold ?? DEFAULT_THRUST_THRESHOLD;
|
||||
const relvolTh = opts.relvolThrust ?? DEFAULT_RELVOL_THRUST;
|
||||
const rankRow = rankRows.find((r) => r.sector === sector.sector);
|
||||
// Must be in the top half of the cross-section to be a candidate.
|
||||
if (!rankRow) return false;
|
||||
const topHalf = rankRows.length > 0 && rankRow.rank <= Math.ceil(rankRows.length / 2);
|
||||
if (!topHalf) return false;
|
||||
// RS-breadth thrust: recent RS-ratio delta exceeds threshold.
|
||||
if (sector.rsRatio.length < 3) return false;
|
||||
const recent = sector.rsRatio[sector.rsRatio.length - 1] - sector.rsRatio[sector.rsRatio.length - 3];
|
||||
if (recent < thrust) return false;
|
||||
// Relative-volume thrust: latest volume notably above the reference average.
|
||||
const lastVol = sector.volume[sector.volume.length - 1] ?? 0;
|
||||
return sector.avgVolumeReference > 0 && (lastVol / sector.avgVolumeReference) >= relvolTh;
|
||||
}
|
||||
|
||||
/** Advance the γ two-stage resolution for an incipient signal. Pure.
|
||||
* - stage 1 (price ~4wk): confirm if the sector's RS-ratio is still above the
|
||||
* detection level ~4 weeks (≈20 trading days) later.
|
||||
* - stage 2 (institutional at quarter-end): confirm if slice-7 flow for that
|
||||
* sector's quarter-end is 'increasing'.
|
||||
* A signal is `real` only when both stages confirm; `falseAlarm` when stage 1
|
||||
* fails (price did not sustain) or stage 2 contradicts (flow 'reducing'). */
|
||||
export function resolveSignal(
|
||||
sector: SectorPriceSeries,
|
||||
flow: SectorFlowSnapshot | null,
|
||||
opts: { weeksForStage1?: number; detectionRsLevel?: number } = {},
|
||||
): { priceConfirmed: boolean; institutionalConfirmed: boolean; real: boolean; falseAlarm: boolean } {
|
||||
const weeks = opts.weeksForStage1 ?? 4;
|
||||
const detectionLevel = opts.detectionRsLevel ?? (sector.rsRatio[sector.rsRatio.length - 1] ?? 0);
|
||||
// Stage 1: price sustained ~`weeks` later. The series is assumed to include the
|
||||
// post-detection window; the latest value IS the "~weeks later" value. Require
|
||||
// enough elapsed data (~weeks*5 trading days) to have passed since detection.
|
||||
const minLen = weeks * 5;
|
||||
const latest = sector.rsRatio[sector.rsRatio.length - 1] ?? -Infinity;
|
||||
const priceConfirmed = sector.rsRatio.length >= minLen && latest >= detectionLevel;
|
||||
// Stage 2: institutional flow at quarter-end.
|
||||
const institutionalConfirmed = flow?.netDirection === 'increasing';
|
||||
const real = priceConfirmed && institutionalConfirmed;
|
||||
// False alarm: stage1 failed, OR stage2 explicitly contradicted (flow reducing).
|
||||
const falseAlarm = !priceConfirmed || flow?.netDirection === 'reducing';
|
||||
return { priceConfirmed, institutionalConfirmed, real, falseAlarm };
|
||||
}
|
||||
|
||||
/** Label a rotation phase from the resolution state. Pure. */
|
||||
export function labelPhase(
|
||||
priceConfirmed: boolean,
|
||||
institutionalConfirmed: boolean,
|
||||
weeksSinceDetection: number,
|
||||
): RotationSignal['phase'] {
|
||||
if (!priceConfirmed) return 'cooling';
|
||||
if (!institutionalConfirmed) return weeksSinceDetection < 4 ? 'early' : 'accelerating';
|
||||
return 'mature';
|
||||
}
|
||||
|
||||
/** Compute a confidence score 0..1 from the resolution state + recency. Pure. */
|
||||
export function confidenceScore(priceConfirmed: boolean, institutionalConfirmed: boolean, rsRatio: number): number {
|
||||
let c = 0;
|
||||
if (priceConfirmed) c += 0.5;
|
||||
if (institutionalConfirmed) c += 0.4;
|
||||
c += Math.min(0.1, Math.max(0, rsRatio) * 0.1);
|
||||
return Math.min(1, c);
|
||||
}
|
||||
|
||||
/** Per-type false-alarm rate over a signal history. Pure. */
|
||||
export function falseAlarmRate(history: RotationSignal[]): number {
|
||||
if (history.length === 0) return 0;
|
||||
const falseCount = history.filter((s) => s.falseAlarm).length;
|
||||
return falseCount / history.length;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Seasonality helpers — pure, cache-only.
|
||||
// Beginner product language lives in API/UI; this module is math only.
|
||||
// Historical averages are tendencies, not schedules.
|
||||
|
||||
export interface CandlePoint {
|
||||
ts: string;
|
||||
c: number;
|
||||
}
|
||||
|
||||
export interface MonthSeasonality {
|
||||
/** 1–12 */
|
||||
month: number;
|
||||
monthName: string;
|
||||
/** Average monthly return % across sample years. */
|
||||
avgReturnPct: number;
|
||||
/** Fraction of years the month finished positive (0–1). */
|
||||
winRate: number;
|
||||
/** Number of years in the sample. */
|
||||
sampleYears: number;
|
||||
}
|
||||
|
||||
export interface SeasonalitySnapshot {
|
||||
symbol: string;
|
||||
months: MonthSeasonality[];
|
||||
/** Current calendar month 1–12. */
|
||||
currentMonth: number;
|
||||
/** Avg return for the current month historically. */
|
||||
currentMonthAvgPct: number | null;
|
||||
currentMonthWinRate: number | null;
|
||||
currentMonthSampleYears: number;
|
||||
/** Half-year: Nov–Apr vs May–Oct classic window (educational). */
|
||||
halfYear: {
|
||||
winterAvgPct: number | null; // Nov–Apr
|
||||
summerAvgPct: number | null; // May–Oct
|
||||
whichHalf: 'winter' | 'summer';
|
||||
};
|
||||
/** Simple US election-cycle year type (calendar year). */
|
||||
electionCycle: {
|
||||
year: number;
|
||||
yearInCycle: 1 | 2 | 3 | 4;
|
||||
label: string;
|
||||
};
|
||||
/** Day-of-month position for turn-of-month note. */
|
||||
calendar: {
|
||||
dayOfMonth: number;
|
||||
nearTurnOfMonth: boolean;
|
||||
quarter: 1 | 2 | 3 | 4;
|
||||
nearQuarterEnd: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
/**
|
||||
* Group daily closes into calendar-month returns: (monthEnd / monthStart) - 1.
|
||||
* Incomplete current month is excluded so we do not bias with partial data.
|
||||
*/
|
||||
export function monthlyReturnsFromCandles(
|
||||
candles: CandlePoint[],
|
||||
now = new Date(),
|
||||
): Array<{ year: number; month: number; returnPct: number }> {
|
||||
if (candles.length < 5) return [];
|
||||
|
||||
const byYm = new Map<string, { first: number; last: number; year: number; month: number }>();
|
||||
for (const c of candles) {
|
||||
const d = new Date(c.ts);
|
||||
if (!Number.isFinite(d.getTime()) || c.c <= 0) continue;
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
const key = `${year}-${month}`;
|
||||
const row = byYm.get(key);
|
||||
if (!row) {
|
||||
byYm.set(key, { first: c.c, last: c.c, year, month });
|
||||
} else {
|
||||
row.last = c.c;
|
||||
}
|
||||
}
|
||||
|
||||
const curY = now.getUTCFullYear();
|
||||
const curM = now.getUTCMonth() + 1;
|
||||
const out: Array<{ year: number; month: number; returnPct: number }> = [];
|
||||
for (const row of byYm.values()) {
|
||||
if (row.year === curY && row.month === curM) continue; // skip incomplete month
|
||||
if (row.first <= 0) continue;
|
||||
out.push({
|
||||
year: row.year,
|
||||
month: row.month,
|
||||
returnPct: ((row.last - row.first) / row.first) * 100,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function aggregateMonthSeasonality(
|
||||
monthly: Array<{ year: number; month: number; returnPct: number }>,
|
||||
): MonthSeasonality[] {
|
||||
const months: MonthSeasonality[] = [];
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const rows = monthly.filter((r) => r.month === m);
|
||||
if (rows.length === 0) {
|
||||
months.push({
|
||||
month: m,
|
||||
monthName: MONTH_NAMES[m - 1],
|
||||
avgReturnPct: 0,
|
||||
winRate: 0,
|
||||
sampleYears: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const avg = rows.reduce((s, r) => s + r.returnPct, 0) / rows.length;
|
||||
const wins = rows.filter((r) => r.returnPct > 0).length;
|
||||
months.push({
|
||||
month: m,
|
||||
monthName: MONTH_NAMES[m - 1],
|
||||
avgReturnPct: Math.round(avg * 100) / 100,
|
||||
winRate: wins / rows.length,
|
||||
sampleYears: rows.length,
|
||||
});
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** Election cycle: year after election = 1 … election year = 4. Uses US 4-year cycle from 1788. */
|
||||
export function electionCycleYear(year: number): { yearInCycle: 1 | 2 | 3 | 4; label: string } {
|
||||
// 2024 was election year → yearInCycle 4; 2025 = 1, 2026 = 2, 2027 = 3, 2028 = 4
|
||||
const mod = ((year - 1788) % 4 + 4) % 4; // 0 = election year
|
||||
const yearInCycle = (mod === 0 ? 4 : mod) as 1 | 2 | 3 | 4;
|
||||
const labels: Record<1 | 2 | 3 | 4, string> = {
|
||||
1: 'Year after the election',
|
||||
2: 'Midterm year',
|
||||
3: 'Pre-election year',
|
||||
4: 'Election year',
|
||||
};
|
||||
return { yearInCycle, label: labels[yearInCycle] };
|
||||
}
|
||||
|
||||
export function buildSeasonalitySnapshot(
|
||||
symbol: string,
|
||||
candles: CandlePoint[],
|
||||
now = new Date(),
|
||||
): SeasonalitySnapshot {
|
||||
const monthly = monthlyReturnsFromCandles(candles, now);
|
||||
const months = aggregateMonthSeasonality(monthly);
|
||||
const currentMonth = now.getUTCMonth() + 1;
|
||||
const cur = months.find((m) => m.month === currentMonth);
|
||||
|
||||
const winterMonths = [11, 12, 1, 2, 3, 4];
|
||||
const summerMonths = [5, 6, 7, 8, 9, 10];
|
||||
function avgFor(ms: number[]): number | null {
|
||||
const rows = months.filter((m) => ms.includes(m.month) && m.sampleYears > 0);
|
||||
if (rows.length === 0) return null;
|
||||
return rows.reduce((s, m) => s + m.avgReturnPct, 0) / rows.length;
|
||||
}
|
||||
const winterAvgPct = avgFor(winterMonths);
|
||||
const summerAvgPct = avgFor(summerMonths);
|
||||
const whichHalf: 'winter' | 'summer' = winterMonths.includes(currentMonth) ? 'winter' : 'summer';
|
||||
|
||||
const dayOfMonth = now.getUTCDate();
|
||||
const quarter = (Math.floor((currentMonth - 1) / 3) + 1) as 1 | 2 | 3 | 4;
|
||||
const cycle = electionCycleYear(now.getUTCFullYear());
|
||||
|
||||
return {
|
||||
symbol,
|
||||
months,
|
||||
currentMonth,
|
||||
currentMonthAvgPct: cur && cur.sampleYears > 0 ? cur.avgReturnPct : null,
|
||||
currentMonthWinRate: cur && cur.sampleYears > 0 ? cur.winRate : null,
|
||||
currentMonthSampleYears: cur?.sampleYears ?? 0,
|
||||
halfYear: {
|
||||
winterAvgPct: winterAvgPct !== null ? Math.round(winterAvgPct * 100) / 100 : null,
|
||||
summerAvgPct: summerAvgPct !== null ? Math.round(summerAvgPct * 100) / 100 : null,
|
||||
whichHalf,
|
||||
},
|
||||
electionCycle: {
|
||||
year: now.getUTCFullYear(),
|
||||
yearInCycle: cycle.yearInCycle,
|
||||
label: cycle.label,
|
||||
},
|
||||
calendar: {
|
||||
dayOfMonth,
|
||||
nearTurnOfMonth: dayOfMonth <= 3 || dayOfMonth >= 28,
|
||||
quarter,
|
||||
nearQuarterEnd: [3, 6, 9, 12].includes(currentMonth) && dayOfMonth >= 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Static high-impact US macro windows (month/day ranges) for a beginner calendar. */
|
||||
export interface SimpleCalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
when: string;
|
||||
impact: 'high' | 'medium';
|
||||
plainWhy: string;
|
||||
}
|
||||
|
||||
export function upcomingSimpleEvents(now = new Date()): SimpleCalendarEvent[] {
|
||||
// Approximate recurring anchors (not exact Fed calendar). Educational only.
|
||||
const y = now.getUTCFullYear();
|
||||
const m = now.getUTCMonth() + 1;
|
||||
const events: SimpleCalendarEvent[] = [
|
||||
{
|
||||
id: 'cpi',
|
||||
title: 'Inflation report (CPI)',
|
||||
when: 'Usually mid-month',
|
||||
impact: 'high',
|
||||
plainWhy: 'Tells how fast prices are rising. Can move interest-rate expectations and the whole stock market.',
|
||||
},
|
||||
{
|
||||
id: 'nfp',
|
||||
title: 'Jobs report',
|
||||
when: 'Usually the first Friday of the month',
|
||||
impact: 'high',
|
||||
plainWhy: 'Shows how many jobs the economy added. Strong or weak jobs numbers can shift rate and growth views.',
|
||||
},
|
||||
{
|
||||
id: 'fomc',
|
||||
title: 'Fed interest-rate meeting',
|
||||
when: 'About every 6–8 weeks',
|
||||
impact: 'high',
|
||||
plainWhy: 'The Fed sets short-term policy rates. Markets often reprice around the decision and press conference.',
|
||||
},
|
||||
{
|
||||
id: 'earnings',
|
||||
title: 'Company earnings season',
|
||||
when: m % 3 === 1 ? 'Active or starting this quarter' : 'Concentrated after each quarter ends',
|
||||
impact: 'medium',
|
||||
plainWhy: 'Lots of companies report results in the same weeks. Single stocks can swing more than usual.',
|
||||
},
|
||||
];
|
||||
|
||||
// Highlight quarter-end window dressing educational note.
|
||||
if ([3, 6, 9, 12].includes(m)) {
|
||||
events.push({
|
||||
id: 'quarter-end',
|
||||
title: 'End of the quarter',
|
||||
when: `Around end of ${MONTH_NAMES[m - 1]} ${y}`,
|
||||
impact: 'medium',
|
||||
plainWhy: 'Some funds tidy portfolios before reports. Can create short-term trading noise, not always a new trend.',
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// Pure helpers for focused-ticker context vs market, sector, and peers.
|
||||
// ADR-0007: describes relative position — never buy/sell directives.
|
||||
|
||||
import {
|
||||
totalReturnPct,
|
||||
HORIZON_MS,
|
||||
type Horizon,
|
||||
type CandlePoint,
|
||||
} from './marketRotationRs.ts';
|
||||
|
||||
export type Stance = 'outperforming' | 'underperforming' | 'inline' | 'unknown';
|
||||
|
||||
export interface HorizonReturns {
|
||||
oneWeek: number | null;
|
||||
oneMonth: number | null;
|
||||
threeMonth: number | null;
|
||||
sixMonth: number | null;
|
||||
oneYear: number | null;
|
||||
threeYear: number | null;
|
||||
fiveYear: number | null;
|
||||
}
|
||||
|
||||
export function returnsBundle(candles: CandlePoint[]): HorizonReturns {
|
||||
return {
|
||||
oneWeek: totalReturnPct(candles, HORIZON_MS.oneWeek),
|
||||
oneMonth: totalReturnPct(candles, HORIZON_MS.oneMonth),
|
||||
threeMonth: totalReturnPct(candles, HORIZON_MS.threeMonth),
|
||||
sixMonth: totalReturnPct(candles, HORIZON_MS.sixMonth),
|
||||
oneYear: totalReturnPct(candles, HORIZON_MS.oneYear),
|
||||
threeYear: totalReturnPct(candles, HORIZON_MS.threeYear),
|
||||
fiveYear: totalReturnPct(candles, HORIZON_MS.fiveYear),
|
||||
};
|
||||
}
|
||||
|
||||
function relativeBundle(subject: HorizonReturns, bench: HorizonReturns): HorizonReturns {
|
||||
return {
|
||||
oneWeek: relativeTo(subject.oneWeek, bench.oneWeek),
|
||||
oneMonth: relativeTo(subject.oneMonth, bench.oneMonth),
|
||||
threeMonth: relativeTo(subject.threeMonth, bench.threeMonth),
|
||||
sixMonth: relativeTo(subject.sixMonth, bench.sixMonth),
|
||||
oneYear: relativeTo(subject.oneYear, bench.oneYear),
|
||||
threeYear: relativeTo(subject.threeYear, bench.threeYear),
|
||||
fiveYear: relativeTo(subject.fiveYear, bench.fiveYear),
|
||||
};
|
||||
}
|
||||
|
||||
export function relativeTo(
|
||||
subject: number | null,
|
||||
benchmark: number | null,
|
||||
): number | null {
|
||||
if (subject === null || benchmark === null) return null;
|
||||
return subject - benchmark;
|
||||
}
|
||||
|
||||
export function stanceFromRs(rs: number | null, threshold = 2): Stance {
|
||||
if (rs === null) return 'unknown';
|
||||
if (rs > threshold) return 'outperforming';
|
||||
if (rs < -threshold) return 'underperforming';
|
||||
return 'inline';
|
||||
}
|
||||
|
||||
export function avgReturn(values: Array<number | null>): number | null {
|
||||
const nums = values.filter((v): v is number => v !== null && Number.isFinite(v));
|
||||
if (nums.length === 0) return null;
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
||||
}
|
||||
|
||||
/** Map Yahoo sector labels → liquid sector ETF. */
|
||||
export function sectorToEtf(sector: string | null | undefined): { etf: string; label: string } | null {
|
||||
if (!sector) return null;
|
||||
const s = sector.toLowerCase();
|
||||
const table: Array<{ match: RegExp; etf: string; label: string }> = [
|
||||
{ match: /technolog|information technology|software/, etf: 'XLK', label: 'Technology' },
|
||||
{ match: /financial|bank/, etf: 'XLF', label: 'Financials' },
|
||||
{ match: /energy|oil|gas/, etf: 'XLE', label: 'Energy' },
|
||||
{ match: /industrial/, etf: 'XLI', label: 'Industrials' },
|
||||
{ match: /health|pharma|biotech|biotechnology/, etf: 'XLV', label: 'Healthcare' },
|
||||
{ match: /consumer cycl|consumer discretionary|retail/, etf: 'XLY', label: 'Consumer Discretionary' },
|
||||
{ match: /consumer defen|consumer staple|food|beverage/, etf: 'XLP', label: 'Consumer Staples' },
|
||||
{ match: /utilit/, etf: 'XLU', label: 'Utilities' },
|
||||
{ match: /real estate|reit/, etf: 'XLRE', label: 'Real Estate' },
|
||||
{ match: /communicat|media|telecom/, etf: 'XLC', label: 'Communication Services' },
|
||||
{ match: /material|basic material|mining|chemical/, etf: 'XLB', label: 'Materials' },
|
||||
];
|
||||
for (const row of table) {
|
||||
if (row.match.test(s)) return { etf: row.etf, label: row.label };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Industry / description keywords → thematic ETF (optional overlay). */
|
||||
export function industryToTheme(
|
||||
industry: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
): { etf: string; label: string } | null {
|
||||
const text = `${industry ?? ''} ${description ?? ''}`.toLowerCase();
|
||||
if (!text.trim()) return null;
|
||||
// Order matters: compute / AI infra and crypto-mining before generic finance.
|
||||
const table: Array<{ match: RegExp; etf: string; label: string }> = [
|
||||
{ match: /ai data.?center|hyperscal|gpu cloud|hpc|high.?performance comput|ai infrastructure|ai infra|neocloud/, etf: 'SMH', label: 'AI / HPC infrastructure' },
|
||||
{ match: /bitcoin.?min|crypto.?min|digital.?asset.?min|cryptocurrency min|btc min/, etf: 'BLOK', label: 'Digital-asset mining' },
|
||||
{ match: /data.?center|colocation|power for compute/, etf: 'SRVR', label: 'Data centers' },
|
||||
{ match: /semiconductor|chip|gpu|foundry|fabless/, etf: 'SMH', label: 'Semiconductors' },
|
||||
{ match: /biotech|biotechnology|genomic/, etf: 'XBI', label: 'Biotech' },
|
||||
{ match: /software|saas|cloud|enterprise software/, etf: 'IGV', label: 'Software' },
|
||||
{ match: /bank|banking|regional bank/, etf: 'KBE', label: 'Banks' },
|
||||
{ match: /insurance/, etf: 'KIE', label: 'Insurance' },
|
||||
{ match: /aerospace|defense|weapon/, etf: 'ITA', label: 'Defense' },
|
||||
{ match: /airline|aviation/, etf: 'JETS', label: 'Airlines' },
|
||||
];
|
||||
for (const row of table) {
|
||||
if (row.match.test(text)) return { etf: row.etf, label: row.label };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Crypto miners / AI power-compute names often mis-bucketed by Yahoo under Financials. */
|
||||
export const CRYPTO_MINING_PEERS = [
|
||||
'IREN', 'CIFR', 'RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'HIVE', 'BTDR', 'BTBT',
|
||||
] as const;
|
||||
|
||||
/** Hyperscaler / GPU-cloud / AI data-center operating peers (not crypto-miner beta). */
|
||||
export const AI_INFRA_PEERS = [
|
||||
'CRWV', 'NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR', 'GDS', 'VNET',
|
||||
] as const;
|
||||
|
||||
export interface SymbolContextOverride {
|
||||
/** Operating peer set (excludes self at use site). */
|
||||
peers: string[];
|
||||
/** Preferred GICS-style comparison ETF (may override Yahoo sector). */
|
||||
sectorEtf: string;
|
||||
sectorLabel: string;
|
||||
themeEtf: string | null;
|
||||
themeLabel: string | null;
|
||||
/** Shown when Yahoo classification disagrees with operating profile. */
|
||||
classificationNote: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit operating-profile overrides. Yahoo still lists many power/compute
|
||||
* and crypto-mining names under Financial Services / Capital Markets.
|
||||
*/
|
||||
export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
IREN: {
|
||||
peers: ['CRWV', 'NBIS', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (AI infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / hyperscaler infrastructure',
|
||||
classificationNote:
|
||||
'Vendor feeds often label IREN as Financial Services / Capital Markets (crypto-era bucket). Operating comparison uses AI infrastructure / GPU-cloud peers (e.g. CRWV, NBIS), not banks or pure crypto miners.',
|
||||
},
|
||||
CRWV: {
|
||||
peers: ['NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (AI infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / hyperscaler infrastructure',
|
||||
classificationNote: 'GPU-cloud / AI infrastructure peer set (with IREN, NBIS, etc.).',
|
||||
},
|
||||
NBIS: {
|
||||
peers: ['CRWV', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (AI infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / hyperscaler infrastructure',
|
||||
classificationNote: 'AI infrastructure peer set (with CRWV, IREN, etc.).',
|
||||
},
|
||||
CIFR: {
|
||||
peers: ['RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'IREN'],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote:
|
||||
'Often classified under Financial Services; primary peer set is crypto miners (IREN retained as a related power/compute name).',
|
||||
},
|
||||
RIOT: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
},
|
||||
MARA: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
},
|
||||
CLSK: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
},
|
||||
WULF: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (compute infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'Mining + HPC/AI data-center transition; not a traditional financial.',
|
||||
},
|
||||
CORZ: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (compute infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'Mining + AI hosting; not a traditional financial.',
|
||||
},
|
||||
APLD: {
|
||||
peers: ['IREN', 'CIFR', 'CRWV', 'NBIS', 'EQIX', 'DLR', 'VRT', 'SMCI'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (data centers)',
|
||||
themeEtf: 'SMH',
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'AI data-center developer/operator peer set.',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ResolvedBusinessContext {
|
||||
sectorEtf: string | null;
|
||||
sectorLabel: string | null;
|
||||
themeEtf: string | null;
|
||||
themeLabel: string | null;
|
||||
/** Curated peers when available; empty means fall back to vendor peers / ETF holdings. */
|
||||
peers: string[];
|
||||
classificationNote: string | null;
|
||||
/** When true, do not pull peers from sector ETF holdings (XLF etc.). */
|
||||
blockSectorEtfPeers: boolean;
|
||||
/** Yahoo-reported sector (may be misleading). */
|
||||
vendorSector: string | null;
|
||||
vendorIndustry: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve operating sector/theme/peers. Prefer symbol overrides and text signals
|
||||
* over vendor GICS when those conflict (e.g. IREN → not XLF).
|
||||
*/
|
||||
export function resolveBusinessContext(opts: {
|
||||
symbol: string;
|
||||
sector: string | null | undefined;
|
||||
industry: string | null | undefined;
|
||||
description: string | null | undefined;
|
||||
}): ResolvedBusinessContext {
|
||||
const symbol = opts.symbol.toUpperCase();
|
||||
const vendorSector = opts.sector ?? null;
|
||||
const vendorIndustry = opts.industry ?? null;
|
||||
const text = `${opts.sector ?? ''} ${opts.industry ?? ''} ${opts.description ?? ''}`.toLowerCase();
|
||||
|
||||
const override = SYMBOL_CONTEXT_OVERRIDES[symbol];
|
||||
if (override) {
|
||||
return {
|
||||
sectorEtf: override.sectorEtf,
|
||||
sectorLabel: override.sectorLabel,
|
||||
themeEtf: override.themeEtf,
|
||||
themeLabel: override.themeLabel,
|
||||
peers: override.peers.filter((p) => p !== symbol),
|
||||
classificationNote: override.classificationNote,
|
||||
blockSectorEtfPeers: true,
|
||||
vendorSector,
|
||||
vendorIndustry,
|
||||
};
|
||||
}
|
||||
|
||||
// Heuristic: crypto mining / AI power-compute mislabeled as Financial Services.
|
||||
const isCryptoOrComputeInfra =
|
||||
/bitcoin.?min|crypto.?min|digital.?asset.?min|cryptocurrency|data.?center|hyperscal|ai infrastructure|ai infra|hpc|gpu cloud|neocloud/.test(text);
|
||||
const vendorIsFinance =
|
||||
/financial|capital markets|asset management/.test(`${vendorSector ?? ''} ${vendorIndustry ?? ''}`.toLowerCase());
|
||||
|
||||
if (isCryptoOrComputeInfra && vendorIsFinance) {
|
||||
const aiTilt = /data.?center|hyperscal|ai infrastructure|ai infra|hpc|gpu|neocloud/.test(text);
|
||||
const peers = (aiTilt ? AI_INFRA_PEERS : CRYPTO_MINING_PEERS).filter((p) => p !== symbol);
|
||||
return {
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: aiTilt ? 'Technology (compute infrastructure)' : 'Technology (digital assets)',
|
||||
themeEtf: aiTilt ? 'SMH' : null,
|
||||
themeLabel: aiTilt ? 'AI / HPC infrastructure' : 'Digital-asset mining',
|
||||
peers: [...peers],
|
||||
classificationNote:
|
||||
`Vendor sector is "${vendorSector ?? 'n/a'}" / "${vendorIndustry ?? 'n/a'}", which is a common bucket for crypto-era names. Operating comparison uses compute/digital-infrastructure peers instead of financials.`,
|
||||
blockSectorEtfPeers: true,
|
||||
vendorSector,
|
||||
vendorIndustry,
|
||||
};
|
||||
}
|
||||
|
||||
const sectorMap = sectorToEtf(vendorSector);
|
||||
const themeMap = industryToTheme(vendorIndustry, opts.description);
|
||||
return {
|
||||
sectorEtf: sectorMap?.etf ?? null,
|
||||
sectorLabel: sectorMap?.label ?? vendorSector,
|
||||
themeEtf: themeMap?.etf ?? null,
|
||||
themeLabel: themeMap?.label ?? null,
|
||||
peers: [],
|
||||
classificationNote: null,
|
||||
blockSectorEtfPeers: false,
|
||||
vendorSector,
|
||||
vendorIndustry,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PeerRow {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
returns: HorizonReturns;
|
||||
rsVsMarket1M: number | null;
|
||||
}
|
||||
|
||||
export interface TickerContextInput {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
tickerKind: string;
|
||||
symbolReturns: HorizonReturns;
|
||||
marketReturns: HorizonReturns;
|
||||
marketRegime: 'trending-up' | 'trending-down' | 'range-bound' | null;
|
||||
marketRegimeConfidence: number | null;
|
||||
sectorEtf: string | null;
|
||||
sectorLabel: string | null;
|
||||
sectorReturns: HorizonReturns | null;
|
||||
sectorLeadership: 'leading' | 'lagging' | 'inline' | 'unknown' | null;
|
||||
themeEtf: string | null;
|
||||
themeLabel: string | null;
|
||||
themeReturns: HorizonReturns | null;
|
||||
peers: PeerRow[];
|
||||
/** When vendor GICS disagrees with operating profile. */
|
||||
classificationNote?: string | null;
|
||||
vendorSector?: string | null;
|
||||
vendorIndustry?: string | null;
|
||||
}
|
||||
|
||||
export interface TickerContextResult {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
tickerKind: string;
|
||||
performance: {
|
||||
symbol: HorizonReturns;
|
||||
vsMarket: HorizonReturns;
|
||||
vsSector: HorizonReturns | null;
|
||||
vsPeers1M: number | null;
|
||||
marketStance1M: Stance;
|
||||
sectorStance1M: Stance;
|
||||
peerStance1M: Stance;
|
||||
};
|
||||
market: {
|
||||
regime: string | null;
|
||||
confidence: number | null;
|
||||
spyReturns: HorizonReturns;
|
||||
};
|
||||
sectorContext: {
|
||||
label: string | null;
|
||||
etf: string | null;
|
||||
etfReturns: HorizonReturns | null;
|
||||
leadership: string | null;
|
||||
};
|
||||
themeContext: {
|
||||
label: string | null;
|
||||
etf: string | null;
|
||||
etfReturns: HorizonReturns | null;
|
||||
};
|
||||
classification: {
|
||||
note: string | null;
|
||||
vendorSector: string | null;
|
||||
vendorIndustry: string | null;
|
||||
};
|
||||
peers: Array<{
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
oneMonth: number | null;
|
||||
rsVsMarket1M: number | null;
|
||||
}>;
|
||||
/** Short professional paragraphs for the UI. */
|
||||
summary: {
|
||||
headline: string;
|
||||
market: string;
|
||||
sector: string;
|
||||
peers: string;
|
||||
synthesis: string;
|
||||
};
|
||||
}
|
||||
|
||||
function fmtPct(v: number | null): string {
|
||||
if (v === null || !Number.isFinite(v)) return 'n/a';
|
||||
const sign = v > 0 ? '+' : '';
|
||||
return `${sign}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function stancePhrase(s: Stance): string {
|
||||
if (s === 'outperforming') return 'outperforming';
|
||||
if (s === 'underperforming') return 'underperforming';
|
||||
if (s === 'inline') return 'broadly in line with';
|
||||
return 'insufficient data versus';
|
||||
}
|
||||
|
||||
export function buildTickerContext(input: TickerContextInput): TickerContextResult {
|
||||
const vsMarket = relativeBundle(input.symbolReturns, input.marketReturns);
|
||||
const vsSector = input.sectorReturns
|
||||
? relativeBundle(input.symbolReturns, input.sectorReturns)
|
||||
: null;
|
||||
|
||||
const peerAvg1M = avgReturn(input.peers.map((p) => p.returns.oneMonth));
|
||||
const vsPeers1M = relativeTo(input.symbolReturns.oneMonth, peerAvg1M);
|
||||
|
||||
const marketStance1M = stanceFromRs(vsMarket.oneMonth);
|
||||
const sectorStance1M = stanceFromRs(vsSector?.oneMonth ?? null);
|
||||
const peerStance1M = stanceFromRs(vsPeers1M);
|
||||
|
||||
const name = input.name ?? input.symbol;
|
||||
const regime =
|
||||
input.marketRegime === 'trending-up' ? 'trending up'
|
||||
: input.marketRegime === 'trending-down' ? 'trending down'
|
||||
: input.marketRegime === 'range-bound' ? 'range-bound'
|
||||
: null;
|
||||
|
||||
const headline = `${input.symbol}${input.name ? ` (${input.name})` : ''} — relative context versus market, sector, and peers.`;
|
||||
|
||||
const market =
|
||||
regime
|
||||
? `Broad market regime is currently ${regime}` +
|
||||
(input.marketRegimeConfidence != null ? ` (~${input.marketRegimeConfidence}% classifier confidence). ` : '. ') +
|
||||
`SPY 1M ${fmtPct(input.marketReturns.oneMonth)}; ${input.symbol} 1M ${fmtPct(input.symbolReturns.oneMonth)} ` +
|
||||
`(${fmtPct(vsMarket.oneMonth)} relative). On a one-month basis the name is ${stancePhrase(marketStance1M)} the broad market.`
|
||||
: `SPY 1M ${fmtPct(input.marketReturns.oneMonth)}; ${input.symbol} 1M ${fmtPct(input.symbolReturns.oneMonth)} ` +
|
||||
`(${fmtPct(vsMarket.oneMonth)} relative). Market regime data is incomplete.`;
|
||||
|
||||
let sector = 'Sector classification is unavailable for this symbol.';
|
||||
if (input.sectorLabel || input.sector) {
|
||||
const label = input.sectorLabel ?? input.sector ?? 'sector';
|
||||
const ind = input.industry ? ` Industry: ${input.industry}.` : '';
|
||||
const vendorNote =
|
||||
input.classificationNote
|
||||
? ` ${input.classificationNote}`
|
||||
: (input.vendorSector && input.sectorLabel && !input.sectorLabel.toLowerCase().includes((input.vendorSector ?? '').toLowerCase().split(' ')[0] ?? '___')
|
||||
? ` Vendor feed lists sector as ${input.vendorSector}${input.vendorIndustry ? ` / ${input.vendorIndustry}` : ''}.`
|
||||
: '');
|
||||
const etfBit = input.sectorEtf
|
||||
? ` Comparison proxy ${input.sectorEtf} 1M ${fmtPct(input.sectorReturns?.oneMonth ?? null)}` +
|
||||
(vsSector ? `; name vs proxy ${fmtPct(vsSector.oneMonth)} (${stancePhrase(sectorStance1M)} that group).`
|
||||
: '.')
|
||||
: '';
|
||||
const themeBit = input.themeLabel
|
||||
? ` Theme: ${input.themeLabel}${input.themeEtf ? ` (${input.themeEtf})` : ''}` +
|
||||
(input.themeReturns ? ` 1M ${fmtPct(input.themeReturns.oneMonth)}.` : '.')
|
||||
: '';
|
||||
const lead =
|
||||
input.sectorLeadership === 'leading' ? ' The comparison group is currently outperforming SPY.'
|
||||
: input.sectorLeadership === 'lagging' ? ' The comparison group is currently underperforming SPY.'
|
||||
: input.sectorLeadership === 'inline' ? ' The comparison group is roughly in line with SPY.'
|
||||
: '';
|
||||
sector = `Operating comparison group: ${label}.${ind}${vendorNote}${etfBit}${themeBit}${lead}`;
|
||||
}
|
||||
|
||||
let peers = 'Peer comparison is limited — peer list or peer price history is incomplete.';
|
||||
if (input.peers.length > 0) {
|
||||
const ranked = [...input.peers]
|
||||
.filter((p) => p.returns.oneMonth !== null)
|
||||
.sort((a, b) => (b.returns.oneMonth ?? -Infinity) - (a.returns.oneMonth ?? -Infinity));
|
||||
const top = ranked.slice(0, 3).map((p) => `${p.symbol} ${fmtPct(p.returns.oneMonth)}`).join(', ');
|
||||
peers =
|
||||
`Compared with ${input.peers.length} peer${input.peers.length === 1 ? '' : 's'} ` +
|
||||
`(1M peer average ${fmtPct(peerAvg1M)}), ${input.symbol} is ${stancePhrase(peerStance1M)} that set ` +
|
||||
`(${fmtPct(vsPeers1M)} relative).` +
|
||||
(top ? ` Stronger peer prints on 1M include: ${top}.` : '');
|
||||
}
|
||||
|
||||
const synthesisParts: string[] = [];
|
||||
if (marketStance1M === 'outperforming' && (sectorStance1M === 'outperforming' || sectorStance1M === 'unknown')) {
|
||||
synthesisParts.push('Relative price action is constructive versus the broad market');
|
||||
if (sectorStance1M === 'outperforming') synthesisParts.push('and its sector group');
|
||||
} else if (marketStance1M === 'underperforming') {
|
||||
synthesisParts.push('Relative price action is lagging the broad market');
|
||||
if (sectorStance1M === 'underperforming') synthesisParts.push('and its sector group');
|
||||
} else {
|
||||
synthesisParts.push('Relative price action is mixed or roughly in line with the market');
|
||||
}
|
||||
if (peerStance1M === 'outperforming') synthesisParts.push('with a lead versus available peers on one month');
|
||||
else if (peerStance1M === 'underperforming') synthesisParts.push('with a lag versus available peers on one month');
|
||||
|
||||
const synthesis =
|
||||
`${name}: ${synthesisParts.join(', ')}. ` +
|
||||
'This is a relative-performance snapshot for research context — not a forecast and not an investment recommendation.';
|
||||
|
||||
return {
|
||||
symbol: input.symbol,
|
||||
name: input.name,
|
||||
sector: input.sector,
|
||||
industry: input.industry,
|
||||
tickerKind: input.tickerKind,
|
||||
performance: {
|
||||
symbol: input.symbolReturns,
|
||||
vsMarket,
|
||||
vsSector,
|
||||
vsPeers1M,
|
||||
marketStance1M,
|
||||
sectorStance1M,
|
||||
peerStance1M,
|
||||
},
|
||||
market: {
|
||||
regime: input.marketRegime,
|
||||
confidence: input.marketRegimeConfidence,
|
||||
spyReturns: input.marketReturns,
|
||||
},
|
||||
sectorContext: {
|
||||
label: input.sectorLabel ?? input.sector,
|
||||
etf: input.sectorEtf,
|
||||
etfReturns: input.sectorReturns,
|
||||
leadership: input.sectorLeadership,
|
||||
},
|
||||
themeContext: {
|
||||
label: input.themeLabel,
|
||||
etf: input.themeEtf,
|
||||
etfReturns: input.themeReturns,
|
||||
},
|
||||
classification: {
|
||||
note: input.classificationNote ?? null,
|
||||
vendorSector: input.vendorSector ?? input.sector,
|
||||
vendorIndustry: input.vendorIndustry ?? input.industry,
|
||||
},
|
||||
peers: input.peers.map((p) => ({
|
||||
symbol: p.symbol,
|
||||
name: p.name,
|
||||
oneMonth: p.returns.oneMonth,
|
||||
rsVsMarket1M: p.rsVsMarket1M,
|
||||
})),
|
||||
summary: { headline, market, sector, peers, synthesis },
|
||||
};
|
||||
}
|
||||
|
||||
// re-export horizon type for callers
|
||||
export type { Horizon, CandlePoint };
|
||||
Reference in New Issue
Block a user