harb/onchain/script/backtesting/PositionTracker.sol
openhands cf8e7ee6ee fix: address review feedback on PositionTracker and StrategyExecutor
- Fix fee attribution: distribute fees only to positions whose tick range
  contains the active tick at close time (in-range weight), not by raw
  liquidity. FLOOR is priced far below current tick and rarely earns fees;
  the old approach would over-credit it and corrupt capital-efficiency and
  net-P&L numbers. Fallback to raw-liquidity weighting with a WARN log
  when no position is in range.

- Warn on first-close skip: when _closePosition finds no open record
  (first recenter, before any tracking), log [TRACKER][WARN] instead of
  silently returning so the gap is visible in reports.

- Add tick range assertion: require() that the incoming close snapshot
  tick range matches the stored open record — a mismatch would mean IL
  is computed across different ranges (apples vs oranges).

- Fix finalBlock accuracy: logSummary now calls
  tracker.logFinalSummary(tracker.lastNotifiedBlock()) instead of
  lastRecenterBlock, so the summary reflects the actual last replay block
  rather than potentially hundreds of blocks early.

- Initialize lastRecenterBlock = block.number in StrategyExecutor
  constructor to defer the first recenter attempt by recenterInterval
  blocks and document the invariant.

- Extract shared FormatLib: _str(uint256) and _istr(int256) were
  copy-pasted in both PositionTracker and StrategyExecutor. Extracted to
  FormatLib.sol internal library; both contracts now use `using FormatLib`.

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

