fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-27 13:08:53 +00:00
parent 33c5244e53
commit 77f0fd82fd
6 changed files with 1095 additions and 6 deletions

View file

@ -2,10 +2,11 @@
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";
@ -151,6 +152,14 @@ contract BacktestRunner is Script {
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);
// Deploy Reporter (no broadcast needed it only writes files).
Reporter reporter = new Reporter();
vm.stopBroadcast();
// ------------------------------------------------------------------
@ -187,6 +196,8 @@ contract BacktestRunner is Script {
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);
@ -218,10 +229,35 @@ contract BacktestRunner is Script {
console2.log("\n=== Starting Event Replay ===");
console2.log("Events file: ", eventsFile);
console2.log("Total events: ", totalEvents);
replayer.replay(eventsFile, totalEvents, executor);
replayer.replay(eventsFile, totalEvents, executor, baselines);
// Print final KrAIken strategy summary.
// Print final strategy summaries.
executor.logSummary();
baselines.logFinalSummary();
// Generate comparison report (Markdown + JSON).
// Use fwTotalBlocks as the total replay duration (every unique block processed).
// Approximate period length at ~2 s/block.
uint256 endBlock = executor.tracker().lastNotifiedBlock();
uint256 totalBlocksElapsed = baselines.fwTotalBlocks();
uint256 startBlock = totalBlocksElapsed > 0 && endBlock > totalBlocksElapsed
? endBlock - totalBlocksElapsed
: endBlock;
uint256 periodDays = (totalBlocksElapsed * 2) / 86_400;
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 { }
}

View file

