harb/onchain/script/backtesting/BacktestRunner.s.sol
openhands 5205ea6f4a fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:11:15 +00:00

453 lines
24 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}
}