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>
This commit is contained in:
openhands 2026-02-27 12:02:29 +00:00
parent cfcf750084
commit cf8e7ee6ee
3 changed files with 159 additions and 106 deletions

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @title FormatLib
* @notice Shared integer-to-string formatting helpers for backtesting log output.
* Extracted to avoid copy-pasting the same logic across PositionTracker
* and StrategyExecutor.
*/
library FormatLib {
/// @notice Format an unsigned integer as a decimal string.
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);
}
/// @notice Format a signed integer as a decimal string (prefixed with '-' if negative).
function istr(int256 v) internal pure returns (string memory) {
if (v >= 0) return str(uint256(v));
return string.concat("-", str(uint256(-v)));
}
}

View file

@ -1,9 +1,9 @@
// 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";
@ -17,7 +17,10 @@ import { console2 } from "forge-std/console2.sol";
* - 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)
* - 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)
*
@ -26,15 +29,20 @@ import { console2 } from "forge-std/console2.sol";
* - 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
* - 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
@ -167,6 +175,14 @@ contract PositionTracker {
/**
* @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.
@ -184,7 +200,7 @@ contract PositionTracker {
)
external
{
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
// Map WETH/KRK fees to pool token0/token1 based on pool ordering.
uint256 fees0 = token0isWeth ? feesWeth : feesKrk;
@ -195,12 +211,26 @@ contract PositionTracker {
totalFeesToken0 += _valueInToken0(fees0, fees1, sqrtPriceX96);
rebalanceCount++;
uint256 totalOldLiq = uint256(oldPos.floorLiq) + uint256(oldPos.anchorLiq) + uint256(oldPos.discLiq);
// 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;
// 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);
// 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);
@ -229,18 +259,18 @@ contract PositionTracker {
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] finalBlock=", blockNum.str()));
console2.log(string.concat("[TRACKER][SUMMARY] rebalances=", rebalanceCount.str()));
console2.log(
string.concat("[TRACKER][SUMMARY] totalFees0=", _str(totalFees0), " totalFees1=", _str(totalFees1), " totalFeesToken0=", _str(totalFeesToken0))
string.concat("[TRACKER][SUMMARY] totalFees0=", totalFees0.str(), " totalFees1=", totalFees1.str(), " totalFeesToken0=", totalFeesToken0.str())
);
console2.log(string.concat("[TRACKER][SUMMARY] totalIL=", _istr(finalIL), " netPnL=", _istr(finalNetPnL), " (token0 units)"));
console2.log(string.concat("[TRACKER][SUMMARY] totalIL=", finalIL.istr(), " netPnL=", finalNetPnL.istr(), " (token0 units)"));
console2.log(
string.concat(
"[TRACKER][SUMMARY] timeInRange=", _str(timeInRangeBps), " bps blocksAnchorInRange=", _str(blocksAnchorInRange), "/", _str(blocksChecked)
"[TRACKER][SUMMARY] timeInRange=", timeInRangeBps.str(), " bps blocksAnchorInRange=", blocksAnchorInRange.str(), "/", blocksChecked.str()
)
);
console2.log(string.concat("[TRACKER][SUMMARY] capitalEff: feesToken0=", _str(totalFeesToken0), " liqBlocks=", _str(totalLiquidityBlocks)));
console2.log(string.concat("[TRACKER][SUMMARY] capitalEff: feesToken0=", totalFeesToken0.str(), " liqBlocks=", totalLiquidityBlocks.str()));
}
// -------------------------------------------------------------------------
@ -275,19 +305,19 @@ contract PositionTracker {
"[TRACKER][OPEN] stage=",
_stageName(stageIdx),
" block=",
_str(blockNum),
blockNum.str(),
" ts=",
_str(timestamp),
timestamp.str(),
" tick=[",
_istr(tickLower),
int256(tickLower).istr(),
",",
_istr(tickUpper),
int256(tickUpper).istr(),
"] liq=",
_str(uint256(liquidity)),
uint256(liquidity).str(),
" amt0=",
_str(amt0),
amt0.str(),
" amt1=",
_str(amt1)
amt1.str()
)
);
}
@ -299,22 +329,35 @@ contract PositionTracker {
int24 tickUpper,
uint256 fees0Total,
uint256 fees1Total,
uint256 totalOldLiq,
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) return; // first ever close: no open record to match
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 share of total liquidity.
// Attribute fees proportional to this position's in-range weight.
uint256 posLiq = uint256(liquidity);
uint256 myFees0 = totalOldLiq > 0 ? fees0Total.mulDiv(posLiq, totalOldLiq) : 0;
uint256 myFees1 = totalOldLiq > 0 ? fees1Total.mulDiv(posLiq, totalOldLiq) : 0;
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);
@ -332,34 +375,34 @@ contract PositionTracker {
"[TRACKER][CLOSE] stage=",
_stageName(stageIdx),
" block=",
_str(blockNum),
blockNum.str(),
" entryBlock=",
_str(pos.entryBlock),
pos.entryBlock.str(),
" tick=[",
_istr(tickLower),
int256(tickLower).istr(),
",",
_istr(tickUpper),
int256(tickUpper).istr(),
"] liq=",
_str(posLiq)
posLiq.str()
)
);
console2.log(
string.concat(
"[TRACKER][CLOSE] entryAmt0=",
_str(pos.entryAmount0),
pos.entryAmount0.str(),
" entryAmt1=",
_str(pos.entryAmount1),
pos.entryAmount1.str(),
" exitAmt0=",
_str(exitAmt0),
exitAmt0.str(),
" exitAmt1=",
_str(exitAmt1),
exitAmt1.str(),
" fees0=",
_str(myFees0),
myFees0.str(),
" fees1=",
_str(myFees1)
myFees1.str()
)
);
console2.log(string.concat("[TRACKER][CLOSE] IL=", _istr(il), " netPnL=", _istr(netPnL), " (token0 units)"));
console2.log(string.concat("[TRACKER][CLOSE] IL=", il.istr(), " netPnL=", netPnL.istr(), " (token0 units)"));
delete openPositions[stageIdx];
}
@ -369,19 +412,19 @@ contract PositionTracker {
console2.log(
string.concat(
"[TRACKER][CUMULATIVE] block=",
_str(blockNum),
blockNum.str(),
" rebalances=",
_str(rebalanceCount),
rebalanceCount.str(),
" totalFees0=",
_str(totalFees0),
totalFees0.str(),
" totalFees1=",
_str(totalFees1),
totalFees1.str(),
" IL=",
_istr(totalILToken0),
totalILToken0.istr(),
" netPnL=",
_istr(totalNetPnLToken0),
totalNetPnLToken0.istr(),
" timeInRange=",
_str(timeInRangeBps),
timeInRangeBps.str(),
" bps"
)
);
@ -391,6 +434,15 @@ contract PositionTracker {
// 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.
*/
@ -433,7 +485,7 @@ contract PositionTracker {
}
// -------------------------------------------------------------------------
// Formatting helpers (no vm dependency)
// Formatting helper
// -------------------------------------------------------------------------
function _stageName(uint8 idx) internal pure returns (string memory) {
@ -441,25 +493,4 @@ contract PositionTracker {
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)));
}
}

