fix: address review feedback on BaselineStrategies and Reporter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-27 13:43:49 +00:00
parent 77f0fd82fd
commit af86ca1226
3 changed files with 130 additions and 55 deletions

View file

@ -157,11 +157,12 @@ contract BacktestRunner is Script {
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();
// 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
@ -231,19 +232,21 @@ contract BacktestRunner is Script {
console2.log("Total events: ", totalEvents);
replayer.replay(eventsFile, totalEvents, executor, baselines);
// Print final strategy summaries.
// 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).
// Use fwTotalBlocks as the total replay duration (every unique block processed).
// Approximate period length at ~2 s/block.
// 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 totalBlocksElapsed = baselines.fwTotalBlocks();
uint256 startBlock = totalBlocksElapsed > 0 && endBlock > totalBlocksElapsed
? endBlock - totalBlocksElapsed
: endBlock;
uint256 periodDays = (totalBlocksElapsed * 2) / 86_400;
uint256 periodDays = endBlock > startBlock ? ((endBlock - startBlock) * 2) / 86_400 : 0;
reporter.generate(
executor,

View file

@ -20,13 +20,21 @@ import { console2 } from "forge-std/console2.sol";
* 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.
* Call order:
* 1. initialize(initialCapital) during vm.startBroadcast()
* 2. maybeUpdate(blockNum) called by EventReplayer on every block during replay
* 3. collectFinalFees() after replay, before logFinalSummary / getResults
* 4. logFinalSummary() / getResults() view, rely on collectFinalFees() having run
*
* 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.
* Capital:
* All strategies receive the same initial capital split 50/50 by value.
* HODL holds the same token amounts that Full-Range LP actually deployed, so the
* HODL vs LP comparison is perfectly apples-to-apples.
*
* Fee accounting:
* Fixed-Width fees are accumulated at each rebalance's contemporaneous sqrtPriceX96 into
* fwTotalFeesToken0. Full-Range fees are collected once (in collectFinalFees) at the final
* price into frTotalFeesToken0. Both accumulators are used directly by getResults().
*
* WARNING backtesting use only. No access control.
*/
@ -86,11 +94,15 @@ contract BaselineStrategies is IUniswapV3MintCallback {
uint128 public fullRangeLiquidity;
int24 public frLo;
int24 public frHi;
/// @notice Token amounts deployed into the Full-Range position at entry.
uint256 public frEntryToken0;
uint256 public frEntryToken1;
uint256 public frInitialValueToken0;
/// @notice Raw fee totals (populated by collectFinalFees).
uint256 public frFees0;
uint256 public frFees1;
/// @notice Fees in token0-equivalent units at collection price (set by collectFinalFees).
uint256 public frTotalFeesToken0;
uint256 public frTotalBlocks;
uint256 public frBlocksInRange;
@ -104,12 +116,12 @@ contract BaselineStrategies is IUniswapV3MintCallback {
/// @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;
/// @notice Raw fee totals accumulated across all periods.
uint256 public fwFees0;
uint256 public fwFees1;
/// @notice Fees accumulated at contemporaneous prices across all closed periods,
/// plus the current period fees added by collectFinalFees().
uint256 public fwTotalFeesToken0;
uint256 public fwRebalances;
/// @notice Cumulative IL from closed periods (token0 units, negative = LP underperformed HODL).
@ -124,6 +136,8 @@ contract BaselineStrategies is IUniswapV3MintCallback {
bool public initialized;
uint256 public lastUpdateBlock;
/// @notice First block number observed during replay (set on first maybeUpdate call).
uint256 public firstUpdateBlock;
// -------------------------------------------------------------------------
// Constructor
@ -144,8 +158,9 @@ contract BaselineStrategies is IUniswapV3MintCallback {
/**
* @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.
* Capital is split 50/50 by value: half in token0, the equivalent in token1.
* Full-Range LP is deployed first; HODL holds the same token amounts that
* Full-Range actually consumed so both strategies start with identical capital.
*/
function initialize(uint256 initialCapital) external {
require(!initialized, "BaselineStrategies: already initialized");
@ -156,12 +171,7 @@ contract BaselineStrategies is IUniswapV3MintCallback {
uint256 half0 = initialCapital / 2;
uint256 half1 = _valueInToken1(half0, sqrtPriceX96);
// ----- HODL -----
hodlEntryToken0 = half0;
hodlEntryToken1 = half1;
hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96);
// ----- Full-Range LP -----
// ----- Full-Range LP (deployed first so HODL can mirror its amounts) -----
{
int24 lo = _fullRangeLo();
int24 hi = _fullRangeHi();
@ -181,6 +191,18 @@ contract BaselineStrategies is IUniswapV3MintCallback {
}
}
// ----- HODL: hold the same amounts Full-Range actually deployed -----
// Falls back to half0/half1 if FullRange mint produced zero (edge case).
if (frEntryToken0 > 0 || frEntryToken1 > 0) {
hodlEntryToken0 = frEntryToken0;
hodlEntryToken1 = frEntryToken1;
hodlInitialValueToken0 = frInitialValueToken0;
} else {
hodlEntryToken0 = half0;
hodlEntryToken1 = half1;
hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96);
}
// ----- Fixed-Width LP -----
{
(int24 lo, int24 hi) = _computeFixedRange(currentTick);
@ -196,8 +218,6 @@ contract BaselineStrategies is IUniswapV3MintCallback {
fwHi = hi;
fwPeriodEntryToken0 = used0;
fwPeriodEntryToken1 = used1;
fwInitialToken0 = used0;
fwInitialToken1 = used1;
fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
}
fwLastCheckBlock = block.number;
@ -259,6 +279,9 @@ contract BaselineStrategies is IUniswapV3MintCallback {
if (blockNum == lastUpdateBlock) return;
lastUpdateBlock = blockNum;
// Record the first block seen during replay for accurate period reporting.
if (firstUpdateBlock == 0) firstUpdateBlock = blockNum;
(, int24 currentTick,,,,,) = pool.slot0();
// Full-Range: always in range.
@ -281,11 +304,48 @@ contract BaselineStrategies is IUniswapV3MintCallback {
}
// -------------------------------------------------------------------------
// Final summary
// Post-replay fee collection (MUST be called before logFinalSummary / getResults)
// -------------------------------------------------------------------------
/**
* @notice Log final metrics for all three strategies. Call once after replay ends.
* @notice Materialize accrued fees for the still-open Full-Range and Fixed-Width positions.
* Uses pool.burn(lo, hi, 0) to trigger fee accrual without removing liquidity,
* then pool.collect() to sweep fees to this contract.
*
* Call once after replay ends and before any summary/reporting functions.
* Idempotent if positions have zero pending fees.
*/
function collectFinalFees() external {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- Full-Range: single collection at end-of-replay price -----
if (fullRangeLiquidity > 0) {
pool.burn(frLo, frHi, 0); // trigger fee accrual; no liquidity removed
(uint128 f0, uint128 f1) = pool.collect(address(this), frLo, frHi, type(uint128).max, type(uint128).max);
frFees0 += uint256(f0);
frFees1 += uint256(f1);
// Value all Full-Range fees at current price (only one collection, so no
// historical repricing concern for this strategy).
frTotalFeesToken0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
}
// ----- Fixed-Width: current open period fees (historical fees already in fwTotalFeesToken0) -----
if (fwLiquidity > 0) {
pool.burn(fwLo, fwHi, 0); // trigger fee accrual; no liquidity removed
(uint128 f0, uint128 f1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max);
fwFees0 += uint256(f0);
fwFees1 += uint256(f1);
// Add current-period fees at current price to the historical-price accumulator.
fwTotalFeesToken0 += _valueInToken0(uint256(f0), uint256(f1), sqrtPriceX96);
}
}
// -------------------------------------------------------------------------
// Final summary (view requires collectFinalFees() to have been called first)
// -------------------------------------------------------------------------
/**
* @notice Log final metrics for all three strategies. Call once after collectFinalFees().
*/
function logFinalSummary() external view {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
@ -309,20 +369,21 @@ contract BaselineStrategies is IUniswapV3MintCallback {
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
// frTotalFeesToken0 populated by collectFinalFees().
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frFinalPos) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frTotalFees);
int256 frNetPnL = frIL + int256(frTotalFeesToken0);
uint256 frFinalValue = frFinalPos + frTotalFeesToken0;
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(),
" finalValue=",
frFinalValue.str(),
" feesToken0=",
frTotalFees.str(),
frTotalFeesToken0.str(),
" IL=",
frIL.istr(),
" netPnL=",
@ -338,22 +399,24 @@ contract BaselineStrategies is IUniswapV3MintCallback {
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// fwTotalFeesToken0: historical periods at contemporaneous prices + current period at current
// price (added by collectFinalFees()).
uint256 fwFinalValue = fwFinalPos + fwTotalFeesToken0;
// 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);
int256 fwNetPnL = fwTotalIL + int256(fwTotalFeesToken0);
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(),
" finalValue=",
fwFinalValue.str(),
" feesToken0=",
fwFinalFees.str(),
fwTotalFeesToken0.str(),
" cumulativeIL=",
fwCumulativeIL.istr(),
" currentPeriodIL=",
@ -374,7 +437,8 @@ contract BaselineStrategies is IUniswapV3MintCallback {
/**
* @notice Return final computed metrics for each strategy. Used by Reporter.
* @dev Computes open-position IL at current price without mutating state.
* @dev Requires collectFinalFees() to have been called first so fee accumulators are complete.
* finalValueToken0 = positionValue + feesToken0 (both non-negative, no underflow risk).
*/
function getResults()
external
@ -400,14 +464,15 @@ contract BaselineStrategies is IUniswapV3MintCallback {
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
// finalValue = position value + fees (always non-negative).
uint256 frFinalValue = frPosValue + frTotalFeesToken0;
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frPosValue) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frFeesT0);
int256 frNetPnL = int256(frFinalValue) - int256(frInitialValueToken0);
frResult = StrategyResult({
initialCapitalToken0: frInitialValueToken0,
finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL),
feesToken0: frFeesT0,
finalValueToken0: frFinalValue,
feesToken0: frTotalFeesToken0,
rebalances: 0,
ilToken0: frIL,
netPnLToken0: frNetPnL,
@ -420,16 +485,17 @@ contract BaselineStrategies is IUniswapV3MintCallback {
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// finalValue = position value + all fees (always non-negative).
uint256 fwFinalValue = fwPosValue + fwTotalFeesToken0;
// Current period IL.
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = fwTotalIL + int256(fwFeesT0);
int256 fwNetPnL = int256(fwFinalValue) - int256(fwInitialValueToken0);
fwResult = StrategyResult({
initialCapitalToken0: fwInitialValueToken0,
finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL),
feesToken0: fwFeesT0,
finalValueToken0: fwFinalValue,
feesToken0: fwTotalFeesToken0,
rebalances: fwRebalances,
ilToken0: fwTotalIL,
netPnLToken0: fwNetPnL,
@ -463,7 +529,11 @@ contract BaselineStrategies is IUniswapV3MintCallback {
// -------------------------------------------------------------------------
function _rebalanceFixedWidth(int24 currentTick) internal {
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// Capture old range before any mutation used in the log line below.
int24 oldLo = fwLo;
int24 oldHi = fwHi;
// Burn current position and collect principal + fees.
(uint256 p0, uint256 p1) = pool.burn(fwLo, fwHi, fwLiquidity);
@ -474,6 +544,7 @@ contract BaselineStrategies is IUniswapV3MintCallback {
uint256 f1 = uint256(c1) > p1 ? uint256(c1) - p1 : 0;
fwFees0 += f0;
fwFees1 += f1;
// Accumulate at contemporaneous price (more accurate than repricing at end).
fwTotalFeesToken0 += _valueInToken0(f0, f1, sqrtPriceX96);
// IL for this closed period: LP exit value vs holding entry amounts at exit price.
@ -502,6 +573,7 @@ contract BaselineStrategies is IUniswapV3MintCallback {
fwPeriodEntryToken1 = used1;
}
// Log uses oldLo/oldHi captured before fwLo/fwHi were overwritten.
console2.log(
string.concat(
"[BASELINE][FW][REBALANCE] #",
@ -509,9 +581,9 @@ contract BaselineStrategies is IUniswapV3MintCallback {
" tick=",
int256(currentTick).istr(),
" oldRange=[",
int256(fwLo).istr(),
int256(oldLo).istr(),
",",
int256(fwHi).istr(),
int256(oldHi).istr(),
"] newRange=[",
int256(newLo).istr(),
",",

View file

@ -193,7 +193,7 @@ contract Reporter {
if (r.isHodl) {
tir = "N/A";
} else if (r.totalBlocks == 0) {
tir = "100.00%";
tir = "0.00%";
} else {
tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks);
}