233 lines
9 KiB
Solidity
233 lines
9 KiB
Solidity
// 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: 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);
|
||
}
|
||
}
|