@ -0,0 +1,608 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { FormatLib } from "./FormatLib.sol";
import { MockToken } from "./MockToken.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { Math } from "@openzeppelin/utils/math/Math.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title BaselineStrategies
* @notice Manages three comparison strategies run alongside KrAIken during event replay:
*
* HODL Hold initial token amounts, no LP. Zero fees, zero IL by definition.
* Full-Range Single position from aligned MIN_TICK to MAX_TICK. Set once, never rebalanced.
* Always 100% time-in-range.
* Fixed-Width ±FIXED_WIDTH_TICKS around current price. Rebalanced at the same block-interval
* as KrAIken when the active tick leaves the range.
*
* All strategies are seeded with the same `initialCapital` (in token0 units) split 50/50 by
* value between token0 and token1. HODL holds those amounts; LP strategies deploy them.
*
* Capital tracking:
* The contract implements IUniswapV3MintCallback and freely mints MockTokens as needed
* MockToken.mint() has no access control. Actual entry amounts are recorded from the return
* values of pool.mint() for accurate IL computation.
*
* WARNING backtesting use only. No access control.
*/
contract BaselineStrategies is IUniswapV3MintCallback {
using Math for uint256;
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Types
// -------------------------------------------------------------------------
/// @notice Packed result for a single strategy, consumed by Reporter.
struct StrategyResult {
uint256 initialCapitalToken0;
uint256 finalValueToken0;
uint256 feesToken0;
uint256 rebalances;
int256 ilToken0;
int256 netPnLToken0;
uint256 blocksInRange;
uint256 totalBlocks;
}
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
uint256 internal constant Q96 = 2 ** 96;
/// @notice Half-width of the fixed-width LP range in ticks (total width = 2×).
int24 public constant FIXED_WIDTH_TICKS = 2000;
// -------------------------------------------------------------------------
// Immutables
// -------------------------------------------------------------------------
IUniswapV3Pool public immutable pool;
MockToken public immutable token0;
MockToken public immutable token1;
bool public immutable token0isWeth;
uint256 public immutable recenterInterval;
int24 public immutable tickSpacing;
// -------------------------------------------------------------------------
// HODL state
// -------------------------------------------------------------------------
uint256 public hodlEntryToken0;
uint256 public hodlEntryToken1;
uint256 public hodlInitialValueToken0;
// -------------------------------------------------------------------------
// Full-Range LP state
// -------------------------------------------------------------------------
uint128 public fullRangeLiquidity;
int24 public frLo;
int24 public frHi;
uint256 public frEntryToken0;
uint256 public frEntryToken1;
uint256 public frInitialValueToken0;
uint256 public frFees0;
uint256 public frFees1;
uint256 public frTotalBlocks;
uint256 public frBlocksInRange;
// -------------------------------------------------------------------------
// Fixed-Width LP state
// -------------------------------------------------------------------------
uint128 public fwLiquidity;
int24 public fwLo;
int24 public fwHi;
/// @notice Entry amounts for the CURRENT open period (reset on each rebalance).
uint256 public fwPeriodEntryToken0;
uint256 public fwPeriodEntryToken1;
/// @notice Original entry amounts at the very first deployment (for overall IL reference).
uint256 public fwInitialToken0;
uint256 public fwInitialToken1;
uint256 public fwInitialValueToken0;
uint256 public fwFees0;
uint256 public fwFees1;
uint256 public fwTotalFeesToken0;
uint256 public fwRebalances;
/// @notice Cumulative IL from closed periods (token0 units, negative = LP underperformed HODL).
int256 public fwCumulativeIL;
uint256 public fwTotalBlocks;
uint256 public fwBlocksInRange;
uint256 public fwLastCheckBlock;
// -------------------------------------------------------------------------
// Common state
// -------------------------------------------------------------------------
bool public initialized;
uint256 public lastUpdateBlock;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(IUniswapV3Pool _pool, MockToken _token0, MockToken _token1, bool _token0isWeth, uint256 _recenterInterval) {
pool = _pool;
token0 = _token0;
token1 = _token1;
token0isWeth = _token0isWeth;
recenterInterval = _recenterInterval;
tickSpacing = _pool.tickSpacing();
}
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
/**
* @notice Deploy all three baseline strategies with `initialCapital` token0 equivalent.
* Capital is split 50/50 by value between token0 and token1.
* HODL holds the split; LP strategies deploy it as liquidity.
*/
function initialize(uint256 initialCapital) external {
require(!initialized, "BaselineStrategies: already initialized");
initialized = true;
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
uint256 half0 = initialCapital / 2;
uint256 half1 = _valueInToken1(half0, sqrtPriceX96);
// ----- HODL -----
hodlEntryToken0 = half0;
hodlEntryToken1 = half1;
hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96);
// ----- Full-Range LP -----
{
int24 lo = _fullRangeLo();
int24 hi = _fullRangeHi();
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
);
if (liq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
fullRangeLiquidity = liq;
frLo = lo;
frHi = hi;
frEntryToken0 = used0;
frEntryToken1 = used1;
frInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
}
}
// ----- Fixed-Width LP -----
{
(int24 lo, int24 hi) = _computeFixedRange(currentTick);
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
);
if (liq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
fwLiquidity = liq;
fwLo = lo;
fwHi = hi;
fwPeriodEntryToken0 = used0;
fwPeriodEntryToken1 = used1;
fwInitialToken0 = used0;
fwInitialToken1 = used1;
fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
}
fwLastCheckBlock = block.number;
}
console2.log("=== Baseline Strategies Initialized ===");
console2.log(
string.concat(
"HODL: entry=[",
hodlEntryToken0.str(),
",",
hodlEntryToken1.str(),
"] initialValue=",
hodlInitialValueToken0.str()
)
);
console2.log(
string.concat(
"FullRange: liq=",
uint256(fullRangeLiquidity).str(),
" ticks=[",
int256(frLo).istr(),
",",
int256(frHi).istr(),
"] entry=[",
frEntryToken0.str(),
",",
frEntryToken1.str(),
"]"
)
);
console2.log(
string.concat(
"FixedWidth: liq=",
uint256(fwLiquidity).str(),
" ticks=[",
int256(fwLo).istr(),
",",
int256(fwHi).istr(),
"] entry=[",
fwPeriodEntryToken0.str(),
",",
fwPeriodEntryToken1.str(),
"]"
)
);
}
// -------------------------------------------------------------------------
// Per-block update (called by EventReplayer after each block advance)
// -------------------------------------------------------------------------
/**
* @notice Update time-in-range counters and trigger FixedWidth rebalancing when needed.
* @param blockNum Current block number as advanced by EventReplayer.
*/
function maybeUpdate(uint256 blockNum) external {
if (!initialized) return;
if (blockNum == lastUpdateBlock) return;
lastUpdateBlock = blockNum;
(, int24 currentTick,,,,,) = pool.slot0();
// Full-Range: always in range.
frTotalBlocks++;
frBlocksInRange++;
// Fixed-Width: track in-range blocks.
fwTotalBlocks++;
if (fwLiquidity > 0 && currentTick >= fwLo && currentTick < fwHi) {
fwBlocksInRange++;
}
// Fixed-Width rebalance: check every recenterInterval blocks.
if (fwLiquidity > 0 && blockNum >= fwLastCheckBlock + recenterInterval) {
fwLastCheckBlock = blockNum;
if (currentTick < fwLo || currentTick >= fwHi) {
_rebalanceFixedWidth(currentTick);
}
}
}
// -------------------------------------------------------------------------
// Final summary
// -------------------------------------------------------------------------
/**
* @notice Log final metrics for all three strategies. Call once after replay ends.
*/
function logFinalSummary() external view {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- HODL -----
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
int256 hodlNetPnL = int256(hodlFinalValue) - int256(hodlInitialValueToken0);
console2.log("[BASELINE][HODL][SUMMARY] === HODL ===");
console2.log(
string.concat(
"[BASELINE][HODL][SUMMARY] initialValue=",
hodlInitialValueToken0.str(),
" finalValue=",
hodlFinalValue.str(),
" netPnL=",
hodlNetPnL.istr()
)
);
// ----- Full-Range LP -----
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frFinalPos) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frTotalFees);
uint256 frTIR = frTotalBlocks > 0 ? (frBlocksInRange * 10_000) / frTotalBlocks : 10_000;
console2.log("[BASELINE][FR][SUMMARY] === Full-Range LP ===");
console2.log(
string.concat(
"[BASELINE][FR][SUMMARY] initialValue=",
frInitialValueToken0.str(),
" finalPosValue=",
frFinalPos.str(),
" feesToken0=",
frTotalFees.str(),
" IL=",
frIL.istr(),
" netPnL=",
frNetPnL.istr(),
" timeInRange=",
frTIR.str(),
" bps"
)
);
}
// ----- Fixed-Width LP -----
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// Current period IL (open position vs holding current period entry amounts).
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwFinalPos) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = fwTotalIL + int256(fwFinalFees);
uint256 fwTIR = fwTotalBlocks > 0 ? (fwBlocksInRange * 10_000) / fwTotalBlocks : 0;
console2.log("[BASELINE][FW][SUMMARY] === Fixed-Width LP ===");
console2.log(
string.concat(
"[BASELINE][FW][SUMMARY] initialValue=",
fwInitialValueToken0.str(),
" finalPosValue=",
fwFinalPos.str(),
" feesToken0=",
fwFinalFees.str(),
" cumulativeIL=",
fwCumulativeIL.istr(),
" currentPeriodIL=",
fwPeriodIL.istr(),
" totalIL=",
fwTotalIL.istr(),
" netPnL=",
fwNetPnL.istr(),
" rebalances=",
fwRebalances.str(),
" timeInRange=",
fwTIR.str(),
" bps"
)
);
}
}
/**
* @notice Return final computed metrics for each strategy. Used by Reporter.
* @dev Computes open-position IL at current price without mutating state.
*/
function getResults()
external
view
returns (StrategyResult memory hodlResult, StrategyResult memory frResult, StrategyResult memory fwResult)
{
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- HODL -----
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
hodlResult = StrategyResult({
initialCapitalToken0: hodlInitialValueToken0,
finalValueToken0: hodlFinalValue,
feesToken0: 0,
rebalances: 0,
ilToken0: 0,
netPnLToken0: int256(hodlFinalValue) - int256(hodlInitialValueToken0),
blocksInRange: 0,
totalBlocks: 0
});
// ----- Full-Range LP -----
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frPosValue) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frFeesT0);
frResult = StrategyResult({
initialCapitalToken0: frInitialValueToken0,
finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL),
feesToken0: frFeesT0,
rebalances: 0,
ilToken0: frIL,
netPnLToken0: frNetPnL,
blocksInRange: frBlocksInRange,
totalBlocks: frTotalBlocks
});
}
// ----- Fixed-Width LP -----
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// Current period IL.
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = fwTotalIL + int256(fwFeesT0);
fwResult = StrategyResult({
initialCapitalToken0: fwInitialValueToken0,
finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL),
feesToken0: fwFeesT0,
rebalances: fwRebalances,
ilToken0: fwTotalIL,
netPnLToken0: fwNetPnL,
blocksInRange: fwBlocksInRange,
totalBlocks: fwTotalBlocks
});
}
}
// -------------------------------------------------------------------------
// Uniswap V3 mint callback
// -------------------------------------------------------------------------
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
require(msg.sender == address(pool), "BaselineStrategies: bad mint callback");
if (amount0Owed > 0) {
uint256 have = token0.balanceOf(address(this));
if (have < amount0Owed) token0.mint(address(this), amount0Owed - have);
token0.transfer(address(pool), amount0Owed);
}
if (amount1Owed > 0) {
uint256 have = token1.balanceOf(address(this));
if (have < amount1Owed) token1.mint(address(this), amount1Owed - have);
token1.transfer(address(pool), amount1Owed);
}
}
// -------------------------------------------------------------------------
// Internal: FixedWidth rebalancing
// -------------------------------------------------------------------------
function _rebalanceFixedWidth(int24 currentTick) internal {
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
// Burn current position and collect principal + fees.
(uint256 p0, uint256 p1) = pool.burn(fwLo, fwHi, fwLiquidity);
(uint128 c0, uint128 c1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max);
// fees = collected - principal
uint256 f0 = uint256(c0) > p0 ? uint256(c0) - p0 : 0;
uint256 f1 = uint256(c1) > p1 ? uint256(c1) - p1 : 0;
fwFees0 += f0;
fwFees1 += f1;
fwTotalFeesToken0 += _valueInToken0(f0, f1, sqrtPriceX96);
// IL for this closed period: LP exit value vs holding entry amounts at exit price.
uint256 exitVal = _valueInToken0(p0, p1, sqrtPriceX96);
uint256 hodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
fwCumulativeIL += int256(exitVal) - int256(hodlVal);
fwRebalances++;
fwLiquidity = 0;
// Deploy new position centered around current tick.
(int24 newLo, int24 newHi) = _computeFixedRange(currentTick);
uint256 avail0 = uint256(c0);
uint256 avail1 = uint256(c1);
uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(newLo), TickMath.getSqrtRatioAtTick(newHi), avail0, avail1
);
if (newLiq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), newLo, newHi, newLiq, "");
fwLiquidity = newLiq;
fwLo = newLo;
fwHi = newHi;
fwPeriodEntryToken0 = used0;
fwPeriodEntryToken1 = used1;
}
console2.log(
string.concat(
"[BASELINE][FW][REBALANCE] #",
fwRebalances.str(),
" tick=",
int256(currentTick).istr(),
" oldRange=[",
int256(fwLo).istr(),
",",
int256(fwHi).istr(),
"] newRange=[",
int256(newLo).istr(),
",",
int256(newHi).istr(),
"] fees0=",
f0.str(),
" fees1=",
f1.str()
)
);
}
// -------------------------------------------------------------------------
// Internal: Uniswap V3 math helpers
// -------------------------------------------------------------------------
function _positionAmounts(
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint160 sqrtPriceX96
)
internal
pure
returns (uint256 amount0, uint256 amount1)
{
if (liquidity == 0) return (0, 0);
uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper);
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLow, sqrtHigh, liquidity);
}
/**
* @notice Convert (amount0, amount1) to token0-equivalent units.
* value = amount0 + amount1 × Q96² / sqrtPriceX96²
*/
function _valueInToken0(uint256 amount0, uint256 amount1, uint160 sqrtPriceX96) internal pure returns (uint256) {
if (sqrtPriceX96 == 0 || amount1 == 0) return amount0;
uint256 amt1InT0 = Math.mulDiv(Math.mulDiv(amount1, Q96, uint256(sqrtPriceX96)), Q96, uint256(sqrtPriceX96));
return amount0 + amt1InT0;
}
/**
* @notice Convert a token0 amount to its token1 equivalent at the current price.
* amount1 = amount0 × sqrtPriceX96² / Q96²
*/
function _valueInToken1(uint256 amount0, uint160 sqrtPriceX96) internal pure returns (uint256) {
if (sqrtPriceX96 == 0 || amount0 == 0) return 0;
return Math.mulDiv(Math.mulDiv(amount0, uint256(sqrtPriceX96), Q96), uint256(sqrtPriceX96), Q96);
}
// -------------------------------------------------------------------------
// Internal: tick range helpers
// -------------------------------------------------------------------------
/// @notice Compute the full-range lower tick (smallest valid tick aligned to tickSpacing).
function _fullRangeLo() internal view returns (int24) {
// Solidity truncation toward zero = ceiling for negatives smallest valid multiple >= MIN_TICK.
return (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
}
/// @notice Compute the full-range upper tick (largest valid tick aligned to tickSpacing).
function _fullRangeHi() internal view returns (int24) {
return (TickMath.MAX_TICK / tickSpacing) * tickSpacing;
}
/**
* @notice Compute ±FIXED_WIDTH_TICKS range centered on `currentTick`, aligned to tickSpacing.
* lo is aligned DOWN (floor); hi is aligned UP (ceiling).
* Result is clamped to the full-range bounds.
*/
function _computeFixedRange(int24 currentTick) internal view returns (int24 lo, int24 hi) {
int24 rawLo = currentTick - FIXED_WIDTH_TICKS;
int24 rawHi = currentTick + FIXED_WIDTH_TICKS;
// Floor for lo (align down toward more-negative).
lo = (rawLo / tickSpacing) * tickSpacing;
if (rawLo < 0 && rawLo % tickSpacing != 0) lo -= tickSpacing;
// Ceiling for hi (align up toward more-positive).
hi = (rawHi / tickSpacing) * tickSpacing;
if (rawHi > 0 && rawHi % tickSpacing != 0) hi += tickSpacing;
// Clamp to valid pool range.
int24 minValid = _fullRangeLo();
int24 maxValid = _fullRangeHi();
if (lo < minValid) lo = minValid;
if (hi > maxValid) hi = maxValid;
if (lo >= hi) {
lo = minValid;
hi = maxValid;
}
}
}

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { BaselineStrategies } from "./BaselineStrategies.sol";
import { MockToken } from "./MockToken.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
@ -94,7 +95,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
* Pass 0 to omit the denominator from progress logs.
*/
function replay(string memory eventsFile, uint256 totalEvents) external {
_replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0)));
_replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0)), BaselineStrategies(address(0)));
}
/**
@ -105,10 +106,35 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
* Pass address(0) to disable strategy integration.
*/
function replay(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) external {
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor);
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor, BaselineStrategies(address(0)));
}
function _replayWithStrategy(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) internal {
/**
* @notice Replay events, trigger KrAIken recenter, and update baseline strategies all in one pass.
* @param eventsFile Path to the .jsonl events cache.
* @param totalEvents Total event count (for progress logs).
* @param strategyExecutor KrAIken StrategyExecutor (pass address(0) to disable).
* @param baselines Baseline strategies to update each block (pass address(0) to disable).
*/
function replay(
string memory eventsFile,
uint256 totalEvents,
StrategyExecutor strategyExecutor,
BaselineStrategies baselines
)
external
{
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor, baselines);
}
function _replayWithStrategy(
string memory eventsFile,
uint256 totalEvents,
StrategyExecutor strategyExecutor,
BaselineStrategies baselines
)
internal
{
uint256 idx = 0;
// Track the last Swap event's expected state for drift measurement.
@ -136,6 +162,11 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
strategyExecutor.maybeRecenter(blockNum);
}
// Update baseline strategies for the same block.
if (address(baselines) != address(0)) {
baselines.maybeUpdate(blockNum);
}
if (_streq(eventName, "Swap")) {
(int24 expTick, uint160 expSqrtPrice) = _replaySwap(line);
// Update reference state only when the swap was not skipped.

View file

@ -238,6 +238,42 @@ contract PositionTracker {
_openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp);
}
// -------------------------------------------------------------------------
// View: final computed metrics (consumed by Reporter)
// -------------------------------------------------------------------------
struct FinalData {
int256 finalIL;
int256 finalNetPnL;
uint256 finalFeesToken0;
uint256 rebalances;
uint256 blocksInRange;
uint256 totalBlocks;
}
/**
* @notice Compute final aggregate metrics including open-position IL.
* Returns the same values that logFinalSummary() logs.
*/
function getFinalData() external view returns (FinalData memory d) {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
d.finalIL = totalILToken0;
d.finalNetPnL = totalNetPnLToken0;
d.finalFeesToken0 = totalFeesToken0;
d.rebalances = rebalanceCount;
d.blocksInRange = blocksAnchorInRange;
d.totalBlocks = blocksChecked;
for (uint8 i = 0; i < 3; i++) {
OpenPosition storage pos = openPositions[i];
if (!pos.active) continue;
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(pos.liquidity, pos.tickLower, pos.tickUpper, sqrtPriceX96);
int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96);
d.finalIL += il;
d.finalNetPnL += il; // no fees for unclosed positions
}
}
/**
* @notice Log the final aggregate summary. Call once at the end of replay.
* @param blockNum Final block number (for context in the summary line).

View file

@ -0,0 +1,378 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { BaselineStrategies } from "./BaselineStrategies.sol";
import { FormatLib } from "./FormatLib.sol";
import { PositionTracker } from "./PositionTracker.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title Reporter
* @notice Generates a Markdown + JSON backtest report comparing KrAIken's 3-position strategy
* against three baseline strategies (HODL, Full-Range LP, Fixed-Width LP).
*
* Output files (relative to the forge project root):
* script/backtesting/reports/report-{endBlock}.md
* script/backtesting/reports/report-{endBlock}.json
*
* Requires Foundry cheatcodes (vm.writeFile) and read-write fs_permissions.
*
* WARNING backtesting use only. Not a deployable production contract.
*/
contract Reporter {
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Foundry cheatcode handle (same pattern as EventReplayer)
// -------------------------------------------------------------------------
Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
// -------------------------------------------------------------------------
// Config struct (passed to generate())
// -------------------------------------------------------------------------
struct Config {
address poolAddress;
uint256 startBlock;
uint256 endBlock;
uint256 initialCapital; // token0 units (wei)
uint256 recenterInterval;
string poolLabel; // e.g. "AERO/WETH 1%"
uint256 periodDays;
}
// -------------------------------------------------------------------------
// Row data (internal working struct)
// -------------------------------------------------------------------------
struct Row {
string name;
uint256 initialCapital;
uint256 finalValue;
uint256 feesToken0;
uint256 rebalances;
int256 ilToken0;
int256 netPnLToken0;
uint256 blocksInRange;
uint256 totalBlocks;
bool isHodl; // suppresses time-in-range column, shows $0 fees
}
// -------------------------------------------------------------------------
// Main entry point
// -------------------------------------------------------------------------
/**
* @notice Collect metrics from all strategies, write Markdown and JSON reports.
* @param kraiken StrategyExecutor for the KrAIken 3-position strategy.
* @param baselines Baseline strategies contract.
* @param cfg Report configuration.
*/
function generate(StrategyExecutor kraiken, BaselineStrategies baselines, Config calldata cfg) external {
PositionTracker tracker = kraiken.tracker();
PositionTracker.FinalData memory fd = tracker.getFinalData();
// Build KrAIken row from PositionTracker data.
Row memory kRow = Row({
name: "KrAIken 3-Pos",
initialCapital: cfg.initialCapital,
finalValue: uint256(int256(cfg.initialCapital) + fd.finalNetPnL),
feesToken0: fd.finalFeesToken0,
rebalances: fd.rebalances,
ilToken0: fd.finalIL,
netPnLToken0: fd.finalNetPnL,
blocksInRange: fd.blocksInRange,
totalBlocks: fd.totalBlocks,
isHodl: false
});
// Collect baseline results.
(BaselineStrategies.StrategyResult memory hodlR, BaselineStrategies.StrategyResult memory frR, BaselineStrategies.StrategyResult memory fwR) =
baselines.getResults();
Row memory frRow = Row({
name: "Full-Range LP",
initialCapital: frR.initialCapitalToken0,
finalValue: frR.finalValueToken0,
feesToken0: frR.feesToken0,
rebalances: frR.rebalances,
ilToken0: frR.ilToken0,
netPnLToken0: frR.netPnLToken0,
blocksInRange: frR.blocksInRange,
totalBlocks: frR.totalBlocks,
isHodl: false
});
Row memory fwRow = Row({
name: "Fixed-Width LP",
initialCapital: fwR.initialCapitalToken0,
finalValue: fwR.finalValueToken0,
feesToken0: fwR.feesToken0,
rebalances: fwR.rebalances,
ilToken0: fwR.ilToken0,
netPnLToken0: fwR.netPnLToken0,
blocksInRange: fwR.blocksInRange,
totalBlocks: fwR.totalBlocks,
isHodl: false
});
Row memory hodlRow = Row({
name: "HODL",
initialCapital: hodlR.initialCapitalToken0,
finalValue: hodlR.finalValueToken0,
feesToken0: 0,
rebalances: 0,
ilToken0: 0,
netPnLToken0: hodlR.netPnLToken0,
blocksInRange: 0,
totalBlocks: 0,
isHodl: true
});
// Write files.
string memory suffix = cfg.endBlock.str();
string memory mdPath = string.concat("script/backtesting/reports/report-", suffix, ".md");
string memory jsonPath = string.concat("script/backtesting/reports/report-", suffix, ".json");
vm.writeFile(mdPath, _buildMarkdown(kRow, frRow, fwRow, hodlRow, cfg));
console2.log(string.concat("[REPORTER] Markdown: ", mdPath));
vm.writeFile(jsonPath, _buildJson(kRow, frRow, fwRow, hodlRow, cfg));
console2.log(string.concat("[REPORTER] JSON: ", jsonPath));
}
// -------------------------------------------------------------------------
// Markdown generation
// -------------------------------------------------------------------------
function _buildMarkdown(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
internal
pure
returns (string memory)
{
return string.concat(
"# Backtest Report: ",
cfg.poolLabel,
", ",
cfg.periodDays.str(),
" days\n\n",
"| Strategy | Final Value (token0) | Fees Earned (token0) | Rebalances |"
" IL (%) | Net P&L (%) | Time in Range |\n",
"|----------------|----------------------|----------------------|------------|"
"---------|-------------|---------------|\n",
_mdRow(k),
_mdRow(fr),
_mdRow(fw),
_mdRow(hodl),
"\n## Configuration\n\n",
"- Pool: `",
_addrStr(cfg.poolAddress),
"`\n",
"- Period: block ",
cfg.startBlock.str(),
" to block ",
cfg.endBlock.str(),
" (",
cfg.periodDays.str(),
" days)\n",
"- Initial capital: ",
_etherStr(cfg.initialCapital),
" ETH equivalent\n",
"- Recenter interval: ",
cfg.recenterInterval.str(),
" blocks\n"
);
}
function _mdRow(Row memory r) internal pure returns (string memory) {
string memory tir;
if (r.isHodl) {
tir = "N/A";
} else if (r.totalBlocks == 0) {
tir = "100.00%";
} else {
tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks);
}
string memory fees = r.isHodl ? "$0" : _etherStr(r.feesToken0);
string memory il = r.isHodl ? "0" : _signedPctStr(r.ilToken0, r.initialCapital);
return string.concat(
"| ",
_pad(r.name, 14),
" | ",
_etherStr(r.finalValue),
" | ",
fees,
" | ",
r.rebalances.str(),
" | ",
il,
" | ",
_signedPctStr(r.netPnLToken0, r.initialCapital),
" | ",
tir,
" |\n"
);
}
// -------------------------------------------------------------------------
// JSON generation
// -------------------------------------------------------------------------
function _buildJson(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
internal
pure
returns (string memory)
{
return string.concat(
"{\n",
' "strategies": {\n',
' "kraiken": ',
_jsonRow(k),
",\n",
' "fullRange": ',
_jsonRow(fr),
",\n",
' "fixedWidth": ',
_jsonRow(fw),
",\n",
' "hodl": ',
_jsonRow(hodl),
"\n",
" },\n",
' "config": {\n',
' "pool": "',
_addrStr(cfg.poolAddress),
'",\n',
' "poolLabel": "',
cfg.poolLabel,
'",\n',
' "startBlock": ',
cfg.startBlock.str(),
",\n",
' "endBlock": ',
cfg.endBlock.str(),
",\n",
' "periodDays": ',
cfg.periodDays.str(),
",\n",
' "initialCapital": "',
cfg.initialCapital.str(),
'",\n',
' "recenterInterval": ',
cfg.recenterInterval.str(),
"\n",
" }\n",
"}\n"
);
}
function _jsonRow(Row memory r) internal pure returns (string memory) {
string memory tir = r.isHodl ? "null" : r.totalBlocks.str();
string memory inRange = r.isHodl ? "null" : r.blocksInRange.str();
return string.concat(
"{\n",
' "initialCapitalToken0": "',
r.initialCapital.str(),
'",\n',
' "finalValueToken0": "',
r.finalValue.str(),
'",\n',
' "feesToken0": "',
r.feesToken0.str(),
'",\n',
' "rebalances": ',
r.rebalances.str(),
",\n",
' "ilToken0": "',
r.ilToken0.istr(),
'",\n',
' "netPnLToken0": "',
r.netPnLToken0.istr(),
'",\n',
' "blocksInRange": ',
inRange,
",\n",
' "totalBlocks": ',
tir,
"\n",
" }"
);
}
// -------------------------------------------------------------------------
// Formatting helpers
// -------------------------------------------------------------------------
/**
* @notice Format wei as "<integer>.<6 decimals>" (without "ETH" suffix unit depends on context).
*/
function _etherStr(uint256 wei_) internal pure returns (string memory) {
uint256 whole = wei_ / 1e18;
uint256 frac = (wei_ % 1e18) / 1e12; // 6 decimal places
string memory fracStr = frac.str();
uint256 len = bytes(fracStr).length;
string memory pad = "";
for (uint256 i = len; i < 6; i++) {
pad = string.concat("0", pad);
}
return string.concat(whole.str(), ".", pad, fracStr);
}
/**
* @notice Format a signed ratio (value/base) as a percentage with 2 decimals: "±X.XX%".
*/
function _signedPctStr(int256 value, uint256 base) internal pure returns (string memory) {
if (base == 0) return "0.00%";
bool neg = value < 0;
uint256 abs = neg ? uint256(-value) : uint256(value);
uint256 bps = (abs * 10_000) / base;
return string.concat(neg ? "-" : "", _bpsStr(bps));
}
/**
* @notice Format bps (0..10000+) as "X.XX%".
*/
function _bpsStr(uint256 bps) internal pure returns (string memory) {
uint256 whole = bps / 100;
uint256 dec = bps % 100;
return string.concat(whole.str(), ".", dec < 10 ? string.concat("0", dec.str()) : dec.str(), "%");
}
/**
* @notice Right-pad string to `width` with spaces.
*/
function _pad(string memory s, uint256 width) internal pure returns (string memory) {
uint256 len = bytes(s).length;
if (len >= width) return s;
string memory out = s;
for (uint256 i = len; i < width; i++) {
out = string.concat(out, " ");
}
return out;
}
/**
* @notice Encode an address as a lowercase hex string "0x...".
*/
function _addrStr(address addr) internal pure returns (string memory) {
bytes memory b = abi.encodePacked(addr);
bytes memory h = new bytes(42);
h[0] = "0";
h[1] = "x";
for (uint256 i = 0; i < 20; i++) {
h[2 + i * 2] = _hexChar(uint8(b[i]) >> 4);
h[3 + i * 2] = _hexChar(uint8(b[i]) & 0x0f);
}
return string(h);
}
function _hexChar(uint8 v) internal pure returns (bytes1) {
return v < 10 ? bytes1(48 + v) : bytes1(87 + v);
}
}