harb/onchain/script/backtesting/BaselineStrategies.sol
openhands af86ca1226 fix: address review feedback on BaselineStrategies and Reporter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:43:49 +00:00

680 lines
28 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.
*
* Call order:
* 1. initialize(initialCapital) — during vm.startBroadcast()
* 2. maybeUpdate(blockNum) — called by EventReplayer on every block during replay
* 3. collectFinalFees() — after replay, before logFinalSummary / getResults
* 4. logFinalSummary() / getResults() — view, rely on collectFinalFees() having run
*
* Capital:
* All strategies receive the same initial capital split 50/50 by value.
* HODL holds the same token amounts that Full-Range LP actually deployed, so the
* HODL vs LP comparison is perfectly apples-to-apples.
*
* Fee accounting:
* Fixed-Width fees are accumulated at each rebalance's contemporaneous sqrtPriceX96 into
* fwTotalFeesToken0. Full-Range fees are collected once (in collectFinalFees) at the final
* price into frTotalFeesToken0. Both accumulators are used directly by getResults().
*
* 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;
/// @notice Token amounts deployed into the Full-Range position at entry.
uint256 public frEntryToken0;
uint256 public frEntryToken1;
uint256 public frInitialValueToken0;
/// @notice Raw fee totals (populated by collectFinalFees).
uint256 public frFees0;
uint256 public frFees1;
/// @notice Fees in token0-equivalent units at collection price (set by collectFinalFees).
uint256 public frTotalFeesToken0;
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;
uint256 public fwInitialValueToken0;
/// @notice Raw fee totals accumulated across all periods.
uint256 public fwFees0;
uint256 public fwFees1;
/// @notice Fees accumulated at contemporaneous prices across all closed periods,
/// plus the current period fees added by collectFinalFees().
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;
/// @notice First block number observed during replay (set on first maybeUpdate call).
uint256 public firstUpdateBlock;
// -------------------------------------------------------------------------
// 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: half in token0, the equivalent in token1.
* Full-Range LP is deployed first; HODL holds the same token amounts that
* Full-Range actually consumed so both strategies start with identical capital.
*/
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);
// ----- Full-Range LP (deployed first so HODL can mirror its amounts) -----
{
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);
}
}
// ----- HODL: hold the same amounts Full-Range actually deployed -----
// Falls back to half0/half1 if FullRange mint produced zero (edge case).
if (frEntryToken0 > 0 || frEntryToken1 > 0) {
hodlEntryToken0 = frEntryToken0;
hodlEntryToken1 = frEntryToken1;
hodlInitialValueToken0 = frInitialValueToken0;
} else {
hodlEntryToken0 = half0;
hodlEntryToken1 = half1;
hodlInitialValueToken0 = _valueInToken0(half0, half1, 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;
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;
// Record the first block seen during replay for accurate period reporting.
if (firstUpdateBlock == 0) firstUpdateBlock = 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);
}
}
}
// -------------------------------------------------------------------------
// Post-replay fee collection (MUST be called before logFinalSummary / getResults)
// -------------------------------------------------------------------------
/**
* @notice Materialize accrued fees for the still-open Full-Range and Fixed-Width positions.
* Uses pool.burn(lo, hi, 0) to trigger fee accrual without removing liquidity,
* then pool.collect() to sweep fees to this contract.
*
* Call once after replay ends and before any summary/reporting functions.
* Idempotent if positions have zero pending fees.
*/
function collectFinalFees() external {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
// ----- Full-Range: single collection at end-of-replay price -----
if (fullRangeLiquidity > 0) {
pool.burn(frLo, frHi, 0); // trigger fee accrual; no liquidity removed
(uint128 f0, uint128 f1) = pool.collect(address(this), frLo, frHi, type(uint128).max, type(uint128).max);
frFees0 += uint256(f0);
frFees1 += uint256(f1);
// Value all Full-Range fees at current price (only one collection, so no
// historical repricing concern for this strategy).
frTotalFeesToken0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
}
// ----- Fixed-Width: current open period fees (historical fees already in fwTotalFeesToken0) -----
if (fwLiquidity > 0) {
pool.burn(fwLo, fwHi, 0); // trigger fee accrual; no liquidity removed
(uint128 f0, uint128 f1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max);
fwFees0 += uint256(f0);
fwFees1 += uint256(f1);
// Add current-period fees at current price to the historical-price accumulator.
fwTotalFeesToken0 += _valueInToken0(uint256(f0), uint256(f1), sqrtPriceX96);
}
}
// -------------------------------------------------------------------------
// Final summary (view — requires collectFinalFees() to have been called first)
// -------------------------------------------------------------------------
/**
* @notice Log final metrics for all three strategies. Call once after collectFinalFees().
*/
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);
// frTotalFeesToken0 populated by collectFinalFees().
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frFinalPos) - int256(frHodlVal);
int256 frNetPnL = frIL + int256(frTotalFeesToken0);
uint256 frFinalValue = frFinalPos + frTotalFeesToken0;
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(),
" finalValue=",
frFinalValue.str(),
" feesToken0=",
frTotalFeesToken0.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);
// fwTotalFeesToken0: historical periods at contemporaneous prices + current period at current
// price (added by collectFinalFees()).
uint256 fwFinalValue = fwFinalPos + fwTotalFeesToken0;
// 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(fwTotalFeesToken0);
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(),
" finalValue=",
fwFinalValue.str(),
" feesToken0=",
fwTotalFeesToken0.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 Requires collectFinalFees() to have been called first so fee accumulators are complete.
* finalValueToken0 = positionValue + feesToken0 (both non-negative, no underflow risk).
*/
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);
// finalValue = position value + fees (always non-negative).
uint256 frFinalValue = frPosValue + frTotalFeesToken0;
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
int256 frIL = int256(frPosValue) - int256(frHodlVal);
int256 frNetPnL = int256(frFinalValue) - int256(frInitialValueToken0);
frResult = StrategyResult({
initialCapitalToken0: frInitialValueToken0,
finalValueToken0: frFinalValue,
feesToken0: frTotalFeesToken0,
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);
// finalValue = position value + all fees (always non-negative).
uint256 fwFinalValue = fwPosValue + fwTotalFeesToken0;
// Current period IL.
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal);
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
int256 fwNetPnL = int256(fwFinalValue) - int256(fwInitialValueToken0);
fwResult = StrategyResult({
initialCapitalToken0: fwInitialValueToken0,
finalValueToken0: fwFinalValue,
feesToken0: fwTotalFeesToken0,
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();
// Capture old range before any mutation — used in the log line below.
int24 oldLo = fwLo;
int24 oldHi = fwHi;
// 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;
// Accumulate at contemporaneous price (more accurate than repricing at end).
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;
}
// Log uses oldLo/oldHi captured before fwLo/fwHi were overwritten.
console2.log(
string.concat(
"[BASELINE][FW][REBALANCE] #",
fwRebalances.str(),
" tick=",
int256(currentTick).istr(),
" oldRange=[",
int256(oldLo).istr(),
",",
int256(oldHi).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;
}
}
}