Skip to main content

nautilus_model/defi/data/
swap_trade_info.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use alloy_primitives::{U160, U256};
17use rust_decimal::prelude::ToPrimitive;
18use rust_decimal_macros::dec;
19
20use crate::{
21    defi::{
22        Token,
23        data::swap::RawSwapData,
24        tick_map::{
25            full_math::{DECIMAL_EXPONENT_MAX, FullMath},
26            sqrt_price_math::{decode_sqrt_price_x96_to_price_tokens_adjusted, price_from_u256},
27        },
28    },
29    enums::OrderSide,
30    types::{Price, Quantity, fixed::FIXED_PRECISION},
31};
32
33/// Trade information derived from raw swap data, normalized to market conventions.
34///
35/// This structure represents a Uniswap V3 swap translated into standard trading terminology
36/// (base/quote, buy/sell) for consistency with traditional financial data systems.
37///
38/// # Base/Quote Token Convention
39///
40/// Tokens are assigned base/quote roles based on their priority:
41/// - Higher priority token → base (asset being traded)
42/// - Lower priority token → quote (pricing currency)
43///
44/// This may differ from the pool's token0/token1 ordering. When token priority differs
45/// from pool ordering, we say the market is "inverted":
46/// - NOT inverted: token0=base, token1=quote
47/// - Inverted: token0=quote, token1=base
48///
49/// # Prices
50///
51/// - `spot_price`: Instantaneous pool price after the swap (from `sqrt_price_x96`)
52/// - `execution_price`: Average realized price for this swap (from amount ratio)
53///
54/// Both prices are in quote/base direction (e.g., USDC per WETH) and adjusted for token decimals.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SwapTradeInfo {
57    /// The direction of the trade from the base token perspective.
58    pub order_side: OrderSide,
59    /// The absolute quantity of the base token involved in the swap.
60    pub quantity_base: Quantity,
61    /// The absolute quantity of the quote token involved in the swap.
62    pub quantity_quote: Quantity,
63    /// The instantaneous pool price after the swap (quote per base).
64    pub spot_price: Price,
65    /// The average realized execution price for this swap (quote per base).
66    pub execution_price: Price,
67    /// Whether the base/quote assignment differs from token0/token1 ordering.
68    pub is_inverted: bool,
69    /// The pool price before that swap executed(optional).
70    pub spot_price_before: Option<Price>,
71}
72
73impl SwapTradeInfo {
74    /// Sets the spot price before the swap for price impact and slippage calculations.
75    pub fn set_spot_price_before(&mut self, price: Price) {
76        self.spot_price_before = Some(price);
77    }
78
79    /// Calculates price impact in basis points (requires token references for decimal adjustment).
80    ///
81    /// Price impact measures the market movement caused by the swap size,
82    /// excluding fees. This is the percentage change in spot price from
83    /// before to after the swap.
84    ///
85    /// # Returns
86    /// Price impact in basis points (10000 = 100%), saturating at `u32::MAX`
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the spot price before the swap is not set or is zero.
91    pub fn get_price_impact_bps(&self) -> anyhow::Result<u32> {
92        if let Some(spot_price_before) = self.spot_price_before {
93            Self::check_spot_price_before(spot_price_before, PriceMetric::Impact)?;
94            let price_change = self.spot_price - spot_price_before;
95            let price_impact =
96                (price_change.as_decimal() / spot_price_before.as_decimal()).abs() * dec!(10_000);
97
98            Ok(price_impact.round().to_u32().unwrap_or(u32::MAX))
99        } else {
100            anyhow::bail!("Cannot calculate price impact, the spot price before is not set");
101        }
102    }
103
104    /// Calculates slippage in basis points (requires token references for decimal adjustment).
105    ///
106    /// Slippage includes both price impact and fees, representing the total
107    /// deviation from the spot price before the swap. This measures the total
108    /// cost to the trader.
109    ///
110    /// # Returns
111    /// Total slippage in basis points (10000 = 100%), saturating at `u32::MAX`
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the spot price before the swap is not set or is zero.
116    pub fn get_slippage_bps(&self) -> anyhow::Result<u32> {
117        if let Some(spot_price_before) = self.spot_price_before {
118            Self::check_spot_price_before(spot_price_before, PriceMetric::Slippage)?;
119            let price_change = self.execution_price - spot_price_before;
120            let slippage =
121                (price_change.as_decimal() / spot_price_before.as_decimal()).abs() * dec!(10_000);
122
123            Ok(slippage.round().to_u32().unwrap_or(u32::MAX))
124        } else {
125            anyhow::bail!("Cannot calculate slippage, the spot price before is not set")
126        }
127    }
128
129    fn check_spot_price_before(
130        spot_price_before: Price,
131        metric: PriceMetric,
132    ) -> anyhow::Result<()> {
133        let metric = metric.name();
134        anyhow::ensure!(
135            !spot_price_before.is_zero(),
136            "Cannot calculate {metric}, the spot price before is zero"
137        );
138        Ok(())
139    }
140}
141
142enum PriceMetric {
143    Impact,
144    Slippage,
145}
146
147impl PriceMetric {
148    const fn name(self) -> &'static str {
149        match self {
150            Self::Impact => "price impact",
151            Self::Slippage => "slippage",
152        }
153    }
154}
155
156/// Computation engine for deriving market-oriented trade info from raw swap data.
157///
158/// This calculator translates DEX's token0/token1 representation into standard
159/// trading terminology (base/quote, buy/sell) based on token priority.
160///
161/// # Token Priority and Inversion
162///
163/// The calculator determines which token is base vs quote by comparing token priorities.
164/// When the higher-priority token is token1 (not token0), the market is "inverted":
165///
166/// # Precision Handling
167///
168/// Quantities keep each token's decimals and return an error when those exceed the
169/// supported quantity precision. Prices always use `FIXED_PRECISION`.
170#[derive(Debug)]
171pub struct SwapTradeInfoCalculator<'a> {
172    /// Reference to token0 from the pool.
173    token0: &'a Token,
174    /// Reference to token1 from the pool.
175    token1: &'a Token,
176    /// Whether the base/quote assignment differs from token0/token1 ordering.
177    ///
178    /// - `true`: token0=quote, token1=base (inverted)
179    /// - `false`: token0=base, token1=quote (normal)
180    pub is_inverted: bool,
181    /// Raw swap amounts and resulting sqrt price from the blockchain event.
182    raw_swap_data: RawSwapData,
183}
184
185impl<'a> SwapTradeInfoCalculator<'a> {
186    #[must_use]
187    pub fn new(token0: &'a Token, token1: &'a Token, raw_swap_data: RawSwapData) -> Self {
188        let is_inverted = token0.get_token_priority() < token1.get_token_priority();
189        Self {
190            token0,
191            token1,
192            is_inverted,
193            raw_swap_data,
194        }
195    }
196
197    /// Determines swap direction from amount signs.
198    ///
199    /// Returns `true` if swapping token0 for token1 (`zero_for_one`).
200    #[must_use]
201    pub fn zero_for_one(&self) -> bool {
202        self.raw_swap_data.amount0.is_positive()
203    }
204
205    /// Computes all trade information fields and returns a complete [`SwapTradeInfo`].
206    ///
207    /// Calculates order side, quantities, and prices from the raw swap data,
208    /// applying token priority rules and decimal adjustments. If the price before
209    /// the swap is provided, also computes price impact and slippage metrics.
210    ///
211    /// # Arguments
212    ///
213    /// * `sqrt_price_x96_before` - Optional square root price before the swap (Q96 format).
214    ///   When provided, enables calculation of `spot_price_before`, price impact, and slippage.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if:
219    /// - A token decimal count exceeds `DECIMAL_EXPONENT_MAX` (77).
220    /// - A quantity or price calculation fails.
221    pub fn compute(&self, sqrt_price_x96_before: Option<U160>) -> anyhow::Result<SwapTradeInfo> {
222        let spot_price_before = if let Some(sqrt_price_x96_before) = sqrt_price_x96_before {
223            Some(decode_sqrt_price_x96_to_price_tokens_adjusted(
224                sqrt_price_x96_before,
225                self.token0.decimals,
226                self.token1.decimals,
227                self.is_inverted,
228            )?)
229        } else {
230            None
231        };
232
233        Ok(SwapTradeInfo {
234            order_side: self.order_side(),
235            quantity_base: self.quantity_base()?,
236            quantity_quote: self.quantity_quote()?,
237            spot_price: self.spot_price()?,
238            execution_price: self.execution_price()?,
239            is_inverted: self.is_inverted,
240            spot_price_before,
241        })
242    }
243
244    /// Determines the order side from the perspective of the determined base/quote tokens.
245    ///
246    /// Uses market convention where base is the asset being traded and quote is the pricing currency.
247    ///
248    /// # Returns
249    /// - `OrderSide::Buy` when buying base token (selling quote for base)
250    /// - `OrderSide::Sell` when selling base token (buying quote with base)
251    ///
252    /// # Logic
253    ///
254    /// The order side depends on:
255    /// 1. Which token is being bought/sold (from amount signs)
256    /// 2. Which token is base vs quote (from priority determination)
257    #[must_use]
258    pub fn order_side(&self) -> OrderSide {
259        let zero_for_one = self.zero_for_one();
260
261        if self.is_inverted {
262            // When inverted: token0=quote, token1=base
263            // - zero_for_one (sell token0/quote, buy token1/base) -> BUY base
264            // - one_for_zero (sell token1/base, buy token0/quote -> SELL base
265            if zero_for_one {
266                OrderSide::Buy
267            } else {
268                OrderSide::Sell
269            }
270        } else {
271            // When NOT inverted: token0=base, token1=quote
272            // - zero_for_one (sell token0/base, buy token1/quote) → SELL base
273            // - one_for_zero (sell token1/quote, buy token0/base) → BUY base
274            if zero_for_one {
275                OrderSide::Sell
276            } else {
277                OrderSide::Buy
278            }
279        }
280    }
281
282    /// Returns the quantity of the base token involved in the swap.
283    ///
284    /// This is always the amount of the base asset being traded,
285    /// regardless of whether it's token0 or token1 in the pool.
286    ///
287    /// # Returns
288    /// Absolute value of base token amount with proper decimals
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the amount cannot be converted to a valid `Quantity`.
293    pub fn quantity_base(&self) -> anyhow::Result<Quantity> {
294        let (amount, precision) = if self.is_inverted {
295            (
296                self.raw_swap_data.amount1.unsigned_abs(),
297                self.token1.decimals,
298            )
299        } else {
300            (
301                self.raw_swap_data.amount0.unsigned_abs(),
302                self.token0.decimals,
303            )
304        };
305
306        Quantity::from_u256(amount, precision).map_err(Into::into)
307    }
308
309    /// Returns the quantity of the quote token involved in the swap.
310    ///
311    /// This is always the amount of the quote (pricing) currency,
312    /// regardless of whether it's token0 or token1 in the pool.
313    ///
314    /// # Returns
315    /// Absolute value of quote token amount with proper decimals
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if the amount cannot be converted to a valid `Quantity`.
320    pub fn quantity_quote(&self) -> anyhow::Result<Quantity> {
321        let (amount, precision) = if self.is_inverted {
322            (
323                self.raw_swap_data.amount0.unsigned_abs(),
324                self.token0.decimals,
325            )
326        } else {
327            (
328                self.raw_swap_data.amount1.unsigned_abs(),
329                self.token1.decimals,
330            )
331        };
332
333        Quantity::from_u256(amount, precision).map_err(Into::into)
334    }
335
336    /// Returns the human-readable spot price in base/quote (market) convention.
337    ///
338    /// This is the instantaneous market price after the swap, adjusted for token decimals
339    /// to provide a human-readable value. This price does NOT include fees or slippage.
340    ///
341    /// # Returns
342    /// Price adjusted for token decimals in quote/base direction (market convention).
343    ///
344    /// # Base/Quote Logic
345    /// - When `is_inverted=false`: token0=base, token1=quote → returns token1/token0 (quote/base)
346    /// - When `is_inverted=true`: token0=quote, token1=base → returns token0/token1 (quote/base)
347    ///
348    /// # Use Cases
349    /// - Displaying current market price to users
350    /// - Calculating price impact: `(spot_after - spot_before) / spot_before`
351    /// - Comparing market rate vs execution rate
352    /// - Real-time price feeds
353    fn spot_price(&self) -> anyhow::Result<Price> {
354        // Pool always stores token1/token0
355        // When is_inverted=false: token0=base, token1=quote → want token1/token0 (quote/base) → don't invert
356        // When is_inverted=true: token0=quote, token1=base → want token0/token1 (quote/base) → invert
357        decode_sqrt_price_x96_to_price_tokens_adjusted(
358            self.raw_swap_data.sqrt_price_x96,
359            self.token0.decimals,
360            self.token1.decimals,
361            self.is_inverted, // invert when base/quote differs from token0/token1
362        )
363    }
364
365    /// Calculates the average execution price for this swap (includes fees and slippage).
366    ///
367    /// This is the actual realized price paid/received in the swap, calculated from
368    /// the input and output amounts. This represents the true cost of the trade.
369    ///
370    /// # Returns
371    /// Price in quote/base direction (market convention), adjusted for token decimals.
372    ///
373    /// # Formula
374    /// ```text
375    /// price = (quote_amount / 10^quote_decimals) / (base_amount / 10^base_decimals)
376    ///       = (quote_amount * 10^base_decimals) / (base_amount * 10^quote_decimals)
377    /// ```
378    ///
379    /// To preserve precision in U256 arithmetic, we scale by `10^FIXED_PRECISION`:
380    /// ```text
381    /// price_raw = (quote_amount * 10^base_decimals * 10^FIXED_PRECISION) / (base_amount * 10^quote_decimals)
382    /// ```
383    ///
384    /// # Base/Quote Logic
385    /// - When `is_inverted=false`: quote=token1, base=token0 → price = amount1/amount0
386    /// - When `is_inverted=true`: quote=token0, base=token1 → price = amount0/amount1
387    ///
388    /// # Use Cases
389    /// - Trade accounting and P&L calculation
390    /// - Comparing quoted vs executed prices
391    /// - Cost analysis (includes all fees and price impact)
392    /// - Performance reporting
393    fn execution_price(&self) -> anyhow::Result<Price> {
394        let amount0 = self.raw_swap_data.amount0.unsigned_abs();
395        let amount1 = self.raw_swap_data.amount1.unsigned_abs();
396
397        if amount0.is_zero() || amount1.is_zero() {
398            anyhow::bail!("Cannot calculate execution price with zero amounts");
399        }
400
401        // Determine base and quote amounts/decimals based on inversion
402        let (quote_amount, base_amount, quote_decimals, base_decimals) = if self.is_inverted {
403            // inverted: token0=quote, token1=base
404            (amount0, amount1, self.token0.decimals, self.token1.decimals)
405        } else {
406            // not inverted: token0=base, token1=quote
407            (amount1, amount0, self.token1.decimals, self.token0.decimals)
408        };
409
410        FullMath::check_decimal_exponent(base_decimals)?;
411        FullMath::check_decimal_exponent(quote_decimals)?;
412
413        let exponent =
414            i16::from(base_decimals) + i16::from(FIXED_PRECISION) - i16::from(quote_decimals);
415        let price_raw_u256 = if exponent >= 0 {
416            let exponent = u8::try_from(exponent)
417                .map_err(|_| anyhow::anyhow!("Decimal exponent {exponent} exceeds u8 range"))?;
418            let primary_exponent = exponent.min(DECIMAL_EXPONENT_MAX);
419            let secondary_exponent = exponent - primary_exponent;
420            let primary_scalar = FullMath::pow10(primary_exponent)?;
421            let secondary_scalar = FullMath::pow10(secondary_exponent)?;
422            FullMath::mul_div_scaled(
423                quote_amount,
424                U256::from(1),
425                base_amount,
426                &[primary_scalar, secondary_scalar],
427            )?
428        } else {
429            let divisor_exponent = u8::try_from(exponent.unsigned_abs())
430                .map_err(|_| anyhow::anyhow!("Decimal exponent {exponent} exceeds u8 range"))?;
431            let divisor = FullMath::pow10(divisor_exponent)?;
432            (quote_amount / base_amount) / divisor
433        };
434
435        price_from_u256(price_raw_u256)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use std::str::FromStr;
442
443    use alloy_primitives::{I256, U160};
444    use rstest::{fixture, rstest};
445    use rust_decimal_macros::dec;
446
447    use super::*;
448    use crate::defi::{
449        stubs::{usdc, weth},
450        tick_map::{full_math::Q96_U160, tick_math::MAX_SQRT_RATIO},
451    };
452
453    #[fixture]
454    fn swap_trade_info() -> SwapTradeInfo {
455        SwapTradeInfo {
456            order_side: OrderSide::Buy,
457            quantity_base: Quantity::from("1"),
458            quantity_quote: Quantity::from("2"),
459            spot_price: Price::from_raw(2, FIXED_PRECISION),
460            execution_price: Price::from_raw(3, FIXED_PRECISION),
461            is_inverted: true,
462            spot_price_before: Some(Price::from_raw(1, FIXED_PRECISION)),
463        }
464    }
465
466    #[rstest]
467    fn test_get_price_impact_bps_rejects_zero_spot_price_before(
468        mut swap_trade_info: SwapTradeInfo,
469    ) {
470        swap_trade_info.spot_price_before = Some(Price::zero(FIXED_PRECISION));
471
472        let error = swap_trade_info.get_price_impact_bps().unwrap_err();
473
474        assert_eq!(
475            error.to_string(),
476            "Cannot calculate price impact, the spot price before is zero"
477        );
478    }
479
480    #[rstest]
481    fn test_get_slippage_bps_rejects_zero_spot_price_before(mut swap_trade_info: SwapTradeInfo) {
482        swap_trade_info.spot_price_before = Some(Price::zero(FIXED_PRECISION));
483
484        let error = swap_trade_info.get_slippage_bps().unwrap_err();
485
486        assert_eq!(
487            error.to_string(),
488            "Cannot calculate slippage, the spot price before is zero"
489        );
490    }
491
492    #[rstest]
493    fn test_get_price_impact_bps_accepts_smallest_positive_spot_price_before(
494        swap_trade_info: SwapTradeInfo,
495    ) {
496        assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 10_000);
497    }
498
499    #[rstest]
500    fn test_get_slippage_bps_accepts_smallest_positive_spot_price_before(
501        swap_trade_info: SwapTradeInfo,
502    ) {
503        assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 20_000);
504    }
505
506    #[rstest]
507    fn test_get_price_impact_and_slippage_bps_saturate_beyond_u32(
508        mut swap_trade_info: SwapTradeInfo,
509    ) {
510        let one = Price::from_raw(10_i128.pow(u32::from(FIXED_PRECISION)), FIXED_PRECISION);
511        swap_trade_info.spot_price = one;
512        swap_trade_info.execution_price = one;
513
514        assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), u32::MAX);
515        assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), u32::MAX);
516    }
517
518    #[rstest]
519    fn test_swap_trade_info_calculator_calculations_buy(weth: Token, usdc: Token) {
520        // Real Arbitrum transaction: https://arbiscan.io/tx/0xb9af1fd5eefe82650a5e0f8ff10b3a5e1c7f05f44f255e1335360df97bd1645a
521        let raw_data = RawSwapData::new(
522            I256::from_str("-466341596920355889").unwrap(),
523            I256::from_str("1656236893").unwrap(),
524            U160::from_str("4720799958938693700000000").unwrap(),
525        );
526
527        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
528        let result = calculator.compute(None).unwrap();
529        // Its not inverted first is WETH(base) and second USDC(quote) as stablecoin
530        assert!(!calculator.is_inverted);
531        // Its buy, as amount0(WETH) < 0 (we received WETH, pool outflow) and amount1 > 0 (USDC sent, pool inflow)
532        assert_eq!(result.order_side, OrderSide::Buy);
533        assert_eq!(
534            result.quantity_base.as_decimal(),
535            dec!(0.466341596920355889)
536        );
537        assert_eq!(result.quantity_quote.as_decimal(), dec!(1656.236893));
538        assert_eq!(result.spot_price.as_decimal(), dec!(3550.3570265047994091));
539        assert_eq!(
540            result.execution_price.as_decimal(),
541            dec!(3551.5529902061477063)
542        );
543    }
544
545    #[rstest]
546    fn test_swap_trade_info_calculator_calculations_sell(weth: Token, usdc: Token) {
547        //Real Arbitrum transaction: https://arbiscan.io/tx/0x1fbedacf4a1cc7f76174d905c93d2f56d42335cadb4a782e2d74e3019107286b
548        let raw_data = RawSwapData::new(
549            I256::from_str("193450074461093702").unwrap(),
550            I256::from_str("-691892530").unwrap(),
551            U160::from_str("4739235524363817533004858").unwrap(),
552        );
553
554        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
555        let result = calculator.compute(None).unwrap();
556        // Its sell as amount0(WETH) > 0 (we send WETH, pool inflow) and amount1 <0 (USDC received, pool outflow)
557        assert_eq!(result.order_side, OrderSide::Sell);
558        assert_eq!(
559            result.quantity_base.as_decimal(),
560            dec!(0.193450074461093702)
561        );
562        assert_eq!(result.quantity_quote.as_decimal(), dec!(691.89253));
563        assert_eq!(result.spot_price.as_decimal(), dec!(3578.1407251651610105));
564        assert_eq!(
565            result.execution_price.as_decimal(),
566            dec!(3576.5947980503469024)
567        );
568    }
569
570    #[rstest]
571    fn test_swap_trade_info_calculator_spot_price_overflow_is_recoverable(
572        weth: Token,
573        usdc: Token,
574    ) {
575        // A near-MAX_SQRT_RATIO swap overflows spot-price decoding, so compute must return a
576        // recoverable error rather than panic, letting the sync keep the swap with empty metadata.
577        let raw_data = RawSwapData::new(
578            I256::from_str("1").unwrap(),
579            I256::from_str("-1").unwrap(),
580            MAX_SQRT_RATIO - U160::from(1),
581        );
582
583        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
584
585        assert!(calculator.compute(None).is_err());
586    }
587
588    #[rstest]
589    fn test_execution_price_scales_distinct_decimals_in_both_directions(weth: Token, usdc: Token) {
590        let normal_data = RawSwapData::new(
591            I256::from_str("-2000000000000000000").unwrap(),
592            I256::from_str("5000000").unwrap(),
593            Q96_U160,
594        );
595        let inverted_data = RawSwapData::new(
596            I256::from_str("5000000").unwrap(),
597            I256::from_str("-2000000000000000000").unwrap(),
598            Q96_U160,
599        );
600
601        let normal = SwapTradeInfoCalculator::new(&weth, &usdc, normal_data)
602            .execution_price()
603            .unwrap();
604        let inverted = SwapTradeInfoCalculator::new(&usdc, &weth, inverted_data)
605            .execution_price()
606            .unwrap();
607        let expected = Price::from_raw(25_000_000_000_000_000, FIXED_PRECISION);
608
609        assert_eq!(normal, expected);
610        assert_eq!(inverted, expected);
611    }
612
613    #[rstest]
614    fn test_execution_price_scales_negative_net_exponent(mut weth: Token, mut usdc: Token) {
615        weth.decimals = 0;
616        usdc.decimals = 18;
617        let raw_data = RawSwapData::new(
618            I256::from_str("4").unwrap(),
619            I256::from_str("-400").unwrap(),
620            Q96_U160,
621        );
622
623        let result = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
624            .execution_price()
625            .unwrap();
626
627        assert_eq!(result, Price::from_raw(1, FIXED_PRECISION));
628    }
629
630    #[rstest]
631    fn test_execution_price_accepts_largest_decimal_exponent(mut weth: Token, mut usdc: Token) {
632        weth.decimals = DECIMAL_EXPONENT_MAX;
633        usdc.decimals = 0;
634        let raw_data = RawSwapData::new(
635            I256::from_raw(FullMath::pow10(76).unwrap()),
636            I256::from_str("-1").unwrap(),
637            Q96_U160,
638        );
639
640        let result = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
641            .execution_price()
642            .unwrap();
643
644        assert_eq!(
645            result,
646            Price::from_raw(100_000_000_000_000_000, FIXED_PRECISION)
647        );
648    }
649
650    #[rstest]
651    fn test_execution_price_rejects_first_unsupported_decimal_exponent(
652        mut weth: Token,
653        mut usdc: Token,
654    ) {
655        weth.decimals = DECIMAL_EXPONENT_MAX + 1;
656        usdc.decimals = 0;
657        let raw_data = RawSwapData::new(
658            I256::from_str("1").unwrap(),
659            I256::from_str("-1").unwrap(),
660            Q96_U160,
661        );
662
663        let error = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
664            .execution_price()
665            .unwrap_err();
666
667        assert_eq!(
668            error.to_string(),
669            "Decimal exponent 78 exceeds supported maximum 77"
670        );
671    }
672}