From 5205ea6f4a9a66e733d83e7c6d06238a81aca690 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Feb 2026 16:11:15 +0000 Subject: [PATCH 1/2] fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320) Co-Authored-By: Claude Sonnet 4.6 --- .../script/backtesting/BacktestRunner.s.sol | 453 ++++++++++++++++++ .../script/backtesting/BaselineStrategies.sol | 229 +++++++++ onchain/script/backtesting/Reporter.sol | 233 +++++++++ onchain/script/backtesting/reports/.gitkeep | 0 4 files changed, 915 insertions(+) create mode 100644 onchain/script/backtesting/BacktestRunner.s.sol create mode 100644 onchain/script/backtesting/BaselineStrategies.sol create mode 100644 onchain/script/backtesting/Reporter.sol create mode 100644 onchain/script/backtesting/reports/.gitkeep diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol new file mode 100644 index 0000000..1edfd4e --- /dev/null +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -0,0 +1,453 @@ +// 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; + } + } +} diff --git a/onchain/script/backtesting/BaselineStrategies.sol b/onchain/script/backtesting/BaselineStrategies.sol new file mode 100644 index 0000000..afd7712 --- /dev/null +++ b/onchain/script/backtesting/BaselineStrategies.sol @@ -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); + } +} diff --git a/onchain/script/backtesting/Reporter.sol b/onchain/script/backtesting/Reporter.sol new file mode 100644 index 0000000..ec71708 --- /dev/null +++ b/onchain/script/backtesting/Reporter.sol @@ -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; // 0–10000 (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: AERO/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); + } +} diff --git a/onchain/script/backtesting/reports/.gitkeep b/onchain/script/backtesting/reports/.gitkeep new file mode 100644 index 0000000..e69de29 From 9061f8e8f601e146f36a6362cf024e5f1a18054b Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Feb 2026 16:39:47 +0000 Subject: [PATCH 2/2] fix: Address AI review findings for backtesting baseline strategies (#320) Co-Authored-By: Claude Sonnet 4.6 --- .../script/backtesting/BacktestRunner.s.sol | 92 +++++++++++++++++-- onchain/script/backtesting/Reporter.sol | 2 +- 2 files changed, 87 insertions(+), 7 deletions(-) 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(