Skip to content

Commit c626940

Browse files
jribbinkclaude
andcommitted
Add finite pool liquidity and arb bot simulation to flash crash moderate test
- MOET:YT pool uses $500K TVL, 95% placed in ±100 ticks (matching Python sim) - Agents trade sequentially through finite-liquidity pool - Arb bot resets pool to peg after each rebalance interval - 10 agents, 60s ticks, rebalance every 5 min (per SIM_VALIDATION_SUMMARY) - Clean up setPoolToPrice transaction: remove concentration param, deduplicate math Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99b3d44 commit c626940

3 files changed

Lines changed: 209 additions & 91 deletions

File tree

cadence/tests/evm_state_helpers.cdc

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ access(all) fun setVaultSharePrice(
2727

2828
/* --- Uniswap V3 Pool State Manipulation --- */
2929

30-
/// Set Uniswap V3 pool to a specific price via EVM.store
31-
/// Creates pool if it doesn't exist, then manipulates state
32-
/// Price is specified as UFix128 for high precision (24 decimal places)
30+
/// Set Uniswap V3 pool to a specific price with infinite liquidity (zero slippage).
3331
access(all) fun setPoolToPrice(
3432
factoryAddress: String,
3533
tokenAAddress: String,
@@ -45,7 +43,37 @@ access(all) fun setPoolToPrice(
4543
code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"),
4644
authorizers: [signer.address],
4745
signers: [signer],
48-
arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot]
46+
arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, 0.0, 0 as Int256, 0.0, 1.0]
47+
)
48+
)
49+
Test.expect(seedResult, Test.beSucceeded())
50+
}
51+
52+
/// Set Uniswap V3 pool to a specific price with finite TVL and concentrated liquidity.
53+
/// tvl: total pool TVL in USD (e.g. 500_000.0)
54+
/// tickRange: ±ticks from current price (e.g. 100 = ±1% for tick_spacing=10)
55+
/// tvlFraction: fraction of TVL placed as liquidity (e.g. 0.95 = 95%)
56+
/// tokenBPriceUSD: USD price of tokenB (1.0 for stablecoins)
57+
access(all) fun setPoolToPriceWithTVL(
58+
factoryAddress: String,
59+
tokenAAddress: String,
60+
tokenBAddress: String,
61+
fee: UInt64,
62+
priceTokenBPerTokenA: UFix128,
63+
tokenABalanceSlot: UInt256,
64+
tokenBBalanceSlot: UInt256,
65+
signer: Test.TestAccount,
66+
tvl: UFix64,
67+
tickRange: Int,
68+
tvlFraction: UFix64,
69+
tokenBPriceUSD: UFix64
70+
) {
71+
let seedResult = Test.executeTransaction(
72+
Test.Transaction(
73+
code: Test.readFile("transactions/set_uniswap_v3_pool_price.cdc"),
74+
authorizers: [signer.address],
75+
signers: [signer],
76+
arguments: [factoryAddress, tokenAAddress, tokenBAddress, fee, priceTokenBPerTokenA, tokenABalanceSlot, tokenBBalanceSlot, tvl, Int256(tickRange), tvlFraction, tokenBPriceUSD]
4977
)
5078
)
5179
Test.expect(seedResult, Test.beSucceeded())

cadence/tests/simulation_flash_crash_moderate.cdc

Lines changed: 95 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ access(all) struct SimConfig {
7878
access(all) let maxHealth: UFix64
7979
/// Initial effective HF to coerce the position to at creation (matches Python initial_hf)
8080
access(all) let initialHF: UFix64
81+
/// MOET:YT pool TVL in USD (from fixture's moet_yt.size)
82+
access(all) let moetYtPoolTVL: UFix64
83+
/// MOET:YT pool TVL fraction — fraction of TVL placed as liquidity (0.95 = 95%)
84+
access(all) let moetYtPoolTVLFraction: UFix64
85+
/// MOET:YT pool tick range — ±ticks from peg (100 = Python sim default)
86+
access(all) let moetYtPoolTickRange: Int
8187

8288
init(
8389
prices: [UFix64],
@@ -90,7 +96,10 @@ access(all) struct SimConfig {
9096
minHealth: UFix64,
9197
targetHealth: UFix64,
9298
maxHealth: UFix64,
93-
initialHF: UFix64
99+
initialHF: UFix64,
100+
moetYtPoolTVL: UFix64,
101+
moetYtPoolTVLFraction: UFix64,
102+
moetYtPoolTickRange: Int
94103
) {
95104
self.prices = prices
96105
self.tickIntervalSeconds = tickIntervalSeconds
@@ -103,6 +112,9 @@ access(all) struct SimConfig {
103112
self.targetHealth = targetHealth
104113
self.maxHealth = maxHealth
105114
self.initialHF = initialHF
115+
self.moetYtPoolTVL = moetYtPoolTVL
116+
self.moetYtPoolTVLFraction = moetYtPoolTVLFraction
117+
self.moetYtPoolTickRange = moetYtPoolTickRange
106118
}
107119
}
108120

@@ -151,16 +163,21 @@ fun setup() {
151163
signer: coaOwnerAccount
152164
)
153165

154-
// MOET:morphoVault (yield token pool)
155-
setPoolToPrice(
166+
// MOET:morphoVault (yield token pool) — finite liquidity matching Python sim
167+
let moetYtPool = simulation_flash_crash_moderate_pools["moet_yt"]!
168+
setPoolToPriceWithTVL(
156169
factoryAddress: factoryAddress,
157170
tokenAAddress: moetAddress,
158171
tokenBAddress: morphoVaultAddress,
159172
fee: 100,
160173
priceTokenBPerTokenA: 1.0,
161174
tokenABalanceSlot: moetBalanceSlot,
162175
tokenBBalanceSlot: fusdevBalanceSlot,
163-
signer: coaOwnerAccount
176+
signer: coaOwnerAccount,
177+
tvl: moetYtPool.size,
178+
tickRange: 100,
179+
tvlFraction: moetYtPool.concentration,
180+
tokenBPriceUSD: 1.0
164181
)
165182

166183
// MOET:PYUSD0 (routing pool)
@@ -175,7 +192,7 @@ fun setup() {
175192
signer: coaOwnerAccount
176193
)
177194

178-
// PYUSD0:WBTC (collateral/liquidation pool) — infinite liquidity for now
195+
// PYUSD0:WBTC (collateral/liquidation pool) — infinite liquidity
179196
let initialBtcPrice = simulation_flash_crash_moderate_prices[0]
180197
setPoolToPrice(
181198
factoryAddress: factoryAddress,
@@ -225,14 +242,15 @@ access(all) fun ytPriceAtTick(_ tick: Int, tickIntervalSeconds: UFix64, yieldAPR
225242
return 1.0 + yieldAPR * (elapsedSeconds / secondsPerYear)
226243
}
227244

228-
/// Update all prices for a given simulation tick.
245+
/// Update oracle, collateral pool, and vault share price each tick.
246+
/// Does NOT touch the MOET/FUSDEV pool — that's managed by the arb bot reset.
229247
access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.TestAccount) {
230248
setBandOraclePrices(signer: bandOracleAccount, symbolPrices: {
231249
"BTC": btcPrice,
232250
"USD": 1.0
233251
})
234252

235-
// PYUSD0:WBTC pool — update BTC price
253+
// PYUSD0:WBTC pool — update BTC price (infinite liquidity)
236254
setPoolToPrice(
237255
factoryAddress: factoryAddress,
238256
tokenAAddress: wbtcAddress,
@@ -244,18 +262,6 @@ access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.T
244262
signer: coaOwnerAccount
245263
)
246264

247-
// MOET:FUSDEV pool — update YT price
248-
setPoolToPrice(
249-
factoryAddress: factoryAddress,
250-
tokenAAddress: moetAddress,
251-
tokenBAddress: morphoVaultAddress,
252-
fee: 100,
253-
priceTokenBPerTokenA: UFix128(ytPrice),
254-
tokenABalanceSlot: moetBalanceSlot,
255-
tokenBBalanceSlot: fusdevBalanceSlot,
256-
signer: coaOwnerAccount
257-
)
258-
259265
setVaultSharePrice(
260266
vaultAddress: morphoVaultAddress,
261267
assetAddress: pyusd0Address,
@@ -267,6 +273,25 @@ access(all) fun applyPriceTick(btcPrice: UFix64, ytPrice: UFix64, signer: Test.T
267273
)
268274
}
269275

276+
/// Arb bot simulation: reset MOET/FUSDEV pool to peg with finite TVL.
277+
/// Called after all agents trade each tick.
278+
access(all) fun resetYieldPoolToFiniteTVL(ytPrice: UFix64, tvl: UFix64, tvlFraction: UFix64, tickRange: Int) {
279+
setPoolToPriceWithTVL(
280+
factoryAddress: factoryAddress,
281+
tokenAAddress: moetAddress,
282+
tokenBAddress: morphoVaultAddress,
283+
fee: 100,
284+
priceTokenBPerTokenA: UFix128(ytPrice),
285+
tokenABalanceSlot: moetBalanceSlot,
286+
tokenBBalanceSlot: fusdevBalanceSlot,
287+
signer: coaOwnerAccount,
288+
tvl: tvl,
289+
tickRange: tickRange,
290+
tvlFraction: tvlFraction,
291+
tokenBPriceUSD: 1.0
292+
)
293+
}
294+
270295
// ============================================================================
271296
// SIMULATION RUNNER
272297
// ============================================================================
@@ -316,8 +341,6 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult {
316341
let vaultId = yieldVaultIDs[0]
317342

318343
// Step 1: Coerce position to the desired initial HF.
319-
// Set temporary health params with targetHealth=initialHF, then force-rebalance.
320-
// This makes the on-chain rebalancer push the position to exactly initialHF.
321344
setPositionHealth(
322345
signer: flowALPAccount,
323346
pid: pid,
@@ -379,56 +402,62 @@ access(all) fun runSimulation(config: SimConfig, label: String): SimResult {
379402
highestPrice = absolutePrice
380403
}
381404

382-
if absolutePrice != previousBTCPrice {
383-
let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds
384-
let currentTimestamp = getCurrentBlockTimestamp()
385-
if expectedTimestamp > currentTimestamp {
386-
Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp))
387-
}
405+
if absolutePrice == previousBTCPrice {
406+
step = step + 1
407+
continue
408+
}
409+
410+
let expectedTimestamp = startTimestamp + UFix64(step) * config.tickIntervalSeconds
411+
let currentTimestamp = getCurrentBlockTimestamp()
412+
if expectedTimestamp > currentTimestamp {
413+
Test.moveTime(by: Fix64(expectedTimestamp - currentTimestamp))
414+
}
388415

389-
applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0])
416+
applyPriceTick(btcPrice: absolutePrice, ytPrice: ytPrice, signer: users[0])
390417

391-
// Calculate HF BEFORE rebalancing
392-
var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false))
418+
// Calculate HF BEFORE rebalancing
419+
var preRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false))
393420

