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
+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,