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:
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user