394-
// Rebalance agents on interval
395-
if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 {
396-
var a = 0
397-
while a < config.numAgents {
398-
rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false)
399-
rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false)
400-
a = a + 1
401-
}
421+
// Rebalance agents sequentially — each swap moves pool price for next agent
422+
if config.rebalanceInterval <= 1 || step % config.rebalanceInterval == 0 {
423+
var a = 0
424+
while a < config.numAgents {
425+
rebalanceYieldVault(signer: flowYieldVaultsAccount, id: vaultIds[a], force: false, beFailed: false)
426+
rebalancePosition(signer: flowALPAccount, pid: pids[a], force: false, beFailed: false)
427+
a = a + 1
402428
}
403429

404-
// Count actual rebalances that occurred this tick
405-
let currentVaultRebalanceCount = Test.eventsOfType(Type<DeFiActions.Rebalanced>()).length
406-
let currentPositionRebalanceCount = Test.eventsOfType(Type<FlowALPv0.Rebalanced>()).length
407-
let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount
408-
let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount
409-
prevVaultRebalanceCount = currentVaultRebalanceCount
410-
prevPositionRebalanceCount = currentPositionRebalanceCount
430+
// Arb bot: reset MOET:FUSDEV pool to peg with finite TVL
431+
resetYieldPoolToFiniteTVL(ytPrice: ytPrice, tvl: config.moetYtPoolTVL, tvlFraction: config.moetYtPoolTVLFraction, tickRange: config.moetYtPoolTickRange)
432+
}
411433

