Files
investor-flow/app/server/src/db/client.ts
T

675 lines
36 KiB
TypeScript

// Investor Flow — SQLite DB client (node:sqlite). DESIGN.md §1 schema seam.
//
// Runtime adaptation (reversible): design specified Bun + bun:sqlite. Implemented on
// Node 26 native TS (type-stripping) + node:sqlite (built-in) so the backend has ZERO
// native dependencies. Deep-module architecture (CacheRepository, etc.) is unchanged.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = join(__dirname, 'schema.sql');
const DEFAULT_DB_DIR = join(__dirname, '..', '..', 'data');
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'investor-flow.db');
export interface DbOptions {
/** SQLite file path. Defaults to $IFLOW_DB_PATH or <server>/data/investor-flow.db. Use ':memory:' for tests. */
path?: string;
readonly?: boolean;
}
/** Open a SQLite connection with foreign keys + WAL enabled. */
export function createDb(opts: DbOptions = {}): DatabaseSync {
const path = opts.path ?? process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
if (!opts.readonly && path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
const database = new DatabaseSync(path, {
readOnly: opts.readonly ?? false,
enableForeignKeyConstraints: true,
});
// WAL is persistent on the file; setting per-connection is harmless and ensures it.
try {
database.exec('PRAGMA journal_mode = WAL;');
} catch {
/* readOnly or disallowed — ignore */
}
// Concurrent queue drain + request handlers write often; wait instead of failing immediately.
try {
database.exec('PRAGMA busy_timeout = 5000;');
} catch {
/* ignore */
}
return database;
}
/** Apply the full schema (CREATE TABLE IF NOT EXISTS). Idempotent. */
export function initSchema(database: DatabaseSync): void {
const sql = readFileSync(SCHEMA_PATH, 'utf8');
database.exec(sql);
}
/** Idempotent migrations for existing databases (new columns, tables, dedupes). */
function runMigrations(db: DatabaseSync): void {
// 1. New columns (ignore "already exists").
const migrations: string[] = [
`ALTER TABLE adapter_queue ADD COLUMN error TEXT`,
`ALTER TABLE adapter_queue ADD COLUMN scheduled_for TEXT`,
`ALTER TABLE insider_transactions ADD COLUMN accession TEXT`,
`ALTER TABLE institution_filings ADD COLUMN accession TEXT`,
`ALTER TABLE x_credentials ADD COLUMN fred_api_key_enc TEXT`,
`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`,
// Onboarding / risk posture fields added after early installs (CREATE IF NOT EXISTS does not alter).
`ALTER TABLE users ADD COLUMN drawdown_tolerance REAL`,
`ALTER TABLE users ADD COLUMN backup_codes_hashed TEXT`,
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE rotation_state ADD COLUMN signal_since TEXT`,
`ALTER TABLE users ADD COLUMN modules TEXT NOT NULL DEFAULT '["research","settings"]'`,
`UPDATE users SET modules='["research","execution","analytics","settings"]' WHERE modules='["research","settings"]'`,
// Workspace profile (adaptive management density)
`ALTER TABLE users ADD COLUMN experience_stage TEXT`,
`ALTER TABLE users ADD COLUMN goal TEXT`,
`ALTER TABLE users ADD COLUMN horizon TEXT`,
`ALTER TABLE users ADD COLUMN density TEXT NOT NULL DEFAULT 'focused'`,
`ALTER TABLE users ADD COLUMN jargon_comfort TEXT NOT NULL DEFAULT 'plain'`,
// Backfill density / experience_stage from legacy complexity (idempotent)
`UPDATE users SET density='standard', experience_stage=COALESCE(experience_stage, 'some_stocks') WHERE complexity='intermediate' AND (experience_stage IS NULL OR density='focused')`,
`UPDATE users SET density='full', experience_stage=COALESCE(experience_stage, 'active_self_directed') WHERE complexity='advanced' AND (experience_stage IS NULL OR density IN ('focused','standard'))`,
`UPDATE users SET experience_stage=COALESCE(experience_stage, 'never_invested') WHERE complexity='beginner' AND experience_stage IS NULL`,
`ALTER TABLE watchlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'user'`,
`ALTER TABLE watchlists ADD COLUMN class_key TEXT`,
`ALTER TABLE watchlists ADD COLUMN class_label TEXT`,
// System-pinned demand symbols (rotation universe / benchmarks) survive unsubscribe
`ALTER TABLE symbol_demand ADD COLUMN system_pin INTEGER NOT NULL DEFAULT 0`,
// Extended-hours quote fields (pre/post last vs RTH close)
`ALTER TABLE quotes ADD COLUMN session TEXT`,
`ALTER TABLE quotes ADD COLUMN regular_price REAL`,
`DROP INDEX IF EXISTS idx_watchlists_owner_name`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_kind_name ON watchlists(owner_id, kind, name)`,
`CREATE TABLE IF NOT EXISTS rotation_state (
id TEXT PRIMARY KEY DEFAULT 'singleton',
signal TEXT NOT NULL DEFAULT 'none',
spread REAL,
inflow_avg REAL,
outflow_avg REAL,
sub_group TEXT,
checked_at TEXT,
signal_since TEXT
)`,
`CREATE TABLE IF NOT EXISTS rotation_rank_snapshots (
as_of_date TEXT NOT NULL,
symbol TEXT NOT NULL,
name TEXT,
grp TEXT,
rank_1m INTEGER,
rs_1m REAL,
rank_1w INTEGER,
rs_1w REAL,
leadership TEXT,
early_watch INTEGER NOT NULL DEFAULT 0,
strength TEXT,
PRIMARY KEY (as_of_date, symbol)
)`,
`CREATE INDEX IF NOT EXISTS idx_rotation_rank_date ON rotation_rank_snapshots(as_of_date DESC)`,
// Option legs book (MVP risk contribution; M17 sleeve later).
`CREATE TABLE IF NOT EXISTS portfolio_option_legs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
underlying TEXT NOT NULL,
right TEXT NOT NULL,
side TEXT NOT NULL,
strike REAL NOT NULL,
expiry TEXT NOT NULL,
contracts REAL NOT NULL,
premium REAL NOT NULL,
multiplier INTEGER NOT NULL DEFAULT 100,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
acquired_at TEXT NOT NULL,
note TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_option_legs_owner_status
ON portfolio_option_legs(owner_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_option_legs_owner_status
ON portfolio_option_legs(owner_id, status)`,
// 13F put/call indicator for options positions.
`ALTER TABLE institution_filings ADD COLUMN put_call TEXT`,
// Mirror Portfolio (M21) — tracked funds + position records.
`CREATE TABLE IF NOT EXISTS tracked_funds (
id TEXT PRIMARY KEY,
ci_key TEXT NOT NULL,
fund_name TEXT NOT NULL,
manager_name TEXT NOT NULL,
x_handle TEXT,
paywall_status TEXT NOT NULL DEFAULT 'unknown',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_tracked_funds_cik ON tracked_funds(ci_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_tracked_funds_name ON tracked_funds(fund_name)`,
`CREATE TABLE IF NOT EXISTS fund_position_records (
id TEXT PRIMARY KEY,
fund_id TEXT NOT NULL REFERENCES tracked_funds(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
shares REAL,
value_usd REAL,
cost_basis REAL,
as_of TEXT NOT NULL,
source TEXT NOT NULL,
evidence_url TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_fund_records_fund_asof ON fund_position_records(fund_id, symbol, as_of DESC)`,
// Symbol Search Index (M22) — issuer CIK on symbols + lookup index.
`ALTER TABLE symbols ADD COLUMN cik TEXT`,
`CREATE INDEX IF NOT EXISTS idx_symbols_cik ON symbols(cik)`,
// Dealer Flow velocity snapshots (PR2)
`CREATE TABLE IF NOT EXISTS dealer_map_snapshots (
symbol TEXT NOT NULL,
as_of_date TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (symbol, as_of_date)
)`,
`CREATE INDEX IF NOT EXISTS idx_dealer_map_snapshots_symbol ON dealer_map_snapshots(symbol, as_of_date DESC)`,
`CREATE TABLE IF NOT EXISTS user_llm_endpoints (
user_id TEXT PRIMARY KEY,
base_url TEXT NOT NULL,
api_key_enc TEXT,
model TEXT NOT NULL DEFAULT 'gpt-4o-mini',
key_hint TEXT,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_alert_events_type_time ON alert_events(type, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_alerts_owner_type ON alerts(owner_id, alert_type)`,
`CREATE TABLE IF NOT EXISTS producer_run_log (
id TEXT PRIMARY KEY,
producer TEXT NOT NULL,
frequency TEXT NOT NULL,
events_fired INTEGER NOT NULL DEFAULT 0,
inspected INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
error TEXT,
run_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_producer_run_log_producer ON producer_run_log(producer, run_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_adapter_queue_status ON adapter_queue(status, last_attempt)`,
`CREATE INDEX IF NOT EXISTS idx_queue_errors_key_ts ON queue_errors(queue_key, ts DESC)`,
`CREATE TABLE IF NOT EXISTS notification_outbox (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
symbol TEXT,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
sent_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_notification_outbox_pending ON notification_outbox(status, created_at)`,
// Confluence Signal Engine (M22) — racks, evaluations, signal history.
`CREATE TABLE IF NOT EXISTS confluence_racks (
id TEXT PRIMARY KEY,
owner_id TEXT,
name TEXT NOT NULL,
slot_ids_json TEXT NOT NULL,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_racks_owner ON confluence_racks(owner_id)`,
`CREATE TABLE IF NOT EXISTS confluence_evaluations (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
as_of TEXT NOT NULL,
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
assessments_json TEXT NOT NULL,
bull_evidence REAL NOT NULL,
bear_evidence REAL NOT NULL,
bull_count INTEGER NOT NULL,
bear_count INTEGER NOT NULL,
assessed_count INTEGER NOT NULL,
net_evidence REAL NOT NULL,
total_evidence REAL NOT NULL,
quality TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(symbol, as_of, rack_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_evals_symbol_asof ON confluence_evaluations(symbol, as_of DESC)`,
`CREATE TABLE IF NOT EXISTS confluence_signal_history (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
slot_id TEXT NOT NULL,
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
fired_at TEXT NOT NULL,
quality_at_fire TEXT NOT NULL,
price_resolved INTEGER NOT NULL DEFAULT 0,
price_confirmed INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT,
verdict TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id)`,
];
for (const sql of migrations) {
try { db.exec(sql); } catch { /* column already exists */ }
}
// 2. De-dupe legacy institution_filings rows (keep the oldest rowid per group).
// Uses rowid (SQLite built-in) rather than tuple NOT IN which can hit limits
// with large duplicate sets. This must succeed before creating the unique index.
try {
db.exec(`
DELETE FROM institution_filings
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM institution_filings
GROUP BY filer_cik, symbol, reported_quarter, form
)
`);
} catch { /* empty / locked — safe to skip */ }
// 3. Idempotency index for institution_filings (now safe after de-dupe).
try {
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_inst_filings ON institution_filings(filer_cik, symbol, reported_quarter, form)`);
} catch { /* ignore */ }
// 4. Rename legacy 'Starter' watchlist to 'default'.
try {
db.exec(`UPDATE watchlists SET name='default' WHERE name='Starter'`);
} catch { /* ignore */ }
// 5. Data-quality lint table.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS data_quality (
symbol TEXT NOT NULL,
kind TEXT NOT NULL,
last_checked_at TEXT,
stored_count INTEGER,
discovered_count INTEGER,
missing_count INTEGER,
stale INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
detail TEXT,
PRIMARY KEY (symbol, kind)
)
`);
} catch { /* ignore */ }
// 6. Analyst ratings / upgrades & downgrades (yahoo-finance2).
try { db.exec(`ALTER TABLE analyst_ratings ADD COLUMN target_from REAL`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE analyst_ratings ADD COLUMN target_to REAL`); } catch { /* ignore */ }
try {
db.exec(`
CREATE TABLE IF NOT EXISTS analyst_ratings (
symbol TEXT NOT NULL,
firm TEXT NOT NULL,
action TEXT,
grade_from TEXT,
grade_to TEXT,
target_from REAL,
target_to REAL,
rating_date TEXT NOT NULL,
fetched_at TEXT NOT NULL,
PRIMARY KEY (symbol, firm, rating_date)
)
`);
} catch { /* ignore */ }
try {
db.exec(`
CREATE TABLE IF NOT EXISTS analyst_consensus (
symbol TEXT PRIMARY KEY,
strong_buy INTEGER NOT NULL DEFAULT 0,
buy INTEGER NOT NULL DEFAULT 0,
hold INTEGER NOT NULL DEFAULT 0,
sell INTEGER NOT NULL DEFAULT 0,
strong_sell INTEGER NOT NULL DEFAULT 0,
fetched_at TEXT NOT NULL
)
`);
} catch { /* ignore */ }
// 7. Alert subscriptions: add watchlist_id + enabled columns (existing DBs).
try { db.exec(`ALTER TABLE alerts ADD COLUMN watchlist_id TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE alerts ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`); } catch { /* ignore */ }
// 8. Alert comparison state table.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS alert_comparison_state (
symbol TEXT NOT NULL,
alert_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL,
PRIMARY KEY (symbol, alert_type)
)
`);
} catch { /* ignore */ }
// 9. SMTP config table for email alerts.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS smtp_config (
id TEXT PRIMARY KEY DEFAULT 'singleton',
host TEXT NOT NULL DEFAULT 'smtp.mail.me.com',
port INTEGER NOT NULL DEFAULT 587,
secure INTEGER NOT NULL DEFAULT 0,
user TEXT,
pass_enc TEXT,
from_name TEXT NOT NULL DEFAULT 'Investor Flow',
from_email TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
`);
} catch { /* ignore */ }
// 10. FINRA config + tables + schedule update (daily + bi-monthly).
try { db.exec(`UPDATE queue_schedules SET interval_ms=86400000 WHERE source_kind='finra-bulk'`); } catch { /* table may not exist yet */ }
try { db.exec(`INSERT OR IGNORE INTO queue_schedules (source_kind, interval_ms, last_enqueued, next_enqueue) VALUES ('finra-si', 1209600000, NULL, ?)`); } catch { /* ignore */ }
try {
db.exec(`
CREATE TABLE IF NOT EXISTS finra_config (
id TEXT PRIMARY KEY DEFAULT 'singleton',
base_url TEXT NOT NULL DEFAULT 'https://cdn.finra.org/equity/regsho/daily',
updated_at TEXT NOT NULL
)
`);
} catch { /* ignore */ }
try {
db.exec(`
CREATE TABLE IF NOT EXISTS finra_short_interest_biweekly (
symbol TEXT NOT NULL, settlement_date TEXT NOT NULL, issue_name TEXT,
exchange_code TEXT, market_class TEXT, current_short_position REAL,
previous_short_position REAL, avg_daily_volume REAL, days_to_cover REAL,
change_percent REAL, change_previous REAL, revision_flag TEXT,
source_file TEXT, ingested_at TEXT NOT NULL,
PRIMARY KEY (symbol, settlement_date)
)
`);
} catch { /* ignore */ }
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_finra_si_symbol ON finra_short_interest_biweekly(symbol)`); } catch { /* ignore */ }
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_finra_si_settlement ON finra_short_interest_biweekly(settlement_date)`); } catch { /* ignore */ }
// 11. Strategy lab + execution redesign tables and columns.
try { db.exec(`ALTER TABLE strategies ADD COLUMN preset_id TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE strategies ADD COLUMN scope TEXT NOT NULL DEFAULT 'single'`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE strategies ADD COLUMN components TEXT NOT NULL DEFAULT '[]'`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE strategy_presets ADD COLUMN example_tickers TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE strategies ADD COLUMN exit_profile TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE strategies ADD COLUMN risk_policy TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE trades ADD COLUMN realized_pnl REAL`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE trades ADD COLUMN allocation_plan_id TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE trades ADD COLUMN allocation_role TEXT`); } catch { /* ignore */ }
try { db.exec(`CREATE TABLE IF NOT EXISTS strategy_presets (id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, complexity TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'single', description TEXT NOT NULL, entry_rules TEXT NOT NULL DEFAULT '[]', exit_profile TEXT NOT NULL DEFAULT '[]', risk_policy TEXT NOT NULL DEFAULT '[]', instrument_type TEXT NOT NULL DEFAULT 'stock', portfolio_template TEXT, conviction_tier_default TEXT, requires_options_unlock INTEGER NOT NULL DEFAULT 0, forkable INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL)`); } catch { /* ignore */ }
try { db.exec(`CREATE TABLE IF NOT EXISTS allocation_plans (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, strategy_id TEXT NOT NULL REFERENCES strategies(id) ON DELETE CASCADE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'building', target_alloc TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL)`); } catch { /* ignore */ }
try { db.exec(`CREATE TABLE IF NOT EXISTS dividend_fundamentals (symbol TEXT PRIMARY KEY, dividend_yield REAL, payout_ratio REAL, dividend_rate REAL, ex_dividend_date TEXT, trailing_eps REAL, forward_pe REAL, growth_streak_years INTEGER, observed_at TEXT NOT NULL)`); } catch { /* ignore */ }
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_strategies_preset ON strategies(preset_id)`); } catch { /* ignore */ }
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_alloc_owner ON allocation_plans(owner_id)`); } catch { /* ignore */ }
// Seed strategy presets (idempotent).
const now = new Date().toISOString();
const seedPresets = [
{
id: 'dca_accumulation', name: 'DCA / Accumulation', category: 'growth', complexity: 'beginner', scope: 'single',
description: 'Build a position steadily over time by investing fixed amounts at regular intervals, regardless of price. Removes the stress of timing the market.',
entry_rules: JSON.stringify([{ conditions: ['Time-based: scheduled buy day', 'Cash available >= planned amount'] }]),
exit_profile: JSON.stringify([
{ action: 'Take profit', reason: 'Price reaches target return (e.g. +20%)', threshold: 0.2 },
{ action: 'Stop loss', reason: 'Price drops below cost basis by max drawdown tolerance', threshold: -0.15 },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.15, stopLossPct: 0.15, takeProfitPct: 0.2, maxDrawdownPct: 0.2 }),
instrument_type: 'stock', portfolio_template: null, conviction_tier_default: 'B', requires_options_unlock: 0, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'VOO', name: 'S&P 500 ETF', role: 'Core holding', allocationPct: 60, reason: 'Broad market low-cost ETF' },
{ symbol: 'IVV', name: 'S&P 500 ETF (iShares)', role: 'Core holding', allocationPct: 40, reason: 'Alternative broad market ETF' },
]),
},
{
id: 'dividend_growth', name: 'Dividend Growth', category: 'income', complexity: 'beginner', scope: 'single',
description: 'Own companies that consistently pay and grow their dividends. Provides income while you hold and benefits from compounding over time.',
entry_rules: JSON.stringify([{ conditions: ['Dividend yield >= 2%', 'Payout ratio <= 60%', 'Dividend growth streak >= 5 years', 'Forward P/E <= 25'] }]),
exit_profile: JSON.stringify([
{ action: 'Sell', reason: 'Dividend cut or suspension', threshold: null },
{ action: 'Sell', reason: 'Payout ratio exceeds 80%', threshold: 0.8 },
{ action: 'Take profit', reason: 'Position grows beyond portfolio allocation target', threshold: null },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.1, stopLossPct: 0.2, maxDrawdownPct: 0.25 }),
instrument_type: 'stock', portfolio_template: null, conviction_tier_default: 'B', requires_options_unlock: 0, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'SCHD', name: 'Schwab US Dividend Equity ETF', role: 'Dividend growth', allocationPct: 40, reason: 'Strong dividend growth history' },
{ symbol: 'JNJ', name: 'Johnson & Johnson', role: 'Dividend growth', allocationPct: 20, reason: '60+ years of dividend growth' },
{ symbol: 'KO', name: 'Coca-Cola', role: 'Dividend growth', allocationPct: 20, reason: '60+ years of dividend growth' },
{ symbol: 'PG', name: 'Procter & Gamble', role: 'Dividend growth', allocationPct: 20, reason: '130+ years of dividends' },
]),
},
{
id: 'core_satellite', name: 'Core + Satellite', category: 'portfolio', complexity: 'intermediate', scope: 'portfolio',
description: 'Put the majority in a broad market core (e.g. VOO, VTI) for stable growth, then add smaller satellite positions around your best ideas.',
entry_rules: JSON.stringify([{ conditions: ['Core: broad market ETF (VOO, VTI, IVV, etc.)', 'Satellites: individual stocks or sector ETFs', 'Each satellite <= target allocation %'] }]),
exit_profile: JSON.stringify([
{ action: 'Rebalance', reason: 'Satellite drifts beyond allocation band', threshold: 0.05 },
{ action: 'Sell satellite', reason: 'Thesis invalidated or position no longer conviction', threshold: null },
{ action: 'Harvest', reason: 'Satellite hits profit target', threshold: null },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.05, stopLossPct: 0.2 }),
instrument_type: 'stock', portfolio_template: JSON.stringify([
{ role: 'Core (broad market)', target: 70, min: 60, max: 80 },
{ role: 'Satellite - Growth', target: 15, min: 5, max: 25 },
{ role: 'Satellite - Value/Income', target: 15, min: 5, max: 25 },
]), conviction_tier_default: 'B', requires_options_unlock: 0, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'VOO', name: 'S&P 500 ETF', role: 'Core', allocationPct: 50, reason: 'Broad market core holding' },
{ symbol: 'VXUS', name: 'Total International Stock ETF', role: 'Core', allocationPct: 20, reason: 'International diversification' },
{ symbol: 'QQQ', name: 'Nasdaq 100 ETF', role: 'Satellite - Growth', allocationPct: 15, reason: 'Tech-heavy growth exposure' },
{ symbol: 'SCHD', name: 'Schwab US Dividend Equity ETF', role: 'Satellite - Income', allocationPct: 15, reason: 'Dividend growth complement' },
]),
},
{
id: 'barbell_strategy', name: 'Bar-bell', category: 'portfolio', complexity: 'intermediate', scope: 'portfolio',
description: 'Keep most of your capital in very safe assets (broad market ETFs, bonds) and put a small portion in high-growth bets. Limits downside while preserving upside.',
entry_rules: JSON.stringify([{ conditions: ['Safe portion: broad market ETF or bond ETF', 'Growth portion: high-conviction growth stocks or sector bets', 'Growth allocation <= max risk budget'] }]),
exit_profile: JSON.stringify([
{ action: 'Rebalance', reason: 'Growth portion exceeds allocation band due to outsized gains', threshold: 0.05 },
{ action: 'Cut growth position', reason: 'Growth thesis breaks or stop loss triggered', threshold: null },
{ action: 'Trim', reason: 'Growth position exceeds max single-stock allocation', threshold: null },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.05, stopLossPct: 0.2, maxDrawdownPct: 0.15 }),
instrument_type: 'stock', portfolio_template: JSON.stringify([
{ role: 'Safe (broad market / bonds)', target: 80, min: 70, max: 90 },
{ role: 'Growth bets', target: 20, min: 10, max: 30 },
]), conviction_tier_default: 'B', requires_options_unlock: 0, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'VOO', name: 'S&P 500 ETF', role: 'Safe core', allocationPct: 50, reason: 'Broad market core' },
{ symbol: 'BND', name: 'Total Bond Market ETF', role: 'Safe bonds', allocationPct: 30, reason: 'Bond diversification' },
{ symbol: 'NVDA', name: 'NVIDIA Corporation', role: 'Growth bet', allocationPct: 10, reason: 'High-growth tech leader' },
{ symbol: 'AMZN', name: 'Amazon.com Inc', role: 'Growth bet', allocationPct: 10, reason: 'High-growth consumer/cloud leader' },
]),
},
{
id: 'covered_call_wheel', name: 'Covered Call Income', category: 'income', complexity: 'advanced', scope: 'single',
description: 'Generate regular income from stocks you plan to hold by selling call options against them. Requires understanding of options basics.',
entry_rules: JSON.stringify([{ conditions: ['Own at least 100 shares of underlying', 'Stock is one you want to hold long-term', 'Option liquidity: bid-ask spread < $0.20', 'IV percentile < 70 (avoid selling into low vol)'] }]),
exit_profile: JSON.stringify([
{ action: 'Roll', reason: 'Stock approaches strike price before expiration', threshold: null },
{ action: 'Let shares get called away', reason: 'Stock closes above strike at expiration', threshold: null },
{ action: 'Close early', reason: 'Captured most of the premium, time value decayed', threshold: null },
{ action: 'Sell put', reason: 'After shares called away, sell cash-secured put to re-enter', threshold: null },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.1, stopLossPct: 0.2, maxDrawdownPct: 0.3 }),
instrument_type: 'option', portfolio_template: null, conviction_tier_default: 'C', requires_options_unlock: 1, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'AAPL', name: 'Apple Inc.', role: 'Covered call', allocationPct: 30, reason: 'High liquidity, strong options market' },
{ symbol: 'MSFT', name: 'Microsoft Corporation', role: 'Covered call', allocationPct: 30, reason: 'High liquidity, stable price' },
{ symbol: 'NVDA', name: 'NVIDIA Corporation', role: 'Covered call', allocationPct: 20, reason: 'Active options chain' },
{ symbol: 'AMZN', name: 'Amazon.com Inc', role: 'Covered call', allocationPct: 20, reason: 'High liquidity options market' },
]),
},
{
id: 'concentrated_value', name: 'Concentrated Value', category: 'growth', complexity: 'advanced', scope: 'portfolio',
description: 'Own a small number of high-conviction positions you understand deeply. Buy at a significant discount to intrinsic value, hold through volatility, and let your best ideas drive returns. Based on the Alpine Fox Capital playbook: board-level research, contrarian entry, permanent capital mindset.',
entry_rules: JSON.stringify([{ conditions: ['Deep research completed — understand the business at board level', 'Price at significant discount to estimated intrinsic value (margin of safety)', 'Contrarian — buying when out of favor or misunderstood', 'Management quality and capital allocation verified', 'Thesis is multi-year, not a trade'] }]),
exit_profile: JSON.stringify([
{ action: 'Sell', reason: 'Thesis invalidated — core assumption broken', threshold: null },
{ action: 'Sell', reason: 'Full valuation reached with no further upside', threshold: null },
{ action: 'Hold', reason: '50%+ drawdown is expected; re-check thesis, do not panic-sell', threshold: null },
]),
risk_policy: JSON.stringify({ maxPositionPct: 0.40, stopLossPct: null, maxDrawdownPct: 0.50 }),
instrument_type: 'stock', portfolio_template: JSON.stringify([
{ role: 'Core thesis positions', target: 80, min: 60, max: 95 },
{ role: 'Satellite / ballast', target: 20, min: 5, max: 40 },
]), conviction_tier_default: 'A', requires_options_unlock: 0, forkable: 1,
example_tickers: JSON.stringify([
{ symbol: 'CIFR', name: 'Cipher Mining', role: 'Core thesis', allocationPct: 25, reason: 'Bitcoin miner pivoting to AI/HPC infrastructure — largest position' },
{ symbol: 'IREN', name: 'IREN Limited', role: 'Core thesis', allocationPct: 20, reason: 'Bitcoin miner to AI cloud, board-level insight, held since $2' },
{ symbol: 'BKKT', name: 'Bakkt Holdings', role: 'Core thesis', allocationPct: 20, reason: 'Newest high-conviction build — 865K shares added in Q2 2026' },
{ symbol: 'IBIT', name: 'iShares Bitcoin Trust', role: 'Core thesis', allocationPct: 15, reason: 'Bitcoin ETF exposure, shares + calls for capital efficiency' },
{ symbol: 'OPEN', name: 'Opendoor Technologies', role: 'Core thesis', allocationPct: 10, reason: 'Contrarian bet — massive QoQ add (+6,581%)' },
{ symbol: 'NVO', name: 'Novo Nordisk', role: 'Satellite / ballast', allocationPct: 10, reason: 'Consumer staple ballast, consistent grower' },
]),
},
];
for (const p of seedPresets) {
try {
db.prepare(`INSERT OR IGNORE INTO strategy_presets (id, name, category, complexity, scope, description, entry_rules, exit_profile, risk_policy, instrument_type, portfolio_template, conviction_tier_default, requires_options_unlock, forkable, created_at, example_tickers) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(p.id, p.name, p.category, p.complexity, p.scope, p.description, p.entry_rules, p.exit_profile, p.risk_policy, p.instrument_type, p.portfolio_template, p.conviction_tier_default, p.requires_options_unlock, p.forkable, now, p.example_tickers);
} catch { /* row may already exist */ }
// Backfill example_tickers for rows that already existed before this column was added.
try {
db.prepare(`UPDATE strategy_presets SET example_tickers=? WHERE id=? AND example_tickers IS NULL`)
.run(p.example_tickers, p.id);
} catch { /* ignore */ }
}
// Dealer Flow Study Desk (educational practice setups + auto-grade).
try {
db.exec(`
CREATE TABLE IF NOT EXISTS dealer_study_setups (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
hypothesis_id TEXT NOT NULL,
title TEXT NOT NULL,
bias TEXT NOT NULL,
map_reading TEXT NOT NULL,
study_question TEXT NOT NULL,
entry REAL NOT NULL,
invalidation REAL NOT NULL,
target REAL NOT NULL,
horizon_days INTEGER NOT NULL,
confidence REAL,
map_snapshot TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'open',
grade TEXT,
score REAL,
grade_summary TEXT,
grade_path TEXT,
graded_at TEXT,
logged_at TEXT NOT NULL,
disclaimer TEXT NOT NULL
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_dealer_study_owner ON dealer_study_setups(owner_id, logged_at DESC)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_dealer_study_symbol ON dealer_study_setups(owner_id, symbol)`);
} catch { /* ignore */ }
// Mentor call ledger (local path-match grades for harvested sources).
try {
db.exec(`
CREATE TABLE IF NOT EXISTS mentor_sources (
handle TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
notes TEXT,
updated_at TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS mentor_calls (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
handle TEXT NOT NULL,
post_id TEXT NOT NULL,
posted_at TEXT,
symbol TEXT,
bias TEXT NOT NULL,
claim_type TEXT NOT NULL,
entry REAL NOT NULL,
invalidation REAL NOT NULL,
target REAL NOT NULL,
horizon_days INTEGER NOT NULL,
excerpt TEXT,
extract_confidence REAL,
status TEXT NOT NULL DEFAULT 'draft',
grade TEXT,
score REAL,
grade_summary TEXT,
grade_path TEXT,
graded_at TEXT,
logged_at TEXT NOT NULL,
UNIQUE(owner_id, handle, post_id)
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_mentor_calls_owner ON mentor_calls(owner_id, logged_at DESC)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_mentor_calls_handle ON mentor_calls(owner_id, handle)`);
} catch { /* ignore */ }
}
let _db: DatabaseSync | null = null;
/** Process-wide singleton connection (auto-creates + inits schema on first call). */
export function db(): DatabaseSync {
if (!_db) {
_db = createDb();
initSchema(_db);
runMigrations(_db);
// Ensure the anonymous user exists for unauthenticated watchlist/portfolio usage.
try {
_db.prepare("INSERT OR IGNORE INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)")
.run('anonymous', 'anonymous@investor-flow.local', '', new Date().toISOString());
} catch { /* table may not exist yet or already exists */ }
seedTrackedFunds(_db);
}
return _db;
}
/** Seed the operator-curated tracked-fund registry (v1: Alpine Fox Capital LLC). */
function seedTrackedFunds(db: DatabaseSync): void {
const alpine: (string | number | null)[] = [
'alpine-fox-capital',
'0002096493', // SEC CIK (filer key), Alpine Fox Capital LLC
'Alpine Fox Capital LLC',
'Mike Alfred',
'mikealfred', // manager X handle (no @)
'paywalled', // verified: subscribe-only capture posts return tombstones
1,
new Date().toISOString(),
null,
];
try {
db.prepare(`INSERT OR IGNORE INTO tracked_funds
(id, ci_key, fund_name, manager_name, x_handle, paywall_status, enabled, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)`).run(...alpine);
} catch { /* row may already exist */ }
// Keep the hero row (fund) in sync with changes we may want to ship.
try {
db.prepare(`UPDATE tracked_funds SET
x_handle='mikealfred', manager_name='Mike Alfred', paywall_status='paywalled', enabled=1
WHERE id='alpine-fox-capital'`).run();
} catch { /* ignore */ }
}
/** CLI entry: `node src/db/client.ts` initializes the on-disk database. */
const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
if (isMain) {
const d = createDb();
initSchema(d);
const path = process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
const rows = d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
console.log(`[investor-flow] schema initialized at ${path} (${rows.length} tables)`);
d.close();
}