fix: Backtesting #3: Replay historical Swap/Mint/Burn events against shadow pool (#317)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-27 06:17:54 +00:00
parent 896fffb2e8
commit a3eb406e46
2 changed files with 116 additions and 91 deletions

View file

@ -117,9 +117,25 @@ contract BacktestRunner is Script {
// -----------------------------------------------------------------------
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 again before the replay pass.
try vm.closeFile(eventsFile) {} catch {}
console2.log("\n=== Starting Event Replay ===");
console2.log("Events file: ", eventsFile);
replayer.replay(eventsFile);
console2.log("Total events: ", totalEvents);
replayer.replay(eventsFile, totalEvents);
}
} catch {}
}

View file

@ -1,13 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { IUniswapV3SwapCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3SwapCallback.sol";
import { MockToken } from "./MockToken.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
import { MockToken } from "./MockToken.sol";
/**
* @title EventReplayer
@ -54,6 +54,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
uint256 public driftCount;
uint256 public totalAbsDrift;
uint256 public maxDrift;
uint256 public skippedCount;
// -------------------------------------------------------------------------
@ -83,33 +84,69 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
* @notice Read every line of the JSON Lines events file and replay it against
* the shadow pool.
* @param eventsFile Absolute (or project-relative) path to the .jsonl cache.
* @param totalEvents Total number of events in the file (for progress display).
* Pass 0 to omit the denominator from progress logs.
*/
function replay(string memory eventsFile) external {
function replay(string memory eventsFile, uint256 totalEvents) external {
uint256 idx = 0;
// Track the last Swap event's expected state for drift measurement.
int24 lastExpectedTick;
uint160 lastExpectedSqrtPrice;
bool hasSwapRef;
while (true) {
string memory line;
try vm.readLine(eventsFile) returns (string memory l) {
line = l;
} catch {
break; // EOF signals end of file
break; // EOF or read error done
}
if (bytes(line).length == 0) break; // empty line signals EOF
string memory eventName = vm.parseJsonString(line, ".event");
uint256 blockNum = vm.parseJsonUint(line, ".block");
_advanceChain(blockNum);
if (_streq(eventName, "Swap")) {
(int24 expTick, uint160 expSqrtPrice) = _replaySwap(line);
// Update reference state only when the swap was not skipped.
if (expSqrtPrice != 0) {
lastExpectedTick = expTick;
lastExpectedSqrtPrice = expSqrtPrice;
hasSwapRef = true;
}
} else if (_streq(eventName, "Mint")) {
_replayMint(line);
} else if (_streq(eventName, "Burn")) {
_replayBurn(line);
}
if (bytes(line).length == 0) break; // empty line also signals EOF
_processEvent(line, idx);
idx++;
// Progress log every LOG_INTERVAL events.
if (idx % LOG_INTERVAL == 0) {
(uint160 sp, int24 tick,,,,,) = pool.slot0();
// Progress + drift validation every LOG_INTERVAL events.
if (idx % LOG_INTERVAL == 0 && hasSwapRef) {
(, int24 currentTick,,,,,) = pool.slot0();
int256 diff = int256(currentTick) - int256(lastExpectedTick);
uint256 absDrift = diff >= 0 ? uint256(diff) : uint256(-diff);
totalAbsDrift += absDrift;
if (absDrift > 0) driftCount++;
if (absDrift > maxDrift) maxDrift = absDrift;
string memory denominator = totalEvents > 0 ? string.concat("/", vm.toString(totalEvents)) : "";
console2.log(
string.concat(
"[",
vm.toString(idx),
denominator,
"] tick=",
vm.toString(int256(tick)),
" sqrtPriceX96=",
vm.toString(uint256(sp))
vm.toString(int256(currentTick)),
" expected=",
vm.toString(int256(lastExpectedTick)),
" drift=",
vm.toString(absDrift)
)
);
}
@ -118,70 +155,59 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
_logSummary(idx);
}
// -------------------------------------------------------------------------
// Internal: event dispatch
// -------------------------------------------------------------------------
function _processEvent(string memory line, uint256 idx) internal {
string memory eventName = vm.parseJsonString(line, ".event");
uint256 blockNum = vm.parseJsonUint(line, ".block");
_advanceChain(blockNum);
if (_streq(eventName, "Swap")) {
_replaySwap(line, idx);
} else if (_streq(eventName, "Mint")) {
_replayMint(line, idx);
} else if (_streq(eventName, "Burn")) {
_replayBurn(line, idx);
}
}
// -------------------------------------------------------------------------
// Swap
// -------------------------------------------------------------------------
function _replaySwap(string memory line, uint256 idx) internal {
/**
* @notice Replay a Swap event.
* @return expectedTick Cached tick from the event (0 if skipped).
* @return expectedSqrtPrice Cached sqrtPriceX96 from the event (0 if skipped).
*/
function _replaySwap(string memory line) internal returns (int24 expectedTick, uint160 expectedSqrtPrice) {
// BigInt fields are serialised as decimal strings by fetch-events.ts.
int256 amount0 = vm.parseInt(vm.parseJsonString(line, ".amount0"));
int256 amount1 = vm.parseInt(vm.parseJsonString(line, ".amount1"));
uint160 targetSqrtPrice = uint160(vm.parseUint(vm.parseJsonString(line, ".sqrtPriceX96")));
int24 expectedTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick"))));
int24 evtTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick"))));
// Skip degenerate zero-amount events.
if (amount0 == 0 && amount1 == 0) {
skippedCount++;
return;
return (0, 0);
}
// amount0 > 0 caller paid token0 (zeroForOne = true).
// amount1 > 0 caller paid token1 (zeroForOne = false).
// amount0 > 0 caller paid token0 into pool (zeroForOne = true).
// amount1 > 0 caller paid token1 into pool (zeroForOne = false).
bool zeroForOne = amount0 > 0;
// Use the cached post-swap sqrtPrice as the price limit so the shadow
// pool stops at exactly the historical price regardless of liquidity shape.
uint160 minLimit = TickMath.MIN_SQRT_RATIO + 1;
uint160 maxLimit = TickMath.MAX_SQRT_RATIO - 1;
uint160 sqrtPriceLimitX96 = targetSqrtPrice;
if (zeroForOne && sqrtPriceLimitX96 <= minLimit) sqrtPriceLimitX96 = minLimit;
if (!zeroForOne && sqrtPriceLimitX96 >= maxLimit) sqrtPriceLimitX96 = maxLimit;
if (zeroForOne && sqrtPriceLimitX96 <= TickMath.MIN_SQRT_RATIO) {
sqrtPriceLimitX96 = TickMath.MIN_SQRT_RATIO + 1;
}
if (!zeroForOne && sqrtPriceLimitX96 >= TickMath.MAX_SQRT_RATIO) {
sqrtPriceLimitX96 = TickMath.MAX_SQRT_RATIO - 1;
}
// Skip if the pool is already at or past the target price.
(uint160 currentSqrtPrice,,,,,,) = pool.slot0();
if (zeroForOne && currentSqrtPrice <= sqrtPriceLimitX96) {
skippedCount++;
return;
return (0, 0);
}
if (!zeroForOne && currentSqrtPrice >= sqrtPriceLimitX96) {
skippedCount++;
return;
return (0, 0);
}
// amountSpecified: use the historical input amount (exact-input mode).
// The price limit is the binding constraint; extra tokens are returned.
// The price limit is the binding constraint; the pool takes at most this amount.
int256 amountSpecified = zeroForOne ? amount0 : amount1;
// Pre-fund this contract so the swap callback can pay the pool.
// Pre-fund so the swap callback can pay the pool without needing to mint
// inside the callback (which avoids a re-entrant MockToken.mint call).
uint256 fundAmount = uint256(amountSpecified > 0 ? amountSpecified : -amountSpecified) + 1;
if (zeroForOne) {
token0.mint(address(this), fundAmount);
@ -190,48 +216,26 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
}
try pool.swap(address(this), zeroForOne, amountSpecified, sqrtPriceLimitX96, abi.encode(zeroForOne)) {
(, int24 newTick,,,,,) = pool.slot0();
int256 drift = int256(newTick) - int256(expectedTick);
if (drift < 0) drift = -drift;
if (drift > 0) {
driftCount++;
totalAbsDrift += uint256(drift);
}
// Per-100-event progress line showing tick drift.
if ((idx + 1) % LOG_INTERVAL == 0) {
console2.log(
string.concat(
"[",
vm.toString(idx + 1),
"] tick=",
vm.toString(int256(newTick)),
" expected=",
vm.toString(int256(expectedTick)),
" drift=",
vm.toString(drift)
)
);
}
// success
} catch {
skippedCount++;
return (0, 0);
}
return (evtTick, targetSqrtPrice);
}
// -------------------------------------------------------------------------
// Mint
// -------------------------------------------------------------------------
function _replayMint(string memory line, uint256 idx) internal {
function _replayMint(string memory line) internal {
uint128 amount = uint128(vm.parseUint(vm.parseJsonString(line, ".amount")));
int24 tickLower = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickLower"))));
int24 tickUpper = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickUpper"))));
// Skip zero-liquidity and degenerate tick ranges.
if (amount == 0) {
skippedCount++;
return;
}
if (tickLower >= tickUpper) {
if (amount == 0 || tickLower >= tickUpper) {
skippedCount++;
return;
}
@ -249,10 +253,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
return;
}
// Pre-fund so the mint callback can transfer both tokens to the pool.
token0.mint(address(this), 1_000_000 ether);
token1.mint(address(this), 1_000_000 ether);
// The mint callback (uniswapV3MintCallback) will mint tokens as needed.
try pool.mint(address(this), tickLower, tickUpper, amount, "") {
// success position recorded under address(this)
} catch {
@ -264,7 +265,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
// Burn
// -------------------------------------------------------------------------
function _replayBurn(string memory line, uint256 idx) internal {
function _replayBurn(string memory line) internal {
uint128 amount = uint128(vm.parseUint(vm.parseJsonString(line, ".amount")));
int24 tickLower = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickLower"))));
int24 tickUpper = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickUpper"))));
@ -278,7 +279,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
bytes32 posKey = keccak256(abi.encodePacked(address(this), tickLower, tickUpper));
(uint128 posLiquidity,,,,) = pool.positions(posKey);
// No shadow position to burn (historical owner had liquidity we never minted).
// No shadow position to burn (historical mint predates our event window).
if (posLiquidity == 0) {
skippedCount++;
return;
@ -301,8 +302,15 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
require(msg.sender == address(pool), "EventReplayer: bad mint callback");
if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed);
if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed);
// Mint exactly what the pool needs and transfer it immediately.
if (amount0Owed > 0) {
token0.mint(address(this), amount0Owed);
token0.transfer(msg.sender, amount0Owed);
}
if (amount1Owed > 0) {
token1.mint(address(this), amount1Owed);
token1.transfer(msg.sender, amount1Owed);
}
}
/// @inheritdoc IUniswapV3SwapCallback
@ -342,17 +350,18 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
return keccak256(bytes(a)) == keccak256(bytes(b));
}
function _logSummary(uint256 totalEvents) internal view {
function _logSummary(uint256 totalReplayed) internal view {
(uint160 finalSp, int24 finalTick,,,,,) = pool.slot0();
console2.log("=== Replay Complete ===");
console2.log("Total events: ", totalEvents);
console2.log("Skipped: ", skippedCount);
console2.log("Drift events: ", driftCount);
console2.log("Total abs drift:", totalAbsDrift);
console2.log("Total events: ", totalReplayed);
console2.log("Skipped: ", skippedCount);
console2.log("Drift checkpoints:", driftCount);
console2.log("Total abs drift: ", totalAbsDrift);
console2.log("Max drift: ", maxDrift);
if (driftCount > 0) {
console2.log("Avg tick drift:", totalAbsDrift / driftCount);
console2.log("Avg tick drift: ", totalAbsDrift / driftCount);
}
console2.log("Final tick: ", int256(finalTick));
console2.log("Final sqrtP: ", uint256(finalSp));
console2.log("Final tick: ", int256(finalTick));
console2.log("Final sqrtPriceX96:", uint256(finalSp));
}
}