fix: Backtesting #5: Position tracking + P&L metrics (#319)

- Add PositionTracker.sol: tracks position lifecycle (open/close per
  recenter), records tick ranges, liquidity, entry/exit blocks/timestamps,
  token amounts (via LiquidityAmounts math), fees (proportional to
  liquidity share), IL (LP exit value − HODL value at exit price), and
  net P&L per position. Aggregates total fees, cumulative IL, net P&L,
  rebalance count, Anchor time-in-range, and capital efficiency accumulators.
  Logs with [TRACKER][TYPE] prefix; emits cumulative P&L every 500 blocks.

- Modify StrategyExecutor.sol: add IUniswapV3Pool + token0isWeth to
  constructor (creates PositionTracker internally), call
  tracker.notifyBlock() on every block for time-in-range, and call
  tracker.recordRecenter() on each successful recenter. logSummary()
  now delegates to tracker.logFinalSummary().

- Modify BacktestRunner.s.sol: pass sp.pool and token0isWeth to
  StrategyExecutor constructor; log tracker address.

- forge fmt: reformat all backtesting scripts and affected src/test files
  to project style (number_underscore=thousands, multiline_func_header=all).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-27 11:23:18 +00:00
parent a491ecb7d9
commit cfcf750084
12 changed files with 666 additions and 244 deletions

View file

