harb/onchain/test/Optimizer.t.sol
openhands cfcf750084 fix: Backtesting #5: Position tracking + P&L metrics (#319)
- Add PositionTracker.sol: tracks position lifecycle (open/close per
  recenter), records tick ranges, liquidity, entry/exit blocks/timestamps,
  token amounts (via LiquidityAmounts math), fees (proportional to
  liquidity share), IL (LP exit value − HODL value at exit price), and
  net P&L per position. Aggregates total fees, cumulative IL, net P&L,
  rebalance count, Anchor time-in-range, and capital efficiency accumulators.
  Logs with [TRACKER][TYPE] prefix; emits cumulative P&L every 500 blocks.

- Modify StrategyExecutor.sol: add IUniswapV3Pool + token0isWeth to
  constructor (creates PositionTracker internally), call
  tracker.notifyBlock() on every block for time-in-range, and call
  tracker.recordRecenter() on each successful recenter. logSummary()
  now delegates to tracker.logFinalSummary().

- Modify BacktestRunner.s.sol: pass sp.pool and token0isWeth to
  StrategyExecutor constructor; log tracker address.

- forge fmt: reformat all backtesting scripts and affected src/test files
  to project style (number_underscore=thousands, multiline_func_header=all).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 11:23:18 +00:00

342 lines
14 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import "../src/Optimizer.sol";
import "./mocks/MockKraiken.sol";
import "./mocks/MockStake.sol";
import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
import "forge-std/Test.sol";
import "forge-std/console.sol";
/// @dev Harness to expose internal _calculateAnchorWidth for direct coverage of the totalWidth < 10 path
contract OptimizerHarness is Optimizer {
function exposed_calculateAnchorWidth(uint256 percentageStaked, uint256 averageTaxRate) external pure returns (uint24) {
return _calculateAnchorWidth(percentageStaked, averageTaxRate);
}
}
contract OptimizerTest is Test {
Optimizer optimizer;
MockStake mockStake;
MockKraiken mockKraiken;
function setUp() public {
// Deploy mocks
mockKraiken = new MockKraiken();
mockStake = new MockStake();
// Deploy Optimizer implementation
Optimizer implementation = new Optimizer();
// Deploy proxy and initialize
bytes memory initData = abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake));
// For simplicity, we'll test the implementation directly
// In production, you'd use a proper proxy setup
optimizer = implementation;
optimizer.initialize(address(mockKraiken), address(mockStake));
}
/**
* @notice Test that anchorWidth adjusts correctly for bull market conditions
* @dev High staking, low tax → narrow anchor (30-35%)
*/
function testBullMarketAnchorWidth() public {
// Set bull market conditions: high staking (80%), low tax (10%)
mockStake.setPercentageStaked(0.8e18);
mockStake.setAverageTaxRate(0.1e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 32 = -12) + tax_adj(4 - 10 = -6) = 22
assertEq(anchorWidth, 22, "Bull market should have narrow anchor width");
assertTrue(anchorWidth >= 20 && anchorWidth <= 35, "Bull market width should be 20-35%");
}
/**
* @notice Test that anchorWidth adjusts correctly for bear market conditions
* @dev Low staking, high tax → wide anchor (60-80%)
*/
function testBearMarketAnchorWidth() public {
// Set bear market conditions: low staking (20%), high tax (70%)
mockStake.setPercentageStaked(0.2e18);
mockStake.setAverageTaxRate(0.7e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 8 = 12) + tax_adj(28 - 10 = 18) = 70
assertEq(anchorWidth, 70, "Bear market should have wide anchor width");
assertTrue(anchorWidth >= 60 && anchorWidth <= 80, "Bear market width should be 60-80%");
}
/**
* @notice Test neutral market conditions
* @dev Medium staking, medium tax → balanced anchor (35-50%)
*/
function testNeutralMarketAnchorWidth() public {
// Set neutral conditions: medium staking (50%), medium tax (30%)
mockStake.setPercentageStaked(0.5e18);
mockStake.setAverageTaxRate(0.3e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 20 = 0) + tax_adj(12 - 10 = 2) = 42
assertEq(anchorWidth, 42, "Neutral market should have balanced anchor width");
assertTrue(anchorWidth >= 35 && anchorWidth <= 50, "Neutral width should be 35-50%");
}
/**
* @notice Test high volatility scenario
* @dev High staking with high tax (speculative frenzy) → moderate-wide anchor
*/
function testHighVolatilityAnchorWidth() public {
// High staking (70%) but also high tax (80%) - speculative market
mockStake.setPercentageStaked(0.7e18);
mockStake.setAverageTaxRate(0.8e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 28 = -8) + tax_adj(32 - 10 = 22) = 54
assertEq(anchorWidth, 54, "High volatility should have moderate-wide anchor");
assertTrue(anchorWidth >= 50 && anchorWidth <= 60, "Volatile width should be 50-60%");
}
/**
* @notice Test stable market conditions
* @dev Medium staking with very low tax → narrow anchor for fee optimization
*/
function testStableMarketAnchorWidth() public {
// Medium staking (50%), very low tax (5%) - stable conditions
mockStake.setPercentageStaked(0.5e18);
mockStake.setAverageTaxRate(0.05e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 20 = 0) + tax_adj(2 - 10 = -8) = 32
assertEq(anchorWidth, 32, "Stable market should have narrower anchor");
assertTrue(anchorWidth >= 30 && anchorWidth <= 40, "Stable width should be 30-40%");
}
/**
* @notice Test minimum bound enforcement
* @dev Extreme conditions that would result in width < 10 should clamp to 10
*/
function testMinimumWidthBound() public {
// Extreme bull: very high staking (95%), zero tax
mockStake.setPercentageStaked(0.95e18);
mockStake.setAverageTaxRate(0);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 38 = -18) + tax_adj(0 - 10 = -10) = 12
// But should be at least 10
assertEq(anchorWidth, 12, "Should not go below calculated value if above 10");
assertTrue(anchorWidth >= 10, "Width should never be less than 10");
}
/**
* @notice Test maximum bound enforcement
* @dev Extreme conditions that would result in width > 80 should clamp to 80
*/
function testMaximumWidthBound() public {
// Extreme bear: zero staking, maximum tax
mockStake.setPercentageStaked(0);
mockStake.setAverageTaxRate(1e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 0 = 20) + tax_adj(40 - 10 = 30) = 90
// But should be clamped to 80
assertEq(anchorWidth, 80, "Should clamp to maximum of 80");
assertTrue(anchorWidth <= 80, "Width should never exceed 80");
}
/**
* @notice Test edge case with exactly minimum staking and tax
*/
function testEdgeCaseMinimumInputs() public {
mockStake.setPercentageStaked(0);
mockStake.setAverageTaxRate(0);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 0 = 20) + tax_adj(0 - 10 = -10) = 50
assertEq(anchorWidth, 50, "Zero inputs should give moderate width");
}
/**
* @notice Test edge case with exactly maximum staking and tax
*/
function testEdgeCaseMaximumInputs() public {
mockStake.setPercentageStaked(1e18);
mockStake.setAverageTaxRate(1e18);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Expected: base(40) + staking_adj(20 - 40 = -20) + tax_adj(40 - 10 = 30) = 50
assertEq(anchorWidth, 50, "Maximum inputs should balance out to moderate width");
}
/**
* @notice Test edge case with high staking and high tax rate
* @dev This specific case previously caused an overflow
*/
function testHighStakingHighTaxEdgeCase() public {
// Set conditions that previously caused overflow
// ~94.6% staked, ~96.7% tax rate
mockStake.setPercentageStaked(946_350_908_835_331_692);
mockStake.setAverageTaxRate(966_925_542_613_630_263);
(uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) = optimizer.getLiquidityParams();
// With very high staking (>92%) and high tax, sentiment reaches maximum (1e18)
// This results in zero capital inefficiency
assertEq(capitalInefficiency, 0, "Max sentiment should result in zero capital inefficiency");
// Anchor share should be at maximum
assertEq(anchorShare, 1e18, "Max sentiment should result in maximum anchor share");
// Anchor width should still be within bounds
assertTrue(anchorWidth >= 10 && anchorWidth <= 80, "Anchor width should be within bounds");
// Expected: base(40) + staking_adj(20 - 37 = -17) + tax_adj(38 - 10 = 28) = 51
assertEq(anchorWidth, 51, "Should calculate correct width for edge case");
}
/**
* @notice Fuzz test to ensure anchorWidth always stays within bounds
*/
function testFuzzAnchorWidthBounds(uint256 percentageStaked, uint256 averageTaxRate) public {
// Bound inputs to valid ranges
percentageStaked = bound(percentageStaked, 0, 1e18);
averageTaxRate = bound(averageTaxRate, 0, 1e18);
mockStake.setPercentageStaked(percentageStaked);
mockStake.setAverageTaxRate(averageTaxRate);
(,, uint24 anchorWidth,) = optimizer.getLiquidityParams();
// Assert bounds are always respected
assertTrue(anchorWidth >= 10, "Width should never be less than 10");
assertTrue(anchorWidth <= 80, "Width should never exceed 80");
// Edge cases (10 or 80) are valid and tested by assertions
}
/**
* @notice Test that other liquidity params are still calculated correctly
*/
function testOtherLiquidityParams() public {
mockStake.setPercentageStaked(0.6e18);
mockStake.setAverageTaxRate(0.4e18);
(uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) = optimizer.getLiquidityParams();
uint256 sentiment = optimizer.getSentiment();
// Verify relationships
assertEq(capitalInefficiency, 1e18 - sentiment, "Capital inefficiency should be 1 - sentiment");
assertEq(anchorShare, sentiment, "Anchor share should equal sentiment");
assertEq(discoveryDepth, sentiment, "Discovery depth should equal sentiment");
// Verify anchor width is calculated independently
// Expected: base(40) + staking_adj(20 - 24 = -4) + tax_adj(16 - 10 = 6) = 42
assertEq(anchorWidth, 42, "Anchor width should be independently calculated");
}
// =========================================================
// COVERAGE TESTS: calculateSentiment direct call + mid-range tax + zero path
// =========================================================
/**
* @notice Direct external call to calculateSentiment covers the function in coverage metrics
*/
function testCalculateSentimentDirect() public view {
// 100% staked, any tax → high staking path → very low penalty
uint256 sentiment = optimizer.calculateSentiment(0, 1e18);
// deltaS = 0, penalty = 0, sentimentValue = 0
assertEq(sentiment, 0, "100% staked, 0 tax: penalty=0 so sentiment=0");
}
/**
* @notice Cover the else-if (averageTaxRate <= 5e16) branch with a result > 0
* @dev averageTaxRate = 3e16 (in range (1e16, 5e16]), percentageStaked = 0
* baseSentiment = 1e18, ratePenalty = (2e16 * 1e18) / 4e16 = 5e17
* result = 1e18 - 5e17 = 5e17
*/
function testCalculateSentimentMidRangeTax() public view {
uint256 sentiment = optimizer.calculateSentiment(3e16, 0);
assertEq(sentiment, 5e17, "Mid-range tax should apply partial penalty");
}
/**
* @notice Cover the ternary zero path: baseSentiment > ratePenalty ? ... : 0
* @dev averageTaxRate = 5e16 (boundary), percentageStaked = 0
* baseSentiment = 1e18, ratePenalty = (4e16 * 1e18) / 4e16 = 1e18
* 1e18 > 1e18 is false → sentimentValue = 0
*/
function testCalculateSentimentZeroPath() public view {
uint256 sentiment = optimizer.calculateSentiment(5e16, 0);
assertEq(sentiment, 0, "At boundary 5e16 ratePenalty equals baseSentiment so result is zero");
}
// =========================================================
// COVERAGE TESTS: UUPS upgrade flow (_checkAdmin, _authorizeUpgrade, onlyAdmin)
// =========================================================
/**
* @notice Deploy via ERC1967Proxy and call upgradeTo to cover _authorizeUpgrade + _checkAdmin
*/
function testUUPSUpgrade() public {
Optimizer impl1 = new Optimizer();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl1), abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake)));
Optimizer proxyOptimizer = Optimizer(address(proxy));
// Deployer (this contract) is admin — upgrade should succeed
Optimizer impl2 = new Optimizer();
proxyOptimizer.upgradeTo(address(impl2));
// Verify proxy still works after upgrade
(,, uint24 w,) = proxyOptimizer.getLiquidityParams();
assertTrue(w >= 10 && w <= 80, "Params should still work after upgrade");
}
/**
* @notice Cover the require revert branch in calculateSentiment (percentageStaked > 1e18)
*/
function testCalculateSentimentRevertsAbove100Percent() public {
vm.expectRevert("Invalid percentage staked");
optimizer.calculateSentiment(0, 1e18 + 1);
}
/**
* @notice Cover the totalWidth < 10 clamp via OptimizerHarness.
* @dev With percentageStaked = 1.5e18 and averageTaxRate = 0:
* stakingAdjustment = 20 - 60 = -40
* taxAdjustment = 0 - 10 = -10
* totalWidth = 40 - 40 - 10 = -10 → clamped to 10
*/
function testAnchorWidthBelowTenClamp() public {
OptimizerHarness harness = new OptimizerHarness();
uint24 w = harness.exposed_calculateAnchorWidth(15e17, 0);
assertEq(w, 10, "totalWidth < 10 should be clamped to minimum of 10");
}
/**
* @notice Non-admin calling upgradeTo should revert with UnauthorizedAccount
*/
function testUnauthorizedUpgradeReverts() public {
Optimizer impl1 = new Optimizer();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl1), abi.encodeWithSelector(Optimizer.initialize.selector, address(mockKraiken), address(mockStake)));
Optimizer proxyOptimizer = Optimizer(address(proxy));
// Deploy impl2 BEFORE the prank so the prank applies only to upgradeTo
Optimizer impl2 = new Optimizer();
address nonAdmin = makeAddr("nonAdmin");
vm.expectRevert(abi.encodeWithSelector(Optimizer.UnauthorizedAccount.selector, nonAdmin));
vm.prank(nonAdmin);
proxyOptimizer.upgradeTo(address(impl2));
}
}