412-
// Calculate HF AFTER rebalancing
413-
var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false))
434+
// Count actual rebalances that occurred this tick
435+
let currentVaultRebalanceCount = Test.eventsOfType(Type<DeFiActions.Rebalanced>()).length
436+
let currentPositionRebalanceCount = Test.eventsOfType(Type<FlowALPv0.Rebalanced>()).length
437+
let tickVaultRebalances = currentVaultRebalanceCount - prevVaultRebalanceCount
438+
let tickPositionRebalances = currentPositionRebalanceCount - prevPositionRebalanceCount
439+
prevVaultRebalanceCount = currentVaultRebalanceCount
440+
prevPositionRebalanceCount = currentPositionRebalanceCount
414441

415-
// Track lowest HF (use pre-rebalance to capture the actual low point)
416-
if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 {
417-
lowestHF = preRebalanceHF
418-
}
442+
// Calculate HF AFTER rebalancing
443+
var postRebalanceHF: UFix64 = UFix64(getPositionHealth(pid: pids[0], beFailed: false))
419444

420-
// Log every tick with pre->post HF
421-
log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)")
445+
// Track lowest HF (use pre-rebalance to capture the actual low point)
446+
if preRebalanceHF < lowestHF && preRebalanceHF > 0.0 {
447+
lowestHF = preRebalanceHF
448+
}
422449

