feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
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:
Investor Flow Build
2026-08-10 13:36:26 -04:00
parent 04fc11b2fd
commit ac94acf9e3
229 changed files with 32617 additions and 3934 deletions
+7 -8
View File
@@ -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'));
});
+87 -117
View File
@@ -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',
});
}