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.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, or from price movement when both amounts are
146    /// zero.
147    ///
148    /// Returns `true` if swapping token0 for token1 (`zero_for_one`).
149    #[must_use]
150    pub fn zero_for_one(&self) -> bool {
151        if self.amount0.is_zero() && self.amount1.is_zero() {
152            self.sqrt_price_after_x96 < self.sqrt_price_before_x96
153        } else {
154            self.amount0.is_positive()
155        }
156    }
157
158    /// Returns the total fees paid in input token(LP fees + protocol fees).
159    #[must_use]
160    pub fn total_fee(&self) -> U256 {
161        self.lp_fee + self.protocol_fee
162    }
163
164    /// Gets the effective fee rate in basis points based on actual fees charged
165    #[must_use]
166    pub fn get_effective_fee_bps(&self) -> u32 {
167        let input_amount = self.get_input_amount();
168        if input_amount.is_zero() {
169            return 0;
170        }
171
172        let total_fees = self.lp_fee + self.protocol_fee;
173
174        // fee_bps = (total_fees / input_amount) × 10000
175        let fee_bps =
176            FullMath::mul_div(total_fees, U256::from(10_000), input_amount).unwrap_or(U256::ZERO);
177
178        fee_bps.to::<u32>()
179    }
180
181    /// Returns the number of tick boundaries crossed during this swap.
182    ///
183    /// This equals the length of the `crossed_ticks` vector and indicates
184    /// how much liquidity the swap traversed.
185    #[must_use]
186    pub fn total_crossed_ticks(&self) -> u32 {
187        self.crossed_ticks.len() as u32
188    }
189
190    /// Gets the output amount for the given swap direction.
191    #[must_use]
192    pub fn get_output_amount(&self) -> U256 {
193        if self.zero_for_one() {
194            self.amount1.unsigned_abs()
195        } else {
196            self.amount0.unsigned_abs()
197        }
198    }
199
200    /// Gets the input amount for the given swap direction.
201    #[must_use]
202    pub fn get_input_amount(&self) -> U256 {
203        if self.zero_for_one() {
204            self.amount0.unsigned_abs()
205        } else {
206            self.amount1.unsigned_abs()
207        }
208    }
209
210    /// Calculates price impact in basis points (requires token references for decimal adjustment).
211    ///
212    /// Price impact measures the market movement caused by the swap size,
213    /// excluding fees. This is the percentage change in spot price from
214    /// before to after the swap.
215    ///
216    /// # Returns
217    /// Price impact in basis points (10000 = 100%)
218    ///
219    /// # Errors
220    /// Returns an error if price calculations fail.
221    pub fn get_price_impact_bps(&mut self) -> anyhow::Result<u32> {
222        match self.check_if_trade_info_initialized() {
223            Ok(trade_info) => trade_info.get_price_impact_bps(),
224            Err(e) => anyhow::bail!("Failed to calculate price impact: {e}"),
225        }
226    }
227
228    /// Calculates slippage in basis points (requires token references for decimal adjustment).
229    ///
230    /// Slippage includes both price impact and fees, representing the total
231    /// deviation from the spot price before the swap. This measures the total
232    /// cost to the trader.
233    ///
234    /// # Returns
235    /// Total slippage in basis points (10000 = 100%)
236    ///
237    /// # Errors
238    /// Returns an error if price calculations fail.
239    pub fn get_slippage_bps(&mut self) -> anyhow::Result<u32> {
240        match self.check_if_trade_info_initialized() {
241            Ok(trade_info) => trade_info.get_slippage_bps(),
242            Err(e) => anyhow::bail!("Failed to calculate slippage: {e}"),
243        }
244    }
245
246    /// # Errors
247    ///
248    /// Returns an error if the actual slippage exceeds the maximum slippage tolerance.
249    pub fn validate_slippage_tolerance(&mut self, max_slippage_bps: u32) -> anyhow::Result<()> {
250        let actual_slippage = self.get_slippage_bps()?;
251        if actual_slippage > max_slippage_bps {
252            anyhow::bail!(
253                "Slippage {actual_slippage} bps exceeds tolerance {max_slippage_bps} bps"
254            );
255        }
256        Ok(())
257    }
258
259    /// Validates that the quote satisfied an exact output request.
260    ///
261    /// # Errors
262    /// Returns an error if the actual output is less than the requested amount.
263    pub fn validate_exact_output(&self, amount_out_requested: U256) -> anyhow::Result<()> {
264        let actual_out = self.get_output_amount();
265        if actual_out < amount_out_requested {
266            anyhow::bail!(
267                "Insufficient liquidity: requested {amount_out_requested}, available {actual_out}"
268            );
269        }
270        Ok(())
271    }
272
273    /// Converts this quote into a [`PoolSwap`] event with the provided metadata.
274    ///
275    /// # Returns
276    /// A [`PoolSwap`] event containing both the quote data and provided metadata
277    #[must_use]
278    #[expect(clippy::too_many_arguments)]
279    pub fn to_swap_event(
280        &self,
281        chain: SharedChain,
282        dex: SharedDex,
283        pool_identifier: PoolIdentifier,
284        block: BlockPosition,
285        ts_event: UnixNanos,
286        ts_init: UnixNanos,
287        sender: Address,
288        recipient: Address,
289    ) -> PoolSwap {
290        let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
291        PoolSwap::new(
292            chain,
293            dex,
294            instrument_id,
295            pool_identifier,
296            block.number,
297            block.transaction_hash,
298            block.transaction_index,
299            block.log_index,
300            ts_event,
301            ts_init,
302            sender,
303            recipient,
304            self.amount0,
305            self.amount1,
306            self.sqrt_price_after_x96,
307            self.liquidity_after,
308            self.tick_after,
309        )
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use std::str::FromStr;
316
317    use rstest::rstest;
318    use rust_decimal_macros::dec;
319
320    use super::*;
321    use crate::{
322        defi::{SharedPool, stubs::rain_pool},
323        enums::OrderSide,
324        types::{Price, Quantity, fixed::FIXED_PRECISION},
325    };
326
327    #[rstest]
328    fn test_metric_errors_propagate_from_trade_info(rain_pool: SharedPool) {
329        let mut swap_quote = SwapQuote::new(
330            rain_pool.instrument_id,
331            I256::from_str("2").unwrap(),
332            I256::from_str("-1").unwrap(),
333            U160::from(3),
334            U160::from(4),
335            -5,
336            -4,
337            6,
338            U256::from(7),
339            U256::from(8),
340            U256::from(9),
341            vec![],
342        );
343        swap_quote.trade_info = Some(SwapTradeInfo {
344            order_side: OrderSide::Buy,
345            quantity_base: Quantity::from("10"),
346            quantity_quote: Quantity::from("11"),
347            spot_price: Price::from_raw(12, FIXED_PRECISION),
348            execution_price: Price::from_raw(13, FIXED_PRECISION),
349            is_inverted: true,
350            spot_price_before: Some(Price::zero(FIXED_PRECISION)),
351        });
352
353        let price_impact_error = swap_quote.get_price_impact_bps().unwrap_err();
354        let slippage_error = swap_quote.get_slippage_bps().unwrap_err();
355
356        assert_eq!(
357            price_impact_error.to_string(),
358            "Cannot calculate price impact, the spot price before is zero"
359        );
360        assert_eq!(
361            slippage_error.to_string(),
362            "Cannot calculate slippage, the spot price before is zero"
363        );
364    }
365
366    #[rstest]
367    fn test_swap_quote_sell(rain_pool: SharedPool) {
368        // https://arbiscan.io/tx/0x3d03debc9f4becac1817c462b80ceae3705887a57b2b07b0d3ae4979d7aed519
369        let sqrt_x96_price_before = U160::from_str("76951769738874829996307631").unwrap();
370        let amount0 = I256::from_str("287175356684998201516914").unwrap();
371        let amount1 = I256::from_str("-270157537808188649").unwrap();
372
373        let mut swap_quote = SwapQuote::new(
374            rain_pool.instrument_id,
375            amount0,
376            amount1,
377            sqrt_x96_price_before,
378            U160::from_str("76812046714213096298497129").unwrap(),
379            -138_746,
380            -138_782,
381            292_285_495_328_044_734_302_670,
382            U256::ZERO,
383            U256::ZERO,
384            U256::ZERO,
385            vec![],
386        );
387        swap_quote
388            .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
389            .unwrap();
390
391        if let Some(swap_trade_info) = &swap_quote.trade_info {
392            assert_eq!(swap_trade_info.order_side, OrderSide::Sell);
393            assert_eq!(swap_quote.get_input_amount(), amount0.unsigned_abs());
394            assert_eq!(swap_quote.get_output_amount(), amount1.unsigned_abs());
395            // Check with DexScreener to get their trade data calculations
396            assert_eq!(
397                swap_trade_info.quantity_base.as_decimal(),
398                dec!(287175.356684998201516914)
399            );
400            assert_eq!(
401                swap_trade_info.quantity_quote.as_decimal(),
402                dec!(0.270157537808188649)
403            );
404            assert_eq!(
405                swap_trade_info.spot_price.as_decimal(),
406                dec!(0.0000009399386483)
407            );
408            assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 36);
409            assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 28);
410        } else {
411            panic!("Trade info is None");
412        }
413    }
414
415    #[rstest]
416    fn test_swap_quote_buy(rain_pool: SharedPool) {
417        // https://arbiscan.io/tx/0x50b5adaf482558f84539e3234dd01b3a29fc43a1e2ab997960efd219d6e81ffe
418        let sqrt_x96_price_before = U160::from_str("76827576486429933391429745").unwrap();
419        let amount0 = I256::from_str("-117180628248242869089291").unwrap();
420        let amount1 = I256::from_str("110241020399788696").unwrap();
421
422        let mut swap_quote = SwapQuote::new(
423            rain_pool.instrument_id,
424            amount0,
425            amount1,
426            sqrt_x96_price_before,
427            U160::from_str("76857455902960072891859299").unwrap(),
428            -138_778,
429            -138_770,
430            292_285_495_328_044_734_302_670,
431            U256::ZERO,
432            U256::ZERO,
433            U256::ZERO,
434            vec![],
435        );
436        swap_quote
437            .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
438            .unwrap();
439
440        if let Some(swap_trade_info) = &swap_quote.trade_info {
441            assert_eq!(swap_trade_info.order_side, OrderSide::Buy);
442            assert_eq!(swap_quote.get_input_amount(), amount1.unsigned_abs());
443            assert_eq!(swap_quote.get_output_amount(), amount0.unsigned_abs());
444            // Check with DexScreener to get their trade data calculations
445            assert_eq!(
446                swap_trade_info.quantity_base.as_decimal(),
447                dec!(117180.628248242869089291)
448            );
449            assert_eq!(
450                swap_trade_info.quantity_quote.as_decimal(),
451                dec!(0.110241020399788696)
452            );
453            assert_eq!(
454                swap_trade_info.spot_price.as_decimal(),
455                dec!(0.000000941050309)
456            );
457            assert_eq!(
458                swap_trade_info.execution_price.as_decimal(),
459                dec!(0.0000009407785403)
460            );
461            assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 8);
462            assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 5);
463        } else {
464            panic!("Trade info is None");
465        }
466    }
467}