Add capital efficiency risk simulation

This commit is contained in:
defiQUG
2026-04-27 11:26:55 -07:00
parent 1cf845cb3a
commit 818e864d2b
15 changed files with 1111 additions and 13 deletions

View File

@@ -80,8 +80,32 @@ node scripts/run-scenario.cjs hub_only_11
node scripts/run-scenario.cjs --scenario full_quote_1_56_137
node scripts/run-scenario.cjs bridge_shock_137_56
node scripts/run-scenario.cjs micro_support_usd_wrappers_1_56_137
node scripts/run-scenario.cjs crash_40pct_external_asset
node scripts/run-scenario.cjs high_vol_sigma_spike
node scripts/run-scenario.cjs bank_run_redemption_spike
node scripts/run-scenario.cjs --optimizer leverage_sweep_1x_to_4x
```
**Output:** JSON scorecard including: `capture_mean`, `churn_mean`, `drain_half_life_epochs`, `path_concentration_index`; `intervention_cost_total` / `intervention_cost_inject_total` / `intervention_cost_withdraw_total` / `intervention_cost_by_chain` / `intervention_cost_per_1M_volume`; `micro_trade_count` / `micro_trade_volume_total` / `micro_trade_gas_cost_total`; `peak_deviation_bps` (post-arb), `peak_deviation_bps_pre_arb`, `peak_deviation_bps_post_arb`, `peak_deviation_bps_post_bot`; `arb_volume_total`, `arb_profit_total` (execution-based, not mid). See [docs/12-sim-scorecard.md](../docs/12-sim-scorecard.md) and [config/scorecard-schema.json](../config/scorecard-schema.json).
When `capitalEfficiency.enabled = true`, output also includes Monte Carlo capital-risk metrics: `roi_mean`, `roi_p05`, `roi_p95`, `pnl_distribution`, `max_drawdown_p95`, `liquidation_probability`, `peg_deviation_frequency`, `external_liquidity_floor_violations`, `volatility_throttle_events`, and `spread_adjustment_events`. Optimizer mode emits ranked parameter candidates and never treats leverage above the configured hard ceiling as deployable.
**Orderflow:** Trade sizes use `distribution: "uniform"` by default. Scenario schema supports `lognormal` / `pareto` for skewed (many small + occasional whale) flows; implement in `sampleTrade()` when needed.
---
## validate-capital-efficiency.cjs
Runs CI-style checks for the simulation-only capital efficiency overlay:
- JSON parse for policy, schemas, and scenarios
- Baseline scenario remains capital-overlay free
- Stress scenarios emit capital risk fields
- Deterministic repeat check
- Optimizer deployable candidates respect `maxDeployableLeverage`
**Run:**
```bash
node scripts/validate-capital-efficiency.cjs
```

View File

@@ -48,12 +48,32 @@ function rng() {
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function normalSample() {
const u1 = Math.max(rng(), 1e-12);
const u2 = Math.max(rng(), 1e-12);
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
function hashScenarioName(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (Math.imul(31, h) + name.charCodeAt(i)) >>> 0;
return h;
}
function percentile(values, p) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[idx];
}
function round2(n) {
return Math.round((Number(n) || 0) * 100) / 100;
}
function round4(n) {
return Math.round((Number(n) || 0) * 10000) / 10000;
}
function loadJson(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
@@ -61,6 +81,9 @@ function loadJson(p) {
function loadConfigs(scenario) {
const simulationParams = loadJson(path.join(CONFIG_DIR, 'simulation-params.json'));
const tokenMap = loadJson(path.join(CONFIG_DIR, 'token-map.json'));
const capitalEfficiencyPolicy = fs.existsSync(path.join(CONFIG_DIR, 'capital-efficiency-policy.json'))
? loadJson(path.join(CONFIG_DIR, 'capital-efficiency-policy.json'))
: {};
const routingControls = fs.existsSync(path.join(CONFIG_DIR, 'routing-controls.json'))
? loadJson(path.join(CONFIG_DIR, 'routing-controls.json'))
: { defaults: { publicRoutingEnabled: true, maxTradeSizeUnits: null } };
@@ -72,14 +95,25 @@ function loadConfigs(scenario) {
const poolMatrix = fs.existsSync(path.join(CONFIG_DIR, 'pool-matrix.json'))
? loadJson(path.join(CONFIG_DIR, 'pool-matrix.json'))
: { chains: {} };
return { simulationParams, tokenMap, routingControls, deploymentStatus, poolMatrix };
return { simulationParams, tokenMap, capitalEfficiencyPolicy, routingControls, deploymentStatus, poolMatrix };
}
function buildGraph(scenario, configs) {
const { simulationParams, tokenMap, poolMatrix } = configs;
const { simulationParams, tokenMap, poolMatrix, deploymentStatus } = configs;
const chains = simulationParams.chains || {};
const publicChains = tokenMap.publicChains || {};
const cwTokens = scenario.tokensIncluded || tokenMap.bridgedSymbols || ['cWUSDT', 'cWUSDC', 'cWAUSDT', 'cWEURC', 'cWEURT', 'cWUSDW'];
const deployedChains = deploymentStatus?.chains || {};
const deployedTokenSet = new Set();
if (scenario.graphMode === 'deployed') {
for (const chain of Object.values(deployedChains)) {
for (const t of Object.keys(chain.cwTokens || {})) deployedTokenSet.add(t);
for (const p of [...(chain.pmmPools || []), ...(chain.pmmPoolsVolatile || []), ...(chain.gasPmmPools || [])]) {
if (p.base) deployedTokenSet.add(p.base);
if (p.tokenIn) deployedTokenSet.add(p.tokenIn);
}
}
}
const cwTokens = scenario.tokensIncluded || (deployedTokenSet.size ? Array.from(deployedTokenSet) : tokenMap.bridgedSymbols) || ['cWUSDT', 'cWUSDC', 'cWAUSDT', 'cWEURC', 'cWEURT', 'cWUSDW'];
const chainIds = scenario.chainsIncluded || Object.keys(chains);
const topology = scenario.topology || 'hub';
const fullQuoteChains = scenario.fullQuoteChains
@@ -92,13 +126,57 @@ function buildGraph(scenario, configs) {
for (const chainId of chainIds) {
const chainConf = chains[chainId] || {};
const deployedChain = scenario.graphMode === 'deployed' ? deployedChains[chainId] : null;
const pub = publicChains[chainId] || {};
const hubStable = chainConf.hubStable || pub.hubStable || 'USDC';
const anchorStables = pub.anchorStables || [hubStable];
const anchorStables = deployedChain
? Object.keys(deployedChain.anchorAddresses || {})
: (pub.anchorStables || [hubStable]);
for (const t of cwTokens) nodes.add(`${chainId}:${t}`);
for (const a of anchorStables) nodes.add(`${chainId}:${a}`);
if (deployedChain) {
const deployedPools = [
...(deployedChain.pmmPools || []),
...(deployedChain.pmmPoolsVolatile || []),
...(deployedChain.gasPmmPools || []),
];
for (const pool of deployedPools) {
const base = pool.base || pool.tokenIn;
const quote = pool.quote || pool.tokenOut;
if (!base || !quote) continue;
nodes.add(`${chainId}:${base}`);
nodes.add(`${chainId}:${quote}`);
pmmEdges.push({
type: 'pmm',
chainId,
base,
quote,
key: `${chainId}:${base}:${quote}`,
k: pool.k,
feeBps: pool.feeBps,
inventoryTargetUnits: pool.inventoryTargetUnits,
depthD0Units: pool.depthD0Units,
role: pool.role || 'public_routing',
publicRoutingEnabled: pool.publicRoutingEnabled !== false,
poolAddress: pool.poolAddress,
});
}
for (let i = 0; i < anchorStables.length; i++) {
for (let j = i + 1; j < anchorStables.length; j++) {
ammEdges.push({
type: 'amm',
chainId,
tokenA: anchorStables[i],
tokenB: anchorStables[j],
key: `${chainId}:amm:${anchorStables[i]}:${anchorStables[j]}`,
});
}
}
continue;
}
const poolChains = poolMatrix.chains || {};
const pc = poolChains[chainId] || {};
const poolsFirst = pc.poolsFirst || [];
@@ -182,11 +260,11 @@ function buildPMMState(graph, configs) {
for (const e of graph.pmmEdges) {
const chainConf = graph.chains[e.chainId] || {};
const isEur = CW_EUR.includes(e.base);
const k = isEur ? (eurDefaults.k ?? 0.2) : (chainConf.k ?? defaultPmm.k ?? 0.1);
const feeBps = isEur ? (eurDefaults.feeBps ?? 35) : (chainConf.feeBps ?? defaultPmm.feeBps ?? 25);
const k = e.k != null ? Number(e.k) : (isEur ? (eurDefaults.k ?? 0.2) : (chainConf.k ?? defaultPmm.k ?? 0.1));
const feeBps = e.feeBps != null ? Number(e.feeBps) : (isEur ? (eurDefaults.feeBps ?? 35) : (chainConf.feeBps ?? defaultPmm.feeBps ?? 25));
const fee = feeBps / 10000;
const invTarget = parseInt(chainConf.inventoryTargetUnits || defaultPmm.inventoryTargetUnits || '1000000', 10);
const d0 = parseInt(chainConf.depthD0Units || defaultPmm.depthD0Units || '500000', 10);
const invTarget = parseInt(e.inventoryTargetUnits || chainConf.inventoryTargetUnits || defaultPmm.inventoryTargetUnits || '1000000', 10);
const d0 = parseInt(e.depthD0Units || chainConf.depthD0Units || defaultPmm.depthD0Units || '500000', 10);
const eurP = params.eurUsd != null ? Number(params.eurUsd) : (params.eurPegMultiplier != null && params.eurPegMultiplier !== 1 ? Number(params.eurPegMultiplier) : 1.1);
const P = isEur ? eurP : 1;
@@ -686,6 +764,7 @@ function getCandidatePaths(chainId, fromToken, toToken, graph, state, probeSize,
paths = paths.filter((p) => {
const first = p[0];
if (first.type === 'pmm' && publicEnabled === false) return false;
if (first.type === 'pmm' && first.publicRoutingEnabled === false) return false;
return true;
});
@@ -883,7 +962,7 @@ function runEpoch(scenario, graph, state, configs, epochIndex) {
const totalPathVol = Object.values(pathShares).reduce((a, b) => a + b, 0);
if (totalPathVol > 0) {
pmmVolume = Object.entries(pathShares)
.filter(([pk]) => pk.includes(':cW'))
.filter(([pk]) => pk.split('|').some((part) => !part.includes(':amm:')))
.reduce((a, [, v]) => a + v, 0);
}
@@ -1023,7 +1102,7 @@ function computeScorecard(scenario, scenarioName, graph, initialState, epochResu
return {
scenario: scenario.scenario || scenarioName,
runId: `run-${Date.now()}`,
runId: `run-${scenario.seed != null ? Number(scenario.seed) : hashScenarioName(scenarioName)}`,
capture_mean: Math.min(1, Math.max(0, captureMean)),
capture_p95: Math.min(1, captureMean * 1.2),
churn_mean: churnMeanNorm,
@@ -1052,9 +1131,334 @@ function computeScorecard(scenario, scenarioName, graph, initialState, epochResu
};
}
function mergeCapitalConfig(policy, scenarioConfig = {}, overrides = {}) {
const defaults = policy.defaults || {};
const risk = { ...(policy.risk || {}), ...(scenarioConfig.risk || {}), ...(overrides.risk || {}) };
const treasury = { ...(policy.treasury || {}), ...(scenarioConfig.treasury || {}), ...(overrides.treasury || {}) };
const volatility = { ...(policy.volatilityProcess || {}), ...(scenarioConfig.volatilityProcess || {}), ...(overrides.volatilityProcess || {}) };
const peg = { ...(policy.pegDynamics || {}), ...(scenarioConfig.pegDynamics || {}), ...(overrides.pegDynamics || {}) };
const stress = { ...(scenarioConfig.stress || {}), ...(overrides.stress || {}) };
return {
enabled: scenarioConfig.enabled === true || overrides.enabled === true,
paths: Number(overrides.paths ?? scenarioConfig.paths ?? defaults.paths ?? 1000),
epochs: Number(overrides.epochs ?? scenarioConfig.epochs ?? defaults.epochs ?? 365),
seed: Number(overrides.seed ?? scenarioConfig.seed ?? defaults.seed ?? 138001),
initialCapital: Number(overrides.initialCapital ?? scenarioConfig.initialCapital ?? defaults.initialCapital ?? 1000000),
alpha: Number(overrides.alpha ?? scenarioConfig.alpha ?? defaults.alpha ?? 0.7),
leverage: Number(overrides.leverage ?? scenarioConfig.leverage ?? defaults.leverage ?? 2),
spreadBps: Number(overrides.spreadBps ?? scenarioConfig.spreadBps ?? defaults.spreadBps ?? 35),
volumeEfficiency: Number(overrides.volumeEfficiency ?? scenarioConfig.volumeEfficiency ?? defaults.volumeEfficiency ?? 2),
pmmK: Number(overrides.pmmK ?? scenarioConfig.pmmK ?? defaults.pmmK ?? 0.1),
liquidityTargetUnits: Number(overrides.liquidityTargetUnits ?? scenarioConfig.liquidityTargetUnits ?? defaults.liquidityTargetUnits ?? 1000000),
treasury,
volatility,
peg,
risk,
stress,
};
}
function getStressForEpoch(stress, epoch, initialCapital) {
const out = {
externalAssetReturn: 0,
volatilityAdd: 0,
redemptionFraction: 0,
imbalanceAdd: 0,
liquidationLossFraction: 0,
};
const events = Array.isArray(stress.events) ? stress.events : [];
for (const ev of events) {
const at = Number(ev.epoch ?? 0);
const duration = Math.max(1, Number(ev.durationEpochs ?? 1));
if (epoch < at || epoch >= at + duration) continue;
if (ev.type === 'crash') out.externalAssetReturn += Number(ev.externalAssetReturn ?? -0.4) / duration;
if (ev.type === 'high_volatility') out.volatilityAdd += Number(ev.sigmaAdd ?? 0.05);
if (ev.type === 'bank_run') out.redemptionFraction += Number(ev.redemptionFraction ?? 0.1) / duration;
if (ev.type === 'bridge_shock') out.imbalanceAdd += Number(ev.imbalanceAdd ?? 0.05);
if (ev.type === 'liquidation_loss') out.liquidationLossFraction += Number(ev.lossFraction ?? 0.05);
}
if (stress.preset === 'crash_40pct_external_asset' && epoch === Number(stress.epoch ?? 24)) {
out.externalAssetReturn -= 0.4;
}
if (stress.preset === 'high_vol_sigma_spike') {
const at = Number(stress.epoch ?? 24);
const duration = Math.max(1, Number(stress.durationEpochs ?? 24));
if (epoch >= at && epoch < at + duration) out.volatilityAdd += Number(stress.sigmaAdd ?? 0.08);
}
if (stress.preset === 'bank_run_redemption_spike') {
const at = Number(stress.epoch ?? 24);
const duration = Math.max(1, Number(stress.durationEpochs ?? 12));
if (epoch >= at && epoch < at + duration) out.redemptionFraction += Number(stress.redemptionFraction ?? 0.25) / duration;
}
if (stress.preset === 'bridge_shock') {
const at = Number(stress.epoch ?? 24);
const duration = Math.max(1, Number(stress.durationEpochs ?? 24));
if (epoch >= at && epoch < at + duration) out.imbalanceAdd += Number(stress.imbalanceAdd ?? 0.05);
}
if (initialCapital <= 0) out.redemptionFraction = 0;
return out;
}
function simulateCapitalPath(config, baselineScorecard) {
const risk = config.risk || {};
const treasury = config.treasury || {};
const vol = config.volatility || {};
const peg = config.peg || {};
const spreadRate = config.spreadBps / 10000;
const minExternalLiquidityPct = Number(risk.minExternalLiquidityPct ?? 0.2);
const maxLtv = Number(risk.maxLtvBps ?? 6500) / 10000;
const hardMaxLtv = Number(risk.hardMaxLtvBps ?? 7500) / 10000;
const hardMaxLeverage = Number(risk.hardMaxLeverage ?? 4);
const liquidationLtv = Math.min(hardMaxLtv, Number(risk.liquidationLtvBps ?? 8000) / 10000);
const sigmaCrit = Number(risk.sigmaCrit ?? 0.08);
const throttleLeverageMultiplier = Number(risk.throttleLeverageMultiplier ?? 0.7);
const throttleAlphaMultiplier = Number(risk.throttleAlphaMultiplier ?? 0.9);
const pegCircuitBreakerBps = Number(risk.pegCircuitBreakerBps ?? 200);
const volDrag = Number(treasury.volatilityDragLambda ?? 0.35);
const yieldRate = Number(treasury.yieldRatePerEpoch ?? 0.00035);
const mmScale = Number(treasury.marketMakingScale ?? 0.04);
const liquidationLossFraction = Number(risk.liquidationLossFraction ?? 0.08);
const redemptionFeeBps = Number(risk.bankRunRedemptionFeeBps ?? 100);
let T = config.initialCapital;
let highWater = T;
let maxDrawdown = 0;
let sigma = Number(vol.sigma0 ?? vol.sigmaBar ?? 0.03);
let P = Number(peg.p0 ?? 1);
let liquidated = false;
let pegDeviationEpochs = 0;
let externalLiquidityFloorViolations = 0;
let volatilityThrottleEvents = 0;
let spreadAdjustmentEvents = 0;
let liquidationEvents = 0;
let totalPnl = 0;
const baselineInterventionDrag = Math.min(0.002, Number(baselineScorecard.intervention_cost_per_1M_volume || 0) / 1e9);
const baselinePegPressure = Math.min(0.1, Number(baselineScorecard.peak_deviation_bps || 0) / 10000);
for (let e = 0; e < config.epochs; e++) {
if (liquidated || T <= 0) break;
const stress = getStressForEpoch(config.stress || {}, e, T);
const z = normalSample();
sigma = Math.max(0, sigma + Number(vol.kappa ?? 0.08) * (Number(vol.sigmaBar ?? 0.03) - sigma) + Number(vol.eta ?? 0.01) * z + stress.volatilityAdd);
let effectiveAlpha = config.alpha;
let effectiveLeverage = config.leverage;
let effectiveSpreadBps = config.spreadBps;
if (sigma > sigmaCrit) {
effectiveAlpha *= throttleAlphaMultiplier;
effectiveLeverage = Math.max(1, effectiveLeverage * throttleLeverageMultiplier);
effectiveSpreadBps = Math.min(Number(risk.maxSpreadBps ?? 100), Math.max(effectiveSpreadBps, Number(risk.throttleSpreadBps ?? 50)));
volatilityThrottleEvents += 1;
spreadAdjustmentEvents += 1;
}
if (effectiveLeverage > hardMaxLeverage) {
liquidated = true;
liquidationEvents += 1;
T = Math.max(0, T * (1 - liquidationLossFraction));
maxDrawdown = Math.max(maxDrawdown, highWater > 0 ? (highWater - T) / highWater : 0);
break;
}
const externalLiquidity = (1 - effectiveAlpha) * T;
if (externalLiquidity < minExternalLiquidityPct * T) {
externalLiquidityFloorViolations += 1;
effectiveAlpha = Math.min(effectiveAlpha, 1 - minExternalLiquidityPct);
spreadAdjustmentEvents += 1;
}
const grossLeveredExposure = effectiveLeverage * effectiveAlpha * T;
const debt = Math.max(0, (effectiveLeverage - 1) * effectiveAlpha * T);
const collateralShock = 1 + stress.externalAssetReturn - sigma * Number(risk.collateralVolatilityHaircut ?? 0.5);
const collateral = Math.max(0, grossLeveredExposure * collateralShock);
const ltv = collateral > 0 ? debt / collateral : 1;
if (ltv > liquidationLtv || ltv > maxLtv * Number(risk.maxLtvBreachMultiplier ?? 1.2)) {
liquidated = true;
liquidationEvents += 1;
T = Math.max(0, T * (1 - liquidationLossFraction - stress.liquidationLossFraction));
maxDrawdown = Math.max(maxDrawdown, highWater > 0 ? (highWater - T) / highWater : 0);
break;
}
const imbalance = Number(peg.imbalanceStd ?? 0.01) * normalSample() + stress.imbalanceAdd + baselinePegPressure;
const arbFlow = Number(peg.arbLiquidityCoefficient ?? 0.65) * (P - 1);
P = P + Number(peg.beta ?? 0.25) * imbalance - arbFlow;
const pegDeviationBps = Math.abs(P - 1) * 10000;
if (pegDeviationBps > pegCircuitBreakerBps) {
pegDeviationEpochs += 1;
effectiveSpreadBps = Math.min(Number(risk.maxSpreadBps ?? 100), Math.max(effectiveSpreadBps, Number(risk.circuitBreakerSpreadBps ?? 100)));
spreadAdjustmentEvents += 1;
}
const sqrtAllocation = Math.sqrt(Math.max(0, effectiveAlpha * (1 - effectiveAlpha)));
const yieldComponent = yieldRate * effectiveAlpha * effectiveLeverage;
const mmComponent = (effectiveSpreadBps / 10000) * config.volumeEfficiency * sqrtAllocation * sigma * mmScale;
const volatilityDrag = volDrag * sigma * Math.max(0.25, effectiveLeverage - 0.5) / 365;
const redemptionDrag = stress.redemptionFraction * (1 - redemptionFeeBps / 10000);
const pnlRate = yieldComponent + mmComponent - volatilityDrag - baselineInterventionDrag - redemptionDrag;
const pnl = T * pnlRate;
totalPnl += pnl;
T = Math.max(0, T + pnl);
highWater = Math.max(highWater, T);
maxDrawdown = Math.max(maxDrawdown, highWater > 0 ? (highWater - T) / highWater : 0);
}
return {
endingCapital: T,
roi: config.initialCapital > 0 ? (T - config.initialCapital) / config.initialCapital : 0,
totalPnl,
maxDrawdown,
liquidated,
liquidationEvents,
pegDeviationEpochs,
externalLiquidityFloorViolations,
volatilityThrottleEvents,
spreadAdjustmentEvents,
};
}
function runCapitalMonteCarlo(scenario, scenarioName, configs, baselineScorecard, overrides = {}) {
const capitalConfig = mergeCapitalConfig(configs.capitalEfficiencyPolicy || {}, scenario.capitalEfficiency || {}, overrides);
if (!capitalConfig.enabled) return null;
const baseSeed = capitalConfig.seed != null ? Number(capitalConfig.seed) : hashScenarioName(`${scenarioName}:capital`);
const rois = [];
const pnls = [];
const drawdowns = [];
let liquidationCount = 0;
let pegDeviationEpochs = 0;
let externalLiquidityFloorViolations = 0;
let volatilityThrottleEvents = 0;
let spreadAdjustmentEvents = 0;
for (let p = 0; p < capitalConfig.paths; p++) {
seedRng((baseSeed + Math.imul(p + 1, 2654435761)) >>> 0);
const result = simulateCapitalPath(capitalConfig, baselineScorecard);
rois.push(result.roi);
pnls.push(result.totalPnl);
drawdowns.push(result.maxDrawdown);
if (result.liquidated) liquidationCount += 1;
pegDeviationEpochs += result.pegDeviationEpochs;
externalLiquidityFloorViolations += result.externalLiquidityFloorViolations;
volatilityThrottleEvents += result.volatilityThrottleEvents;
spreadAdjustmentEvents += result.spreadAdjustmentEvents;
}
const totalEpochs = Math.max(1, capitalConfig.paths * capitalConfig.epochs);
return {
capital_efficiency_enabled: true,
capital_efficiency_paths: capitalConfig.paths,
capital_efficiency_epochs: capitalConfig.epochs,
initial_capital: Math.round(capitalConfig.initialCapital),
alpha: round4(capitalConfig.alpha),
leverage: round4(capitalConfig.leverage),
spread_bps: round2(capitalConfig.spreadBps),
volume_efficiency: round4(capitalConfig.volumeEfficiency),
pmm_k: round4(capitalConfig.pmmK),
liquidity_target_units: Math.round(capitalConfig.liquidityTargetUnits),
roi_mean: round4(rois.reduce((a, b) => a + b, 0) / Math.max(1, rois.length)),
roi_p05: round4(percentile(rois, 5)),
roi_p95: round4(percentile(rois, 95)),
pnl_distribution: {
p05: round2(percentile(pnls, 5)),
p50: round2(percentile(pnls, 50)),
p95: round2(percentile(pnls, 95)),
},
max_drawdown_p95: round4(percentile(drawdowns, 95)),
liquidation_probability: round4(liquidationCount / Math.max(1, capitalConfig.paths)),
peg_deviation_frequency: round4(pegDeviationEpochs / totalEpochs),
external_liquidity_floor_violations: externalLiquidityFloorViolations,
volatility_throttle_events: volatilityThrottleEvents,
spread_adjustment_events: spreadAdjustmentEvents,
};
}
function passesCapitalGates(metrics, policy, overrides = {}) {
const gates = { ...(policy.gates || {}), ...(overrides.gates || {}) };
return (
metrics.leverage <= Number((policy.risk || {}).hardMaxLeverage ?? 4)
&& metrics.leverage <= Number(gates.maxDeployableLeverage ?? (policy.risk || {}).hardMaxLeverage ?? 4)
&& metrics.liquidation_probability <= Number(gates.maxLiquidationProbability ?? 0.02)
&& metrics.max_drawdown_p95 <= Number(gates.maxDrawdownP95 ?? 0.25)
&& metrics.peg_deviation_frequency <= Number(gates.maxPegDeviationFrequency ?? 0.05)
&& metrics.external_liquidity_floor_violations <= Number(gates.maxExternalLiquidityFloorViolations ?? 0)
);
}
function buildOptimizerGrid(policy, scenarioConfig) {
const opt = scenarioConfig.optimizer || {};
const grid = opt.grid || {};
const alphas = grid.alpha || [0.65, 0.7, 0.75, 0.8, 0.85];
const leverages = grid.leverage || [1, 2, 2.5, 3];
const spreads = grid.spreadBps || [30, 40, 50];
const pmmKs = grid.pmmK || [0.1, 0.15, 0.2];
const liquidityTargets = grid.liquidityTargetUnits || [500000, 1000000, 1500000];
const out = [];
for (const alpha of alphas) {
for (const leverage of leverages) {
for (const spreadBps of spreads) {
for (const pmmK of pmmKs) {
for (const liquidityTargetUnits of liquidityTargets) {
out.push({ alpha, leverage, spreadBps, pmmK, liquidityTargetUnits });
}
}
}
}
}
const limit = Number(opt.maxCandidates ?? (policy.optimizer || {}).maxCandidates ?? 250);
return out.slice(0, limit);
}
function capitalBand(initialCapital) {
if (initialCapital < 500000) return "sub_500k";
if (initialCapital < 2000000) return "500k_2m";
if (initialCapital < 10000000) return "2m_10m";
return "10m_plus";
}
function runOptimizer(scenario, scenarioName, configs, baselineScorecard) {
const policy = configs.capitalEfficiencyPolicy || {};
const scenarioConfig = scenario.capitalEfficiency || {};
const grid = buildOptimizerGrid(policy, scenarioConfig);
const candidates = [];
for (let i = 0; i < grid.length; i++) {
const overrides = {
enabled: true,
...grid[i],
paths: Number((scenarioConfig.optimizer || {}).paths ?? (policy.optimizer || {}).paths ?? 250),
seed: (Number(scenarioConfig.seed ?? (policy.defaults || {}).seed ?? 138001) + i * 9973) >>> 0,
};
const metrics = runCapitalMonteCarlo(scenario, scenarioName, configs, baselineScorecard, overrides);
const deployable = passesCapitalGates(metrics, policy, scenarioConfig.optimizer || {});
candidates.push({
rank_score: round4(metrics.roi_mean - metrics.liquidation_probability * 2 - metrics.max_drawdown_p95 * 0.5 - metrics.peg_deviation_frequency),
deployable,
...metrics,
});
}
candidates.sort((a, b) => {
if (a.deployable !== b.deployable) return a.deployable ? -1 : 1;
return b.rank_score - a.rank_score;
});
const top = candidates.slice(0, Number((scenarioConfig.optimizer || {}).topN ?? 10));
return {
scenario: `${scenario.scenario || scenarioName}:optimizer`,
runId: `optimizer-${scenarioConfig.seed != null ? Number(scenarioConfig.seed) : hashScenarioName(`${scenarioName}:optimizer`)}`,
optimizer_enabled: true,
capital_band: capitalBand(Number((scenarioConfig.initialCapital ?? (policy.defaults || {}).initialCapital) || 0)),
candidates_evaluated: candidates.length,
deployable_candidates: candidates.filter((c) => c.deployable).length,
top_candidates: top,
};
}
function main() {
const idx = process.argv.indexOf('--scenario');
const scenarioName = idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : process.argv[2] || 'hub_only_11';
const optimizerMode = process.argv.includes('--optimizer') || process.argv.includes('--optimize');
const positional = process.argv.slice(2).filter((arg, i, args) => {
if (arg.startsWith('--')) return false;
const prev = args[i - 1];
return prev !== '--scenario';
});
const scenarioName = idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : positional[0] || 'hub_only_11';
const scenarioPath = path.join(SCENARIOS_DIR, `${scenarioName}.json`);
if (!fs.existsSync(scenarioPath)) {
process.stderr.write(`Scenario not found: ${scenarioPath}\n`);
@@ -1078,7 +1482,13 @@ function main() {
}
const scorecard = computeScorecard(scenario, scenarioName, graph, initialState, epochResults);
console.log(JSON.stringify(scorecard, null, 2));
const capitalMetrics = runCapitalMonteCarlo(scenario, scenarioName, configs, scorecard);
const fullScorecard = capitalMetrics ? { ...scorecard, ...capitalMetrics } : scorecard;
if (optimizerMode || scenario.capitalEfficiency?.optimizer?.enabled === true) {
console.log(JSON.stringify(runOptimizer(scenario, scenarioName, configs, fullScorecard), null, 2));
return;
}
console.log(JSON.stringify(fullScorecard, null, 2));
}
main();

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* CI-style validation for the simulation-only capital efficiency overlay.
*
* Checks:
* - JSON configs and scenario files parse.
* - Baseline scenario stays capital-overlay free.
* - Capital stress scenarios emit required risk metrics.
* - Deterministic seeds produce byte-identical scorecards.
* - Optimizer never marks leverage above policy gates as deployable.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const CONFIG_DIR = path.join(ROOT, 'config');
const SCENARIOS_DIR = path.join(CONFIG_DIR, 'scenarios');
const RUNNER = path.join(ROOT, 'scripts', 'run-scenario.cjs');
const REQUIRED_CAPITAL_FIELDS = [
'roi_mean',
'roi_p05',
'roi_p95',
'pnl_distribution',
'max_drawdown_p95',
'liquidation_probability',
'peg_deviation_frequency',
'external_liquidity_floor_violations',
'volatility_throttle_events',
'spread_adjustment_events',
];
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function runScenario(name, extraArgs = []) {
const out = execFileSync(process.execPath, [RUNNER, ...extraArgs, name], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { raw: out, json: JSON.parse(out) };
}
function fail(msg) {
process.stderr.write(`[capital-efficiency] ${msg}\n`);
process.exit(1);
}
function assert(cond, msg) {
if (!cond) fail(msg);
}
function main() {
const files = [
path.join(CONFIG_DIR, 'scenario-schema.json'),
path.join(CONFIG_DIR, 'scorecard-schema.json'),
path.join(CONFIG_DIR, 'capital-efficiency-policy.json'),
...fs.readdirSync(SCENARIOS_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => path.join(SCENARIOS_DIR, f)),
];
for (const file of files) readJson(file);
const policy = readJson(path.join(CONFIG_DIR, 'capital-efficiency-policy.json'));
const maxDeployableLeverage = Number(policy.gates?.maxDeployableLeverage ?? policy.risk?.hardMaxLeverage ?? 4);
const baseline = runScenario('hub_only_11').json;
assert(baseline.capital_efficiency_enabled !== true, 'hub_only_11 unexpectedly enabled capital overlay');
for (const scenario of [
'chain138_deployed_capital_efficiency',
'crash_40pct_external_asset',
'high_vol_sigma_spike',
'bank_run_redemption_spike',
]) {
const scorecard = runScenario(scenario).json;
assert(scorecard.capital_efficiency_enabled === true, `${scenario} did not emit capital overlay`);
for (const field of REQUIRED_CAPITAL_FIELDS) {
assert(Object.prototype.hasOwnProperty.call(scorecard, field), `${scenario} missing ${field}`);
}
}
const a = runScenario('crash_40pct_external_asset').raw;
const b = runScenario('crash_40pct_external_asset').raw;
assert(a === b, 'deterministic seed repeat check failed for crash_40pct_external_asset');
const optimizer = runScenario('leverage_sweep_1x_to_4x', ['--optimizer']).json;
assert(optimizer.optimizer_enabled === true, 'optimizer did not emit optimizer payload');
for (const candidate of optimizer.top_candidates || []) {
if (candidate.deployable) {
assert(candidate.leverage <= maxDeployableLeverage, `deployable candidate exceeds maxDeployableLeverage: ${candidate.leverage}`);
}
}
process.stdout.write('capital efficiency validation OK\n');
}
main();