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