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