harb/onchain/script/backtesting/BacktestRunner.s.sol

454 lines
24 KiB
Solidity
Raw Normal View History

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