Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
33c5244e53
commit
77f0fd82fd
6 changed files with 1095 additions and 6 deletions
|
|
@ -2,10 +2,11 @@
|
|||
pragma solidity ^0.8.19;
|
||||
|
||||
import { BacktestKraiken } from "./BacktestKraiken.sol";
|
||||
|
||||
import { BaselineStrategies } from "./BaselineStrategies.sol";
|
||||
import { EventReplayer } from "./EventReplayer.sol";
|
||||
import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol";
|
||||
import { MockToken } from "./MockToken.sol";
|
||||
import { Reporter } from "./Reporter.sol";
|
||||
import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol";
|
||||
import { StrategyExecutor } from "./StrategyExecutor.sol";
|
||||
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
||||
|
|
@ -151,6 +152,14 @@ contract BacktestRunner is Script {
|
|||
new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth);
|
||||
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);
|
||||
|
||||
// Deploy Reporter (no broadcast needed — it only writes files).
|
||||
Reporter reporter = new Reporter();
|
||||
|
||||
vm.stopBroadcast();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -187,6 +196,8 @@ contract BacktestRunner is Script {
|
|||
console2.log("LiquidityMgr: ", address(sys.lm));
|
||||
console2.log("StrategyExec: ", address(executor));
|
||||
console2.log("PositionTracker:", address(executor.tracker()));
|
||||
console2.log("BaselineStrats: ", address(baselines));
|
||||
console2.log("Reporter: ", address(reporter));
|
||||
console2.log("Recenter intv: ", recenterInterval, " blocks");
|
||||
console2.log("Initial capital:", initialCapital, " (mock WETH wei)");
|
||||
console2.log("token0isWeth: ", token0isWeth);
|
||||
|
|
@ -218,10 +229,35 @@ contract BacktestRunner is Script {
|
|||
console2.log("\n=== Starting Event Replay ===");
|
||||
console2.log("Events file: ", eventsFile);
|
||||
console2.log("Total events: ", totalEvents);
|
||||
replayer.replay(eventsFile, totalEvents, executor);
|
||||
replayer.replay(eventsFile, totalEvents, executor, baselines);
|
||||
|
||||
// Print final KrAIken strategy summary.
|
||||
// Print final strategy summaries.
|
||||
executor.logSummary();
|
||||
baselines.logFinalSummary();
|
||||
|
||||
// Generate comparison report (Markdown + JSON).
|
||||
// Use fwTotalBlocks as the total replay duration (every unique block processed).
|
||||
// Approximate period length at ~2 s/block.
|
||||
uint256 endBlock = executor.tracker().lastNotifiedBlock();
|
||||
uint256 totalBlocksElapsed = baselines.fwTotalBlocks();
|
||||
uint256 startBlock = totalBlocksElapsed > 0 && endBlock > totalBlocksElapsed
|
||||
? endBlock - totalBlocksElapsed
|
||||
: endBlock;
|
||||
uint256 periodDays = (totalBlocksElapsed * 2) / 86_400;
|
||||
|
||||
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 { }
|
||||
}
|
||||
|
|
|
|||
608
onchain/script/backtesting/BaselineStrategies.sol
Normal file
608
onchain/script/backtesting/BaselineStrategies.sol
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { FormatLib } from "./FormatLib.sol";
|
||||
import { MockToken } from "./MockToken.sol";
|
||||
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
||||
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
||||
import { Math } from "@openzeppelin/utils/math/Math.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
|
||||
import { console2 } from "forge-std/console2.sol";
|
||||
|
||||
/**
|
||||
* @title BaselineStrategies
|
||||
* @notice Manages three comparison strategies run alongside KrAIken during event replay:
|
||||
*
|
||||
* HODL — Hold initial token amounts, no LP. Zero fees, zero IL by definition.
|
||||
* Full-Range — Single position from aligned MIN_TICK to MAX_TICK. Set once, never rebalanced.
|
||||
* Always 100% time-in-range.
|
||||
* Fixed-Width — ±FIXED_WIDTH_TICKS around current price. Rebalanced at the same block-interval
|
||||
* as KrAIken when the active tick leaves the range.
|
||||
*
|
||||
* All strategies are seeded with the same `initialCapital` (in token0 units) split 50/50 by
|
||||
* value between token0 and token1. HODL holds those amounts; LP strategies deploy them.
|
||||
*
|
||||
* Capital tracking:
|
||||
* The contract implements IUniswapV3MintCallback and freely mints MockTokens as needed —
|
||||
* MockToken.mint() has no access control. Actual entry amounts are recorded from the return
|
||||
* values of pool.mint() for accurate IL computation.
|
||||
*
|
||||
* WARNING — backtesting use only. No access control.
|
||||
*/
|
||||
contract BaselineStrategies is IUniswapV3MintCallback {
|
||||
using Math for uint256;
|
||||
using FormatLib for uint256;
|
||||
using FormatLib for int256;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// @notice Packed result for a single strategy, consumed by Reporter.
|
||||
struct StrategyResult {
|
||||
uint256 initialCapitalToken0;
|
||||
uint256 finalValueToken0;
|
||||
uint256 feesToken0;
|
||||
uint256 rebalances;
|
||||
int256 ilToken0;
|
||||
int256 netPnLToken0;
|
||||
uint256 blocksInRange;
|
||||
uint256 totalBlocks;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
uint256 internal constant Q96 = 2 ** 96;
|
||||
|
||||
/// @notice Half-width of the fixed-width LP range in ticks (total width = 2×).
|
||||
int24 public constant FIXED_WIDTH_TICKS = 2000;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Immutables
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
IUniswapV3Pool public immutable pool;
|
||||
MockToken public immutable token0;
|
||||
MockToken public immutable token1;
|
||||
bool public immutable token0isWeth;
|
||||
uint256 public immutable recenterInterval;
|
||||
int24 public immutable tickSpacing;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HODL state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
uint256 public hodlEntryToken0;
|
||||
uint256 public hodlEntryToken1;
|
||||
uint256 public hodlInitialValueToken0;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Full-Range LP state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
uint128 public fullRangeLiquidity;
|
||||
int24 public frLo;
|
||||
int24 public frHi;
|
||||
uint256 public frEntryToken0;
|
||||
uint256 public frEntryToken1;
|
||||
uint256 public frInitialValueToken0;
|
||||
uint256 public frFees0;
|
||||
uint256 public frFees1;
|
||||
uint256 public frTotalBlocks;
|
||||
uint256 public frBlocksInRange;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fixed-Width LP state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
uint128 public fwLiquidity;
|
||||
int24 public fwLo;
|
||||
int24 public fwHi;
|
||||
/// @notice Entry amounts for the CURRENT open period (reset on each rebalance).
|
||||
uint256 public fwPeriodEntryToken0;
|
||||
uint256 public fwPeriodEntryToken1;
|
||||
/// @notice Original entry amounts at the very first deployment (for overall IL reference).
|
||||
uint256 public fwInitialToken0;
|
||||
uint256 public fwInitialToken1;
|
||||
uint256 public fwInitialValueToken0;
|
||||
uint256 public fwFees0;
|
||||
uint256 public fwFees1;
|
||||
uint256 public fwTotalFeesToken0;
|
||||
uint256 public fwRebalances;
|
||||
/// @notice Cumulative IL from closed periods (token0 units, negative = LP underperformed HODL).
|
||||
int256 public fwCumulativeIL;
|
||||
uint256 public fwTotalBlocks;
|
||||
uint256 public fwBlocksInRange;
|
||||
uint256 public fwLastCheckBlock;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Common state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool public initialized;
|
||||
uint256 public lastUpdateBlock;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
constructor(IUniswapV3Pool _pool, MockToken _token0, MockToken _token1, bool _token0isWeth, uint256 _recenterInterval) {
|
||||
pool = _pool;
|
||||
token0 = _token0;
|
||||
token1 = _token1;
|
||||
token0isWeth = _token0isWeth;
|
||||
recenterInterval = _recenterInterval;
|
||||
tickSpacing = _pool.tickSpacing();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @notice Deploy all three baseline strategies with `initialCapital` token0 equivalent.
|
||||
* Capital is split 50/50 by value between token0 and token1.
|
||||
* HODL holds the split; LP strategies deploy it as liquidity.
|
||||
*/
|
||||
function initialize(uint256 initialCapital) external {
|
||||
require(!initialized, "BaselineStrategies: already initialized");
|
||||
initialized = true;
|
||||
|
||||
(uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0();
|
||||
|
||||
uint256 half0 = initialCapital / 2;
|
||||
uint256 half1 = _valueInToken1(half0, sqrtPriceX96);
|
||||
|
||||
// ----- HODL -----
|
||||
hodlEntryToken0 = half0;
|
||||
hodlEntryToken1 = half1;
|
||||
hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96);
|
||||
|
||||
// ----- Full-Range LP -----
|
||||
{
|
||||
int24 lo = _fullRangeLo();
|
||||
int24 hi = _fullRangeHi();
|
||||
|
||||
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
|
||||
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
|
||||
);
|
||||
|
||||
if (liq > 0) {
|
||||
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
|
||||
fullRangeLiquidity = liq;
|
||||
frLo = lo;
|
||||
frHi = hi;
|
||||
frEntryToken0 = used0;
|
||||
frEntryToken1 = used1;
|
||||
frInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Fixed-Width LP -----
|
||||
{
|
||||
(int24 lo, int24 hi) = _computeFixedRange(currentTick);
|
||||
|
||||
uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
|
||||
sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1
|
||||
);
|
||||
|
||||
if (liq > 0) {
|
||||
(uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, "");
|
||||
fwLiquidity = liq;
|
||||
fwLo = lo;
|
||||
fwHi = hi;
|
||||
fwPeriodEntryToken0 = used0;
|
||||
fwPeriodEntryToken1 = used1;
|
||||
fwInitialToken0 = used0;
|
||||
fwInitialToken1 = used1;
|
||||
fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96);
|
||||
}
|
||||
fwLastCheckBlock = block.number;
|
||||
}
|
||||
|
||||
console2.log("=== Baseline Strategies Initialized ===");
|
||||
console2.log(
|
||||
string.concat(
|
||||
"HODL: entry=[",
|
||||
hodlEntryToken0.str(),
|
||||
",",
|
||||
hodlEntryToken1.str(),
|
||||
"] initialValue=",
|
||||
hodlInitialValueToken0.str()
|
||||
)
|
||||
);
|
||||
console2.log(
|
||||
string.concat(
|
||||
"FullRange: liq=",
|
||||
uint256(fullRangeLiquidity).str(),
|
||||
" ticks=[",
|
||||
int256(frLo).istr(),
|
||||
",",
|
||||
int256(frHi).istr(),
|
||||
"] entry=[",
|
||||
frEntryToken0.str(),
|
||||
",",
|
||||
frEntryToken1.str(),
|
||||
"]"
|
||||
)
|
||||
);
|
||||
console2.log(
|
||||
string.concat(
|
||||
"FixedWidth: liq=",
|
||||
uint256(fwLiquidity).str(),
|
||||
" ticks=[",
|
||||
int256(fwLo).istr(),
|
||||
",",
|
||||
int256(fwHi).istr(),
|
||||
"] entry=[",
|
||||
fwPeriodEntryToken0.str(),
|
||||
",",
|
||||
fwPeriodEntryToken1.str(),
|
||||
"]"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-block update (called by EventReplayer after each block advance)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @notice Update time-in-range counters and trigger FixedWidth rebalancing when needed.
|
||||
* @param blockNum Current block number as advanced by EventReplayer.
|
||||
*/
|
||||
function maybeUpdate(uint256 blockNum) external {
|
||||
if (!initialized) return;
|
||||
if (blockNum == lastUpdateBlock) return;
|
||||
lastUpdateBlock = blockNum;
|
||||
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
|
||||
// Full-Range: always in range.
|
||||
frTotalBlocks++;
|
||||
frBlocksInRange++;
|
||||
|
||||
// Fixed-Width: track in-range blocks.
|
||||
fwTotalBlocks++;
|
||||
if (fwLiquidity > 0 && currentTick >= fwLo && currentTick < fwHi) {
|
||||
fwBlocksInRange++;
|
||||
}
|
||||
|
||||
// Fixed-Width rebalance: check every recenterInterval blocks.
|
||||
if (fwLiquidity > 0 && blockNum >= fwLastCheckBlock + recenterInterval) {
|
||||
fwLastCheckBlock = blockNum;
|
||||
if (currentTick < fwLo || currentTick >= fwHi) {
|
||||
_rebalanceFixedWidth(currentTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Final summary
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @notice Log final metrics for all three strategies. Call once after replay ends.
|
||||
*/
|
||||
function logFinalSummary() external view {
|
||||
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
|
||||
|
||||
// ----- HODL -----
|
||||
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
|
||||
int256 hodlNetPnL = int256(hodlFinalValue) - int256(hodlInitialValueToken0);
|
||||
console2.log("[BASELINE][HODL][SUMMARY] === HODL ===");
|
||||
console2.log(
|
||||
string.concat(
|
||||
"[BASELINE][HODL][SUMMARY] initialValue=",
|
||||
hodlInitialValueToken0.str(),
|
||||
" finalValue=",
|
||||
hodlFinalValue.str(),
|
||||
" netPnL=",
|
||||
hodlNetPnL.istr()
|
||||
)
|
||||
);
|
||||
|
||||
// ----- Full-Range LP -----
|
||||
{
|
||||
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
|
||||
uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96);
|
||||
uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
|
||||
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
|
||||
int256 frIL = int256(frFinalPos) - int256(frHodlVal);
|
||||
int256 frNetPnL = frIL + int256(frTotalFees);
|
||||
uint256 frTIR = frTotalBlocks > 0 ? (frBlocksInRange * 10_000) / frTotalBlocks : 10_000;
|
||||
console2.log("[BASELINE][FR][SUMMARY] === Full-Range LP ===");
|
||||
console2.log(
|
||||
string.concat(
|
||||
"[BASELINE][FR][SUMMARY] initialValue=",
|
||||
frInitialValueToken0.str(),
|
||||
" finalPosValue=",
|
||||
frFinalPos.str(),
|
||||
" feesToken0=",
|
||||
frTotalFees.str(),
|
||||
" IL=",
|
||||
frIL.istr(),
|
||||
" netPnL=",
|
||||
frNetPnL.istr(),
|
||||
" timeInRange=",
|
||||
frTIR.str(),
|
||||
" bps"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Fixed-Width LP -----
|
||||
{
|
||||
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
|
||||
uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96);
|
||||
uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
|
||||
// Current period IL (open position vs holding current period entry amounts).
|
||||
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
|
||||
int256 fwPeriodIL = int256(fwFinalPos) - int256(fwPeriodHodlVal);
|
||||
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
|
||||
int256 fwNetPnL = fwTotalIL + int256(fwFinalFees);
|
||||
uint256 fwTIR = fwTotalBlocks > 0 ? (fwBlocksInRange * 10_000) / fwTotalBlocks : 0;
|
||||
console2.log("[BASELINE][FW][SUMMARY] === Fixed-Width LP ===");
|
||||
console2.log(
|
||||
string.concat(
|
||||
"[BASELINE][FW][SUMMARY] initialValue=",
|
||||
fwInitialValueToken0.str(),
|
||||
" finalPosValue=",
|
||||
fwFinalPos.str(),
|
||||
" feesToken0=",
|
||||
fwFinalFees.str(),
|
||||
" cumulativeIL=",
|
||||
fwCumulativeIL.istr(),
|
||||
" currentPeriodIL=",
|
||||
fwPeriodIL.istr(),
|
||||
" totalIL=",
|
||||
fwTotalIL.istr(),
|
||||
" netPnL=",
|
||||
fwNetPnL.istr(),
|
||||
" rebalances=",
|
||||
fwRebalances.str(),
|
||||
" timeInRange=",
|
||||
fwTIR.str(),
|
||||
" bps"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Return final computed metrics for each strategy. Used by Reporter.
|
||||
* @dev Computes open-position IL at current price without mutating state.
|
||||
*/
|
||||
function getResults()
|
||||
external
|
||||
view
|
||||
returns (StrategyResult memory hodlResult, StrategyResult memory frResult, StrategyResult memory fwResult)
|
||||
{
|
||||
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
|
||||
|
||||
// ----- HODL -----
|
||||
uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96);
|
||||
hodlResult = StrategyResult({
|
||||
initialCapitalToken0: hodlInitialValueToken0,
|
||||
finalValueToken0: hodlFinalValue,
|
||||
feesToken0: 0,
|
||||
rebalances: 0,
|
||||
ilToken0: 0,
|
||||
netPnLToken0: int256(hodlFinalValue) - int256(hodlInitialValueToken0),
|
||||
blocksInRange: 0,
|
||||
totalBlocks: 0
|
||||
});
|
||||
|
||||
// ----- Full-Range LP -----
|
||||
{
|
||||
(uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96);
|
||||
uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96);
|
||||
uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96);
|
||||
uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96);
|
||||
int256 frIL = int256(frPosValue) - int256(frHodlVal);
|
||||
int256 frNetPnL = frIL + int256(frFeesT0);
|
||||
frResult = StrategyResult({
|
||||
initialCapitalToken0: frInitialValueToken0,
|
||||
finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL),
|
||||
feesToken0: frFeesT0,
|
||||
rebalances: 0,
|
||||
ilToken0: frIL,
|
||||
netPnLToken0: frNetPnL,
|
||||
blocksInRange: frBlocksInRange,
|
||||
totalBlocks: frTotalBlocks
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Fixed-Width LP -----
|
||||
{
|
||||
(uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96);
|
||||
uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96);
|
||||
uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96);
|
||||
// Current period IL.
|
||||
uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
|
||||
int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal);
|
||||
int256 fwTotalIL = fwCumulativeIL + fwPeriodIL;
|
||||
int256 fwNetPnL = fwTotalIL + int256(fwFeesT0);
|
||||
fwResult = StrategyResult({
|
||||
initialCapitalToken0: fwInitialValueToken0,
|
||||
finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL),
|
||||
feesToken0: fwFeesT0,
|
||||
rebalances: fwRebalances,
|
||||
ilToken0: fwTotalIL,
|
||||
netPnLToken0: fwNetPnL,
|
||||
blocksInRange: fwBlocksInRange,
|
||||
totalBlocks: fwTotalBlocks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Uniswap V3 mint callback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// @inheritdoc IUniswapV3MintCallback
|
||||
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
|
||||
require(msg.sender == address(pool), "BaselineStrategies: bad mint callback");
|
||||
if (amount0Owed > 0) {
|
||||
uint256 have = token0.balanceOf(address(this));
|
||||
if (have < amount0Owed) token0.mint(address(this), amount0Owed - have);
|
||||
token0.transfer(address(pool), amount0Owed);
|
||||
}
|
||||
if (amount1Owed > 0) {
|
||||
uint256 have = token1.balanceOf(address(this));
|
||||
if (have < amount1Owed) token1.mint(address(this), amount1Owed - have);
|
||||
token1.transfer(address(pool), amount1Owed);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: FixedWidth rebalancing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _rebalanceFixedWidth(int24 currentTick) internal {
|
||||
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
|
||||
|
||||
// Burn current position and collect principal + fees.
|
||||
(uint256 p0, uint256 p1) = pool.burn(fwLo, fwHi, fwLiquidity);
|
||||
(uint128 c0, uint128 c1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max);
|
||||
|
||||
// fees = collected - principal
|
||||
uint256 f0 = uint256(c0) > p0 ? uint256(c0) - p0 : 0;
|
||||
uint256 f1 = uint256(c1) > p1 ? uint256(c1) - p1 : 0;
|
||||
fwFees0 += f0;
|
||||
fwFees1 += f1;
|
||||
fwTotalFeesToken0 += _valueInToken0(f0, f1, sqrtPriceX96);
|
||||
|
||||
// IL for this closed period: LP exit value vs holding entry amounts at exit price.
|
||||
uint256 exitVal = _valueInToken0(p0, p1, sqrtPriceX96);
|
||||
uint256 hodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96);
|
||||
fwCumulativeIL += int256(exitVal) - int256(hodlVal);
|
||||
|
||||
fwRebalances++;
|
||||
fwLiquidity = 0;
|
||||
|
||||
// Deploy new position centered around current tick.
|
||||
(int24 newLo, int24 newHi) = _computeFixedRange(currentTick);
|
||||
uint256 avail0 = uint256(c0);
|
||||
uint256 avail1 = uint256(c1);
|
||||
|
||||
uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts(
|
||||
sqrtPriceX96, TickMath.getSqrtRatioAtTick(newLo), TickMath.getSqrtRatioAtTick(newHi), avail0, avail1
|
||||
);
|
||||
|
||||
if (newLiq > 0) {
|
||||
(uint256 used0, uint256 used1) = pool.mint(address(this), newLo, newHi, newLiq, "");
|
||||
fwLiquidity = newLiq;
|
||||
fwLo = newLo;
|
||||
fwHi = newHi;
|
||||
fwPeriodEntryToken0 = used0;
|
||||
fwPeriodEntryToken1 = used1;
|
||||
}
|
||||
|
||||
console2.log(
|
||||
string.concat(
|
||||
"[BASELINE][FW][REBALANCE] #",
|
||||
fwRebalances.str(),
|
||||
" tick=",
|
||||
int256(currentTick).istr(),
|
||||
" oldRange=[",
|
||||
int256(fwLo).istr(),
|
||||
",",
|
||||
int256(fwHi).istr(),
|
||||
"] newRange=[",
|
||||
int256(newLo).istr(),
|
||||
",",
|
||||
int256(newHi).istr(),
|
||||
"] fees0=",
|
||||
f0.str(),
|
||||
" fees1=",
|
||||
f1.str()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Uniswap V3 math helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _positionAmounts(
|
||||
uint128 liquidity,
|
||||
int24 tickLower,
|
||||
int24 tickUpper,
|
||||
uint160 sqrtPriceX96
|
||||
)
|
||||
internal
|
||||
pure
|
||||
returns (uint256 amount0, uint256 amount1)
|
||||
{
|
||||
if (liquidity == 0) return (0, 0);
|
||||
uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower);
|
||||
uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper);
|
||||
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLow, sqrtHigh, liquidity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Convert (amount0, amount1) to token0-equivalent units.
|
||||
* value = amount0 + amount1 × Q96² / sqrtPriceX96²
|
||||
*/
|
||||
function _valueInToken0(uint256 amount0, uint256 amount1, uint160 sqrtPriceX96) internal pure returns (uint256) {
|
||||
if (sqrtPriceX96 == 0 || amount1 == 0) return amount0;
|
||||
uint256 amt1InT0 = Math.mulDiv(Math.mulDiv(amount1, Q96, uint256(sqrtPriceX96)), Q96, uint256(sqrtPriceX96));
|
||||
return amount0 + amt1InT0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Convert a token0 amount to its token1 equivalent at the current price.
|
||||
* amount1 = amount0 × sqrtPriceX96² / Q96²
|
||||
*/
|
||||
function _valueInToken1(uint256 amount0, uint160 sqrtPriceX96) internal pure returns (uint256) {
|
||||
if (sqrtPriceX96 == 0 || amount0 == 0) return 0;
|
||||
return Math.mulDiv(Math.mulDiv(amount0, uint256(sqrtPriceX96), Q96), uint256(sqrtPriceX96), Q96);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: tick range helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// @notice Compute the full-range lower tick (smallest valid tick aligned to tickSpacing).
|
||||
function _fullRangeLo() internal view returns (int24) {
|
||||
// Solidity truncation toward zero = ceiling for negatives → smallest valid multiple >= MIN_TICK.
|
||||
return (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
|
||||
}
|
||||
|
||||
/// @notice Compute the full-range upper tick (largest valid tick aligned to tickSpacing).
|
||||
function _fullRangeHi() internal view returns (int24) {
|
||||
return (TickMath.MAX_TICK / tickSpacing) * tickSpacing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Compute ±FIXED_WIDTH_TICKS range centered on `currentTick`, aligned to tickSpacing.
|
||||
* lo is aligned DOWN (floor); hi is aligned UP (ceiling).
|
||||
* Result is clamped to the full-range bounds.
|
||||
*/
|
||||
function _computeFixedRange(int24 currentTick) internal view returns (int24 lo, int24 hi) {
|
||||
int24 rawLo = currentTick - FIXED_WIDTH_TICKS;
|
||||
int24 rawHi = currentTick + FIXED_WIDTH_TICKS;
|
||||
|
||||
// Floor for lo (align down toward more-negative).
|
||||
lo = (rawLo / tickSpacing) * tickSpacing;
|
||||
if (rawLo < 0 && rawLo % tickSpacing != 0) lo -= tickSpacing;
|
||||
|
||||
// Ceiling for hi (align up toward more-positive).
|
||||
hi = (rawHi / tickSpacing) * tickSpacing;
|
||||
if (rawHi > 0 && rawHi % tickSpacing != 0) hi += tickSpacing;
|
||||
|
||||
// Clamp to valid pool range.
|
||||
int24 minValid = _fullRangeLo();
|
||||
int24 maxValid = _fullRangeHi();
|
||||
if (lo < minValid) lo = minValid;
|
||||
if (hi > maxValid) hi = maxValid;
|
||||
if (lo >= hi) {
|
||||
lo = minValid;
|
||||
hi = maxValid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { BaselineStrategies } from "./BaselineStrategies.sol";
|
||||
import { MockToken } from "./MockToken.sol";
|
||||
import { StrategyExecutor } from "./StrategyExecutor.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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
|
||||
// Track the last Swap event's expected state for drift measurement.
|
||||
|
|
@ -136,6 +162,11 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback {
|
|||
strategyExecutor.maybeRecenter(blockNum);
|
||||
}
|
||||
|
||||
// Update baseline strategies for the same block.
|
||||
if (address(baselines) != address(0)) {
|
||||
baselines.maybeUpdate(blockNum);
|
||||
}
|
||||
|
||||
if (_streq(eventName, "Swap")) {
|
||||
(int24 expTick, uint160 expSqrtPrice) = _replaySwap(line);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.
|
||||
* @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 = "100.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