diff --git a/cadence/contracts/mocks/FlowTransactionScheduler.cdc b/cadence/contracts/mocks/FlowTransactionScheduler.cdc index 642b938f..edebbfd2 100644 --- a/cadence/contracts/mocks/FlowTransactionScheduler.cdc +++ b/cadence/contracts/mocks/FlowTransactionScheduler.cdc @@ -1,3 +1,6 @@ +// This contract extends the original contract by adding a reset method, +// which is useful in tests for clearing any pre-existing scheduled transactions. +// https://github.com/onflow/flow-core-contracts/blob/master/contracts/FlowTransactionScheduler.cdc import "FungibleToken" import "FlowToken" import "FlowFees" diff --git a/cadence/tests/scripts/simulations/simulation_ht_vs_aave.json b/cadence/tests/scripts/simulations/simulation_ht_vs_aave.json new file mode 100644 index 00000000..97727dbc --- /dev/null +++ b/cadence/tests/scripts/simulations/simulation_ht_vs_aave.json @@ -0,0 +1,45 @@ +{ + "scenario": "simulation_ht_vs_aave", + "duration_minutes": 60, + "btc_prices": [ + 100000.00, 99551.11, 99104.23, 98659.37, 98216.49, + 97775.61, 97336.70, 96899.77, 96464.80, 96031.78, + 95600.70, 95171.56, 94744.34, 94319.04, 93895.65, + 93474.17, 93054.57, 92636.86, 92221.02, 91807.05, + 91394.93, 90984.67, 90576.25, 90169.66, 89764.90, + 89361.95, 88960.82, 88561.48, 88163.94, 87768.18, + 87374.20, 86981.98, 86591.53, 86202.83, 85815.87, + 85430.65, 85047.16, 84665.39, 84285.34, 83906.99, + 83530.34, 83155.38, 82782.10, 82410.50, 82040.57, + 81672.30, 81305.68, 80940.71, 80577.37, 80215.67, + 79855.59, 79497.12, 79140.27, 78785.02, 78431.36, + 78079.29, 77728.80, 77379.88, 77032.53, 76686.74, + 76342.50 + ], + "agents": [ + {"count": 5, "initial_hf": 1.15, "rebalancing_hf": 1.05, "target_hf": 1.08, "debt_per_agent": 133333, "total_system_debt": 666665} + ], + "pools": { + "pyusd0_yt": { + "size": 500000, + "concentration": 0.95, + "fee_tier": 0.0005 + }, + "pyusd0_flow": { + "size": 500000, + "concentration": 0.80, + "fee_tier": 0.003 + } + }, + "constants": { + "btc_collateral_factor": 0.75, + "btc_liquidation_threshold": 0.80, + "yield_apr": 0.10, + "direct_mint_yt": true + }, + "expected": { + "liquidation_count": 0, + "all_agents_survive": true + }, + "notes": "BTC $100K to $76,342.50 (-23.66%) exponential decline over 60 minutes. Source: comprehensive_ht_vs_aave_analysis.py" +} diff --git a/cadence/tests/simulation_base_case_stress.cdc b/cadence/tests/simulation_base_case_stress.cdc new file mode 100644 index 00000000..54bd791c --- /dev/null +++ b/cadence/tests/simulation_base_case_stress.cdc @@ -0,0 +1,707 @@ +#test_fork(network: "mainnet-fork", height: 147316310) + +import Test +import BlockchainHelpers + +import "test_helpers.cdc" +import "evm_state_helpers.cdc" +import "simulation_base_case_stress_helpers.cdc" + +import "FlowYieldVaults" +import "FlowToken" +import "FlowYieldVaultsStrategiesV2" +import "FlowALPv0" +import "DeFiActions" + +// ============================================================================ +// CADENCE ACCOUNTS +// ============================================================================ + +access(all) let flowYieldVaultsAccount = Test.getAccount(0xb1d63873c3cc9f79) +access(all) let flowALPAccount = Test.getAccount(0x6b00ff876c299c61) +access(all) let bandOracleAccount = Test.getAccount(0x6801a6222ebf784a) +access(all) let whaleFlowAccount = Test.getAccount(0x92674150c9213fc9) +access(all) let coaOwnerAccount = Test.getAccount(0xe467b9dd11fa00df) + +// WBTC on Flow EVM +access(all) let WBTC_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" +access(all) let WBTC_TYPE = CompositeType(WBTC_TOKEN_ID)! + +access(all) var strategyIdentifier = Type<@FlowYieldVaultsStrategiesV2.FUSDEVStrategy>().identifier +access(all) var wbtcTokenIdentifier = WBTC_TOKEN_ID + +// ============================================================================ +// PROTOCOL ADDRESSES +// ============================================================================ + +access(all) let factoryAddress = "0xca6d7Bb03334bBf135902e1d919a5feccb461632" + +// ============================================================================ +// VAULT & TOKEN ADDRESSES +// ============================================================================ + +access(all) let morphoVaultAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" +access(all) let pyusd0Address = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" +access(all) let wbtcAddress = "0x717dae2baf7656be9a9b01dee31d571a9d4c9579" + +// ============================================================================ +// STORAGE SLOT CONSTANTS +// ============================================================================ + +access(all) let pyusd0BalanceSlot = 1 as UInt256 +access(all) let fusdevBalanceSlot = 12 as UInt256 +access(all) let wbtcBalanceSlot = 5 as UInt256 + +access(all) let morphoVaultTotalSupplySlot = 11 as UInt256 +access(all) let morphoVaultTotalAssetsSlot = 15 as UInt256 + +// ============================================================================ +// SIMULATION TYPES +// ============================================================================ + +access(all) struct SimConfig { + access(all) let prices: [UFix64] + access(all) let tickIntervalSeconds: UFix64 + access(all) let numAgents: Int + access(all) let fundingPerAgent: UFix64 + access(all) let yieldAPR: UFix64 + access(all) let expectedLiquidationCount: Int + /// How often (in ticks) to attempt rebalancing. + /// 1 = rebalance every tick (default) + access(all) let rebalanceInterval: Int + /// Position health thresholds + access(all) let minHealth: UFix64 + access(all) let targetHealth: UFix64 + access(all) let maxHealth: UFix64 + /// Initial HF range — agents are linearly spread across [low, high] + /// (Python sim uses random.uniform; linear spread is the deterministic equivalent) + access(all) let initialHFLow: UFix64 + access(all) let initialHFHigh: UFix64 + /// PYUSD0:YT pool TVL in USD (from fixture's pyusd0_yt.size) + /// Used to simulate slippage: within a tick, earlier agents get better prices + /// than later agents as each trade consumes liquidity. + access(all) let ytPoolTVL: UFix64 + /// PYUSD0:YT pool concentration (0.95 = 95% of liquidity in concentrated range) + /// Used to simulate slippage: within a tick, earlier agents get better prices + /// than later agents as each trade consumes liquidity. + access(all) let ytPoolConcentration: UFix64 + + init( + prices: [UFix64], + tickIntervalSeconds: UFix64, + numAgents: Int, + fundingPerAgent: UFix64, + yieldAPR: UFix64, + expectedLiquidationCount: Int, + rebalanceInterval: Int, + minHealth: UFix64, + targetHealth: UFix64, + maxHealth: UFix64, + initialHFLow: UFix64, + initialHFHigh: UFix64, + ytPoolTVL: UFix64, + ytPoolConcentration: UFix64 + ) { + self.prices = prices + self.tickIntervalSeconds = tickIntervalSeconds + self.numAgents = numAgents + self.fundingPerAgent = fundingPerAgent + self.yieldAPR = yieldAPR + self.expectedLiquidationCount = expectedLiquidationCount + self.rebalanceInterval = rebalanceInterval + self.minHealth = minHealth + self.targetHealth = targetHealth + self.maxHealth = maxHealth + self.initialHFLow = initialHFLow + self.initialHFHigh = initialHFHigh + self.ytPoolTVL = ytPoolTVL + self.ytPoolConcentration = ytPoolConcentration + } +} + +access(all) struct SimResult { + access(all) let rebalanceCount: Int + access(all) let liquidationCount: Int + access(all) let lowestHF: UFix64 + access(all) let finalHF: UFix64 + access(all) let lowestPrice: UFix64 + access(all) let finalPrice: UFix64 + access(all) let finalCollateral: UFix64 + + init( + rebalanceCount: Int, + liquidationCount: Int, + lowestHF: UFix64, + finalHF: UFix64, + lowestPrice: UFix64, + finalPrice: UFix64, + finalCollateral: UFix64 + ) { + self.rebalanceCount = rebalanceCount + self.liquidationCount = liquidationCount + self.lowestHF = lowestHF + self.finalHF = finalHF + self.lowestPrice = lowestPrice + self.finalPrice = finalPrice + self.finalCollateral = finalCollateral + } +} + +// ============================================================================ +// SETUP +// ============================================================================ + +access(all) +fun setup() { + deployContractsForFork() + + // PYUSD0:morphoVault (routing pool) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + signer: coaOwnerAccount + ) + + // PYUSD0:morphoVault (yield token pool) — finite liquidity matching Python sim + // ±100 ticks with 95% of $500K TVL, same as Python _initialize_symmetric_yield_token_positions + let ytPool = simulation_ht_vs_aave_pools["pyusd0_yt"]! + setPoolToPriceWithTVL( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: 1.0, + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + tvl: ytPool.size, + concentration: ytPool.concentration, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount + ) + + // PYUSD0:WBTC (collateral/liquidation pool) — finite liquidity matching Python sim + // Python sim: _initialize_btc_pair_positions places 80% of $500K at ±100 ticks (~1%) + let btcPool = simulation_ht_vs_aave_pools["pyusd0_flow"]! + let initialBtcPrice = simulation_ht_vs_aave_prices[0] + setPoolToPriceWithTVL( + factoryAddress: factoryAddress, + tokenAAddress: wbtcAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: UFix128(initialBtcPrice), + tokenABalanceSlot: wbtcBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + tvl: btcPool.size, + concentration: btcPool.concentration, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount + ) + + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "BTC": initialBtcPrice, + "USD": 1.0, + "PYUSD": 1.0 + }) + + let reserveAmount = 100_000_00.0 + transferFlow(signer: whaleFlowAccount, recipient: flowALPAccount.address, amount: reserveAmount) + seedPoolWithPYUSD0(poolSigner: flowALPAccount, amount: reserveAmount) + + transferFlow(signer: whaleFlowAccount, recipient: flowYieldVaultsAccount.address, amount: reserveAmount) + transferFlow(signer: whaleFlowAccount, recipient: coaOwnerAccount.address, amount: reserveAmount) +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +access(all) fun getBTCCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == WBTC_TYPE { + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +/// Compute deterministic YT (ERC4626 vault share) price at a given tick. +/// price = 1.0 + yieldAPR * (seconds / secondsPerYear) +access(all) fun ytPriceAtTick(_ tick: Int, tickIntervalSeconds: UFix64, yieldAPR: UFix64): UFix64 { + let secondsPerYear: UFix64 = 31536000.0 + let elapsedSeconds = UFix64(tick) * tickIntervalSeconds + return 1.0 + yieldAPR * (elapsedSeconds / secondsPerYear) +} + +/// Update oracle, collateral pool, and vault share price each tick. +access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.TestAccount) { + setBandOraclePrices(signer: bandOracleAccount, symbolPrices: { + "BTC": btcPrice, + "USD": 1.0, + "PYUSD": 1.0 + }) + + // PYUSD0:WBTC pool — reset to new BTC price with finite liquidity (arb bot) + let btcPool = simulation_ht_vs_aave_pools["pyusd0_flow"]! + setPoolToPriceWithTVL( + factoryAddress: factoryAddress, + tokenAAddress: wbtcAddress, + tokenBAddress: pyusd0Address, + fee: 3000, + priceTokenBPerTokenA: UFix128(btcPrice), + tokenABalanceSlot: wbtcBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + tvl: btcPool.size, + concentration: btcPool.concentration, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount + ) + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: ytPrice, + signer: signer + ) +} + +/// Arb bot simulation: reset PYUSD0:FUSDEV pool to peg with finite TVL. +/// Called after all agents trade each tick. Matches Python sim arb bot +/// which pushes the pool back toward peg every tick. +access(all) fun resetYieldPoolToFiniteTVL(ytPrice: UFix64, tvl: UFix64, concentration: UFix64) { + setPoolToPriceWithTVL( + factoryAddress: factoryAddress, + tokenAAddress: pyusd0Address, + tokenBAddress: morphoVaultAddress, + fee: 100, + priceTokenBPerTokenA: UFix128(ytPrice), + tokenABalanceSlot: pyusd0BalanceSlot, + tokenBBalanceSlot: fusdevBalanceSlot, + tvl: tvl, + concentration: concentration, + tokenBPriceUSD: 1.0, + signer: coaOwnerAccount + ) +} + +// ============================================================================ +// SIMULATION RUNNER +// ============================================================================ + +access(all) fun runSimulation(config: SimConfig, label: String): SimResult { + let prices = config.prices + let initialPrice = prices[0] + + // Clear scheduled transactions inherited from forked mainnet state + resetTransactionScheduler() + + // Apply initial pricing + applyPriceTick(btcPrice: initialPrice, ytPrice: ytPriceAtTick(0, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR), signer: coaOwnerAccount) + + // Create agents + let users: [Test.TestAccount] = [] + let pids: [UInt64] = [] + let vaultIds: [UInt64] = [] + + var i = 0 + while i < config.numAgents { + let user = Test.createAccount() + transferFlow(signer: whaleFlowAccount, recipient: user.address, amount: 10.0) + mintBTC(signer: user, amount: config.fundingPerAgent) + grantBeta(flowYieldVaultsAccount, user) + + setVaultSharePrice( + vaultAddress: morphoVaultAddress, + assetAddress: pyusd0Address, + assetBalanceSlot: pyusd0BalanceSlot, + totalSupplySlot: morphoVaultTotalSupplySlot, + vaultTotalAssetsSlot: morphoVaultTotalAssetsSlot, + priceMultiplier: 1.0, + signer: user + ) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: wbtcTokenIdentifier, + amount: config.fundingPerAgent, + beFailed: false + ) + + let pid = (getLastPositionOpenedEvent(Test.eventsOfType(Type())) as! FlowALPv0.Opened).pid + let yieldVaultIDs = getYieldVaultIDs(address: user.address)! + let vaultId = yieldVaultIDs[0] + + // Linearly spread initial HF across [low, high] (Python uses random.uniform) + let agentInitialHF = config.numAgents > 1 + ? config.initialHFLow + (config.initialHFHigh - config.initialHFLow) * UFix64(i) / UFix64(config.numAgents - 1) + : config.initialHFLow + + // Step 1: Coerce position to the desired initial HF. + // Set temporary health params with targetHealth=initialHF, then force-rebalance. + // This makes the on-chain rebalancer push the position to exactly initialHF. + setPositionHealth( + signer: flowALPAccount, + pid: pid, + minHealth: agentInitialHF - 0.01, + targetHealth: agentInitialHF, + maxHealth: agentInitialHF + 0.01 + ) + rebalancePosition(signer: flowALPAccount, pid: pid, force: true, beFailed: false) + + // Step 2: Set the real health thresholds for the simulation. + setPositionHealth( + signer: flowALPAccount, + pid: pid, + minHealth: config.minHealth, + targetHealth: config.targetHealth, + maxHealth: config.maxHealth + ) + + users.append(user) + pids.append(pid) + vaultIds.append(vaultId) + + log(" Agent \(i): pid=\(pid) vaultId=\(vaultId) initialHF=\(agentInitialHF)") + i = i + 1 + } + + log("\n=== SIMULATION: \(label) ===") + log("Agents: \(config.numAgents)") + log("Funding per agent: \(config.fundingPerAgent) BTC (~\(config.fundingPerAgent * initialPrice) PYUSD0)") + log("Tick interval: \(config.tickIntervalSeconds)s") + log("Price points: \(prices.length)") + log("Initial BTC price: $\(prices[0])") + log("Initial HF range: \(config.initialHFLow) - \(config.initialHFHigh)") + log("") + log("Rebalance Triggers:") + log(" HF (Position): triggers when HF < \(config.minHealth), rebalances to HF = \(config.targetHealth)") + log(" Liquidation: HF < 1.0 (on-chain effectiveCollateral/effectiveDebt)") + log("Notes: BTC $100K -> $76,342.50 (-23.66%) over 60 minutes") + + var liquidationCount = 0 + var previousBTCPrice = initialPrice + var lowestPrice = initialPrice + var highestPrice = initialPrice + var lowestHF = 100.0 + var prevVaultRebalanceCount = 0 + var prevPositionRebalanceCount = 0 + + let startTimestamp = getCurrentBlockTimestamp() + + var step = 0 + while step < prices.length { + let absolutePrice = prices[step] + let ytPrice = ytPriceAtTick(step, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR) + + if absolutePrice < lowestPrice { + lowestPrice = absolutePrice + } + if absolutePrice > highestPrice { + highestPrice = absolutePrice + } + + if absolutePrice == previousBTCPrice { + step = step + 1 + continue + } + + let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds + let currentTimestamp = getCurrentBlockTimestamp() + if expectedTimestamp > currentTimestamp { + Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp)) + } + + applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0]) + + // Calculate HF BEFORE rebalancing + var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + + // Rebalance agents sequentially — each swap moves pool price for next agent + if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 { + var a = 0 + while a < config.numAgents { + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false) + rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false) + a = a + 1 + } + } + + // Arb bot: reset PYUSD0:FUSDEV pool to peg with finite TVL + resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.ytPoolTVL, concentration: config.ytPoolConcentration) + + // Count actual rebalances that occurred this tick + let currentVaultRebalanceCount = Test.eventsOfType(Type()).length + let currentPositionRebalanceCount = Test.eventsOfType(Type()).length + let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount + let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount + prevVaultRebalanceCount = currentVaultRebalanceCount + prevPositionRebalanceCount = currentPositionRebalanceCount + + // Calculate HF AFTER rebalancing + var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + + // Track lowest HF (use pre-rebalance to capture the actual low point) + if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 { + lowestHF = preRebalanceHF + } + + // Log every tick with pre→post HF + log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)") + + // Liquidation check (pre-rebalance HF is the danger point) + if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 { + liquidationCount = liquidationCount + 1 + log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***") + } + + previousBTCPrice = absolutePrice + + step = step + 1 + } + + // Count actual rebalance events (not just attempts) + let vaultRebalanceCount = Test.eventsOfType(Type()).length + let positionRebalanceCount = Test.eventsOfType(Type()).length + + // Final state + let finalHF = UFix64(getPositionHealth(pid: pids[0], beFailed: false)) + let finalBTCCollateral = getBTCCollateralFromPosition(pid: pids[0]) + let finalDebt = getPYUSD0DebtFromPosition(pid: pids[0]) + let finalYieldTokens = getAutoBalancerBalance(id: vaultIds[0])! + let finalYtPrice = ytPriceAtTick(prices.length - 1, tickIntervalSeconds: config.tickIntervalSeconds, yieldAPR: config.yieldAPR) + let finalPrice = prices[prices.length - 1] + let collateralValuePYUSD0 = finalBTCCollateral * previousBTCPrice + let ytValuePYUSD0 = finalYieldTokens * finalYtPrice + + log("\n=== SIMULATION RESULTS ===") + log("Agents: \(config.numAgents)") + log("Rebalance attempts: \(prices.length * config.numAgents)") + log("Vault rebalances: \(vaultRebalanceCount)") + log("Position rebalances: \(positionRebalanceCount)") + log("Liquidation count: \(liquidationCount)") + log("") + log("--- Price ---") + log("Initial BTC price: $\(initialPrice)") + log("Lowest BTC price: $\(lowestPrice)") + log("Highest BTC price: $\(highestPrice)") + log("Final BTC price: $\(finalPrice)") + log("") + log("--- Position ---") + log("Initial HF range: \(config.initialHFLow) - \(config.initialHFHigh)") + log("Lowest HF observed: \(lowestHF)") + log("Final HF (agent 0): \(finalHF)") + log("Final collateral: \(finalBTCCollateral) BTC (value: \(collateralValuePYUSD0) PYUSD0)") + log("Final debt: \(finalDebt) PYUSD0") + log("Final yield tokens: \(finalYieldTokens) (value: \(ytValuePYUSD0) PYUSD0 @ yt=\(finalYtPrice))") + log("===========================\n") + + return SimResult( + rebalanceCount: positionRebalanceCount, + liquidationCount: liquidationCount, + lowestHF: lowestHF, + finalHF: finalHF, + lowestPrice: lowestPrice, + finalPrice: finalPrice, + finalCollateral: finalBTCCollateral + ) +} + +// ============================================================================ +// TEST: Aggressive_1.01 — Initial HF 1.1–1.2, Target HF 1.01 +// ============================================================================ + +/// Most aggressive rebalancing target (HF=1.01) with tight initial spread (1.1–1.2). +/// Validates that even with minimal safety margin, the rebalancer prevents liquidation +/// during a 23.66% BTC crash over 60 minutes. +access(all) +fun test_Aggressive_1_01_ZeroLiquidations() { + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.01, + targetHealth: 1.01000001, + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHFLow: 1.1, // Python initial_hf_range + initialHFHigh: 1.2, + ytPoolTVL: simulation_ht_vs_aave_pools["pyusd0_yt"]!.size, + ytPoolConcentration: simulation_ht_vs_aave_pools["pyusd0_yt"]!.concentration + ), + label: "Aggressive_1.01" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + // No liquidations means collateral should never decrease from initial funding + Test.assert(result.finalCollateral >= 1.0, message: "Expected collateral >= 1.0 BTC but got \(result.finalCollateral)") + + log("=== TEST PASSED: Aggressive_1.01 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Balanced_1.1 — Initial HF 1.25–1.45, Target HF 1.1 +// ============================================================================ + +/// Highest target HF (1.1) with moderate initial spread (1.25–1.45). +/// Rebalancer maintains a larger safety buffer, resulting in fewer rebalance events +/// but more collateral held in reserve. +access(all) +fun test_Balanced_1_1_ZeroLiquidations() { + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.1, + targetHealth: 1.10000001, + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHFLow: 1.25, // Python initial_hf_range + initialHFHigh: 1.45, + ytPoolTVL: simulation_ht_vs_aave_pools["pyusd0_yt"]!.size, + ytPoolConcentration: simulation_ht_vs_aave_pools["pyusd0_yt"]!.concentration + ), + label: "Balanced_1.1" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + // No liquidations means collateral should never decrease from initial funding + Test.assert(result.finalCollateral >= 1.0, message: "Expected collateral >= 1.0 BTC but got \(result.finalCollateral)") + + log("=== TEST PASSED: Balanced_1.1 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Conservative_1.05 — Initial HF 1.3–1.5, Target HF 1.05 +// ============================================================================ + +/// Conservative target (HF=1.05) with wide initial spread (1.3–1.5). +/// Tests that positions starting further from liquidation threshold still survive +/// when rebalanced to a moderate target. +access(all) +fun test_Conservative_1_05_ZeroLiquidations() { + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.05, + targetHealth: 1.05000001, + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHFLow: 1.3, // Python initial_hf_range + initialHFHigh: 1.5, + ytPoolTVL: simulation_ht_vs_aave_pools["pyusd0_yt"]!.size, + ytPoolConcentration: simulation_ht_vs_aave_pools["pyusd0_yt"]!.concentration + ), + label: "Conservative_1.05" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + // No liquidations means collateral should never decrease from initial funding + Test.assert(result.finalCollateral >= 1.0, message: "Expected collateral >= 1.0 BTC but got \(result.finalCollateral)") + + log("=== TEST PASSED: Conservative_1.05 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Mixed_1.075 — Initial HF 1.1–1.5, Target HF 1.075 +// ============================================================================ + +/// Mid-range target (HF=1.075) with the widest initial spread (1.1–1.5). +/// Stress-tests the rebalancer across a diverse agent population where some start +/// near liquidation and others start far above target. +access(all) +fun test_Mixed_1_075_ZeroLiquidations() { + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.075, + targetHealth: 1.07500001, + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHFLow: 1.1, // Python initial_hf_range + initialHFHigh: 1.5, + ytPoolTVL: simulation_ht_vs_aave_pools["pyusd0_yt"]!.size, + ytPoolConcentration: simulation_ht_vs_aave_pools["pyusd0_yt"]!.concentration + ), + label: "Mixed_1.075" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + // No liquidations means collateral should never decrease from initial funding + Test.assert(result.finalCollateral >= 1.0, message: "Expected collateral >= 1.0 BTC but got \(result.finalCollateral)") + + log("=== TEST PASSED: Mixed_1.075 — Zero liquidations under 23.66% BTC crash ===") +} + +// ============================================================================ +// TEST: Moderate_1.025 — Initial HF 1.2–1.4, Target HF 1.025 +// ============================================================================ + +/// Moderate target (HF=1.025) with medium initial spread (1.2–1.4). +/// Balances capital efficiency (low target) with a reasonable starting buffer. +access(all) +fun test_Moderate_1_025_ZeroLiquidations() { + let result = runSimulation( + config: SimConfig( + prices: simulation_ht_vs_aave_prices, + tickIntervalSeconds: 60.0, + numAgents: 5, + fundingPerAgent: 1.0, + yieldAPR: simulation_ht_vs_aave_constants.yieldAPR, + expectedLiquidationCount: 0, + rebalanceInterval: 1, + minHealth: 1.025, + targetHealth: 1.02500001, + maxHealth: UFix64.max, // Python sim has no upper health bound + initialHFLow: 1.2, // Python initial_hf_range + initialHFHigh: 1.4, + ytPoolTVL: simulation_ht_vs_aave_pools["pyusd0_yt"]!.size, + ytPoolConcentration: simulation_ht_vs_aave_pools["pyusd0_yt"]!.concentration + ), + label: "Moderate_1.025" + ) + + Test.assertEqual(0, result.liquidationCount) + Test.assert(result.finalHF > 1.0, message: "Expected final HF > 1.0 but got \(result.finalHF)") + Test.assert(result.lowestHF > 1.0, message: "Expected lowest HF > 1.0 but got \(result.lowestHF)") + // No liquidations means collateral should never decrease from initial funding + Test.assert(result.finalCollateral >= 1.0, message: "Expected collateral >= 1.0 BTC but got \(result.finalCollateral)") + + log("=== TEST PASSED: Moderate_1.025 — Zero liquidations under 23.66% BTC crash ===") +} diff --git a/cadence/tests/simulation_base_case_stress_helpers.cdc b/cadence/tests/simulation_base_case_stress_helpers.cdc new file mode 100644 index 00000000..f21cc526 --- /dev/null +++ b/cadence/tests/simulation_base_case_stress_helpers.cdc @@ -0,0 +1,161 @@ +import Test + +// AUTO-GENERATED from simulation_ht_vs_aave.json — do not edit manually +// Run: python3 generate_fixture.py generate + +access(all) struct SimAgent { + access(all) let count: Int + access(all) let initialHF: UFix64 + access(all) let rebalancingHF: UFix64 + access(all) let targetHF: UFix64 + access(all) let debtPerAgent: UFix64 + access(all) let totalSystemDebt: UFix64 + + init( + count: Int, + initialHF: UFix64, + rebalancingHF: UFix64, + targetHF: UFix64, + debtPerAgent: UFix64, + totalSystemDebt: UFix64 + ) { + self.count = count + self.initialHF = initialHF + self.rebalancingHF = rebalancingHF + self.targetHF = targetHF + self.debtPerAgent = debtPerAgent + self.totalSystemDebt = totalSystemDebt + } +} + +access(all) struct SimPool { + access(all) let size: UFix64 + access(all) let concentration: UFix64 + access(all) let feeTier: UFix64 + + init(size: UFix64, concentration: UFix64, feeTier: UFix64) { + self.size = size + self.concentration = concentration + self.feeTier = feeTier + } +} + +access(all) struct SimConstants { + access(all) let btcCollateralFactor: UFix64 + access(all) let btcLiquidationThreshold: UFix64 + access(all) let yieldAPR: UFix64 + access(all) let directMintYT: Bool + + init( + btcCollateralFactor: UFix64, + btcLiquidationThreshold: UFix64, + yieldAPR: UFix64, + directMintYT: Bool + ) { + self.btcCollateralFactor = btcCollateralFactor + self.btcLiquidationThreshold = btcLiquidationThreshold + self.yieldAPR = yieldAPR + self.directMintYT = directMintYT + } +} + +access(all) let simulation_ht_vs_aave_prices: [UFix64] = [ + 100000.00000000, + 99551.11000000, + 99104.23000000, + 98659.37000000, + 98216.49000000, + 97775.61000000, + 97336.70000000, + 96899.77000000, + 96464.80000000, + 96031.78000000, + 95600.70000000, + 95171.56000000, + 94744.34000000, + 94319.04000000, + 93895.65000000, + 93474.17000000, + 93054.57000000, + 92636.86000000, + 92221.02000000, + 91807.05000000, + 91394.93000000, + 90984.67000000, + 90576.25000000, + 90169.66000000, + 89764.90000000, + 89361.95000000, + 88960.82000000, + 88561.48000000, + 88163.94000000, + 87768.18000000, + 87374.20000000, + 86981.98000000, + 86591.53000000, + 86202.83000000, + 85815.87000000, + 85430.65000000, + 85047.16000000, + 84665.39000000, + 84285.34000000, + 83906.99000000, + 83530.34000000, + 83155.38000000, + 82782.10000000, + 82410.50000000, + 82040.57000000, + 81672.30000000, + 81305.68000000, + 80940.71000000, + 80577.37000000, + 80215.67000000, + 79855.59000000, + 79497.12000000, + 79140.27000000, + 78785.02000000, + 78431.36000000, + 78079.29000000, + 77728.80000000, + 77379.88000000, + 77032.53000000, + 76686.74000000, + 76342.50000000 +] + +access(all) let simulation_ht_vs_aave_agents: [SimAgent] = [ + SimAgent( + count: 5, + initialHF: 1.15000000, + rebalancingHF: 1.05000000, + targetHF: 1.08000000, + debtPerAgent: 133333.00000000, + totalSystemDebt: 666665.00000000 + ) +] + +access(all) let simulation_ht_vs_aave_pools: {String: SimPool} = { + "pyusd0_yt": SimPool( + size: 500000.00000000, + concentration: 0.95000000, + feeTier: 0.00050000 + ), + "pyusd0_flow": SimPool( + size: 500000.00000000, + concentration: 0.80000000, + feeTier: 0.00300000 + ) +} + +access(all) let simulation_ht_vs_aave_constants: SimConstants = SimConstants( + btcCollateralFactor: 0.75000000, + btcLiquidationThreshold: 0.80000000, + yieldAPR: 0.10000000, + directMintYT: true +) + +access(all) let simulation_ht_vs_aave_expectedLiquidationCount: Int = 0 +access(all) let simulation_ht_vs_aave_expectedAllAgentsSurvive: Bool = true + +access(all) let simulation_ht_vs_aave_durationMinutes: Int = 60 +access(all) let simulation_ht_vs_aave_notes: String = "BTC $100K to $76,342.50 (-23.66%) exponential decline over 60 minutes. Source: comprehensive_ht_vs_aave_analysis.py"