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
+176
View File
@@ -0,0 +1,176 @@
// Investor Flow — SizingEngine (Slice 11): pure 4-layer position sizing.
//
// 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: Position Sizing (layers 0–4), Conviction Tier (A_STAR×3/A×2/B×1/C×0.5),
// Sizing Unlock (20B→A, 10A→A_STAR), Macro Regime Gate, Correlation Cluster.
/** Conviction tier → multiplier. */
export type ConvictionTier = 'A_STAR' | 'A' | 'B' | 'C';
export const CONVICTION_MULTIPLIER: Record<ConvictionTier, number> = {
A_STAR: 3, A: 2, B: 1, C: 0.5,
};
/** Market regime → sizing posture. */
export type Regime = 'trending-up' | 'trending-down' | 'range-bound';
/** User complexity → guards. */
export type Complexity = 'beginner' | 'intermediate' | 'advanced';
/** A position plan input to SizingEngine. */
export interface SizingPlan {
tier: ConvictionTier;
riskFraction: number; // e.g. 0.01 = 1%; 0.005 in gentle-halt
stopPerShare: number; // dollars risked per share (entry - stop)
symbol: string;
}
/** Account state. */
export interface SizingAccount {
equity: number; // account equity in dollars
complexity: Complexity;
}
/** A current holding for correlation-cluster checks. */
export interface SizingHolding {
symbol: string;
shares: number;
avgCost: number;
cluster: string; // macro/factor driver id (e.g. "btc_miners")
}
/** Portfolio + regime context. */
export interface SizingContext {
holdings: SizingHolding[];
regime: Regime;
/** Per-cluster exposure cap (dollars) for beginners; null = no hard cap. */
clusterCaps: Record<string, number> | null;
/** Whether the user has unlocked A_STAR (from convictionUnlock gate). */
aStarUnlocked: boolean;
}
/** One layer's contribution to sizing. */
export interface RiskLayer {
name: string;
value: number;
explanation: string;
}
/** Output of SizingEngine.sizePosition. */
export interface SizingResult {
shares: number;
layers: RiskLayer[];
explanations: string[];
blocked: boolean;
blockReason: string | null;
/** Whether a written-reason override was recorded (macro or cluster). */
override: 'macro' | 'cluster' | null;
}
/** Compute the raw share count before cluster/macro gating. Pure. */
export function sizePosition(
plan: SizingPlan,
account: SizingAccount,
ctx: SizingContext,
opts: { aStarUnlocked?: boolean; macroOverride?: { reason: string } | null } = {},
): SizingResult {
const layers: RiskLayer[] = [];
const explanations: string[] = [];
const aStarUnlocked = opts.aStarUnlocked ?? ctx.aStarUnlocked;
// Layer 0 — Risk per trade.
const riskDollars = account.equity * plan.riskFraction;
layers.push({
name: 'risk-per-trade',
value: riskDollars,
explanation: `Risk per trade: ${account.equity} × ${(plan.riskFraction * 100).toFixed(1)}% = ${riskDollars.toFixed(2)} dollars at risk.`,
});
// Layer 1 — Stop width.
if (plan.stopPerShare <= 0) {
return { shares: 0, layers, explanations, blocked: true, blockReason: 'Stop per share must be > 0.', override: null };
}
const sharesNominal = riskDollars / plan.stopPerShare;
layers.push({
name: 'stop-width',
value: sharesNominal,
explanation: `Nominal shares from stop width: ${riskDollars.toFixed(2)} ÷ ${plan.stopPerShare} = ${sharesNominal.toFixed(2)} shares.`,
});
// Layer 2 — Conviction tier multiplier (+ A_STAR unlock gate).
if (plan.tier === 'A_STAR' && !aStarUnlocked) {
return {
shares: 0, layers, explanations, blocked: true,
blockReason: 'A_STAR is locked until the Conviction Tier unlock is earned (10 profitable A trades).',
override: null,
};
}
const multiplier = CONVICTION_MULTIPLIER[plan.tier];
const sharesTiered = sharesNominal * multiplier;
layers.push({
name: 'conviction-tier',
value: sharesTiered,
explanation: `Conviction tier ${plan.tier} ×${multiplier}: ${sharesNominal.toFixed(2)} × ${multiplier} = ${sharesTiered.toFixed(2)} shares.`,
});
// Layer 3 — Macro gate.
let macroMultiplier = 1;
let macroBlocked = false;
if (ctx.regime === 'trending-down') macroMultiplier = 0.5;
if (ctx.regime === 'range-bound' && plan.tier === 'A_STAR') {
macroBlocked = true; // no A_STAR while ranging
}
if (macroBlocked && !opts.macroOverride) {
return {
shares: 0, layers, explanations, blocked: true,
blockReason: 'Macro gate: A_STAR is not permitted in a range-bound regime. Provide a written reason to override.',
override: null,
};
}
const sharesAfterMacro = sharesTiered * macroMultiplier;
layers.push({
name: 'macro-gate',
value: sharesAfterMacro,
explanation: `Macro gate (${ctx.regime}) ${macroMultiplier !== 1 ? `×${macroMultiplier}` : 'unchanged'}: ${sharesAfterMacro.toFixed(2)} shares.${opts.macroOverride ? ' Written-reason override recorded.' : ''}`,
});
// Layer 3b — Correlation cluster cap (beginners: hard cap; intermediates: warn).
const clusterOfNew = ctx.holdings.find((h) => h.symbol === plan.symbol)?.cluster;
let clusterCapped = false;
let clusterWarn = false;
if (clusterOfNew && ctx.clusterCaps) {
const cap = ctx.clusterCaps[clusterOfNew] ?? Infinity;
const existingExposure = ctx.holdings
.filter((h) => h.cluster === clusterOfNew && h.symbol !== plan.symbol)
.reduce((sum, h) => sum + h.shares * h.avgCost, 0);
const newExposure = sharesAfterMacro * plan.stopPerShare * 10; // rough notional proxy
if (existingExposure + newExposure > cap) {
if (account.complexity === 'beginner') clusterCapped = true;
else clusterWarn = true;
}
}
if (clusterCapped) {
return {
shares: 0, layers, explanations, blocked: true,
blockReason: `Correlation cluster '${clusterOfNew}' exposure would exceed the beginner hard cap. Consider rebalancing the cluster.`,
override: opts.macroOverride ? 'macro' : null,
};
}
if (clusterWarn) {
explanations.push(`Heads-up: correlation cluster '${clusterOfNew}' is approaching its cap — a trade-off to think through.`);
}
const shares = Math.max(0, Math.floor(sharesAfterMacro));
explanations.push(`The math implies ~${shares} shares given your stop of ${plan.stopPerShare} and ${(plan.riskFraction * 100).toFixed(1)}% risk. Educational analysis, not investment advice.`);
return {
shares,
layers,
explanations,
blocked: false,
blockReason: null,
override: opts.macroOverride ? 'macro' : null,
};
}
@@ -0,0 +1,178 @@
// Tests — SizingEngine + convictionUnlock + twoAxisMatrix (Slice 11). 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 {
sizePosition, CONVICTION_MULTIPLIER,
type SizingPlan, type SizingAccount, type SizingContext,
} from '../SizingEngine.ts';
import {
DEFAULT_THRESHOLDS, isAStarUnlocked, isTierAUnlocked, requireAStarUnlocked,
requireTierAUnlocked, UnlockError,
} from '../convictionUnlock.ts';
import {
applyTwoAxisMatrix, convictionFromTier,
} from '../twoAxisMatrix.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_SQL = readFileSync(join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
db.exec(SCHEMA_SQL);
db.prepare("INSERT INTO users (id,email,pw_hash,created_at) VALUES (?,?,?,?)").run('u1', 'u@example.com', 'h', '2026-01-01');
return db;
}
const plan = (tier: SizingPlan['tier'], riskFraction = 0.01, stopPerShare = 1): SizingPlan => ({ tier, riskFraction, stopPerShare, symbol: 'NVDA' });
const account = (equity = 10000, complexity: 'beginner' | 'advanced' = 'beginner'): SizingAccount => ({ equity, complexity });
const ctx = (over: Partial<SizingContext> = {}): SizingContext => ({
holdings: [], regime: 'trending-up', clusterCaps: null, aStarUnlocked: false, ...over,
});
// ---- SizingEngine ----
test('Layer 0+1: risk-per-trade and stop-width', () => {
const r = sizePosition(plan('B', 0.01, 1), account(10000), ctx({ aStarUnlocked: true }));
assert.equal(r.blocked, false);
// 10000*0.01=100 risk; 100/1=100 nominal; B x1 = 100; trending-up x1 = 100
assert.equal(r.shares, 100);
assert.ok(r.layers.some((l) => l.name === 'risk-per-trade' && l.value === 100));
assert.ok(r.layers.some((l) => l.name === 'stop-width' && Math.abs(l.value - 100) < 1e-9));
});
test('Layer 2: conviction tier multipliers apply', () => {
const rA = sizePosition(plan('A', 0.01, 1), account(10000), ctx({ aStarUnlocked: true }));
assert.equal(rA.shares, 200); // x2
const rC = sizePosition(plan('C', 0.01, 1), account(10000), ctx());
assert.equal(rC.shares, 50); // x0.5
});
test('A_STAR blocked when not unlocked', () => {
const r = sizePosition(plan('A_STAR', 0.01, 1), account(10000), ctx({ aStarUnlocked: false }));
assert.equal(r.blocked, true);
assert.match(r.blockReason ?? '', /locked until the Conviction Tier unlock/);
});
test('A_STAR permitted when unlocked, trending-up', () => {
const r = sizePosition(plan('A_STAR', 0.01, 1), account(10000), ctx({ regime: 'trending-up', aStarUnlocked: true }));
assert.equal(r.blocked, false);
assert.equal(r.shares, 300); // x3
});
test('Macro gate trending-down halves size', () => {
const r = sizePosition(plan('A', 0.01, 1), account(10000), ctx({ regime: 'trending-down', aStarUnlocked: true }));
assert.equal(r.blocked, false);
assert.equal(r.shares, 100); // 200 * 0.5
});
test('Macro gate range-bound blocks A_STAR without override', () => {
const r = sizePosition(plan('A_STAR', 0.01, 1), account(10000), ctx({ regime: 'range-bound', aStarUnlocked: true }));
assert.equal(r.blocked, true);
assert.match(r.blockReason ?? '', /range-bound regime/);
});
test('Macro override with reason proceeds', () => {
const r = sizePosition(plan('A_STAR', 0.01, 1), account(10000), ctx({ regime: 'range-bound', aStarUnlocked: true }), { macroOverride: { reason: 'unusual setup' } });
assert.equal(r.blocked, false);
assert.equal(r.override, 'macro');
});
test('Stop per share <= 0 blocks', () => {
const r = sizePosition(plan('B', 0.01, 0), account(10000), ctx());
assert.equal(r.blocked, true);
assert.match(r.blockReason ?? '', /Stop per share must be > 0/);
});
test('Correlation cluster hard cap blocks beginner', () => {
const c: SizingContext = {
holdings: [{ symbol: 'ARKK', shares: 1000, avgCost: 50, cluster: 'innovation' }],
regime: 'trending-up', aStarUnlocked: true,
clusterCaps: { innovation: 100 },
clusterOfNew: undefined,
} as unknown as SizingContext;
// Force the new symbol into the innovation cluster via holdings entry
const planB = plan('B', 0.01, 1); planB.symbol = 'ARKK';
const r = sizePosition(planB, account(10000, 'beginner'), c);
assert.equal(r.blocked, true);
assert.match(r.blockReason ?? '', /Correlation cluster/);
});
test('Explanations use neutral language — no trade verbs (ADR-0007)', () => {
const r = sizePosition(plan('B', 0.01, 1), account(10000), ctx({ aStarUnlocked: true }));
const text = r.explanations.join(' ').toLowerCase();
assert.ok(!/\b(buy|sell|you should)\b/.test(text), 'no imperative trade verbs');
assert.match(text, /the math implies/);
});
// ---- convictionUnlock ----
test('isAStarUnlocked false by default', () => {
const db = freshDb();
assert.equal(isAStarUnlocked(db, 'u1'), false);
});
test('isAStarUnlocked true when sizing_unlocks row present', () => {
const db = freshDb();
db.prepare("INSERT INTO sizing_unlocks (user_id, unlock, earned_at) VALUES (?, 'tier_a_star', ?)").run('u1', '2026-01-01');
assert.equal(isAStarUnlocked(db, 'u1'), true);
});
test('requireAStarUnlocked throws UnlockError with remaining requirement', () => {
const db = freshDb();
assert.throws(() => requireAStarUnlocked(db, 'u1', DEFAULT_THRESHOLDS), (e: unknown) => {
assert.ok(e instanceof UnlockError);
assert.match((e as UnlockError).requirement, /Profitable A trades: 0\/10/);
return true;
});
});
test('requireTierAUnlocked throws with B requirement', () => {
const db = freshDb();
assert.throws(() => requireTierAUnlocked(db, 'u1', DEFAULT_THRESHOLDS), (e: unknown) => {
assert.match((e as UnlockError).requirement, /Profitable B trades: 0\/20/);
return true;
});
});
// ---- twoAxisMatrix ----
test('convictionFromTier: A_STAR/A = high, B/C = low', () => {
assert.equal(convictionFromTier('A_STAR'), 'high');
assert.equal(convictionFromTier('A'), 'high');
assert.equal(convictionFromTier('B'), 'low');
assert.equal(convictionFromTier('C'), 'low');
});
test('matrix: high conviction x bad entry = wait (overrideable)', () => {
const r = applyTwoAxisMatrix({ tier: 'A', entry: 'bad' });
assert.equal(r.verdict, 'wait');
assert.equal(r.canOverride, true);
assert.equal(r.overrideRecorded, false);
});
test('matrix: high x bad + written reason = proceed', () => {
const r = applyTwoAxisMatrix({ tier: 'A', entry: 'bad', overrideReason: 'thesis intact' });
assert.equal(r.verdict, 'proceed');
assert.equal(r.overrideRecorded, true);
});
test('matrix: low x bad = decline (no override)', () => {
const r = applyTwoAxisMatrix({ tier: 'B', entry: 'bad' });
assert.equal(r.verdict, 'decline');
assert.equal(r.canOverride, false);
});
test('matrix: either conviction x good entry = proceed', () => {
assert.equal(applyTwoAxisMatrix({ tier: 'A', entry: 'good' }).verdict, 'proceed');
assert.equal(applyTwoAxisMatrix({ tier: 'C', entry: 'good' }).verdict, 'proceed');
});
test('matrix messages: neutral, no trade verbs (ADR-0007)', () => {
const all = [
applyTwoAxisMatrix({ tier: 'A', entry: 'bad' }).message,
applyTwoAxisMatrix({ tier: 'B', entry: 'bad' }).message,
applyTwoAxisMatrix({ tier: 'A', entry: 'good' }).message,
].join(' ').toLowerCase();
assert.ok(!/\b(buy|sell|you should|add to your)\b/.test(all), 'no trade verbs');
});
+123
View File
@@ -0,0 +1,123 @@
// Investor Flow — Conviction Tier unlock gate (Slice 11).
//
// Reads per-tier win-rate / profitable-count from the journal `trades` table to
// decide whether a complexity-tier elevation has been earned.
// Pure/cache-deterministic over a DB handle (read-only). ADR-0007: neutral.
import type { DatabaseSync } from 'node:sqlite';
/** A closed trade with outcome used to compute per-tier win-rate. */
export interface ClosedTrade {
tier: 'A_STAR' | 'A' | 'B' | 'C';
profitable: boolean;
}
/** Unlock thresholds (CONTEXT.md defaults, tunable per-user). */
export interface UnlockThresholds {
profitableBToUnlockA: number; // default 20
profitableAToUnlockAStar: number; // default 10
}
export const DEFAULT_THRESHOLDS: UnlockThresholds = {
profitableBToUnlockA: 20,
profitableAToUnlockAStar: 10,
};
/** Raised when an attempt to use a locked tier is made. */
export class UnlockError extends Error {
readonly requirement: string;
constructor(requirement: string, message: string) {
super(message);
this.name = 'UnlockError';
this.requirement = requirement;
}
}
/** Per-tier aggregated stats. */
export interface TierStats {
tier: 'A_STAR' | 'A' | 'B' | 'C';
total: number;
profitable: number;
winRate: number;
}
/** Read per-tier stats from closed trades. Pure over the DB (read-only). */
export function computeTierStats(db: DatabaseSync, userId: string): TierStats[] {
const rows = db
.prepare("SELECT tier, status FROM trades WHERE owner_id=? AND status='closed'")
.all(userId) as Array<{ tier: string; status: string; entry_price?: number | null; position_size?: number | null }>;
// A trade is "profitable" if recorded with a positive outcome. The schema does
// not yet carry a realized PnL column; we infer profitability from a
// convention: a future `meta`/`outcome` column. For now, if absent, count by
// presence of a targets hit via the `targets` JSON — but to keep this pure and
// deterministic without a schema migration, we treat profitability as a
// caller-provided signal via a separate column when it exists.
// See isProfit() below for the heuristic.
const byTier: Record<string, { total: number; profitable: number }> = {};
for (const r of rows) {
const t = (r.tier ?? 'B') as 'A_STAR' | 'A' | 'B' | 'C';
byTier[t] ??= { total: 0, profitable: 0 };
byTier[t].total += 1;
// Profitability heuristic: a closed trade with a positive realized gain.
// Falls back gracefully if no outcome column exists (counts total only).
if (isProfit(db, userId, r as never)) byTier[t].profitable += 1;
}
return (['A_STAR', 'A', 'B', 'C'] as const).map((tier) => {
const s = byTier[tier] ?? { total: 0, profitable: 0 };
return { tier, total: s.total, profitable: s.profitable, winRate: s.total > 0 ? s.profitable / s.total : 0 };
});
}
/** Deterministic profitability probe — returns false when the outcome column
* is absent (no false positives; unlock stays conservative). */
function isProfit(_db: DatabaseSync, _userId: string, _row: never): boolean {
// Reserved: once a `realized_pnl`/`outcome` column is added to `trades`,
// inspect it here. Today there is no such column, so we conservatively return
// false — unlocking requires explicit operator/user evidence recorded in
// `sizing_unlocks`. This keeps the gate fail-closed until the journal records
// realized PnL (planned in the alerts/thesis slices).
return false;
}
/** Has the user unlocked A_STAR? Reads `sizing_unlocks` (operator-evidenced). */
export function isAStarUnlocked(db: DatabaseSync, userId: string): boolean {
const row = db
.prepare("SELECT 1 FROM sizing_unlocks WHERE user_id=? AND unlock='tier_a_star' LIMIT 1")
.get(userId);
return !!row;
}
/** Has the user unlocked tier A? */
export function isTierAUnlocked(db: DatabaseSync, userId: string): boolean {
const row = db
.prepare("SELECT 1 FROM sizing_unlocks WHERE user_id=? AND unlock='tier_a' LIMIT 1")
.get(userId);
return !!row;
}
/** Require tier A to be unlocked, else raise UnlockError with the remaining requirement. */
export function requireTierAUnlocked(db: DatabaseSync, userId: string, thresholds: UnlockThresholds = DEFAULT_THRESHOLDS): void {
if (isTierAUnlocked(db, userId)) return;
const stats = computeTierStats(db, userId).find((s) => s.tier === 'B');
const have = stats?.profitable ?? 0;
const need = thresholds.profitableBToUnlockA;
if (have < need) {
throw new UnlockError(
`Profitable B trades: ${have}/${need}`,
`Tier A is locked. Earn ${need - have} more profitable B trade(s) to unlock A.`,
);
}
}
/** Require A_STAR unlocked, else raise UnlockError with the remaining requirement. */
export function requireAStarUnlocked(db: DatabaseSync, userId: string, thresholds: UnlockThresholds = DEFAULT_THRESHOLDS): void {
if (isAStarUnlocked(db, userId)) return;
const stats = computeTierStats(db, userId).find((s) => s.tier === 'A');
const have = stats?.profitable ?? 0;
const need = thresholds.profitableAToUnlockAStar;
if (have < need) {
throw new UnlockError(
`Profitable A trades: ${have}/${need}`,
`A_STAR is locked. Earn ${need - have} more profitable A trade(s) to unlock A_STAR.`,
);
}
}
+66
View File
@@ -0,0 +1,66 @@
// Investor Flow — Two-Axis Matrix (Slice 11): conviction × entry enforcement.
//
// Enforced on BOTH UI and server. High conviction × Bad entry = WAIT (block with
// a written-reason override); Low × Bad = decline; the proceed states pass.
// ADR-0007: "consider" framing, never instructions.
import type { ConvictionTier } from './SizingEngine.ts';
/** Conviction strength (derived from tier). */
export type Conviction = 'high' | 'low';
/** Entry quality (derived from TA/setup). */
export type EntryQuality = 'good' | 'bad';
/** The matrix verdict. */
export type MatrixVerdict = 'proceed' | 'wait' | 'decline';
export interface TwoAxisInput {
tier: ConvictionTier;
entry: EntryQuality;
/** A written rationale to override a WAIT verdict. Empty = no override. */
overrideReason?: string;
}
export interface TwoAxisResult {
verdict: MatrixVerdict;
canOverride: boolean;
overrideRecorded: boolean;
message: string;
}
/** Map a ConvictionTier to high/low conviction (A_STAR, A = high; B, C = low). */
export function convictionFromTier(tier: ConvictionTier): Conviction {
return tier === 'A_STAR' || tier === 'A' ? 'high' : 'low';
}
/** Apply the Two-Axis matrix. Pure. */
export function applyTwoAxisMatrix(input: TwoAxisInput): TwoAxisResult {
const conviction = convictionFromTier(input.tier);
const wait = conviction === 'high' && input.entry === 'bad';
const decline = conviction === 'low' && input.entry === 'bad';
const proceed = !wait && !decline;
if (decline) {
return {
verdict: 'decline', canOverride: false, overrideRecorded: false,
message: 'Low conviction with a poor entry is not a favorable setup. Consider waiting for a better entry or a stronger thesis.',
};
}
if (wait) {
const hasReason = (input.overrideReason ?? '').trim().length > 0;
return {
verdict: hasReason ? 'proceed' : 'wait',
canOverride: true,
overrideRecorded: hasReason,
message: hasReason
? 'Overriding the WAIT with a written reason recorded. Consider whether this strong conviction justifies the weaker entry — a trade-off to think through.'
: 'High conviction but a poor entry. WAIT: consider a better entry before proceeding, or record a written reason to override.',
};
}
return {
verdict: 'proceed', canOverride: false, overrideRecorded: false,
message: 'Conviction and entry both favor proceeding. Verify your stop and risk% before creating the trade.',
};
}