harb/onchain/script/backtesting/PositionTracker.sol

466 lines
18 KiB
Solidity
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
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 (proportional to each position's share of total liquidity)
* - 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 numerator/denominator 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.
*/
contract PositionTracker {
using Math for uint256;
// -------------------------------------------------------------------------
// 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.
* @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,,,,,,) = 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++;
uint256 totalOldLiq = uint256(oldPos.floorLiq) + uint256(oldPos.anchorLiq) + uint256(oldPos.discLiq);
// Close old positions (skip stages where old liquidity is zero or no open record exists).
_closePosition(0, oldPos.floorLiq, oldPos.floorLo, oldPos.floorHi, fees0, fees1, totalOldLiq, sqrtPriceX96, blockNum);
_closePosition(1, oldPos.anchorLiq, oldPos.anchorLo, oldPos.anchorHi, fees0, fees1, totalOldLiq, sqrtPriceX96, blockNum);
_closePosition(2, oldPos.discLiq, oldPos.discLo, oldPos.discHi, fees0, fees1, totalOldLiq, 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=", _str(blockNum)));
console2.log(string.concat("[TRACKER][SUMMARY] rebalances=", _str(rebalanceCount)));
console2.log(
string.concat("[TRACKER][SUMMARY] totalFees0=", _str(totalFees0), " totalFees1=", _str(totalFees1), " totalFeesToken0=", _str(totalFeesToken0))
);
console2.log(string.concat("[TRACKER][SUMMARY] totalIL=", _istr(finalIL), " netPnL=", _istr(finalNetPnL), " (token0 units)"));
console2.log(
string.concat(
"[TRACKER][SUMMARY] timeInRange=", _str(timeInRangeBps), " bps blocksAnchorInRange=", _str(blocksAnchorInRange), "/", _str(blocksChecked)
)
);
console2.log(string.concat("[TRACKER][SUMMARY] capitalEff: feesToken0=", _str(totalFeesToken0), " liqBlocks=", _str(totalLiquidityBlocks)));
}
// -------------------------------------------------------------------------
// 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=",
_str(blockNum),
" ts=",
_str(timestamp),
" tick=[",
_istr(tickLower),
",",
_istr(tickUpper),
"] liq=",
_str(uint256(liquidity)),
" amt0=",
_str(amt0),
" amt1=",
_str(amt1)
)
);
}
function _closePosition(
uint8 stageIdx,
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint256 fees0Total,
uint256 fees1Total,
uint256 totalOldLiq,
uint160 sqrtPriceX96,
uint256 blockNum
)
internal
{
if (liquidity == 0) return;
OpenPosition storage pos = openPositions[stageIdx];
if (!pos.active) return; // first ever close: no open record to match
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(liquidity, tickLower, tickUpper, sqrtPriceX96);
// Attribute fees proportional to this position's share of total liquidity.
uint256 posLiq = uint256(liquidity);
uint256 myFees0 = totalOldLiq > 0 ? fees0Total.mulDiv(posLiq, totalOldLiq) : 0;
uint256 myFees1 = totalOldLiq > 0 ? fees1Total.mulDiv(posLiq, totalOldLiq) : 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=",
_str(blockNum),
" entryBlock=",
_str(pos.entryBlock),
" tick=[",
_istr(tickLower),
",",
_istr(tickUpper),
"] liq=",
_str(posLiq)
)
);
console2.log(
string.concat(
"[TRACKER][CLOSE] entryAmt0=",
_str(pos.entryAmount0),
" entryAmt1=",
_str(pos.entryAmount1),
" exitAmt0=",
_str(exitAmt0),
" exitAmt1=",
_str(exitAmt1),
" fees0=",
_str(myFees0),
" fees1=",
_str(myFees1)
)
);
console2.log(string.concat("[TRACKER][CLOSE] IL=", _istr(il), " netPnL=", _istr(netPnL), " (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=",
_str(blockNum),
" rebalances=",
_str(rebalanceCount),
" totalFees0=",
_str(totalFees0),
" totalFees1=",
_str(totalFees1),
" IL=",
_istr(totalILToken0),
" netPnL=",
_istr(totalNetPnLToken0),
" timeInRange=",
_str(timeInRangeBps),
" bps"
)
);
}
// -------------------------------------------------------------------------
// Internal: Uniswap V3 math
// -------------------------------------------------------------------------
/**
* @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 helpers (no vm dependency)
// -------------------------------------------------------------------------
function _stageName(uint8 idx) internal pure returns (string memory) {
if (idx == 0) return "FLOOR";
if (idx == 1) return "ANCHOR";
return "DISC";
}
function _str(uint256 v) internal pure returns (string memory) {
if (v == 0) return "0";
uint256 tmp = v;
uint256 len;
while (tmp != 0) {
len++;
tmp /= 10;
}
bytes memory buf = new bytes(len);
while (v != 0) {
buf[--len] = bytes1(uint8(48 + v % 10));
v /= 10;
}
return string(buf);
}
function _istr(int256 v) internal pure returns (string memory) {
if (v >= 0) return _str(uint256(v));
return string.concat("-", _str(uint256(-v)));
}
}