496 lines
20 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { FormatLib } from "./FormatLib.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 { console2 } from "forge-std/console2.sol";
/**
* @title PositionTracker
* @notice Tracks KrAIken's three-position lifecycle and computes P&L metrics
* for backtesting analysis.
*
* For each position lifecycle (open → close on next recenter), records:
* - Tick range (tickLower, tickUpper) and liquidity
* - Entry/exit block and timestamp
* - Token amounts at entry vs exit (via Uniswap V3 LiquidityAmounts math)
* - Fees earned — attributed only to positions whose tick range contained the
* active tick at the time of close (the only positions that could have been
* accruing fees). Falls back to raw-liquidity weighting if no position is
* in range at close time (rare edge case).
* - Impermanent loss vs holding the initial token amounts
* - Net P&L = fees value + IL (IL is negative when LP underperforms HODL)
*
* Aggregate metrics:
* - Total fees (token0 + token1 raw, and token0-equivalent)
* - Cumulative IL and net P&L (token0 units)
* - Rebalance count
* - Time in range: % of notified blocks where Anchor tick range contains current tick
* - Capital efficiency accumulators (feesToken0 / liqBlocks) for offline calculation
*
* All output lines carry a [TRACKER][TYPE] prefix for downstream parseability.
* Cumulative P&L is logged every CUMULATIVE_LOG_INTERVAL blocks.
*
* Not a Script — no vm access. Uses console2 for output only.
*
* WARNING — backtesting use only: `recordRecenter` has no access control. Do not
* reuse this contract in production contexts where untrusted callers exist.
*/
contract PositionTracker {
using Math for uint256;
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Types
// -------------------------------------------------------------------------
/**
* @notice Snapshot of all three KrAIken positions at a point in time.
* @dev Stage ordinals: 0 = FLOOR, 1 = ANCHOR, 2 = DISCOVERY.
*/
struct PositionSnapshot {
uint128 floorLiq;
int24 floorLo;
int24 floorHi;
uint128 anchorLiq;
int24 anchorLo;
int24 anchorHi;
uint128 discLiq;
int24 discLo;
int24 discHi;
}
struct OpenPosition {
int24 tickLower;
int24 tickUpper;
uint128 liquidity;
uint256 entryBlock;
uint256 entryTimestamp;
uint256 entryAmount0;
uint256 entryAmount1;
bool active;
}
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
uint256 internal constant Q96 = 2 ** 96;
/// @notice Blocks between cumulative P&L log lines.
uint256 public constant CUMULATIVE_LOG_INTERVAL = 500;
// -------------------------------------------------------------------------
// Immutables
// -------------------------------------------------------------------------
IUniswapV3Pool public immutable pool;
/// @notice True when pool token0 is WETH; affects fees0/fees1 mapping.
bool public immutable token0isWeth;
// -------------------------------------------------------------------------
// Open position state (indexed 0=FLOOR, 1=ANCHOR, 2=DISCOVERY)
// -------------------------------------------------------------------------
OpenPosition[3] public openPositions;
// -------------------------------------------------------------------------
// Cumulative aggregate metrics
// -------------------------------------------------------------------------
uint256 public totalFees0;
uint256 public totalFees1;
uint256 public rebalanceCount;
/// @notice Cumulative IL across all closed positions in token0 units.
/// Negative means LP underperformed HODL.
int256 public totalILToken0;
/// @notice Cumulative net P&L = IL + fees value (token0 units).
int256 public totalNetPnLToken0;
// Time-in-range (Anchor position).
uint256 public blocksChecked;
uint256 public blocksAnchorInRange;
uint256 public lastNotifiedBlock;
// Capital efficiency accumulators.
/// @notice Cumulative fees expressed in token0 units.
uint256 public totalFeesToken0;
/// @notice Sum of (liquidity × blocksOpen) for all closed positions.
uint256 public totalLiquidityBlocks;
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
uint256 internal _lastCumulativeLogBlock;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(IUniswapV3Pool _pool, bool _token0isWeth) {
pool = _pool;
token0isWeth = _token0isWeth;
}
// -------------------------------------------------------------------------
// Integration API (called by StrategyExecutor)
// -------------------------------------------------------------------------
/**
* @notice Notify the tracker that a new block has been observed.
* Updates Anchor time-in-range and emits cumulative P&L lines at
* every CUMULATIVE_LOG_INTERVAL blocks.
* @param blockNum Block number as advanced by EventReplayer.
*/
function notifyBlock(uint256 blockNum) external {
if (blockNum == lastNotifiedBlock) return;
lastNotifiedBlock = blockNum;
// Track Anchor time-in-range.
OpenPosition storage anchor = openPositions[1]; // ANCHOR = 1
if (anchor.active) {
(, int24 tick,,,,,) = pool.slot0();
blocksChecked++;
if (tick >= anchor.tickLower && tick < anchor.tickUpper) {
blocksAnchorInRange++;
}
}
// Log cumulative P&L at regular intervals.
if (_lastCumulativeLogBlock == 0) {
_lastCumulativeLogBlock = blockNum;
} else if (blockNum - _lastCumulativeLogBlock >= CUMULATIVE_LOG_INTERVAL) {
_logCumulative(blockNum);
_lastCumulativeLogBlock = blockNum;
}
}
/**
* @notice Record a successful recenter: close old positions, open new ones.
*
* Fee attribution: fees are distributed only to positions whose tick range
* contained the active tick at the moment of close — the only positions that
* could have been accruing fees in Uniswap V3. When no position is in range
* at close time (rare), attribution falls back to raw liquidity weighting and
* a warning is logged.
*
* @dev No access control — this contract is backtesting-only tooling.
* @param oldPos Pre-recenter snapshot (positions being burned).
* @param newPos Post-recenter snapshot (positions being minted).
* @param feesWeth Total WETH fees collected across all positions this recenter.
* @param feesKrk Total KRK fees collected across all positions this recenter.
* @param blockNum Block number of the recenter.
* @param timestamp Block timestamp of the recenter.
*/
function recordRecenter(
PositionSnapshot calldata oldPos,
PositionSnapshot calldata newPos,
uint256 feesWeth,
uint256 feesKrk,
uint256 blockNum,
uint256 timestamp
)
external
{
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
// Map WETH/KRK fees to pool token0/token1 based on pool ordering.
uint256 fees0 = token0isWeth ? feesWeth : feesKrk;
uint256 fees1 = token0isWeth ? feesKrk : feesWeth;
totalFees0 += fees0;
totalFees1 += fees1;
totalFeesToken0 += _valueInToken0(fees0, fees1, sqrtPriceX96);
rebalanceCount++;
// Uniswap V3 only accrues fees to a position when the active tick is
// inside its range. Weight fee attribution by in-range liquidity only.
uint256 fFloorWeight = _inRangeLiq(oldPos.floorLiq, oldPos.floorLo, oldPos.floorHi, currentTick);
uint256 fAnchorWeight = _inRangeLiq(oldPos.anchorLiq, oldPos.anchorLo, oldPos.anchorHi, currentTick);
uint256 fDiscWeight = _inRangeLiq(oldPos.discLiq, oldPos.discLo, oldPos.discHi, currentTick);
uint256 totalFeeWeight = fFloorWeight + fAnchorWeight + fDiscWeight;
// Fallback to raw-liquidity weights when no position covers the current tick.
if (totalFeeWeight == 0) {
console2.log("[TRACKER][WARN] no position in range at close: fee attribution falling back to raw liquidity");
fFloorWeight = oldPos.floorLiq;
fAnchorWeight = oldPos.anchorLiq;
fDiscWeight = oldPos.discLiq;
totalFeeWeight = uint256(oldPos.floorLiq) + uint256(oldPos.anchorLiq) + uint256(oldPos.discLiq);
}
// Close old positions, passing per-position fee weights.
_closePosition(0, oldPos.floorLiq, oldPos.floorLo, oldPos.floorHi, fees0, fees1, fFloorWeight, totalFeeWeight, sqrtPriceX96, blockNum);
_closePosition(1, oldPos.anchorLiq, oldPos.anchorLo, oldPos.anchorHi, fees0, fees1, fAnchorWeight, totalFeeWeight, sqrtPriceX96, blockNum);
_closePosition(2, oldPos.discLiq, oldPos.discLo, oldPos.discHi, fees0, fees1, fDiscWeight, totalFeeWeight, sqrtPriceX96, blockNum);
// Open new positions.
_openPosition(0, newPos.floorLiq, newPos.floorLo, newPos.floorHi, sqrtPriceX96, blockNum, timestamp);
_openPosition(1, newPos.anchorLiq, newPos.anchorLo, newPos.anchorHi, sqrtPriceX96, blockNum, timestamp);
_openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp);
}
/**
* @notice Log the final aggregate summary. Call once at the end of replay.
* @param blockNum Final block number (for context in the summary line).
*/
function logFinalSummary(uint256 blockNum) external view {
// Compute incremental IL from still-open positions without mutating state.
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
int256 finalIL = totalILToken0;
int256 finalNetPnL = totalNetPnLToken0;
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);
finalIL += il;
finalNetPnL += il; // no fees for unclosed positions
}
uint256 timeInRangeBps = blocksChecked > 0 ? (blocksAnchorInRange * 10_000) / blocksChecked : 0;
console2.log("[TRACKER][SUMMARY] === Final P&L Summary ===");
console2.log(string.concat("[TRACKER][SUMMARY] finalBlock=", blockNum.str()));
console2.log(string.concat("[TRACKER][SUMMARY] rebalances=", rebalanceCount.str()));
console2.log(
string.concat("[TRACKER][SUMMARY] totalFees0=", totalFees0.str(), " totalFees1=", totalFees1.str(), " totalFeesToken0=", totalFeesToken0.str())
);
console2.log(string.concat("[TRACKER][SUMMARY] totalIL=", finalIL.istr(), " netPnL=", finalNetPnL.istr(), " (token0 units)"));
console2.log(
string.concat(
"[TRACKER][SUMMARY] timeInRange=", timeInRangeBps.str(), " bps blocksAnchorInRange=", blocksAnchorInRange.str(), "/", blocksChecked.str()
)
);
console2.log(string.concat("[TRACKER][SUMMARY] capitalEff: feesToken0=", totalFeesToken0.str(), " liqBlocks=", totalLiquidityBlocks.str()));
}
// -------------------------------------------------------------------------
// Internal: position lifecycle
// -------------------------------------------------------------------------
function _openPosition(
uint8 stageIdx,
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint160 sqrtPriceX96,
uint256 blockNum,
uint256 timestamp
)
internal
{
if (liquidity == 0) return;
(uint256 amt0, uint256 amt1) = _positionAmounts(liquidity, tickLower, tickUpper, sqrtPriceX96);
openPositions[stageIdx] = OpenPosition({
tickLower: tickLower,
tickUpper: tickUpper,
liquidity: liquidity,
entryBlock: blockNum,
entryTimestamp: timestamp,
entryAmount0: amt0,
entryAmount1: amt1,
active: true
});
console2.log(
string.concat(
"[TRACKER][OPEN] stage=",
_stageName(stageIdx),
" block=",
blockNum.str(),
" ts=",
timestamp.str(),
" tick=[",
int256(tickLower).istr(),
",",
int256(tickUpper).istr(),
"] liq=",
uint256(liquidity).str(),
" amt0=",
amt0.str(),
" amt1=",
amt1.str()
)
);
}
function _closePosition(
uint8 stageIdx,
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint256 fees0Total,
uint256 fees1Total,
uint256 posWeight, // fee weight for this position (0 = out-of-range)
uint256 totalWeight, // sum of fee weights across all positions
uint160 sqrtPriceX96,
uint256 blockNum
)
internal
{
if (liquidity == 0) return;
OpenPosition storage pos = openPositions[stageIdx];
if (!pos.active) {
// First recenter: no prior open record exists (LM deployed with positions
// already placed before tracking began). Log a warning so the gap is visible.
console2.log(
string.concat("[TRACKER][WARN] stage=", _stageName(stageIdx), " close skipped at block=", blockNum.str(), " (no open record: first recenter)")
);
return;
}
// Guard: the incoming snapshot must match the stored open record.
// A mismatch would mean IL is computed across mismatched tick ranges.
require(tickLower == pos.tickLower && tickUpper == pos.tickUpper, "PositionTracker: tick range mismatch");
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(liquidity, tickLower, tickUpper, sqrtPriceX96);
// Attribute fees proportional to this position's in-range weight.
uint256 posLiq = uint256(liquidity);
uint256 myFees0 = totalWeight > 0 ? fees0Total.mulDiv(posWeight, totalWeight) : 0;
uint256 myFees1 = totalWeight > 0 ? fees1Total.mulDiv(posWeight, totalWeight) : 0;
// IL = LP exit value (ex-fees) HODL value at exit price (both in token0 units).
int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96);
int256 feesToken0 = int256(_valueInToken0(myFees0, myFees1, sqrtPriceX96));
int256 netPnL = il + feesToken0;
totalILToken0 += il;
totalNetPnLToken0 += netPnL;
uint256 blocksOpen = blockNum > pos.entryBlock ? blockNum - pos.entryBlock : 0;
totalLiquidityBlocks += posLiq * blocksOpen;
console2.log(
string.concat(
"[TRACKER][CLOSE] stage=",
_stageName(stageIdx),
" block=",
blockNum.str(),
" entryBlock=",
pos.entryBlock.str(),
" tick=[",
int256(tickLower).istr(),
",",
int256(tickUpper).istr(),
"] liq=",
posLiq.str()
)
);
console2.log(
string.concat(
"[TRACKER][CLOSE] entryAmt0=",
pos.entryAmount0.str(),
" entryAmt1=",
pos.entryAmount1.str(),
" exitAmt0=",
exitAmt0.str(),
" exitAmt1=",
exitAmt1.str(),
" fees0=",
myFees0.str(),
" fees1=",
myFees1.str()
)
);
console2.log(string.concat("[TRACKER][CLOSE] IL=", il.istr(), " netPnL=", netPnL.istr(), " (token0 units)"));
delete openPositions[stageIdx];
}
function _logCumulative(uint256 blockNum) internal view {
uint256 timeInRangeBps = blocksChecked > 0 ? (blocksAnchorInRange * 10_000) / blocksChecked : 0;
console2.log(
string.concat(
"[TRACKER][CUMULATIVE] block=",
blockNum.str(),
" rebalances=",
rebalanceCount.str(),
" totalFees0=",
totalFees0.str(),
" totalFees1=",
totalFees1.str(),
" IL=",
totalILToken0.istr(),
" netPnL=",
totalNetPnLToken0.istr(),
" timeInRange=",
timeInRangeBps.str(),
" bps"
)
);
}
// -------------------------------------------------------------------------
// Internal: Uniswap V3 math
// -------------------------------------------------------------------------
/**
* @notice Return `liquidity` if currentTick is inside [tickLo, tickHi), else 0.
* Used to attribute fees only to positions that were potentially in range.
*/
function _inRangeLiq(uint128 liquidity, int24 tickLo, int24 tickHi, int24 currentTick) internal pure returns (uint256) {
if (liquidity == 0) return 0;
return (currentTick >= tickLo && currentTick < tickHi) ? uint256(liquidity) : 0;
}
/**
* @notice Compute (amount0, amount1) for a liquidity position at the given sqrt price.
*/
function _positionAmounts(
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint160 sqrtPriceX96
)
internal
pure
returns (uint256 amount0, uint256 amount1)
{
uint160 sqrtRatioLow = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtRatioHigh = TickMath.getSqrtRatioAtTick(tickUpper);
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioLow, sqrtRatioHigh, liquidity);
}
/**
* @notice Impermanent loss for a position close (token0 units).
* IL = lpExitValue hodlValue at the exit price.
* Negative when LP underperforms HODL (the typical case).
*/
function _computeIL(uint256 entryAmt0, uint256 entryAmt1, uint256 exitAmt0, uint256 exitAmt1, uint160 sqrtPriceX96) internal pure returns (int256) {
uint256 lpVal = _valueInToken0(exitAmt0, exitAmt1, sqrtPriceX96);
uint256 hodlVal = _valueInToken0(entryAmt0, entryAmt1, sqrtPriceX96);
return int256(lpVal) - int256(hodlVal);
}
/**
* @notice Convert (amount0, amount1) to token0-equivalent units at the given sqrt price.
* @dev value = amount0 + amount1 / price
* = amount0 + amount1 × Q96² / sqrtPriceX96²
* = amount0 + mulDiv(mulDiv(amount1, Q96, sqrtPrice), Q96, sqrtPrice)
*/
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;
}
// -------------------------------------------------------------------------
// Formatting helper
// -------------------------------------------------------------------------
function _stageName(uint8 idx) internal pure returns (string memory) {
if (idx == 0) return "FLOOR";
if (idx == 1) return "ANCHOR";
return "DISC";
}
}