harb/onchain/script/backtesting/BacktestRunner.s.sol
openhands cd065275be fix: address AI review feedback for #317 event replay
- Cache pool.tickSpacing() as immutable in EventReplayer constructor
  to avoid a repeated external call per _replayMint() invocation
- Rename driftCount → driftCheckpoints for consistency with log label
- Add sqrtDriftBps to the per-checkpoint progress log line, using the
  now-live lastExpectedSqrtPrice field (previously written but never read)
- Guard _replaySwap(): skip and count events where amountSpecified ≤ 0,
  which would silently flip exact-input into exact-output mode
- Add a final drift sample after the while-loop for trailing events not
  covered by the last LOG_INTERVAL checkpoint
- Move EventReplayer construction outside the broadcast block in
  BacktestRunner (it uses vm.* cheat codes incompatible with real RPC)
- Change second vm.closeFile() from try/catch to a direct call so errors
  surface rather than being silently swallowed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 07:09:29 +00:00

144 lines
6.6 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Script } from "forge-std/Script.sol";
import { console2 } from "forge-std/console2.sol";
import { MockToken } from "./MockToken.sol";
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
import { EventReplayer } from "./EventReplayer.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,
* then replays all Swap/Mint/Burn events from the cache against the shadow pool.
*
* 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>]
*
* 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;
// -------------------------------------------------------------------------
// 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 price before broadcast so the self-call is not recorded.
uint160 sqrtPriceX96 = _resolveSqrtPrice();
vm.startBroadcast();
// Deploy mock ERC20 tokens (18 decimals, matching AERO and WETH).
MockToken tokenA = new MockToken("Mock AERO", "mAERO", 18);
MockToken tokenB = new MockToken("Mock WETH", "mWETH", 18);
// Mint an initial supply to the broadcaster for future liquidity seeding.
address sender = msg.sender;
tokenA.mint(sender, 1_000_000 ether);
tokenB.mint(sender, 1_000_000 ether);
// Deploy factory + pool and initialise at the resolved price.
ShadowPool memory sp = ShadowPoolDeployer.deploy(address(tokenA), address(tokenB), sqrtPriceX96);
vm.stopBroadcast();
// Instantiate EventReplayer outside the broadcast block: 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 to the RPC endpoint.
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));
// -----------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------
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);
}
} catch {}
}
}