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%)
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(0))
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%)
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(0))
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/// For tokens with more than 16 decimals, quantities and prices are automatically
169/// scaled down to `MAX_FLOAT_PRECISION` (16) to ensure safe f64 conversion while
170/// maintaining reasonable precision for practical trading purposes.
171#[derive(Debug)]
172pub struct SwapTradeInfoCalculator<'a> {
173    /// Reference to token0 from the pool.
174    token0: &'a Token,
175    /// Reference to token1 from the pool.
176    token1: &'a Token,
177    /// Whether the base/quote assignment differs from token0/token1 ordering.
178    ///
179    /// - `true`: token0=quote, token1=base (inverted)
180    /// - `false`: token0=base, token1=quote (normal)
181    pub is_inverted: bool,
182    /// Raw swap amounts and resulting sqrt price from the blockchain event.
183    raw_swap_data: RawSwapData,
184}
185
186impl<'a> SwapTradeInfoCalculator<'a> {
187    #[must_use]
188    pub fn new(token0: &'a Token, token1: &'a Token, raw_swap_data: RawSwapData) -> Self {
189        let is_inverted = token0.get_token_priority() < token1.get_token_priority();
190        Self {
191            token0,
192            token1,
193            is_inverted,
194            raw_swap_data,
195        }
196    }
197
198    /// Determines swap direction from amount signs.
199    ///
200    /// Returns `true` if swapping token0 for token1 (`zero_for_one`).
201    #[must_use]
202    pub fn zero_for_one(&self) -> bool {
203        self.raw_swap_data.amount0.is_positive()
204    }
205
206    /// Computes all trade information fields and returns a complete [`SwapTradeInfo`].
207    ///
208    /// Calculates order side, quantities, and prices from the raw swap data,
209    /// applying token priority rules and decimal adjustments. If the price before
210    /// the swap is provided, also computes price impact and slippage metrics.
211    ///
212    /// # Arguments
213    ///
214    /// * `sqrt_price_x96_before` - Optional square root price before the swap (Q96 format).
215    ///   When provided, enables calculation of `spot_price_before`, price impact, and slippage.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if:
220    /// - A token decimal count exceeds `DECIMAL_EXPONENT_MAX` (77).
221    /// - A quantity or price calculation fails.
222    pub fn compute(&self, sqrt_price_x96_before: Option<U160>) -> anyhow::Result<SwapTradeInfo> {
223        let spot_price_before = if let Some(sqrt_price_x96_before) = sqrt_price_x96_before {
224            Some(decode_sqrt_price_x96_to_price_tokens_adjusted(
225                sqrt_price_x96_before,
226                self.token0.decimals,
227                self.token1.decimals,
228                self.is_inverted,
229            )?)
230        } else {
231            None
232        };
233
234        Ok(SwapTradeInfo {
235            order_side: self.order_side(),
236            quantity_base: self.quantity_base()?,
237            quantity_quote: self.quantity_quote()?,
238            spot_price: self.spot_price()?,
239            execution_price: self.execution_price()?,
240            is_inverted: self.is_inverted,
241            spot_price_before,
242        })
243    }
244
245    /// Determines the order side from the perspective of the determined base/quote tokens.
246    ///
247    /// Uses market convention where base is the asset being traded and quote is the pricing currency.
248    ///
249    /// # Returns
250    /// - `OrderSide::Buy` when buying base token (selling quote for base)
251    /// - `OrderSide::Sell` when selling base token (buying quote with base)
252    ///
253    /// # Logic
254    ///
255    /// The order side depends on:
256    /// 1. Which token is being bought/sold (from amount signs)
257    /// 2. Which token is base vs quote (from priority determination)
258    #[must_use]
259    pub fn order_side(&self) -> OrderSide {
260        let zero_for_one = self.zero_for_one();
261
262        if self.is_inverted {
263            // When inverted: token0=quote, token1=base
264            // - zero_for_one (sell token0/quote, buy token1/base) -> BUY base
265            // - one_for_zero (sell token1/base, buy token0/quote -> SELL base
266            if zero_for_one {
267                OrderSide::Buy
268            } else {
269                OrderSide::Sell
270            }
271        } else {
272            // When NOT inverted: token0=base, token1=quote
273            // - zero_for_one (sell token0/base, buy token1/quote) → SELL base
274            // - one_for_zero (sell token1/quote, buy token0/base) → BUY base
275            if zero_for_one {
276                OrderSide::Sell
277            } else {
278                OrderSide::Buy
279            }
280        }
281    }
282
283    /// Returns the quantity of the base token involved in the swap.
284    ///
285    /// This is always the amount of the base asset being traded,
286    /// regardless of whether it's token0 or token1 in the pool.
287    ///
288    /// # Returns
289    /// Absolute value of base token amount with proper decimals
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if the amount cannot be converted to a valid `Quantity`.
294    pub fn quantity_base(&self) -> anyhow::Result<Quantity> {
295        let (amount, precision) = if self.is_inverted {
296            (
297                self.raw_swap_data.amount1.unsigned_abs(),
298                self.token1.decimals,
299            )
300        } else {
301            (
302                self.raw_swap_data.amount0.unsigned_abs(),
303                self.token0.decimals,
304            )
305        };
306
307        Quantity::from_u256(amount, precision).map_err(Into::into)
308    }
309
310    /// Returns the quantity of the quote token involved in the swap.
311    ///
312    /// This is always the amount of the quote (pricing) currency,
313    /// regardless of whether it's token0 or token1 in the pool.
314    ///
315    /// # Returns
316    /// Absolute value of quote token amount with proper decimals
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the amount cannot be converted to a valid `Quantity`.
321    pub fn quantity_quote(&self) -> anyhow::Result<Quantity> {
322        let (amount, precision) = if self.is_inverted {
323            (
324                self.raw_swap_data.amount0.unsigned_abs(),
325                self.token0.decimals,
326            )
327        } else {
328            (
329                self.raw_swap_data.amount1.unsigned_abs(),
330                self.token1.decimals,
331            )
332        };
333
334        Quantity::from_u256(amount, precision).map_err(Into::into)
335    }
336
337    /// Returns the human-readable spot price in base/quote (market) convention.
338    ///
339    /// This is the instantaneous market price after the swap, adjusted for token decimals
340    /// to provide a human-readable value. This price does NOT include fees or slippage.
341    ///
342    /// # Returns
343    /// Price adjusted for token decimals in quote/base direction (market convention).
344    ///
345    /// # Base/Quote Logic
346    /// - When `is_inverted=false`: token0=base, token1=quote → returns token1/token0 (quote/base)
347    /// - When `is_inverted=true`: token0=quote, token1=base → returns token0/token1 (quote/base)
348    ///
349    /// # Use Cases
350    /// - Displaying current market price to users
351    /// - Calculating price impact: `(spot_after - spot_before) / spot_before`
352    /// - Comparing market rate vs execution rate
353    /// - Real-time price feeds
354    fn spot_price(&self) -> anyhow::Result<Price> {
355        // Pool always stores token1/token0
356        // When is_inverted=false: token0=base, token1=quote → want token1/token0 (quote/base) → don't invert
357        // When is_inverted=true: token0=quote, token1=base → want token0/token1 (quote/base) → invert
358        decode_sqrt_price_x96_to_price_tokens_adjusted(
359            self.raw_swap_data.sqrt_price_x96,
360            self.token0.decimals,
361            self.token1.decimals,
362            self.is_inverted, // invert when base/quote differs from token0/token1
363        )
364    }
365
366    /// Calculates the average execution price for this swap (includes fees and slippage).
367    ///
368    /// This is the actual realized price paid/received in the swap, calculated from
369    /// the input and output amounts. This represents the true cost of the trade.
370    ///
371    /// # Returns
372    /// Price in quote/base direction (market convention), adjusted for token decimals.
373    ///
374    /// # Formula
375    /// ```text
376    /// price = (quote_amount / 10^quote_decimals) / (base_amount / 10^base_decimals)
377    ///       = (quote_amount * 10^base_decimals) / (base_amount * 10^quote_decimals)
378    /// ```
379    ///
380    /// To preserve precision in U256 arithmetic, we scale by `10^FIXED_PRECISION`:
381    /// ```text
382    /// price_raw = (quote_amount * 10^base_decimals * 10^FIXED_PRECISION) / (base_amount * 10^quote_decimals)
383    /// ```
384    ///
385    /// # Base/Quote Logic
386    /// - When `is_inverted=false`: quote=token1, base=token0 → price = amount1/amount0
387    /// - When `is_inverted=true`: quote=token0, base=token1 → price = amount0/amount1
388    ///
389    /// # Use Cases
390    /// - Trade accounting and P&L calculation
391    /// - Comparing quoted vs executed prices
392    /// - Cost analysis (includes all fees and price impact)
393    /// - Performance reporting
394    fn execution_price(&self) -> anyhow::Result<Price> {
395        let amount0 = self.raw_swap_data.amount0.unsigned_abs();
396        let amount1 = self.raw_swap_data.amount1.unsigned_abs();
397
398        if amount0.is_zero() || amount1.is_zero() {
399            anyhow::bail!("Cannot calculate execution price with zero amounts");
400        }
401
402        // Determine base and quote amounts/decimals based on inversion
403        let (quote_amount, base_amount, quote_decimals, base_decimals) = if self.is_inverted {
404            // inverted: token0=quote, token1=base
405            (amount0, amount1, self.token0.decimals, self.token1.decimals)
406        } else {
407            // not inverted: token0=base, token1=quote
408            (amount1, amount0, self.token1.decimals, self.token0.decimals)
409        };
410
411        FullMath::check_decimal_exponent(base_decimals)?;
412        FullMath::check_decimal_exponent(quote_decimals)?;
413
414        let exponent =
415            i16::from(base_decimals) + i16::from(FIXED_PRECISION) - i16::from(quote_decimals);
416        let price_raw_u256 = if exponent >= 0 {
417            let exponent = u8::try_from(exponent)
418                .map_err(|_| anyhow::anyhow!("Decimal exponent {exponent} exceeds u8 range"))?;
419            let primary_exponent = exponent.min(DECIMAL_EXPONENT_MAX);
420            let secondary_exponent = exponent - primary_exponent;
421            let primary_scalar = FullMath::pow10(primary_exponent)?;
422            let secondary_scalar = FullMath::pow10(secondary_exponent)?;
423            FullMath::mul_div_scaled(
424                quote_amount,
425                U256::from(1),
426                base_amount,
427                &[primary_scalar, secondary_scalar],
428            )?
429        } else {
430            let divisor_exponent = u8::try_from(exponent.unsigned_abs())
431                .map_err(|_| anyhow::anyhow!("Decimal exponent {exponent} exceeds u8 range"))?;
432            let divisor = FullMath::pow10(divisor_exponent)?;
433            (quote_amount / base_amount) / divisor
434        };
435
436        price_from_u256(price_raw_u256)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use std::str::FromStr;
443
444    use alloy_primitives::{I256, U160};
445    use rstest::{fixture, rstest};
446    use rust_decimal_macros::dec;
447
448    use super::*;
449    use crate::defi::{
450        stubs::{usdc, weth},
451        tick_map::{full_math::Q96_U160, tick_math::MAX_SQRT_RATIO},
452    };
453
454    #[fixture]
455    fn swap_trade_info() -> SwapTradeInfo {
456        SwapTradeInfo {
457            order_side: OrderSide::Buy,
458            quantity_base: Quantity::from("1"),
459            quantity_quote: Quantity::from("2"),
460            spot_price: Price::from_raw(2, FIXED_PRECISION),
461            execution_price: Price::from_raw(3, FIXED_PRECISION),
462            is_inverted: true,
463            spot_price_before: Some(Price::from_raw(1, FIXED_PRECISION)),
464        }
465    }
466
467    #[rstest]
468    fn test_get_price_impact_bps_rejects_zero_spot_price_before(
469        mut swap_trade_info: SwapTradeInfo,
470    ) {
471        swap_trade_info.spot_price_before = Some(Price::zero(FIXED_PRECISION));
472
473        let error = swap_trade_info.get_price_impact_bps().unwrap_err();
474
475        assert_eq!(
476            error.to_string(),
477            "Cannot calculate price impact, the spot price before is zero"
478        );
479    }
480
481    #[rstest]
482    fn test_get_slippage_bps_rejects_zero_spot_price_before(mut swap_trade_info: SwapTradeInfo) {
483        swap_trade_info.spot_price_before = Some(Price::zero(FIXED_PRECISION));
484
485        let error = swap_trade_info.get_slippage_bps().unwrap_err();
486
487        assert_eq!(
488            error.to_string(),
489            "Cannot calculate slippage, the spot price before is zero"
490        );
491    }
492
493    #[rstest]
494    fn test_get_price_impact_bps_accepts_smallest_positive_spot_price_before(
495        swap_trade_info: SwapTradeInfo,
496    ) {
497        assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 10_000);
498    }
499
500    #[rstest]
501    fn test_get_slippage_bps_accepts_smallest_positive_spot_price_before(
502        swap_trade_info: SwapTradeInfo,
503    ) {
504        assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 20_000);
505    }
506
507    #[rstest]
508    fn test_swap_trade_info_calculator_calculations_buy(weth: Token, usdc: Token) {
509        // Real Arbitrum transaction: https://arbiscan.io/tx/0xb9af1fd5eefe82650a5e0f8ff10b3a5e1c7f05f44f255e1335360df97bd1645a
510        let raw_data = RawSwapData::new(
511            I256::from_str("-466341596920355889").unwrap(),
512            I256::from_str("1656236893").unwrap(),
513            U160::from_str("4720799958938693700000000").unwrap(),
514        );
515
516        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
517        let result = calculator.compute(None).unwrap();
518        // Its not inverted first is WETH(base) and second USDC(quote) as stablecoin
519        assert!(!calculator.is_inverted);
520        // Its buy, as amount0(WETH) < 0 (we received WETH, pool outflow) and amount1 > 0 (USDC sent, pool inflow)
521        assert_eq!(result.order_side, OrderSide::Buy);
522        assert_eq!(
523            result.quantity_base.as_decimal(),
524            dec!(0.466341596920355889)
525        );
526        assert_eq!(result.quantity_quote.as_decimal(), dec!(1656.236893));
527        assert_eq!(result.spot_price.as_decimal(), dec!(3550.3570265047994091));
528        assert_eq!(
529            result.execution_price.as_decimal(),
530            dec!(3551.5529902061477063)
531        );
532    }
533
534    #[rstest]
535    fn test_swap_trade_info_calculator_calculations_sell(weth: Token, usdc: Token) {
536        //Real Arbitrum transaction: https://arbiscan.io/tx/0x1fbedacf4a1cc7f76174d905c93d2f56d42335cadb4a782e2d74e3019107286b
537        let raw_data = RawSwapData::new(
538            I256::from_str("193450074461093702").unwrap(),
539            I256::from_str("-691892530").unwrap(),
540            U160::from_str("4739235524363817533004858").unwrap(),
541        );
542
543        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
544        let result = calculator.compute(None).unwrap();
545        // Its sell as amount0(WETH) > 0 (we send WETH, pool inflow) and amount1 <0 (USDC received, pool outflow)
546        assert_eq!(result.order_side, OrderSide::Sell);
547        assert_eq!(
548            result.quantity_base.as_decimal(),
549            dec!(0.193450074461093702)
550        );
551        assert_eq!(result.quantity_quote.as_decimal(), dec!(691.89253));
552        assert_eq!(result.spot_price.as_decimal(), dec!(3578.1407251651610105));
553        assert_eq!(
554            result.execution_price.as_decimal(),
555            dec!(3576.5947980503469024)
556        );
557    }
558
559    #[rstest]
560    fn test_swap_trade_info_calculator_spot_price_overflow_is_recoverable(
561        weth: Token,
562        usdc: Token,
563    ) {
564        // A near-MAX_SQRT_RATIO swap overflows spot-price decoding, so compute must return a
565        // recoverable error rather than panic, letting the sync keep the swap with empty metadata.
566        let raw_data = RawSwapData::new(
567            I256::from_str("1").unwrap(),
568            I256::from_str("-1").unwrap(),
569            MAX_SQRT_RATIO - U160::from(1),
570        );
571
572        let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
573
574        assert!(calculator.compute(None).is_err());
575    }
576
577    #[rstest]
578    fn test_execution_price_scales_distinct_decimals_in_both_directions(weth: Token, usdc: Token) {
579        let normal_data = RawSwapData::new(
580            I256::from_str("-2000000000000000000").unwrap(),
581            I256::from_str("5000000").unwrap(),
582            Q96_U160,
583        );
584        let inverted_data = RawSwapData::new(
585            I256::from_str("5000000").unwrap(),
586            I256::from_str("-2000000000000000000").unwrap(),
587            Q96_U160,
588        );
589
590        let normal = SwapTradeInfoCalculator::new(&weth, &usdc, normal_data)
591            .execution_price()
592            .unwrap();
593        let inverted = SwapTradeInfoCalculator::new(&usdc, &weth, inverted_data)
594            .execution_price()
595            .unwrap();
596        let expected = Price::from_raw(25_000_000_000_000_000, FIXED_PRECISION);
597
598        assert_eq!(normal, expected);
599        assert_eq!(inverted, expected);
600    }
601
602    #[rstest]
603    fn test_execution_price_scales_negative_net_exponent(mut weth: Token, mut usdc: Token) {
604        weth.decimals = 0;
605        usdc.decimals = 18;
606        let raw_data = RawSwapData::new(
607            I256::from_str("1").unwrap(),
608            I256::from_str("-100").unwrap(),
609            Q96_U160,
610        );
611
612        let result = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
613            .execution_price()
614            .unwrap();
615
616        assert_eq!(result, Price::from_raw(1, FIXED_PRECISION));
617    }
618
619    #[rstest]
620    fn test_execution_price_accepts_largest_decimal_exponent(mut weth: Token, mut usdc: Token) {
621        weth.decimals = DECIMAL_EXPONENT_MAX;
622        usdc.decimals = 0;
623        let raw_data = RawSwapData::new(
624            I256::from_raw(FullMath::pow10(76).unwrap()),
625            I256::from_str("-1").unwrap(),
626            Q96_U160,
627        );
628
629        let result = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
630            .execution_price()
631            .unwrap();
632
633        assert_eq!(
634            result,
635            Price::from_raw(100_000_000_000_000_000, FIXED_PRECISION)
636        );
637    }
638
639    #[rstest]
640    fn test_execution_price_rejects_first_unsupported_decimal_exponent(
641        mut weth: Token,
642        mut usdc: Token,
643    ) {
644        weth.decimals = DECIMAL_EXPONENT_MAX + 1;
645        usdc.decimals = 0;
646        let raw_data = RawSwapData::new(
647            I256::from_str("1").unwrap(),
648            I256::from_str("-1").unwrap(),
649            Q96_U160,
650        );
651
652        let error = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data)
653            .execution_price()
654            .unwrap_err();
655
656        assert_eq!(
657            error.to_string(),
658            "Decimal exponent 78 exceeds supported maximum 77"
659        );
660    }
661}