// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import { BacktestKraiken } from "./BacktestKraiken.sol"; import { BaselineStrategies } from "./BaselineStrategies.sol"; import { EventReplayer } from "./EventReplayer.sol"; import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol"; import { MockToken } from "./MockToken.sol"; import { Reporter } from "./Reporter.sol"; import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol"; import { StrategyExecutor } from "./StrategyExecutor.sol"; import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; import { Script } from "forge-std/Script.sol"; import { console2 } from "forge-std/console2.sol"; /** * @title BacktestRunner * @notice Entry point for backtesting. Deploys a UniswapV3 shadow pool that mirrors the * AERO/WETH 1% pool configuration, initialised at the price from the event cache, * deploys the KrAIken protocol on top of the shadow pool, then replays all * Swap/Mint/Burn events from the cache while triggering recenter() at configurable * block intervals. * * Token setup: * Mock WETH (MockToken) and BacktestKraiken are the shadow pool token pair, sorted by * address. Both are freely mintable via MockToken.mint(address,uint256), so EventReplayer * can seed historical LP positions without access-control friction. LiquidityManager * additionally uses BacktestKraiken.mint(uint256) — the Kraiken-compatible overload * restricted to the LM address — for its own liquidity minting. * * Negligible-impact assumption: * KrAIken positions sit in the same shadow pool as replayed historical events. * Events are replayed as-is without adjusting swap amounts for KrAIken's liquidity. * TODO(#319): Add a feedback loop that accounts for KrAIken's active tick ranges * when computing the actual swap amounts seen by each LP. * * Usage: * forge script script/backtesting/BacktestRunner.s.sol \ * --rpc-url http://localhost:8545 --broadcast \ * [BACKTEST_EVENTS_FILE=/path/to/events.jsonl] \ * [INITIAL_SQRT_PRICE_X96=] \ * [RECENTER_INTERVAL=100] \ * [INITIAL_CAPITAL_WETH=10000000000000000000] * * Price resolution order (first that succeeds wins): * 1. Environment variable INITIAL_SQRT_PRICE_X96 * 2. sqrtPriceX96 field from the first line of $BACKTEST_EVENTS_FILE * 3. Default: 2^96 (tick = 0, price 1:1) */ contract BacktestRunner is Script { /// @dev 2^96 — corresponds to tick 0 (price ratio 1:1). uint160 internal constant DEFAULT_SQRT_PRICE_X96 = 79_228_162_514_264_337_593_543_950_336; /// @dev Default number of blocks between recenter attempts (~3 min on Base at 2s/block). uint256 internal constant DEFAULT_RECENTER_INTERVAL = 100; // ------------------------------------------------------------------------- // Price resolution helpers // ------------------------------------------------------------------------- /** * @notice Parse sqrtPriceX96 from the first line of a JSON Lines events file. * @dev Must be `external` so it can be called via `this.` inside a try/catch. * fetch-events.ts serialises BigInt values as decimal strings, so we use * parseJsonString then parseUint rather than parseJsonUint. */ function _parseSqrtPriceFromFile(string memory eventsFile) external view returns (uint160) { string memory line = vm.readLine(eventsFile); string memory sqrtStr = vm.parseJsonString(line, ".sqrtPriceX96"); return uint160(vm.parseUint(sqrtStr)); } /** * @notice Resolve the initial sqrtPriceX96 without broadcasting any transaction. * @dev Call this BEFORE vm.startBroadcast() — the `this.` external call must not * be included in the broadcast. */ function _resolveSqrtPrice() internal view returns (uint160) { // 1. Explicit env override. try vm.envUint("INITIAL_SQRT_PRICE_X96") returns (uint256 val) { if (val != 0) return uint160(val); } catch { } // 2. First Swap event in the events cache. try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) { try this._parseSqrtPriceFromFile(eventsFile) returns (uint160 val) { if (val != 0) return val; } catch { } } catch { } // 3. Fallback default. return DEFAULT_SQRT_PRICE_X96; } // ------------------------------------------------------------------------- // Main entry point // ------------------------------------------------------------------------- function run() external { // Resolve configuration before broadcast so self-calls are not recorded. uint160 sqrtPriceX96 = _resolveSqrtPrice(); uint256 recenterInterval = DEFAULT_RECENTER_INTERVAL; try vm.envUint("RECENTER_INTERVAL") returns (uint256 v) { if (v > 0) recenterInterval = v; } catch { } uint256 initialCapital = KrAIkenDeployer.DEFAULT_INITIAL_CAPITAL; try vm.envUint("INITIAL_CAPITAL_WETH") returns (uint256 v) { if (v > 0) initialCapital = v; } catch { } vm.startBroadcast(); address sender = msg.sender; // ------------------------------------------------------------------ // Token deployment // // BacktestKraiken extends MockToken, so EventReplayer can mint it // freely via MockToken.mint(address,uint256) for historical pool // positions. LiquidityManager calls the restricted mint(uint256) // overload (onlyLiquidityManager) wired by KrAIkenDeployer.deploy(). // ------------------------------------------------------------------ MockToken mockWeth = new MockToken("Mock WETH", "mWETH", 18); BacktestKraiken krk = new BacktestKraiken(); // Seed the broadcaster with mock WETH for manual interactions. mockWeth.mint(sender, 1_000_000 ether); // Deploy factory + pool, sorted by address (token0 < token1). ShadowPool memory sp = ShadowPoolDeployer.deploy(address(mockWeth), address(krk), sqrtPriceX96); // ------------------------------------------------------------------ // KrAIken system deployment (follows DeployLocal.sol pattern) // // 1. Deploy OptimizerV3Push3 (no proxy — getLiquidityParams() uses zeroed // inputs, returning bear-mode defaults on every recenter). // 2. Deploy LiquidityManager pointing at the shadow pool. // 3. Wire BacktestKraiken.setLiquidityManager(lm). // 4. Set feeDestination = sender. // 5. Mint initialCapital mock WETH to LM. // ------------------------------------------------------------------ KrAIkenSystem memory sys = KrAIkenDeployer.deploy(address(sp.factory), address(mockWeth), address(krk), sender, initialCapital); // Deploy StrategyExecutor and grant it recenter access on the LM. // recenterAccess bypasses TWAP stability check and cooldown — correct // for simulation where vm.warp drives time, not a real oracle. // sender == feeDestination, so the onlyFeeDestination guard is satisfied. bool token0isWeth = sp.token0 == address(mockWeth); StrategyExecutor executor = new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth); sys.lm.setRecenterAccess(address(executor)); // Deploy baseline strategies and initialize with the same capital as KrAIken. BaselineStrategies baselines = new BaselineStrategies(sp.pool, MockToken(sp.token0), MockToken(sp.token1), token0isWeth, recenterInterval); baselines.initialize(initialCapital); vm.stopBroadcast(); // Reporter uses Foundry cheatcodes (vm.writeFile) — must live outside the broadcast // block so it is never sent as a real transaction on a live fork. Reporter reporter = new Reporter(); // ------------------------------------------------------------------ // EventReplayer is instantiated outside the broadcast block because // it uses Foundry cheat codes (vm.readLine, vm.roll, vm.warp) that // only work in the forge simulation context and must not be sent as // real transactions. // // sp.token0 / sp.token1 are cast to MockToken — BacktestKraiken // inherits MockToken so the cast is valid; its mint(address,uint256) // selector matches, enabling EventReplayer's mint callback. // ------------------------------------------------------------------ EventReplayer replayer = new EventReplayer(sp.pool, MockToken(sp.token0), MockToken(sp.token1)); // Query pool state (view calls, no broadcast needed). (uint160 slot0SqrtPrice, int24 tick,,,,,) = sp.pool.slot0(); uint128 liquidity = sp.pool.liquidity(); int24 tickSpacing = sp.pool.tickSpacing(); console2.log("=== Backtesting Shadow Pool Deployment ==="); console2.log("Factory: ", address(sp.factory)); console2.log("Pool: ", address(sp.pool)); console2.log("Token0: ", sp.token0); console2.log("Token1: ", sp.token1); console2.log("Replayer: ", address(replayer)); console2.log("Fee tier: ", uint256(ShadowPoolDeployer.SHADOW_FEE)); console2.log("Tick spacing: ", int256(tickSpacing)); console2.log("sqrtPriceX96: ", uint256(slot0SqrtPrice)); console2.log("Initial tick: ", int256(tick)); console2.log("Liquidity: ", uint256(liquidity)); console2.log("\n=== KrAIken System ==="); console2.log("BacktestKraiken:", address(krk)); console2.log("Optimizer: ", address(sys.optimizer)); console2.log("LiquidityMgr: ", address(sys.lm)); console2.log("StrategyExec: ", address(executor)); console2.log("PositionTracker:", address(executor.tracker())); console2.log("BaselineStrats: ", address(baselines)); console2.log("Reporter: ", address(reporter)); console2.log("Recenter intv: ", recenterInterval, " blocks"); console2.log("Initial capital:", initialCapital, " (mock WETH wei)"); console2.log("token0isWeth: ", token0isWeth); // ----------------------------------------------------------------------- // Event replay (runs as local simulation — no broadcast required). // Each pool.mint / pool.swap / pool.burn call executes against the shadow // pool; vm.roll + vm.warp advance block state to match historical timing. // After each block advancement, StrategyExecutor.maybeRecenter() is called // to trigger LiquidityManager.recenter() at the configured interval. // ----------------------------------------------------------------------- try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) { if (bytes(eventsFile).length > 0) { // Reset file position — _resolveSqrtPrice() may have consumed line 1. try vm.closeFile(eventsFile) { } catch { } // Pre-count events so replay() can show "[N/total]" progress lines. uint256 totalEvents = 0; { string memory l = vm.readLine(eventsFile); while (bytes(l).length > 0) { totalEvents++; l = vm.readLine(eventsFile); } } // Reset before the replay pass; we know the file is open after the count loop. vm.closeFile(eventsFile); console2.log("\n=== Starting Event Replay ==="); console2.log("Events file: ", eventsFile); console2.log("Total events: ", totalEvents); replayer.replay(eventsFile, totalEvents, executor, baselines); // Print final KrAIken strategy summary. executor.logSummary(); // Materialize open-position fees for both LP baselines before summary/reporting. baselines.collectFinalFees(); // Print baseline summaries (now have complete fee data). baselines.logFinalSummary(); // Generate comparison report (Markdown + JSON). // firstUpdateBlock and lastNotifiedBlock are the actual block numbers from the // events file — use them directly for an accurate period estimate. uint256 startBlock = baselines.firstUpdateBlock(); uint256 endBlock = executor.tracker().lastNotifiedBlock(); uint256 periodDays = endBlock > startBlock ? ((endBlock - startBlock) * 2) / 86_400 : 0; reporter.generate( executor, baselines, Reporter.Config({ poolAddress: address(sp.pool), startBlock: startBlock, endBlock: endBlock, initialCapital: initialCapital, recenterInterval: recenterInterval, poolLabel: "AERO/WETH 1%", periodDays: periodDays }) ); } } catch { } } }