2026-02-27 13:08:53 +00:00
|
|
|
// 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) {
|
2026-02-27 13:43:49 +00:00
|
|
|
tir = "0.00%";
|
2026-02-27 13:08:53 +00: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);
|
|
|
|
|
}
|
|
|
|
|
}
|