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,121 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
addOptionLeg,
listOptionLegs,
removeOptionLeg,
defaultRole,
} from '../portfolioOptionRepository.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8');
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
db.exec(SCHEMA_SQL);
db.prepare(
'INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)',
).run('user_1', 'u@example.com', 'hash', '2026-01-01T00:00:00Z');
return db;
}
test('defaultRole maps long/short call/put', () => {
assert.equal(defaultRole('call', 'long'), 'long_call');
assert.equal(defaultRole('put', 'long'), 'long_put');
assert.equal(defaultRole('call', 'short'), 'covered_call');
assert.equal(defaultRole('put', 'short'), 'cash_secured_put');
});
test('addOptionLeg inserts and listOptionLegs returns open legs', () => {
const db = freshDb();
const id = addOptionLeg(db, 'user_1', {
underlying: 'aapl',
right: 'call',
side: 'long',
strike: 150,
expiry: '2026-06-20',
contracts: 2,
premium: 4.2,
});
assert.ok(id.startsWith('ol_'));
const legs = listOptionLegs(db, 'user_1');
assert.equal(legs.length, 1);
assert.equal(legs[0].underlying, 'AAPL');
assert.equal(legs[0].right, 'call');
assert.equal(legs[0].role, 'long_call');
assert.equal(legs[0].contracts, 2);
assert.equal(legs[0].premium, 4.2);
assert.equal(legs[0].multiplier, 100);
});
test('multiple strikes on same underlying are separate legs', () => {
const db = freshDb();
addOptionLeg(db, 'user_1', {
underlying: 'NVDA',
right: 'call',
side: 'long',
strike: 100,
expiry: '2026-01-16',
contracts: 1,
premium: 5,
});
addOptionLeg(db, 'user_1', {
underlying: 'NVDA',
right: 'call',
side: 'long',
strike: 120,
expiry: '2026-01-16',
contracts: 1,
premium: 2,
});
assert.equal(listOptionLegs(db, 'user_1').length, 2);
});
test('removeOptionLeg soft-closes and hides from list', () => {
const db = freshDb();
const id = addOptionLeg(db, 'user_1', {
underlying: 'SPY',
right: 'put',
side: 'short',
strike: 400,
expiry: '2026-03-20',
contracts: 1,
premium: 3,
});
assert.equal(listOptionLegs(db, 'user_1').length, 1);
assert.equal(removeOptionLeg(db, 'user_1', id), true);
assert.equal(listOptionLegs(db, 'user_1').length, 0);
assert.equal(removeOptionLeg(db, 'user_1', id), false);
});
test('rejects invalid inputs', () => {
const db = freshDb();
assert.throws(() =>
addOptionLeg(db, 'user_1', {
underlying: '',
right: 'call',
side: 'long',
strike: 10,
expiry: '2026-01-01',
contracts: 1,
premium: 1,
}),
);
assert.throws(() =>
addOptionLeg(db, 'user_1', {
underlying: 'AAPL',
right: 'call',
side: 'long',
strike: 0,
expiry: '2026-01-01',
contracts: 1,
premium: 1,
}),
);
});
@@ -0,0 +1,296 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
addHolding,
updateHolding,
removeHolding,
listHoldings,
} from '../portfolioRepository.ts';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8');
/** Create a fresh in-memory DatabaseSync with the portfolio tables ready. */
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
db.exec(SCHEMA_SQL);
// Seed a users row so the FK constraint on portfolio_holdings.owner_id doesn't fire.
db.prepare(
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
).run('user_1', 'u@example.com', 'hash', '2026-01-01T00:00:00Z');
return db;
}
// ---------------------------------------------------------------------------
// Tests — addHolding (VWAP accumulation)
// ---------------------------------------------------------------------------
test('addHolding inserts a new holding and returns true', () => {
const db = freshDb();
const inserted = addHolding(db, 'user_1', 'NVDA', 10, 50);
assert.equal(inserted, true);
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings.length, 1);
assert.equal(holdings[0].symbol, 'NVDA');
assert.equal(holdings[0].shares, 10);
assert.equal(holdings[0].avg_cost, 50);
db.close();
});
test('addHolding accumulates into existing holding via VWAP (fix #1)', () => {
const db = freshDb();
// Buy 10 @ $50.
addHolding(db, 'user_1', 'NVDA', 10, 50);
// Buy 10 @ $60 — should accumulate, not replace.
const accumulated = addHolding(db, 'user_1', 'NVDA', 10, 60);
// Second add returns false (existing row was updated, not a new insert).
assert.equal(accumulated, false);
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings.length, 1);
// VWAP: (10*50 + 10*60) / (10+10) = 1100/20 = 55
assert.equal(holdings[0].shares, 20);
assert.equal(holdings[0].avg_cost, 55);
db.close();
});
test('addHolding with unequal quantities computes correct VWAP', () => {
const db = freshDb();
addHolding(db, 'user_1', 'AAPL', 5, 100);
addHolding(db, 'user_1', 'AAPL', 15, 80);
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings.length, 1);
// VWAP: (5*100 + 15*80) / (5+15) = (500 + 1200) / 20 = 85
assert.equal(holdings[0].shares, 20);
assert.equal(holdings[0].avg_cost, 85);
db.close();
});
test('addHolding with zero shares throws (fix #4)', () => {
const db = freshDb();
assert.throws(
() => addHolding(db, 'user_1', 'AAPL', 0, 100),
/shares must be > 0/,
);
db.close();
});
test('addHolding with negative shares throws (fix #4)', () => {
const db = freshDb();
assert.throws(
() => addHolding(db, 'user_1', 'AAPL', -5, 100),
/shares must be > 0/,
);
db.close();
});
test('addHolding with negative avgCost throws (fix #4)', () => {
const db = freshDb();
assert.throws(
() => addHolding(db, 'user_1', 'AAPL', 10, -5),
/avgCost must be >= 0/,
);
db.close();
});
test('re-adding same symbol does not duplicate (UNIQUE INDEX working, fix #0)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'TSLA', 10, 200);
addHolding(db, 'user_1', 'TSLA', 5, 210);
addHolding(db, 'user_1', 'TSLA', 3, 220);
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings.length, 1);
// 10+5+3 = 18 shares
assert.equal(holdings[0].shares, 18);
// VWAP: (10*200 + 5*210 + 3*220) / 18 = (2000+1050+660)/18 = 3710/18 ≈ 206.111...
assert.ok(Math.abs(holdings[0].avg_cost - 3710 / 18) < 0.001);
// Verify no 'closed' rows are lurking either.
const allRows = db.prepare(
'SELECT * FROM portfolio_holdings WHERE owner_id = ?',
).all('user_1') as Array<{ status: string }>;
assert.equal(allRows.length, 1);
assert.equal(allRows[0].status, 'open');
db.close();
});
// ---------------------------------------------------------------------------
// Tests — updateHolding (return-value fix #2)
// ---------------------------------------------------------------------------
test('updateHolding returns true when the holding is modified', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
const updated = updateHolding(db, 'user_1', 'NVDA', { shares: 20 });
assert.equal(updated, true);
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings[0].shares, 20);
db.close();
});
test('updateHolding returns false when no matching holding exists', () => {
const db = freshDb();
const updated = updateHolding(db, 'user_1', 'XYZ', { shares: 5 });
assert.equal(updated, false);
db.close();
});
test('updateHolding returns false when the row is already closed (no-op)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
// Soft-close it.
removeHolding(db, 'user_1', 'NVDA');
const updated = updateHolding(db, 'user_1', 'NVDA', { shares: 20 });
assert.equal(updated, false);
db.close();
});
test('updateHolding with negative shares throws (fix #4)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
assert.throws(
() => updateHolding(db, 'user_1', 'NVDA', { shares: -5 }),
/shares must be > 0/,
);
db.close();
});
test('updateHolding with negative avgCost throws (fix #4)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
assert.throws(
() => updateHolding(db, 'user_1', 'NVDA', { avgCost: -10 }),
/avgCost must be >= 0/,
);
db.close();
});
// ---------------------------------------------------------------------------
// Tests — removeHolding (soft-delete, fix #3)
// ---------------------------------------------------------------------------
test('removeHolding soft-closes by default (fix #3)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
const removed = removeHolding(db, 'user_1', 'NVDA');
assert.equal(removed, true);
// Should not appear in listHoldings (filters status='open').
const open = listHoldings(db, 'user_1');
assert.equal(open.length, 0);
// But the row still exists in the table with status='closed'.
const allRows = db.prepare(
'SELECT * FROM portfolio_holdings WHERE owner_id = ? AND symbol = ?',
).all('user_1', 'NVDA') as Array<{ status: string }>;
assert.equal(allRows.length, 1);
assert.equal(allRows[0].status, 'closed');
db.close();
});
test('removeHolding with permanent=true hard-deletes (fix #3)', () => {
const db = freshDb();
addHolding(db, 'user_1', 'NVDA', 10, 50);
const removed = removeHolding(db, 'user_1', 'NVDA', { permanent: true });
assert.equal(removed, true);
// Row should be completely gone.
const allRows = db.prepare(
'SELECT * FROM portfolio_holdings WHERE owner_id = ? AND symbol = ?',
).all('user_1', 'NVDA') as Array<{ status: string }>;
assert.equal(allRows.length, 0);
db.close();
});
test('removeHolding returns false when no matching holding exists', () => {
const db = freshDb();
const removed = removeHolding(db, 'user_1', 'XYZ');
assert.equal(removed, false);
db.close();
});
// ---------------------------------------------------------------------------
// Tests — listHoldings
// ---------------------------------------------------------------------------
test('listHoldings returns open holdings sorted by acquired_at DESC', () => {
const db = freshDb();
addHolding(db, 'user_1', 'AAPL', 5, 100);
addHolding(db, 'user_1', 'GOOG', 3, 200);
// Soft-close AAPL — should not appear.
removeHolding(db, 'user_1', 'AAPL');
const holdings = listHoldings(db, 'user_1');
assert.equal(holdings.length, 1);
assert.equal(holdings[0].symbol, 'GOOG');
db.close();
});
test('listHoldings returns empty array for user with no holdings', () => {
const db = freshDb();
const holdings = listHoldings(db, 'unknown_user');
assert.equal(holdings.length, 0);
db.close();
});
+2 -2
View File
@@ -15,10 +15,10 @@ const EXPECTED_TABLES = [
'institution_filings', 'insider_transactions', 'sector_map',
'macro_events', 'macro_regimes', 'price_adjustments',
'threads', 'adapter_queue', 'symbol_demand',
'watchlists', 'portfolio_holdings', 'trades', 'strategies',
'watchlists', 'portfolio_holdings', 'portfolio_option_legs', 'trades', 'strategies',
'screener_filters', 'reports', 'alerts', 'trusted_accounts',
'saved_posts', 'trade_key_levels',
'llm_summaries', 'rotation_signals', 'llm_providers', 'llm_dispatch_audit',
'llm_summaries', 'rotation_signals', 'rotation_rank_snapshots', 'llm_providers', 'llm_dispatch_audit',
];
test('schema creates all locked tables', () => {
+132 -1
View File
@@ -35,6 +35,12 @@ export function createDb(opts: DbOptions = {}): DatabaseSync {
} 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;
}
@@ -44,15 +50,140 @@ export function initSchema(database: DatabaseSync): void {
database.exec(sql);
}
/** Idempotent migrations for existing databases (new columns, tables). */
/** 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 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`,
`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)`,
// 13F put/call indicator for options positions.
`ALTER TABLE institution_filings ADD COLUMN put_call TEXT`,
];
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. 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 */ }
// 5. 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 */ }
}
let _db: DatabaseSync | null = null;
+138
View File
@@ -0,0 +1,138 @@
// Investor Flow — Emotion Log Repository (Slice 22: emotion-logger-storage)
//
// Thin data-access layer over the `emotion_logs` table. Logs user emotions during
// trade execution so they can be reviewed later for behavioral analysis.
//
// Schema (schema.sql):
// CREATE TABLE IF NOT EXISTS emotion_logs (
// id TEXT PRIMARY KEY,
// trade_execution_id TEXT NOT NULL,
// timestamp INTEGER NOT NULL,
// price_at_event REAL,
// emotion TEXT NOT NULL,
// note TEXT,
// created_at TEXT DEFAULT CURRENT_TIMESTAMP,
// FOREIGN KEY (trade_execution_id) REFERENCES trade_executions(id) ON DELETE CASCADE
// );
import type { DatabaseSync } from 'node:sqlite';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single emotion log entry. */
export interface EmotionLog {
id: string;
tradeExecutionId: string;
timestamp: number;
priceAtEvent?: number;
emotion: string;
note?: string;
createdAt: string;
}
// ---------------------------------------------------------------------------
// Prepared statements (lazy, one per method)
// ---------------------------------------------------------------------------
function stmts(db: DatabaseSync) {
return {
/** Insert an emotion log row. */
insert: db.prepare(
`INSERT INTO emotion_logs (id, trade_execution_id, timestamp, price_at_event, emotion, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
),
/** Select all emotion logs for a trade execution. */
selectByTrade: db.prepare(
`SELECT id, trade_execution_id, timestamp, price_at_event, emotion, note, created_at
FROM emotion_logs WHERE trade_execution_id = ?
ORDER BY timestamp ASC`,
),
/** Delete an emotion log by id. */
deleteById: db.prepare(
`DELETE FROM emotion_logs WHERE id = ?`,
),
};
}
// ---------------------------------------------------------------------------
// Repository — public API
// ---------------------------------------------------------------------------
/**
* Add an emotion log entry for a trade execution.
*
* @param log - Omitting id and timestamp; they are generated automatically.
* priceAtEvent, emotion, and note are the user-provided fields.
* @returns The full EmotionLog with id and timestamp populated.
*/
export function addEmotionLog(
db: DatabaseSync,
log: Omit<EmotionLog, 'id' | 'timestamp' | 'createdAt'>,
): EmotionLog {
const s = stmts(db);
const id = `el_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const timestamp = Date.now();
const createdAt = new Date().toISOString();
s.insert.run(
id,
log.tradeExecutionId,
timestamp,
log.priceAtEvent ?? null,
log.emotion,
log.note ?? null,
createdAt,
);
return {
id,
tradeExecutionId: log.tradeExecutionId,
timestamp,
priceAtEvent: log.priceAtEvent,
emotion: log.emotion,
note: log.note,
createdAt,
};
}
/**
* Get all emotion logs for a given trade execution, ordered by timestamp.
*/
export function getEmotionLogsByTrade(
db: DatabaseSync,
tradeExecutionId: string,
): EmotionLog[] {
const s = stmts(db);
const rows = s.selectByTrade.all(tradeExecutionId) as unknown as Array<{
id: string;
trade_execution_id: string;
timestamp: number;
price_at_event: number | null;
emotion: string;
note: string | null;
created_at: string;
}>;
return rows.map((row) => ({
id: row.id,
tradeExecutionId: row.trade_execution_id,
timestamp: row.timestamp,
priceAtEvent: row.price_at_event ?? undefined,
emotion: row.emotion,
note: row.note ?? undefined,
createdAt: row.created_at,
}));
}
/**
* Delete an emotion log by id. No-op if not found.
*/
export function deleteEmotionLog(db: DatabaseSync, id: string): void {
const s = stmts(db);
s.deleteById.run(id);
}
@@ -0,0 +1,175 @@
// Investor Flow - Portfolio option legs (user-owned book, not chain cache).
// ADR-0007: record / remove language only - never trade verbs.
import type { DatabaseSync } from 'node:sqlite';
export type OptionRight = 'call' | 'put';
export type OptionSide = 'long' | 'short';
export type OptionRole =
| 'long_call'
| 'long_put'
| 'covered_call'
| 'cash_secured_put'
| 'other';
export interface PortfolioOptionLeg {
id: string;
underlying: string;
right: OptionRight;
side: OptionSide;
strike: number;
expiry: string;
contracts: number;
premium: number;
multiplier: number;
role: OptionRole;
acquired_at: string;
note: string | null;
}
interface OptionLegRow {
id: string;
owner_id: string;
underlying: string;
right: string;
side: string;
strike: number;
expiry: string;
contracts: number;
premium: number;
multiplier: number;
role: string;
status: string;
acquired_at: string;
note: string | null;
}
export interface AddOptionLegInput {
underlying: string;
right: OptionRight;
side: OptionSide;
strike: number;
expiry: string;
contracts: number;
premium: number;
multiplier?: number;
role?: OptionRole;
note?: string | null;
}
export function defaultRole(right: OptionRight, side: OptionSide): OptionRole {
if (side === 'long' && right === 'call') return 'long_call';
if (side === 'long' && right === 'put') return 'long_put';
if (side === 'short' && right === 'call') return 'covered_call';
if (side === 'short' && right === 'put') return 'cash_secured_put';
return 'other';
}
function stmts(db: DatabaseSync) {
return {
insert: db.prepare(
`INSERT INTO portfolio_option_legs
(id, owner_id, underlying, right, side, strike, expiry, contracts, premium, multiplier, role, status, acquired_at, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
),
selectOpen: db.prepare(
`SELECT id, owner_id, underlying, right, side, strike, expiry, contracts, premium, multiplier, role, status, acquired_at, note
FROM portfolio_option_legs
WHERE owner_id = ? AND status = 'open'
ORDER BY acquired_at DESC`,
),
selectById: db.prepare(
`SELECT id, owner_id, underlying, right, side, strike, expiry, contracts, premium, multiplier, role, status, acquired_at, note
FROM portfolio_option_legs
WHERE id = ? AND owner_id = ?`,
),
closeById: db.prepare(
`UPDATE portfolio_option_legs SET status = 'closed'
WHERE id = ? AND owner_id = ? AND status = 'open'`,
),
};
}
function rowToLeg(row: OptionLegRow): PortfolioOptionLeg {
return {
id: row.id,
underlying: row.underlying,
right: row.right as OptionRight,
side: row.side as OptionSide,
strike: row.strike,
expiry: row.expiry,
contracts: row.contracts,
premium: row.premium,
multiplier: row.multiplier,
role: row.role as OptionRole,
acquired_at: row.acquired_at,
note: row.note,
};
}
function generateId(): string {
return `ol_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/** Normalize expiry to YYYY-MM-DD when possible. */
export function normalizeExpiry(expiry: string): string {
const t = expiry.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t;
const d = new Date(t);
if (!Number.isNaN(d.getTime())) {
return d.toISOString().slice(0, 10);
}
return t;
}
/**
* Record a new open option leg. Always inserts a new row (multiple strikes/expiries OK).
* @returns the created leg id
*/
export function addOptionLeg(
db: DatabaseSync,
userId: string,
input: AddOptionLegInput,
): string {
const underlying = input.underlying.trim().toUpperCase();
if (!underlying) throw new Error('portfolioOptionRepository: underlying required');
if (input.strike <= 0) throw new Error('portfolioOptionRepository: strike must be > 0');
if (input.contracts <= 0) throw new Error('portfolioOptionRepository: contracts must be > 0');
if (input.premium < 0) throw new Error('portfolioOptionRepository: premium must be >= 0');
const multiplier = input.multiplier ?? 100;
if (multiplier <= 0) throw new Error('portfolioOptionRepository: multiplier must be > 0');
const role = input.role ?? defaultRole(input.right, input.side);
const expiry = normalizeExpiry(input.expiry);
if (!expiry) throw new Error('portfolioOptionRepository: expiry required');
const id = generateId();
const now = new Date().toISOString();
stmts(db).insert.run(
id,
userId,
underlying,
input.right,
input.side,
input.strike,
expiry,
input.contracts,
input.premium,
multiplier,
role,
now,
input.note ?? null,
);
return id;
}
export function listOptionLegs(db: DatabaseSync, userId: string): PortfolioOptionLeg[] {
const rows = stmts(db).selectOpen.all(userId) as unknown as OptionLegRow[];
return rows.map(rowToLeg);
}
/** Soft-close an open leg. Returns true if a row was closed. */
export function removeOptionLeg(db: DatabaseSync, userId: string, legId: string): boolean {
const result = stmts(db).closeById.run(legId, userId);
return result.changes > 0;
}
+43 -24
View File
@@ -14,6 +14,8 @@
// acquired_at TEXT NOT NULL,
// status TEXT NOT NULL DEFAULT 'open' -- open | closed
// );
// CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_owner_symbol
// ON portfolio_holdings(owner_id, symbol);
import type { DatabaseSync } from 'node:sqlite';
@@ -112,11 +114,18 @@ export function addHolding(
shares: number,
avgCost: number,
): boolean {
if (shares <= 0) {
throw new Error('portfolioRepository: shares must be > 0');
}
if (avgCost < 0) {
throw new Error('portfolioRepository: avgCost must be >= 0');
}
const s = stmts(db);
const upper = symbol.toUpperCase();
// Check if a holding already exists for this user + symbol.
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
if (existing.length === 0) {
// New holding — insert with a generated id and current timestamp.
@@ -126,17 +135,13 @@ export function addHolding(
return true;
}
// Existing holding — update in place (ON CONFLICT branch handles qty/avg_cost merge).
const row = existing[0];
s.updateHolding.run(shares, avgCost, userId, upper);
// Existing holding — route through insertHolding so ON CONFLICT does VWAP accumulation.
const id = generateId();
const now = new Date().toISOString();
s.insertHolding.run(id, userId, upper, shares, avgCost, now);
// If qty didn't change and avg_cost didn't change, treat as no-op.
const updated = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
const updatedRow = updated[0];
return (
updatedRow.qty !== row.qty || updatedRow.avg_cost !== row.avg_cost
);
// We already knew the row existed (existing.length > 0), so this is an accumulation.
return false;
}
/**
@@ -154,7 +159,7 @@ export function updateHolding(
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
if (existing.length === 0) return false;
const row = existing[0];
@@ -163,34 +168,48 @@ export function updateHolding(
const qtyParam = updates.shares !== undefined ? updates.shares : null;
const avgCostParam = updates.avgCost !== undefined ? updates.avgCost : null;
// Capture before-state for change detection.
const beforeQty = row.qty;
const beforeAvgCost = row.avg_cost;
// Input validation.
if (qtyParam !== null && qtyParam <= 0) {
throw new Error('portfolioRepository: shares must be > 0');
}
if (avgCostParam !== null && avgCostParam < 0) {
throw new Error('portfolioRepository: avgCost must be >= 0');
}
s.updateHolding.run(qtyParam, avgCostParam, userId, upper);
const result = s.updateHolding.run(qtyParam, avgCostParam, userId, upper);
return beforeQty !== row.qty || beforeAvgCost !== row.avg_cost;
// Use changes() to report whether the DB row was actually modified.
return result.changes > 0;
}
/**
* Remove a holding from the user's portfolio. The row is hard-deleted (not
* soft-closed) so it no longer appears in listHoldings.
* Remove a holding from the user's portfolio. By default performs a SOFT-DELETE
* (marks status='closed') so the row remains in the database for audit/restore.
*
* @returns true if a row was deleted, false if no matching holding exists.
* When `permanent` is true, performs a hard DELETE instead.
*
* @returns true if a row was closed/deleted, false if no matching holding exists.
*/
export function removeHolding(
db: DatabaseSync,
userId: string,
symbol: string,
options?: { permanent?: boolean },
): boolean {
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
if (existing.length === 0) return false;
s.deleteByOwnerAndSymbol.run(userId, upper);
return true;
if (options?.permanent === true) {
const result = s.deleteByOwnerAndSymbol.run(userId, upper);
return result.changes > 0;
}
// Default: soft-delete (close).
const result = s.closeHolding.run(userId, upper);
return result.changes > 0;
}
/**
@@ -202,7 +221,7 @@ export function listHoldings(
userId: string,
): PortfolioHolding[] {
const s = stmts(db);
const rows = s.selectOpenByOwner.all(userId) as PortfolioRow[];
const rows = s.selectOpenByOwner.all(userId) as unknown as PortfolioRow[];
return rows.map((row) => ({
symbol: row.symbol,
+99 -2
View File
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS users (
drawdown_tolerance REAL, -- onboarding max-drawdown % (beginner -20, etc.)
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2)
created_at TEXT NOT NULL
status TEXT NOT NULL DEFAULT 'active', -- active|pending_approval|rejected (user provisioning)
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
@@ -112,7 +113,27 @@ CREATE TABLE IF NOT EXISTS institution_filings (
value_usd REAL,
reported_quarter TEXT NOT NULL, -- calendar quarter positions are "as of"
filed_at TEXT NOT NULL,
fetched_at TEXT NOT NULL
fetched_at TEXT NOT NULL,
put_call TEXT -- Put|Call|null for common stock (13F only)
);
-- Idempotency for institution_filings: one row per (filer, symbol, quarter, form).
-- Created in runMigrations (after legacy de-dupe) so it never fails on existing DBs.
-- CREATE UNIQUE INDEX IF NOT EXISTS uq_inst_filings
-- ON institution_filings(filer_cik, symbol, reported_quarter, form);
-- Data-quality lint reports (periodic gap/staleness detection).
CREATE TABLE IF NOT EXISTS data_quality (
symbol TEXT NOT NULL,
kind TEXT NOT NULL, -- 'institution_filings' | 'insider_transactions'
last_checked_at TEXT,
stored_count INTEGER,
discovered_count INTEGER,
missing_count INTEGER,
stale INTEGER NOT NULL DEFAULT 0, -- 1 if latest data is older than threshold
status TEXT NOT NULL, -- ok | gaps_found | stale | error
detail TEXT, -- JSON blob (notes)
PRIMARY KEY (symbol, kind)
);
CREATE TABLE IF NOT EXISTS insider_transactions (
@@ -129,6 +150,7 @@ CREATE TABLE IF NOT EXISTS insider_transactions (
classification TEXT NOT NULL, -- informed_buy|informed_sell|routine
filed_at TEXT NOT NULL,
fetched_at TEXT NOT NULL,
accession TEXT, -- SEC accession number (enables lint diff by filing)
PRIMARY KEY (form4_id, symbol, insider_name, tx_date)
);
@@ -254,6 +276,27 @@ CREATE TABLE IF NOT EXISTS portfolio_holdings (
CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_owner_symbol
ON portfolio_holdings(owner_id, symbol);
-- User-owned option legs (long calls/puts, covered calls, CSPs). Not chain cache.
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, -- call | put
side TEXT NOT NULL, -- long | short
strike REAL NOT NULL,
expiry TEXT NOT NULL, -- YYYY-MM-DD
contracts REAL NOT NULL,
premium REAL NOT NULL, -- avg premium per share
multiplier INTEGER NOT NULL DEFAULT 100,
role TEXT NOT NULL, -- long_call | long_put | covered_call | cash_secured_put | other
status TEXT NOT NULL DEFAULT 'open', -- open | closed
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 TABLE IF NOT EXISTS trades (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -387,6 +430,23 @@ CREATE TABLE IF NOT EXISTS rotation_signals (
verdict TEXT -- real|false_alarm (gamma two-stage)
);
-- Daily snapshot of sector RS ranks (Market Outlook history).
CREATE TABLE IF NOT EXISTS rotation_rank_snapshots (
as_of_date TEXT NOT NULL, -- YYYY-MM-DD
symbol TEXT NOT NULL,
name TEXT,
grp TEXT, -- UI group label
rank_1m INTEGER,
rs_1m REAL,
rank_1w INTEGER,
rs_1w REAL,
leadership TEXT, -- leading|lagging|inline|unknown
early_watch INTEGER NOT NULL DEFAULT 0,
strength TEXT, -- none|weak|moderate|strong (market-wide)
PRIMARY KEY (as_of_date, symbol)
);
CREATE INDEX IF NOT EXISTS idx_rotation_rank_date ON rotation_rank_snapshots(as_of_date DESC);
-- ===== Section 1.5 — LLM provenance (ADR-0006). Operator-managed, NOT user-editable. =====
CREATE TABLE IF NOT EXISTS llm_providers (
id TEXT PRIMARY KEY, -- 'local_ollama' | 'local_vllm' | 'zen_glm_5_2' | ...
@@ -441,6 +501,32 @@ CREATE TABLE IF NOT EXISTS reddit_posts (
CREATE INDEX IF NOT EXISTS idx_x_cookie_cashtag ON x_cookie_posts(cashtag, posted_at DESC);
CREATE INDEX IF NOT EXISTS idx_reddit_subreddit ON reddit_posts(subreddit, posted_at DESC);
-- ===== X cookie credentials (operator-managed; never exposed to client) =====
-- Singleton row (id = 'singleton') stores AES-256-GCM encrypted ct0 + auth_token.
-- Health status is tracked here so the adapter can report degraded/failed state.
CREATE TABLE IF NOT EXISTS x_credentials (
id TEXT PRIMARY KEY DEFAULT 'singleton',
ct0_enc TEXT, -- AES-256-GCM ciphertext (or NULL if unset)
auth_token_enc TEXT, -- AES-256-GCM ciphertext (or NULL if unset)
fred_api_key_enc TEXT, -- AES-256-GCM ciphertext (or NULL if unset)
healthy INTEGER NOT NULL DEFAULT 0, -- 1 = healthy, 0 = degraded/failed
last_error TEXT,
updated_at TEXT -- ISO8601; when credentials were last stored or health last reported
);
-- Per-symbol tracked X accounts (admin-managed). The overview page merges cashtag
-- feed with timelines of these handles for a given symbol.
CREATE TABLE IF NOT EXISTS x_accounts (
id TEXT PRIMARY KEY, -- uuid
symbol TEXT NOT NULL,
handle TEXT NOT NULL, -- bare handle (no @)
label TEXT, -- optional human-readable name
created_at TEXT NOT NULL,
UNIQUE(symbol, handle)
);
CREATE INDEX IF NOT EXISTS idx_x_accounts_symbol ON x_accounts(symbol);
-- ===== Indexes (existing) =====
CREATE INDEX IF NOT EXISTS idx_pc_symbol_tf_ts ON price_candles(symbol, timeframe, ts);
CREATE INDEX IF NOT EXISTS idx_quote_obs ON quotes(observed_at);
@@ -550,3 +636,14 @@ CREATE TABLE IF NOT EXISTS theses (
CREATE INDEX IF NOT EXISTS idx_theses_user ON theses(user_id);
CREATE INDEX IF NOT EXISTS idx_theses_symbol ON theses(symbol);
-- Rotation signal state (latest check result for alert dedup)
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, -- the strongest sub-group (e.g. "Financials")
checked_at TEXT
);