fix: Address AI review findings for backtesting baseline strategies (#320)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-26 16:39:47 +00:00
parent 5205ea6f4a
commit 9061f8e8f6
2 changed files with 87 additions and 7 deletions

View file

@ -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 {

View file

@ -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(