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

267 lines
13 KiB
Solidity
Raw Normal View History

// 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=<uint160>] \
* [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() always reverts;
// LM's try/catch falls back to 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 — recenter() is now permissionless, so no
// access grant is needed. StrategyExecutor.maybeRecenter() calls
// recenter() via try/catch and logs "SKIP" on cooldown/TWAP failures.
// vm.warp in EventReplayer drives time so TWAP and cooldown pass.
bool token0isWeth = sp.token0 == address(mockWeth);
StrategyExecutor executor =
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth);
// 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 { }
}
}