nautilus_model/defi/pool_analysis/
quote.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::{Address, I256, U160, U256};
17
18use crate::{
19    defi::{
20        Pool, PoolIdentifier, PoolSwap, SharedChain, SharedDex, Token,
21        data::{
22            block::BlockPosition,
23            swap::RawSwapData,
24            swap_trade_info::{SwapTradeInfo, SwapTradeInfoCalculator},
25        },
26        tick_map::{full_math::FullMath, tick::CrossedTick},
27    },
28    identifiers::InstrumentId,
29};
30
31/// Comprehensive swap quote containing profiling metrics for a hypothetical swap.
32///
33/// This structure provides detailed analysis of what would happen if a swap were executed,
34/// including price impact, fees, slippage, and execution details, without actually
35/// modifying the pool state.
36#[derive(Debug, Clone)]
37#[cfg_attr(
38    feature = "python",
39    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
40)]
41pub struct SwapQuote {
42    /// Instrument identifier ......
43    pub instrument_id: InstrumentId,
44    /// Amount of token0 that would be swapped (positive = in, negative = out).
45    pub amount0: I256,
46    /// Amount of token1 that would be swapped (positive = in, negative = out).
47    pub amount1: I256,
48    /// Square root price before the swap (Q96 format).
49    pub sqrt_price_before_x96: U160,
50    /// Square root price after the swap (Q96 format).
51    pub sqrt_price_after_x96: U160,
52    /// Tick position before the swap.
53    pub tick_before: i32,
54    /// Tick position after the swap.
55    pub tick_after: i32,
56    /// Active liquidity after the swap.
57    pub liquidity_after: u128,
58    /// Fee growth global for target token after the swap (Q128.128 format).
59    pub fee_growth_global_after: U256,
60    /// Total fees paid to liquidity providers.
61    pub lp_fee: U256,
62    /// Total fees paid to the protocol.
63    pub protocol_fee: U256,
64    /// List of tick boundaries crossed during the swap, in order of crossing.
65    pub crossed_ticks: Vec<CrossedTick>,
66    /// Computed swap trade information in market-oriented format.
67    pub trade_info: Option<SwapTradeInfo>,
68}
69
70impl SwapQuote {
71    #[allow(clippy::too_many_arguments)]
72    /// Creates a [`SwapQuote`] instance with comprehensive swap simulation results.
73    ///
74    /// The `trade_info` field is initialized to `None` and must be populated by calling
75    /// [`calculate_trade_info()`](Self::calculate_trade_info) or will be lazily computed
76    /// when accessing price impact or slippage methods.
77    pub fn new(
78        instrument_id: InstrumentId,
79        amount0: I256,
80        amount1: I256,
81        sqrt_price_before_x96: U160,
82        sqrt_price_after_x96: U160,
83        tick_before: i32,
84        tick_after: i32,
85        liquidity_after: u128,
86        fee_growth_global_after: U256,
87        lp_fee: U256,
88        protocol_fee: U256,
89        crossed_ticks: Vec<CrossedTick>,
90    ) -> Self {
91        Self {
92            instrument_id,
93            amount0,
94            amount1,
95            sqrt_price_before_x96,
96            sqrt_price_after_x96,
97            tick_before,
98            tick_after,
99            liquidity_after,
100            fee_growth_global_after,
101            lp_fee,
102            protocol_fee,
103            crossed_ticks,
104            trade_info: None,
105        }
106    }
107
108    fn check_if_trade_info_initialized(&mut self) -> anyhow::Result<&SwapTradeInfo> {
109        if self.trade_info.is_none() {
110            anyhow::bail!(
111                "Trade info is not initialized. Please call calculate_trade_info() first."
112            );
113        }
114
115        Ok(self.trade_info.as_ref().unwrap())
116    }
117
118    /// Calculates and populates the `trade_info` field with market-oriented trade data.
119    ///
120    /// This method transforms the raw swap quote data (token0/token1 amounts, sqrt prices)
121    /// into standard trading terminology (base/quote, order side, execution price).
122    /// The computation uses the `sqrt_price_before_x96` to calculate price impact and slippage.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if trade info computation or price calculations fail.
127    pub fn calculate_trade_info(&mut self, token0: &Token, token1: &Token) -> anyhow::Result<()> {
128        let trade_info_calculator = SwapTradeInfoCalculator::new(
129            token0,
130            token1,
131            RawSwapData::new(self.amount0, self.amount1, self.sqrt_price_after_x96),
132        );
133        let trade_info = trade_info_calculator.compute(Some(self.sqrt_price_before_x96))?;
134        self.trade_info = Some(trade_info);
135
136        Ok(())
137    }
138
139    /// Determines swap direction from amount signs.
140    ///
141    /// Returns `true` if swapping token0 for token1 (zero_for_one).
142    pub fn zero_for_one(&self) -> bool {
143        self.amount0.is_positive()
144    }
145
146    /// Returns the total fees paid in input token(LP fees + protocol fees).
147    pub fn total_fee(&self) -> U256 {
148        self.lp_fee + self.protocol_fee
149    }
150
151    /// Gets the effective fee rate in basis points based on actual fees charged
152    pub fn get_effective_fee_bps(&self) -> u32 {
153        let input_amount = self.get_input_amount();
154        if input_amount.is_zero() {
155            return 0;
156        }
157
158        let total_fees = self.lp_fee + self.protocol_fee;
159
160        // fee_bps = (total_fees / input_amount) × 10000
161        let fee_bps =
162            FullMath::mul_div(total_fees, U256::from(10_000), input_amount).unwrap_or(U256::ZERO);
163
164        fee_bps.to::<u32>()
165    }
166
167    /// Returns the number of tick boundaries crossed during this swap.
168    ///
169    /// This equals the length of the `crossed_ticks` vector and indicates
170    /// how much liquidity the swap traversed.
171    pub fn total_crossed_ticks(&self) -> u32 {
172        self.crossed_ticks.len() as u32
173    }
174
175    /// Gets the output amount for the given swap direction.
176    pub fn get_output_amount(&self) -> U256 {
177        if self.zero_for_one() {
178            self.amount1.unsigned_abs()
179        } else {
180            self.amount0.unsigned_abs()
181        }
182    }
183
184    /// Gets the input amount for the given swap direction.
185    pub fn get_input_amount(&self) -> U256 {
186        if self.zero_for_one() {
187            self.amount0.unsigned_abs()
188        } else {
189            self.amount1.unsigned_abs()
190        }
191    }
192
193    /// Calculates price impact in basis points (requires token references for decimal adjustment).
194    ///
195    /// Price impact measures the market movement caused by the swap size,
196    /// excluding fees. This is the percentage change in spot price from
197    /// before to after the swap.
198    ///
199    /// # Returns
200    /// Price impact in basis points (10000 = 100%)
201    ///
202    /// # Errors
203    /// Returns error if price calculations fail
204    pub fn get_price_impact_bps(&mut self) -> anyhow::Result<u32> {
205        match self.check_if_trade_info_initialized() {
206            Ok(trade_info) => trade_info.get_price_impact_bps(),
207            Err(e) => anyhow::bail!("Failed to calculate price impact: {e}"),
208        }
209    }
210
211    /// Calculates slippage in basis points (requires token references for decimal adjustment).
212    ///
213    /// Slippage includes both price impact and fees, representing the total
214    /// deviation from the spot price before the swap. This measures the total
215    /// cost to the trader.
216    ///
217    /// # Returns
218    /// Total slippage in basis points (10000 = 100%)
219    ///
220    /// # Errors
221    /// Returns error if price calculations fail
222    pub fn get_slippage_bps(&mut self) -> anyhow::Result<u32> {
223        match self.check_if_trade_info_initialized() {
224            Ok(trade_info) => trade_info.get_slippage_bps(),
225            Err(e) => anyhow::bail!("Failed to calculate slippage: {e}"),
226        }
227    }
228
229    /// # Errors
230    ///
231    /// Returns an error if the actual slippage exceeds the maximum slippage tolerance.
232    pub fn validate_slippage_tolerance(&mut self, max_slippage_bps: u32) -> anyhow::Result<()> {
233        let actual_slippage = self.get_slippage_bps()?;
234        if actual_slippage > max_slippage_bps {
235            anyhow::bail!(
236                "Slippage {actual_slippage} bps exceeds tolerance {max_slippage_bps} bps"
237            );
238        }
239        Ok(())
240    }
241
242    /// Validates that the quote satisfied an exact output request.
243    ///
244    /// # Errors
245    /// Returns error if the actual output is less than the requested amount.
246    pub fn validate_exact_output(&self, amount_out_requested: U256) -> anyhow::Result<()> {
247        let actual_out = self.get_output_amount();
248        if actual_out < amount_out_requested {
249            anyhow::bail!(
250                "Insufficient liquidity: requested {amount_out_requested}, available {actual_out}"
251            );
252        }
253        Ok(())
254    }
255
256    /// Converts this quote into a [`PoolSwap`] event with the provided metadata.
257    ///
258    /// # Returns
259    /// A [`PoolSwap`] event containing both the quote data and provided metadata
260    #[allow(clippy::too_many_arguments)]
261    pub fn to_swap_event(
262        &self,
263        chain: SharedChain,
264        dex: SharedDex,
265        pool_identifier: PoolIdentifier,
266        block: BlockPosition,
267        sender: Address,
268        recipient: Address,
269    ) -> PoolSwap {
270        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
271        PoolSwap::new(
272            chain,
273            dex,
274            instrument_id,
275            pool_identifier,
276            block.number,
277            block.transaction_hash,
278            block.transaction_index,
279            block.log_index,
280            None, // timestamp
281            sender,
282            recipient,
283            self.amount0,
284            self.amount1,
285            self.sqrt_price_after_x96,
286            self.liquidity_after,
287            self.tick_after,
288        )
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::str::FromStr;
295
296    use rstest::rstest;
297    use rust_decimal_macros::dec;
298
299    use super::*;
300    use crate::{
301        defi::{SharedPool, stubs::rain_pool},
302        enums::OrderSide,
303    };
304
305    #[rstest]
306    fn test_swap_quote_sell(rain_pool: SharedPool) {
307        // https://arbiscan.io/tx/0x3d03debc9f4becac1817c462b80ceae3705887a57b2b07b0d3ae4979d7aed519
308        let sqrt_x96_price_before = U160::from_str("76951769738874829996307631").unwrap();
309        let amount0 = I256::from_str("287175356684998201516914").unwrap();
310        let amount1 = I256::from_str("-270157537808188649").unwrap();
311
312        let mut swap_quote = SwapQuote::new(
313            rain_pool.instrument_id,
314            amount0,
315            amount1,
316            sqrt_x96_price_before,
317            U160::from_str("76812046714213096298497129").unwrap(),
318            -138746,
319            -138782,
320            292285495328044734302670,
321            U256::ZERO,
322            U256::ZERO,
323            U256::ZERO,
324            vec![],
325        );
326        swap_quote
327            .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
328            .unwrap();
329
330        if let Some(swap_trade_info) = &swap_quote.trade_info {
331            assert_eq!(swap_trade_info.order_side, OrderSide::Sell);
332            assert_eq!(swap_quote.get_input_amount(), amount0.unsigned_abs());
333            assert_eq!(swap_quote.get_output_amount(), amount1.unsigned_abs());
334            // Check with DexScreener to get their trade data calculations
335            assert_eq!(
336                swap_trade_info.quantity_base.as_decimal(),
337                dec!(287175.356684998201516914)
338            );
339            assert_eq!(
340                swap_trade_info.quantity_quote.as_decimal(),
341                dec!(0.270157537808188649)
342            );
343            assert_eq!(
344                swap_trade_info.spot_price.as_decimal(),
345                dec!(0.0000009399386483)
346            );
347            assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 36);
348            assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 28);
349        } else {
350            panic!("Trade info is None");
351        }
352    }
353
354    #[rstest]
355    fn test_swap_quote_buy(rain_pool: SharedPool) {
356        // https://arbiscan.io/tx/0x50b5adaf482558f84539e3234dd01b3a29fc43a1e2ab997960efd219d6e81ffe
357        let sqrt_x96_price_before = U160::from_str("76827576486429933391429745").unwrap();
358        let amount0 = I256::from_str("-117180628248242869089291").unwrap();
359        let amount1 = I256::from_str("110241020399788696").unwrap();
360
361        let mut swap_quote = SwapQuote::new(
362            rain_pool.instrument_id,
363            amount0,
364            amount1,
365            sqrt_x96_price_before,
366            U160::from_str("76857455902960072891859299").unwrap(),
367            -138778,
368            -138770,
369            292285495328044734302670,
370            U256::ZERO,
371            U256::ZERO,
372            U256::ZERO,
373            vec![],
374        );
375        swap_quote
376            .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
377            .unwrap();
378
379        if let Some(swap_trade_info) = &swap_quote.trade_info {
380            assert_eq!(swap_trade_info.order_side, OrderSide::Buy);
381            assert_eq!(swap_quote.get_input_amount(), amount1.unsigned_abs());
382            assert_eq!(swap_quote.get_output_amount(), amount0.unsigned_abs());
383            // Check with DexScreener to get their trade data calculations
384            assert_eq!(
385                swap_trade_info.quantity_base.as_decimal(),
386                dec!(117180.628248242869089291)
387            );
388            assert_eq!(
389                swap_trade_info.quantity_quote.as_decimal(),
390                dec!(0.110241020399788696)
391            );
392            assert_eq!(
393                swap_trade_info.spot_price.as_decimal(),
394                dec!(0.000000941050309)
395            );
396            assert_eq!(
397                swap_trade_info.execution_price.as_decimal(),
398                dec!(0.0000009407785403)
399            );
400            assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 8);
401            assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 5);
402        } else {
403            panic!("Trade info is None");
404        }
405    }
406}