harb/onchain/script/backtesting/BaselineStrategies.sol
openhands 77f0fd82fd fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:08:53 +00:00

608 lines
24 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { FormatLib } from "./FormatLib.sol";
import { MockToken } from "./MockToken.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 { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title BaselineStrategies
* @notice Manages three comparison strategies run alongside KrAIken during event replay:
*
* HODL — Hold initial token amounts, no LP. Zero fees, zero IL by definition.
* Full-Range — Single position from aligned MIN_TICK to MAX_TICK. Set once, never rebalanced.
* Always 100% time-in-range.
* Fixed-Width — ±FIXED_WIDTH_TICKS around current price. Rebalanced at the same block-interval
* as KrAIken when the active tick leaves the range.
*
* All strategies are seeded with the same `initialCapital` (in token0 units) split 50/50 by
* value between token0 and token1. HODL holds those amounts; LP strategies deploy them.
*
* Capital tracking:
* The contract implements IUniswapV3MintCallback and freely mints MockTokens as needed —
* MockToken.mint() has no access control. Actual entry amounts are recorded from the return
* values of pool.mint() for accurate IL computation.
*
* WARNING — backtesting use only. No access control.
*/
contract BaselineStrategies is IUniswapV3MintCallback {
using Math for uint256;
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Types
// -------------------------------------------------------------------------
/// @notice Packed result for a single strategy, consumed by Reporter.
struct StrategyResult {
uint256 initialCapitalToken0;
uint256 finalValueToken0;
uint256 feesToken0;
uint256 rebalances;
int256 ilToken0;
int256 netPnLToken0;
uint256 blocksInRange;
uint256 totalBlocks;
}
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
uint256 internal constant Q96 = 2 ** 96;
/// @notice Half-width of the fixed-width LP range in ticks (total width = 2×).
int24 public constant FIXED_WIDTH_TICKS = 2000;
// -------------------------------------------------------------------------
// Immutables
// -------------------------------------------------------------------------
IUniswapV3Pool public immutable pool;
MockToken public immutable token0;
MockToken public immutable token1;
bool public immutable token0isWeth;
uint256 public immutable recenterInterval;
int24 public immutable tickSpacing;
// -------------------------------------------------------------------------
// HODL state
// -------------------------------------------------------------------------
uint256 public hodlEntryToken0;
uint256 public hodlEntryToken1;
uint256 public hodlInitialValueToken0;
// -------------------------------------------------------------------------
// Full-Range LP state
// -------------------------------------------------------------------------
uint128 public fullRangeLiquidity;
int24 public frLo;
int24 public frHi;
uint256 public frEntryToken0;
uint256 public frEntryToken1;
uint256 public frInitialValueToken0;
uint256 public frFees0;
uint256 public frFees1;
uint256 public frTotalBlocks;
uint256 public frBlocksInRange;
// -------------------------------------------------------------------------
// Fixed-Width LP state
// -------------------------------------------------------------------------
uint128 public fwLiquidity;
int24 public fwLo;
int24 public fwHi;
/// @notice Entry amounts for the CURRENT open period (reset on each rebalance).
uint256 public fwPeriodEntryToken0;
uint256 public fwPeriodEntryToken1;
/// @notice Original entry amounts at the very first deployment (for overall IL reference).
uint256 public fwInitialToken0;
uint256 public fwInitialToken1;
uint256 public fwInitialValueToken0;
uint256 public fwFees0;
uint256 public fwFees1;
uint256 public fwTotalFeesToken0;
uint256 public fwRebalances;
/// @notice Cumulative IL from closed periods (token0 units, negative = LP underperformed HODL).
int256 public fwCumulativeIL;
uint256 public fwTotalBlocks;
uint256 public fwBlocksInRange;
uint256 public fwLastCheckBlock;
// -------------------------------------------------------------------------
// Common state
// -------------------------------------------------------------------------
bool public initialized;
uint256 public lastUpdateBlock;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(IUniswapV3Pool _pool, MockToken _token0, MockToken _token1, bool _token0isWeth, uint256 _recenterInterval) {
pool = _pool;
token0 = _token0;
token1 = _token1;
token0isWeth = _token0isWeth;
recenterInterval = _recenterInterval;
tickSpacing = _pool.tickSpacing();
}
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
/**
* @notice Deploy all three baseline strategies with `initialCapital` token0 equivalent.
* Capital is split 50/50 by value between token0 and token1.
* HODL holds the split; LP strategies deploy it as liquidity.
*/
function initialize(uint256 initialCapital) external {
require(!initialized, "BaselineStrategies: already initialized");
initialized = true;
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
uint256 half0 = initialCapital / 2;
uint256 half1 = _valueInToken1(half0, sqrtPriceX96);
// ----- HODL -----
hodlEntryToken0 = half0;
hodlEntryToken1 = half1;
hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96);
// ----- Full-Range LP -----
{
int24 lo = _fullRangeLo();
int24 hi = _fullRangeHi();
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
);
if (liq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
fullRangeLiquidity = liq;
frLo = lo;
frHi = hi;
frEntryToken0 = used0;
frEntryToken1 = used1;
frInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
}
}
// ----- Fixed-Width LP -----
{
(int24 lo, int24 hi) = _computeFixedRange(currentTick);
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
);
if (liq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
fwLiquidity = liq;
fwLo = lo;
fwHi = hi;
fwPeriodEntryToken0 = used0;
fwPeriodEntryToken1 = used1;
fwInitialToken0 = used0;
fwInitialToken1 = used1;
fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
}
fwLastCheckBlock = block.number;
}
console2.log("=== Baseline Strategies Initialized ===");
console2.log(
string.concat(
"HODL: entry=[",
hodlEntryToken0.str(),
",",
hodlEntryToken1.str(),
"] initialValue=",
hodlInitialValueToken0.str()
)
);
console2.log(
string.concat(
"FullRange: liq=",
uint256(fullRangeLiquidity).str(),
" ticks=[",
int256(frLo).istr(),
",",
int256(frHi).istr(),
"] entry=[",
frEntryToken0.str(),
",",
frEntryToken1.str(),
"]"
)
);
console2.log(
string.concat(
"FixedWidth: liq=",
uint256(fwLiquidity).str(),
" ticks=[",
int256(fwLo).istr(),
",",
int256(fwHi).istr(),
"] entry=[",
fwPeriodEntryToken0.str(),
",",
fwPeriodEntryToken1.str(),
"]"
)
);
}
// -------------------------------------------------------------------------
// Per-block update (called by EventReplayer after each block advance)
// -------------------------------------------------------------------------
/**
* @notice Update time-in-range counters and trigger FixedWidth rebalancing when needed.
* @param blockNum Current block number as advanced by EventReplayer.
*/
function maybeUpdate(uint256 blockNum) external {
if (!initialized) return;
if (blockNum == lastUpdateBlock) return;
lastUpdateBlock = blockNum;
(, int24 currentTick,,,,,) = pool.slot0();
// Full-Range: always in range.
frTotalBlocks++;
frBlocksInRange++;
// Fixed-Width: track in-range blocks.
fwTotalBlocks++;
if (fwLiquidity > 0 && currentTick >= fwLo && currentTick < fwHi) {
fwBlocksInRange++;
}
// Fixed-Width rebalance: check every recenterInterval blocks.
if (fwLiquidity > 0 && blockNum >= fwLastCheckBlock + recenterInterval) {
fwLastCheckBlock = blockNum;
if (currentTick < fwLo || currentTick >= fwHi) {
_rebalanceFixedWidth(currentTick);
}
}
}
// -------------------------------------------------------------------------
// Final summary
// -------------------------------------------------------------------------
/**
* @notice Log final metrics for all three strategies. Call once after replay ends.
*/
function logFinalSummary() external view {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- HODL -----
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
int256 hodlNetPnL = int256(hodlFinalValue) - int256(hodlInitialValueToken0);
console2.log("[BASELINE][HODL][SUMMARY] === HODL ===");
console2.log(
string.concat(
"[BASELINE][HODL][SUMMARY] initialValue=",
hodlInitialValueToken0.str(),
" finalValue=",
hodlFinalValue.str(),
" netPnL=",
hodlNetPnL.istr()
)
);
// ----- Full-Range LP -----
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frFinalPos) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frTotalFees);
uint256 frTIR = frTotalBlocks > 0 ? (frBlocksInRange * 10_000) / frTotalBlocks : 10_000;
console2.log("[BASELINE][FR][SUMMARY] === Full-Range LP ===");
console2.log(
string.concat(
"[BASELINE][FR][SUMMARY] initialValue=",
frInitialValueToken0.str(),
" finalPosValue=",
frFinalPos.str(),
" feesToken0=",
frTotalFees.str(),
" IL=",
frIL.istr(),
" netPnL=",
frNetPnL.istr(),
" timeInRange=",
frTIR.str(),
" bps"
)
);
}
// ----- Fixed-Width LP -----
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// Current period IL (open position vs holding current period entry amounts).
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwFinalPos) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = fwTotalIL + int256(fwFinalFees);
uint256 fwTIR = fwTotalBlocks > 0 ? (fwBlocksInRange * 10_000) / fwTotalBlocks : 0;
console2.log("[BASELINE][FW][SUMMARY] === Fixed-Width LP ===");
console2.log(
string.concat(
"[BASELINE][FW][SUMMARY] initialValue=",
fwInitialValueToken0.str(),
" finalPosValue=",
fwFinalPos.str(),
" feesToken0=",
fwFinalFees.str(),
" cumulativeIL=",
fwCumulativeIL.istr(),
" currentPeriodIL=",
fwPeriodIL.istr(),
" totalIL=",
fwTotalIL.istr(),
" netPnL=",
fwNetPnL.istr(),
" rebalances=",
fwRebalances.str(),
" timeInRange=",
fwTIR.str(),
" bps"
)
);
}
}
/**
* @notice Return final computed metrics for each strategy. Used by Reporter.
* @dev Computes open-position IL at current price without mutating state.
*/
function getResults()
external
view
returns (StrategyResult memory hodlResult, StrategyResult memory frResult, StrategyResult memory fwResult)
{
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- HODL -----
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
hodlResult = StrategyResult({
initialCapitalToken0: hodlInitialValueToken0,
finalValueToken0: hodlFinalValue,
feesToken0: 0,
rebalances: 0,
ilToken0: 0,
netPnLToken0: int256(hodlFinalValue) - int256(hodlInitialValueToken0),
blocksInRange: 0,
totalBlocks: 0
});
// ----- Full-Range LP -----
{
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96);
uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frPosValue) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frFeesT0);
frResult = StrategyResult({
initialCapitalToken0: frInitialValueToken0,
finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL),
feesToken0: frFeesT0,
rebalances: 0,
ilToken0: frIL,
netPnLToken0: frNetPnL,
blocksInRange: frBlocksInRange,
totalBlocks: frTotalBlocks
});
}
// ----- Fixed-Width LP -----
{
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96);
uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
// Current period IL.
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = fwTotalIL + int256(fwFeesT0);
fwResult = StrategyResult({
initialCapitalToken0: fwInitialValueToken0,
finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL),
feesToken0: fwFeesT0,
rebalances: fwRebalances,
ilToken0: fwTotalIL,
netPnLToken0: fwNetPnL,
blocksInRange: fwBlocksInRange,
totalBlocks: fwTotalBlocks
});
}
}
// -------------------------------------------------------------------------
// Uniswap V3 mint callback
// -------------------------------------------------------------------------
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
require(msg.sender == address(pool), "BaselineStrategies: bad mint callback");
if (amount0Owed > 0) {
uint256 have = token0.balanceOf(address(this));
if (have < amount0Owed) token0.mint(address(this), amount0Owed - have);
token0.transfer(address(pool), amount0Owed);
}
if (amount1Owed > 0) {
uint256 have = token1.balanceOf(address(this));
if (have < amount1Owed) token1.mint(address(this), amount1Owed - have);
token1.transfer(address(pool), amount1Owed);
}
}
// -------------------------------------------------------------------------
// Internal: FixedWidth rebalancing
// -------------------------------------------------------------------------
function _rebalanceFixedWidth(int24 currentTick) internal {
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
// Burn current position and collect principal + fees.
(uint256 p0, uint256 p1) = pool.burn(fwLo, fwHi, fwLiquidity);
(uint128 c0, uint128 c1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max);
// fees = collected - principal
uint256 f0 = uint256(c0) > p0 ? uint256(c0) - p0 : 0;
uint256 f1 = uint256(c1) > p1 ? uint256(c1) - p1 : 0;
fwFees0 += f0;
fwFees1 += f1;
fwTotalFeesToken0 += _valueInToken0(f0, f1, sqrtPriceX96);
// IL for this closed period: LP exit value vs holding entry amounts at exit price.
uint256 exitVal = _valueInToken0(p0, p1, sqrtPriceX96);
uint256 hodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
fwCumulativeIL += int256(exitVal) - int256(hodlVal);
fwRebalances++;
fwLiquidity = 0;
// Deploy new position centered around current tick.
(int24 newLo, int24 newHi) = _computeFixedRange(currentTick);
uint256 avail0 = uint256(c0);
uint256 avail1 = uint256(c1);
uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96, TickMath.getSqrtRatioAtTick(newLo), TickMath.getSqrtRatioAtTick(newHi), avail0, avail1
);
if (newLiq > 0) {
(uint256 used0, uint256 used1) = pool.mint(address(this), newLo, newHi, newLiq, "");
fwLiquidity = newLiq;
fwLo = newLo;
fwHi = newHi;
fwPeriodEntryToken0 = used0;
fwPeriodEntryToken1 = used1;
}
console2.log(
string.concat(
"[BASELINE][FW][REBALANCE] #",
fwRebalances.str(),
" tick=",
int256(currentTick).istr(),
" oldRange=[",
int256(fwLo).istr(),
",",
int256(fwHi).istr(),
"] newRange=[",
int256(newLo).istr(),
",",
int256(newHi).istr(),
"] fees0=",
f0.str(),
" fees1=",
f1.str()
)
);
}
// -------------------------------------------------------------------------
// Internal: Uniswap V3 math helpers
// -------------------------------------------------------------------------
function _positionAmounts(
uint128 liquidity,
int24 tickLower,
int24 tickUpper,
uint160 sqrtPriceX96
)
internal
pure
returns (uint256 amount0, uint256 amount1)
{
if (liquidity == 0) return (0, 0);
uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper);
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLow, sqrtHigh, liquidity);
}
/**
* @notice Convert (amount0, amount1) to token0-equivalent units.
* value = amount0 + amount1 × Q96² / sqrtPriceX96²
*/
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;
}
/**
* @notice Convert a token0 amount to its token1 equivalent at the current price.
* amount1 = amount0 × sqrtPriceX96² / Q96²
*/
function _valueInToken1(uint256 amount0, uint160 sqrtPriceX96) internal pure returns (uint256) {
if (sqrtPriceX96 == 0 || amount0 == 0) return 0;
return Math.mulDiv(Math.mulDiv(amount0, uint256(sqrtPriceX96), Q96), uint256(sqrtPriceX96), Q96);
}
// -------------------------------------------------------------------------
// Internal: tick range helpers
// -------------------------------------------------------------------------
/// @notice Compute the full-range lower tick (smallest valid tick aligned to tickSpacing).
function _fullRangeLo() internal view returns (int24) {
// Solidity truncation toward zero = ceiling for negatives → smallest valid multiple >= MIN_TICK.
return (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
}
/// @notice Compute the full-range upper tick (largest valid tick aligned to tickSpacing).
function _fullRangeHi() internal view returns (int24) {
return (TickMath.MAX_TICK / tickSpacing) * tickSpacing;
}
/**
* @notice Compute ±FIXED_WIDTH_TICKS range centered on `currentTick`, aligned to tickSpacing.
* lo is aligned DOWN (floor); hi is aligned UP (ceiling).
* Result is clamped to the full-range bounds.
*/
function _computeFixedRange(int24 currentTick) internal view returns (int24 lo, int24 hi) {
int24 rawLo = currentTick - FIXED_WIDTH_TICKS;
int24 rawHi = currentTick + FIXED_WIDTH_TICKS;
// Floor for lo (align down toward more-negative).
lo = (rawLo / tickSpacing) * tickSpacing;
if (rawLo < 0 && rawLo % tickSpacing != 0) lo -= tickSpacing;
// Ceiling for hi (align up toward more-positive).
hi = (rawHi / tickSpacing) * tickSpacing;
if (rawHi > 0 && rawHi % tickSpacing != 0) hi += tickSpacing;
// Clamp to valid pool range.
int24 minValid = _fullRangeLo();
int24 maxValid = _fullRangeHi();
if (lo < minValid) lo = minValid;
if (hi > maxValid) hi = maxValid;
if (lo >= hi) {
lo = minValid;
hi = maxValid;
}
}
}