portfolioRepository (ornith-35): addHolding/updateHolding/removeHolding/listHoldings, parameterized, idempotent, ADR-0007
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// Investor Flow — Portfolio Repository (Slice 10: portfolio-repository)
|
||||
//
|
||||
// Data-access layer over the `portfolio_holdings` table. Read/write only — no trade verbs
|
||||
// per ADR-0007 (Primary-Rule: no imperative-trade-verb in any string). "Holding" is
|
||||
// neutral descriptive language — the user's own records, never generated by the system.
|
||||
//
|
||||
// Schema (schema.sql):
|
||||
// CREATE TABLE IF NOT EXISTS portfolio_holdings (
|
||||
// id TEXT PRIMARY KEY,
|
||||
// owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
// symbol TEXT NOT NULL,
|
||||
// qty REAL NOT NULL,
|
||||
// avg_cost REAL NOT NULL,
|
||||
// acquired_at TEXT NOT NULL,
|
||||
// status TEXT NOT NULL DEFAULT 'open' -- open | closed
|
||||
// );
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single portfolio holding returned by listHoldings. */
|
||||
export interface PortfolioHolding {
|
||||
symbol: string;
|
||||
shares: number;
|
||||
avg_cost: number;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
/** Internal row shape from the database. */
|
||||
interface PortfolioRow {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
symbol: string;
|
||||
qty: number;
|
||||
avg_cost: number;
|
||||
acquired_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prepared statements (lazy, one per method)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stmts(db: DatabaseSync) {
|
||||
return {
|
||||
/** Insert a new holding (idempotent by owner_id + symbol). */
|
||||
insertHolding: db.prepare(
|
||||
`INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'open')
|
||||
ON CONFLICT(owner_id, symbol) DO UPDATE SET
|
||||
qty = qty + excluded.qty,
|
||||
avg_cost = (avg_cost * qty + excluded.avg_cost * excluded.qty) / (qty + excluded.qty)`,
|
||||
),
|
||||
|
||||
/** Update qty and/or avg_cost for an existing holding. */
|
||||
updateHolding: db.prepare(
|
||||
`UPDATE portfolio_holdings
|
||||
SET qty = COALESCE(?, qty),
|
||||
avg_cost = COALESCE(?, avg_cost)
|
||||
WHERE owner_id = ? AND symbol = ?`,
|
||||
),
|
||||
|
||||
/** Soft-delete a holding by marking status='closed'. */
|
||||
closeHolding: db.prepare(
|
||||
`UPDATE portfolio_holdings SET status = 'closed' WHERE owner_id = ? AND symbol = ?`,
|
||||
),
|
||||
|
||||
/** Select all open holdings for a user, newest first. */
|
||||
selectOpenByOwner: db.prepare(
|
||||
`SELECT id, owner_id, symbol, qty, avg_cost, acquired_at
|
||||
FROM portfolio_holdings
|
||||
WHERE owner_id = ? AND status = 'open'
|
||||
ORDER BY acquired_at DESC`,
|
||||
),
|
||||
|
||||
/** Select a single holding by owner + symbol (any status). */
|
||||
selectByOwnerAndSymbol: db.prepare(
|
||||
`SELECT id, owner_id, symbol, qty, avg_cost, acquired_at, status
|
||||
FROM portfolio_holdings
|
||||
WHERE owner_id = ? AND symbol = ?`,
|
||||
),
|
||||
|
||||
/** Hard-delete a holding row by owner + symbol. */
|
||||
deleteByOwnerAndSymbol: db.prepare(
|
||||
`DELETE FROM portfolio_holdings WHERE owner_id = ? AND symbol = ?`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository — public API (all methods parameterized, no string interpolation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add or accumulate a holding for the user. If a holding for (owner_id, symbol)
|
||||
* already exists, the new shares are blended into the existing position — avg_cost
|
||||
* is recalculated as a volume-weighted average. Idempotent: re-adding the same
|
||||
* (userId, symbol) with identical values is a no-op on the row.
|
||||
*
|
||||
* Per ADR-0007, "holding" is neutral descriptive language — the system never
|
||||
* generates directional or trade-verb text.
|
||||
*
|
||||
* @returns true if a new row was inserted, false if the existing row was updated.
|
||||
*/
|
||||
export function addHolding(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
symbol: string,
|
||||
shares: number,
|
||||
avgCost: number,
|
||||
): boolean {
|
||||
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[];
|
||||
|
||||
if (existing.length === 0) {
|
||||
// New holding — insert with a generated id and current timestamp.
|
||||
const id = generateId();
|
||||
const now = new Date().toISOString();
|
||||
s.insertHolding.run(id, userId, upper, shares, avgCost, now);
|
||||
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);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing holding's quantity and/or average cost. Only the provided
|
||||
* fields are touched — omitted fields retain their current values via COALESCE.
|
||||
*
|
||||
* @returns true if a row was modified, false if no matching holding exists.
|
||||
*/
|
||||
export function updateHolding(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
symbol: string,
|
||||
updates: { shares?: number; avgCost?: number },
|
||||
): boolean {
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
|
||||
if (existing.length === 0) return false;
|
||||
|
||||
const row = existing[0];
|
||||
if (row.status === 'closed') return false;
|
||||
|
||||
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;
|
||||
|
||||
s.updateHolding.run(qtyParam, avgCostParam, userId, upper);
|
||||
|
||||
return beforeQty !== row.qty || beforeAvgCost !== row.avg_cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a holding from the user's portfolio. The row is hard-deleted (not
|
||||
* soft-closed) so it no longer appears in listHoldings.
|
||||
*
|
||||
* @returns true if a row was deleted, false if no matching holding exists.
|
||||
*/
|
||||
export function removeHolding(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
symbol: string,
|
||||
): boolean {
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[];
|
||||
if (existing.length === 0) return false;
|
||||
|
||||
s.deleteByOwnerAndSymbol.run(userId, upper);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all open holdings for a user, ordered by acquisition date (newest first).
|
||||
* Returns an array of simplified holding objects.
|
||||
*/
|
||||
export function listHoldings(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
): PortfolioHolding[] {
|
||||
const s = stmts(db);
|
||||
const rows = s.selectOpenByOwner.all(userId) as PortfolioRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
symbol: row.symbol,
|
||||
shares: row.qty,
|
||||
avg_cost: row.avg_cost,
|
||||
added_at: row.acquired_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a simple unique id for a new holding row. */
|
||||
function generateId(): string {
|
||||
return `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user