diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 1edfd4e..027b18b 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -4,6 +4,7 @@ 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"; @@ -66,6 +67,11 @@ contract BacktestRunner is Reporter { 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; @@ -104,7 +110,9 @@ contract BacktestRunner is Reporter { // First recenter establishes the three-position structure vm.prank(feesDest); - try lm.recenter() { } catch { } + try lm.recenter() { } catch { + console2.log("WARNING: initial recenter failed -- LM positions may be uninitialised"); + } console2.log("Pool:", address(pool)); console2.log("token0isWeth:", token0isWeth); @@ -163,6 +171,10 @@ contract BacktestRunner is Reporter { 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); @@ -270,11 +282,31 @@ contract BacktestRunner is Reporter { 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); - uint256 kraikenFinalValue = INITIAL_CAPITAL + kraikenFeesValue; - int256 kraikenPnlBps = int256(kraikenFeesValue) * 10_000 / int256(INITIAL_CAPITAL); + + // 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 ─────────────────────────────────────────────── @@ -282,12 +314,12 @@ contract BacktestRunner is Reporter { reports[0] = StrategyReport({ name: "KrAIken 3-Pos", - initialValueWeth: INITIAL_CAPITAL, + initialValueWeth: kraikenInitialValue, finalValueWeth: kraikenFinalValue, feesEarnedWeth: kraikenFeesWeth, feesEarnedToken: kraikenFeesToken, rebalanceCount: kraikenRecenterCount, - ilBps: 0, // KrAIken floor mechanism is designed to prevent IL below VWAP + ilBps: kraikenIlBps, netPnlBps: kraikenPnlBps, timeInRangeBps: kraikenTimeInRangeBps, hasTimeInRange: true @@ -356,6 +388,51 @@ contract BacktestRunner is Reporter { } } + // ───────────────────────────────────────────────────────────────────────── + // 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) // ───────────────────────────────────────────────────────────────────────── @@ -422,7 +499,10 @@ contract BacktestRunner is Reporter { 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 + // 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 { diff --git a/onchain/script/backtesting/Reporter.sol b/onchain/script/backtesting/Reporter.sol index ec71708..0eef24b 100644 --- a/onchain/script/backtesting/Reporter.sol +++ b/onchain/script/backtesting/Reporter.sol @@ -52,7 +52,7 @@ abstract contract Reporter is Script { // ── Markdown builder ────────────────────────────────────────────────────── function _buildMarkdown(StrategyReport[] memory reports, BacktestConfig memory config) internal view returns (string memory out) { - out = "# Backtest Report: AERO/WETH 1%, 7 days\n\n"; + out = "# Backtest Report: KRK/WETH 1%, 7 days\n\n"; // Table header out = string(