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
+303
View File
@@ -0,0 +1,303 @@
// Investor Flow — RiskEngine (Slice 18): pure risk-posture aggregator.
//
// ADR-0007: outputs MATH and PLAIN-ENGLISH explanations, never instructions.
// "the math implies ~N shares given your stop and risk%" — never "buy N shares".
// Pure/cache-deterministic: no I/O, no network. Fully testable with any data.
//
// CONTEXT.md: Risk Management (ADR-0007 territory). Primary Rule — education,
// not investment advice. Every recommended action is a "trade-off to think through"
// frame + the ADR-0007 footer. cut_to_cash → consider_reducing_position;
// trim_cluster → consider_rebalancing_cluster.
import type { Complexity, Regime } from '../sizing/SizingEngine.ts';
// ─── Input types (mirrors SizingEngine shapes) ────────────────────────────────
/** A current holding for risk aggregation. */
export interface RiskHolding {
symbol: string;
shares: number;
avgCost: number;
cluster: string; // macro/factor driver id (e.g. "btc_miners")
/** Reward target price (use-case dependent). */
rewardTarget?: number;
/** Stop-loss price. */
stopPrice?: number;
}
/** Account state for risk posture. */
export interface RiskAccount {
equity: number;
drawdownTolerancePct: number; // e.g. -20 means 20% max drawdown tolerance
complexity: Complexity;
}
/** Portfolio context for risk aggregation. */
export interface RiskPortfolio {
symbol: string;
shares: number;
avgCost: number;
cluster: string;
rewardTarget?: number; // optional; used for asymmetry calculation
stopPrice?: number; // optional; used for asymmetry calculation
}
/** Inputs to RiskEngine. */
export interface RiskEngineInput {
portfolio: RiskPortfolio[];
account: RiskAccount;
/** Peak equity for drawdown math. Defaults to account.equity (0 drawdown). */
peakEquity?: number;
/** Current market regime. */
regime?: Regime;
/** Optional sizing context for cluster caps. */
sizingContext?: {
clusterCaps: Record<string, number> | null;
};
}
// ─── Output types ─────────────────────────────────────────────────────────────
/** ADR-0007 reworded action identifiers (never trade verbs). */
export type RiskActionId =
| 'consider_reducing_position'
| 'consider_rebalancing_cluster';
/** A single recommended action (consideration, not instruction). */
export interface Recommendation {
/** Stable identifier for the consideration. */
id: RiskActionId;
/** The math framing: what's happening numerically. */
tradeOff: string;
/** Human-readable explanation of the trade-off to think through. */
explanation: string;
/** Severity: 'warning' | 'info'. */
severity: 'warning' | 'info';
}
/** Aggregated risk posture. */
export interface RiskPosture {
/** Overall reward:risk ratio across the portfolio (aggregate asymmetry). */
asymmetry: number;
/** Per-cluster exposure map ($ value per cluster). */
clusterExposure: Record<string, number>;
/** Per-cluster cap (from sizing context) for breach detection. */
clusterCaps?: Record<string, number>;
/** Current drawdown vs the user's tolerance (absolute $ and pct). */
drawdownVsTolerance: {
currentDrawdownPct: number;
tolerancePct: number;
/** -1 = below tolerance, 0 = at tolerance, 1 = above (halted). */
status: -1 | 0 | 1;
};
/** Whether the gentle-halt circuit breaker is triggered. */
halted: boolean;
/** Recommended considerations (ADR-0007 reworded, never trade verbs). */
recommendedActions: Recommendation[];
}
// ─── ADR-0007 footer (shared across all outputs) ──────────────────────────────
/** The ADR-0007 footer string. */
export const ADR_0007_FOOTER =
'Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.';
/** Duration of a gentle-halt cooldown in milliseconds (24h). */
export const HALT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
// ─── Pure aggregation logic ────────────────────────────────────────────────────
/** Compute the dollar exposure of a single holding. Pure. */
export function exposure(shares: number, avgCost: number): number {
return shares * avgCost;
}
/** Aggregate cluster exposure from a portfolio. Pure. */
export function aggregateClusterExposure(
portfolio: RiskPortfolio[],
): Record<string, number> {
const map: Record<string, number> = {};
for (const p of portfolio) {
const e = exposure(p.shares, p.avgCost);
map[p.cluster] = (map[p.cluster] ?? 0) + e;
}
return map;
}
/** Compute aggregate asymmetry (reward:risk) across all positions. Pure. */
export function aggregateAsymmetry(portfolio: RiskPortfolio[]): number {
let totalReward = 0;
let totalRisk = 0;
for (const p of portfolio) {
const costBasis = p.shares * p.avgCost;
const rewardTarget = p.rewardTarget ?? 0;
const stopPrice = p.stopPrice ?? p.avgCost;
// Reward = upside from current cost to target (if target > cost).
if (rewardTarget > p.avgCost) {
totalReward += p.shares * (rewardTarget - p.avgCost);
}
// Risk = downside from cost to stop (if stop < cost).
if (stopPrice < p.avgCost) {
totalRisk += p.shares * (p.avgCost - stopPrice);
}
}
if (totalRisk === 0) return Infinity; // no risk defined → infinite asymmetry
return totalReward / totalRisk;
}
/** Compute drawdown status vs tolerance. Pure. */
export function computeDrawdownStatus(
equity: number,
peakEquity: number,
tolerancePct: number,
): { currentDrawdownPct: number; status: -1 | 0 | 1 } {
if (peakEquity <= 0) return { currentDrawdownPct: 0, status: 1 };
const drawdownPct = ((peakEquity - equity) / peakEquity) * 100;
// tolerancePct is given as a positive number (e.g. 20 means 20% max drawdown).
// status: -1 = healthy (below tolerance), 0 = at boundary, 1 = breached (halted).
const status: -1 | 0 | 1 = drawdownPct > tolerancePct ? 1 : drawdownPct < tolerancePct ? -1 : 0;
return { currentDrawdownPct: drawdownPct, status };
}
/** Generate recommendations based on portfolio analysis. Pure. */
export function generateRecommendations(
portfolio: RiskPortfolio[],
account: RiskAccount,
clusterExposure: Record<string, number>,
clusterCaps: Record<string, number> | null,
asymmetry: number,
drawdownStatus: { currentDrawdownPct: number; status: -1 | 0 | 1 },
halted: boolean,
regime?: Regime,
): Recommendation[] {
const recs: Recommendation[] = [];
// Asymmetry < 1 → consider reducing positions with poor reward:risk.
if (asymmetry < 1 && isFinite(asymmetry)) {
const weakPositions = portfolio.filter((p) => {
const costBasis = p.shares * p.avgCost;
const rewardTarget = p.rewardTarget ?? 0;
const stopPrice = p.stopPrice ?? p.avgCost;
const upside = rewardTarget > p.avgCost ? rewardTarget - p.avgCost : 0;
const downside = stopPrice < p.avgCost ? p.avgCost - stopPrice : 0;
return downside > 0 && (upside / Math.max(downside, 0.01)) < 1;
});
if (weakPositions.length > 0) {
const symbols = weakPositions.map((p) => p.symbol).join(', ');
recs.push({
id: 'consider_reducing_position',
tradeOff: `Aggregate asymmetry is ${asymmetry.toFixed(2)} (< 1.0), meaning risk outweighs reward across the portfolio.`,
explanation: `The math implies positions in ${symbols} have more downside risk than upside reward. A trade-off to think through: consider reducing these positions to improve overall portfolio asymmetry.\n\n${ADR_0007_FOOTER}`,
severity: 'warning',
});
}
}
// Cluster breach detection.
if (clusterCaps) {
for (const [cluster, exposureValue] of Object.entries(clusterExposure)) {
const cap = clusterCaps[cluster];
if (cap !== undefined && exposureValue > cap) {
const breachPct = ((exposureValue - cap) / cap) * 100;
if (account.complexity === 'beginner') {
recs.push({
id: 'consider_rebalancing_cluster',
tradeOff: `Cluster '${cluster}' exposure ($${exposureValue.toFixed(0)}) exceeds the beginner hard cap ($${cap.toFixed(0)}) by ${breachPct.toFixed(1)}%.`,
explanation: `A trade-off to think through: consider rebalancing the cluster by reducing exposure to ${cluster}. Beginner accounts have hard caps on correlated clusters for risk management.\n\n${ADR_0007_FOOTER}`,
severity: 'warning',
});
} else {
recs.push({
id: 'consider_rebalancing_cluster',
tradeOff: `Cluster '${cluster}' exposure ($${exposureValue.toFixed(0)}) is approaching/exceeding the cap ($${cap.toFixed(0)}).`,
explanation: `A trade-off to think through: consider rebalancing the cluster. You're advanced enough to manage correlated exposure, but monitor the cap.\n\n${ADR_0007_FOOTER}`,
severity: 'info',
});
}
}
}
}
// Drawdown halt → consider reducing position.
if (halted || drawdownStatus.status === 1) {
recs.push({
id: 'consider_reducing_position',
tradeOff: `Current drawdown (${drawdownStatus.currentDrawdownPct.toFixed(1)}%) has breached the ${account.drawdownTolerancePct}% tolerance.`,
explanation: `A trade-off to think through: consider reducing positions. The gentle-halt circuit breaker is active — no new entries for 24 hours. Existing positions continue unaffected.\n\n${ADR_0007_FOOTER}`,
severity: 'warning',
});
}
// Regime-based consideration.
if (regime === 'trending-down') {
recs.push({
id: 'consider_reducing_position',
tradeOff: `Market regime is trending-down. The math suggests reduced exposure in this environment.`,
explanation: `A trade-off to think through: consider reducing position sizes or increasing cash reserves during a trending-down regime.\n\n${ADR_0007_FOOTER}`,
severity: 'info',
});
}
return recs;
}
/**
* RiskEngine — pure aggregator.
*
* Given a portfolio + account state, returns a RiskPosture with:
* - aggregate asymmetry (reward:risk)
* - per-cluster exposure map
* - drawdown vs tolerance status
* - gentle-halt flag
* - ADR-0007 reworded recommendations (never trade verbs)
*
* Pure/cache-deterministic: no I/O, no network. Fully testable with any data.
*/
export function assessRisk(input: RiskEngineInput): RiskPosture {
const { portfolio, account, regime, sizingContext } = input;
const peakEquity = input.peakEquity ?? account.equity;
// Aggregate cluster exposure.
const clusterExposure = aggregateClusterExposure(portfolio);
// Compute aggregate asymmetry.
const asymmetry = aggregateAsymmetry(portfolio);
// Drawdown status: peakEquity defaults to current equity (0 drawdown) when omitted.
const drawdownStatus = computeDrawdownStatus(
account.equity,
peakEquity,
account.drawdownTolerancePct,
);
// Check if halted (drawdown breach → halt).
const halted = drawdownStatus.status === 1;
// Generate recommendations (ADR-0007 reworded).
const clusterCaps = sizingContext?.clusterCaps ?? null;
const recommendedActions = generateRecommendations(
portfolio,
account,
clusterExposure,
clusterCaps,
asymmetry,
drawdownStatus,
halted,
regime,
);
return {
asymmetry,
clusterExposure,
clusterCaps: clusterCaps ?? undefined,
drawdownVsTolerance: {
currentDrawdownPct: drawdownStatus.currentDrawdownPct,
tolerancePct: account.drawdownTolerancePct,
status: drawdownStatus.status,
},
halted,
recommendedActions,
};
}
@@ -0,0 +1,240 @@
// Tests — RiskEngine (Slice 18). Pure, no network.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
assessRisk,
aggregateClusterExposure,
aggregateAsymmetry,
computeDrawdownStatus,
generateRecommendations,
exposure,
ADR_0007_FOOTER,
HALT_COOLDOWN_MS,
type RiskEngineInput,
type RiskPortfolio,
type RiskAccount,
type Recommendation,
} from '../RiskEngine.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
// ─── Helpers ──────────────────────────────────────────────────────────────────
function portfolio(overrides: Partial<RiskPortfolio>[]): RiskPortfolio[] {
return overrides.map((o) => ({
symbol: 'TSLA',
shares: 10,
avgCost: 250,
cluster: 'tech_growth',
...o,
}));
}
function account(overrides: Partial<RiskAccount> = {}): RiskAccount {
return {
equity: 100_000,
drawdownTolerancePct: 20,
complexity: 'intermediate',
...overrides,
};
}
function baseInput(overrides: { portfolio?: Partial<RiskPortfolio>[]; account?: Partial<RiskAccount>; regime?: string } = {}): RiskEngineInput {
return {
portfolio: portfolio(overrides.portfolio ?? []),
account: account(overrides.account),
regime: (overrides.regime as 'trending-up' | 'trending-down' | 'range-bound') ?? 'trending-up',
sizingContext: { clusterCaps: { tech_growth: 50_000 } },
};
}
// ─── Pure utility tests ──────────────────────────────────────────────────────
test('exposure() computes shares × avgCost', () => {
assert.equal(exposure(10, 250), 2500);
assert.equal(exposure(0, 250), 0);
});
test('aggregateClusterExposure() sums per cluster', () => {
const p = [
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'tech' },
{ symbol: 'B', shares: 20, avgCost: 50, cluster: 'tech' },
{ symbol: 'C', shares: 5, avgCost: 200, cluster: 'energy' },
];
const result = aggregateClusterExposure(p);
assert.deepEqual(result, { tech: 2000, energy: 1000 });
});
test('aggregateAsymmetry() computes reward:risk ratio', () => {
// Position with $500 upside, $200 downside → asymmetry = 2.5
const p = [
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x', rewardTarget: 150, stopPrice: 90 },
];
const result = aggregateAsymmetry(p);
// reward = 10 * (150-100) = 500; risk = 10 * (100-90) = 100; ratio = 5
assert.equal(result, 5);
});
test('aggregateAsymmetry() returns Infinity when no downside', () => {
const p = [{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x' }];
assert.equal(aggregateAsymmetry(p), Infinity);
});
test('computeDrawdownStatus() returns correct status', () => {
// No drawdown → status -1 (healthy)
assert.deepEqual(computeDrawdownStatus(100_000, 100_000, 20), {
currentDrawdownPct: 0,
status: -1,
});
// Breached tolerance → status 1 (halted)
assert.deepEqual(computeDrawdownStatus(70_000, 100_000, 20), {
currentDrawdownPct: 30,
status: 1,
});
// At tolerance boundary → status 0 (warning)
assert.deepEqual(computeDrawdownStatus(80_000, 100_000, 20), {
currentDrawdownPct: 20,
status: 0,
});
});
// ─── Recommendation generation tests ──────────────────────────────────────────
test('generateRecommendations() emits consider_reducing_position when asymmetry < 1', () => {
// Portfolio with poor reward:risk (downside > upside).
const p = [
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x', rewardTarget: 110, stopPrice: 80 },
];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, {}, null, 0.5, { currentDrawdownPct: 0, status: -1 }, false);
const reducing = recs.filter((r) => r.id === 'consider_reducing_position');
assert.ok(reducing.length > 0, 'Should emit consider_reducing_position when asymmetry < 1');
assert.ok(reducing[0].tradeOff.includes('asymmetry'), 'Trade-off should mention asymmetry');
});
test('generateRecommendations() emits consider_rebalancing_cluster on beginner hard cap breach', () => {
const p = [
{ symbol: 'A', shares: 100, avgCost: 600, cluster: 'tech' },
{ symbol: 'B', shares: 100, avgCost: 600, cluster: 'tech' },
];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'beginner' as const };
const recs = generateRecommendations(p, account, { tech: 120_000 }, { tech: 50_000 }, 2, { currentDrawdownPct: 0, status: -1 }, false);
const rebalancing = recs.filter((r) => r.id === 'consider_rebalancing_cluster');
assert.ok(rebalancing.length > 0, 'Should emit consider_rebalancing_cluster for beginner hard cap breach');
assert.equal(rebalancing[0].severity, 'warning', 'Beginner breach should be warning severity');
});
test('generateRecommendations() emits consider_rebalancing_cluster as info for intermediate', () => {
const p = [
{ symbol: 'A', shares: 100, avgCost: 600, cluster: 'tech' },
{ symbol: 'B', shares: 100, avgCost: 600, cluster: 'tech' },
];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, { tech: 120_000 }, { tech: 50_000 }, 2, { currentDrawdownPct: 0, status: -1 }, false);
const rebalancing = recs.filter((r) => r.id === 'consider_rebalancing_cluster');
assert.ok(rebalancing.length > 0, 'Should emit consider_rebalancing_cluster for intermediate');
assert.equal(rebalancing[0].severity, 'info', 'Intermediate breach should be info severity');
});
test('generateRecommendations() includes ADR-0007 footer in all recommendations', () => {
const p = [
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x', rewardTarget: 110, stopPrice: 80 },
];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, {}, null, 0.5, { currentDrawdownPct: 0, status: -1 }, false);
for (const r of recs) {
assert.ok(r.tradeOff, 'Every recommendation must have a tradeOff');
assert.ok(r.explanation, 'Every recommendation must have an explanation');
assert.ok(r.severity === 'warning' || r.severity === 'info', 'Severity must be warning or info');
assert.ok(r.explanation.includes('Educational analysis'), `Recommendation ${r.id} must include ADR-0007 footer`);
}
});
test('generateRecommendations() does NOT emit trade verbs (buy/sell/cut/trim)', () => {
const p = [
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x', rewardTarget: 110, stopPrice: 80 },
];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, {}, null, 0.5, { currentDrawdownPct: 0, status: -1 }, false);
const allText = [...recs.map((r) => r.tradeOff), ...recs.map((r) => r.explanation)].join(' ').toLowerCase();
const forbidden = ['buy ', 'sell ', 'cut ', 'trim ', 'buy.', 'sell.', 'cut.', 'trim.'];
for (const word of forbidden) {
assert.ok(!allText.includes(word), `Should not contain forbidden trade verb: "${word}"`);
}
});
test('generateRecommendations() halts on drawdown breach', () => {
const p = [{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x' }];
const account = { equity: 70_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, {}, null, 2, { currentDrawdownPct: 30, status: 1 }, true);
const reducing = recs.filter((r) => r.id === 'consider_reducing_position');
assert.ok(reducing.length > 0, 'Should emit consider_reducing_position when halted');
assert.ok(reducing[0].explanation.includes('gentle-halt'), 'Should mention gentle-halt in explanation');
});
test('generateRecommendations() includes regime consideration for trending-down', () => {
const p = [{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x' }];
const account = { equity: 100_000, drawdownTolerancePct: 20, complexity: 'intermediate' as const };
const recs = generateRecommendations(p, account, {}, null, 2, { currentDrawdownPct: 0, status: -1 }, false, 'trending-down');
const regimeRec = recs.filter((r) => r.id === 'consider_reducing_position');
assert.ok(regimeRec.length > 0, 'Should emit consideration for trending-down regime');
});
// ─── Full RiskEngine.assessRisk() integration tests ───────────────────────────
test('assessRisk() returns complete RiskPosture', () => {
const input = baseInput();
const result = assessRisk(input);
assert.ok(typeof result.asymmetry === 'number', 'asymmetry must be a number');
assert.ok(typeof result.clusterExposure === 'object', 'clusterExposure must be an object');
assert.ok(typeof result.drawdownVsTolerance === 'object', 'drawdownVsTolerance must be an object');
assert.ok(typeof result.halted === 'boolean', 'halted must be a boolean');
assert.ok(Array.isArray(result.recommendedActions), 'recommendedActions must be an array');
});
test('assessRisk() clusters exposure is correct', () => {
const input = baseInput({ portfolio: [{}] });
const result = assessRisk(input);
// 10 shares × $250 = $2500 in tech_growth cluster
assert.equal(result.clusterExposure['tech_growth'], 2500);
});
test('assessRisk() halts on drawdown breach', () => {
// When equity is 70k and peak is 100k, drawdown = 30% > 20% tolerance.
const statusResult = computeDrawdownStatus(70_000, 100_000, 20);
assert.equal(statusResult.status, 1, 'Status should be 1 when drawdown > tolerance');
assert.ok(statusResult.currentDrawdownPct > 20, 'Drawdown should exceed tolerance');
const result = assessRisk({
...baseInput({ account: { equity: 70_000, drawdownTolerancePct: 20 } }),
peakEquity: 100_000,
});
assert.equal(result.halted, true, 'assessRisk must halt when peakEquity implies breach');
assert.ok(result.recommendedActions.some((a) => a.id === 'consider_reducing_position'));
});
test('assessRisk() does NOT halt when within tolerance', () => {
const input = baseInput();
const result = assessRisk(input);
assert.equal(result.halted, false, 'Should not be halted when within tolerance');
});
// ─── Constants validation ────────────────────────────────────────────────────
test('ADR_0007_FOOTER is defined', () => {
assert.ok(ADR_0007_FOOTER, 'ADR_0007_FOOTER must be defined');
assert.ok(ADR_0007_FOOTER.includes('Educational analysis'), 'Must contain educational disclaimer');
assert.ok(ADR_0007_FOOTER.includes('not investment advice'), 'Must state not investment advice');
});
test('HALT_COOLDOWN_MS is 24 hours', () => {
assert.equal(HALT_COOLDOWN_MS, 24 * 60 * 60 * 1000);
});
@@ -0,0 +1,221 @@
// Tests — haltCircuitBreaker (Slice 18). Pure, no network.
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 {
HaltedError,
triggerHalt,
isHalted,
clearHalt,
getHaltRecord,
ensureHaltTable,
HALT_STATE_TABLE_SQL,
} from '../haltCircuitBreaker.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
// ─── Helpers ──────────────────────────────────────────────────────────────────
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
db.exec(SCHEMA_SQL);
return db;
}
const USER_ID = 'test-user-1';
// ─── HaltedError tests ────────────────────────────────────────────────────────
test('HaltedError is a regular Error (not a parameter property)', () => {
const err = new HaltedError('u1', '2026-12-31T23:59:59Z', 'drawdown_breach');
assert.ok(err instanceof Error, 'Must be an instance of Error');
assert.ok(err instanceof HaltedError, 'Must be an instance of HaltedError');
assert.equal(err.name, 'HaltedError', 'Name must be HaltedError');
assert.equal(err.userId, 'u1', 'userId field must be set');
assert.equal(err.haltedUntil, '2026-12-31T23:59:59Z', 'haltedUntil field must be set');
assert.equal(err.triggeredBy, 'drawdown_breach', 'triggeredBy field must be set');
assert.ok(err.message.includes('u1'), 'Message must mention user id');
assert.ok(err.message.includes('2026-12-31T23:59:59Z'), 'Message must mention halted_until');
});
// ─── ensureHaltTable tests ────────────────────────────────────────────────────
test('ensureHaltTable() creates the halt_state table', () => {
const db = freshDb();
ensureHaltTable(db);
// Should not throw; table exists now.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='halt_state'").all();
assert.ok(tables.length > 0, 'halt_state table must exist after ensureHaltTable');
});
test('ensureHaltTable() is idempotent (CREATE IF NOT EXISTS)', () => {
const db = freshDb();
ensureHaltTable(db);
ensureHaltTable(db); // Should not throw.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='halt_state'").all();
assert.equal(tables.length, 1, 'Table should exist exactly once');
});
// ─── triggerHalt tests ────────────────────────────────────────────────────────
test('triggerHalt() records a halt with 24h window', () => {
const db = freshDb();
const result = triggerHalt(db, USER_ID, 'drawdown_breach');
assert.equal(result.user_id, USER_ID);
assert.equal(result.triggered_by, 'drawdown_breach');
assert.ok(result.halted_until, 'halted_until must be set');
assert.ok(result.ts, 'ts must be set');
// Verify in DB.
const row = db.prepare('SELECT * FROM halt_state WHERE user_id=?').get(USER_ID) as {
user_id: string;
halted_until: string;
triggered_by: string;
ts: string;
} | undefined;
assert.ok(row, 'Row must exist in DB');
assert.equal(row.user_id, USER_ID);
assert.equal(row.triggered_by, 'drawdown_breach');
});
test('triggerHalt() sets halted_until ~24h from now', () => {
const db = freshDb();
const before = new Date();
const result = triggerHalt(db, USER_ID, 'test');
const after = new Date();
const haltedUntil = new Date(result.halted_until);
const expectedMin = new Date(before.getTime() + 24 * 60 * 60 * 1000);
const expectedMax = new Date(after.getTime() + 24 * 60 * 60 * 1000);
assert.ok(haltedUntil.getTime() >= expectedMin.getTime(), 'halted_until must be >= now + 24h');
assert.ok(haltedUntil.getTime() <= expectedMax.getTime(), 'halted_until must be <= now + 24h + buffer');
});
test('triggerHalt() replaces existing halt for same user', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'first');
triggerHalt(db, USER_ID, 'second');
const row = db.prepare('SELECT triggered_by FROM halt_state WHERE user_id=?').get(USER_ID) as { triggered_by: string } | undefined;
assert.equal(row?.triggered_by, 'second', 'Second trigger should replace first');
});
// ─── isHalted tests ──────────────────────────────────────────────────────────
test('isHalted() returns false when no halt exists', () => {
const db = freshDb();
assert.equal(isHalted(db, 'nonexistent'), false);
});
test('isHalted() returns true within 24h of halt', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'test');
// Now should be halted.
assert.equal(isHalted(db, USER_ID), true, 'Must be halted within 24h');
});
test('isHalted() returns false after 24h cooldown expires', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'test');
// Simulate time after cooldown.
const future = new Date(Date.now() + 25 * 60 * 60 * 1000);
assert.equal(isHalted(db, USER_ID, future), false, 'Must not be halted after 24h');
});
test('isHalted() accepts custom now parameter', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'test');
// Exactly at the boundary.
const row = db.prepare('SELECT halted_until FROM halt_state WHERE user_id=?').get(USER_ID) as { halted_until: string } | undefined;
const boundary = new Date(row!.halted_until);
assert.equal(isHalted(db, USER_ID, boundary), false, 'At exact boundary should not be halted');
const justBefore = new Date(boundary.getTime() - 1);
assert.equal(isHalted(db, USER_ID, justBefore), true, 'Just before boundary should be halted');
});
// ─── clearHalt tests ─────────────────────────────────────────────────────────
test('clearHalt() removes the halt record', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'test');
const cleared = clearHalt(db, USER_ID);
assert.equal(cleared, true, 'clearHalt should return true when record existed');
// Should no longer be halted.
assert.equal(isHalted(db, USER_ID), false, 'Should not be halted after clear');
});
test('clearHalt() returns false when no record exists', () => {
const db = freshDb();
const cleared = clearHalt(db, 'nonexistent');
assert.equal(cleared, false, 'clearHalt should return false when no record exists');
});
test('cleared halt allows new entries (isHalted returns false)', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'test');
assert.equal(isHalted(db, USER_ID), true, 'Initially halted');
clearHalt(db, USER_ID);
assert.equal(isHalted(db, USER_ID), false, 'After clear, should not be halted');
});
// ─── getHaltRecord tests ─────────────────────────────────────────────────────
test('getHaltRecord() returns the halt record', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'drawdown_breach');
const record = getHaltRecord(db, USER_ID);
assert.ok(record, 'Record must exist');
assert.equal(record!.user_id, USER_ID);
assert.equal(record!.triggered_by, 'drawdown_breach');
});
test('getHaltRecord() returns null when no halt exists', () => {
const db = freshDb();
assert.equal(getHaltRecord(db, 'nonexistent'), null);
});
// ─── HaltedError thrown by journal.trade.create scenario ──────────────────────
test('HaltedError can be thrown from trade creation flow', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'drawdown_breach');
// Simulate what journal.trade.create would do.
const record = getHaltRecord(db, USER_ID);
if (record) {
const err = new HaltedError(record.user_id, record.halted_until, record.triggered_by);
assert.ok(err instanceof Error);
assert.ok(err.message.includes('Gentle-halt active'));
}
});
// ─── Existing positions unaffected by halt ────────────────────────────────────
test('halt does not affect existing positions (only blocks new entries)', () => {
const db = freshDb();
triggerHalt(db, USER_ID, 'drawdown_breach');
// The halt only blocks NEW entries. Existing positions continue.
// This is validated by the design: halt_state only tracks cooldown,
// it doesn't touch trades table.
assert.equal(isHalted(db, USER_ID), true, 'New entries are blocked');
// Existing positions would still be in the trades table and unaffected.
const trades = db.prepare('SELECT * FROM trades WHERE owner_id=?').all(USER_ID);
// No trades inserted, but the point is: halt_state doesn't delete them.
assert.ok(Array.isArray(trades), 'Trades table query works (no halt interference)');
});
@@ -0,0 +1,96 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
assessOptionRiskContribution,
cspCashReserved,
legNotionalPremium,
mergeOptionCapitalIntoClusters,
} from '../optionRiskContribution.ts';
import type { PortfolioOptionLeg } from '../../db/portfolioOptionRepository.ts';
function leg(partial: Partial<PortfolioOptionLeg> & Pick<PortfolioOptionLeg, 'underlying' | 'right' | 'side' | 'strike' | 'contracts' | 'premium' | 'role'>): PortfolioOptionLeg {
return {
id: 'ol_test',
expiry: '2026-06-20',
multiplier: 100,
acquired_at: '2026-01-01T00:00:00Z',
note: null,
...partial,
};
}
test('long call premium at risk = premium × mult × contracts', () => {
const l = leg({
underlying: 'AAPL',
right: 'call',
side: 'long',
strike: 150,
contracts: 2,
premium: 4.2,
role: 'long_call',
});
assert.equal(legNotionalPremium(l), 4.2 * 100 * 2);
const r = assessOptionRiskContribution([l], []);
assert.equal(r.premiumAtRiskUsd, 840);
assert.equal(r.cashReservedUsd, 0);
assert.equal(r.capitalCommittedUsd, 840);
assert.equal(r.legsCount, 1);
});
test('CSP cash reserved = strike collateral − credit', () => {
const l = leg({
underlying: 'IWM',
right: 'put',
side: 'short',
strike: 200,
contracts: 1,
premium: 3,
role: 'cash_secured_put',
});
// collateral 200*100=20000, credit 300 → 19700
assert.equal(cspCashReserved(l), 19700);
const r = assessOptionRiskContribution([l], [], 50_000); // 19700/50k ≥ 25% → consideration
assert.equal(r.cashReservedUsd, 19700);
assert.equal(r.creditReceivedUsd, 300);
assert.ok(r.recommendations.some((x) => x.tradeOff.includes('Cash reserved')));
});
test('covered call with stock is not uncovered', () => {
const l = leg({
underlying: 'AAPL',
right: 'call',
side: 'short',
strike: 160,
contracts: 1,
premium: 2,
role: 'covered_call',
});
const r = assessOptionRiskContribution([l], [{ symbol: 'AAPL', shares: 100 }]);
assert.equal(r.uncoveredShortCallCount, 0);
assert.equal(r.premiumAtRiskUsd, 0);
assert.equal(r.creditReceivedUsd, 200);
});
test('short call without stock flags uncovered', () => {
const l = leg({
underlying: 'TSLA',
right: 'call',
side: 'short',
strike: 250,
contracts: 1,
premium: 5,
role: 'covered_call',
});
const r = assessOptionRiskContribution([l], [{ symbol: 'TSLA', shares: 50 }]);
assert.equal(r.uncoveredShortCallCount, 1);
assert.ok(r.recommendations.some((x) => x.severity === 'warning'));
});
test('mergeOptionCapitalIntoClusters folds into uncategorized by default', () => {
const merged = mergeOptionCapitalIntoClusters(
{ uncategorized: 1000 },
{ AAPL: 840, IWM: 19700 },
true,
);
assert.equal(merged.uncategorized, 1000 + 840 + 19700);
});
@@ -0,0 +1,40 @@
// Unit coverage for sizing/risk product contracts used by tRPC routers.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { sizePosition } from '../../sizing/SizingEngine.ts';
import { assessRisk, ADR_0007_FOOTER } from '../RiskEngine.ts';
test('sizing.compute contract: math implies shares, ADR-0007 footer-ready', () => {
const result = sizePosition(
{ symbol: 'NVDA', tier: 'B', riskFraction: 0.01, stopPerShare: 5 },
{ equity: 100_000, complexity: 'beginner' },
{
holdings: [],
regime: 'trending-up',
clusterCaps: { uncategorized: 25_000 },
aStarUnlocked: false,
},
);
// 100000 * 0.01 / 5 = 200 shares * B(×1) = 200
assert.equal(result.shares, 200);
assert.equal(result.blocked, false);
assert.ok(result.explanations.some((e) => /math implies/i.test(e)));
assert.ok(result.explanations.some((e) => /not investment advice/i.test(e)));
});
test('risk.posture contract: peak breach yields halt + consideration', () => {
const posture = assessRisk({
portfolio: [
{ symbol: 'NVDA', shares: 100, avgCost: 100, cluster: 'uncategorized', stopPrice: 90, rewardTarget: 130 },
],
account: { equity: 80_000, drawdownTolerancePct: 15, complexity: 'beginner' },
peakEquity: 100_000,
regime: 'trending-down',
sizingContext: { clusterCaps: { uncategorized: 20_000 } },
});
assert.equal(posture.halted, true);
assert.ok(posture.recommendedActions.length > 0);
assert.ok(posture.recommendedActions.every((a) => a.explanation.includes(ADR_0007_FOOTER)
|| a.explanation.toLowerCase().includes('trade-off')));
assert.ok(!JSON.stringify(posture.recommendedActions).toLowerCase().includes('you should sell'));
});
+134
View File
@@ -0,0 +1,134 @@
// Investor Flow — haltCircuitBreaker (Slice 18): gentle-halt state management.
//
// ADR-0007: outputs MATH and PLAIN-ENGLISH explanations, never instructions.
// "the math implies ~N shares given your stop and risk%" — never "buy N shares".
// Pure/cache-deterministic: no I/O, no network. Fully testable with any data.
import { DatabaseSync } from 'node:sqlite';
// ─── Error type (NOT a TS parameter property — declare field, assign in body) ─
/**
* Thrown by journal.trade.create when the user's gentle-halt is active.
* Regular class with a message field, NOT a TS parameter property.
* Node's --experimental-strip-types rejects parameter properties.
*/
export class HaltedError extends Error {
public readonly userId: string;
public readonly haltedUntil: string;
public readonly triggeredBy: string;
constructor(userId: string, haltedUntil: string, triggeredBy: string) {
super(
`Gentle-halt active for user ${userId} until ${haltedUntil}. ` +
`Triggered by: ${triggeredBy}. Existing positions continue unaffected.`,
);
this.name = 'HaltedError';
this.userId = userId;
this.haltedUntil = haltedUntil;
this.triggeredBy = triggeredBy;
}
}
// ─── Database schema for halt_state (append to schema.sql) ────────────────────
/** SQL to create the halt_state table. */
export const HALT_STATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS halt_state (
user_id TEXT PRIMARY KEY,
halted_until TEXT NOT NULL,
triggered_by TEXT NOT NULL,
ts TEXT NOT NULL
);
`;
// ─── Core halt operations (require a DatabaseSync instance) ──────────────────
/** Ensure the halt_state table exists in the database. */
export function ensureHaltTable(db: DatabaseSync): void {
db.exec(HALT_STATE_TABLE_SQL);
}
/**
* Trigger a gentle-halt for a user.
*
* Records the halt in halt_state with:
* - user_id (PK)
* - halted_until: ISO timestamp = now + 24h
* - triggered_by: reason (e.g. "max_drawdown_tolerance_breach")
* - ts: current ISO timestamp
*
* Returns the halt record.
*/
export function triggerHalt(
db: DatabaseSync,
userId: string,
triggeredBy: string,
): { user_id: string; halted_until: string; triggered_by: string; ts: string } {
ensureHaltTable(db);
const now = new Date();
const haltedUntil = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
const ts = now.toISOString();
db.prepare(
'INSERT OR REPLACE INTO halt_state (user_id, halted_until, triggered_by, ts) VALUES (?, ?, ?, ?)',
).run(userId, haltedUntil, triggeredBy, ts);
return { user_id: userId, halted_until: haltedUntil, triggered_by: triggeredBy, ts };
}
/**
* Check if a user is currently halted.
*
* Returns true if:
* - A halt record exists for the user
* - The halted_until timestamp is in the future (relative to `now`)
*
* If `now` is not provided, uses the current time.
*/
export function isHalted(
db: DatabaseSync,
userId: string,
now?: Date,
): boolean {
ensureHaltTable(db);
const checkTime = now ?? new Date();
const row = db.prepare(
'SELECT halted_until FROM halt_state WHERE user_id = ?',
).get(userId) as { halted_until: string } | undefined;
if (!row) return false;
const haltedUntil = new Date(row.halted_until);
return checkTime.getTime() < haltedUntil.getTime();
}
/**
* Clear a user's halt state (remove the record).
*
* Returns true if a record was removed, false otherwise.
*/
export function clearHalt(db: DatabaseSync, userId: string): boolean {
ensureHaltTable(db);
const result = db.prepare(
'DELETE FROM halt_state WHERE user_id = ?',
).run(userId);
return result.changes > 0;
}
/**
* Get the halt record for a user (if any).
*
* Returns null if no halt is active.
*/
export function getHaltRecord(
db: DatabaseSync,
userId: string,
): { user_id: string; halted_until: string; triggered_by: string; ts: string } | null {
ensureHaltTable(db);
const row = db.prepare(
'SELECT user_id, halted_until, triggered_by, ts FROM halt_state WHERE user_id = ?',
).get(userId) as { user_id: string; halted_until: string; triggered_by: string; ts: string } | undefined;
return row ?? null;
}
@@ -0,0 +1,213 @@
// Pure option-leg risk contribution for Risk posture (MVP).
// Cost-basis / structural capital - not delta or live marks.
// ADR-0007: considerations only, never trade instructions.
import type { PortfolioOptionLeg } from '../db/portfolioOptionRepository.ts';
import { ADR_0007_FOOTER, type Recommendation } from './RiskEngine.ts';
export interface EquityHoldingLite {
symbol: string;
shares: number;
}
export interface OptionUnderlyingBucket {
premiumAtRiskUsd: number;
cashReservedUsd: number;
creditReceivedUsd: number;
legs: number;
uncoveredShortCalls: number;
}
export interface OptionRiskContribution {
legsCount: number;
premiumAtRiskUsd: number;
cashReservedUsd: number;
creditReceivedUsd: number;
capitalCommittedUsd: number;
uncoveredShortCallCount: number;
byUnderlying: Record<string, OptionUnderlyingBucket>;
/** Capital committed added into cluster map (key = underlying uppercased for later sector map). */
capitalByUnderlying: Record<string, number>;
recommendations: Recommendation[];
}
function emptyBucket(): OptionUnderlyingBucket {
return {
premiumAtRiskUsd: 0,
cashReservedUsd: 0,
creditReceivedUsd: 0,
legs: 0,
uncoveredShortCalls: 0,
};
}
/** Premium or credit dollars for a leg. */
export function legNotionalPremium(leg: PortfolioOptionLeg): number {
return leg.premium * leg.multiplier * leg.contracts;
}
/** Cash reserved for a cash-secured put (strike collateral minus credit). */
export function cspCashReserved(leg: PortfolioOptionLeg): number {
const credit = legNotionalPremium(leg);
const collateral = leg.strike * leg.multiplier * leg.contracts;
return Math.max(0, collateral - credit);
}
function hasCoveringStock(
underlying: string,
contracts: number,
multiplier: number,
equity: EquityHoldingLite[],
): boolean {
const needShares = contracts * multiplier;
const held = equity
.filter((h) => h.symbol.toUpperCase() === underlying.toUpperCase())
.reduce((s, h) => s + h.shares, 0);
return held + 1e-9 >= needShares;
}
/**
* Aggregate option risk contribution from open legs + equity book (for cover checks).
*/
export function assessOptionRiskContribution(
legs: PortfolioOptionLeg[],
equityHoldings: EquityHoldingLite[],
accountEquity?: number,
): OptionRiskContribution {
const byUnderlying: Record<string, OptionUnderlyingBucket> = {};
let premiumAtRiskUsd = 0;
let cashReservedUsd = 0;
let creditReceivedUsd = 0;
let uncoveredShortCallCount = 0;
for (const leg of legs) {
const u = leg.underlying.toUpperCase();
const bucket = byUnderlying[u] ?? emptyBucket();
bucket.legs += 1;
const dollars = legNotionalPremium(leg);
const isLong = leg.side === 'long';
const isShort = leg.side === 'short';
if (isLong) {
premiumAtRiskUsd += dollars;
bucket.premiumAtRiskUsd += dollars;
}
if (isShort) {
creditReceivedUsd += dollars;
bucket.creditReceivedUsd += dollars;
}
const role = leg.role;
const isCsp =
role === 'cash_secured_put' || (leg.side === 'short' && leg.right === 'put');
const isShortCall =
role === 'covered_call' || (leg.side === 'short' && leg.right === 'call');
if (isCsp) {
const reserved = cspCashReserved(leg);
cashReservedUsd += reserved;
bucket.cashReservedUsd += reserved;
}
if (isShortCall) {
const covered = hasCoveringStock(u, leg.contracts, leg.multiplier, equityHoldings);
if (!covered) {
uncoveredShortCallCount += 1;
bucket.uncoveredShortCalls += 1;
}
}
byUnderlying[u] = bucket;
}
const capitalCommittedUsd = premiumAtRiskUsd + cashReservedUsd;
const capitalByUnderlying: Record<string, number> = {};
for (const [u, b] of Object.entries(byUnderlying)) {
capitalByUnderlying[u] = b.premiumAtRiskUsd + b.cashReservedUsd;
}
const recommendations = buildOptionRecommendations({
premiumAtRiskUsd,
cashReservedUsd,
capitalCommittedUsd,
uncoveredShortCallCount,
accountEquity,
byUnderlying,
});
return {
legsCount: legs.length,
premiumAtRiskUsd,
cashReservedUsd,
creditReceivedUsd,
capitalCommittedUsd,
uncoveredShortCallCount,
byUnderlying,
capitalByUnderlying,
recommendations,
};
}
function buildOptionRecommendations(input: {
premiumAtRiskUsd: number;
cashReservedUsd: number;
capitalCommittedUsd: number;
uncoveredShortCallCount: number;
accountEquity?: number;
byUnderlying: Record<string, OptionUnderlyingBucket>;
}): Recommendation[] {
const recs: Recommendation[] = [];
const eq = input.accountEquity;
if (input.uncoveredShortCallCount > 0) {
recs.push({
id: 'consider_reducing_position',
tradeOff: `${input.uncoveredShortCallCount} short call leg(s) lack matching long stock at contract size (shares needed = contracts × multiplier).`,
explanation:
`Short calls without covering stock have an undefined-risk shape in the teaching model. A trade-off to think through: whether the position is intentionally structured elsewhere, or whether risk is open-ended relative to a covered call.\n\n${ADR_0007_FOOTER}`,
severity: 'warning',
});
}
if (eq && eq > 0 && input.premiumAtRiskUsd / eq >= 0.05) {
const pct = ((input.premiumAtRiskUsd / eq) * 100).toFixed(1);
recs.push({
id: 'consider_reducing_position',
tradeOff: `Long option premium at risk is ~${pct}% of stated equity ($${input.premiumAtRiskUsd.toFixed(0)}).`,
explanation:
`Debit option premium is capital that can go to zero if contracts expire worthless. A trade-off to think through: whether that premium share of equity matches your stated risk process.\n\n${ADR_0007_FOOTER}`,
severity: 'info',
});
}
if (eq && eq > 0 && input.cashReservedUsd / eq >= 0.25) {
const pct = ((input.cashReservedUsd / eq) * 100).toFixed(1);
recs.push({
id: 'consider_rebalancing_cluster',
tradeOff: `Cash reserved for cash-secured puts is ~${pct}% of stated equity ($${input.cashReservedUsd.toFixed(0)}).`,
explanation:
`CSP collateral is capital committed to potential assignment at the strike. A trade-off to think through: concentration of reserved cash versus other uses of that capital.\n\n${ADR_0007_FOOTER}`,
severity: 'info',
});
}
return recs;
}
/** Merge option capital into cluster exposure (adds to existing keys; uses underlying as key). */
export function mergeOptionCapitalIntoClusters(
clusterExposure: Record<string, number>,
capitalByUnderlying: Record<string, number>,
/** When true, fold all option capital into uncategorized (beginner default). */
foldUncategorized = true,
): Record<string, number> {
const out = { ...clusterExposure };
for (const [u, usd] of Object.entries(capitalByUnderlying)) {
if (usd <= 0) continue;
const key = foldUncategorized ? 'uncategorized' : u;
out[key] = (out[key] ?? 0) + usd;
}
return out;
}