From 4a6256b94179643450879fb3645925c5a9793c62 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Feb 2026 20:45:19 +0000 Subject: [PATCH] Revert "Merge pull request 'fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)' (#322) from fix/issue-320 into master" This reverts commit 1e5ac0de80ba9a182a57f8bcd1d1f2c313738f27, reversing changes made to 6526928b67e8e2d8ceb04ec8ed125789525beacb. --- .../script/backtesting/BacktestRunner.s.sol | 533 ------------------ .../script/backtesting/BaselineStrategies.sol | 229 -------- onchain/script/backtesting/Reporter.sol | 233 -------- onchain/script/backtesting/reports/.gitkeep | 0 4 files changed, 995 deletions(-) delete mode 100644 onchain/script/backtesting/BacktestRunner.s.sol delete mode 100644 onchain/script/backtesting/BaselineStrategies.sol delete mode 100644 onchain/script/backtesting/Reporter.sol delete mode 100644 onchain/script/backtesting/reports/.gitkeep diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol deleted file mode 100644 index 027b18b..0000000 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ /dev/null @@ -1,533 +0,0 @@ -// 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 { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.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; - - // Snapshot of LM position composition at simulation start (for IL calculation) - uint256 internal kraikenInitialValue; // total WETH-equivalent value at start - uint256 internal kraikenInitialWeth; // raw WETH portion at start (positions + balance) - uint256 internal kraikenInitialToken; // KRK portion at start (positions + balance) - - // ── 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("WARNING: initial recenter failed -- LM positions may be uninitialised"); - } - - 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; - - // Snapshot LM position value at simulation start for fair P&L and IL measurement - (uint160 sqrtInit,,,,,, ) = pool.slot0(); - (kraikenInitialValue, kraikenInitialWeth, kraikenInitialToken) = _getLmPositionStats(sqrtInit); - - 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 ──────────────────────────────────── - // feesDest is the configured feeDestination for this LM; _scrapePositions() transfers - // fee0 and fee1 directly to feeDestination on every recenter, so these balances are - // the actual LP trading fees distributed to the protocol over the simulation period. - uint256 kraikenFeesWeth = weth.balanceOf(feesDest); - uint256 kraikenFeesToken = kraiken.balanceOf(feesDest); - uint256 kraikenFeesValue = kraikenFeesWeth + (kraikenFeesToken * krkPrice / 1e18); - - // Measure the current market value of the LM's live positions plus its raw balances. - // The LM's positions remain open at end of simulation (no finalize() call), so we - // must compute their value using tick math rather than burning them. - (uint256 kraikenEndPositionValue, , ) = _getLmPositionStats(sqrtFinal); - - // Final value = open position value + fees already distributed to feesDest - uint256 kraikenFinalValue = kraikenEndPositionValue + kraikenFeesValue; - - // P&L relative to measured start value (not a hardcoded constant) - int256 kraikenPnlBps = (int256(kraikenFinalValue) - int256(kraikenInitialValue)) * 10_000 - / int256(kraikenInitialValue > 0 ? kraikenInitialValue : 1); - - // IL: compare open position value (fees already excluded — they left to feesDest) to - // HODL of the same initial token split at final price. - uint256 kraikenHodlFinalValue = kraikenInitialWeth + (kraikenInitialToken * krkPrice / 1e18); - int256 kraikenIlBps = (int256(kraikenEndPositionValue) - int256(kraikenHodlFinalValue)) * 10_000 - / int256(kraikenHodlFinalValue > 0 ? kraikenHodlFinalValue : 1); - - 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: kraikenInitialValue, - finalValueWeth: kraikenFinalValue, - feesEarnedWeth: kraikenFeesWeth, - feesEarnedToken: kraikenFeesToken, - rebalanceCount: kraikenRecenterCount, - ilBps: kraikenIlBps, - 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); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // KrAIken position valuation - // ───────────────────────────────────────────────────────────────────────── - - /// @notice Compute the total WETH-equivalent value of all three LM positions - /// plus the LM's uninvested WETH balance, at the given sqrtPrice. - /// @return totalValueWeth All holdings valued in WETH (wei) - /// @return totalWeth Raw WETH in positions + LM balance (wei) - /// @return totalKrk Raw KRK in positions + LM balance (base units) - function _getLmPositionStats(uint160 sqrtPrice) - internal - view - returns (uint256 totalValueWeth, uint256 totalWeth, uint256 totalKrk) - { - ThreePositionStrategy.Stage[3] memory stages = [ - ThreePositionStrategy.Stage.FLOOR, - ThreePositionStrategy.Stage.ANCHOR, - ThreePositionStrategy.Stage.DISCOVERY - ]; - - for (uint256 i = 0; i < 3; i++) { - (uint128 liquidity, int24 tickLower, int24 tickUpper) = lm.positions(stages[i]); - if (liquidity == 0) continue; - - uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower); - uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper); - (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPrice, sqrtA, sqrtB, liquidity); - - if (token0isWeth) { - totalWeth += amount0; - totalKrk += amount1; - } else { - totalKrk += amount0; - totalWeth += amount1; - } - } - - // Include uninvested balances held directly by the LM - totalWeth += weth.balanceOf(address(lm)); - totalKrk += kraiken.balanceOf(address(lm)); - - uint256 krkPrice = _krkPriceScaled(sqrtPrice, token0isWeth); - totalValueWeth = totalWeth + (totalKrk * krkPrice / 1e18); - } - - // ───────────────────────────────────────────────────────────────────────── - // 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 - // Approximation: shifting sq right by 48 before squaring avoids overflow at typical - // pool prices (sqrtPriceX96 well below 2^128). At extreme tick boundaries - // (sqrtPriceX96 near MAX_SQRT_RATIO ≈ 2^128) p would be ~2^80 and overflow uint256. - // Acceptable for this simulation where the pool is initialised near 1 ETH : 240k KRK. - 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; - } - } -} diff --git a/onchain/script/backtesting/BaselineStrategies.sol b/onchain/script/backtesting/BaselineStrategies.sol deleted file mode 100644 index afd7712..0000000 --- a/onchain/script/backtesting/BaselineStrategies.sol +++ /dev/null @@ -1,229 +0,0 @@ -// 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 { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol"; -import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol"; -import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; - -// ───────────────────────────────────────────────────────────────────────────── -// Full-Range LP Strategy -// ───────────────────────────────────────────────────────────────────────────── - -/// @title FullRangeLPStrategy -/// @notice Single LP position covering the entire valid tick range (MIN_TICK to MAX_TICK). -/// Position is set once at initialization and never rebalanced, giving 100% time-in-range. -contract FullRangeLPStrategy is IUniswapV3MintCallback { - /// @dev Tick boundaries aligned to 200-spacing for the 1% fee pool - int24 public constant TICK_LOWER = -887_200; - int24 public constant TICK_UPPER = 887_200; - - IUniswapV3Pool public immutable pool; - IERC20 public immutable token0; - IERC20 public immutable token1; - bool public immutable token0isWeth; - - uint128 public liquidity; - uint256 public feesCollected0; - uint256 public feesCollected1; - uint256 public totalBlocks; - bool public initialized; - - constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) { - pool = _pool; - token0isWeth = _token0isWeth; - token0 = _token0isWeth ? _weth : _token; - token1 = _token0isWeth ? _token : _weth; - } - - /// @notice Initialize: use current token balances to mint a full-range position. - /// Caller must transfer tokens to this contract before calling. - function initialize() external { - require(!initialized, "already initialized"); - initialized = true; - - uint256 bal0 = token0.balanceOf(address(this)); - uint256 bal1 = token1.balanceOf(address(this)); - (, int24 currentTick,,,,,) = pool.slot0(); - uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick); - uint160 sqrtLower = TickMath.getSqrtRatioAtTick(TICK_LOWER); - uint160 sqrtUpper = TickMath.getSqrtRatioAtTick(TICK_UPPER); - - liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLower, sqrtUpper, bal0, bal1); - if (liquidity > 0) { - pool.mint(address(this), TICK_LOWER, TICK_UPPER, liquidity, ""); - } - } - - /// @notice Record one simulation block. Full-range is always in-range, so this just increments totalBlocks. - function recordBlock() external { - totalBlocks++; - } - - /// @notice Collect accrued fees without changing liquidity. - function collectFees() external { - if (liquidity > 0) { - pool.burn(TICK_LOWER, TICK_UPPER, 0); - } - (uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max); - feesCollected0 += f0; - feesCollected1 += f1; - } - - /// @notice Burn entire position and collect all tokens + fees. - function finalize() external { - if (liquidity > 0) { - pool.burn(TICK_LOWER, TICK_UPPER, liquidity); - (uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max); - feesCollected0 += f0; - feesCollected1 += f1; - liquidity = 0; - } - } - - function getWethBalance() external view returns (uint256) { - return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this)); - } - - function getTokenBalance() external view returns (uint256) { - return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this)); - } - - /// @inheritdoc IUniswapV3MintCallback - function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { - require(msg.sender == address(pool), "not pool"); - if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed); - if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fixed-Width LP Strategy -// ───────────────────────────────────────────────────────────────────────────── - -/// @title FixedWidthLPStrategy -/// @notice LP position ±2000 ticks around current price (200-tick spacing). -/// Rebalances (removes + remints) when price exits the range. -contract FixedWidthLPStrategy is IUniswapV3MintCallback { - int24 public constant HALF_WIDTH = 2_000; // ±2000 ticks from center - int24 public constant TICK_SPACING = 200; - int24 public constant MIN_TICK = -887_200; - int24 public constant MAX_TICK = 887_200; - - IUniswapV3Pool public immutable pool; - IERC20 public immutable token0; - IERC20 public immutable token1; - bool public immutable token0isWeth; - - int24 public tickLower; - int24 public tickUpper; - uint128 public liquidity; - - uint256 public rebalanceCount; - uint256 public feesCollected0; - uint256 public feesCollected1; - uint256 public blocksInRange; - uint256 public totalBlocks; - bool public initialized; - - constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) { - pool = _pool; - token0isWeth = _token0isWeth; - token0 = _token0isWeth ? _weth : _token; - token1 = _token0isWeth ? _token : _weth; - } - - /// @notice Initialize around the current pool price. - /// Caller must transfer tokens to this contract before calling. - function initialize() external { - require(!initialized, "already initialized"); - initialized = true; - (, int24 currentTick,,,,,) = pool.slot0(); - _createPosition(currentTick); - } - - /// @notice Record one simulation block; tracks time-in-range. - function recordBlock() external { - totalBlocks++; - (, int24 currentTick,,,,,) = pool.slot0(); - if (currentTick >= tickLower && currentTick < tickUpper) { - blocksInRange++; - } - } - - /// @notice Rebalance if current price is outside the position range. - /// @return didRebalance true if a rebalance occurred. - function maybeRebalance() external returns (bool didRebalance) { - (, int24 currentTick,,,,,) = pool.slot0(); - if (currentTick >= tickLower && currentTick < tickUpper) return false; - - _removePosition(); - _createPosition(currentTick); - rebalanceCount++; - return true; - } - - /// @notice Collect accrued fees without changing liquidity. - function collectFees() external { - if (liquidity > 0) { - pool.burn(tickLower, tickUpper, 0); - } - (uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); - feesCollected0 += f0; - feesCollected1 += f1; - } - - /// @notice Burn current position and collect everything. - function finalize() external { - _removePosition(); - } - - function getWethBalance() external view returns (uint256) { - return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this)); - } - - function getTokenBalance() external view returns (uint256) { - return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this)); - } - - // ── Internal ────────────────────────────────────────────────────────────── - - function _createPosition(int24 currentTick) internal { - int24 center = (currentTick / TICK_SPACING) * TICK_SPACING; - tickLower = center - HALF_WIDTH; - tickUpper = center + HALF_WIDTH; - - // Clamp to pool boundaries - if (tickLower < MIN_TICK) tickLower = ((MIN_TICK / TICK_SPACING) + 1) * TICK_SPACING; - if (tickUpper > MAX_TICK) tickUpper = (MAX_TICK / TICK_SPACING) * TICK_SPACING; - - uint256 bal0 = token0.balanceOf(address(this)); - uint256 bal1 = token1.balanceOf(address(this)); - uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick); - uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower); - uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper); - - liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLow, sqrtHigh, bal0, bal1); - if (liquidity > 0) { - pool.mint(address(this), tickLower, tickUpper, liquidity, ""); - } - } - - function _removePosition() internal { - if (liquidity > 0) { - pool.burn(tickLower, tickUpper, liquidity); - (uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); - feesCollected0 += f0; - feesCollected1 += f1; - liquidity = 0; - } - } - - /// @inheritdoc IUniswapV3MintCallback - function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { - require(msg.sender == address(pool), "not pool"); - if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed); - if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed); - } -} diff --git a/onchain/script/backtesting/Reporter.sol b/onchain/script/backtesting/Reporter.sol deleted file mode 100644 index 0eef24b..0000000 --- a/onchain/script/backtesting/Reporter.sol +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; - -import "forge-std/Script.sol"; -import "forge-std/console2.sol"; - -/// @notice Data for a single strategy's backtest result -struct StrategyReport { - string name; - uint256 initialValueWeth; // wei - uint256 finalValueWeth; // wei (position value + fees) - uint256 feesEarnedWeth; // WETH-denominated fees (wei) - uint256 feesEarnedToken; // Token-denominated fees (token base units) - uint256 rebalanceCount; - int256 ilBps; // Impermanent loss in basis points (0 or negative vs HODL) - int256 netPnlBps; // Net P&L in basis points vs initial capital - uint256 timeInRangeBps; // 0–10000 (10000 = 100%; ignored when hasTimeInRange=false) - bool hasTimeInRange; // false for HODL -} - -/// @notice Backtest configuration metadata written to the report -struct BacktestConfig { - address pool; - uint256 startBlock; - uint256 endBlock; - uint256 startTimestamp; - uint256 endTimestamp; - uint256 initialCapitalWei; - uint256 recenterInterval; -} - -/// @title Reporter -/// @notice Abstract mixin that generates Markdown and JSON backtest reports. -/// Inherit alongside Script to get access to vm and file I/O. -abstract contract Reporter is Script { - // ── Public entry point ──────────────────────────────────────────────────── - - /// @notice Write markdown + JSON reports to script/backtesting/reports/. - function _writeReport(StrategyReport[] memory reports, BacktestConfig memory config) internal { - string memory ts = vm.toString(block.timestamp); - string memory mdPath = string(abi.encodePacked("script/backtesting/reports/report-", ts, ".md")); - string memory jsonPath = string(abi.encodePacked("script/backtesting/reports/report-", ts, ".json")); - - vm.writeFile(mdPath, _buildMarkdown(reports, config)); - vm.writeFile(jsonPath, _buildJson(reports, config)); - - console2.log("\n=== Backtest Report ==="); - console2.log("Markdown:", mdPath); - console2.log(" JSON:", jsonPath); - } - - // ── Markdown builder ────────────────────────────────────────────────────── - - function _buildMarkdown(StrategyReport[] memory reports, BacktestConfig memory config) internal view returns (string memory out) { - out = "# Backtest Report: KRK/WETH 1%, 7 days\n\n"; - - // Table header - out = string( - abi.encodePacked( - out, - "| Strategy | Final Value | Fees Earned | Rebalances | IL (%) | Net P&L (%) | Time in Range |\n", - "|---|---|---|---|---|---|---|\n" - ) - ); - - // Table rows - for (uint256 i = 0; i < reports.length; i++) { - out = string(abi.encodePacked(out, _markdownRow(reports[i]), "\n")); - } - - // Configuration section - out = string( - abi.encodePacked( - out, - "\n## Configuration\n", - "- Pool: `", - vm.toString(config.pool), - "`\n", - "- Period: block ", - vm.toString(config.startBlock), - " to block ", - vm.toString(config.endBlock), - " (7 days simulated)\n", - "- Initial capital: 10 ETH equivalent\n", - "- Recenter interval: ", - vm.toString(config.recenterInterval), - " blocks\n" - ) - ); - } - - function _markdownRow(StrategyReport memory r) internal view returns (string memory) { - string memory tir = r.hasTimeInRange ? _fmtPct(r.timeInRangeBps) : "N/A"; - return string( - abi.encodePacked( - "| ", - r.name, - " | ", - _fmtEth(r.finalValueWeth), - " | ", - _fmtEth(r.feesEarnedWeth), - " | ", - vm.toString(r.rebalanceCount), - " | ", - _fmtSignedPct(r.ilBps), - " | ", - _fmtSignedPct(r.netPnlBps), - " | ", - tir, - " |" - ) - ); - } - - // ── JSON builder ────────────────────────────────────────────────────────── - - function _buildJson(StrategyReport[] memory reports, BacktestConfig memory config) internal view returns (string memory) { - string memory strategiesArr = "["; - for (uint256 i = 0; i < reports.length; i++) { - if (i > 0) strategiesArr = string(abi.encodePacked(strategiesArr, ",")); - strategiesArr = string(abi.encodePacked(strategiesArr, _strategyJson(reports[i]))); - } - strategiesArr = string(abi.encodePacked(strategiesArr, "]")); - - return string( - abi.encodePacked( - '{"report":{"timestamp":', - vm.toString(block.timestamp), - ',"strategies":', - strategiesArr, - '},"config":{"pool":"', - vm.toString(config.pool), - '","startBlock":', - vm.toString(config.startBlock), - ',"endBlock":', - vm.toString(config.endBlock), - ',"durationDays":7,"initialCapitalWei":"', - vm.toString(config.initialCapitalWei), - '","recenterInterval":', - vm.toString(config.recenterInterval), - "}}" - ) - ); - } - - function _strategyJson(StrategyReport memory r) internal view returns (string memory) { - string memory tir = r.hasTimeInRange ? vm.toString(r.timeInRangeBps) : '"N/A"'; - return string( - abi.encodePacked( - '{"name":"', - r.name, - '","initialValueWei":"', - vm.toString(r.initialValueWeth), - '","finalValueWei":"', - vm.toString(r.finalValueWeth), - '","feesEarnedWei":"', - vm.toString(r.feesEarnedWeth), - '","rebalances":', - vm.toString(r.rebalanceCount), - ',"ilBps":', - _intStr(r.ilBps), - ',"netPnlBps":', - _intStr(r.netPnlBps), - ',"timeInRangeBps":', - tir, - "}" - ) - ); - } - - // ── Formatting helpers ──────────────────────────────────────────────────── - - /// @dev Format wei amount as "X.XXXX ETH" - function _fmtEth(uint256 weiAmt) internal view returns (string memory) { - uint256 whole = weiAmt / 1e18; - uint256 frac = (weiAmt % 1e18) * 10_000 / 1e18; - return string(abi.encodePacked(vm.toString(whole), ".", _pad4(frac), " ETH")); - } - - /// @dev Format basis points as signed percentage "+X.XX%" or "-X.XX%" - function _fmtSignedPct(int256 bps) internal view returns (string memory) { - if (bps < 0) { - uint256 abs = uint256(-bps); - return string(abi.encodePacked("-", vm.toString(abs / 100), ".", _pad2(abs % 100), "%")); - } - uint256 u = uint256(bps); - return string(abi.encodePacked("+", vm.toString(u / 100), ".", _pad2(u % 100), "%")); - } - - /// @dev Format basis points as percentage "X.XX%" - function _fmtPct(uint256 bps) internal view returns (string memory) { - return string(abi.encodePacked(vm.toString(bps / 100), ".", _pad2(bps % 100), "%")); - } - - /// @dev Convert int256 to decimal string (no leading sign for positive) - function _intStr(int256 n) internal view returns (string memory) { - if (n < 0) { - return string(abi.encodePacked("-", vm.toString(uint256(-n)))); - } - return vm.toString(uint256(n)); - } - - /// @dev Pad to 4 decimal digits with leading zeros - function _pad4(uint256 n) internal pure returns (string memory) { - if (n >= 1000) return _numStr(n); - if (n >= 100) return string(abi.encodePacked("0", _numStr(n))); - if (n >= 10) return string(abi.encodePacked("00", _numStr(n))); - return string(abi.encodePacked("000", _numStr(n))); - } - - /// @dev Pad to 2 decimal digits with leading zeros - function _pad2(uint256 n) internal pure returns (string memory) { - if (n >= 10) return _numStr(n); - return string(abi.encodePacked("0", _numStr(n))); - } - - /// @dev Pure uint-to-decimal-string (avoids vm dependency for small helpers) - function _numStr(uint256 n) internal pure returns (string memory) { - if (n == 0) return "0"; - uint256 len; - uint256 tmp = n; - while (tmp > 0) { - len++; - tmp /= 10; - } - bytes memory buf = new bytes(len); - for (uint256 i = len; i > 0; i--) { - buf[i - 1] = bytes1(uint8(48 + (n % 10))); - n /= 10; - } - return string(buf); - } -} diff --git a/onchain/script/backtesting/reports/.gitkeep b/onchain/script/backtesting/reports/.gitkeep deleted file mode 100644 index e69de29..0000000