harb/onchain/script/backtesting/Reporter.sol
openhands 5205ea6f4a fix: Backtesting #6: Baseline strategies (HODL, full-range, fixed-width) + reporting (#320)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:11:15 +00:00

233 lines
9 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}