fix: address review feedback on BaselineStrategies and Reporter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
77f0fd82fd
commit
af86ca1226
3 changed files with 130 additions and 55 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
",",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue