675 lines
36 KiB
TypeScript
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();
|
|
}
|