Merge pull request 'fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)' (#358) from fix/issue-320 into master
This commit is contained in:
commit
59d8364ef3
6 changed files with 1169 additions and 5 deletions
|
|
@ -2,10 +2,11 @@
|
||||||
pragma solidity ^0.8.19;
|
pragma solidity ^0.8.19;
|
||||||
|
|
||||||
import { BacktestKraiken } from "./BacktestKraiken.sol";
|
import { BacktestKraiken } from "./BacktestKraiken.sol";
|
||||||
|
import { BaselineStrategies } from "./BaselineStrategies.sol";
|
||||||
import { EventReplayer } from "./EventReplayer.sol";
|
import { EventReplayer } from "./EventReplayer.sol";
|
||||||
import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol";
|
import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol";
|
||||||
import { MockToken } from "./MockToken.sol";
|
import { MockToken } from "./MockToken.sol";
|
||||||
|
import { Reporter } from "./Reporter.sol";
|
||||||
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
|
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
|
||||||
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
||||||
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
||||||
|
|
@ -151,8 +152,17 @@ contract BacktestRunner is Script {
|
||||||
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth);
|
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth);
|
||||||
sys.lm.setRecenterAccess(address(executor));
|
sys.lm.setRecenterAccess(address(executor));
|
||||||
|
|
||||||
|
// Deploy baseline strategies and initialize with the same capital as KrAIken.
|
||||||
|
BaselineStrategies baselines =
|
||||||
|
new BaselineStrategies(sp.pool, MockToken(sp.token0), MockToken(sp.token1), token0isWeth, recenterInterval);
|
||||||
|
baselines.initialize(initialCapital);
|
||||||
|
|
||||||
vm.stopBroadcast();
|
vm.stopBroadcast();
|
||||||
|
|
||||||
|
// Reporter uses Foundry cheatcodes (vm.writeFile) — must live outside the broadcast
|
||||||
|
// block so it is never sent as a real transaction on a live fork.
|
||||||
|
Reporter reporter = new Reporter();
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// EventReplayer is instantiated outside the broadcast block because
|
// EventReplayer is instantiated outside the broadcast block because
|
||||||
// it uses Foundry cheat codes (vm.readLine, vm.roll, vm.warp) that
|
// it uses Foundry cheat codes (vm.readLine, vm.roll, vm.warp) that
|
||||||
|
|
@ -187,6 +197,8 @@ contract BacktestRunner is Script {
|
||||||
console2.log("LiquidityMgr: ", address(sys.lm));
|
console2.log("LiquidityMgr: ", address(sys.lm));
|
||||||
console2.log("StrategyExec: ", address(executor));
|
console2.log("StrategyExec: ", address(executor));
|
||||||
console2.log("PositionTracker:", address(executor.tracker()));
|
console2.log("PositionTracker:", address(executor.tracker()));
|
||||||
|
console2.log("BaselineStrats: ", address(baselines));
|
||||||
|
console2.log("Reporter: ", address(reporter));
|
||||||
console2.log("Recenter intv: ", recenterInterval, " blocks");
|
console2.log("Recenter intv: ", recenterInterval, " blocks");
|
||||||
console2.log("Initial capital:", initialCapital, " (mock WETH wei)");
|
console2.log("Initial capital:", initialCapital, " (mock WETH wei)");
|
||||||
console2.log("token0isWeth: ", token0isWeth);
|
console2.log("token0isWeth: ", token0isWeth);
|
||||||
|
|
@ -218,10 +230,37 @@ contract BacktestRunner is Script {
|
||||||
console2.log("\n=== Starting Event Replay ===");
|
console2.log("\n=== Starting Event Replay ===");
|
||||||
console2.log("Events file: ", eventsFile);
|
console2.log("Events file: ", eventsFile);
|
||||||
console2.log("Total events: ", totalEvents);
|
console2.log("Total events: ", totalEvents);
|
||||||
replayer.replay(eventsFile, totalEvents, executor);
|
replayer.replay(eventsFile, totalEvents, executor, baselines);
|
||||||
|
|
||||||
// Print final KrAIken strategy summary.
|
// Print final KrAIken strategy summary.
|
||||||
executor.logSummary();
|
executor.logSummary();
|
||||||
|
|
||||||
|
// Materialize open-position fees for both LP baselines before summary/reporting.
|
||||||
|
baselines.collectFinalFees();
|
||||||
|
|
||||||
|
// Print baseline summaries (now have complete fee data).
|
||||||
|
baselines.logFinalSummary();
|
||||||
|
|
||||||
|
// Generate comparison report (Markdown + JSON).
|
||||||
|
// firstUpdateBlock and lastNotifiedBlock are the actual block numbers from the
|
||||||
|
// events file — use them directly for an accurate period estimate.
|
||||||
|
uint256 startBlock = baselines.firstUpdateBlock();
|
||||||
|
uint256 endBlock = executor.tracker().lastNotifiedBlock();
|
||||||
|
uint256 periodDays = endBlock > startBlock ? ((endBlock - startBlock) * 2) / 86_400 : 0;
|
||||||
|
|
||||||
|
reporter.generate(
|
||||||
|
executor,
|
||||||
|
baselines,
|
||||||
|
Reporter.Config({
|
||||||
|
poolAddress: address(sp.pool),
|
||||||
|
startBlock: startBlock,
|
||||||
|
endBlock: endBlock,
|
||||||
|
initialCapital: initialCapital,
|
||||||
|
recenterInterval: recenterInterval,
|
||||||
|
poolLabel: "AERO/WETH 1%",
|
||||||
|
periodDays: periodDays
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
680
onchain/script/backtesting/BaselineStrategies.sol
Normal file
680
onchain/script/backtesting/BaselineStrategies.sol
Normal file
|
|
@ -0,0 +1,680 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
pragma solidity ^0.8.19;
|
pragma solidity ^0.8.19;
|
||||||
|
|
||||||
|
import { BaselineStrategies } from "./BaselineStrategies.sol";
|
||||||
import { MockToken } from "./MockToken.sol";
|
import { MockToken } from "./MockToken.sol";
|
||||||
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
||||||
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
||||||
|
|
@ -94,7 +95,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
|
||||||
* Pass 0 to omit the denominator from progress logs.
|
* Pass 0 to omit the denominator from progress logs.
|
||||||
*/
|
*/
|
||||||
function replay(string memory eventsFile, uint256 totalEvents) external {
|
function replay(string memory eventsFile, uint256 totalEvents) external {
|
||||||
_replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0)));
|
_replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0)), BaselineStrategies(address(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,10 +106,35 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
|
||||||
* Pass address(0) to disable strategy integration.
|
* Pass address(0) to disable strategy integration.
|
||||||
*/
|
*/
|
||||||
function replay(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) external {
|
function replay(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) external {
|
||||||
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor);
|
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor, BaselineStrategies(address(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _replayWithStrategy(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) internal {
|
/**
|
||||||
|
* @notice Replay events, trigger KrAIken recenter, and update baseline strategies — all in one pass.
|
||||||
|
* @param eventsFile Path to the .jsonl events cache.
|
||||||
|
* @param totalEvents Total event count (for progress logs).
|
||||||
|
* @param strategyExecutor KrAIken StrategyExecutor (pass address(0) to disable).
|
||||||
|
* @param baselines Baseline strategies to update each block (pass address(0) to disable).
|
||||||
|
*/
|
||||||
|
function replay(
|
||||||
|
string memory eventsFile,
|
||||||
|
uint256 totalEvents,
|
||||||
|
StrategyExecutor strategyExecutor,
|
||||||
|
BaselineStrategies baselines
|
||||||
|
)
|
||||||
|
external
|
||||||
|
{
|
||||||
|
_replayWithStrategy(eventsFile, totalEvents, strategyExecutor, baselines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _replayWithStrategy(
|
||||||
|
string memory eventsFile,
|
||||||
|
uint256 totalEvents,
|
||||||
|
StrategyExecutor strategyExecutor,
|
||||||
|
BaselineStrategies baselines
|
||||||
|
)
|
||||||
|
internal
|
||||||
|
{
|
||||||
uint256 idx = 0;
|
uint256 idx = 0;
|
||||||
|
|
||||||
// Track the last Swap event's expected state for drift measurement.
|
// Track the last Swap event's expected state for drift measurement.
|
||||||
|
|
@ -136,6 +162,11 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
|
||||||
strategyExecutor.maybeRecenter(blockNum);
|
strategyExecutor.maybeRecenter(blockNum);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update baseline strategies for the same block.
|
||||||
|
if (address(baselines) != address(0)) {
|
||||||
|
baselines.maybeUpdate(blockNum);
|
||||||
|
}
|
||||||
|
|
||||||
if (_streq(eventName, "Swap")) {
|
if (_streq(eventName, "Swap")) {
|
||||||
(int24 expTick, uint160 expSqrtPrice) = _replaySwap(line);
|
(int24 expTick, uint160 expSqrtPrice) = _replaySwap(line);
|
||||||
// Update reference state only when the swap was not skipped.
|
// Update reference state only when the swap was not skipped.
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,42 @@ contract PositionTracker {
|
||||||
_openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp);
|
_openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// View: final computed metrics (consumed by Reporter)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct FinalData {
|
||||||
|
int256 finalIL;
|
||||||
|
int256 finalNetPnL;
|
||||||
|
uint256 finalFeesToken0;
|
||||||
|
uint256 rebalances;
|
||||||
|
uint256 blocksInRange;
|
||||||
|
uint256 totalBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Compute final aggregate metrics including open-position IL.
|
||||||
|
* Returns the same values that logFinalSummary() logs.
|
||||||
|
*/
|
||||||
|
function getFinalData() external view returns (FinalData memory d) {
|
||||||
|
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
|
||||||
|
d.finalIL = totalILToken0;
|
||||||
|
d.finalNetPnL = totalNetPnLToken0;
|
||||||
|
d.finalFeesToken0 = totalFeesToken0;
|
||||||
|
d.rebalances = rebalanceCount;
|
||||||
|
d.blocksInRange = blocksAnchorInRange;
|
||||||
|
d.totalBlocks = blocksChecked;
|
||||||
|
|
||||||
|
for (uint8 i = 0; i < 3; i++) {
|
||||||
|
OpenPosition storage pos = openPositions[i];
|
||||||
|
if (!pos.active) continue;
|
||||||
|
(uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(pos.liquidity, pos.tickLower, pos.tickUpper, sqrtPriceX96);
|
||||||
|
int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96);
|
||||||
|
d.finalIL += il;
|
||||||
|
d.finalNetPnL += il; // no fees for unclosed positions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notice Log the final aggregate summary. Call once at the end of replay.
|
* @notice Log the final aggregate summary. Call once at the end of replay.
|
||||||
* @param blockNum Final block number (for context in the summary line).
|
* @param blockNum Final block number (for context in the summary line).
|
||||||
|
|
|
||||||
378
onchain/script/backtesting/Reporter.sol
Normal file
378
onchain/script/backtesting/Reporter.sol
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
pragma solidity ^0.8.19;
|
||||||
|
|
||||||
|
import { BaselineStrategies } from "./BaselineStrategies.sol";
|
||||||
|
import { FormatLib } from "./FormatLib.sol";
|
||||||
|
import { PositionTracker } from "./PositionTracker.sol";
|
||||||
|
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
||||||
|
import { Vm } from "forge-std/Vm.sol";
|
||||||
|
import { console2 } from "forge-std/console2.sol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @title Reporter
|
||||||
|
* @notice Generates a Markdown + JSON backtest report comparing KrAIken's 3-position strategy
|
||||||
|
* against three baseline strategies (HODL, Full-Range LP, Fixed-Width LP).
|
||||||
|
*
|
||||||
|
* Output files (relative to the forge project root):
|
||||||
|
* script/backtesting/reports/report-{endBlock}.md
|
||||||
|
* script/backtesting/reports/report-{endBlock}.json
|
||||||
|
*
|
||||||
|
* Requires Foundry cheatcodes (vm.writeFile) and read-write fs_permissions.
|
||||||
|
*
|
||||||
|
* WARNING — backtesting use only. Not a deployable production contract.
|
||||||
|
*/
|
||||||
|
contract Reporter {
|
||||||
|
using FormatLib for uint256;
|
||||||
|
using FormatLib for int256;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Foundry cheatcode handle (same pattern as EventReplayer)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Config struct (passed to generate())
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
address poolAddress;
|
||||||
|
uint256 startBlock;
|
||||||
|
uint256 endBlock;
|
||||||
|
uint256 initialCapital; // token0 units (wei)
|
||||||
|
uint256 recenterInterval;
|
||||||
|
string poolLabel; // e.g. "AERO/WETH 1%"
|
||||||
|
uint256 periodDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Row data (internal working struct)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Row {
|
||||||
|
string name;
|
||||||
|
uint256 initialCapital;
|
||||||
|
uint256 finalValue;
|
||||||
|
uint256 feesToken0;
|
||||||
|
uint256 rebalances;
|
||||||
|
int256 ilToken0;
|
||||||
|
int256 netPnLToken0;
|
||||||
|
uint256 blocksInRange;
|
||||||
|
uint256 totalBlocks;
|
||||||
|
bool isHodl; // suppresses time-in-range column, shows $0 fees
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Main entry point
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Collect metrics from all strategies, write Markdown and JSON reports.
|
||||||
|
* @param kraiken StrategyExecutor for the KrAIken 3-position strategy.
|
||||||
|
* @param baselines Baseline strategies contract.
|
||||||
|
* @param cfg Report configuration.
|
||||||
|
*/
|
||||||
|
function generate(StrategyExecutor kraiken, BaselineStrategies baselines, Config calldata cfg) external {
|
||||||
|
PositionTracker tracker = kraiken.tracker();
|
||||||
|
PositionTracker.FinalData memory fd = tracker.getFinalData();
|
||||||
|
|
||||||
|
// Build KrAIken row from PositionTracker data.
|
||||||
|
Row memory kRow = Row({
|
||||||
|
name: "KrAIken 3-Pos",
|
||||||
|
initialCapital: cfg.initialCapital,
|
||||||
|
finalValue: uint256(int256(cfg.initialCapital) + fd.finalNetPnL),
|
||||||
|
feesToken0: fd.finalFeesToken0,
|
||||||
|
rebalances: fd.rebalances,
|
||||||
|
ilToken0: fd.finalIL,
|
||||||
|
netPnLToken0: fd.finalNetPnL,
|
||||||
|
blocksInRange: fd.blocksInRange,
|
||||||
|
totalBlocks: fd.totalBlocks,
|
||||||
|
isHodl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect baseline results.
|
||||||
|
(BaselineStrategies.StrategyResult memory hodlR, BaselineStrategies.StrategyResult memory frR, BaselineStrategies.StrategyResult memory fwR) =
|
||||||
|
baselines.getResults();
|
||||||
|
|
||||||
|
Row memory frRow = Row({
|
||||||
|
name: "Full-Range LP",
|
||||||
|
initialCapital: frR.initialCapitalToken0,
|
||||||
|
finalValue: frR.finalValueToken0,
|
||||||
|
feesToken0: frR.feesToken0,
|
||||||
|
rebalances: frR.rebalances,
|
||||||
|
ilToken0: frR.ilToken0,
|
||||||
|
netPnLToken0: frR.netPnLToken0,
|
||||||
|
blocksInRange: frR.blocksInRange,
|
||||||
|
totalBlocks: frR.totalBlocks,
|
||||||
|
isHodl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
Row memory fwRow = Row({
|
||||||
|
name: "Fixed-Width LP",
|
||||||
|
initialCapital: fwR.initialCapitalToken0,
|
||||||
|
finalValue: fwR.finalValueToken0,
|
||||||
|
feesToken0: fwR.feesToken0,
|
||||||
|
rebalances: fwR.rebalances,
|
||||||
|
ilToken0: fwR.ilToken0,
|
||||||
|
netPnLToken0: fwR.netPnLToken0,
|
||||||
|
blocksInRange: fwR.blocksInRange,
|
||||||
|
totalBlocks: fwR.totalBlocks,
|
||||||
|
isHodl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
Row memory hodlRow = Row({
|
||||||
|
name: "HODL",
|
||||||
|
initialCapital: hodlR.initialCapitalToken0,
|
||||||
|
finalValue: hodlR.finalValueToken0,
|
||||||
|
feesToken0: 0,
|
||||||
|
rebalances: 0,
|
||||||
|
ilToken0: 0,
|
||||||
|
netPnLToken0: hodlR.netPnLToken0,
|
||||||
|
blocksInRange: 0,
|
||||||
|
totalBlocks: 0,
|
||||||
|
isHodl: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write files.
|
||||||
|
string memory suffix = cfg.endBlock.str();
|
||||||
|
string memory mdPath = string.concat("script/backtesting/reports/report-", suffix, ".md");
|
||||||
|
string memory jsonPath = string.concat("script/backtesting/reports/report-", suffix, ".json");
|
||||||
|
|
||||||
|
vm.writeFile(mdPath, _buildMarkdown(kRow, frRow, fwRow, hodlRow, cfg));
|
||||||
|
console2.log(string.concat("[REPORTER] Markdown: ", mdPath));
|
||||||
|
|
||||||
|
vm.writeFile(jsonPath, _buildJson(kRow, frRow, fwRow, hodlRow, cfg));
|
||||||
|
console2.log(string.concat("[REPORTER] JSON: ", jsonPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Markdown generation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _buildMarkdown(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
|
||||||
|
internal
|
||||||
|
pure
|
||||||
|
returns (string memory)
|
||||||
|
{
|
||||||
|
return string.concat(
|
||||||
|
"# Backtest Report: ",
|
||||||
|
cfg.poolLabel,
|
||||||
|
", ",
|
||||||
|
cfg.periodDays.str(),
|
||||||
|
" days\n\n",
|
||||||
|
"| Strategy | Final Value (token0) | Fees Earned (token0) | Rebalances |"
|
||||||
|
" IL (%) | Net P&L (%) | Time in Range |\n",
|
||||||
|
"|----------------|----------------------|----------------------|------------|"
|
||||||
|
"---------|-------------|---------------|\n",
|
||||||
|
_mdRow(k),
|
||||||
|
_mdRow(fr),
|
||||||
|
_mdRow(fw),
|
||||||
|
_mdRow(hodl),
|
||||||
|
"\n## Configuration\n\n",
|
||||||
|
"- Pool: `",
|
||||||
|
_addrStr(cfg.poolAddress),
|
||||||
|
"`\n",
|
||||||
|
"- Period: block ",
|
||||||
|
cfg.startBlock.str(),
|
||||||
|
" to block ",
|
||||||
|
cfg.endBlock.str(),
|
||||||
|
" (",
|
||||||
|
cfg.periodDays.str(),
|
||||||
|
" days)\n",
|
||||||
|
"- Initial capital: ",
|
||||||
|
_etherStr(cfg.initialCapital),
|
||||||
|
" ETH equivalent\n",
|
||||||
|
"- Recenter interval: ",
|
||||||
|
cfg.recenterInterval.str(),
|
||||||
|
" blocks\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mdRow(Row memory r) internal pure returns (string memory) {
|
||||||
|
string memory tir;
|
||||||
|
if (r.isHodl) {
|
||||||
|
tir = "N/A";
|
||||||
|
} else if (r.totalBlocks == 0) {
|
||||||
|
tir = "0.00%";
|
||||||
|
} else {
|
||||||
|
tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
string memory fees = r.isHodl ? "$0" : _etherStr(r.feesToken0);
|
||||||
|
string memory il = r.isHodl ? "0" : _signedPctStr(r.ilToken0, r.initialCapital);
|
||||||
|
|
||||||
|
return string.concat(
|
||||||
|
"| ",
|
||||||
|
_pad(r.name, 14),
|
||||||
|
" | ",
|
||||||
|
_etherStr(r.finalValue),
|
||||||
|
" | ",
|
||||||
|
fees,
|
||||||
|
" | ",
|
||||||
|
r.rebalances.str(),
|
||||||
|
" | ",
|
||||||
|
il,
|
||||||
|
" | ",
|
||||||
|
_signedPctStr(r.netPnLToken0, r.initialCapital),
|
||||||
|
" | ",
|
||||||
|
tir,
|
||||||
|
" |\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON generation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _buildJson(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
|
||||||
|
internal
|
||||||
|
pure
|
||||||
|
returns (string memory)
|
||||||
|
{
|
||||||
|
return string.concat(
|
||||||
|
"{\n",
|
||||||
|
' "strategies": {\n',
|
||||||
|
' "kraiken": ',
|
||||||
|
_jsonRow(k),
|
||||||
|
",\n",
|
||||||
|
' "fullRange": ',
|
||||||
|
_jsonRow(fr),
|
||||||
|
",\n",
|
||||||
|
' "fixedWidth": ',
|
||||||
|
_jsonRow(fw),
|
||||||
|
",\n",
|
||||||
|
' "hodl": ',
|
||||||
|
_jsonRow(hodl),
|
||||||
|
"\n",
|
||||||
|
" },\n",
|
||||||
|
' "config": {\n',
|
||||||
|
' "pool": "',
|
||||||
|
_addrStr(cfg.poolAddress),
|
||||||
|
'",\n',
|
||||||
|
' "poolLabel": "',
|
||||||
|
cfg.poolLabel,
|
||||||
|
'",\n',
|
||||||
|
' "startBlock": ',
|
||||||
|
cfg.startBlock.str(),
|
||||||
|
",\n",
|
||||||
|
' "endBlock": ',
|
||||||
|
cfg.endBlock.str(),
|
||||||
|
",\n",
|
||||||
|
' "periodDays": ',
|
||||||
|
cfg.periodDays.str(),
|
||||||
|
",\n",
|
||||||
|
' "initialCapital": "',
|
||||||
|
cfg.initialCapital.str(),
|
||||||
|
'",\n',
|
||||||
|
' "recenterInterval": ',
|
||||||
|
cfg.recenterInterval.str(),
|
||||||
|
"\n",
|
||||||
|
" }\n",
|
||||||
|
"}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _jsonRow(Row memory r) internal pure returns (string memory) {
|
||||||
|
string memory tir = r.isHodl ? "null" : r.totalBlocks.str();
|
||||||
|
string memory inRange = r.isHodl ? "null" : r.blocksInRange.str();
|
||||||
|
return string.concat(
|
||||||
|
"{\n",
|
||||||
|
' "initialCapitalToken0": "',
|
||||||
|
r.initialCapital.str(),
|
||||||
|
'",\n',
|
||||||
|
' "finalValueToken0": "',
|
||||||
|
r.finalValue.str(),
|
||||||
|
'",\n',
|
||||||
|
' "feesToken0": "',
|
||||||
|
r.feesToken0.str(),
|
||||||
|
'",\n',
|
||||||
|
' "rebalances": ',
|
||||||
|
r.rebalances.str(),
|
||||||
|
",\n",
|
||||||
|
' "ilToken0": "',
|
||||||
|
r.ilToken0.istr(),
|
||||||
|
'",\n',
|
||||||
|
' "netPnLToken0": "',
|
||||||
|
r.netPnLToken0.istr(),
|
||||||
|
'",\n',
|
||||||
|
' "blocksInRange": ',
|
||||||
|
inRange,
|
||||||
|
",\n",
|
||||||
|
' "totalBlocks": ',
|
||||||
|
tir,
|
||||||
|
"\n",
|
||||||
|
" }"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Formatting helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Format wei as "<integer>.<6 decimals>" (without "ETH" suffix — unit depends on context).
|
||||||
|
*/
|
||||||
|
function _etherStr(uint256 wei_) internal pure returns (string memory) {
|
||||||
|
uint256 whole = wei_ / 1e18;
|
||||||
|
uint256 frac = (wei_ % 1e18) / 1e12; // 6 decimal places
|
||||||
|
string memory fracStr = frac.str();
|
||||||
|
uint256 len = bytes(fracStr).length;
|
||||||
|
string memory pad = "";
|
||||||
|
for (uint256 i = len; i < 6; i++) {
|
||||||
|
pad = string.concat("0", pad);
|
||||||
|
}
|
||||||
|
return string.concat(whole.str(), ".", pad, fracStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Format a signed ratio (value/base) as a percentage with 2 decimals: "±X.XX%".
|
||||||
|
*/
|
||||||
|
function _signedPctStr(int256 value, uint256 base) internal pure returns (string memory) {
|
||||||
|
if (base == 0) return "0.00%";
|
||||||
|
bool neg = value < 0;
|
||||||
|
uint256 abs = neg ? uint256(-value) : uint256(value);
|
||||||
|
uint256 bps = (abs * 10_000) / base;
|
||||||
|
return string.concat(neg ? "-" : "", _bpsStr(bps));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Format bps (0..10000+) as "X.XX%".
|
||||||
|
*/
|
||||||
|
function _bpsStr(uint256 bps) internal pure returns (string memory) {
|
||||||
|
uint256 whole = bps / 100;
|
||||||
|
uint256 dec = bps % 100;
|
||||||
|
return string.concat(whole.str(), ".", dec < 10 ? string.concat("0", dec.str()) : dec.str(), "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Right-pad string to `width` with spaces.
|
||||||
|
*/
|
||||||
|
function _pad(string memory s, uint256 width) internal pure returns (string memory) {
|
||||||
|
uint256 len = bytes(s).length;
|
||||||
|
if (len >= width) return s;
|
||||||
|
string memory out = s;
|
||||||
|
for (uint256 i = len; i < width; i++) {
|
||||||
|
out = string.concat(out, " ");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notice Encode an address as a lowercase hex string "0x...".
|
||||||
|
*/
|
||||||
|
function _addrStr(address addr) internal pure returns (string memory) {
|
||||||
|
bytes memory b = abi.encodePacked(addr);
|
||||||
|
bytes memory h = new bytes(42);
|
||||||
|
h[0] = "0";
|
||||||
|
h[1] = "x";
|
||||||
|
for (uint256 i = 0; i < 20; i++) {
|
||||||
|
h[2 + i * 2] = _hexChar(uint8(b[i]) >> 4);
|
||||||
|
h[3 + i * 2] = _hexChar(uint8(b[i]) & 0x0f);
|
||||||
|
}
|
||||||
|
return string(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hexChar(uint8 v) internal pure returns (bytes1) {
|
||||||
|
return v < 10 ? bytes1(48 + v) : bytes1(87 + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
onchain/script/backtesting/reports/.gitkeep
Normal file
0
onchain/script/backtesting/reports/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue