2026-02-26 16:11:15 +00:00
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
pragma solidity ^0.8.19;
|
|
|
|
|
|
|
|
|
|
|
|
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
|
|
|
|
|
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
|
|
|
|
|
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
2026-02-26 16:39:47 +00:00
|
|
|
|
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
2026-02-26 16:11:15 +00:00
|
|
|
|
|
|
|
|
|
|
import { Kraiken } from "../../src/Kraiken.sol";
|
|
|
|
|
|
import { LiquidityManager } from "../../src/LiquidityManager.sol";
|
|
|
|
|
|
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
|
|
|
|
|
|
import { IWETH9 } from "../../src/interfaces/IWETH9.sol";
|
|
|
|
|
|
|
|
|
|
|
|
import { TestEnvironment } from "../../test/helpers/TestBase.sol";
|
|
|
|
|
|
import { BullMarketOptimizer } from "../../test/mocks/BullMarketOptimizer.sol";
|
|
|
|
|
|
|
|
|
|
|
|
import { FullRangeLPStrategy, FixedWidthLPStrategy } from "./BaselineStrategies.sol";
|
|
|
|
|
|
import { Reporter, StrategyReport, BacktestConfig } from "./Reporter.sol";
|
|
|
|
|
|
|
|
|
|
|
|
import "forge-std/console2.sol";
|
|
|
|
|
|
|
|
|
|
|
|
/// @title BacktestRunner
|
|
|
|
|
|
/// @notice Simulates KrAIken's 3-position strategy against three baselines
|
|
|
|
|
|
/// (Full-Range LP, Fixed-Width LP, HODL) over a 7-day synthetic market.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// All strategies receive the same initial capital (10 ETH equivalent).
|
|
|
|
|
|
/// A random stream of buy/sell swaps is replayed through the shared pool;
|
|
|
|
|
|
/// KrAIken recenters every RECENTER_INTERVAL blocks, Fixed-Width LP
|
|
|
|
|
|
/// rebalances whenever price leaves its ±2000-tick window.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Usage:
|
|
|
|
|
|
/// forge script script/backtesting/BacktestRunner.s.sol --sig "run()"
|
|
|
|
|
|
contract BacktestRunner is Reporter {
|
|
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
uint256 internal constant INITIAL_CAPITAL = 10 ether;
|
|
|
|
|
|
uint256 internal constant RECENTER_INTERVAL = 100; // blocks per check
|
|
|
|
|
|
uint256 internal constant SIM_STEPS = 50; // simulation steps
|
|
|
|
|
|
uint256 internal constant BLOCKS_PER_STEP = 100; // each step = 100 blocks
|
|
|
|
|
|
uint256 internal constant SECONDS_PER_STEP = 7 days / SIM_STEPS; // evenly distributes 7-day window
|
|
|
|
|
|
uint256 internal constant TRADES_PER_STEP = 5;
|
|
|
|
|
|
uint256 internal constant BUY_AMOUNT = 1 ether; // WETH per buy trade
|
|
|
|
|
|
uint256 internal constant SELL_KRK_FRACTION = 10; // sell 1/10 of KRK balance per sell trade
|
|
|
|
|
|
|
|
|
|
|
|
// ── Pool state ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
IUniswapV3Pool internal pool;
|
|
|
|
|
|
IWETH9 internal weth;
|
|
|
|
|
|
Kraiken internal kraiken;
|
|
|
|
|
|
LiquidityManager internal lm;
|
|
|
|
|
|
bool internal token0isWeth;
|
|
|
|
|
|
address internal feesDest;
|
|
|
|
|
|
address internal trader;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Strategy instances ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
FullRangeLPStrategy internal fullRangeLp;
|
|
|
|
|
|
FixedWidthLPStrategy internal fixedWidthLp;
|
|
|
|
|
|
|
|
|
|
|
|
// HODL: tracked as plain state (no contract needed)
|
|
|
|
|
|
uint256 internal hodlWeth;
|
|
|
|
|
|
uint256 internal hodlToken; // KRK base units
|
|
|
|
|
|
|
|
|
|
|
|
// ── KrAIken tracking ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
uint256 internal kraikenRecenterCount;
|
|
|
|
|
|
uint256 internal kraikenBlocksInRange;
|
|
|
|
|
|
uint256 internal kraikenTotalBlocks;
|
|
|
|
|
|
|
2026-02-26 16:39:47 +00:00
|
|
|
|
// Snapshot of LM position composition at simulation start (for IL calculation)
|
|
|
|
|
|
uint256 internal kraikenInitialValue; // total WETH-equivalent value at start
|
|
|
|
|
|
uint256 internal kraikenInitialWeth; // raw WETH portion at start (positions + balance)
|
|
|
|
|
|
uint256 internal kraikenInitialToken; // KRK portion at start (positions + balance)
|
|
|
|
|
|
|
2026-02-26 16:11:15 +00:00
|
|
|
|
// ── Simulation bookkeeping ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
uint256 internal simStartBlock;
|
|
|
|
|
|
uint256 internal simStartTimestamp;
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Entry point
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function run() public {
|
|
|
|
|
|
console2.log("=== BacktestRunner: 7-day Simulation ===");
|
|
|
|
|
|
|
|
|
|
|
|
_setup();
|
|
|
|
|
|
_initializeStrategies();
|
|
|
|
|
|
_runSimulation();
|
|
|
|
|
|
_finalizeAndReport();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Setup
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function _setup() internal {
|
|
|
|
|
|
feesDest = vm.addr(1);
|
|
|
|
|
|
trader = vm.addr(2);
|
|
|
|
|
|
|
|
|
|
|
|
// Deploy the full protocol environment (pool + LM + optimizer + tokens)
|
|
|
|
|
|
address optimizer = address(new BullMarketOptimizer());
|
|
|
|
|
|
TestEnvironment testEnv = new TestEnvironment(feesDest);
|
|
|
|
|
|
(, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(true, feesDest, optimizer);
|
|
|
|
|
|
|
|
|
|
|
|
// Fund LM and wrap ETH for the initial recenter
|
|
|
|
|
|
// TestEnvironment already gives LM 50 ETH; we additionally wrap 10 for positioning
|
|
|
|
|
|
vm.prank(address(lm));
|
|
|
|
|
|
weth.deposit{ value: 10 ether }();
|
|
|
|
|
|
|
|
|
|
|
|
// First recenter establishes the three-position structure
|
|
|
|
|
|
vm.prank(feesDest);
|
2026-02-26 16:39:47 +00:00
|
|
|
|
try lm.recenter() { } catch {
|
|
|
|
|
|
console2.log("WARNING: initial recenter failed -- LM positions may be uninitialised");
|
|
|
|
|
|
}
|
2026-02-26 16:11:15 +00:00
|
|
|
|
|
|
|
|
|
|
console2.log("Pool:", address(pool));
|
|
|
|
|
|
console2.log("token0isWeth:", token0isWeth);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Strategy initialisation
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function _initializeStrategies() internal {
|
|
|
|
|
|
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
|
|
|
|
|
|
|
|
|
|
|
|
// KRK amount equivalent to INITIAL_CAPITAL/2 at current price (for LP baselines and HODL)
|
|
|
|
|
|
uint256 krkPerStrategy = _krkAmountForWeth(INITIAL_CAPITAL / 2, sqrtPriceX96, token0isWeth);
|
|
|
|
|
|
|
|
|
|
|
|
// Mint KRK for baselines (3× for FullRange + FixedWidth + HODL)
|
|
|
|
|
|
// In this simulation the LM pranks are used to issue tokens for all three
|
|
|
|
|
|
vm.prank(address(lm));
|
|
|
|
|
|
kraiken.mint(krkPerStrategy * 3);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Full-Range LP ──────────────────────────────────────────────────
|
|
|
|
|
|
fullRangeLp = new FullRangeLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
|
|
|
|
|
|
|
|
|
|
|
|
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
|
|
|
|
|
|
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
|
|
|
|
|
|
weth.transfer(address(fullRangeLp), INITIAL_CAPITAL / 2);
|
|
|
|
|
|
|
|
|
|
|
|
vm.prank(address(lm));
|
|
|
|
|
|
kraiken.transfer(address(fullRangeLp), krkPerStrategy);
|
|
|
|
|
|
fullRangeLp.initialize();
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fixed-Width LP ─────────────────────────────────────────────────
|
|
|
|
|
|
fixedWidthLp = new FixedWidthLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
|
|
|
|
|
|
|
|
|
|
|
|
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
|
|
|
|
|
|
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
|
|
|
|
|
|
weth.transfer(address(fixedWidthLp), INITIAL_CAPITAL / 2);
|
|
|
|
|
|
|
|
|
|
|
|
vm.prank(address(lm));
|
|
|
|
|
|
kraiken.transfer(address(fixedWidthLp), krkPerStrategy);
|
|
|
|
|
|
fixedWidthLp.initialize();
|
|
|
|
|
|
|
|
|
|
|
|
// ── HODL ─────────────────────────────────────────────────────────
|
|
|
|
|
|
// Just record amounts; no actual position opened
|
|
|
|
|
|
hodlWeth = INITIAL_CAPITAL / 2;
|
|
|
|
|
|
hodlToken = krkPerStrategy;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fund trader for simulation swaps ──────────────────────────────
|
|
|
|
|
|
vm.deal(trader, 200 ether);
|
|
|
|
|
|
vm.prank(trader);
|
|
|
|
|
|
weth.deposit{ value: 100 ether }();
|
|
|
|
|
|
|
|
|
|
|
|
// Trader buys KRK up front so they can also execute sells
|
|
|
|
|
|
_traderBuy(30 ether);
|
|
|
|
|
|
|
|
|
|
|
|
simStartBlock = block.number;
|
|
|
|
|
|
simStartTimestamp = block.timestamp;
|
|
|
|
|
|
|
2026-02-26 16:39:47 +00:00
|
|
|
|
// Snapshot LM position value at simulation start for fair P&L and IL measurement
|
|
|
|
|
|
(uint160 sqrtInit,,,,,, ) = pool.slot0();
|
|
|
|
|
|
(kraikenInitialValue, kraikenInitialWeth, kraikenInitialToken) = _getLmPositionStats(sqrtInit);
|
|
|
|
|
|
|
2026-02-26 16:11:15 +00:00
|
|
|
|
console2.log("Strategies initialised.");
|
|
|
|
|
|
console2.log(" Full-Range LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
|
|
|
|
|
|
console2.log(" Fixed-Width LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
|
|
|
|
|
|
console2.log(" HODL: WETH", hodlWeth / 1e18, "| KRK", hodlToken / 1e18);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Simulation loop
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function _runSimulation() internal {
|
|
|
|
|
|
console2.log("\nRunning", SIM_STEPS, "simulation steps...");
|
|
|
|
|
|
|
|
|
|
|
|
for (uint256 step = 0; step < SIM_STEPS; step++) {
|
|
|
|
|
|
// Advance time and block number
|
|
|
|
|
|
vm.roll(block.number + BLOCKS_PER_STEP);
|
|
|
|
|
|
vm.warp(block.timestamp + SECONDS_PER_STEP);
|
|
|
|
|
|
|
|
|
|
|
|
// Execute randomised trades to move price
|
|
|
|
|
|
_runTrades(step);
|
|
|
|
|
|
|
|
|
|
|
|
// ── KrAIken recenter (every RECENTER_INTERVAL blocks) ──────────
|
|
|
|
|
|
vm.prank(feesDest);
|
|
|
|
|
|
try lm.recenter() {
|
|
|
|
|
|
kraikenRecenterCount++;
|
|
|
|
|
|
} catch { }
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fixed-Width LP rebalance if out of range ───────────────────
|
|
|
|
|
|
try fixedWidthLp.maybeRebalance() { } catch { }
|
|
|
|
|
|
|
|
|
|
|
|
// ── Record time-in-range for all strategies ────────────────────
|
|
|
|
|
|
_recordBlockState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console2.log("Simulation complete. Recenters:", kraikenRecenterCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _runTrades(uint256 step) internal {
|
|
|
|
|
|
for (uint256 i = 0; i < TRADES_PER_STEP; i++) {
|
|
|
|
|
|
uint256 rand = uint256(keccak256(abi.encodePacked(step, i, block.timestamp))) % 100;
|
|
|
|
|
|
if (rand < 60) {
|
|
|
|
|
|
// 60% buys: WETH → KRK, pushes price up
|
|
|
|
|
|
uint256 wethBal = weth.balanceOf(trader);
|
|
|
|
|
|
uint256 buyAmt = BUY_AMOUNT < wethBal ? BUY_AMOUNT : wethBal / 2;
|
|
|
|
|
|
if (buyAmt > 0) _traderBuy(buyAmt);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 40% sells: KRK → WETH, pushes price down
|
|
|
|
|
|
uint256 krkBal = kraiken.balanceOf(trader);
|
|
|
|
|
|
if (krkBal > 0) {
|
|
|
|
|
|
uint256 sellAmt = krkBal / SELL_KRK_FRACTION;
|
|
|
|
|
|
if (sellAmt > 0) _traderSell(sellAmt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _recordBlockState() internal {
|
|
|
|
|
|
kraikenTotalBlocks++;
|
|
|
|
|
|
fullRangeLp.recordBlock();
|
|
|
|
|
|
fixedWidthLp.recordBlock();
|
|
|
|
|
|
|
|
|
|
|
|
// KrAIken: track whether current tick is within the anchor position
|
|
|
|
|
|
(, int24 currentTick,,,,,) = pool.slot0();
|
|
|
|
|
|
(, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
|
|
|
|
|
|
if (currentTick >= anchorLower && currentTick < anchorUpper) {
|
|
|
|
|
|
kraikenBlocksInRange++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Finalization & reporting
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function _finalizeAndReport() internal {
|
|
|
|
|
|
// Close LP positions and collect all tokens
|
|
|
|
|
|
fullRangeLp.finalize();
|
|
|
|
|
|
fixedWidthLp.finalize();
|
|
|
|
|
|
|
|
|
|
|
|
// Current KRK price (WETH base units per 1 KRK base unit, scaled ×1e18)
|
|
|
|
|
|
(uint160 sqrtFinal,,,,,, ) = pool.slot0();
|
|
|
|
|
|
uint256 krkPrice = _krkPriceScaled(sqrtFinal, token0isWeth);
|
|
|
|
|
|
|
|
|
|
|
|
// ── HODL final value ───────────────────────────────────────────────
|
|
|
|
|
|
uint256 hodlFinalValue = hodlWeth + (hodlToken * krkPrice / 1e18);
|
|
|
|
|
|
int256 hodlPnlBps = (int256(hodlFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Full-Range LP ──────────────────────────────────────────────────
|
|
|
|
|
|
uint256 frFinalValue = fullRangeLp.getWethBalance() + (fullRangeLp.getTokenBalance() * krkPrice / 1e18);
|
|
|
|
|
|
uint256 frFeesWeth = token0isWeth ? fullRangeLp.feesCollected0() : fullRangeLp.feesCollected1();
|
|
|
|
|
|
uint256 frFeesToken = token0isWeth ? fullRangeLp.feesCollected1() : fullRangeLp.feesCollected0();
|
|
|
|
|
|
uint256 frFeesValue = frFeesWeth + (frFeesToken * krkPrice / 1e18);
|
|
|
|
|
|
// Position value without fees (for IL calculation)
|
|
|
|
|
|
uint256 frNoFees = frFinalValue > frFeesValue ? frFinalValue - frFeesValue : 0;
|
|
|
|
|
|
int256 frIlBps = (int256(frNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
|
|
|
|
|
|
int256 frPnlBps = (int256(frFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fixed-Width LP ─────────────────────────────────────────────────
|
|
|
|
|
|
uint256 fwFinalValue = fixedWidthLp.getWethBalance() + (fixedWidthLp.getTokenBalance() * krkPrice / 1e18);
|
|
|
|
|
|
uint256 fwFeesWeth = token0isWeth ? fixedWidthLp.feesCollected0() : fixedWidthLp.feesCollected1();
|
|
|
|
|
|
uint256 fwFeesToken = token0isWeth ? fixedWidthLp.feesCollected1() : fixedWidthLp.feesCollected0();
|
|
|
|
|
|
uint256 fwFeesValue = fwFeesWeth + (fwFeesToken * krkPrice / 1e18);
|
|
|
|
|
|
uint256 fwNoFees = fwFinalValue > fwFeesValue ? fwFinalValue - fwFeesValue : 0;
|
|
|
|
|
|
int256 fwIlBps = (int256(fwNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
|
|
|
|
|
|
int256 fwPnlBps = (int256(fwFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
|
|
|
|
|
uint256 fwTimeInRangeBps = fixedWidthLp.totalBlocks() > 0 ? fixedWidthLp.blocksInRange() * 10_000 / fixedWidthLp.totalBlocks() : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// ── KrAIken 3-position strategy ────────────────────────────────────
|
2026-02-26 16:39:47 +00:00
|
|
|
|
// feesDest is the configured feeDestination for this LM; _scrapePositions() transfers
|
|
|
|
|
|
// fee0 and fee1 directly to feeDestination on every recenter, so these balances are
|
|
|
|
|
|
// the actual LP trading fees distributed to the protocol over the simulation period.
|
2026-02-26 16:11:15 +00:00
|
|
|
|
uint256 kraikenFeesWeth = weth.balanceOf(feesDest);
|
|
|
|
|
|
uint256 kraikenFeesToken = kraiken.balanceOf(feesDest);
|
|
|
|
|
|
uint256 kraikenFeesValue = kraikenFeesWeth + (kraikenFeesToken * krkPrice / 1e18);
|
2026-02-26 16:39:47 +00:00
|
|
|
|
|
|
|
|
|
|
// Measure the current market value of the LM's live positions plus its raw balances.
|
|
|
|
|
|
// The LM's positions remain open at end of simulation (no finalize() call), so we
|
|
|
|
|
|
// must compute their value using tick math rather than burning them.
|
|
|
|
|
|
(uint256 kraikenEndPositionValue, , ) = _getLmPositionStats(sqrtFinal);
|
|
|
|
|
|
|
|
|
|
|
|
// Final value = open position value + fees already distributed to feesDest
|
|
|
|
|
|
uint256 kraikenFinalValue = kraikenEndPositionValue + kraikenFeesValue;
|
|
|
|
|
|
|
|
|
|
|
|
// P&L relative to measured start value (not a hardcoded constant)
|
|
|
|
|
|
int256 kraikenPnlBps = (int256(kraikenFinalValue) - int256(kraikenInitialValue)) * 10_000
|
|
|
|
|
|
/ int256(kraikenInitialValue > 0 ? kraikenInitialValue : 1);
|
|
|
|
|
|
|
|
|
|
|
|
// IL: compare open position value (fees already excluded — they left to feesDest) to
|
|
|
|
|
|
// HODL of the same initial token split at final price.
|
|
|
|
|
|
uint256 kraikenHodlFinalValue = kraikenInitialWeth + (kraikenInitialToken * krkPrice / 1e18);
|
|
|
|
|
|
int256 kraikenIlBps = (int256(kraikenEndPositionValue) - int256(kraikenHodlFinalValue)) * 10_000
|
|
|
|
|
|
/ int256(kraikenHodlFinalValue > 0 ? kraikenHodlFinalValue : 1);
|
|
|
|
|
|
|
2026-02-26 16:11:15 +00:00
|
|
|
|
uint256 kraikenTimeInRangeBps = kraikenTotalBlocks > 0 ? kraikenBlocksInRange * 10_000 / kraikenTotalBlocks : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Assemble reports ───────────────────────────────────────────────
|
|
|
|
|
|
StrategyReport[] memory reports = new StrategyReport[](4);
|
|
|
|
|
|
|
|
|
|
|
|
reports[0] = StrategyReport({
|
|
|
|
|
|
name: "KrAIken 3-Pos",
|
2026-02-26 16:39:47 +00:00
|
|
|
|
initialValueWeth: kraikenInitialValue,
|
2026-02-26 16:11:15 +00:00
|
|
|
|
finalValueWeth: kraikenFinalValue,
|
|
|
|
|
|
feesEarnedWeth: kraikenFeesWeth,
|
|
|
|
|
|
feesEarnedToken: kraikenFeesToken,
|
|
|
|
|
|
rebalanceCount: kraikenRecenterCount,
|
2026-02-26 16:39:47 +00:00
|
|
|
|
ilBps: kraikenIlBps,
|
2026-02-26 16:11:15 +00:00
|
|
|
|
netPnlBps: kraikenPnlBps,
|
|
|
|
|
|
timeInRangeBps: kraikenTimeInRangeBps,
|
|
|
|
|
|
hasTimeInRange: true
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
reports[1] = StrategyReport({
|
|
|
|
|
|
name: "Full-Range LP",
|
|
|
|
|
|
initialValueWeth: INITIAL_CAPITAL,
|
|
|
|
|
|
finalValueWeth: frFinalValue,
|
|
|
|
|
|
feesEarnedWeth: frFeesWeth,
|
|
|
|
|
|
feesEarnedToken: frFeesToken,
|
|
|
|
|
|
rebalanceCount: 0,
|
|
|
|
|
|
ilBps: frIlBps,
|
|
|
|
|
|
netPnlBps: frPnlBps,
|
|
|
|
|
|
timeInRangeBps: 10_000, // always 100%
|
|
|
|
|
|
hasTimeInRange: true
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
reports[2] = StrategyReport({
|
|
|
|
|
|
name: "Fixed-Width LP",
|
|
|
|
|
|
initialValueWeth: INITIAL_CAPITAL,
|
|
|
|
|
|
finalValueWeth: fwFinalValue,
|
|
|
|
|
|
feesEarnedWeth: fwFeesWeth,
|
|
|
|
|
|
feesEarnedToken: fwFeesToken,
|
|
|
|
|
|
rebalanceCount: fixedWidthLp.rebalanceCount(),
|
|
|
|
|
|
ilBps: fwIlBps,
|
|
|
|
|
|
netPnlBps: fwPnlBps,
|
|
|
|
|
|
timeInRangeBps: fwTimeInRangeBps,
|
|
|
|
|
|
hasTimeInRange: true
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
reports[3] = StrategyReport({
|
|
|
|
|
|
name: "HODL",
|
|
|
|
|
|
initialValueWeth: INITIAL_CAPITAL,
|
|
|
|
|
|
finalValueWeth: hodlFinalValue,
|
|
|
|
|
|
feesEarnedWeth: 0,
|
|
|
|
|
|
feesEarnedToken: 0,
|
|
|
|
|
|
rebalanceCount: 0,
|
|
|
|
|
|
ilBps: 0,
|
|
|
|
|
|
netPnlBps: hodlPnlBps,
|
|
|
|
|
|
timeInRangeBps: 0,
|
|
|
|
|
|
hasTimeInRange: false
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
BacktestConfig memory config = BacktestConfig({
|
|
|
|
|
|
pool: address(pool),
|
|
|
|
|
|
startBlock: simStartBlock,
|
|
|
|
|
|
endBlock: block.number,
|
|
|
|
|
|
startTimestamp: simStartTimestamp,
|
|
|
|
|
|
endTimestamp: block.timestamp,
|
|
|
|
|
|
initialCapitalWei: INITIAL_CAPITAL,
|
|
|
|
|
|
recenterInterval: RECENTER_INTERVAL
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
_printSummary(reports);
|
|
|
|
|
|
_writeReport(reports, config);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _printSummary(StrategyReport[] memory reports) internal view {
|
|
|
|
|
|
console2.log("\n--- Strategy Summary ---");
|
|
|
|
|
|
for (uint256 i = 0; i < reports.length; i++) {
|
|
|
|
|
|
console2.log(reports[i].name);
|
|
|
|
|
|
console2.log(" Final value (ETH):", reports[i].finalValueWeth / 1e18);
|
|
|
|
|
|
console2.log(" Fees WETH: ", reports[i].feesEarnedWeth / 1e18);
|
|
|
|
|
|
console2.log(" Rebalances: ", reports[i].rebalanceCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 16:39:47 +00:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// KrAIken position valuation
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Compute the total WETH-equivalent value of all three LM positions
|
|
|
|
|
|
/// plus the LM's uninvested WETH balance, at the given sqrtPrice.
|
|
|
|
|
|
/// @return totalValueWeth All holdings valued in WETH (wei)
|
|
|
|
|
|
/// @return totalWeth Raw WETH in positions + LM balance (wei)
|
|
|
|
|
|
/// @return totalKrk Raw KRK in positions + LM balance (base units)
|
|
|
|
|
|
function _getLmPositionStats(uint160 sqrtPrice)
|
|
|
|
|
|
internal
|
|
|
|
|
|
view
|
|
|
|
|
|
returns (uint256 totalValueWeth, uint256 totalWeth, uint256 totalKrk)
|
|
|
|
|
|
{
|
|
|
|
|
|
ThreePositionStrategy.Stage[3] memory stages = [
|
|
|
|
|
|
ThreePositionStrategy.Stage.FLOOR,
|
|
|
|
|
|
ThreePositionStrategy.Stage.ANCHOR,
|
|
|
|
|
|
ThreePositionStrategy.Stage.DISCOVERY
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (uint256 i = 0; i < 3; i++) {
|
|
|
|
|
|
(uint128 liquidity, int24 tickLower, int24 tickUpper) = lm.positions(stages[i]);
|
|
|
|
|
|
if (liquidity == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower);
|
|
|
|
|
|
uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper);
|
|
|
|
|
|
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPrice, sqrtA, sqrtB, liquidity);
|
|
|
|
|
|
|
|
|
|
|
|
if (token0isWeth) {
|
|
|
|
|
|
totalWeth += amount0;
|
|
|
|
|
|
totalKrk += amount1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
totalKrk += amount0;
|
|
|
|
|
|
totalWeth += amount1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Include uninvested balances held directly by the LM
|
|
|
|
|
|
totalWeth += weth.balanceOf(address(lm));
|
|
|
|
|
|
totalKrk += kraiken.balanceOf(address(lm));
|
|
|
|
|
|
|
|
|
|
|
|
uint256 krkPrice = _krkPriceScaled(sqrtPrice, token0isWeth);
|
|
|
|
|
|
totalValueWeth = totalWeth + (totalKrk * krkPrice / 1e18);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 16:11:15 +00:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Swap helpers (direct pool interaction)
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Execute a buy (WETH → KRK) on behalf of the trader.
|
|
|
|
|
|
function _traderBuy(uint256 wethAmount) internal {
|
|
|
|
|
|
if (wethAmount == 0 || weth.balanceOf(trader) < wethAmount) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Transfer WETH to this contract for the swap callback
|
|
|
|
|
|
vm.prank(trader);
|
|
|
|
|
|
weth.transfer(address(this), wethAmount);
|
|
|
|
|
|
|
|
|
|
|
|
bool zeroForOne = token0isWeth; // WETH→KRK: zeroForOne when WETH is token0
|
|
|
|
|
|
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
|
|
|
|
|
|
|
|
|
|
|
|
try pool.swap(trader, zeroForOne, int256(wethAmount), limitPrice, "") { } catch { }
|
|
|
|
|
|
|
|
|
|
|
|
// Return any unused WETH to trader
|
|
|
|
|
|
uint256 stuck = weth.balanceOf(address(this));
|
|
|
|
|
|
if (stuck > 0) weth.transfer(trader, stuck);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Execute a sell (KRK → WETH) on behalf of the trader.
|
|
|
|
|
|
function _traderSell(uint256 krkAmount) internal {
|
|
|
|
|
|
if (krkAmount == 0 || kraiken.balanceOf(trader) < krkAmount) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Transfer KRK to this contract for the swap callback
|
|
|
|
|
|
vm.prank(trader);
|
|
|
|
|
|
kraiken.transfer(address(this), krkAmount);
|
|
|
|
|
|
|
|
|
|
|
|
bool zeroForOne = !token0isWeth; // KRK→WETH: zeroForOne when KRK is token0
|
|
|
|
|
|
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
|
|
|
|
|
|
|
|
|
|
|
|
try pool.swap(trader, zeroForOne, int256(krkAmount), limitPrice, "") { } catch { }
|
|
|
|
|
|
|
|
|
|
|
|
// Return any unused KRK to trader
|
|
|
|
|
|
uint256 stuck = kraiken.balanceOf(address(this));
|
|
|
|
|
|
if (stuck > 0) kraiken.transfer(trader, stuck);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Uniswap V3 swap callback — provides tokens from this contract's balance.
|
|
|
|
|
|
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
|
|
|
|
|
|
require(msg.sender == address(pool), "not pool");
|
|
|
|
|
|
if (amount0Delta > 0) {
|
|
|
|
|
|
IERC20(pool.token0()).transfer(msg.sender, uint256(amount0Delta));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (amount1Delta > 0) {
|
|
|
|
|
|
IERC20(pool.token1()).transfer(msg.sender, uint256(amount1Delta));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Price math helpers
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Compute KRK (base units) per WETH (base units) at the given sqrtPriceX96.
|
|
|
|
|
|
/// Used to fund baseline LP strategies with the right KRK/WETH split.
|
|
|
|
|
|
/// @param wethAmount WETH in base units (wei)
|
|
|
|
|
|
/// @param sqrtPriceX96 Q64.96 sqrt price from pool.slot0()
|
|
|
|
|
|
/// @param t0isWeth Whether token0 is WETH in this pool
|
|
|
|
|
|
/// @return krkAmount KRK base units equivalent to wethAmount
|
|
|
|
|
|
function _krkAmountForWeth(uint256 wethAmount, uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 krkAmount) {
|
|
|
|
|
|
uint256 sq = uint256(sqrtPriceX96);
|
|
|
|
|
|
if (t0isWeth) {
|
|
|
|
|
|
// price = KRK base units / WETH base units = sq^2 / 2^192
|
|
|
|
|
|
// krkAmount = wethAmount × sq^2 / 2^192
|
2026-02-26 16:39:47 +00:00
|
|
|
|
// Approximation: shifting sq right by 48 before squaring avoids overflow at typical
|
|
|
|
|
|
// pool prices (sqrtPriceX96 well below 2^128). At extreme tick boundaries
|
|
|
|
|
|
// (sqrtPriceX96 near MAX_SQRT_RATIO ≈ 2^128) p would be ~2^80 and overflow uint256.
|
|
|
|
|
|
// Acceptable for this simulation where the pool is initialised near 1 ETH : 240k KRK.
|
2026-02-26 16:11:15 +00:00
|
|
|
|
uint256 p = sq >> 48;
|
|
|
|
|
|
krkAmount = wethAmount * p * p >> 96;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// price = WETH base units / KRK base units = sq^2 / 2^192
|
|
|
|
|
|
// krkAmount = wethAmount × 2^192 / sq^2
|
|
|
|
|
|
// Safe two-step division avoids squaring a uint160
|
|
|
|
|
|
uint256 step1 = (wethAmount << 96) / sq;
|
|
|
|
|
|
krkAmount = (step1 << 96) / sq;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// @notice Compute KRK price in WETH, scaled by 1e18 (i.e. "1 KRK base unit = X wei WETH × 1e18").
|
|
|
|
|
|
/// Used to value token positions and HODL at final prices.
|
|
|
|
|
|
function _krkPriceScaled(uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 priceScaled) {
|
|
|
|
|
|
uint256 sq = uint256(sqrtPriceX96);
|
|
|
|
|
|
if (t0isWeth) {
|
|
|
|
|
|
// price (KRK/WETH) = sq^2/2^192 → WETH/KRK = 2^192/sq^2
|
|
|
|
|
|
// priceScaled = 1e18 × 2^192 / sq^2
|
|
|
|
|
|
uint256 step1 = (uint256(1e18) << 96) / sq;
|
|
|
|
|
|
priceScaled = (step1 << 96) / sq;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// price (WETH/KRK) = sq^2/2^192
|
|
|
|
|
|
// priceScaled = 1e18 × sq^2/2^192 → use (sq×1e9 >> 96)^2
|
|
|
|
|
|
uint256 sqNorm = (sq * 1e9) >> 96;
|
|
|
|
|
|
priceScaled = sqNorm * sqNorm;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|