feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
This commit is contained in:
@@ -98,9 +98,8 @@ export interface RiskPosture {
|
||||
|
||||
// ─── 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.';
|
||||
/** Single app-wide footnote (keep off body copy). */
|
||||
export const ADR_0007_FOOTER = 'Educational observation only.';
|
||||
|
||||
/** Duration of a gentle-halt cooldown in milliseconds (24h). */
|
||||
export const HALT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -189,7 +188,7 @@ export function generateRecommendations(
|
||||
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}`,
|
||||
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.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -205,14 +204,14 @@ export function generateRecommendations(
|
||||
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}`,
|
||||
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.`,
|
||||
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}`,
|
||||
explanation: `A trade-off to think through: consider rebalancing the cluster. You're advanced enough to manage correlated exposure, but monitor the cap.`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
@@ -225,7 +224,7 @@ export function generateRecommendations(
|
||||
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}`,
|
||||
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.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -235,7 +234,7 @@ export function generateRecommendations(
|
||||
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}`,
|
||||
explanation: `A trade-off to think through: consider reducing position sizes or increasing cash reserves during a trending-down regime.`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ test('generateRecommendations() emits consider_rebalancing_cluster as info for i
|
||||
assert.equal(rebalancing[0].severity, 'info', 'Intermediate breach should be info severity');
|
||||
});
|
||||
|
||||
test('generateRecommendations() includes ADR-0007 footer in all recommendations', () => {
|
||||
test('generateRecommendations() returns structured trade-offs without plastered disclaimers', () => {
|
||||
const p = [
|
||||
{ symbol: 'A', shares: 10, avgCost: 100, cluster: 'x', rewardTarget: 110, stopPrice: 80 },
|
||||
];
|
||||
@@ -153,7 +153,7 @@ test('generateRecommendations() includes ADR-0007 footer in all recommendations'
|
||||
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`);
|
||||
assert.ok(!r.explanation.includes(ADR_0007_FOOTER), 'Disclaimer stays on the page footer, not each flag');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,8 +231,7 @@ test('assessRisk() does NOT halt when within tolerance', () => {
|
||||
|
||||
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');
|
||||
assert.equal(ADR_0007_FOOTER, 'Educational observation only.');
|
||||
});
|
||||
|
||||
test('HALT_COOLDOWN_MS is 24 hours', () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
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';
|
||||
import { assessRisk } from '../RiskEngine.ts';
|
||||
|
||||
test('sizing.compute contract: math implies shares, ADR-0007 footer-ready', () => {
|
||||
test('sizing.compute contract: math implies shares without plastered disclaimer', () => {
|
||||
const result = sizePosition(
|
||||
{ symbol: 'NVDA', tier: 'B', riskFraction: 0.01, stopPerShare: 5 },
|
||||
{ equity: 100_000, complexity: 'beginner' },
|
||||
@@ -19,13 +19,13 @@ test('sizing.compute contract: math implies shares, ADR-0007 footer-ready', () =
|
||||
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)));
|
||||
assert.ok(result.explanations.every((e) => !/educational observation only|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 },
|
||||
{ symbol: 'NVDA', shares: 100, avgCost: 100, cluster: 'uncategorized', stopPrice: 90, priceTarget: 130 },
|
||||
],
|
||||
account: { equity: 80_000, drawdownTolerancePct: 15, complexity: 'beginner' },
|
||||
peakEquity: 100_000,
|
||||
@@ -34,7 +34,6 @@ test('risk.posture contract: peak breach yields halt + consideration', () => {
|
||||
});
|
||||
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(posture.recommendedActions.every((a) => a.explanation.toLowerCase().includes('trade-off')));
|
||||
assert.ok(!JSON.stringify(posture.recommendedActions).toLowerCase().includes('you should sell'));
|
||||
});
|
||||
|
||||
@@ -1,27 +1,100 @@
|
||||
// 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.
|
||||
// Investor Flow — Risk Engine: Gentle-halt circuit breaker (Slice 18).
|
||||
// When a user breaches risk limits, they enter a 24h cooldown window where new trades are blocked.
|
||||
// Existing positions continue; only new entries are halted.
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// ─── Error type (NOT a TS parameter property — declare field, assign in body) ─
|
||||
/** 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,
|
||||
triggered_by TEXT NOT NULL,
|
||||
halted_until TEXT NOT NULL, -- ISO8601; 24h cooldown window
|
||||
ts TEXT NOT NULL -- ISO8601; when the halt was recorded
|
||||
)
|
||||
`;
|
||||
|
||||
/** Halt cooldown duration in milliseconds (24 hours). */
|
||||
export const HALT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Ensure the halt_state table exists. Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function ensureHaltTable(db: DatabaseSync): void {
|
||||
db.exec(HALT_STATE_TABLE_SQL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a gentle-halt for a user with a 24h cooldown window.
|
||||
* Replaces any existing halt for the same user (UPSERT).
|
||||
*/
|
||||
export function triggerHalt(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
triggeredBy: string,
|
||||
): { user_id: string; triggered_by: string; halted_until: string; ts: string } {
|
||||
const now = new Date().toISOString();
|
||||
const haltedUntil = new Date(Date.now() + HALT_COOLDOWN_MS).toISOString();
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO halt_state (user_id, triggered_by, halted_until, ts) VALUES (?, ?, ?, ?)`
|
||||
).run(userId, triggeredBy, haltedUntil, now);
|
||||
|
||||
return { user_id: userId, triggered_by: triggeredBy, halted_until: haltedUntil, ts: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is currently under a gentle-halt (within the 24h cooldown window).
|
||||
*/
|
||||
export function isHalted(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
now?: Date,
|
||||
): boolean {
|
||||
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 cutoff = new Date(row.halted_until);
|
||||
const checkTime = now ?? new Date();
|
||||
return checkTime.getTime() < cutoff.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a user's gentle-halt record. Returns true if a record was removed, false otherwise.
|
||||
*/
|
||||
export function clearHalt(db: DatabaseSync, userId: string): boolean {
|
||||
const result = db.prepare('DELETE FROM halt_state WHERE user_id=?').run(userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current halt record for a user, or null if not halted.
|
||||
*/
|
||||
export function getHaltRecord(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
): { user_id: string; triggered_by: string; halted_until: string } | null {
|
||||
const row = db.prepare(
|
||||
'SELECT user_id, triggered_by, halted_until FROM halt_state WHERE user_id=?'
|
||||
).get(userId) as { user_id: string; triggered_by: string; halted_until: string } | undefined;
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a trade is attempted during the gentle-halt cooldown.
|
||||
*/
|
||||
export class HaltedError extends Error {
|
||||
public readonly userId: string;
|
||||
public readonly haltedUntil: string;
|
||||
public readonly triggeredBy: string;
|
||||
readonly userId: string;
|
||||
readonly haltedUntil: string;
|
||||
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.`,
|
||||
`Gentle-halt active for user ${userId}: trading paused until ${haltedUntil} (reason: ${triggeredBy})`
|
||||
);
|
||||
this.name = 'HaltedError';
|
||||
this.userId = userId;
|
||||
@@ -29,106 +102,3 @@ export class HaltedError extends Error {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ADR-0007: considerations only, never trade instructions.
|
||||
|
||||
import type { PortfolioOptionLeg } from '../db/portfolioOptionRepository.ts';
|
||||
import { ADR_0007_FOOTER, type Recommendation } from './RiskEngine.ts';
|
||||
import { type Recommendation } from './RiskEngine.ts';
|
||||
|
||||
export interface EquityHoldingLite {
|
||||
symbol: string;
|
||||
@@ -166,7 +166,7 @@ function buildOptionRecommendations(input: {
|
||||
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}`,
|
||||
`Short calls without covering stock have an undefined-risk shape in this model. Check whether stock cover lives in another account, or whether short-call risk is open-ended relative to a covered call.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -177,7 +177,7 @@ function buildOptionRecommendations(input: {
|
||||
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}`,
|
||||
`Debit option premium is capital that can go to zero if contracts expire worthless. Compare that premium share of equity to your stated risk process.`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
@@ -188,7 +188,7 @@ function buildOptionRecommendations(input: {
|
||||
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}`,
|
||||
`CSP collateral is capital committed to potential assignment at the strike. Compare reserved cash concentration to other uses of that capital.`,
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user