@ -1,15 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { BacktestKraiken } from "./BacktestKraiken.sol";
import { EventReplayer } from "./EventReplayer.sol";
import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol";
import { MockToken } from "./MockToken.sol";
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { Script } from "forge-std/Script.sol";
import { console2 } from "forge-std/console2.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { MockToken } from "./MockToken.sol";
import { BacktestKraiken } from "./BacktestKraiken.sol";
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
import { EventReplayer } from "./EventReplayer.sol";
import { KrAIkenSystem, KrAIkenDeployer } from "./KrAIkenDeployer.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
/**
* @title BacktestRunner
@ -77,14 +78,14 @@ contract BacktestRunner is Script {
// 1. Explicit env override.
try vm.envUint("INITIAL_SQRT_PRICE_X96") returns (uint256 val) {
if (val != 0) return uint160(val);
} catch {}
} catch { }
// 2. First Swap event in the events cache.
try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) {
try this._parseSqrtPriceFromFile(eventsFile) returns (uint160 val) {
if (val != 0) return val;
} catch {}
} catch {}
} catch { }
} catch { }
// 3. Fallback default.
return DEFAULT_SQRT_PRICE_X96;
@ -101,12 +102,12 @@ contract BacktestRunner is Script {
uint256 recenterInterval = DEFAULT_RECENTER_INTERVAL;
try vm.envUint("RECENTER_INTERVAL") returns (uint256 v) {
if (v > 0) recenterInterval = v;
} catch {}
} catch { }
uint256 initialCapital = KrAIkenDeployer.DEFAULT_INITIAL_CAPITAL;
try vm.envUint("INITIAL_CAPITAL_WETH") returns (uint256 v) {
if (v > 0) initialCapital = v;
} catch {}
} catch { }
vm.startBroadcast();
@ -139,15 +140,15 @@ contract BacktestRunner is Script {
// 4. Set feeDestination = sender.
// 5. Mint initialCapital mock WETH to LM.
// ------------------------------------------------------------------
KrAIkenSystem memory sys =
KrAIkenDeployer.deploy(address(sp.factory), address(mockWeth), address(krk), sender, initialCapital);
KrAIkenSystem memory sys = KrAIkenDeployer.deploy(address(sp.factory), address(mockWeth), address(krk), sender, initialCapital);
// Deploy StrategyExecutor and grant it recenter access on the LM.
// recenterAccess bypasses TWAP stability check and cooldown correct
// for simulation where vm.warp drives time, not a real oracle.
// sender == feeDestination, so the onlyFeeDestination guard is satisfied.
bool token0isWeth = sp.token0 == address(mockWeth);
StrategyExecutor executor =
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval);
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth);
sys.lm.setRecenterAccess(address(executor));
vm.stopBroadcast();
@ -185,9 +186,10 @@ contract BacktestRunner is Script {
console2.log("Optimizer: ", address(sys.optimizer));
console2.log("LiquidityMgr: ", address(sys.lm));
console2.log("StrategyExec: ", address(executor));
console2.log("PositionTracker:", address(executor.tracker()));
console2.log("Recenter intv: ", recenterInterval, " blocks");
console2.log("Initial capital:", initialCapital, " (mock WETH wei)");
console2.log("token0isWeth: ", sp.token0 == address(mockWeth));
console2.log("token0isWeth: ", token0isWeth);
// -----------------------------------------------------------------------
// Event replay (runs as local simulation no broadcast required).
@ -199,7 +201,7 @@ contract BacktestRunner is Script {
try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) {
if (bytes(eventsFile).length > 0) {
// Reset file position _resolveSqrtPrice() may have consumed line 1.
try vm.closeFile(eventsFile) {} catch {}
try vm.closeFile(eventsFile) { } catch { }
// Pre-count events so replay() can show "[N/total]" progress lines.
uint256 totalEvents = 0;
@ -221,6 +223,6 @@ contract BacktestRunner is Script {
// Print final KrAIken strategy summary.
executor.logSummary();
}
} catch {}
} catch { }
}
}

View file

@ -1,14 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { IUniswapV3SwapCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3SwapCallback.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
import { MockToken } from "./MockToken.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { IUniswapV3SwapCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3SwapCallback.sol";
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title EventReplayer
@ -107,13 +108,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor);
}
function _replayWithStrategy(
string memory eventsFile,
uint256 totalEvents,
StrategyExecutor strategyExecutor
)
internal
{
function _replayWithStrategy(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) internal {
uint256 idx = 0;
// Track the last Swap event's expected state for drift measurement.
@ -178,9 +173,8 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
if (absDrift > maxDrift) maxDrift = absDrift;
// Log sqrtPrice deviation when it exceeds ~0.01% (filters rounding noise).
if (finalSqrtPrice != lastExpectedSqrtPrice) {
uint256 priceDelta = finalSqrtPrice > lastExpectedSqrtPrice
? uint256(finalSqrtPrice - lastExpectedSqrtPrice)
: uint256(lastExpectedSqrtPrice - finalSqrtPrice);
uint256 priceDelta =
finalSqrtPrice > lastExpectedSqrtPrice ? uint256(finalSqrtPrice - lastExpectedSqrtPrice) : uint256(lastExpectedSqrtPrice - finalSqrtPrice);
if (lastExpectedSqrtPrice > 0 && priceDelta * 10_000 > uint256(lastExpectedSqrtPrice)) {
console2.log(" final sqrtPrice divergence:", priceDelta);
}
@ -392,14 +386,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
/**
* @notice Emit a progress line and accumulate drift statistics for one checkpoint.
*/
function _logCheckpoint(
uint256 idx,
uint256 totalEvents,
int24 expectedTick,
uint160 expectedSqrtPrice
)
internal
{
function _logCheckpoint(uint256 idx, uint256 totalEvents, int24 expectedTick, uint160 expectedSqrtPrice) internal {
(uint160 currentSqrtPrice, int24 currentTick,,,,,) = pool.slot0();
int256 diff = int256(currentTick) - int256(expectedTick);
uint256 absDrift = diff >= 0 ? uint256(diff) : uint256(-diff);
@ -425,9 +412,8 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
// Log sqrtPrice deviation when it exceeds ~0.01% (filters rounding noise).
if (currentSqrtPrice != expectedSqrtPrice) {
uint256 priceDelta = currentSqrtPrice > expectedSqrtPrice
? uint256(currentSqrtPrice - expectedSqrtPrice)
: uint256(expectedSqrtPrice - currentSqrtPrice);
uint256 priceDelta =
currentSqrtPrice > expectedSqrtPrice ? uint256(currentSqrtPrice - expectedSqrtPrice) : uint256(expectedSqrtPrice - currentSqrtPrice);
if (expectedSqrtPrice > 0 && priceDelta * 10_000 > uint256(expectedSqrtPrice)) {
console2.log(" sqrtPrice divergence:", priceDelta);
}

View file

@ -1,10 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { MockToken } from "./MockToken.sol";
import { BacktestKraiken } from "./BacktestKraiken.sol";
import { OptimizerV3Push3 } from "../../src/OptimizerV3Push3.sol";
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { OptimizerV3Push3 } from "../../src/OptimizerV3Push3.sol";
import { BacktestKraiken } from "./BacktestKraiken.sol";
import { MockToken } from "./MockToken.sol";
/**
* @notice Deployment result for the KrAIken system on the shadow pool.
@ -40,15 +40,7 @@ library KrAIkenDeployer {
/**
* @notice Deploy the KrAIken system with default initial capital (10 ETH equivalent).
*/
function deploy(
address shadowFactory,
address mockWeth,
address krkToken,
address feeDestination
)
internal
returns (KrAIkenSystem memory sys)
{
function deploy(address shadowFactory, address mockWeth, address krkToken, address feeDestination) internal returns (KrAIkenSystem memory sys) {
return deploy(shadowFactory, mockWeth, krkToken, feeDestination, DEFAULT_INITIAL_CAPITAL);
}

View file

@ -0,0 +1,465 @@
// 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)));
}
}

View file

@ -1,9 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { UniswapHelpers } from "../../src/helpers/UniswapHelpers.sol";
import { IUniswapV3Factory } from "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { UniswapHelpers } from "../../src/helpers/UniswapHelpers.sol";
struct ShadowPool {
IUniswapV3Factory factory;
@ -27,14 +27,7 @@ library ShadowPoolDeployer {
* @param sqrtPriceX96 Initial sqrt price (Q64.96).
* @return sp ShadowPool struct with factory, pool, token0, token1 addresses.
*/
function deploy(
address tokenA,
address tokenB,
uint160 sqrtPriceX96
)
internal
returns (ShadowPool memory sp)
{
function deploy(address tokenA, address tokenB, uint160 sqrtPriceX96) internal returns (ShadowPool memory sp) {
sp.factory = UniswapHelpers.deployUniswapFactory();
address poolAddr = sp.factory.createPool(tokenA, tokenB, SHADOW_FEE);

View file

@ -1,10 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { console2 } from "forge-std/console2.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
import { PositionTracker } from "./PositionTracker.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title StrategyExecutor
@ -17,6 +19,10 @@ import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy
* - Fees collected (WETH + KRK) as the delta of feeDestination's balances
* - Revert reason on failure (logged and skipped replay never halts)
*
* Position tracking and P&L metrics are delegated to PositionTracker, which is
* notified on every block (for time-in-range) and on each successful recenter
* (for position lifecycle and fee/IL accounting).
*
* Access model: StrategyExecutor must be set as recenterAccess on the LM so that
* the cooldown and TWAP price-stability checks are bypassed in the simulation
* (vm.warp advances simulated time, not real oracle state).
@ -37,6 +43,8 @@ contract StrategyExecutor {
address public immutable feeDestination;
/// @notice Minimum block gap between recenter attempts.
uint256 public immutable recenterInterval;
/// @notice Position tracker records lifecycle, fees, and P&L for each position.
PositionTracker public immutable tracker;
// -------------------------------------------------------------------------
// Runtime state
@ -55,13 +63,16 @@ contract StrategyExecutor {
IERC20 _wethToken,
IERC20 _krkToken,
address _feeDestination,
uint256 _recenterInterval
uint256 _recenterInterval,
IUniswapV3Pool _pool,
bool _token0isWeth
) {
lm = _lm;
wethToken = _wethToken;
krkToken = _krkToken;
feeDestination = _feeDestination;
recenterInterval = _recenterInterval;
tracker = new PositionTracker(_pool, _token0isWeth);
}
// -------------------------------------------------------------------------
@ -70,9 +81,13 @@ contract StrategyExecutor {
/**
* @notice Attempt a recenter if enough blocks have elapsed since the last one.
* Always notifies the tracker of the current block for time-in-range accounting.
* @param blockNum Current block number (as advanced by EventReplayer via vm.roll).
*/
function maybeRecenter(uint256 blockNum) external {
// Always notify the tracker so time-in-range is counted for every observed block.
tracker.notifyBlock(blockNum);
if (blockNum - lastRecenterBlock < recenterInterval) return;
// Snapshot pre-recenter positions.
@ -103,9 +118,7 @@ contract StrategyExecutor {
}
if (!success) {
console2.log(
string.concat("[recenter SKIP @ block ", _str(blockNum), "] reason: ", failReason)
);
console2.log(string.concat("[recenter SKIP @ block ", _str(blockNum), "] reason: ", failReason));
return;
}
@ -117,14 +130,56 @@ contract StrategyExecutor {
uint256 feesWeth = wethToken.balanceOf(feeDestination) - wethPre;
uint256 feesKrk = krkToken.balanceOf(feeDestination) - krkPre;
// Record recenter in position tracker.
tracker.recordRecenter(
PositionTracker.PositionSnapshot({
floorLiq: fLiqPre,
floorLo: fLoPre,
floorHi: fHiPre,
anchorLiq: aLiqPre,
anchorLo: aLoPre,
anchorHi: aHiPre,
discLiq: dLiqPre,
discLo: dLoPre,
discHi: dHiPre
}),
PositionTracker.PositionSnapshot({
floorLiq: fLiqPost,
floorLo: fLoPost,
floorHi: fHiPost,
anchorLiq: aLiqPost,
anchorLo: aLoPost,
anchorHi: aHiPost,
discLiq: dLiqPost,
discLo: dLoPost,
discHi: dHiPost
}),
feesWeth,
feesKrk,
blockNum,
block.timestamp
);
_logRecenter(
blockNum,
fLiqPre, fLoPre, fHiPre,
aLiqPre, aLoPre, aHiPre,
dLiqPre, dLoPre, dHiPre,
fLiqPost, fLoPost, fHiPost,
aLiqPost, aLoPost, aHiPost,
dLiqPost, dLoPost, dHiPost,
fLiqPre,
fLoPre,
fHiPre,
aLiqPre,
aLoPre,
aHiPre,
dLiqPre,
dLoPre,
dHiPre,
fLiqPost,
fLoPost,
fHiPost,
aLiqPost,
aLoPost,
aHiPost,
dLiqPost,
dLoPost,
dHiPost,
feesWeth,
feesKrk
);
@ -146,36 +201,11 @@ 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 [", _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))));
tracker.logFinalSummary(lastRecenterBlock);
}
// -------------------------------------------------------------------------
@ -184,84 +214,38 @@ contract StrategyExecutor {
function _logRecenter(
uint256 blockNum,
uint128 fLiqPre, int24 fLoPre, int24 fHiPre,
uint128 aLiqPre, int24 aLoPre, int24 aHiPre,
uint128 dLiqPre, int24 dLoPre, int24 dHiPre,
uint128 fLiqPost, int24 fLoPost, int24 fHiPost,
uint128 aLiqPost, int24 aLoPost, int24 aHiPost,
uint128 dLiqPost, int24 dLoPost, int24 dHiPost,
uint128 fLiqPre,
int24 fLoPre,
int24 fHiPre,
uint128 aLiqPre,
int24 aLoPre,
int24 aHiPre,
uint128 dLiqPre,
int24 dLoPre,
int24 dHiPre,
uint128 fLiqPost,
int24 fLoPost,
int24 fHiPost,
uint128 aLiqPost,
int24 aLoPost,
int24 aHiPost,
uint128 dLiqPost,
int24 dLoPost,
int24 dHiPost,
uint256 feesWeth,
uint256 feesKrk
)
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))
);
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)));
}
// -------------------------------------------------------------------------