harb/onchain/script/backtesting/Reporter.sol

379 lines
13 KiB
Solidity
Raw Permalink Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { BaselineStrategies } from "./BaselineStrategies.sol";
import { FormatLib } from "./FormatLib.sol";
import { PositionTracker } from "./PositionTracker.sol";
import { StrategyExecutor } from "./StrategyExecutor.sol";
import { Vm } from "forge-std/Vm.sol";
import { console2 } from "forge-std/console2.sol";
/**
* @title Reporter
* @notice Generates a Markdown + JSON backtest report comparing KrAIken's 3-position strategy
* against three baseline strategies (HODL, Full-Range LP, Fixed-Width LP).
*
* Output files (relative to the forge project root):
* script/backtesting/reports/report-{endBlock}.md
* script/backtesting/reports/report-{endBlock}.json
*
* Requires Foundry cheatcodes (vm.writeFile) and read-write fs_permissions.
*
* WARNING backtesting use only. Not a deployable production contract.
*/
contract Reporter {
using FormatLib for uint256;
using FormatLib for int256;
// -------------------------------------------------------------------------
// Foundry cheatcode handle (same pattern as EventReplayer)
// -------------------------------------------------------------------------
Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
// -------------------------------------------------------------------------
// Config struct (passed to generate())
// -------------------------------------------------------------------------
struct Config {
address poolAddress;
uint256 startBlock;
uint256 endBlock;
uint256 initialCapital; // token0 units (wei)
uint256 recenterInterval;
string poolLabel; // e.g. "AERO/WETH 1%"
uint256 periodDays;
}
// -------------------------------------------------------------------------
// Row data (internal working struct)
// -------------------------------------------------------------------------
struct Row {
string name;
uint256 initialCapital;
uint256 finalValue;
uint256 feesToken0;
uint256 rebalances;
int256 ilToken0;
int256 netPnLToken0;
uint256 blocksInRange;
uint256 totalBlocks;
bool isHodl; // suppresses time-in-range column, shows $0 fees
}
// -------------------------------------------------------------------------
// Main entry point
// -------------------------------------------------------------------------
/**
* @notice Collect metrics from all strategies, write Markdown and JSON reports.
* @param kraiken StrategyExecutor for the KrAIken 3-position strategy.
* @param baselines Baseline strategies contract.
* @param cfg Report configuration.
*/
function generate(StrategyExecutor kraiken, BaselineStrategies baselines, Config calldata cfg) external {
PositionTracker tracker = kraiken.tracker();
PositionTracker.FinalData memory fd = tracker.getFinalData();
// Build KrAIken row from PositionTracker data.
Row memory kRow = Row({
name: "KrAIken 3-Pos",
initialCapital: cfg.initialCapital,
finalValue: uint256(int256(cfg.initialCapital) + fd.finalNetPnL),
feesToken0: fd.finalFeesToken0,
rebalances: fd.rebalances,
ilToken0: fd.finalIL,
netPnLToken0: fd.finalNetPnL,
blocksInRange: fd.blocksInRange,
totalBlocks: fd.totalBlocks,
isHodl: false
});
// Collect baseline results.
(BaselineStrategies.StrategyResult memory hodlR, BaselineStrategies.StrategyResult memory frR, BaselineStrategies.StrategyResult memory fwR) =
baselines.getResults();
Row memory frRow = Row({
name: "Full-Range LP",
initialCapital: frR.initialCapitalToken0,
finalValue: frR.finalValueToken0,
feesToken0: frR.feesToken0,
rebalances: frR.rebalances,
ilToken0: frR.ilToken0,
netPnLToken0: frR.netPnLToken0,
blocksInRange: frR.blocksInRange,
totalBlocks: frR.totalBlocks,
isHodl: false
});
Row memory fwRow = Row({
name: "Fixed-Width LP",
initialCapital: fwR.initialCapitalToken0,
finalValue: fwR.finalValueToken0,
feesToken0: fwR.feesToken0,
rebalances: fwR.rebalances,
ilToken0: fwR.ilToken0,
netPnLToken0: fwR.netPnLToken0,
blocksInRange: fwR.blocksInRange,
totalBlocks: fwR.totalBlocks,
isHodl: false
});
Row memory hodlRow = Row({
name: "HODL",
initialCapital: hodlR.initialCapitalToken0,
finalValue: hodlR.finalValueToken0,
feesToken0: 0,
rebalances: 0,
ilToken0: 0,
netPnLToken0: hodlR.netPnLToken0,
blocksInRange: 0,
totalBlocks: 0,
isHodl: true
});
// Write files.
string memory suffix = cfg.endBlock.str();
string memory mdPath = string.concat("script/backtesting/reports/report-", suffix, ".md");
string memory jsonPath = string.concat("script/backtesting/reports/report-", suffix, ".json");
vm.writeFile(mdPath, _buildMarkdown(kRow, frRow, fwRow, hodlRow, cfg));
console2.log(string.concat("[REPORTER] Markdown: ", mdPath));
vm.writeFile(jsonPath, _buildJson(kRow, frRow, fwRow, hodlRow, cfg));
console2.log(string.concat("[REPORTER] JSON: ", jsonPath));
}
// -------------------------------------------------------------------------
// Markdown generation
// -------------------------------------------------------------------------
function _buildMarkdown(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
internal
pure
returns (string memory)
{
return string.concat(
"# Backtest Report: ",
cfg.poolLabel,
", ",
cfg.periodDays.str(),
" days\n\n",
"| Strategy | Final Value (token0) | Fees Earned (token0) | Rebalances |"
" IL (%) | Net P&L (%) | Time in Range |\n",
"|----------------|----------------------|----------------------|------------|"
"---------|-------------|---------------|\n",
_mdRow(k),
_mdRow(fr),
_mdRow(fw),
_mdRow(hodl),
"\n## Configuration\n\n",
"- Pool: `",
_addrStr(cfg.poolAddress),
"`\n",
"- Period: block ",
cfg.startBlock.str(),
" to block ",
cfg.endBlock.str(),
" (",
cfg.periodDays.str(),
" days)\n",
"- Initial capital: ",
_etherStr(cfg.initialCapital),
" ETH equivalent\n",
"- Recenter interval: ",
cfg.recenterInterval.str(),
" blocks\n"
);
}
function _mdRow(Row memory r) internal pure returns (string memory) {
string memory tir;
if (r.isHodl) {
tir = "N/A";
} else if (r.totalBlocks == 0) {
tir = "0.00%";
} else {
tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks);
}
string memory fees = r.isHodl ? "$0" : _etherStr(r.feesToken0);
string memory il = r.isHodl ? "0" : _signedPctStr(r.ilToken0, r.initialCapital);
return string.concat(
"| ",
_pad(r.name, 14),
" | ",
_etherStr(r.finalValue),
" | ",
fees,
" | ",
r.rebalances.str(),
" | ",
il,
" | ",
_signedPctStr(r.netPnLToken0, r.initialCapital),
" | ",
tir,
" |\n"
);
}
// -------------------------------------------------------------------------
// JSON generation
// -------------------------------------------------------------------------
function _buildJson(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg)
internal
pure
returns (string memory)
{
return string.concat(
"{\n",
' "strategies": {\n',
' "kraiken": ',
_jsonRow(k),
",\n",
' "fullRange": ',
_jsonRow(fr),
",\n",
' "fixedWidth": ',
_jsonRow(fw),
",\n",
' "hodl": ',
_jsonRow(hodl),
"\n",
" },\n",
' "config": {\n',
' "pool": "',
_addrStr(cfg.poolAddress),
'",\n',
' "poolLabel": "',
cfg.poolLabel,
'",\n',
' "startBlock": ',
cfg.startBlock.str(),
",\n",
' "endBlock": ',
cfg.endBlock.str(),
",\n",
' "periodDays": ',
cfg.periodDays.str(),
",\n",
' "initialCapital": "',
cfg.initialCapital.str(),
'",\n',
' "recenterInterval": ',
cfg.recenterInterval.str(),
"\n",
" }\n",
"}\n"
);
}
function _jsonRow(Row memory r) internal pure returns (string memory) {
string memory tir = r.isHodl ? "null" : r.totalBlocks.str();
string memory inRange = r.isHodl ? "null" : r.blocksInRange.str();
return string.concat(
"{\n",
' "initialCapitalToken0": "',
r.initialCapital.str(),
'",\n',
' "finalValueToken0": "',
r.finalValue.str(),
'",\n',
' "feesToken0": "',
r.feesToken0.str(),
'",\n',
' "rebalances": ',
r.rebalances.str(),
",\n",
' "ilToken0": "',
r.ilToken0.istr(),
'",\n',
' "netPnLToken0": "',
r.netPnLToken0.istr(),
'",\n',
' "blocksInRange": ',
inRange,
",\n",
' "totalBlocks": ',
tir,
"\n",
" }"
);
}
// -------------------------------------------------------------------------
// Formatting helpers
// -------------------------------------------------------------------------
/**
* @notice Format wei as "<integer>.<6 decimals>" (without "ETH" suffix unit depends on context).
*/
function _etherStr(uint256 wei_) internal pure returns (string memory) {
uint256 whole = wei_ / 1e18;
uint256 frac = (wei_ % 1e18) / 1e12; // 6 decimal places
string memory fracStr = frac.str();
uint256 len = bytes(fracStr).length;
string memory pad = "";
for (uint256 i = len; i < 6; i++) {
pad = string.concat("0", pad);
}
return string.concat(whole.str(), ".", pad, fracStr);
}
/**
* @notice Format a signed ratio (value/base) as a percentage with 2 decimals: "±X.XX%".
*/
function _signedPctStr(int256 value, uint256 base) internal pure returns (string memory) {
if (base == 0) return "0.00%";
bool neg = value < 0;
uint256 abs = neg ? uint256(-value) : uint256(value);
uint256 bps = (abs * 10_000) / base;
return string.concat(neg ? "-" : "", _bpsStr(bps));
}
/**
* @notice Format bps (0..10000+) as "X.XX%".
*/
function _bpsStr(uint256 bps) internal pure returns (string memory) {
uint256 whole = bps / 100;
uint256 dec = bps % 100;
return string.concat(whole.str(), ".", dec < 10 ? string.concat("0", dec.str()) : dec.str(), "%");
}
/**
* @notice Right-pad string to `width` with spaces.
*/
function _pad(string memory s, uint256 width) internal pure returns (string memory) {
uint256 len = bytes(s).length;
if (len >= width) return s;
string memory out = s;
for (uint256 i = len; i < width; i++) {
out = string.concat(out, " ");
}
return out;
}
/**
* @notice Encode an address as a lowercase hex string "0x...".
*/
function _addrStr(address addr) internal pure returns (string memory) {
bytes memory b = abi.encodePacked(addr);
bytes memory h = new bytes(42);
h[0] = "0";
h[1] = "x";
for (uint256 i = 0; i < 20; i++) {
h[2 + i * 2] = _hexChar(uint8(b[i]) >> 4);
h[3 + i * 2] = _hexChar(uint8(b[i]) & 0x0f);
}
return string(h);
}
function _hexChar(uint8 v) internal pure returns (bytes1) {
return v < 10 ? bytes1(48 + v) : bytes1(87 + v);
}
}