223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
// 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)}`;
|
||
|
|
}
|