harb/onchain/script/backtesting/BacktestRunner.s.sol
openhands 9061f8e8f6 fix: Address AI review findings for backtesting baseline strategies (#320)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:39:47 +00:00

533 lines
28 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 { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { Kraiken } from "../../src/Kraiken.sol";
import { LiquidityManager } from "../../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../../src/abstracts/ThreePositionStrategy.sol";
import { IWETH9 } from "../../src/interfaces/IWETH9.sol";
import { TestEnvironment } from "../../test/helpers/TestBase.sol";
import { BullMarketOptimizer } from "../../test/mocks/BullMarketOptimizer.sol";
import { FullRangeLPStrategy, FixedWidthLPStrategy } from "./BaselineStrategies.sol";
import { Reporter, StrategyReport, BacktestConfig } from "./Reporter.sol";
import "forge-std/console2.sol";
/// @title BacktestRunner
/// @notice Simulates KrAIken's 3-position strategy against three baselines
/// (Full-Range LP, Fixed-Width LP, HODL) over a 7-day synthetic market.
///
/// All strategies receive the same initial capital (10 ETH equivalent).
/// A random stream of buy/sell swaps is replayed through the shared pool;
/// KrAIken recenters every RECENTER_INTERVAL blocks, Fixed-Width LP
/// rebalances whenever price leaves its ±2000-tick window.
///
/// Usage:
/// forge script script/backtesting/BacktestRunner.s.sol --sig "run()"
contract BacktestRunner is Reporter {
// ── Constants ─────────────────────────────────────────────────────────────
uint256 internal constant INITIAL_CAPITAL = 10 ether;
uint256 internal constant RECENTER_INTERVAL = 100; // blocks per check
uint256 internal constant SIM_STEPS = 50; // simulation steps
uint256 internal constant BLOCKS_PER_STEP = 100; // each step = 100 blocks
uint256 internal constant SECONDS_PER_STEP = 7 days / SIM_STEPS; // evenly distributes 7-day window
uint256 internal constant TRADES_PER_STEP = 5;
uint256 internal constant BUY_AMOUNT = 1 ether; // WETH per buy trade
uint256 internal constant SELL_KRK_FRACTION = 10; // sell 1/10 of KRK balance per sell trade
// ── Pool state ────────────────────────────────────────────────────────────
IUniswapV3Pool internal pool;
IWETH9 internal weth;
Kraiken internal kraiken;
LiquidityManager internal lm;
bool internal token0isWeth;
address internal feesDest;
address internal trader;
// ── Strategy instances ────────────────────────────────────────────────────
FullRangeLPStrategy internal fullRangeLp;
FixedWidthLPStrategy internal fixedWidthLp;
// HODL: tracked as plain state (no contract needed)
uint256 internal hodlWeth;
uint256 internal hodlToken; // KRK base units
// ── KrAIken tracking ─────────────────────────────────────────────────────
uint256 internal kraikenRecenterCount;
uint256 internal kraikenBlocksInRange;
uint256 internal kraikenTotalBlocks;
// Snapshot of LM position composition at simulation start (for IL calculation)
uint256 internal kraikenInitialValue; // total WETH-equivalent value at start
uint256 internal kraikenInitialWeth; // raw WETH portion at start (positions + balance)
uint256 internal kraikenInitialToken; // KRK portion at start (positions + balance)
// ── Simulation bookkeeping ────────────────────────────────────────────────
uint256 internal simStartBlock;
uint256 internal simStartTimestamp;
// ─────────────────────────────────────────────────────────────────────────
// Entry point
// ─────────────────────────────────────────────────────────────────────────
function run() public {
console2.log("=== BacktestRunner: 7-day Simulation ===");
_setup();
_initializeStrategies();
_runSimulation();
_finalizeAndReport();
}
// ─────────────────────────────────────────────────────────────────────────
// Setup
// ─────────────────────────────────────────────────────────────────────────
function _setup() internal {
feesDest = vm.addr(1);
trader = vm.addr(2);
// Deploy the full protocol environment (pool + LM + optimizer + tokens)
address optimizer = address(new BullMarketOptimizer());
TestEnvironment testEnv = new TestEnvironment(feesDest);
(, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(true, feesDest, optimizer);
// Fund LM and wrap ETH for the initial recenter
// TestEnvironment already gives LM 50 ETH; we additionally wrap 10 for positioning
vm.prank(address(lm));
weth.deposit{ value: 10 ether }();
// First recenter establishes the three-position structure
vm.prank(feesDest);
try lm.recenter() { } catch {
console2.log("WARNING: initial recenter failed -- LM positions may be uninitialised");
}
console2.log("Pool:", address(pool));
console2.log("token0isWeth:", token0isWeth);
}
// ─────────────────────────────────────────────────────────────────────────
// Strategy initialisation
// ─────────────────────────────────────────────────────────────────────────
function _initializeStrategies() internal {
(uint160 sqrtPriceX96,,,,,, ) = pool.slot0();
// KRK amount equivalent to INITIAL_CAPITAL/2 at current price (for LP baselines and HODL)
uint256 krkPerStrategy = _krkAmountForWeth(INITIAL_CAPITAL / 2, sqrtPriceX96, token0isWeth);
// Mint KRK for baselines (3× for FullRange + FixedWidth + HODL)
// In this simulation the LM pranks are used to issue tokens for all three
vm.prank(address(lm));
kraiken.mint(krkPerStrategy * 3);
// ── Full-Range LP ──────────────────────────────────────────────────
fullRangeLp = new FullRangeLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
weth.transfer(address(fullRangeLp), INITIAL_CAPITAL / 2);
vm.prank(address(lm));
kraiken.transfer(address(fullRangeLp), krkPerStrategy);
fullRangeLp.initialize();
// ── Fixed-Width LP ─────────────────────────────────────────────────
fixedWidthLp = new FixedWidthLPStrategy(pool, IERC20(address(weth)), IERC20(address(kraiken)), token0isWeth);
vm.deal(address(this), INITIAL_CAPITAL / 2 + 1 ether);
weth.deposit{ value: INITIAL_CAPITAL / 2 }();
weth.transfer(address(fixedWidthLp), INITIAL_CAPITAL / 2);
vm.prank(address(lm));
kraiken.transfer(address(fixedWidthLp), krkPerStrategy);
fixedWidthLp.initialize();
// ── HODL ─────────────────────────────────────────────────────────
// Just record amounts; no actual position opened
hodlWeth = INITIAL_CAPITAL / 2;
hodlToken = krkPerStrategy;
// ── Fund trader for simulation swaps ──────────────────────────────
vm.deal(trader, 200 ether);
vm.prank(trader);
weth.deposit{ value: 100 ether }();
// Trader buys KRK up front so they can also execute sells
_traderBuy(30 ether);
simStartBlock = block.number;
simStartTimestamp = block.timestamp;
// Snapshot LM position value at simulation start for fair P&L and IL measurement
(uint160 sqrtInit,,,,,, ) = pool.slot0();
(kraikenInitialValue, kraikenInitialWeth, kraikenInitialToken) = _getLmPositionStats(sqrtInit);
console2.log("Strategies initialised.");
console2.log(" Full-Range LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
console2.log(" Fixed-Width LP: WETH", INITIAL_CAPITAL / 2 / 1e18, "| KRK", krkPerStrategy / 1e18);
console2.log(" HODL: WETH", hodlWeth / 1e18, "| KRK", hodlToken / 1e18);
}
// ─────────────────────────────────────────────────────────────────────────
// Simulation loop
// ─────────────────────────────────────────────────────────────────────────
function _runSimulation() internal {
console2.log("\nRunning", SIM_STEPS, "simulation steps...");
for (uint256 step = 0; step < SIM_STEPS; step++) {
// Advance time and block number
vm.roll(block.number + BLOCKS_PER_STEP);
vm.warp(block.timestamp + SECONDS_PER_STEP);
// Execute randomised trades to move price
_runTrades(step);
// ── KrAIken recenter (every RECENTER_INTERVAL blocks) ──────────
vm.prank(feesDest);
try lm.recenter() {
kraikenRecenterCount++;
} catch { }
// ── Fixed-Width LP rebalance if out of range ───────────────────
try fixedWidthLp.maybeRebalance() { } catch { }
// ── Record time-in-range for all strategies ────────────────────
_recordBlockState();
}
console2.log("Simulation complete. Recenters:", kraikenRecenterCount);
}
function _runTrades(uint256 step) internal {
for (uint256 i = 0; i < TRADES_PER_STEP; i++) {
uint256 rand = uint256(keccak256(abi.encodePacked(step, i, block.timestamp))) % 100;
if (rand < 60) {
// 60% buys: WETH → KRK, pushes price up
uint256 wethBal = weth.balanceOf(trader);
uint256 buyAmt = BUY_AMOUNT < wethBal ? BUY_AMOUNT : wethBal / 2;
if (buyAmt > 0) _traderBuy(buyAmt);
} else {
// 40% sells: KRK → WETH, pushes price down
uint256 krkBal = kraiken.balanceOf(trader);
if (krkBal > 0) {
uint256 sellAmt = krkBal / SELL_KRK_FRACTION;
if (sellAmt > 0) _traderSell(sellAmt);
}
}
}
}
function _recordBlockState() internal {
kraikenTotalBlocks++;
fullRangeLp.recordBlock();
fixedWidthLp.recordBlock();
// KrAIken: track whether current tick is within the anchor position
(, int24 currentTick,,,,,) = pool.slot0();
(, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR);
if (currentTick >= anchorLower && currentTick < anchorUpper) {
kraikenBlocksInRange++;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Finalization & reporting
// ─────────────────────────────────────────────────────────────────────────
function _finalizeAndReport() internal {
// Close LP positions and collect all tokens
fullRangeLp.finalize();
fixedWidthLp.finalize();
// Current KRK price (WETH base units per 1 KRK base unit, scaled ×1e18)
(uint160 sqrtFinal,,,,,, ) = pool.slot0();
uint256 krkPrice = _krkPriceScaled(sqrtFinal, token0isWeth);
// ── HODL final value ───────────────────────────────────────────────
uint256 hodlFinalValue = hodlWeth + (hodlToken * krkPrice / 1e18);
int256 hodlPnlBps = (int256(hodlFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
// ── Full-Range LP ──────────────────────────────────────────────────
uint256 frFinalValue = fullRangeLp.getWethBalance() + (fullRangeLp.getTokenBalance() * krkPrice / 1e18);
uint256 frFeesWeth = token0isWeth ? fullRangeLp.feesCollected0() : fullRangeLp.feesCollected1();
uint256 frFeesToken = token0isWeth ? fullRangeLp.feesCollected1() : fullRangeLp.feesCollected0();
uint256 frFeesValue = frFeesWeth + (frFeesToken * krkPrice / 1e18);
// Position value without fees (for IL calculation)
uint256 frNoFees = frFinalValue > frFeesValue ? frFinalValue - frFeesValue : 0;
int256 frIlBps = (int256(frNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
int256 frPnlBps = (int256(frFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
// ── Fixed-Width LP ─────────────────────────────────────────────────
uint256 fwFinalValue = fixedWidthLp.getWethBalance() + (fixedWidthLp.getTokenBalance() * krkPrice / 1e18);
uint256 fwFeesWeth = token0isWeth ? fixedWidthLp.feesCollected0() : fixedWidthLp.feesCollected1();
uint256 fwFeesToken = token0isWeth ? fixedWidthLp.feesCollected1() : fixedWidthLp.feesCollected0();
uint256 fwFeesValue = fwFeesWeth + (fwFeesToken * krkPrice / 1e18);
uint256 fwNoFees = fwFinalValue > fwFeesValue ? fwFinalValue - fwFeesValue : 0;
int256 fwIlBps = (int256(fwNoFees) - int256(hodlFinalValue)) * 10_000 / int256(hodlFinalValue > 0 ? hodlFinalValue : 1);
int256 fwPnlBps = (int256(fwFinalValue) - int256(INITIAL_CAPITAL)) * 10_000 / int256(INITIAL_CAPITAL);
uint256 fwTimeInRangeBps = fixedWidthLp.totalBlocks() > 0 ? fixedWidthLp.blocksInRange() * 10_000 / fixedWidthLp.totalBlocks() : 0;
// ── KrAIken 3-position strategy ────────────────────────────────────
// feesDest is the configured feeDestination for this LM; _scrapePositions() transfers
// fee0 and fee1 directly to feeDestination on every recenter, so these balances are
// the actual LP trading fees distributed to the protocol over the simulation period.
uint256 kraikenFeesWeth = weth.balanceOf(feesDest);
uint256 kraikenFeesToken = kraiken.balanceOf(feesDest);
uint256 kraikenFeesValue = kraikenFeesWeth + (kraikenFeesToken * krkPrice / 1e18);
// Measure the current market value of the LM's live positions plus its raw balances.
// The LM's positions remain open at end of simulation (no finalize() call), so we
// must compute their value using tick math rather than burning them.
(uint256 kraikenEndPositionValue, , ) = _getLmPositionStats(sqrtFinal);
// Final value = open position value + fees already distributed to feesDest
uint256 kraikenFinalValue = kraikenEndPositionValue + kraikenFeesValue;
// P&L relative to measured start value (not a hardcoded constant)
int256 kraikenPnlBps = (int256(kraikenFinalValue) - int256(kraikenInitialValue)) * 10_000
/ int256(kraikenInitialValue > 0 ? kraikenInitialValue : 1);
// IL: compare open position value (fees already excluded — they left to feesDest) to
// HODL of the same initial token split at final price.
uint256 kraikenHodlFinalValue = kraikenInitialWeth + (kraikenInitialToken * krkPrice / 1e18);
int256 kraikenIlBps = (int256(kraikenEndPositionValue) - int256(kraikenHodlFinalValue)) * 10_000
/ int256(kraikenHodlFinalValue > 0 ? kraikenHodlFinalValue : 1);
uint256 kraikenTimeInRangeBps = kraikenTotalBlocks > 0 ? kraikenBlocksInRange * 10_000 / kraikenTotalBlocks : 0;
// ── Assemble reports ───────────────────────────────────────────────
StrategyReport[] memory reports = new StrategyReport[](4);
reports[0] = StrategyReport({
name: "KrAIken 3-Pos",
initialValueWeth: kraikenInitialValue,
finalValueWeth: kraikenFinalValue,
feesEarnedWeth: kraikenFeesWeth,
feesEarnedToken: kraikenFeesToken,
rebalanceCount: kraikenRecenterCount,
ilBps: kraikenIlBps,
netPnlBps: kraikenPnlBps,
timeInRangeBps: kraikenTimeInRangeBps,
hasTimeInRange: true
});
reports[1] = StrategyReport({
name: "Full-Range LP",
initialValueWeth: INITIAL_CAPITAL,
finalValueWeth: frFinalValue,
feesEarnedWeth: frFeesWeth,
feesEarnedToken: frFeesToken,
rebalanceCount: 0,
ilBps: frIlBps,
netPnlBps: frPnlBps,
timeInRangeBps: 10_000, // always 100%
hasTimeInRange: true
});
reports[2] = StrategyReport({
name: "Fixed-Width LP",
initialValueWeth: INITIAL_CAPITAL,
finalValueWeth: fwFinalValue,
feesEarnedWeth: fwFeesWeth,
feesEarnedToken: fwFeesToken,
rebalanceCount: fixedWidthLp.rebalanceCount(),
ilBps: fwIlBps,
netPnlBps: fwPnlBps,
timeInRangeBps: fwTimeInRangeBps,
hasTimeInRange: true
});
reports[3] = StrategyReport({
name: "HODL",
initialValueWeth: INITIAL_CAPITAL,
finalValueWeth: hodlFinalValue,
feesEarnedWeth: 0,
feesEarnedToken: 0,
rebalanceCount: 0,
ilBps: 0,
netPnlBps: hodlPnlBps,
timeInRangeBps: 0,
hasTimeInRange: false
});
BacktestConfig memory config = BacktestConfig({
pool: address(pool),
startBlock: simStartBlock,
endBlock: block.number,
startTimestamp: simStartTimestamp,
endTimestamp: block.timestamp,
initialCapitalWei: INITIAL_CAPITAL,
recenterInterval: RECENTER_INTERVAL
});
_printSummary(reports);
_writeReport(reports, config);
}
function _printSummary(StrategyReport[] memory reports) internal view {
console2.log("\n--- Strategy Summary ---");
for (uint256 i = 0; i < reports.length; i++) {
console2.log(reports[i].name);
console2.log(" Final value (ETH):", reports[i].finalValueWeth / 1e18);
console2.log(" Fees WETH: ", reports[i].feesEarnedWeth / 1e18);
console2.log(" Rebalances: ", reports[i].rebalanceCount);
}
}
// ─────────────────────────────────────────────────────────────────────────
// KrAIken position valuation
// ─────────────────────────────────────────────────────────────────────────
/// @notice Compute the total WETH-equivalent value of all three LM positions
/// plus the LM's uninvested WETH balance, at the given sqrtPrice.
/// @return totalValueWeth All holdings valued in WETH (wei)
/// @return totalWeth Raw WETH in positions + LM balance (wei)
/// @return totalKrk Raw KRK in positions + LM balance (base units)
function _getLmPositionStats(uint160 sqrtPrice)
internal
view
returns (uint256 totalValueWeth, uint256 totalWeth, uint256 totalKrk)
{
ThreePositionStrategy.Stage[3] memory stages = [
ThreePositionStrategy.Stage.FLOOR,
ThreePositionStrategy.Stage.ANCHOR,
ThreePositionStrategy.Stage.DISCOVERY
];
for (uint256 i = 0; i < 3; i++) {
(uint128 liquidity, int24 tickLower, int24 tickUpper) = lm.positions(stages[i]);
if (liquidity == 0) continue;
uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper);
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPrice, sqrtA, sqrtB, liquidity);
if (token0isWeth) {
totalWeth += amount0;
totalKrk += amount1;
} else {
totalKrk += amount0;
totalWeth += amount1;
}
}
// Include uninvested balances held directly by the LM
totalWeth += weth.balanceOf(address(lm));
totalKrk += kraiken.balanceOf(address(lm));
uint256 krkPrice = _krkPriceScaled(sqrtPrice, token0isWeth);
totalValueWeth = totalWeth + (totalKrk * krkPrice / 1e18);
}
// ─────────────────────────────────────────────────────────────────────────
// Swap helpers (direct pool interaction)
// ─────────────────────────────────────────────────────────────────────────
/// @notice Execute a buy (WETH → KRK) on behalf of the trader.
function _traderBuy(uint256 wethAmount) internal {
if (wethAmount == 0 || weth.balanceOf(trader) < wethAmount) return;
// Transfer WETH to this contract for the swap callback
vm.prank(trader);
weth.transfer(address(this), wethAmount);
bool zeroForOne = token0isWeth; // WETH→KRK: zeroForOne when WETH is token0
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
try pool.swap(trader, zeroForOne, int256(wethAmount), limitPrice, "") { } catch { }
// Return any unused WETH to trader
uint256 stuck = weth.balanceOf(address(this));
if (stuck > 0) weth.transfer(trader, stuck);
}
/// @notice Execute a sell (KRK → WETH) on behalf of the trader.
function _traderSell(uint256 krkAmount) internal {
if (krkAmount == 0 || kraiken.balanceOf(trader) < krkAmount) return;
// Transfer KRK to this contract for the swap callback
vm.prank(trader);
kraiken.transfer(address(this), krkAmount);
bool zeroForOne = !token0isWeth; // KRK→WETH: zeroForOne when KRK is token0
uint160 limitPrice = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
try pool.swap(trader, zeroForOne, int256(krkAmount), limitPrice, "") { } catch { }
// Return any unused KRK to trader
uint256 stuck = kraiken.balanceOf(address(this));
if (stuck > 0) kraiken.transfer(trader, stuck);
}
/// @notice Uniswap V3 swap callback — provides tokens from this contract's balance.
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
require(msg.sender == address(pool), "not pool");
if (amount0Delta > 0) {
IERC20(pool.token0()).transfer(msg.sender, uint256(amount0Delta));
}
if (amount1Delta > 0) {
IERC20(pool.token1()).transfer(msg.sender, uint256(amount1Delta));
}
}
// ─────────────────────────────────────────────────────────────────────────
// Price math helpers
// ─────────────────────────────────────────────────────────────────────────
/// @notice Compute KRK (base units) per WETH (base units) at the given sqrtPriceX96.
/// Used to fund baseline LP strategies with the right KRK/WETH split.
/// @param wethAmount WETH in base units (wei)
/// @param sqrtPriceX96 Q64.96 sqrt price from pool.slot0()
/// @param t0isWeth Whether token0 is WETH in this pool
/// @return krkAmount KRK base units equivalent to wethAmount
function _krkAmountForWeth(uint256 wethAmount, uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 krkAmount) {
uint256 sq = uint256(sqrtPriceX96);
if (t0isWeth) {
// price = KRK base units / WETH base units = sq^2 / 2^192
// krkAmount = wethAmount × sq^2 / 2^192
// Approximation: shifting sq right by 48 before squaring avoids overflow at typical
// pool prices (sqrtPriceX96 well below 2^128). At extreme tick boundaries
// (sqrtPriceX96 near MAX_SQRT_RATIO ≈ 2^128) p would be ~2^80 and overflow uint256.
// Acceptable for this simulation where the pool is initialised near 1 ETH : 240k KRK.
uint256 p = sq >> 48;
krkAmount = wethAmount * p * p >> 96;
} else {
// price = WETH base units / KRK base units = sq^2 / 2^192
// krkAmount = wethAmount × 2^192 / sq^2
// Safe two-step division avoids squaring a uint160
uint256 step1 = (wethAmount << 96) / sq;
krkAmount = (step1 << 96) / sq;
}
}
/// @notice Compute KRK price in WETH, scaled by 1e18 (i.e. "1 KRK base unit = X wei WETH × 1e18").
/// Used to value token positions and HODL at final prices.
function _krkPriceScaled(uint160 sqrtPriceX96, bool t0isWeth) internal pure returns (uint256 priceScaled) {
uint256 sq = uint256(sqrtPriceX96);
if (t0isWeth) {
// price (KRK/WETH) = sq^2/2^192 → WETH/KRK = 2^192/sq^2
// priceScaled = 1e18 × 2^192 / sq^2
uint256 step1 = (uint256(1e18) << 96) / sq;
priceScaled = (step1 << 96) / sq;
} else {
// price (WETH/KRK) = sq^2/2^192
// priceScaled = 1e18 × sq^2/2^192 → use (sq×1e9 >> 96)^2
uint256 sqNorm = (sq * 1e9) >> 96;
priceScaled = sqNorm * sqNorm;
}
}
}