harb/onchain/script/backtesting/BaselineStrategies.sol

609 lines
24 KiB
Solidity
Raw Normal View History

// 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;
}
}
}