harb/onchain/script/backtesting/Reporter.sol

234 lines
9 KiB
Solidity
Raw Normal View History

// 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; // 010000 (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: AERO/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);
}
}