diff --git a/src/methodology/MarketCapMethodology.sol b/src/methodology/MarketCapMethodology.sol index d26a15d..2bf0375 100644 --- a/src/methodology/MarketCapMethodology.sol +++ b/src/methodology/MarketCapMethodology.sol @@ -55,15 +55,25 @@ contract MarketCapMethodology is IMethodology, Ownable2Step { /// supply arrived in native token decimals instead of whole tokens. uint256 public constant MARKET_CAP_SANITY_BOUND = 1e30; - /// @notice Hard per-asset weight cap in WAD. Default 25%. - uint256 public capWad = 0.25e18; + /// @notice Per-asset weight cap in WAD, the level a constituent is brought + /// down to when capped. The target computation always caps to this. Default 25%. + uint256 public capTargetWad = 0.25e18; + + /// @notice Actual-weight threshold (at or above capTargetWad) past which a + /// capped constituent is rebalanced back to capTargetWad. The gap is + /// hysteresis: a held weight may drift between target and trigger without + /// forcing a trade, mirroring the Nasdaq-100 special rebalance (cap to 20%, + /// trigger at 24%). The weight computation here always caps to capTargetWad; + /// the rebalancer consumes capTriggerWad as the off-cycle rebalance condition. + /// Default 30% (a 1.2x gap over the 25% target). + uint256 public capTriggerWad = 0.3e18; /// @notice Minimum weight in WAD below which a constituent is pruned to /// zero. Default 0.01%: positions smaller than this cost more in gas and /// slippage at rebalance than they contribute to tracking. uint256 public floorWad = 1e14; - event WeightParamsSet(uint256 capWad, uint256 floorWad); + event WeightParamsSet(uint256 capTargetWad, uint256 capTriggerWad, uint256 floorWad); constructor(AssetRegistry registry, ISupplyOracle supplyOracle, address initialOwner) Ownable(initialOwner) { if (address(registry) == address(0) || address(supplyOracle) == address(0)) { @@ -97,22 +107,28 @@ contract MarketCapMethodology is IMethodology, Ownable2Step { if (total == 0) revert MarketCapMethodology_InvalidTotalMarketCap(); uint256[] memory weights = WeightMath.normalize(marketCaps); - weights = WeightMath.applyCap(weights, capWad); - return WeightMath.applyFloor(weights, floorWad, capWad); + weights = WeightMath.applyCap(weights, capTargetWad); + return WeightMath.applyFloor(weights, floorWad, capTargetWad); } - /// @notice Sets the per-asset cap and the minimum-weight floor. + /// @notice Sets the cap target, the cap trigger, and the minimum-weight floor. /// @dev Methodology-admin lever; sits behind the methodology-admin timelock. - /// The cap must be feasible for the constituent set: getWeights reverts + /// The cap target must be feasible for the constituent set: getWeights reverts /// with WeightMath_CapInfeasible when the number of nonzero-market-cap - /// constituents k satisfies k * capWad < 1e18 (a 25% cap needs at least - /// four viable names). Size the cap to the index, not the other way round. - function setWeightParams(uint256 capWad_, uint256 floorWad_) external onlyOwner { - if (capWad_ == 0 || capWad_ > WeightMath.WAD || floorWad_ >= capWad_) { + /// constituents k satisfies k * capTargetWad < 1e18 (a 25% target needs at + /// least four viable names). The trigger must sit at or above the target; + /// equal disables hysteresis (rebalance on any breach of the cap), a wider + /// gap gives the rebalancer a larger dead-band. + function setWeightParams(uint256 capTargetWad_, uint256 capTriggerWad_, uint256 floorWad_) external onlyOwner { + if ( + capTargetWad_ == 0 || capTriggerWad_ > WeightMath.WAD || capTriggerWad_ < capTargetWad_ + || floorWad_ >= capTargetWad_ + ) { revert MarketCapMethodology_InvalidParams(); } - capWad = capWad_; + capTargetWad = capTargetWad_; + capTriggerWad = capTriggerWad_; floorWad = floorWad_; - emit WeightParamsSet(capWad_, floorWad_); + emit WeightParamsSet(capTargetWad_, capTriggerWad_, floorWad_); } } diff --git a/src/oracle/SupplyOracle.sol b/src/oracle/SupplyOracle.sol index 33b1918..135eadf 100644 --- a/src/oracle/SupplyOracle.sol +++ b/src/oracle/SupplyOracle.sol @@ -274,19 +274,26 @@ contract SupplyOracle is ISupplyOracle, Ownable2Step { revert SupplyOracle_SourcesDiverged(token, spreadBps, divergenceToleranceBps); } - // Layer 3 rate-limit: clamp the move toward the median so a large jump - // is approached over several commits, each at least minCommitInterval - // apart (enforced above), rather than landing at once. - uint256 next = median; + // Round the agreed median to a coarse float-factor tier (CRSP Effective + // Float Factor style) so small, noisy float changes do not move target + // weights: a median that wiggles within a tier produces the same target, + // and once the committed value has converged to it no further commit + // moves it. + uint256 target = _roundFactorWad(median); + + // Layer 3 rate-limit: clamp the move toward the rounded target so a + // large jump is approached over several commits, each at least + // minCommitInterval apart (enforced above), rather than landing at once. + uint256 next = target; if (prev.initialized) { uint256 maxStep = prev.factorWad.mulDiv(maxFactorDeltaBps, BPS, Math.Rounding.Floor); - if (median > prev.factorWad + maxStep) { + if (target > prev.factorWad + maxStep) { next = prev.factorWad + maxStep; - } else if (median + maxStep < prev.factorWad) { + } else if (target + maxStep < prev.factorWad) { next = prev.factorWad - maxStep; } } - if (next > WAD) next = WAD; // structural cap; median is already <= WAD + if (next > WAD) next = WAD; // structural cap; target is already <= WAD committed[token] = Committed({ factorWad: next, timestamp: uint64(block.timestamp), initialized: true }); emit Committed_(token, next, median, count); @@ -398,4 +405,18 @@ contract SupplyOracle is ISupplyOracle, Ownable2Step { if (len % 2 == 1) return sorted[mid]; return (sorted[mid - 1] + sorted[mid]) / 2; } + + /// @dev Rounds a free-float factor to a coarse tier, modelled on CRSP's + /// Effective Float Factor: nearest 5% at or above 10% float, nearest 1% + /// between 1% and 10%, nearest 0.1% below 1%. A nonzero factor never rounds + /// to zero. Coarse rounding is what suppresses needless re-weighting on + /// small, noisy float changes. + function _roundFactorWad(uint256 f) private pure returns (uint256) { + if (f == 0) return 0; + uint256 tier = f >= 1e17 ? 5e16 : (f >= 1e16 ? 1e16 : 1e15); + uint256 rounded = ((f + tier / 2) / tier) * tier; + if (rounded > WAD) rounded = WAD; + if (rounded == 0) rounded = tier; + return rounded; + } } diff --git a/test/MarketCapMethodology.t.sol b/test/MarketCapMethodology.t.sol index a1f33fc..05b487a 100644 --- a/test/MarketCapMethodology.t.sol +++ b/test/MarketCapMethodology.t.sol @@ -71,7 +71,7 @@ contract MarketCapMethodologyTest is Test { function test_GetWeights_UncappedMatchesRawMarketCapRatio() public { // Lift the cap out of the way to verify the raw weighting alone. - methodology.setWeightParams(WAD, 1); + methodology.setWeightParams(WAD, WAD, 1); uint256[] memory w = methodology.getWeights(tokens); // Total $2.701T: BTC 2000/2701, ETH 600/2701, SOL 100/2701, TAIL 1/2701. @@ -112,7 +112,7 @@ contract MarketCapMethodologyTest is Test { function test_GetWeights_FloorPrunesDustPosition() public { // 40% cap so the index is not fully degenerate, tail floor at 0.05%. - methodology.setWeightParams(0.4e18, 5e14); + methodology.setWeightParams(0.4e18, 0.4e18, 5e14); // Shrink TAIL to a dust market cap ($100M against $2.7T). supplyOracle.setSupply(address(tail), 100_000_000); @@ -156,16 +156,40 @@ contract MarketCapMethodologyTest is Test { methodology.getWeights(single); } + function test_CapParams_Defaults() public view { + // The target is what getWeights caps to; the trigger is the wider + // actual-weight threshold the rebalancer reads for hysteresis. + assertEq(methodology.capTargetWad(), 0.25e18); + assertEq(methodology.capTriggerWad(), 0.3e18); + assertEq(methodology.floorWad(), 1e14); + assertGt(methodology.capTriggerWad(), methodology.capTargetWad()); + } + function test_SetWeightParams_Validates() public { + // Zero cap target is invalid. + vm.expectRevert(MarketCapMethodology_InvalidParams.selector); + methodology.setWeightParams(0, 0.3e18, 0); + + // Floor at or above the cap target is invalid. vm.expectRevert(MarketCapMethodology_InvalidParams.selector); - methodology.setWeightParams(0, 0); + methodology.setWeightParams(0.2e18, 0.2e18, 0.2e18); + // Trigger below the cap target is invalid. vm.expectRevert(MarketCapMethodology_InvalidParams.selector); - methodology.setWeightParams(0.2e18, 0.2e18); + methodology.setWeightParams(0.3e18, 0.2e18, 1e14); + + // Trigger above 1e18 is invalid. + vm.expectRevert(MarketCapMethodology_InvalidParams.selector); + methodology.setWeightParams(0.3e18, WAD + 1, 1e14); + + // Trigger equal to target is allowed (hysteresis disabled). + methodology.setWeightParams(0.3e18, 0.3e18, 1e14); + assertEq(methodology.capTriggerWad(), 0.3e18); + // Non-owner cannot set params. vm.prank(makeAddr("rando")); vm.expectRevert(); - methodology.setWeightParams(0.3e18, 1e14); + methodology.setWeightParams(0.3e18, 0.35e18, 1e14); } /// @notice End-to-end weighting invariants under fuzzed prices and supplies. @@ -187,7 +211,7 @@ contract MarketCapMethodologyTest is Test { uint256 sum = 0; for (uint256 i = 0; i < 4; i++) { - assertLe(w[i], methodology.capWad()); + assertLe(w[i], methodology.capTargetWad()); assertTrue(w[i] == 0 || w[i] >= methodology.floorWad()); sum += w[i]; } diff --git a/test/SupplyOracle.t.sol b/test/SupplyOracle.t.sol index 53aec0f..595c88f 100644 --- a/test/SupplyOracle.t.sol +++ b/test/SupplyOracle.t.sol @@ -163,16 +163,17 @@ contract SupplyOracleTest is Test { function test_Commit_MedianOfFreshReports() public { _exclude(treasury); _exclude(vesting); // circulating = 600,000 - // Reports cluster within the 2% tolerance; median is the middle value. + // Reports cluster within the 2% tolerance; median is the middle value, + // 0.92, which the EFF rounding snaps to the nearest 5% tier, 0.90. _reportAll(0.91e18, 0.92e18, 0.93e18); oracle.commit(address(token)); (uint256 factor,, bool frozen) = oracle.freeFloatFactor(address(token)); - assertEq(factor, 0.92e18, "median not committed"); + assertEq(factor, 0.9e18, "median not committed at the rounded tier"); assertFalse(frozen); - // free-float = 600,000 * 0.92 = 552,000 - assertEq(oracle.getFreeFloatSupply(address(token)), 552_000); + // free-float = 600,000 * 0.90 = 540,000 + assertEq(oracle.getFreeFloatSupply(address(token)), 540_000); } function test_Commit_RevertsBelowQuorum() public { @@ -213,7 +214,8 @@ contract SupplyOracleTest is Test { _exclude(vesting); _reportAll(0.91e18, 0.92e18, 0.93e18); oracle.commit(address(token)); - assertEq(oracle.getFreeFloatSupply(address(token)), 600_000 * 92 / 100); + // Median 0.92 rounds to the 0.90 tier, so free-float = 600,000 * 0.90. + assertEq(oracle.getFreeFloatSupply(address(token)), 600_000 * 90 / 100); // All three reporters now disagree wildly: median 0.60, and neither // 0.30 nor 0.92 sits within the 2% band, so fewer than two agree. @@ -225,9 +227,9 @@ contract SupplyOracleTest is Test { ); oracle.commit(address(token)); - // Last-good factor untouched: the freeze held. + // Last-good factor untouched: the freeze held (at the rounded 0.90 tier). (uint256 factor,,) = oracle.freeFloatFactor(address(token)); - assertEq(factor, 0.92e18); + assertEq(factor, 0.9e18); } /// @notice The median is robust to a single captured reporter: a 2-of-3 @@ -382,4 +384,53 @@ contract SupplyOracleTest is Test { uint256 circulating = registry.onChainCirculating(address(token)); assertLe(oracle.getFreeFloatSupply(address(token)), circulating); } + + // ======================================================================== + // Float-factor rounding (CRSP EFF tiers) + // ======================================================================== + + /// @notice A float wiggle within the same tier does not move the committed + /// factor, while a change that crosses a tier does. This is what suppresses + /// needless re-weighting on small, noisy float changes. + function test_FloatRounding_WiggleWithinTierIsNoOp() public { + _exclude(treasury); + _exclude(vesting); + + // 0.90 is tier-aligned (nearest 5%). + _reportAll(0.9e18, 0.9e18, 0.9e18); + oracle.commit(address(token)); + (uint256 f0,,) = oracle.freeFloatFactor(address(token)); + assertEq(f0, 0.9e18); + + // 0.91 rounds back to the 0.90 tier, so the committed factor does not move. + vm.warp(block.timestamp + oracle.minCommitInterval()); + _reportAll(0.91e18, 0.91e18, 0.91e18); + oracle.commit(address(token)); + (uint256 f1,,) = oracle.freeFloatFactor(address(token)); + assertEq(f1, 0.9e18, "in-tier wiggle moved the factor"); + + // 0.93 crosses into the 0.95 tier, so the factor does move. + vm.warp(block.timestamp + oracle.minCommitInterval()); + _reportAll(0.93e18, 0.93e18, 0.93e18); + oracle.commit(address(token)); + (uint256 f2,,) = oracle.freeFloatFactor(address(token)); + assertEq(f2, 0.95e18, "tier-crossing change did not round to 0.95"); + } + + /// @notice The committed factor is always tier-aligned at steady state, and + /// a nonzero factor never rounds to zero. + function testFuzz_FloatRounding_StaysTierAlignedAndNonzero(uint256 factorSeed) public { + uint256 factor = bound(factorSeed, 1, WAD); + _reportAll(factor, factor, factor); + // The first commit has no prior value, so the clamp does not apply and + // the committed factor is exactly the rounded target. + oracle.commit(address(token)); + + (uint256 committedFactor,,) = oracle.freeFloatFactor(address(token)); + assertGt(committedFactor, 0, "nonzero factor rounded to zero"); + assertLe(committedFactor, WAD); + // Aligned to one of the three tiers. + uint256 tier = committedFactor >= 1e17 ? 5e16 : (committedFactor >= 1e16 ? 1e16 : 1e15); + assertEq(committedFactor % tier, 0, "committed factor not tier-aligned"); + } } diff --git a/test/SupplyOracleIntegration.t.sol b/test/SupplyOracleIntegration.t.sol index 1cad188..37a88ea 100644 --- a/test/SupplyOracleIntegration.t.sol +++ b/test/SupplyOracleIntegration.t.sol @@ -138,7 +138,7 @@ contract SupplyOracleIntegrationTest is Test { // Raise the cap so the 4-name index is not saturated (at 25% every // name pins to the cap and no supply change can move a weight); at 40% // the tails sit below the cap and supply flows through. - methodology.setWeightParams(0.4e18, 1e14); + methodology.setWeightParams(0.4e18, 0.4e18, 1e14); uint256[] memory before = methodology.getWeights(tokens); // Exclude tailA's vesting tranche (half its supply) once identified.