From 44243618d56d8b7c471b897d4aaa77889fafd335 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 21:59:45 -0400 Subject: [PATCH] feat(confluence): add15-symbol universe seed + 3 system rack presets (M22 slice 10) Confluence startup seed: pins the15-symbol research universe + SPY benchmark into the permanent demand set, and creates three system rack presets (Full Confluence, Technical Momentum, Macro+Flows+Sentiment) if absent. Called once from index.ts; idempotent on every restart. --- .../__tests__/confluenceSeed.test.ts | 96 ++++++++++++ app/server/src/confluence/confluenceSeed.ts | 139 ++++++++++++++++++ app/server/src/index.ts | 11 ++ 3 files changed, 246 insertions(+) create mode 100644 app/server/src/confluence/__tests__/confluenceSeed.test.ts create mode 100644 app/server/src/confluence/confluenceSeed.ts diff --git a/app/server/src/confluence/__tests__/confluenceSeed.test.ts b/app/server/src/confluence/__tests__/confluenceSeed.test.ts new file mode 100644 index 0000000..a08b5bb --- /dev/null +++ b/app/server/src/confluence/__tests__/confluenceSeed.test.ts @@ -0,0 +1,96 @@ +// Investor Flow — confluenceSeed.test.ts (M22 slice 10) +// Integration test: pin symbols and create system racks against an in-memory DB. + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDb, initSchema } from '../../db/client.ts'; +import { ConfluenceRepository } from '../../db/confluenceRepository.ts'; +import { createCacheRepository, type CacheRepository } from '../../cache/CacheRepository.ts'; +import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts'; +import { AdapterQueue } from '../../queue/AdapterQueue.ts'; +import { + seedConfluence, + CONFLUENCE_UNIVERSE, + BENCHMARK_SYMBOL, + defineSystemRackPresets, +} from '../confluenceSeed.ts'; +import { CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts'; + +let db: ReturnType; +let cache: CacheRepository; +let repo: ConfluenceRepository; + +beforeEach(() => { + db = createDb({ path: ':memory:' }); + initSchema(db); + repo = new ConfluenceRepository(db); + const adapter = new FakeSourceAdapter('yfinance'); + const queue = new AdapterQueue({ db, adapters: new Map([['yfinance', adapter as any]]) }); + cache = createCacheRepository({ db, scheduler: queue }); + (queue as any).cache = cache; +}); + +describe('CONFLUENCE_UNIVERSE', () => { + it('contains exactly15 symbols plus benchmark', () => { + assert.equal(CONFLUENCE_UNIVERSE.length, 15); + assert.equal(BENCHMARK_SYMBOL, 'SPY'); + const symbols = CONFLUENCE_UNIVERSE.map((s) => s.symbol); + assert.equal(new Set(symbols).size, symbols.length, 'no duplicate symbols'); + }); + + it('SMH is the only ETF; the rest are equities', () => { + const etfs = CONFLUENCE_UNIVERSE.filter((s) => s.kind === 'etf'); + assert.deepEqual(etfs.map((s) => s.symbol), ['SMH']); + for (const s of CONFLUENCE_UNIVERSE.filter((s) => s.symbol !== 'SMH')) { + assert.equal(s.kind, 'equity', `${s.symbol} should be equity`); + } + }); +}); + +describe('defineSystemRackPresets', () => { + it('returns exactly 3 presets', () => { + const presets = defineSystemRackPresets(); + assert.equal(presets.length, 3); + }); + + it('all slot ids in presets are valid', () => { + const validIds = new Set(CONFLUENCE_SLOT_IDS); + for (const preset of defineSystemRackPresets()) { + for (const id of preset.slotIds) { + assert.ok(validIds.has(id), `${preset.name} has unknown slot id: ${id}`); + } + } + }); + + it('"Full Confluence" uses all34 slots', () => { + const full = defineSystemRackPresets().find((p) => p.id === 'confluence-full')!; + assert.equal(full.slotIds.length, 34); + }); + + it('"Technical Momentum" has 15 slots', () => { + const tech = defineSystemRackPresets().find((p) => p.id === 'confluence-technical')!; + assert.equal(tech.slotIds.length, 15); + }); + + it('"Macro + Flows + Sentiment" has 14 slots', () => { + const macro = defineSystemRackPresets().find((p) => p.id === 'confluence-macro-flows')!; + assert.equal(macro.slotIds.length, 14); + }); +}); + +describe('seedConfluence', () => { + it('pins16 symbols (15 universe + benchmark) and creates 3 system racks', async () => { + const { symbolsPinned, racksCreated } = await seedConfluence(db, cache); + assert.equal(symbolsPinned, 16); + assert.equal(racksCreated, 3); + const systemRacks = repo.listSystemRacks(); + assert.equal(systemRacks.length, 3); + }); + + it('is idempotent: running twice does not duplicate racks', async () => { + await seedConfluence(db, cache); + await seedConfluence(db, cache); + assert.equal(repo.listSystemRacks().length, 3); + }); +}); \ No newline at end of file diff --git a/app/server/src/confluence/confluenceSeed.ts b/app/server/src/confluence/confluenceSeed.ts new file mode 100644 index 0000000..6dcb7f8 --- /dev/null +++ b/app/server/src/confluence/confluenceSeed.ts @@ -0,0 +1,139 @@ +// Investor Flow — Confluence Starter Seed (M22, slice 10) +// +// Defines the 15-symbol confluence universe, three system rack presets, and the +// startup function that pins the universe into the demand set and persists the +// system racks if they don't exist yet. +// +// Called once on server start from index.ts. All operations are idempotent: +// running twice is a no-op. + +import type { DatabaseSync } from 'node:sqlite'; +import type { CacheRepository, TickerKind } from '../cache/CacheRepository.ts'; +import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts'; +import { CONFLUENCE_SLOTS, CONFLUENCE_SLOT_IDS } from './confluenceSlots.ts'; + +// --------------------------------------------------------------------------- +// Universe +// --------------------------------------------------------------------------- + +export interface ConfluenceSymbol { + symbol: string; + kind: TickerKind; + label: string; +} + +/** + * The15-symbol confluence research universe + SPY benchmark. Every confluence + * evaluation and backtest assumes these symbols are warm. SMH is the + * semiconductor ETF; the rest are single-name equities. + */ +export const CONFLUENCE_UNIVERSE: ConfluenceSymbol[] = [ + { symbol: 'PLTR', kind: 'equity', label: 'Palantir' }, + { symbol: 'NVDA', kind: 'equity', label: 'NVIDIA' }, + { symbol: 'AMD', kind: 'equity', label: 'AMD' }, + { symbol: 'AAPL', kind: 'equity', label: 'Apple' }, + { symbol: 'MSFT', kind: 'equity', label: 'Microsoft' }, + { symbol: 'SMH', kind: 'etf', label: 'Semiconductor ETF' }, + { symbol: 'XOM', kind: 'equity', label: 'ExxonMobil' }, + { symbol: 'JPM', kind: 'equity', label: 'JPMorgan' }, + { symbol: 'UNH', kind: 'equity', label: 'UnitedHealth' }, + { symbol: 'COST', kind: 'equity', label: 'Costco' }, + { symbol: 'AMZN', kind: 'equity', label: 'Amazon' }, + { symbol: 'CAT', kind: 'equity', label: 'Caterpillar' }, + { symbol: 'LMT', kind: 'equity', label: 'Lockheed Martin' }, + { symbol: 'LIN', kind: 'equity', label: 'Linde' }, + { symbol: 'NEE', kind: 'equity', label: 'NextEra Energy' }, +]; + +export const BENCHMARK_SYMBOL = 'SPY'; + +// --------------------------------------------------------------------------- +// System rack presets +// --------------------------------------------------------------------------- + +interface RackPreset { + id: string; + name: string; + description: string; + slotIds: string[]; +} + +const ALL_IDS = CONFLUENCE_SLOT_IDS as readonly string[]; + +function familySlots(...families: string[]): string[] { + return CONFLUENCE_SLOTS.filter((s) => families.includes(s.family)).map((s) => s.id); +} + +/** + * Three curated system rack presets. Each is a different lens on the same + * symbol data, expressed as a subset of the 34-slot catalog: + * + * 1. "Full Confluence" — all 34 slots (the default every-picture view) + * 2. "Technical Momentum" — the15 technical slots only (price-action focus) + * 3. "Macro + Flows + Sentiment" — macro 5 + seasonal 5 + flows 3 + + * sentiment 1 = 14 slots (the macro/structural lens) + */ +export function defineSystemRackPresets(): RackPreset[] { + return [ + { + id: 'confluence-full', + name: 'Full Confluence', + description: 'All 34 slots. The broadest evidence view of a symbol\'s picture.', + slotIds: [...ALL_IDS], + }, + { + id: 'confluence-technical', + name: 'Technical Momentum', + description: 'The 15 technical slots: trend, momentum, mean-reversion, and volume.', + slotIds: familySlots('technical'), + }, + { + id: 'confluence-macro-flows', + name: 'Macro + Flows + Sentiment', + description: 'Macro regime, seasonal calendar, ETF/COT flows, and informed-commentator sentiment (14 slots).', + slotIds: familySlots('macro', 'seasonal', 'flows', 'sentiment'), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Seed routine +// --------------------------------------------------------------------------- + +/** + * Pin the confluence universe into the demand set and create the three system + * rack presets if they don't already exist. Safe to call on every startup. + */ +export async function seedConfluence( + db: DatabaseSync, + cache: CacheRepository, +): Promise<{ symbolsPinned: number; racksCreated: number }> { + // 1. Pin universe symbols + benchmark into the permanent demand set. + const allSymbols: ConfluenceSymbol[] = [ + ...CONFLUENCE_UNIVERSE, + { symbol: BENCHMARK_SYMBOL, kind: 'etf', label: 'S&P 500 benchmark' }, + ]; + let symbolsPinned = 0; + for (const { symbol, kind } of allSymbols) { + try { + await cache.pinSystemSymbol(symbol, kind); + symbolsPinned++; + } catch { /* symbol already pinned — ignore */ } + } + + // 2. Create system rack presets that don't already exist. + const repo = new ConfluenceRepository(db); + const existing = new Set(repo.listSystemRacks().map((r) => r.id)); + let racksCreated = 0; + for (const preset of defineSystemRackPresets()) { + if (existing.has(preset.id)) continue; + const rack = rackFromSlots(preset.id, preset.name, preset.slotIds, { + isSystem: true, + description: preset.description, + }); + repo.saveRack(rack); + racksCreated++; + } + + return { symbolsPinned, racksCreated }; +} \ No newline at end of file diff --git a/app/server/src/index.ts b/app/server/src/index.ts index 101f33b..4731259 100644 --- a/app/server/src/index.ts +++ b/app/server/src/index.ts @@ -21,6 +21,7 @@ import cryptoMod from './lib/crypto.ts'; import { AdapterQueue } from './queue/AdapterQueue.ts'; import { seedCuratedCusips } from './services/cusipRegistry.ts'; import { seedAdminDefaultAlertSubscriptions } from './db/alertSubscriptionRepository.ts'; +import { seedConfluence } from './confluence/confluenceSeed.ts'; import { makeCreateContext } from './trpc/context.ts'; import { appRouter } from './trpc/router.ts'; import type { Alert } from './alerts/AlertEngine.ts'; @@ -100,6 +101,16 @@ try { console.error('[investor-flow] admin alert seed failed', e); } +// Confluence starter seed: pin15-symbol universe + 3 system rack presets +try { + const { symbolsPinned, racksCreated } = await seedConfluence(database, cache); + if (symbolsPinned > 0 || racksCreated > 0) { + console.log(`[investor-flow] confluence seed: ${symbolsPinned} symbols pinned, ${racksCreated} system racks created`); + } +} catch (e) { + console.error('[investor-flow] confluence seed failed', e); +} + // Demand hygiene: junk test symbols + inflated refcounts from page-view subscribe spam try { const hygiene = queue.cleanupDemandHygiene();