Add capital efficiency risk simulation
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
|
||||
102
scripts/validate-capital-efficiency.cjs
Normal file
102
scripts/validate-capital-efficiency.cjs
Normal 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();
|
||||
Reference in New Issue
Block a user