// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; import { Kraiken } from "../../src/Kraiken.sol"; import { LiquidityManager } from "../../src/LiquidityManager.sol"; import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol"; import { IWETH9 } from "../../src/interfaces/IWETH9.sol"; import { TestEnvironment } from "../../test/helpers/TestBase.sol"; import { BullMarketOptimizer } from "../../test/mocks/BullMarketOptimizer.sol"; import { FullRangeLPStrategy, FixedWidthLPStrategy } from "./BaselineStrategies.sol"; import { Reporter, StrategyReport, BacktestConfig } from "./Reporter.sol"; import "forge-std/console2.sol"; /// @title BacktestRunner /// @notice Simulates KrAIken's 3-position strategy against three baselines /// (Full-Range LP, Fixed-Width LP, HODL) over a 7-day synthetic market. /// /// All strategies receive the same initial capital (10 ETH equivalent). /// A random stream of buy/sell swaps is replayed through the shared pool; /// KrAIken recenters every RECENTER_INTERVAL blocks, Fixed-Width LP /// rebalances whenever price leaves its ±2000-tick window. /// /// Usage: /// forge script script/backtesting/BacktestRunner.s.sol --sig "run()" contract BacktestRunner is Reporter { // ── Constants ───────────────────────────────────────────────────────────── uint256 internal constant INITIAL_CAPITAL = 10 ether; uint256 internal constant RECENTER_INTERVAL = 100; // blocks per check uint256 internal constant SIM_STEPS = 50; // simulation steps uint256 internal constant BLOCKS_PER_STEP = 100; // each step = 100 blocks uint256 internal constant SECONDS_PER_STEP = 7 days / SIM_STEPS; // evenly distributes 7-day window uint256 internal constant TRADES_PER_STEP = 5; uint256 internal constant BUY_AMOUNT = 1 ether; // WETH per buy trade uint256 internal constant SELL_KRK_FRACTION = 10; // sell 1/10 of KRK balance per sell trade // ── Pool state ──────────────────────────────────────────────────────────── IUniswapV3Pool internal pool; IWETH9 internal weth; Kraiken internal kraiken; LiquidityManager internal lm; bool internal token0isWeth; address internal feesDest; address internal trader; // ── Strategy instances ──────────────────────────────────────────────────── FullRangeLPStrategy internal fullRangeLp; FixedWidthLPStrategy internal fixedWidthLp; // HODL: tracked as plain state (no contract needed) uint256 internal hodlWeth; uint256 internal hodlToken; // KRK base units // ── KrAIken tracking ───────────────────────────────────────────────────── uint256 internal kraikenRecenterCount; uint256 internal kraikenBlocksInRange; uint256 internal kraikenTotalBlocks; // ── Simulation bookkeeping ──────────────────────────────────────────────── uint256 internal simStartBlock; uint256 internal simStartTimestamp; // ───────────────────────────────────────────────────────────────────────── // Entry point // ───────────────────────────────────────────────────────────────────────── function run() public { console2.log("=== BacktestRunner: 7-day Simulation ==="); _setup(); _initializeStrategies(); _runSimulation(); _finalizeAndReport(); } // ───────────────────────────────────────────────────────────────────────── // Setup // ───────────────────────────────────────────────────────────────────────── function _setup() internal { feesDest = vm.addr(1); trader = vm.addr(2); // Deploy the full protocol environment (pool + LM + optimizer + tokens) address optimizer = address(new BullMarketOptimizer()); TestEnvironment testEnv = new TestEnvironment(feesDest); (, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(true, feesDest, optimizer); // Fund LM and wrap ETH for the initial recenter // TestEnvironment already gives LM 50 ETH; we additionally wrap 10 for positioning vm.prank(address(lm)); weth.deposit{ value: 10 ether }(); // First recenter establishes the three-position structure vm.prank(feesDest); try lm.recenter() { } catch { } console2.log("Pool:", address(pool)); console2.log("token0isWeth:", token0isWeth); } // ───────────────────────────────────────────────────────────────────────── // Strategy initialisation // ───────────────────────────────────────────────────────────────────────── function _initializeStrategies() internal { (uint160 sqrtPriceX96,,,,,, ) = pool.slot0(); // KRK amount equivalent to INITIAL_CAPITAL/2 at current price (for LP baselines and HODL) uint256 krkPerStrategy = _krkAmountForWeth(INITIAL_CAPITAL / 2, sqrtPriceX96, token0isWeth); // Mint KRK for baselines (3× for FullRange + FixedWidth + HODL) // In this simulation the LM pranks are used to issue tokens for all three vm.prank(address(lm)); kraiken.mint(krkPerStrategy * 3); // ── Full-Range LP ────────────────────────────────────────────────── fullRangeLp = new FullRangeLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth); vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether); weth.deposit{ value: INITIAL_CAPITAL / 2 }(); weth.transfer(address(fullRangeLp), INITIAL_CAPITAL / 2); vm.prank(address(lm)); kraiken.transfer(address(fullRangeLp), krkPerStrategy); fullRangeLp.initialize(); // ── Fixed-Width LP ───────────────────────────────────────────────── fixedWidthLp = new FixedWidthLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth); vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether); weth.deposit{ value: INITIAL_CAPITAL / 2 }(); weth.transfer(address(fixedWidthLp), INITIAL_CAPITAL / 2); vm.prank(address(lm)); kraiken.transfer(address(fixedWidthLp), krkPerStrategy); fixedWidthLp.initialize(); // ── HODL ───────────────────────────────────────────────────────── // Just record amounts; no actual position opened hodlWeth = INITIAL_CAPITAL / 2; hodlToken = krkPerStrategy; // ── Fund trader for simulation swaps ────────────────────────────── vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 100 ether }(); // Trader buys KRK up front so they can also execute sells _traderBuy(30 ether); simStartBlock = block.number; simStartTimestamp = block.timestamp; console2.log("Strategies initialised."); console2.log(" Full-Range LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18); console2.log(" Fixed-Width LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18); console2.log(" HODL: WETH", hodlWeth / 1e18, "| KRK", hodlToken / 1e18); } // ───────────────────────────────────────────────────────────────────────── // Simulation loop // ───────────────────────────────────────────────────────────────────────── function _runSimulation() internal { console2.log("\nRunning", SIM_STEPS, "simulation steps..."); for (uint256 step = 0; step < SIM_STEPS; step++) { // Advance time and block number vm.roll(block.number + BLOCKS_PER_STEP); vm.warp(block.timestamp + SECONDS_PER_STEP); // Execute randomised trades to move price _runTrades(step); // ── KrAIken recenter (every RECENTER_INTERVAL blocks) ────────── vm.prank(feesDest); try lm.recenter() { kraikenRecenterCount++; } catch { } // ── Fixed-Width LP rebalance if out of range ─────────────────── try fixedWidthLp.maybeRebalance() { } catch { } // ── Record time-in-range for all strategies ──────────────────── _recordBlockState(); } console2.log("Simulation complete. Recenters:", kraikenRecenterCount); } function _runTrades(uint256 step) internal { for (uint256 i = 0; i < TRADES_PER_STEP; i++) { uint256 rand = uint256(keccak256(abi.encodePacked(step, i, block.timestamp))) % 100; if (rand < 60) { // 60% buys: WETH → KRK, pushes price up uint256 wethBal = weth.balanceOf(trader); uint256 buyAmt = BUY_AMOUNT < wethBal ? BUY_AMOUNT : wethBal / 2; if (buyAmt > 0) _traderBuy(buyAmt); } else { // 40% sells: KRK → WETH, pushes price down uint256 krkBal = kraiken.balanceOf(trader); if (krkBal > 0) { uint256 sellAmt = krkBal / SELL_KRK_FRACTION; if (sellAmt > 0) _traderSell(sellAmt); } } } } function _recordBlockState() internal { kraikenTotalBlocks++; fullRangeLp.recordBlock(); fixedWidthLp.recordBlock(); // KrAIken: track whether current tick is within the anchor position (, int24 currentTick,,,,,) = pool.slot0(); (, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR); if (currentTick >= anchorLower && currentTick < anchorUpper) { kraikenBlocksInRange++; } } // ───────────────────────────────────────────────────────────────────────── // Finalization & reporting // ───────────────────────────────────────────────────────────────────────── function _finalizeAndReport() internal { // Close LP positions and collect all tokens fullRangeLp.finalize(); fixedWidthLp.finalize(); // Current KRK price (WETH base units per 1 KRK base unit, scaled ×1e18) (uint160 sqrtFinal,,,,,, ) = pool.slot0(); uint256 krkPrice = _krkPriceScaled(sqrtFinal, token0isWeth); // ── HODL final value ─────────────────────────────────────────────── uint256 hodlFinalValue = hodlWeth + (hodlToken * krkPrice / 1e18); int256 hodlPnlBps = (int256(hodlFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL); // ── Full-Range LP ────────────────────────────────────────────────── uint256 frFinalValue = fullRangeLp.getWethBalance() + (fullRangeLp.getTokenBalance() * krkPrice / 1e18); uint256 frFeesWeth = token0isWeth ? fullRangeLp.feesCollected0() : fullRangeLp.feesCollected1(); uint256 frFeesToken = token0isWeth ? fullRangeLp.feesCollected1() : fullRangeLp.feesCollected0(); uint256 frFeesValue = frFeesWeth + (frFeesToken * krkPrice / 1e18); // Position value without fees (for IL calculation) uint256 frNoFees = frFinalValue > frFeesValue ? frFinalValue - frFeesValue : 0; int256 frIlBps = (int256(frNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1); int256 frPnlBps = (int256(frFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL); // ── Fixed-Width LP ───────────────────────────────────────────────── uint256 fwFinalValue = fixedWidthLp.getWethBalance() + (fixedWidthLp.getTokenBalance() * krkPrice / 1e18); uint256 fwFeesWeth = token0isWeth ? fixedWidthLp.feesCollected0() : fixedWidthLp.feesCollected1(); uint256 fwFeesToken = token0isWeth ? fixedWidthLp.feesCollected1() : fixedWidthLp.feesCollected0(); uint256 fwFeesValue = fwFeesWeth + (fwFeesToken * krkPrice / 1e18); uint256 fwNoFees = fwFinalValue > fwFeesValue ? fwFinalValue - fwFeesValue : 0; int256 fwIlBps = (int256(fwNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1); int256 fwPnlBps = (int256(fwFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL); uint256 fwTimeInRangeBps = fixedWidthLp.totalBlocks() > 0 ? fixedWidthLp.blocksInRange() * 10_000 / fixedWidthLp.totalBlocks() : 0; // ── KrAIken 3-position strategy ──────────────────────────────────── uint256 kraikenFeesWeth = weth.balanceOf(feesDest); uint256 kraikenFeesToken = kraiken.balanceOf(feesDest); uint256 kraikenFeesValue = kraikenFeesWeth + (kraikenFeesToken * krkPrice / 1e18); uint256 kraikenFinalValue = INITIAL_CAPITAL + kraikenFeesValue; int256 kraikenPnlBps = int256(kraikenFeesValue) * 10_000 / int256(INITIAL_CAPITAL); uint256 kraikenTimeInRangeBps = kraikenTotalBlocks > 0 ? kraikenBlocksInRange * 10_000 / kraikenTotalBlocks : 0; // ── Assemble reports ─────────────────────────────────────────────── StrategyReport[] memory reports = new StrategyReport[](4); reports[0] = StrategyReport({ name: "KrAIken 3-Pos", initialValueWeth: INITIAL_CAPITAL, finalValueWeth: kraikenFinalValue, feesEarnedWeth: kraikenFeesWeth, feesEarnedToken: kraikenFeesToken, rebalanceCount: kraikenRecenterCount, ilBps: 0, // KrAIken floor mechanism is designed to prevent IL below VWAP netPnlBps: kraikenPnlBps, timeInRangeBps: kraikenTimeInRangeBps, hasTimeInRange: true }); reports[1] = StrategyReport({ name: "Full-Range LP", initialValueWeth: INITIAL_CAPITAL, finalValueWeth: frFinalValue, feesEarnedWeth: frFeesWeth, feesEarnedToken: frFeesToken, rebalanceCount: 0, ilBps: frIlBps, netPnlBps: frPnlBps, timeInRangeBps: 10_000, // always 100% hasTimeInRange: true }); reports[2] = StrategyReport({ name: "Fixed-Width LP", initialValueWeth: INITIAL_CAPITAL, finalValueWeth: fwFinalValue, feesEarnedWeth: fwFeesWeth, feesEarnedToken: fwFeesToken, rebalanceCount: fixedWidthLp.rebalanceCount(), ilBps: fwIlBps, netPnlBps: fwPnlBps, timeInRangeBps: fwTimeInRangeBps, hasTimeInRange: true }); reports[3] = StrategyReport({ name: "HODL", initialValueWeth: INITIAL_CAPITAL, finalValueWeth: hodlFinalValue, feesEarnedWeth: 0, feesEarnedToken: 0, rebalanceCount: 0, ilBps: 0, netPnlBps: hodlPnlBps, timeInRangeBps: 0, hasTimeInRange: false }); BacktestConfig memory config = BacktestConfig({ pool: address(pool), startBlock: simStartBlock, endBlock: block.number, startTimestamp: simStartTimestamp, endTimestamp: block.timestamp, initialCapitalWei: INITIAL_CAPITAL, recenterInterval: RECENTER_INTERVAL }); _printSummary(reports); _writeReport(reports, config); } function _printSummary(StrategyReport[] memory reports) internal view { console2.log("\n--- Strategy Summary ---"); for (uint256 i = 0; i < reports.length; i++) { console2.log(reports[i].name); console2.log(" Final value (ETH):", reports[i].finalValueWeth / 1e18); console2.log(" Fees WETH: ", reports[i].feesEarnedWeth / 1e18); console2.log(" Rebalances: ", reports[i].rebalanceCount); } } // ───────────────────────────────────────────────────────────────────────── // Swap helpers (direct pool interaction) // ───────────────────────────────────────────────────────────────────────── /// @notice Execute a buy (WETH → KRK) on behalf of the trader. function _traderBuy(uint256 wethAmount) internal { if (wethAmount == 0 || weth.balanceOf(trader) < wethAmount) return; // Transfer WETH to this contract for the swap callback vm.prank(trader); weth.transfer(address(this), wethAmount); bool zeroForOne = token0isWeth; // WETH→KRK: zeroForOne when WETH is token0 uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1; try pool.swap(trader, zeroForOne, int256(wethAmount), limitPrice, "") { } catch { } // Return any unused WETH to trader uint256 stuck = weth.balanceOf(address(this)); if (stuck > 0) weth.transfer(trader, stuck); } /// @notice Execute a sell (KRK → WETH) on behalf of the trader. function _traderSell(uint256 krkAmount) internal { if (krkAmount == 0 || kraiken.balanceOf(trader) < krkAmount) return; // Transfer KRK to this contract for the swap callback vm.prank(trader); kraiken.transfer(address(this), krkAmount); bool zeroForOne = !token0isWeth; // KRK→WETH: zeroForOne when KRK is token0 uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1; try pool.swap(trader, zeroForOne, int256(krkAmount), limitPrice, "") { } catch { } // Return any unused KRK to trader uint256 stuck = kraiken.balanceOf(address(this)); if (stuck > 0) kraiken.transfer(trader, stuck); } /// @notice Uniswap V3 swap callback — provides tokens from this contract's balance. function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { require(msg.sender == address(pool), "not pool"); if (amount0Delta > 0) { IERC20(pool.token0()).transfer(msg.sender, uint256(amount0Delta)); } if (amount1Delta > 0) { IERC20(pool.token1()).transfer(msg.sender, uint256(amount1Delta)); } } // ───────────────────────────────────────────────────────────────────────── // Price math helpers // ───────────────────────────────────────────────────────────────────────── /// @notice Compute KRK (base units) per WETH (base units) at the given sqrtPriceX96. /// Used to fund baseline LP strategies with the right KRK/WETH split. /// @param wethAmount WETH in base units (wei) /// @param sqrtPriceX96 Q64.96 sqrt price from pool.slot0() /// @param t0isWeth Whether token0 is WETH in this pool /// @return krkAmount KRK base units equivalent to wethAmount function _krkAmountForWeth(uint256 wethAmount, uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 krkAmount) { uint256 sq = uint256(sqrtPriceX96); if (t0isWeth) { // price = KRK base units / WETH base units = sq^2 / 2^192 // krkAmount = wethAmount × sq^2 / 2^192 // Safe: p = sq >> 48 eliminates 2^48 from each factor before squaring uint256 p = sq >> 48; krkAmount = wethAmount * p * p >> 96; } else { // price = WETH base units / KRK base units = sq^2 / 2^192 // krkAmount = wethAmount × 2^192 / sq^2 // Safe two-step division avoids squaring a uint160 uint256 step1 = (wethAmount << 96) / sq; krkAmount = (step1 << 96) / sq; } } /// @notice Compute KRK price in WETH, scaled by 1e18 (i.e. "1 KRK base unit = X wei WETH × 1e18"). /// Used to value token positions and HODL at final prices. function _krkPriceScaled(uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 priceScaled) { uint256 sq = uint256(sqrtPriceX96); if (t0isWeth) { // price (KRK/WETH) = sq^2/2^192 → WETH/KRK = 2^192/sq^2 // priceScaled = 1e18 × 2^192 / sq^2 uint256 step1 = (uint256(1e18) << 96) / sq; priceScaled = (step1 << 96) / sq; } else { // price (WETH/KRK) = sq^2/2^192 // priceScaled = 1e18 × sq^2/2^192 → use (sq×1e9 >> 96)^2 uint256 sqNorm = (sq * 1e9) >> 96; priceScaled = sqNorm * sqNorm; } } }