423-
// Liquidation check (pre-rebalance HF is the danger point)
424-
if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 {
425-
liquidationCount = liquidationCount + 1
426-
log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***")
427-
}
450+
// Log every tick with pre->post HF
451+
log(" [t=\(step)] price=$\(absolutePrice) yt=\(ytPrice) HF=\(preRebalanceHF)->\(postRebalanceHF) vaultRebalances=\(tickVaultRebalances) positionRebalances=\(tickPositionRebalances)")
428452

429-
previousBTCPrice = absolutePrice
453+
// Liquidation check (pre-rebalance HF is the danger point)
454+
if preRebalanceHF < 1.0 && preRebalanceHF > 0.0 {
455+
liquidationCount = liquidationCount + 1
456+
log(" *** LIQUIDATION agent=0 at t=\(step)! HF=\(preRebalanceHF) ***")
430457
}
431458

459+
previousBTCPrice = absolutePrice
460+
432461
step = step + 1
433462
}
434463

@@ -487,16 +516,19 @@ fun test_FlashCrashModerate_ZeroLiquidations() {
487516
let result = runSimulation(
488517
config: SimConfig(
489518
prices: simulation_flash_crash_moderate_prices,
490-
tickIntervalSeconds: 5.0,
491-
numAgents: 5,
519+
tickIntervalSeconds: 60.0, // 1 min per tick, matching Python sim
520+
numAgents: 10,
492521
fundingPerAgent: 1.0,
493522
yieldAPR: simulation_flash_crash_moderate_constants.yieldAPR,
494523
expectedLiquidationCount: 0,
495-
rebalanceInterval: 1,
524+
rebalanceInterval: 5, // every 5 ticks
496525
minHealth: 1.05,
497526
targetHealth: 1.08,
498527
maxHealth: UFix64.max, // Python sim has no upper health bound
499-
initialHF: 1.15
528+
initialHF: 1.15,
529+
moetYtPoolTVL: simulation_flash_crash_moderate_pools["moet_yt"]!.size,
530+
moetYtPoolTVLFraction: simulation_flash_crash_moderate_pools["moet_yt"]!.concentration,
531+
moetYtPoolTickRange: 100 // Python sim: ±100 ticks (~1% range)
500532
),
501533
label: "FlashCrashModerate"
502534
)

0 commit comments

Comments
 (0)