1495 lines
57 KiB
JavaScript
1495 lines
57 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Routing simulator: graph, PMM state, path enumeration + waterfilling, arb step,
|
|
* bot step, bridge shock (optional). Emits real scorecard. PR#2: deterministic,
|
|
* arb + bot + intervention cost + peak deviation.
|
|
*
|
|
* Usage: node scripts/run-scenario.cjs hub_only_11
|
|
* node scripts/run-scenario.cjs --scenario full_quote_1_56_137
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const CONFIG_DIR = path.join(__dirname, '..', 'config');
|
|
const SCENARIOS_DIR = path.join(CONFIG_DIR, 'scenarios');
|
|
|
|
const CW_EUR = ['cWEURC', 'cWEURT'];
|
|
const PROBE_SIZE = 1000;
|
|
const K_PATHS = 5;
|
|
const CHUNK_FRACTION = 0.05;
|
|
const AMM_DEPTH_UNITS = 10e6;
|
|
const AMM_FEE_BPS = 5;
|
|
const MAX_ITER_INVERSE = 50;
|
|
const TOL_INVERSE = 1;
|
|
|
|
// PR#2: Arb
|
|
const DELTA_ARB_BPS = 40;
|
|
const ARB_ALPHA = 1.0;
|
|
const ARB_MAX_FRACTION_OF_TARGET = 0.1;
|
|
// Marginal probe: must be small (x << D) so implied price ≈ (1-fee)*P, not curvature-dominated. 0.01 gives true marginal.
|
|
const MARGINAL_EPS = 0.01;
|
|
const ARB_PROBE = MARGINAL_EPS;
|
|
const ARB_GAS_UNITS = 0;
|
|
|
|
// PR#2: Bot corridor (tighter 0.75/1.25 so bot acts before depleted depth; faster 0.40)
|
|
const LOW_WATER = 0.75;
|
|
const HIGH_WATER = 1.25;
|
|
const BOT_MAX_FRACTION_OF_TARGET = 0.4;
|
|
|
|
// Seeded RNG (mulberry32) for deterministic runs
|
|
let rngState = 0;
|
|
function seedRng(seed) {
|
|
rngState = (seed >>> 0) || 1;
|
|
}
|
|
function rng() {
|
|
let t = (rngState += 0x6d2b79f5);
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
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'));
|
|
}
|
|
|
|
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 } };
|
|
let deploymentStatus = null;
|
|
if (scenario.graphMode === 'deployed') {
|
|
const p = path.join(CONFIG_DIR, 'deployment-status.json');
|
|
if (fs.existsSync(p)) deploymentStatus = loadJson(p);
|
|
}
|
|
const poolMatrix = fs.existsSync(path.join(CONFIG_DIR, 'pool-matrix.json'))
|
|
? loadJson(path.join(CONFIG_DIR, 'pool-matrix.json'))
|
|
: { chains: {} };
|
|
return { simulationParams, tokenMap, capitalEfficiencyPolicy, routingControls, deploymentStatus, poolMatrix };
|
|
}
|
|
|
|
function buildGraph(scenario, configs) {
|
|
const { simulationParams, tokenMap, poolMatrix, deploymentStatus } = configs;
|
|
const chains = simulationParams.chains || {};
|
|
const publicChains = tokenMap.publicChains || {};
|
|
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
|
|
|| (simulationParams.scenarioDefaults?.topologySensitivity?.fullQuoteModelChains) || [];
|
|
|
|
const nodes = new Set();
|
|
const pmmEdges = [];
|
|
const ammEdges = [];
|
|
const bridgeEdges = [];
|
|
|
|
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 = 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 || [];
|
|
const poolsOptional = pc.poolsOptional || [];
|
|
const isFullQuote = topology === 'full_quote' || (topology === 'mixed' && fullQuoteChains.includes(chainId));
|
|
|
|
for (const cw of cwTokens) {
|
|
const hubPool = `${cw}/${hubStable}`;
|
|
const hasHub = poolsFirst.includes(hubPool) || true;
|
|
if (hasHub) {
|
|
pmmEdges.push({
|
|
type: 'pmm',
|
|
chainId,
|
|
base: cw,
|
|
quote: hubStable,
|
|
key: `${chainId}:${cw}:${hubStable}`,
|
|
});
|
|
}
|
|
if (isFullQuote) {
|
|
for (const pool of poolsOptional) {
|
|
const [base, quote] = pool.split('/');
|
|
if (base === cw && anchorStables.includes(quote)) {
|
|
pmmEdges.push({
|
|
type: 'pmm',
|
|
chainId,
|
|
base,
|
|
quote,
|
|
key: `${chainId}:${base}:${quote}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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]}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < chainIds.length; i++) {
|
|
for (let j = 0; j < chainIds.length; j++) {
|
|
if (i === j) continue;
|
|
for (const cw of cwTokens) {
|
|
bridgeEdges.push({
|
|
type: 'bridge',
|
|
fromChain: chainIds[i],
|
|
toChain: chainIds[j],
|
|
token: cw,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodes: Array.from(nodes),
|
|
pmmEdges,
|
|
ammEdges,
|
|
bridgeEdges,
|
|
chainIds,
|
|
cwTokens,
|
|
chains,
|
|
publicChains,
|
|
defaultPmm: simulationParams.defaultPmm || {},
|
|
};
|
|
}
|
|
|
|
function buildPMMState(graph, configs) {
|
|
const state = {};
|
|
const params = configs.simulationParams;
|
|
const defaultPmm = params.defaultPmm || {};
|
|
const eurDefaults = params.eurDefaults || { k: 0.2, feeBps: 35 };
|
|
|
|
for (const e of graph.pmmEdges) {
|
|
const chainConf = graph.chains[e.chainId] || {};
|
|
const isEur = CW_EUR.includes(e.base);
|
|
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(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;
|
|
|
|
state[e.key] = {
|
|
I_T: invTarget,
|
|
I_T_star: invTarget,
|
|
D_0: d0,
|
|
k,
|
|
fee,
|
|
feeBps,
|
|
P,
|
|
};
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function getD(stateKey, state) {
|
|
const s = state[stateKey];
|
|
if (!s) return 0;
|
|
const ratio = s.I_T / s.I_T_star;
|
|
return s.D_0 * Math.min(1, ratio);
|
|
}
|
|
|
|
function pmmSellT(stateKey, x, state) {
|
|
const s = state[stateKey];
|
|
if (!s || x <= 0) return { outputQ: 0, newState: state };
|
|
const D = getD(stateKey, state);
|
|
const term = D > 0 ? x - D * Math.log(1 + x / D) : x;
|
|
const y = (1 - s.fee) * s.P * (x - s.k * (x - term));
|
|
const newState = { ...state, [stateKey]: { ...s, I_T: s.I_T + x } };
|
|
return { outputQ: Math.max(0, y), newState };
|
|
}
|
|
|
|
function pmmBuyT(stateKey, Q, state) {
|
|
const s = state[stateKey];
|
|
if (!s || Q <= 0) return { outputT: 0, newState: state };
|
|
let lo = 0;
|
|
let hi = Q / ((1 - s.fee) * s.P) * 2;
|
|
for (let iter = 0; iter < MAX_ITER_INVERSE; iter++) {
|
|
const x = (lo + hi) / 2;
|
|
const D = getD(stateKey, state);
|
|
const term = D > 0 ? x - D * Math.log(1 + x / D) : x;
|
|
const y = (1 - s.fee) * s.P * (x - s.k * (x - term));
|
|
if (Math.abs(y - Q) < TOL_INVERSE) {
|
|
const newState = { ...state, [stateKey]: { ...s, I_T: Math.max(0, s.I_T - x) } };
|
|
return { outputT: x, newState };
|
|
}
|
|
if (y < Q) lo = x;
|
|
else hi = x;
|
|
}
|
|
const x = (lo + hi) / 2;
|
|
const newState = { ...state, [stateKey]: { ...s, I_T: Math.max(0, s.I_T - x) } };
|
|
return { outputT: x, newState };
|
|
}
|
|
|
|
// Implied price = analytical marginal at x=0: d/dx output = (1-fee)*P (invariant to D at limit). Avoids (1-k) bias from finite sell probe.
|
|
function getImpliedPriceAndDeviation(stateKey, state) {
|
|
const s = state[stateKey];
|
|
if (!s) return { pHat: 1, deviationBps: 0 };
|
|
const P = s.P;
|
|
const pHat = (1 - s.fee) * P;
|
|
const deviationBps = P > 0 ? ((pHat - P) / P) * 10000 : 0;
|
|
return { pHat, deviationBps };
|
|
}
|
|
|
|
function maxDeviationBpsOverPools(graph, state) {
|
|
let maxBps = 0;
|
|
for (const e of graph.pmmEdges) {
|
|
const { deviationBps } = getImpliedPriceAndDeviation(e.key, state);
|
|
if (Math.abs(deviationBps) > maxBps) maxBps = Math.abs(deviationBps);
|
|
}
|
|
return maxBps;
|
|
}
|
|
|
|
function getWorstPoolDiagnostic(graph, state) {
|
|
let maxBps = 0;
|
|
let worstKey = null;
|
|
for (const e of graph.pmmEdges) {
|
|
const { deviationBps } = getImpliedPriceAndDeviation(e.key, state);
|
|
if (Math.abs(deviationBps) > maxBps) {
|
|
maxBps = Math.abs(deviationBps);
|
|
worstKey = e.key;
|
|
}
|
|
}
|
|
if (!worstKey) return { key: null, deviation_bps: 0, I_T_ratio: null, D_effective: null, oracle_P: null, p_hat: null };
|
|
const s = state[worstKey];
|
|
const I_T_ratio = s && s.I_T_star > 0 ? s.I_T / s.I_T_star : null;
|
|
const D_effective = s ? getD(worstKey, state) : null;
|
|
const { pHat } = getImpliedPriceAndDeviation(worstKey, state);
|
|
return { key: worstKey, deviation_bps: maxBps, I_T_ratio, D_effective, oracle_P: s ? s.P : null, p_hat: pHat };
|
|
}
|
|
|
|
function arbProfitSellT(key, xArb, state) {
|
|
const { outputQ } = pmmSellT(key, xArb, state);
|
|
const s = state[key];
|
|
const P = s ? s.P : 1;
|
|
return outputQ - P * xArb - ARB_GAS_UNITS;
|
|
}
|
|
|
|
function qRequiredForBuyT(key, xT, state, tol = 1) {
|
|
const s = state[key];
|
|
if (!s) return 0;
|
|
let qLo = 0;
|
|
let qHi = xT * s.P * 2;
|
|
for (let i = 0; i < MAX_ITER_INVERSE; i++) {
|
|
const Q = (qLo + qHi) / 2;
|
|
const { outputT } = pmmBuyT(key, Q, state);
|
|
if (Math.abs(outputT - xT) < tol) return Q;
|
|
if (outputT < xT) qLo = Q;
|
|
else qHi = Q;
|
|
}
|
|
return (qLo + qHi) / 2;
|
|
}
|
|
|
|
function arbProfitBuyT(key, xArb, state) {
|
|
const s = state[key];
|
|
const P = s ? s.P : 1;
|
|
const Q = qRequiredForBuyT(key, xArb, state);
|
|
return P * xArb - Q - ARB_GAS_UNITS;
|
|
}
|
|
|
|
function runArbStep(graph, state, configs) {
|
|
let curState = state;
|
|
let arbVolumeTotal = 0;
|
|
let arbProfitTotal = 0;
|
|
let peakDeviationBps = 0;
|
|
|
|
for (const e of graph.pmmEdges) {
|
|
const key = e.key;
|
|
const s = curState[key];
|
|
if (!s || s.I_T_star == null) continue;
|
|
const { deviationBps } = getImpliedPriceAndDeviation(key, curState);
|
|
if (Math.abs(deviationBps) > peakDeviationBps) peakDeviationBps = Math.abs(deviationBps);
|
|
if (Math.abs(deviationBps) <= DELTA_ARB_BPS) continue;
|
|
|
|
const xMax = ARB_MAX_FRACTION_OF_TARGET * s.I_T_star;
|
|
const xArb = Math.min(xMax, ARB_ALPHA * Math.abs(deviationBps) / 10000 * s.I_T_star);
|
|
if (xArb < 1) continue;
|
|
|
|
let profit = 0;
|
|
if (deviationBps > 0) {
|
|
profit = arbProfitSellT(key, xArb, curState);
|
|
if (profit <= 0) continue;
|
|
const { outputQ, newState } = pmmSellT(key, xArb, curState);
|
|
curState = newState;
|
|
arbVolumeTotal += xArb;
|
|
arbProfitTotal += profit;
|
|
} else {
|
|
profit = arbProfitBuyT(key, xArb, curState);
|
|
if (profit <= 0) continue;
|
|
const Q = qRequiredForBuyT(key, xArb, curState);
|
|
const { outputT, newState } = pmmBuyT(key, Q, curState);
|
|
curState = newState;
|
|
arbVolumeTotal += outputT;
|
|
arbProfitTotal += profit;
|
|
}
|
|
}
|
|
|
|
return { state: curState, arbVolumeTotal, arbProfitTotal, peakDeviationBps };
|
|
}
|
|
|
|
function getBridgeRho(scenario, fromChain, toChain) {
|
|
const lat = scenario.latencyModel || {};
|
|
const blocks = lat.finalityBlocksPerChainPair && lat.finalityBlocksPerChainPair[`${fromChain}-${toChain}`];
|
|
const rhoPerBlock = lat.rhoPerBlockBps ?? 0.5;
|
|
return (blocks ?? 10) * rhoPerBlock / 10000;
|
|
}
|
|
|
|
function getMatchedQuoteForBase(base) {
|
|
if (base === 'cWUSDT') return 'USDT';
|
|
if (base === 'cWUSDC') return 'USDC';
|
|
return null;
|
|
}
|
|
|
|
function getMicroTradeCandidates(graph, policy) {
|
|
const tokenFilter = new Set(policy.tokens || []);
|
|
const quoteFilter = new Set(policy.quoteTokens || []);
|
|
let candidates = graph.pmmEdges.filter((e) => {
|
|
if (tokenFilter.size > 0 && !tokenFilter.has(e.base)) return false;
|
|
if (quoteFilter.size > 0 && !quoteFilter.has(e.quote)) return false;
|
|
return true;
|
|
});
|
|
|
|
if (policy.preferMatchedQuote) {
|
|
const perChainBase = new Map();
|
|
for (const edge of candidates) {
|
|
const key = `${edge.chainId}:${edge.base}`;
|
|
const preferredQuote = getMatchedQuoteForBase(edge.base);
|
|
const existing = perChainBase.get(key);
|
|
if (!existing) {
|
|
perChainBase.set(key, edge);
|
|
continue;
|
|
}
|
|
const score = edge.quote === preferredQuote ? 0 : 1;
|
|
const existingScore = existing.quote === preferredQuote ? 0 : 1;
|
|
if (
|
|
score < existingScore
|
|
|| (score === existingScore && edge.quote.localeCompare(existing.quote) < 0)
|
|
) {
|
|
perChainBase.set(key, edge);
|
|
}
|
|
}
|
|
candidates = Array.from(perChainBase.values());
|
|
}
|
|
|
|
candidates.sort((a, b) => (
|
|
a.chainId.localeCompare(b.chainId)
|
|
|| a.base.localeCompare(b.base)
|
|
|| a.quote.localeCompare(b.quote)
|
|
));
|
|
return candidates;
|
|
}
|
|
|
|
function resolveMicroTradeDirection(policy, poolState, epochIndex, tradeIndex) {
|
|
const mode = policy.mode || 'inventory_or_alternate';
|
|
const bandFrac = Number(policy.inventoryBandFraction ?? 0.05);
|
|
const lower = (1 - bandFrac) * poolState.I_T_star;
|
|
const upper = (1 + bandFrac) * poolState.I_T_star;
|
|
|
|
if (mode === 'inventory' || mode === 'inventory_or_alternate') {
|
|
if (poolState.I_T < lower) return 'sell_base';
|
|
if (poolState.I_T > upper) return 'buy_base';
|
|
if (mode === 'inventory') return null;
|
|
}
|
|
|
|
if (mode === 'alternate' || mode === 'inventory_or_alternate') {
|
|
return ((epochIndex + tradeIndex) % 2 === 0) ? 'buy_base' : 'sell_base';
|
|
}
|
|
if (mode === 'buy_base') return 'buy_base';
|
|
if (mode === 'sell_base') return 'sell_base';
|
|
return null;
|
|
}
|
|
|
|
function runMicroTradeSupportStep(graph, state, scenario, epochIndex) {
|
|
const policy = scenario.microTradePolicy || {};
|
|
if (policy.enabled === false || Object.keys(policy).length === 0) {
|
|
return {
|
|
state,
|
|
microTradeCount: 0,
|
|
microTradeBuyCount: 0,
|
|
microTradeSellCount: 0,
|
|
microTradeVolumeTotal: 0,
|
|
microTradeGasCostTotal: 0,
|
|
};
|
|
}
|
|
|
|
const requestedTrades = Math.max(0, parseInt(policy.tradesPerEpoch ?? 0, 10));
|
|
if (requestedTrades === 0) {
|
|
return {
|
|
state,
|
|
microTradeCount: 0,
|
|
microTradeBuyCount: 0,
|
|
microTradeSellCount: 0,
|
|
microTradeVolumeTotal: 0,
|
|
microTradeGasCostTotal: 0,
|
|
};
|
|
}
|
|
|
|
const candidates = getMicroTradeCandidates(graph, policy);
|
|
if (candidates.length === 0) {
|
|
return {
|
|
state,
|
|
microTradeCount: 0,
|
|
microTradeBuyCount: 0,
|
|
microTradeSellCount: 0,
|
|
microTradeVolumeTotal: 0,
|
|
microTradeGasCostTotal: 0,
|
|
};
|
|
}
|
|
|
|
const gasCostPerTrade = Number(policy.gasCostPerTradeUnits ?? 0);
|
|
const gasBudgetPerEpoch = Number(policy.gasBudgetPerEpochUnits ?? 0);
|
|
let tradesAllowed = requestedTrades;
|
|
if (gasCostPerTrade > 0 && gasBudgetPerEpoch > 0) {
|
|
tradesAllowed = Math.min(tradesAllowed, Math.floor(gasBudgetPerEpoch / gasCostPerTrade));
|
|
}
|
|
if (tradesAllowed <= 0) {
|
|
return {
|
|
state,
|
|
microTradeCount: 0,
|
|
microTradeBuyCount: 0,
|
|
microTradeSellCount: 0,
|
|
microTradeVolumeTotal: 0,
|
|
microTradeGasCostTotal: 0,
|
|
};
|
|
}
|
|
|
|
let curState = state;
|
|
let microTradeCount = 0;
|
|
let microTradeBuyCount = 0;
|
|
let microTradeSellCount = 0;
|
|
let microTradeVolumeTotal = 0;
|
|
let microTradeGasCostTotal = 0;
|
|
|
|
for (let i = 0; i < tradesAllowed; i++) {
|
|
const edge = candidates[(epochIndex * tradesAllowed + i) % candidates.length];
|
|
const poolState = curState[edge.key];
|
|
if (!poolState || poolState.I_T_star == null) continue;
|
|
|
|
const rawTradeSize = Number(policy.tradeSizeUnits ?? 0);
|
|
const maxFractionOfTarget = Number(policy.maxFractionOfTarget ?? 0.01);
|
|
const maxTradeSize = maxFractionOfTarget > 0 ? poolState.I_T_star * maxFractionOfTarget : rawTradeSize;
|
|
const tradeSize = Math.max(0, Math.min(rawTradeSize, maxTradeSize || rawTradeSize));
|
|
if (tradeSize <= 0) continue;
|
|
|
|
const direction = resolveMicroTradeDirection(policy, poolState, epochIndex, i);
|
|
if (!direction) continue;
|
|
|
|
if (direction === 'sell_base') {
|
|
const { newState } = pmmSellT(edge.key, tradeSize, curState);
|
|
curState = newState;
|
|
microTradeSellCount += 1;
|
|
microTradeVolumeTotal += tradeSize;
|
|
} else {
|
|
const quoteSpend = tradeSize * (poolState.P || 1);
|
|
const { outputT, newState } = pmmBuyT(edge.key, quoteSpend, curState);
|
|
curState = newState;
|
|
microTradeBuyCount += 1;
|
|
microTradeVolumeTotal += outputT;
|
|
}
|
|
|
|
microTradeCount += 1;
|
|
microTradeGasCostTotal += gasCostPerTrade;
|
|
}
|
|
|
|
return {
|
|
state: curState,
|
|
microTradeCount,
|
|
microTradeBuyCount,
|
|
microTradeSellCount,
|
|
microTradeVolumeTotal,
|
|
microTradeGasCostTotal,
|
|
};
|
|
}
|
|
|
|
function runBotStep(graph, state, scenario, configs) {
|
|
const chains = graph.chains;
|
|
let curState = state;
|
|
let interventionCostInject = 0;
|
|
let interventionCostWithdraw = 0;
|
|
const byChain = {};
|
|
|
|
for (const e of graph.pmmEdges) {
|
|
const key = e.key;
|
|
const s = curState[key];
|
|
if (!s || s.I_T_star == null) continue;
|
|
const I_T = s.I_T;
|
|
const I_T_star = s.I_T_star;
|
|
let u = 0;
|
|
if (I_T < LOW_WATER * I_T_star) {
|
|
u = Math.min(I_T_star - I_T, BOT_MAX_FRACTION_OF_TARGET * I_T_star);
|
|
} else if (I_T > HIGH_WATER * I_T_star) {
|
|
u = -Math.min(I_T - I_T_star, BOT_MAX_FRACTION_OF_TARGET * I_T_star);
|
|
}
|
|
if (u === 0) continue;
|
|
|
|
const chainId = e.chainId;
|
|
const chainConf = chains[chainId] || {};
|
|
const beta = Number(chainConf.bridgeBeta ?? 0.001);
|
|
const gamma = Number(chainConf.bridgeGammaUnits ?? 10);
|
|
const rho = getBridgeRho(scenario, chainId, '138');
|
|
const cost = Math.abs(u) * (beta + rho) + gamma;
|
|
const c = Number(cost);
|
|
if (u > 0) {
|
|
interventionCostInject += c;
|
|
} else {
|
|
interventionCostWithdraw += c;
|
|
}
|
|
if (!byChain[chainId]) byChain[chainId] = { inject: 0, withdraw: 0 };
|
|
if (u > 0) byChain[chainId].inject += c;
|
|
else byChain[chainId].withdraw += c;
|
|
|
|
const newS = { ...s, I_T: Math.max(0, s.I_T + u) };
|
|
curState = { ...curState, [key]: newS };
|
|
}
|
|
|
|
return {
|
|
state: curState,
|
|
interventionCostInject,
|
|
interventionCostWithdraw,
|
|
interventionCostByChain: byChain,
|
|
};
|
|
}
|
|
|
|
function ammOutput(inputUnits, feeBps = AMM_FEE_BPS) {
|
|
return inputUnits * (1 - feeBps / 10000);
|
|
}
|
|
|
|
function pathCostSameChain(path, graph, state, fromToken, toToken, amount) {
|
|
if (path.length === 1) {
|
|
const e = path[0];
|
|
if (e.type === 'pmm') {
|
|
const key = e.key;
|
|
const s = state[key];
|
|
if (!s) return { cost: 1e9, output: 0 };
|
|
if (e.base === fromToken && e.quote === toToken) {
|
|
const { outputQ } = pmmSellT(key, amount, state);
|
|
return { cost: 1 - outputQ / amount, output: outputQ };
|
|
}
|
|
if (e.base === toToken && e.quote === fromToken) {
|
|
const { outputT } = pmmBuyT(key, amount, state);
|
|
return { cost: 1 - outputT / amount, output: outputT };
|
|
}
|
|
}
|
|
if (e.type === 'amm') {
|
|
const out = ammOutput(amount);
|
|
return { cost: 1 - out / amount, output: out };
|
|
}
|
|
}
|
|
if (path.length === 2) {
|
|
const [e1, e2] = path;
|
|
let out1 = amount;
|
|
let curState = state;
|
|
if (e1.type === 'pmm') {
|
|
const key = e1.key;
|
|
if (e1.base === fromToken && e1.quote !== toToken) {
|
|
const r = pmmSellT(key, out1, curState);
|
|
out1 = r.outputQ;
|
|
curState = r.newState;
|
|
} else if (e1.quote === fromToken && e1.base !== toToken) {
|
|
const r = pmmBuyT(key, out1, curState);
|
|
out1 = r.outputT;
|
|
curState = r.newState;
|
|
}
|
|
} else if (e1.type === 'amm') {
|
|
out1 = ammOutput(out1);
|
|
}
|
|
const midToken = e1.type === 'pmm' ? (e1.base === fromToken ? e1.quote : e1.base) : (e1.tokenA === fromToken ? e1.tokenB : e1.tokenA);
|
|
if (e2.type === 'pmm') {
|
|
const key = e2.key;
|
|
if (e2.base === midToken && e2.quote === toToken) {
|
|
const r = pmmSellT(key, out1, curState);
|
|
return { cost: 1 - r.outputQ / amount, output: r.outputQ };
|
|
}
|
|
if (e2.quote === midToken && e2.base === toToken) {
|
|
const r = pmmBuyT(key, out1, curState);
|
|
return { cost: 1 - r.outputT / amount, output: r.outputT };
|
|
}
|
|
} else if (e2.type === 'amm') {
|
|
const out2 = ammOutput(out1);
|
|
return { cost: 1 - out2 / amount, output: out2 };
|
|
}
|
|
}
|
|
return { cost: 1e9, output: 0 };
|
|
}
|
|
|
|
function getNeighbors(chainId, token, graph) {
|
|
const out = [];
|
|
for (const e of graph.pmmEdges) {
|
|
if (e.chainId !== chainId) continue;
|
|
if (e.base === token) out.push({ token: e.quote, edge: e });
|
|
if (e.quote === token) out.push({ token: e.base, edge: e });
|
|
}
|
|
for (const e of graph.ammEdges) {
|
|
if (e.chainId !== chainId) continue;
|
|
if (e.tokenA === token) out.push({ token: e.tokenB, edge: { ...e, type: 'amm' } });
|
|
if (e.tokenB === token) out.push({ token: e.tokenA, edge: { ...e, type: 'amm' } });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function enumeratePaths(chainId, fromToken, toToken, graph, maxLen = 3) {
|
|
const paths = [];
|
|
function dfs(cur, path, visited) {
|
|
if (cur === toToken) {
|
|
paths.push([...path]);
|
|
return;
|
|
}
|
|
if (path.length >= maxLen) return;
|
|
const neighbors = getNeighbors(chainId, cur, graph);
|
|
for (const { token, edge } of neighbors) {
|
|
const key = `${chainId}:${token}`;
|
|
if (visited.has(key)) continue;
|
|
visited.add(key);
|
|
path.push(edge);
|
|
dfs(token, path, visited);
|
|
path.pop();
|
|
visited.delete(key);
|
|
}
|
|
}
|
|
dfs(fromToken, [], new Set([`${chainId}:${fromToken}`]));
|
|
return paths;
|
|
}
|
|
|
|
function getRoutingControlsForChain(chainId, routingControls) {
|
|
const defaults = routingControls.defaults || {};
|
|
const overrides = (routingControls.perChainOverrides || {})[String(chainId)] || {};
|
|
return { ...defaults, ...overrides };
|
|
}
|
|
|
|
function getCandidatePaths(chainId, fromToken, toToken, graph, state, probeSize, k, routingControls) {
|
|
const effective = getRoutingControlsForChain(chainId, routingControls);
|
|
const publicEnabled = effective.publicRoutingEnabled !== false;
|
|
const maxTrade = effective.maxTradeSizeUnits;
|
|
|
|
let paths = enumeratePaths(chainId, fromToken, toToken, graph);
|
|
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;
|
|
});
|
|
|
|
const withCost = paths.map((p) => {
|
|
const { cost, output } = pathCostSameChain(p, graph, state, fromToken, toToken, probeSize);
|
|
return { path: p, cost, output };
|
|
});
|
|
withCost.sort((a, b) => a.cost - b.cost);
|
|
return withCost.slice(0, k).map((x) => x.path);
|
|
}
|
|
|
|
function waterfill(chainId, fromToken, toToken, amount, graph, state, routingControls) {
|
|
const effective = getRoutingControlsForChain(chainId, routingControls);
|
|
const maxTrade = effective.maxTradeSizeUnits ? parseInt(effective.maxTradeSizeUnits, 10) : null;
|
|
const chunkSize = Math.max(1, Math.floor(amount * CHUNK_FRACTION));
|
|
let remaining = amount;
|
|
let curState = state;
|
|
const pathVolumes = {};
|
|
let totalOutput = 0;
|
|
|
|
while (remaining > 0.5) {
|
|
const chunk = Math.min(chunkSize, remaining);
|
|
const capped = maxTrade != null ? Math.min(chunk, maxTrade) : chunk;
|
|
const candidates = getCandidatePaths(chainId, fromToken, toToken, graph, curState, PROBE_SIZE, K_PATHS, routingControls);
|
|
if (candidates.length === 0) break;
|
|
|
|
let bestPath = null;
|
|
let bestOutput = -1;
|
|
for (const p of candidates) {
|
|
const { output } = pathCostSameChain(p, graph, curState, fromToken, toToken, capped);
|
|
if (output > bestOutput) {
|
|
bestOutput = output;
|
|
bestPath = p;
|
|
}
|
|
}
|
|
if (!bestPath || bestOutput <= 0) break;
|
|
|
|
const pathKey = bestPath.map((e) => (e.key || `${e.type}:${e.chainId}:${e.tokenA || e.base}:${e.tokenB || e.quote}`)).join('|');
|
|
pathVolumes[pathKey] = (pathVolumes[pathKey] || 0) + capped;
|
|
totalOutput += bestOutput;
|
|
|
|
let nextState = curState;
|
|
let left = capped;
|
|
let from = fromToken;
|
|
for (let i = 0; i < bestPath.length && left > 0; i++) {
|
|
const e = bestPath[i];
|
|
if (e.type === 'pmm') {
|
|
const key = e.key;
|
|
if (e.base === from) {
|
|
const r = pmmSellT(key, left, nextState);
|
|
left = r.outputQ;
|
|
nextState = r.newState;
|
|
from = e.quote;
|
|
} else {
|
|
const r = pmmBuyT(key, left, nextState);
|
|
left = r.outputT;
|
|
nextState = r.newState;
|
|
from = e.base;
|
|
}
|
|
} else {
|
|
left = ammOutput(left);
|
|
from = e.tokenA === from ? e.tokenB : e.tokenA;
|
|
}
|
|
}
|
|
curState = nextState;
|
|
remaining -= capped;
|
|
}
|
|
|
|
return { state: curState, pathVolumes, totalOutput };
|
|
}
|
|
|
|
function sampleTrade(scenario, chainIds, cwTokens, graph) {
|
|
const orderflow = scenario.orderflowModel || {};
|
|
const volMin = orderflow.volumeMinUnits ?? 1000;
|
|
const volMax = orderflow.volumeMaxUnits ?? 50000;
|
|
const dist = orderflow.distribution || 'uniform';
|
|
let amount;
|
|
if (dist === 'lognormal') {
|
|
const median = (volMin + volMax) / 2;
|
|
const sigma = 1.0;
|
|
const z = Math.sqrt(-2 * Math.log(rng() || 1e-10)) * Math.cos(2 * Math.PI * rng());
|
|
amount = Math.max(volMin, Math.min(volMax, median * Math.exp(sigma * z)));
|
|
} else if (dist === 'pareto') {
|
|
const alpha = orderflow.paretoAlpha ?? 2.0;
|
|
const xm = volMin;
|
|
amount = Math.min(volMax, xm / Math.pow(rng() || 1e-10, 1 / alpha));
|
|
} else {
|
|
amount = volMin + rng() * (volMax - volMin);
|
|
}
|
|
const chainId = chainIds[Math.floor(rng() * chainIds.length)];
|
|
const pub = graph.publicChains[chainId] || {};
|
|
const anchors = pub.anchorStables || ['USDC', 'USDT'];
|
|
const fromToken = cwTokens[Math.floor(rng() * cwTokens.length)];
|
|
const toToken = anchors[Math.floor(rng() * anchors.length)];
|
|
if (fromToken === toToken) return null;
|
|
return { chainId, fromToken, toToken, amount };
|
|
}
|
|
|
|
function runEpoch(scenario, graph, state, configs, epochIndex) {
|
|
const orderflow = scenario.orderflowModel || {};
|
|
const tradesPerEpoch = orderflow.tradesPerEpoch ?? 20;
|
|
const pathShares = {};
|
|
let totalVolume = 0;
|
|
let pmmVolume = 0;
|
|
let churnSum = 0;
|
|
const I_T_start = {};
|
|
let microTradeCount = 0;
|
|
let microTradeBuyCount = 0;
|
|
let microTradeSellCount = 0;
|
|
let microTradeVolumeTotal = 0;
|
|
let microTradeGasCostTotal = 0;
|
|
|
|
for (const k of Object.keys(state)) {
|
|
if (state[k].I_T_star != null) I_T_start[k] = state[k].I_T;
|
|
}
|
|
|
|
let curState = state;
|
|
|
|
const shock = scenario.bridgeShock;
|
|
if (shock && epochIndex < (shock.durationEpochs || 24)) {
|
|
const fromChain = shock.fromChain;
|
|
const toChain = shock.toChain;
|
|
const frac = (shock.magnitudeFraction || 0.05) / (shock.durationEpochs || 24);
|
|
let baseline = 0;
|
|
for (const e of graph.pmmEdges) {
|
|
if (e.chainId === fromChain || e.chainId === toChain) {
|
|
const s = curState[e.key];
|
|
if (s && s.I_T_star != null) baseline += s.I_T_star;
|
|
}
|
|
}
|
|
const shockAmount = Math.max(1000, frac * baseline);
|
|
for (const cw of graph.cwTokens) {
|
|
const fromPool = graph.pmmEdges.find((x) => x.chainId === fromChain && x.base === cw);
|
|
const toPool = graph.pmmEdges.find((x) => x.chainId === toChain && x.base === cw);
|
|
if (!fromPool || !toPool) continue;
|
|
const sellAmount = Math.min(shockAmount / graph.cwTokens.length, curState[fromPool.key] ? curState[fromPool.key].I_T * 0.5 : shockAmount);
|
|
if (sellAmount < 1) continue;
|
|
const r1 = pmmSellT(fromPool.key, sellAmount, curState);
|
|
curState = r1.newState;
|
|
totalVolume += sellAmount;
|
|
const buyAmount = Math.min(r1.outputQ, (curState[toPool.key] ? curState[toPool.key].I_T_star : 1e6) * 0.5);
|
|
if (buyAmount > 1) {
|
|
const r2 = pmmBuyT(toPool.key, buyAmount, curState);
|
|
curState = r2.newState;
|
|
totalVolume += buyAmount;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let t = 0; t < tradesPerEpoch; t++) {
|
|
const trade = sampleTrade(scenario, graph.chainIds, graph.cwTokens, graph);
|
|
if (!trade) continue;
|
|
const { state: nextState, pathVolumes } = waterfill(
|
|
trade.chainId,
|
|
trade.fromToken,
|
|
trade.toToken,
|
|
trade.amount,
|
|
graph,
|
|
curState,
|
|
configs.routingControls
|
|
);
|
|
curState = nextState;
|
|
totalVolume += trade.amount;
|
|
for (const [pathKey, vol] of Object.entries(pathVolumes || {})) {
|
|
pathShares[pathKey] = (pathShares[pathKey] || 0) + vol;
|
|
}
|
|
}
|
|
|
|
const micro = runMicroTradeSupportStep(graph, curState, scenario, epochIndex);
|
|
curState = micro.state;
|
|
microTradeCount += micro.microTradeCount || 0;
|
|
microTradeBuyCount += micro.microTradeBuyCount || 0;
|
|
microTradeSellCount += micro.microTradeSellCount || 0;
|
|
microTradeVolumeTotal += micro.microTradeVolumeTotal || 0;
|
|
microTradeGasCostTotal += micro.microTradeGasCostTotal || 0;
|
|
totalVolume += micro.microTradeVolumeTotal || 0;
|
|
|
|
const peakDeviationBpsPreArb = maxDeviationBpsOverPools(graph, curState);
|
|
const worstPreArb = getWorstPoolDiagnostic(graph, curState);
|
|
const { state: afterArb, arbVolumeTotal, arbProfitTotal, peakDeviationBps: peakDeviationBpsPostArb } = runArbStep(graph, curState, configs);
|
|
curState = afterArb;
|
|
const worstPostArb = getWorstPoolDiagnostic(graph, curState);
|
|
const { state: afterBot, interventionCostInject, interventionCostWithdraw, interventionCostByChain } = runBotStep(graph, curState, scenario, configs);
|
|
curState = afterBot;
|
|
const peakDeviationBpsPostBot = maxDeviationBpsOverPools(graph, curState);
|
|
const worstPostBot = getWorstPoolDiagnostic(graph, curState);
|
|
|
|
for (const k of Object.keys(curState)) {
|
|
const s = curState[k];
|
|
if (s.I_T_star != null && I_T_start[k] != null) {
|
|
churnSum += Math.abs(s.I_T - I_T_start[k]);
|
|
}
|
|
}
|
|
|
|
const totalPathVol = Object.values(pathShares).reduce((a, b) => a + b, 0);
|
|
if (totalPathVol > 0) {
|
|
pmmVolume = Object.entries(pathShares)
|
|
.filter(([pk]) => pk.split('|').some((part) => !part.includes(':amm:')))
|
|
.reduce((a, [, v]) => a + v, 0);
|
|
}
|
|
|
|
return {
|
|
state: curState,
|
|
pathShares,
|
|
totalVolume,
|
|
pmmVolume,
|
|
churnSum,
|
|
arbVolumeTotal: arbVolumeTotal || 0,
|
|
arbProfitTotal: arbProfitTotal || 0,
|
|
interventionCostInject: interventionCostInject || 0,
|
|
interventionCostWithdraw: interventionCostWithdraw || 0,
|
|
interventionCostByChain: interventionCostByChain || {},
|
|
microTradeCount,
|
|
microTradeBuyCount,
|
|
microTradeSellCount,
|
|
microTradeVolumeTotal,
|
|
microTradeGasCostTotal,
|
|
peakDeviationBpsPreArb: peakDeviationBpsPreArb || 0,
|
|
peakDeviationBpsPostArb: peakDeviationBpsPostArb || 0,
|
|
peakDeviationBpsPostBot: peakDeviationBpsPostBot || 0,
|
|
worst_pool_pre_arb: worstPreArb,
|
|
worst_pool_post_arb: worstPostArb,
|
|
worst_pool_post_bot: worstPostBot,
|
|
I_T_by_key: Object.fromEntries(Object.entries(curState).filter(([, s]) => s.I_T_star != null).map(([k, s]) => [k, s.I_T])),
|
|
};
|
|
}
|
|
|
|
function computeScorecard(scenario, scenarioName, graph, initialState, epochResults) {
|
|
const params = graph.chains;
|
|
const defaultPmm = graph.defaultPmm || {};
|
|
const invTargetDefault = parseInt(defaultPmm.inventoryTargetUnits || '1000000', 10);
|
|
|
|
let totalVolume = 0;
|
|
let totalPmmVolume = 0;
|
|
let churnSum = 0;
|
|
let churnMax = 0;
|
|
const pathShareTotals = {};
|
|
const I_T_over_epochs = {};
|
|
const I_T_star_by_key = {};
|
|
|
|
for (const e of graph.pmmEdges) {
|
|
const chainConf = params[e.chainId] || {};
|
|
I_T_star_by_key[e.key] = parseInt(chainConf.inventoryTargetUnits || defaultPmm?.inventoryTargetUnits || '1000000', 10);
|
|
}
|
|
|
|
let arbVolumeTotal = 0;
|
|
let arbProfitTotal = 0;
|
|
let interventionCostInjectTotal = 0;
|
|
let interventionCostWithdrawTotal = 0;
|
|
const interventionCostByChain = {};
|
|
let peakDeviationBpsPreArb = 0;
|
|
let peakDeviationBpsPostArb = 0;
|
|
let peakDeviationBpsPostBot = 0;
|
|
let microTradeCountTotal = 0;
|
|
let microTradeBuyCountTotal = 0;
|
|
let microTradeSellCountTotal = 0;
|
|
let microTradeVolumeTotal = 0;
|
|
let microTradeGasCostTotal = 0;
|
|
|
|
for (const r of epochResults) {
|
|
totalVolume += r.totalVolume;
|
|
totalPmmVolume += r.pmmVolume;
|
|
churnSum += r.churnSum;
|
|
if (r.churnSum > churnMax) churnMax = r.churnSum;
|
|
arbVolumeTotal += r.arbVolumeTotal || 0;
|
|
arbProfitTotal += r.arbProfitTotal || 0;
|
|
interventionCostInjectTotal += r.interventionCostInject || 0;
|
|
interventionCostWithdrawTotal += r.interventionCostWithdraw || 0;
|
|
microTradeCountTotal += r.microTradeCount || 0;
|
|
microTradeBuyCountTotal += r.microTradeBuyCount || 0;
|
|
microTradeSellCountTotal += r.microTradeSellCount || 0;
|
|
microTradeVolumeTotal += r.microTradeVolumeTotal || 0;
|
|
microTradeGasCostTotal += r.microTradeGasCostTotal || 0;
|
|
for (const [chainId, v] of Object.entries(r.interventionCostByChain || {})) {
|
|
if (!interventionCostByChain[chainId]) interventionCostByChain[chainId] = { inject: 0, withdraw: 0 };
|
|
interventionCostByChain[chainId].inject += v.inject || 0;
|
|
interventionCostByChain[chainId].withdraw += v.withdraw || 0;
|
|
}
|
|
if ((r.peakDeviationBpsPreArb || 0) > peakDeviationBpsPreArb) peakDeviationBpsPreArb = r.peakDeviationBpsPreArb;
|
|
if ((r.peakDeviationBpsPostArb || 0) > peakDeviationBpsPostArb) peakDeviationBpsPostArb = r.peakDeviationBpsPostArb;
|
|
if ((r.peakDeviationBpsPostBot || 0) > peakDeviationBpsPostBot) peakDeviationBpsPostBot = r.peakDeviationBpsPostBot;
|
|
for (const [pk, vol] of Object.entries(r.pathShares || {})) {
|
|
pathShareTotals[pk] = (pathShareTotals[pk] || 0) + vol;
|
|
}
|
|
for (const [k, iT] of Object.entries(r.I_T_by_key || {})) {
|
|
if (!I_T_over_epochs[k]) I_T_over_epochs[k] = [];
|
|
I_T_over_epochs[k].push(iT);
|
|
}
|
|
}
|
|
|
|
const interventionCostTotal = interventionCostInjectTotal + interventionCostWithdrawTotal;
|
|
|
|
const drainHalfLife = {};
|
|
for (const [key, series] of Object.entries(I_T_over_epochs)) {
|
|
const start = initialState[key] ? initialState[key].I_T : (series[0] || 0);
|
|
const threshold = 0.5 * start;
|
|
let epoch = -1;
|
|
for (let i = 0; i < series.length; i++) {
|
|
if (series[i] <= threshold) {
|
|
epoch = i;
|
|
break;
|
|
}
|
|
}
|
|
if (epoch >= 0) drainHalfLife[key] = epoch;
|
|
else drainHalfLife[key] = series.length;
|
|
}
|
|
|
|
const totalPathVol = Object.values(pathShareTotals).reduce((a, b) => a + b, 0);
|
|
let hhi = 0;
|
|
if (totalPathVol > 0) {
|
|
for (const vol of Object.values(pathShareTotals)) {
|
|
const s = vol / totalPathVol;
|
|
hhi += s * s;
|
|
}
|
|
}
|
|
const pathConcentrationIndex = hhi;
|
|
|
|
const captureMean = totalVolume > 0 ? totalPmmVolume / totalVolume : 0;
|
|
const numEpochs = epochResults.length;
|
|
const churnMean = numEpochs > 0 ? churnSum / numEpochs : 0;
|
|
const invTotal = Object.values(I_T_star_by_key).reduce((a, b) => a + b, 0);
|
|
const churnMeanNorm = invTotal > 0 ? churnSum / numEpochs / invTotal : 0;
|
|
|
|
const interventionNum = Number.isFinite(interventionCostTotal) ? interventionCostTotal : 0;
|
|
const interventionPer1M = totalVolume > 0 ? (interventionNum / totalVolume) * 1e6 : 0;
|
|
|
|
const lastEpoch = epochResults.length > 0 ? epochResults[epochResults.length - 1] : {};
|
|
const worst_pool_diagnostic = lastEpoch.worst_pool_pre_arb
|
|
? {
|
|
pre_arb: lastEpoch.worst_pool_pre_arb,
|
|
post_arb: lastEpoch.worst_pool_post_arb,
|
|
post_bot: lastEpoch.worst_pool_post_bot,
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
scenario: scenario.scenario || scenarioName,
|
|
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,
|
|
churn_p95: churnMax / Math.max(1, invTotal),
|
|
churn_max: churnMax,
|
|
intervention_cost_total: Math.round(interventionNum),
|
|
intervention_cost_inject_total: Math.round(interventionCostInjectTotal),
|
|
intervention_cost_withdraw_total: Math.round(interventionCostWithdrawTotal),
|
|
intervention_cost_by_chain: interventionCostByChain,
|
|
intervention_cost_per_1M_volume: Math.round(interventionPer1M * 100) / 100,
|
|
micro_trade_count: Math.round(microTradeCountTotal),
|
|
micro_trade_buy_count: Math.round(microTradeBuyCountTotal),
|
|
micro_trade_sell_count: Math.round(microTradeSellCountTotal),
|
|
micro_trade_volume_total: Math.round(microTradeVolumeTotal),
|
|
micro_trade_gas_cost_total: Math.round(microTradeGasCostTotal * 100) / 100,
|
|
peak_deviation_bps: Math.round(Number.isFinite(peakDeviationBpsPostArb) ? peakDeviationBpsPostArb : 0),
|
|
peak_deviation_bps_pre_arb: Math.round(peakDeviationBpsPreArb),
|
|
peak_deviation_bps_post_arb: Math.round(peakDeviationBpsPostArb),
|
|
peak_deviation_bps_post_bot: Math.round(peakDeviationBpsPostBot),
|
|
reflexive_route_count: 0,
|
|
drain_half_life_epochs: drainHalfLife,
|
|
path_concentration_index: Math.min(1, Math.max(0, pathConcentrationIndex)),
|
|
arb_volume_total: Math.round(arbVolumeTotal),
|
|
arb_profit_total: Math.round(arbProfitTotal * 100) / 100,
|
|
...(worst_pool_diagnostic && { worst_pool_diagnostic }),
|
|
};
|
|
}
|
|
|
|
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 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`);
|
|
process.exit(1);
|
|
}
|
|
const scenario = loadJson(scenarioPath);
|
|
const seed = scenario.seed != null ? Number(scenario.seed) : hashScenarioName(scenarioName);
|
|
seedRng(seed);
|
|
|
|
const configs = loadConfigs(scenario);
|
|
const graph = buildGraph(scenario, configs);
|
|
let state = buildPMMState(graph, configs);
|
|
|
|
const epochs = scenario.epochs || 10;
|
|
const initialState = JSON.parse(JSON.stringify(state));
|
|
const epochResults = [];
|
|
for (let e = 0; e < epochs; e++) {
|
|
const result = runEpoch(scenario, graph, state, configs, e);
|
|
state = result.state;
|
|
epochResults.push(result);
|
|
}
|
|
|
|
const scorecard = computeScorecard(scenario, scenarioName, graph, initialState, epochResults);
|
|
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();
|