// 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); } // ------------------------------------------------------------------------- // 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). */ 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"; } }