Merge pull request 'revert: Remove premature backtesting code (#320)' (#328) from revert/backtesting-320 into master
Reviewed-on: https://codeberg.org/johba/harb/pulls/328
This commit is contained in:
commit
52411e881a
4 changed files with 0 additions and 995 deletions
|
|
@ -1,533 +0,0 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
||||
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
||||
|
||||
import { Kraiken } from "../../src/Kraiken.sol";
|
||||
import { LiquidityManager } from "../../src/LiquidityManager.sol";
|
||||
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
|
||||
import { IWETH9 } from "../../src/interfaces/IWETH9.sol";
|
||||
|
||||
import { TestEnvironment } from "../../test/helpers/TestBase.sol";
|
||||
import { BullMarketOptimizer } from "../../test/mocks/BullMarketOptimizer.sol";
|
||||
|
||||
import { FullRangeLPStrategy, FixedWidthLPStrategy } from "./BaselineStrategies.sol";
|
||||
import { Reporter, StrategyReport, BacktestConfig } from "./Reporter.sol";
|
||||
|
||||
import "forge-std/console2.sol";
|
||||
|
||||
/// @title BacktestRunner
|
||||
/// @notice Simulates KrAIken's 3-position strategy against three baselines
|
||||
/// (Full-Range LP, Fixed-Width LP, HODL) over a 7-day synthetic market.
|
||||
///
|
||||
/// All strategies receive the same initial capital (10 ETH equivalent).
|
||||
/// A random stream of buy/sell swaps is replayed through the shared pool;
|
||||
/// KrAIken recenters every RECENTER_INTERVAL blocks, Fixed-Width LP
|
||||
/// rebalances whenever price leaves its ±2000-tick window.
|
||||
///
|
||||
/// Usage:
|
||||
/// forge script script/backtesting/BacktestRunner.s.sol --sig "run()"
|
||||
contract BacktestRunner is Reporter {
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
uint256 internal constant INITIAL_CAPITAL = 10 ether;
|
||||
uint256 internal constant RECENTER_INTERVAL = 100; // blocks per check
|
||||
uint256 internal constant SIM_STEPS = 50; // simulation steps
|
||||
uint256 internal constant BLOCKS_PER_STEP = 100; // each step = 100 blocks
|
||||
uint256 internal constant SECONDS_PER_STEP = 7 days / SIM_STEPS; // evenly distributes 7-day window
|
||||
uint256 internal constant TRADES_PER_STEP = 5;
|
||||
uint256 internal constant BUY_AMOUNT = 1 ether; // WETH per buy trade
|
||||
uint256 internal constant SELL_KRK_FRACTION = 10; // sell 1/10 of KRK balance per sell trade
|
||||
|
||||
// ── Pool state ────────────────────────────────────────────────────────────
|
||||
|
||||
IUniswapV3Pool internal pool;
|
||||
IWETH9 internal weth;
|
||||
Kraiken internal kraiken;
|
||||
LiquidityManager internal lm;
|
||||
bool internal token0isWeth;
|
||||
address internal feesDest;
|
||||
address internal trader;
|
||||
|
||||
// ── Strategy instances ────────────────────────────────────────────────────
|
||||
|
||||
FullRangeLPStrategy internal fullRangeLp;
|
||||
FixedWidthLPStrategy internal fixedWidthLp;
|
||||
|
||||
// HODL: tracked as plain state (no contract needed)
|
||||
uint256 internal hodlWeth;
|
||||
uint256 internal hodlToken; // KRK base units
|
||||
|
||||
// ── KrAIken tracking ─────────────────────────────────────────────────────
|
||||
|
||||
uint256 internal kraikenRecenterCount;
|
||||
uint256 internal kraikenBlocksInRange;
|
||||
uint256 internal kraikenTotalBlocks;
|
||||
|
||||
// Snapshot of LM position composition at simulation start (for IL calculation)
|
||||
uint256 internal kraikenInitialValue; // total WETH-equivalent value at start
|
||||
uint256 internal kraikenInitialWeth; // raw WETH portion at start (positions + balance)
|
||||
uint256 internal kraikenInitialToken; // KRK portion at start (positions + balance)
|
||||
|
||||
// ── Simulation bookkeeping ────────────────────────────────────────────────
|
||||
|
||||
uint256 internal simStartBlock;
|
||||
uint256 internal simStartTimestamp;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function run() public {
|
||||
console2.log("=== BacktestRunner: 7-day Simulation ===");
|
||||
|
||||
_setup();
|
||||
_initializeStrategies();
|
||||
_runSimulation();
|
||||
_finalizeAndReport();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Setup
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _setup() internal {
|
||||
feesDest = vm.addr(1);
|
||||
trader = vm.addr(2);
|
||||
|
||||
// Deploy the full protocol environment (pool + LM + optimizer + tokens)
|
||||
address optimizer = address(new BullMarketOptimizer());
|
||||
TestEnvironment testEnv = new TestEnvironment(feesDest);
|
||||
(, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(true, feesDest, optimizer);
|
||||
|
||||
// Fund LM and wrap ETH for the initial recenter
|
||||
// TestEnvironment already gives LM 50 ETH; we additionally wrap 10 for positioning
|
||||
vm.prank(address(lm));
|
||||
weth.deposit{ value: 10 ether }();
|
||||
|
||||
// First recenter establishes the three-position structure
|
||||
vm.prank(feesDest);
|
||||
try lm.recenter() { } catch {
|
||||
console2.log("WARNING: initial recenter failed -- LM positions may be uninitialised");
|
||||
}
|
||||
|
||||
console2.log("Pool:", address(pool));
|
||||
console2.log("token0isWeth:", token0isWeth);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Strategy initialisation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _initializeStrategies() internal {
|
||||
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
|
||||
|
||||
// KRK amount equivalent to INITIAL_CAPITAL/2 at current price (for LP baselines and HODL)
|
||||
uint256 krkPerStrategy = _krkAmountForWeth(INITIAL_CAPITAL / 2, sqrtPriceX96, token0isWeth);
|
||||
|
||||
// Mint KRK for baselines (3× for FullRange + FixedWidth + HODL)
|
||||
// In this simulation the LM pranks are used to issue tokens for all three
|
||||
vm.prank(address(lm));
|
||||
kraiken.mint(krkPerStrategy * 3);
|
||||
|
||||
// ── Full-Range LP ──────────────────────────────────────────────────
|
||||
fullRangeLp = new FullRangeLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
|
||||
|
||||
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
|
||||
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
|
||||
weth.transfer(address(fullRangeLp), INITIAL_CAPITAL / 2);
|
||||
|
||||
vm.prank(address(lm));
|
||||
kraiken.transfer(address(fullRangeLp), krkPerStrategy);
|
||||
fullRangeLp.initialize();
|
||||
|
||||
// ── Fixed-Width LP ─────────────────────────────────────────────────
|
||||
fixedWidthLp = new FixedWidthLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
|
||||
|
||||
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
|
||||
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
|
||||
weth.transfer(address(fixedWidthLp), INITIAL_CAPITAL / 2);
|
||||
|
||||
vm.prank(address(lm));
|
||||
kraiken.transfer(address(fixedWidthLp), krkPerStrategy);
|
||||
fixedWidthLp.initialize();
|
||||
|
||||
// ── HODL ─────────────────────────────────────────────────────────
|
||||
// Just record amounts; no actual position opened
|
||||
hodlWeth = INITIAL_CAPITAL / 2;
|
||||
hodlToken = krkPerStrategy;
|
||||
|
||||
// ── Fund trader for simulation swaps ──────────────────────────────
|
||||
vm.deal(trader, 200 ether);
|
||||
vm.prank(trader);
|
||||
weth.deposit{ value: 100 ether }();
|
||||
|
||||
// Trader buys KRK up front so they can also execute sells
|
||||
_traderBuy(30 ether);
|
||||
|
||||
simStartBlock = block.number;
|
||||
simStartTimestamp = block.timestamp;
|
||||
|
||||
// Snapshot LM position value at simulation start for fair P&L and IL measurement
|
||||
(uint160 sqrtInit,,,,,, ) = pool.slot0();
|
||||
(kraikenInitialValue, kraikenInitialWeth, kraikenInitialToken) = _getLmPositionStats(sqrtInit);
|
||||
|
||||
console2.log("Strategies initialised.");
|
||||
console2.log(" Full-Range LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
|
||||
console2.log(" Fixed-Width LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
|
||||
console2.log(" HODL: WETH", hodlWeth / 1e18, "| KRK", hodlToken / 1e18);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Simulation loop
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _runSimulation() internal {
|
||||
console2.log("\nRunning", SIM_STEPS, "simulation steps...");
|
||||
|
||||
for (uint256 step = 0; step < SIM_STEPS; step++) {
|
||||
// Advance time and block number
|
||||
vm.roll(block.number + BLOCKS_PER_STEP);
|
||||
vm.warp(block.timestamp + SECONDS_PER_STEP);
|
||||
|
||||
// Execute randomised trades to move price
|
||||
_runTrades(step);
|
||||
|
||||
// ── KrAIken recenter (every RECENTER_INTERVAL blocks) ──────────
|
||||
vm.prank(feesDest);
|
||||
try lm.recenter() {
|
||||
kraikenRecenterCount++;
|
||||
} catch { }
|
||||
|
||||
// ── Fixed-Width LP rebalance if out of range ───────────────────
|
||||
try fixedWidthLp.maybeRebalance() { } catch { }
|
||||
|
||||
// ── Record time-in-range for all strategies ────────────────────
|
||||
_recordBlockState();
|
||||
}
|
||||
|
||||
console2.log("Simulation complete. Recenters:", kraikenRecenterCount);
|
||||
}
|
||||
|
||||
function _runTrades(uint256 step) internal {
|
||||
for (uint256 i = 0; i < TRADES_PER_STEP; i++) {
|
||||
uint256 rand = uint256(keccak256(abi.encodePacked(step, i, block.timestamp))) % 100;
|
||||
if (rand < 60) {
|
||||
// 60% buys: WETH → KRK, pushes price up
|
||||
uint256 wethBal = weth.balanceOf(trader);
|
||||
uint256 buyAmt = BUY_AMOUNT < wethBal ? BUY_AMOUNT : wethBal / 2;
|
||||
if (buyAmt > 0) _traderBuy(buyAmt);
|
||||
} else {
|
||||
// 40% sells: KRK → WETH, pushes price down
|
||||
uint256 krkBal = kraiken.balanceOf(trader);
|
||||
if (krkBal > 0) {
|
||||
uint256 sellAmt = krkBal / SELL_KRK_FRACTION;
|
||||
if (sellAmt > 0) _traderSell(sellAmt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _recordBlockState() internal {
|
||||
kraikenTotalBlocks++;
|
||||
fullRangeLp.recordBlock();
|
||||
fixedWidthLp.recordBlock();
|
||||
|
||||
// KrAIken: track whether current tick is within the anchor position
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
(, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
|
||||
if (currentTick >= anchorLower && currentTick < anchorUpper) {
|
||||
kraikenBlocksInRange++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Finalization & reporting
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _finalizeAndReport() internal {
|
||||
// Close LP positions and collect all tokens
|
||||
fullRangeLp.finalize();
|
||||
fixedWidthLp.finalize();
|
||||
|
||||
// Current KRK price (WETH base units per 1 KRK base unit, scaled ×1e18)
|
||||
(uint160 sqrtFinal,,,,,, ) = pool.slot0();
|
||||
uint256 krkPrice = _krkPriceScaled(sqrtFinal, token0isWeth);
|
||||
|
||||
// ── HODL final value ───────────────────────────────────────────────
|
||||
uint256 hodlFinalValue = hodlWeth + (hodlToken * krkPrice / 1e18);
|
||||
int256 hodlPnlBps = (int256(hodlFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
||||
|
||||
// ── Full-Range LP ──────────────────────────────────────────────────
|
||||
uint256 frFinalValue = fullRangeLp.getWethBalance() + (fullRangeLp.getTokenBalance() * krkPrice / 1e18);
|
||||
uint256 frFeesWeth = token0isWeth ? fullRangeLp.feesCollected0() : fullRangeLp.feesCollected1();
|
||||
uint256 frFeesToken = token0isWeth ? fullRangeLp.feesCollected1() : fullRangeLp.feesCollected0();
|
||||
uint256 frFeesValue = frFeesWeth + (frFeesToken * krkPrice / 1e18);
|
||||
// Position value without fees (for IL calculation)
|
||||
uint256 frNoFees = frFinalValue > frFeesValue ? frFinalValue - frFeesValue : 0;
|
||||
int256 frIlBps = (int256(frNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
|
||||
int256 frPnlBps = (int256(frFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
||||
|
||||
// ── Fixed-Width LP ─────────────────────────────────────────────────
|
||||
uint256 fwFinalValue = fixedWidthLp.getWethBalance() + (fixedWidthLp.getTokenBalance() * krkPrice / 1e18);
|
||||
uint256 fwFeesWeth = token0isWeth ? fixedWidthLp.feesCollected0() : fixedWidthLp.feesCollected1();
|
||||
uint256 fwFeesToken = token0isWeth ? fixedWidthLp.feesCollected1() : fixedWidthLp.feesCollected0();
|
||||
uint256 fwFeesValue = fwFeesWeth + (fwFeesToken * krkPrice / 1e18);
|
||||
uint256 fwNoFees = fwFinalValue > fwFeesValue ? fwFinalValue - fwFeesValue : 0;
|
||||
int256 fwIlBps = (int256(fwNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
|
||||
int256 fwPnlBps = (int256(fwFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
|
||||
uint256 fwTimeInRangeBps = fixedWidthLp.totalBlocks() > 0 ? fixedWidthLp.blocksInRange() * 10_000 / fixedWidthLp.totalBlocks() : 0;
|
||||
|
||||
// ── KrAIken 3-position strategy ────────────────────────────────────
|
||||
// feesDest is the configured feeDestination for this LM; _scrapePositions() transfers
|
||||
// fee0 and fee1 directly to feeDestination on every recenter, so these balances are
|
||||
// the actual LP trading fees distributed to the protocol over the simulation period.
|
||||
uint256 kraikenFeesWeth = weth.balanceOf(feesDest);
|
||||
uint256 kraikenFeesToken = kraiken.balanceOf(feesDest);
|
||||
uint256 kraikenFeesValue = kraikenFeesWeth + (kraikenFeesToken * krkPrice / 1e18);
|
||||
|
||||
// Measure the current market value of the LM's live positions plus its raw balances.
|
||||
// The LM's positions remain open at end of simulation (no finalize() call), so we
|
||||
// must compute their value using tick math rather than burning them.
|
||||
(uint256 kraikenEndPositionValue, , ) = _getLmPositionStats(sqrtFinal);
|
||||
|
||||
// Final value = open position value + fees already distributed to feesDest
|
||||
uint256 kraikenFinalValue = kraikenEndPositionValue + kraikenFeesValue;
|
||||
|
||||
// P&L relative to measured start value (not a hardcoded constant)
|
||||
int256 kraikenPnlBps = (int256(kraikenFinalValue) - int256(kraikenInitialValue)) * 10_000
|
||||
/ int256(kraikenInitialValue > 0 ? kraikenInitialValue : 1);
|
||||
|
||||
// IL: compare open position value (fees already excluded — they left to feesDest) to
|
||||
// HODL of the same initial token split at final price.
|
||||
uint256 kraikenHodlFinalValue = kraikenInitialWeth + (kraikenInitialToken * krkPrice / 1e18);
|
||||
int256 kraikenIlBps = (int256(kraikenEndPositionValue) - int256(kraikenHodlFinalValue)) * 10_000
|
||||
/ int256(kraikenHodlFinalValue > 0 ? kraikenHodlFinalValue : 1);
|
||||
|
||||
uint256 kraikenTimeInRangeBps = kraikenTotalBlocks > 0 ? kraikenBlocksInRange * 10_000 / kraikenTotalBlocks : 0;
|
||||
|
||||
// ── Assemble reports ───────────────────────────────────────────────
|
||||
StrategyReport[] memory reports = new StrategyReport[](4);
|
||||
|
||||
reports[0] = StrategyReport({
|
||||
name: "KrAIken 3-Pos",
|
||||
initialValueWeth: kraikenInitialValue,
|
||||
finalValueWeth: kraikenFinalValue,
|
||||
feesEarnedWeth: kraikenFeesWeth,
|
||||
feesEarnedToken: kraikenFeesToken,
|
||||
rebalanceCount: kraikenRecenterCount,
|
||||
ilBps: kraikenIlBps,
|
||||
netPnlBps: kraikenPnlBps,
|
||||
timeInRangeBps: kraikenTimeInRangeBps,
|
||||
hasTimeInRange: true
|
||||
});
|
||||
|
||||
reports[1] = StrategyReport({
|
||||
name: "Full-Range LP",
|
||||
initialValueWeth: INITIAL_CAPITAL,
|
||||
finalValueWeth: frFinalValue,
|
||||
feesEarnedWeth: frFeesWeth,
|
||||
feesEarnedToken: frFeesToken,
|
||||
rebalanceCount: 0,
|
||||
ilBps: frIlBps,
|
||||
netPnlBps: frPnlBps,
|
||||
timeInRangeBps: 10_000, // always 100%
|
||||
hasTimeInRange: true
|
||||
});
|
||||
|
||||
reports[2] = StrategyReport({
|
||||
name: "Fixed-Width LP",
|
||||
initialValueWeth: INITIAL_CAPITAL,
|
||||
finalValueWeth: fwFinalValue,
|
||||
feesEarnedWeth: fwFeesWeth,
|
||||
feesEarnedToken: fwFeesToken,
|
||||
rebalanceCount: fixedWidthLp.rebalanceCount(),
|
||||
ilBps: fwIlBps,
|
||||
netPnlBps: fwPnlBps,
|
||||
timeInRangeBps: fwTimeInRangeBps,
|
||||
hasTimeInRange: true
|
||||
});
|
||||
|
||||
reports[3] = StrategyReport({
|
||||
name: "HODL",
|
||||
initialValueWeth: INITIAL_CAPITAL,
|
||||
finalValueWeth: hodlFinalValue,
|
||||
feesEarnedWeth: 0,
|
||||
feesEarnedToken: 0,
|
||||
rebalanceCount: 0,
|
||||
ilBps: 0,
|
||||
netPnlBps: hodlPnlBps,
|
||||
timeInRangeBps: 0,
|
||||
hasTimeInRange: false
|
||||
});
|
||||
|
||||
BacktestConfig memory config = BacktestConfig({
|
||||
pool: address(pool),
|
||||
startBlock: simStartBlock,
|
||||
endBlock: block.number,
|
||||
startTimestamp: simStartTimestamp,
|
||||
endTimestamp: block.timestamp,
|
||||
initialCapitalWei: INITIAL_CAPITAL,
|
||||
recenterInterval: RECENTER_INTERVAL
|
||||
});
|
||||
|
||||
_printSummary(reports);
|
||||
_writeReport(reports, config);
|
||||
}
|
||||
|
||||
function _printSummary(StrategyReport[] memory reports) internal view {
|
||||
console2.log("\n--- Strategy Summary ---");
|
||||
for (uint256 i = 0; i < reports.length; i++) {
|
||||
console2.log(reports[i].name);
|
||||
console2.log(" Final value (ETH):", reports[i].finalValueWeth / 1e18);
|
||||
console2.log(" Fees WETH: ", reports[i].feesEarnedWeth / 1e18);
|
||||
console2.log(" Rebalances: ", reports[i].rebalanceCount);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// KrAIken position valuation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// @notice Compute the total WETH-equivalent value of all three LM positions
|
||||
/// plus the LM's uninvested WETH balance, at the given sqrtPrice.
|
||||
/// @return totalValueWeth All holdings valued in WETH (wei)
|
||||
/// @return totalWeth Raw WETH in positions + LM balance (wei)
|
||||
/// @return totalKrk Raw KRK in positions + LM balance (base units)
|
||||
function _getLmPositionStats(uint160 sqrtPrice)
|
||||
internal
|
||||
view
|
||||
returns (uint256 totalValueWeth, uint256 totalWeth, uint256 totalKrk)
|
||||
{
|
||||
ThreePositionStrategy.Stage[3] memory stages = [
|
||||
ThreePositionStrategy.Stage.FLOOR,
|
||||
ThreePositionStrategy.Stage.ANCHOR,
|
||||
ThreePositionStrategy.Stage.DISCOVERY
|
||||
];
|
||||
|
||||
for (uint256 i = 0; i < 3; i++) {
|
||||
(uint128 liquidity, int24 tickLower, int24 tickUpper) = lm.positions(stages[i]);
|
||||
if (liquidity == 0) continue;
|
||||
|
||||
uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower);
|
||||
uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper);
|
||||
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPrice, sqrtA, sqrtB, liquidity);
|
||||
|
||||
if (token0isWeth) {
|
||||
totalWeth += amount0;
|
||||
totalKrk += amount1;
|
||||
} else {
|
||||
totalKrk += amount0;
|
||||
totalWeth += amount1;
|
||||
}
|
||||
}
|
||||
|
||||
// Include uninvested balances held directly by the LM
|
||||
totalWeth += weth.balanceOf(address(lm));
|
||||
totalKrk += kraiken.balanceOf(address(lm));
|
||||
|
||||
uint256 krkPrice = _krkPriceScaled(sqrtPrice, token0isWeth);
|
||||
totalValueWeth = totalWeth + (totalKrk * krkPrice / 1e18);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Swap helpers (direct pool interaction)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// @notice Execute a buy (WETH → KRK) on behalf of the trader.
|
||||
function _traderBuy(uint256 wethAmount) internal {
|
||||
if (wethAmount == 0 || weth.balanceOf(trader) < wethAmount) return;
|
||||
|
||||
// Transfer WETH to this contract for the swap callback
|
||||
vm.prank(trader);
|
||||
weth.transfer(address(this), wethAmount);
|
||||
|
||||
bool zeroForOne = token0isWeth; // WETH→KRK: zeroForOne when WETH is token0
|
||||
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
|
||||
|
||||
try pool.swap(trader, zeroForOne, int256(wethAmount), limitPrice, "") { } catch { }
|
||||
|
||||
// Return any unused WETH to trader
|
||||
uint256 stuck = weth.balanceOf(address(this));
|
||||
if (stuck > 0) weth.transfer(trader, stuck);
|
||||
}
|
||||
|
||||
/// @notice Execute a sell (KRK → WETH) on behalf of the trader.
|
||||
function _traderSell(uint256 krkAmount) internal {
|
||||
if (krkAmount == 0 || kraiken.balanceOf(trader) < krkAmount) return;
|
||||
|
||||
// Transfer KRK to this contract for the swap callback
|
||||
vm.prank(trader);
|
||||
kraiken.transfer(address(this), krkAmount);
|
||||
|
||||
bool zeroForOne = !token0isWeth; // KRK→WETH: zeroForOne when KRK is token0
|
||||
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
|
||||
|
||||
try pool.swap(trader, zeroForOne, int256(krkAmount), limitPrice, "") { } catch { }
|
||||
|
||||
// Return any unused KRK to trader
|
||||
uint256 stuck = kraiken.balanceOf(address(this));
|
||||
if (stuck > 0) kraiken.transfer(trader, stuck);
|
||||
}
|
||||
|
||||
/// @notice Uniswap V3 swap callback — provides tokens from this contract's balance.
|
||||
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
|
||||
require(msg.sender == address(pool), "not pool");
|
||||
if (amount0Delta > 0) {
|
||||
IERC20(pool.token0()).transfer(msg.sender, uint256(amount0Delta));
|
||||
}
|
||||
if (amount1Delta > 0) {
|
||||
IERC20(pool.token1()).transfer(msg.sender, uint256(amount1Delta));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Price math helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// @notice Compute KRK (base units) per WETH (base units) at the given sqrtPriceX96.
|
||||
/// Used to fund baseline LP strategies with the right KRK/WETH split.
|
||||
/// @param wethAmount WETH in base units (wei)
|
||||
/// @param sqrtPriceX96 Q64.96 sqrt price from pool.slot0()
|
||||
/// @param t0isWeth Whether token0 is WETH in this pool
|
||||
/// @return krkAmount KRK base units equivalent to wethAmount
|
||||
function _krkAmountForWeth(uint256 wethAmount, uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 krkAmount) {
|
||||
uint256 sq = uint256(sqrtPriceX96);
|
||||
if (t0isWeth) {
|
||||
// price = KRK base units / WETH base units = sq^2 / 2^192
|
||||
// krkAmount = wethAmount × sq^2 / 2^192
|
||||
// Approximation: shifting sq right by 48 before squaring avoids overflow at typical
|
||||
// pool prices (sqrtPriceX96 well below 2^128). At extreme tick boundaries
|
||||
// (sqrtPriceX96 near MAX_SQRT_RATIO ≈ 2^128) p would be ~2^80 and overflow uint256.
|
||||
// Acceptable for this simulation where the pool is initialised near 1 ETH : 240k KRK.
|
||||
uint256 p = sq >> 48;
|
||||
krkAmount = wethAmount * p * p >> 96;
|
||||
} else {
|
||||
// price = WETH base units / KRK base units = sq^2 / 2^192
|
||||
// krkAmount = wethAmount × 2^192 / sq^2
|
||||
// Safe two-step division avoids squaring a uint160
|
||||
uint256 step1 = (wethAmount << 96) / sq;
|
||||
krkAmount = (step1 << 96) / sq;
|
||||
}
|
||||
}
|
||||
|
||||
/// @notice Compute KRK price in WETH, scaled by 1e18 (i.e. "1 KRK base unit = X wei WETH × 1e18").
|
||||
/// Used to value token positions and HODL at final prices.
|
||||
function _krkPriceScaled(uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 priceScaled) {
|
||||
uint256 sq = uint256(sqrtPriceX96);
|
||||
if (t0isWeth) {
|
||||
// price (KRK/WETH) = sq^2/2^192 → WETH/KRK = 2^192/sq^2
|
||||
// priceScaled = 1e18 × 2^192 / sq^2
|
||||
uint256 step1 = (uint256(1e18) << 96) / sq;
|
||||
priceScaled = (step1 << 96) / sq;
|
||||
} else {
|
||||
// price (WETH/KRK) = sq^2/2^192
|
||||
// priceScaled = 1e18 × sq^2/2^192 → use (sq×1e9 >> 96)^2
|
||||
uint256 sqNorm = (sq * 1e9) >> 96;
|
||||
priceScaled = sqNorm * sqNorm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
|
||||
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
|
||||
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
|
||||
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Full-Range LP Strategy
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// @title FullRangeLPStrategy
|
||||
/// @notice Single LP position covering the entire valid tick range (MIN_TICK to MAX_TICK).
|
||||
/// Position is set once at initialization and never rebalanced, giving 100% time-in-range.
|
||||
contract FullRangeLPStrategy is IUniswapV3MintCallback {
|
||||
/// @dev Tick boundaries aligned to 200-spacing for the 1% fee pool
|
||||
int24 public constant TICK_LOWER = -887_200;
|
||||
int24 public constant TICK_UPPER = 887_200;
|
||||
|
||||
IUniswapV3Pool public immutable pool;
|
||||
IERC20 public immutable token0;
|
||||
IERC20 public immutable token1;
|
||||
bool public immutable token0isWeth;
|
||||
|
||||
uint128 public liquidity;
|
||||
uint256 public feesCollected0;
|
||||
uint256 public feesCollected1;
|
||||
uint256 public totalBlocks;
|
||||
bool public initialized;
|
||||
|
||||
constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) {
|
||||
pool = _pool;
|
||||
token0isWeth = _token0isWeth;
|
||||
token0 = _token0isWeth ? _weth : _token;
|
||||
token1 = _token0isWeth ? _token : _weth;
|
||||
}
|
||||
|
||||
/// @notice Initialize: use current token balances to mint a full-range position.
|
||||
/// Caller must transfer tokens to this contract before calling.
|
||||
function initialize() external {
|
||||
require(!initialized, "already initialized");
|
||||
initialized = true;
|
||||
|
||||
uint256 bal0 = token0.balanceOf(address(this));
|
||||
uint256 bal1 = token1.balanceOf(address(this));
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick);
|
||||
uint160 sqrtLower = TickMath.getSqrtRatioAtTick(TICK_LOWER);
|
||||
uint160 sqrtUpper = TickMath.getSqrtRatioAtTick(TICK_UPPER);
|
||||
|
||||
liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLower, sqrtUpper, bal0, bal1);
|
||||
if (liquidity > 0) {
|
||||
pool.mint(address(this), TICK_LOWER, TICK_UPPER, liquidity, "");
|
||||
}
|
||||
}
|
||||
|
||||
/// @notice Record one simulation block. Full-range is always in-range, so this just increments totalBlocks.
|
||||
function recordBlock() external {
|
||||
totalBlocks++;
|
||||
}
|
||||
|
||||
/// @notice Collect accrued fees without changing liquidity.
|
||||
function collectFees() external {
|
||||
if (liquidity > 0) {
|
||||
pool.burn(TICK_LOWER, TICK_UPPER, 0);
|
||||
}
|
||||
(uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max);
|
||||
feesCollected0 += f0;
|
||||
feesCollected1 += f1;
|
||||
}
|
||||
|
||||
/// @notice Burn entire position and collect all tokens + fees.
|
||||
function finalize() external {
|
||||
if (liquidity > 0) {
|
||||
pool.burn(TICK_LOWER, TICK_UPPER, liquidity);
|
||||
(uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max);
|
||||
feesCollected0 += f0;
|
||||
feesCollected1 += f1;
|
||||
liquidity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getWethBalance() external view returns (uint256) {
|
||||
return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this));
|
||||
}
|
||||
|
||||
function getTokenBalance() external view returns (uint256) {
|
||||
return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this));
|
||||
}
|
||||
|
||||
/// @inheritdoc IUniswapV3MintCallback
|
||||
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
|
||||
require(msg.sender == address(pool), "not pool");
|
||||
if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed);
|
||||
if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fixed-Width LP Strategy
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// @title FixedWidthLPStrategy
|
||||
/// @notice LP position ±2000 ticks around current price (200-tick spacing).
|
||||
/// Rebalances (removes + remints) when price exits the range.
|
||||
contract FixedWidthLPStrategy is IUniswapV3MintCallback {
|
||||
int24 public constant HALF_WIDTH = 2_000; // ±2000 ticks from center
|
||||
int24 public constant TICK_SPACING = 200;
|
||||
int24 public constant MIN_TICK = -887_200;
|
||||
int24 public constant MAX_TICK = 887_200;
|
||||
|
||||
IUniswapV3Pool public immutable pool;
|
||||
IERC20 public immutable token0;
|
||||
IERC20 public immutable token1;
|
||||
bool public immutable token0isWeth;
|
||||
|
||||
int24 public tickLower;
|
||||
int24 public tickUpper;
|
||||
uint128 public liquidity;
|
||||
|
||||
uint256 public rebalanceCount;
|
||||
uint256 public feesCollected0;
|
||||
uint256 public feesCollected1;
|
||||
uint256 public blocksInRange;
|
||||
uint256 public totalBlocks;
|
||||
bool public initialized;
|
||||
|
||||
constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) {
|
||||
pool = _pool;
|
||||
token0isWeth = _token0isWeth;
|
||||
token0 = _token0isWeth ? _weth : _token;
|
||||
token1 = _token0isWeth ? _token : _weth;
|
||||
}
|
||||
|
||||
/// @notice Initialize around the current pool price.
|
||||
/// Caller must transfer tokens to this contract before calling.
|
||||
function initialize() external {
|
||||
require(!initialized, "already initialized");
|
||||
initialized = true;
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
_createPosition(currentTick);
|
||||
}
|
||||
|
||||
/// @notice Record one simulation block; tracks time-in-range.
|
||||
function recordBlock() external {
|
||||
totalBlocks++;
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
if (currentTick >= tickLower && currentTick < tickUpper) {
|
||||
blocksInRange++;
|
||||
}
|
||||
}
|
||||
|
||||
/// @notice Rebalance if current price is outside the position range.
|
||||
/// @return didRebalance true if a rebalance occurred.
|
||||
function maybeRebalance() external returns (bool didRebalance) {
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
if (currentTick >= tickLower && currentTick < tickUpper) return false;
|
||||
|
||||
_removePosition();
|
||||
_createPosition(currentTick);
|
||||
rebalanceCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// @notice Collect accrued fees without changing liquidity.
|
||||
function collectFees() external {
|
||||
if (liquidity > 0) {
|
||||
pool.burn(tickLower, tickUpper, 0);
|
||||
}
|
||||
(uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max);
|
||||
feesCollected0 += f0;
|
||||
feesCollected1 += f1;
|
||||
}
|
||||
|
||||
/// @notice Burn current position and collect everything.
|
||||
function finalize() external {
|
||||
_removePosition();
|
||||
}
|
||||
|
||||
function getWethBalance() external view returns (uint256) {
|
||||
return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this));
|
||||
}
|
||||
|
||||
function getTokenBalance() external view returns (uint256) {
|
||||
return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this));
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _createPosition(int24 currentTick) internal {
|
||||
int24 center = (currentTick / TICK_SPACING) * TICK_SPACING;
|
||||
tickLower = center - HALF_WIDTH;
|
||||
tickUpper = center + HALF_WIDTH;
|
||||
|
||||
// Clamp to pool boundaries
|
||||
if (tickLower < MIN_TICK) tickLower = ((MIN_TICK / TICK_SPACING) + 1) * TICK_SPACING;
|
||||
if (tickUpper > MAX_TICK) tickUpper = (MAX_TICK / TICK_SPACING) * TICK_SPACING;
|
||||
|
||||
uint256 bal0 = token0.balanceOf(address(this));
|
||||
uint256 bal1 = token1.balanceOf(address(this));
|
||||
uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick);
|
||||
uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower);
|
||||
uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper);
|
||||
|
||||
liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLow, sqrtHigh, bal0, bal1);
|
||||
if (liquidity > 0) {
|
||||
pool.mint(address(this), tickLower, tickUpper, liquidity, "");
|
||||
}
|
||||
}
|
||||
|
||||
function _removePosition() internal {
|
||||
if (liquidity > 0) {
|
||||
pool.burn(tickLower, tickUpper, liquidity);
|
||||
(uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max);
|
||||
feesCollected0 += f0;
|
||||
feesCollected1 += f1;
|
||||
liquidity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// @inheritdoc IUniswapV3MintCallback
|
||||
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
|
||||
require(msg.sender == address(pool), "not pool");
|
||||
if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed);
|
||||
if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Script.sol";
|
||||
import "forge-std/console2.sol";
|
||||
|
||||
/// @notice Data for a single strategy's backtest result
|
||||
struct StrategyReport {
|
||||
string name;
|
||||
uint256 initialValueWeth; // wei
|
||||
uint256 finalValueWeth; // wei (position value + fees)
|
||||
uint256 feesEarnedWeth; // WETH-denominated fees (wei)
|
||||
uint256 feesEarnedToken; // Token-denominated fees (token base units)
|
||||
uint256 rebalanceCount;
|
||||
int256 ilBps; // Impermanent loss in basis points (0 or negative vs HODL)
|
||||
int256 netPnlBps; // Net P&L in basis points vs initial capital
|
||||
uint256 timeInRangeBps; // 0–10000 (10000 = 100%; ignored when hasTimeInRange=false)
|
||||
bool hasTimeInRange; // false for HODL
|
||||
}
|
||||
|
||||
/// @notice Backtest configuration metadata written to the report
|
||||
struct BacktestConfig {
|
||||
address pool;
|
||||
uint256 startBlock;
|
||||
uint256 endBlock;
|
||||
uint256 startTimestamp;
|
||||
uint256 endTimestamp;
|
||||
uint256 initialCapitalWei;
|
||||
uint256 recenterInterval;
|
||||
}
|
||||
|
||||
/// @title Reporter
|
||||
/// @notice Abstract mixin that generates Markdown and JSON backtest reports.
|
||||
/// Inherit alongside Script to get access to vm and file I/O.
|
||||
abstract contract Reporter is Script {
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
|
||||
/// @notice Write markdown + JSON reports to script/backtesting/reports/.
|
||||
function _writeReport(StrategyReport[] memory reports, BacktestConfig memory config) internal {
|
||||
string memory ts = vm.toString(block.timestamp);
|
||||
string memory mdPath = string(abi.encodePacked("script/backtesting/reports/report-", ts, ".md"));
|
||||
string memory jsonPath = string(abi.encodePacked("script/backtesting/reports/report-", ts, ".json"));
|
||||
|
||||
vm.writeFile(mdPath, _buildMarkdown(reports, config));
|
||||
vm.writeFile(jsonPath, _buildJson(reports, config));
|
||||
|
||||
console2.log("\n=== Backtest Report ===");
|
||||
console2.log("Markdown:", mdPath);
|
||||
console2.log(" JSON:", jsonPath);
|
||||
}
|
||||
|
||||
// ── Markdown builder ──────────────────────────────────────────────────────
|
||||
|
||||
function _buildMarkdown(StrategyReport[] memory reports, BacktestConfig memory config) internal view returns (string memory out) {
|
||||
out = "# Backtest Report: KRK/WETH 1%, 7 days\n\n";
|
||||
|
||||
// Table header
|
||||
out = string(
|
||||
abi.encodePacked(
|
||||
out,
|
||||
"| Strategy | Final Value | Fees Earned | Rebalances | IL (%) | Net P&L (%) | Time in Range |\n",
|
||||
"|---|---|---|---|---|---|---|\n"
|
||||
)
|
||||
);
|
||||
|
||||
// Table rows
|
||||
for (uint256 i = 0; i < reports.length; i++) {
|
||||
out = string(abi.encodePacked(out, _markdownRow(reports[i]), "\n"));
|
||||
}
|
||||
|
||||
// Configuration section
|
||||
out = string(
|
||||
abi.encodePacked(
|
||||
out,
|
||||
"\n## Configuration\n",
|
||||
"- Pool: `",
|
||||
vm.toString(config.pool),
|
||||
"`\n",
|
||||
"- Period: block ",
|
||||
vm.toString(config.startBlock),
|
||||
" to block ",
|
||||
vm.toString(config.endBlock),
|
||||
" (7 days simulated)\n",
|
||||
"- Initial capital: 10 ETH equivalent\n",
|
||||
"- Recenter interval: ",
|
||||
vm.toString(config.recenterInterval),
|
||||
" blocks\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function _markdownRow(StrategyReport memory r) internal view returns (string memory) {
|
||||
string memory tir = r.hasTimeInRange ? _fmtPct(r.timeInRangeBps) : "N/A";
|
||||
return string(
|
||||
abi.encodePacked(
|
||||
"| ",
|
||||
r.name,
|
||||
" | ",
|
||||
_fmtEth(r.finalValueWeth),
|
||||
" | ",
|
||||
_fmtEth(r.feesEarnedWeth),
|
||||
" | ",
|
||||
vm.toString(r.rebalanceCount),
|
||||
" | ",
|
||||
_fmtSignedPct(r.ilBps),
|
||||
" | ",
|
||||
_fmtSignedPct(r.netPnlBps),
|
||||
" | ",
|
||||
tir,
|
||||
" |"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── JSON builder ──────────────────────────────────────────────────────────
|
||||
|
||||
function _buildJson(StrategyReport[] memory reports, BacktestConfig memory config) internal view returns (string memory) {
|
||||
string memory strategiesArr = "[";
|
||||
for (uint256 i = 0; i < reports.length; i++) {
|
||||
if (i > 0) strategiesArr = string(abi.encodePacked(strategiesArr, ","));
|
||||
strategiesArr = string(abi.encodePacked(strategiesArr, _strategyJson(reports[i])));
|
||||
}
|
||||
strategiesArr = string(abi.encodePacked(strategiesArr, "]"));
|
||||
|
||||
return string(
|
||||
abi.encodePacked(
|
||||
'{"report":{"timestamp":',
|
||||
vm.toString(block.timestamp),
|
||||
',"strategies":',
|
||||
strategiesArr,
|
||||
'},"config":{"pool":"',
|
||||
vm.toString(config.pool),
|
||||
'","startBlock":',
|
||||
vm.toString(config.startBlock),
|
||||
',"endBlock":',
|
||||
vm.toString(config.endBlock),
|
||||
',"durationDays":7,"initialCapitalWei":"',
|
||||
vm.toString(config.initialCapitalWei),
|
||||
'","recenterInterval":',
|
||||
vm.toString(config.recenterInterval),
|
||||
"}}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function _strategyJson(StrategyReport memory r) internal view returns (string memory) {
|
||||
string memory tir = r.hasTimeInRange ? vm.toString(r.timeInRangeBps) : '"N/A"';
|
||||
return string(
|
||||
abi.encodePacked(
|
||||
'{"name":"',
|
||||
r.name,
|
||||
'","initialValueWei":"',
|
||||
vm.toString(r.initialValueWeth),
|
||||
'","finalValueWei":"',
|
||||
vm.toString(r.finalValueWeth),
|
||||
'","feesEarnedWei":"',
|
||||
vm.toString(r.feesEarnedWeth),
|
||||
'","rebalances":',
|
||||
vm.toString(r.rebalanceCount),
|
||||
',"ilBps":',
|
||||
_intStr(r.ilBps),
|
||||
',"netPnlBps":',
|
||||
_intStr(r.netPnlBps),
|
||||
',"timeInRangeBps":',
|
||||
tir,
|
||||
"}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formatting helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// @dev Format wei amount as "X.XXXX ETH"
|
||||
function _fmtEth(uint256 weiAmt) internal view returns (string memory) {
|
||||
uint256 whole = weiAmt / 1e18;
|
||||
uint256 frac = (weiAmt % 1e18) * 10_000 / 1e18;
|
||||
return string(abi.encodePacked(vm.toString(whole), ".", _pad4(frac), " ETH"));
|
||||
}
|
||||
|
||||
/// @dev Format basis points as signed percentage "+X.XX%" or "-X.XX%"
|
||||
function _fmtSignedPct(int256 bps) internal view returns (string memory) {
|
||||
if (bps < 0) {
|
||||
uint256 abs = uint256(-bps);
|
||||
return string(abi.encodePacked("-", vm.toString(abs / 100), ".", _pad2(abs % 100), "%"));
|
||||
}
|
||||
uint256 u = uint256(bps);
|
||||
return string(abi.encodePacked("+", vm.toString(u / 100), ".", _pad2(u % 100), "%"));
|
||||
}
|
||||
|
||||
/// @dev Format basis points as percentage "X.XX%"
|
||||
function _fmtPct(uint256 bps) internal view returns (string memory) {
|
||||
return string(abi.encodePacked(vm.toString(bps / 100), ".", _pad2(bps % 100), "%"));
|
||||
}
|
||||
|
||||
/// @dev Convert int256 to decimal string (no leading sign for positive)
|
||||
function _intStr(int256 n) internal view returns (string memory) {
|
||||
if (n < 0) {
|
||||
return string(abi.encodePacked("-", vm.toString(uint256(-n))));
|
||||
}
|
||||
return vm.toString(uint256(n));
|
||||
}
|
||||
|
||||
/// @dev Pad to 4 decimal digits with leading zeros
|
||||
function _pad4(uint256 n) internal pure returns (string memory) {
|
||||
if (n >= 1000) return _numStr(n);
|
||||
if (n >= 100) return string(abi.encodePacked("0", _numStr(n)));
|
||||
if (n >= 10) return string(abi.encodePacked("00", _numStr(n)));
|
||||
return string(abi.encodePacked("000", _numStr(n)));
|
||||
}
|
||||
|
||||
/// @dev Pad to 2 decimal digits with leading zeros
|
||||
function _pad2(uint256 n) internal pure returns (string memory) {
|
||||
if (n >= 10) return _numStr(n);
|
||||
return string(abi.encodePacked("0", _numStr(n)));
|
||||
}
|
||||
|
||||
/// @dev Pure uint-to-decimal-string (avoids vm dependency for small helpers)
|
||||
function _numStr(uint256 n) internal pure returns (string memory) {
|
||||
if (n == 0) return "0";
|
||||
uint256 len;
|
||||
uint256 tmp = n;
|
||||
while (tmp > 0) {
|
||||
len++;
|
||||
tmp /= 10;
|
||||
}
|
||||
bytes memory buf = new bytes(len);
|
||||
for (uint256 i = len; i > 0; i--) {
|
||||
buf[i - 1] = bytes1(uint8(48 + (n % 10)));
|
||||
n /= 10;
|
||||
}
|
||||
return string(buf);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue