1use 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#[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 pub instrument_id: InstrumentId,
49 pub amount0: I256,
51 pub amount1: I256,
53 pub sqrt_price_before_x96: U160,
55 pub sqrt_price_after_x96: U160,
57 pub tick_before: i32,
59 pub tick_after: i32,
61 pub liquidity_after: u128,
63 pub fee_growth_global_after: U256,
65 pub lp_fee: U256,
67 pub protocol_fee: U256,
69 pub crossed_ticks: Vec<CrossedTick>,
71 pub trade_info: Option<SwapTradeInfo>,
73}
74
75impl SwapQuote {
76 #[expect(clippy::too_many_arguments)]
77 #[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 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 #[must_use]
149 pub fn zero_for_one(&self) -> bool {
150 self.amount0.is_positive()
151 }
152
153 #[must_use]
155 pub fn total_fee(&self) -> U256 {
156 self.lp_fee + self.protocol_fee
157 }
158
159 #[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 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 #[must_use]
181 pub fn total_crossed_ticks(&self) -> u32 {
182 self.crossed_ticks.len() as u32
183 }
184
185 #[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 #[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 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 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 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 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 #[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 types::{Price, Quantity, fixed::FIXED_PRECISION},
320 };
321
322 #[rstest]
323 fn test_metric_errors_propagate_from_trade_info(rain_pool: SharedPool) {
324 let mut swap_quote = SwapQuote::new(
325 rain_pool.instrument_id,
326 I256::from_str("2").unwrap(),
327 I256::from_str("-1").unwrap(),
328 U160::from(3),
329 U160::from(4),
330 -5,
331 -4,
332 6,
333 U256::from(7),
334 U256::from(8),
335 U256::from(9),
336 vec![],
337 );
338 swap_quote.trade_info = Some(SwapTradeInfo {
339 order_side: OrderSide::Buy,
340 quantity_base: Quantity::from("10"),
341 quantity_quote: Quantity::from("11"),
342 spot_price: Price::from_raw(12, FIXED_PRECISION),
343 execution_price: Price::from_raw(13, FIXED_PRECISION),
344 is_inverted: true,
345 spot_price_before: Some(Price::zero(FIXED_PRECISION)),
346 });
347
348 let price_impact_error = swap_quote.get_price_impact_bps().unwrap_err();
349 let slippage_error = swap_quote.get_slippage_bps().unwrap_err();
350
351 assert_eq!(
352 price_impact_error.to_string(),
353 "Cannot calculate price impact, the spot price before is zero"
354 );
355 assert_eq!(
356 slippage_error.to_string(),
357 "Cannot calculate slippage, the spot price before is zero"
358 );
359 }
360
361 #[rstest]
362 fn test_swap_quote_sell(rain_pool: SharedPool) {
363 let sqrt_x96_price_before = U160::from_str("76951769738874829996307631").unwrap();
365 let amount0 = I256::from_str("287175356684998201516914").unwrap();
366 let amount1 = I256::from_str("-270157537808188649").unwrap();
367
368 let mut swap_quote = SwapQuote::new(
369 rain_pool.instrument_id,
370 amount0,
371 amount1,
372 sqrt_x96_price_before,
373 U160::from_str("76812046714213096298497129").unwrap(),
374 -138_746,
375 -138_782,
376 292_285_495_328_044_734_302_670,
377 U256::ZERO,
378 U256::ZERO,
379 U256::ZERO,
380 vec![],
381 );
382 swap_quote
383 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
384 .unwrap();
385
386 if let Some(swap_trade_info) = &swap_quote.trade_info {
387 assert_eq!(swap_trade_info.order_side, OrderSide::Sell);
388 assert_eq!(swap_quote.get_input_amount(), amount0.unsigned_abs());
389 assert_eq!(swap_quote.get_output_amount(), amount1.unsigned_abs());
390 assert_eq!(
392 swap_trade_info.quantity_base.as_decimal(),
393 dec!(287175.356684998201516914)
394 );
395 assert_eq!(
396 swap_trade_info.quantity_quote.as_decimal(),
397 dec!(0.270157537808188649)
398 );
399 assert_eq!(
400 swap_trade_info.spot_price.as_decimal(),
401 dec!(0.0000009399386483)
402 );
403 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 36);
404 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 28);
405 } else {
406 panic!("Trade info is None");
407 }
408 }
409
410 #[rstest]
411 fn test_swap_quote_buy(rain_pool: SharedPool) {
412 let sqrt_x96_price_before = U160::from_str("76827576486429933391429745").unwrap();
414 let amount0 = I256::from_str("-117180628248242869089291").unwrap();
415 let amount1 = I256::from_str("110241020399788696").unwrap();
416
417 let mut swap_quote = SwapQuote::new(
418 rain_pool.instrument_id,
419 amount0,
420 amount1,
421 sqrt_x96_price_before,
422 U160::from_str("76857455902960072891859299").unwrap(),
423 -138_778,
424 -138_770,
425 292_285_495_328_044_734_302_670,
426 U256::ZERO,
427 U256::ZERO,
428 U256::ZERO,
429 vec![],
430 );
431 swap_quote
432 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
433 .unwrap();
434
435 if let Some(swap_trade_info) = &swap_quote.trade_info {
436 assert_eq!(swap_trade_info.order_side, OrderSide::Buy);
437 assert_eq!(swap_quote.get_input_amount(), amount1.unsigned_abs());
438 assert_eq!(swap_quote.get_output_amount(), amount0.unsigned_abs());
439 assert_eq!(
441 swap_trade_info.quantity_base.as_decimal(),
442 dec!(117180.628248242869089291)
443 );
444 assert_eq!(
445 swap_trade_info.quantity_quote.as_decimal(),
446 dec!(0.110241020399788696)
447 );
448 assert_eq!(
449 swap_trade_info.spot_price.as_decimal(),
450 dec!(0.000000941050309)
451 );
452 assert_eq!(
453 swap_trade_info.execution_price.as_decimal(),
454 dec!(0.0000009407785403)
455 );
456 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 8);
457 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 5);
458 } else {
459 panic!("Trade info is None");
460 }
461 }
462}