Merge pull request 'fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)' (#322) from fix/issue-320 into master

This commit is contained in:
johba 2026-02-26 18:02:54 +01:00
commit 1e5ac0de80
4 changed files with 995 additions and 0 deletions

View file

@ -0,0 +1,533 @@
// 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; // WETHKRK: 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; // KRKWETH: 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;
}
}
}

View file

@ -0,0 +1,229 @@
// 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);
}
}

View file

@ -0,0 +1,233 @@
// 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; // 010000 (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);
}
}