Skip to main content

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