harb/onchain/script/backtesting/StrategyExecutor.sol

269 lines
11 KiB
Solidity
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
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
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";
import { console2 } from "forge-std/console2.sol";
/**
* @title StrategyExecutor
* @notice Drives the KrAIken recenter loop during event replay.
*
* Called by EventReplayer after every block advancement. Triggers
* LiquidityManager.recenter() once per `recenterInterval` blocks and logs:
* - Block number and timestamp
* - Pre/post positions (tickLower, tickUpper, liquidity) for Floor, Anchor, Discovery
* - 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).
*
* TODO(#319): The negligible-impact assumption means we replay historical events
* as-is without accounting for KrAIken's own liquidity affecting swap outcomes.
* A future enhancement should add a feedback loop that adjusts replayed swap
* amounts based on KrAIken's active tick ranges.
*/
contract StrategyExecutor {
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
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Configuration (immutable)
// -------------------------------------------------------------------------
LiquidityManager public immutable lm;
IERC20 public immutable wethToken;
IERC20 public immutable krkToken;
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
// -------------------------------------------------------------------------
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
/// @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;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(
LiquidityManager _lm,
IERC20 _wethToken,
IERC20 _krkToken,
address _feeDestination,
uint256 _recenterInterval,
IUniswapV3Pool _pool,
bool _token0isWeth
) {
lm = _lm;
wethToken = _wethToken;
krkToken = _krkToken;
feeDestination = _feeDestination;
recenterInterval = _recenterInterval;
tracker = new PositionTracker(_pool, _token0isWeth);
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
// 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;
}
// -------------------------------------------------------------------------
// Main entry point (called by EventReplayer after each block advancement)
// -------------------------------------------------------------------------
/**
* @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.
(uint128 fLiqPre, int24 fLoPre, int24 fHiPre) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 aLiqPre, int24 aLoPre, int24 aHiPre) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 dLiqPre, int24 dLoPre, int24 dHiPre) = lm.positions(ThreePositionStrategy.Stage.DISCOVERY);
// Snapshot fee destination balances to compute fees collected during this recenter.
uint256 wethPre = wethToken.balanceOf(feeDestination);
uint256 krkPre = krkToken.balanceOf(feeDestination);
// Always advance lastRecenterBlock, even on failure, to avoid hammering a
// persistently failing condition on every subsequent block.
lastRecenterBlock = blockNum;
bool success;
bool isUp;
string memory failReason;
try lm.recenter() returns (bool _isUp) {
success = true;
isUp = _isUp;
totalRecenters++;
} catch Error(string memory reason) {
failReason = reason;
failedRecenters++;
} catch {
failReason = "unknown revert";
failedRecenters++;
}
if (!success) {
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
console2.log(string.concat("[recenter SKIP @ block ", blockNum.str(), "] reason: ", failReason));
return;
}
// Snapshot post-recenter positions.
(uint128 fLiqPost, int24 fLoPost, int24 fHiPost) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 aLiqPost, int24 aLoPost, int24 aHiPost) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 dLiqPost, int24 dLoPost, int24 dHiPost) = lm.positions(ThreePositionStrategy.Stage.DISCOVERY);
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,
isUp,
fLiqPre,
fLoPre,
fHiPre,
aLiqPre,
aLoPre,
aHiPre,
dLiqPre,
dLoPre,
dHiPre,
fLiqPost,
fLoPost,
fHiPost,
aLiqPost,
aLoPost,
aHiPost,
dLiqPost,
dLoPost,
dHiPost,
feesWeth,
feesKrk
);
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
/**
* @notice Print a summary of all recenter activity. Call at end of replay.
*/
function logSummary() external view {
(uint128 fLiq, int24 fLo, int24 fHi) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 aLiq, int24 aLo, int24 aHi) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 dLiq, int24 dLo, int24 dHi) = lm.positions(ThreePositionStrategy.Stage.DISCOVERY);
console2.log("=== KrAIken Strategy Summary ===");
console2.log("Total recenters: ", totalRecenters);
console2.log("Failed recenters: ", failedRecenters);
console2.log("Recenter interval:", recenterInterval, "blocks");
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
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()));
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
// 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());
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
function _logRecenter(
uint256 blockNum,
bool isUp,
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 #", totalRecenters.str(), " @ block ", blockNum.str(), " direction=", isUp ? "UP" : "DOWN", " ==="));
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
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()));
}
}