View file

@ -3,6 +3,7 @@ pragma solidity ^0.8.19;
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
import { FormatLib } from "./FormatLib.sol";
import { PositionTracker } from "./PositionTracker.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
@ -33,6 +34,9 @@ import { console2 } from "forge-std/console2.sol";
* amounts based on KrAIken's active tick ranges.
*/
contract StrategyExecutor {
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Configuration (immutable)
// -------------------------------------------------------------------------
@ -50,6 +54,10 @@ contract StrategyExecutor {
// Runtime state
// -------------------------------------------------------------------------
/// @notice Block of the last recenter attempt (successful or not).
/// Initialised to block.number at construction so the first recenter
/// attempt is deferred by recenterInterval blocks rather than firing
/// immediately on the first observed historical block.
uint256 public lastRecenterBlock;
uint256 public totalRecenters;
uint256 public failedRecenters;
@ -73,6 +81,9 @@ contract StrategyExecutor {
feeDestination = _feeDestination;
recenterInterval = _recenterInterval;
tracker = new PositionTracker(_pool, _token0isWeth);
// Defer the first recenter attempt by recenterInterval blocks so we don't
// try to recenter before any meaningful price movement has occurred.
lastRecenterBlock = block.number;
}
// -------------------------------------------------------------------------
@ -118,7 +129,7 @@ contract StrategyExecutor {
}
if (!success) {
console2.log(string.concat("[recenter SKIP @ block ", _str(blockNum), "] reason: ", failReason));
console2.log(string.concat("[recenter SKIP @ block ", blockNum.str(), "] reason: ", failReason));
return;
}
@ -201,11 +212,14 @@ contract StrategyExecutor {
console2.log("Total recenters: ", totalRecenters);
console2.log("Failed recenters: ", failedRecenters);
console2.log("Recenter interval:", recenterInterval, "blocks");
console2.log(string.concat("Final Floor: tick [", _istr(fLo), ", ", _istr(fHi), "] liq=", _str(uint256(fLiq))));
console2.log(string.concat("Final Anchor: tick [", _istr(aLo), ", ", _istr(aHi), "] liq=", _str(uint256(aLiq))));
console2.log(string.concat("Final Discovery: tick [", _istr(dLo), ", ", _istr(dHi), "] liq=", _str(uint256(dLiq))));
console2.log(string.concat("Final Floor: tick [", int256(fLo).istr(), ", ", int256(fHi).istr(), "] liq=", uint256(fLiq).str()));
console2.log(string.concat("Final Anchor: tick [", int256(aLo).istr(), ", ", int256(aHi).istr(), "] liq=", uint256(aLiq).str()));
console2.log(string.concat("Final Discovery: tick [", int256(dLo).istr(), ", ", int256(dHi).istr(), "] liq=", uint256(dLiq).str()));
tracker.logFinalSummary(lastRecenterBlock);
// Use lastNotifiedBlock from the tracker as the authoritative final block
// it reflects the last block actually processed by the replay, which may be
// up to recenterInterval blocks later than lastRecenterBlock.
tracker.logFinalSummary(tracker.lastNotifiedBlock());
}
// -------------------------------------------------------------------------
@ -238,38 +252,13 @@ contract StrategyExecutor {
internal
view
{
console2.log(string.concat("=== Recenter #", _str(totalRecenters), " @ block ", _str(blockNum), " ==="));
console2.log(string.concat(" Floor pre: tick [", _istr(fLoPre), ", ", _istr(fHiPre), "] liq=", _str(uint256(fLiqPre))));
console2.log(string.concat(" Anchor pre: tick [", _istr(aLoPre), ", ", _istr(aHiPre), "] liq=", _str(uint256(aLiqPre))));
console2.log(string.concat(" Disc pre: tick [", _istr(dLoPre), ", ", _istr(dHiPre), "] liq=", _str(uint256(dLiqPre))));
console2.log(string.concat(" Floor post: tick [", _istr(fLoPost), ", ", _istr(fHiPost), "] liq=", _str(uint256(fLiqPost))));
console2.log(string.concat(" Anchor post: tick [", _istr(aLoPost), ", ", _istr(aHiPost), "] liq=", _str(uint256(aLiqPost))));
console2.log(string.concat(" Disc post: tick [", _istr(dLoPost), ", ", _istr(dHiPost), "] liq=", _str(uint256(dLiqPost))));
console2.log(string.concat(" Fees WETH: ", _str(feesWeth), " Fees KRK: ", _str(feesKrk)));
}
// -------------------------------------------------------------------------
// Formatting helpers (no vm dependency required)
// -------------------------------------------------------------------------
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)));
console2.log(string.concat("=== Recenter #", totalRecenters.str(), " @ block ", blockNum.str(), " ==="));
console2.log(string.concat(" Floor pre: tick [", int256(fLoPre).istr(), ", ", int256(fHiPre).istr(), "] liq=", uint256(fLiqPre).str()));
console2.log(string.concat(" Anchor pre: tick [", int256(aLoPre).istr(), ", ", int256(aHiPre).istr(), "] liq=", uint256(aLiqPre).str()));
console2.log(string.concat(" Disc pre: tick [", int256(dLoPre).istr(), ", ", int256(dHiPre).istr(), "] liq=", uint256(dLiqPre).str()));
console2.log(string.concat(" Floor post: tick [", int256(fLoPost).istr(), ", ", int256(fHiPost).istr(), "] liq=", uint256(fLiqPost).str()));
console2.log(string.concat(" Anchor post: tick [", int256(aLoPost).istr(), ", ", int256(aHiPost).istr(), "] liq=", uint256(aLiqPost).str()));
console2.log(string.concat(" Disc post: tick [", int256(dLoPost).istr(), ", ", int256(dHiPost).istr(), "] liq=", uint256(dLiqPost).str()));
console2.log(string.concat(" Fees WETH: ", feesWeth.str(), " Fees KRK: ", feesKrk.str()));
}
}