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

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