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