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:
@@ -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');
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user