nautilus_model/defi/data/swap_trade_info.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::{U160, U256};
17use rust_decimal::prelude::ToPrimitive;
18use rust_decimal_macros::dec;
19
20use crate::{
21 defi::{
22 Token,
23 data::swap::RawSwapData,
24 tick_map::{
25 full_math::FullMath, sqrt_price_math::decode_sqrt_price_x96_to_price_tokens_adjusted,
26 },
27 },
28 enums::OrderSide,
29 types::{Price, Quantity, fixed::FIXED_PRECISION},
30};
31
32/// Trade information derived from raw swap data, normalized to market conventions.
33///
34/// This structure represents a Uniswap V3 swap translated into standard trading terminology
35/// (base/quote, buy/sell) for consistency with traditional financial data systems.
36///
37/// # Base/Quote Token Convention
38///
39/// Tokens are assigned base/quote roles based on their priority:
40/// - Higher priority token → base (asset being traded)
41/// - Lower priority token → quote (pricing currency)
42///
43/// This may differ from the pool's token0/token1 ordering. When token priority differs
44/// from pool ordering, we say the market is "inverted":
45/// - NOT inverted: token0=base, token1=quote
46/// - Inverted: token0=quote, token1=base
47///
48/// # Prices
49///
50/// - `spot_price`: Instantaneous pool price after the swap (from `sqrt_price_x96`)
51/// - `execution_price`: Average realized price for this swap (from amount ratio)
52///
53/// Both prices are in quote/base direction (e.g., USDC per WETH) and adjusted for token decimals.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SwapTradeInfo {
56 /// The direction of the trade from the base token perspective.
57 pub order_side: OrderSide,
58 /// The absolute quantity of the base token involved in the swap.
59 pub quantity_base: Quantity,
60 /// The absolute quantity of the quote token involved in the swap.
61 pub quantity_quote: Quantity,
62 /// The instantaneous pool price after the swap (quote per base).
63 pub spot_price: Price,
64 /// The average realized execution price for this swap (quote per base).
65 pub execution_price: Price,
66 /// Whether the base/quote assignment differs from token0/token1 ordering.
67 pub is_inverted: bool,
68 /// The pool price before that swap executed(optional).
69 pub spot_price_before: Option<Price>,
70}
71
72impl SwapTradeInfo {
73 /// Sets the spot price before the swap for price impact and slippage calculations.
74 pub fn set_spot_price_before(&mut self, price: Price) {
75 self.spot_price_before = Some(price);
76 }
77
78 /// Calculates price impact in basis points (requires token references for decimal adjustment).
79 ///
80 /// Price impact measures the market movement caused by the swap size,
81 /// excluding fees. This is the percentage change in spot price from
82 /// before to after the swap.
83 ///
84 /// # Returns
85 /// Price impact in basis points (10000 = 100%)
86 ///
87 /// # Errors
88 /// Returns error if price calculations fail
89 pub fn get_price_impact_bps(&self) -> anyhow::Result<u32> {
90 if let Some(spot_price_before) = self.spot_price_before {
91 let price_change = self.spot_price - spot_price_before;
92 let price_impact =
93 (price_change.as_decimal() / spot_price_before.as_decimal()).abs() * dec!(10_000);
94
95 Ok(price_impact.round().to_u32().unwrap_or(0))
96 } else {
97 anyhow::bail!("Cannot calculate price impact, the spot price before is not set");
98 }
99 }
100
101 /// Calculates slippage in basis points (requires token references for decimal adjustment).
102 ///
103 /// Slippage includes both price impact and fees, representing the total
104 /// deviation from the spot price before the swap. This measures the total
105 /// cost to the trader.
106 ///
107 /// # Returns
108 /// Total slippage in basis points (10000 = 100%)
109 ///
110 /// # Errors
111 /// Returns error if price calculations fail
112 pub fn get_slippage_bps(&self) -> anyhow::Result<u32> {
113 if let Some(spot_price_before) = self.spot_price_before {
114 let price_change = self.execution_price - spot_price_before;
115 let slippage =
116 (price_change.as_decimal() / spot_price_before.as_decimal()).abs() * dec!(10_000);
117
118 Ok(slippage.round().to_u32().unwrap_or(0))
119 } else {
120 anyhow::bail!("Cannot calculate slippage, the spot price before is not set")
121 }
122 }
123}
124
125/// Computation engine for deriving market-oriented trade info from raw swap data.
126///
127/// This calculator translates DEX's token0/token1 representation into standard
128/// trading terminology (base/quote, buy/sell) based on token priority.
129///
130/// # Token Priority and Inversion
131///
132/// The calculator determines which token is base vs quote by comparing token priorities.
133/// When the higher-priority token is token1 (not token0), the market is "inverted":
134///
135/// # Precision Handling
136///
137/// For tokens with more than 16 decimals, quantities and prices are automatically
138/// scaled down to `MAX_FLOAT_PRECISION` (16) to ensure safe f64 conversion while
139/// maintaining reasonable precision for practical trading purposes.
140#[derive(Debug)]
141pub struct SwapTradeInfoCalculator<'a> {
142 /// Reference to token0 from the pool.
143 token0: &'a Token,
144 /// Reference to token1 from the pool.
145 token1: &'a Token,
146 /// Whether the base/quote assignment differs from token0/token1 ordering.
147 ///
148 /// - `true`: token0=quote, token1=base (inverted)
149 /// - `false`: token0=base, token1=quote (normal)
150 pub is_inverted: bool,
151 /// Raw swap amounts and resulting sqrt price from the blockchain event.
152 raw_swap_data: RawSwapData,
153}
154
155impl<'a> SwapTradeInfoCalculator<'a> {
156 #[must_use]
157 pub fn new(token0: &'a Token, token1: &'a Token, raw_swap_data: RawSwapData) -> Self {
158 let is_inverted = token0.get_token_priority() < token1.get_token_priority();
159 Self {
160 token0,
161 token1,
162 is_inverted,
163 raw_swap_data,
164 }
165 }
166
167 /// Determines swap direction from amount signs.
168 ///
169 /// Returns `true` if swapping token0 for token1 (`zero_for_one`).
170 #[must_use]
171 pub fn zero_for_one(&self) -> bool {
172 self.raw_swap_data.amount0.is_positive()
173 }
174
175 /// Computes all trade information fields and returns a complete [`SwapTradeInfo`].
176 ///
177 /// Calculates order side, quantities, and prices from the raw swap data,
178 /// applying token priority rules and decimal adjustments. If the price before
179 /// the swap is provided, also computes price impact and slippage metrics.
180 ///
181 /// # Arguments
182 ///
183 /// * `sqrt_price_x96_before` - Optional square root price before the swap (Q96 format).
184 /// When provided, enables calculation of `spot_price_before`, price impact, and slippage.
185 ///
186 /// # Errors
187 ///
188 /// Returns an error if quantity or price calculations fail.
189 pub fn compute(&self, sqrt_price_x96_before: Option<U160>) -> anyhow::Result<SwapTradeInfo> {
190 let spot_price_before = if let Some(sqrt_price_x96_before) = sqrt_price_x96_before {
191 Some(decode_sqrt_price_x96_to_price_tokens_adjusted(
192 sqrt_price_x96_before,
193 self.token0.decimals,
194 self.token1.decimals,
195 self.is_inverted,
196 )?)
197 } else {
198 None
199 };
200
201 Ok(SwapTradeInfo {
202 order_side: self.order_side(),
203 quantity_base: self.quantity_base()?,
204 quantity_quote: self.quantity_quote()?,
205 spot_price: self.spot_price()?,
206 execution_price: self.execution_price()?,
207 is_inverted: self.is_inverted,
208 spot_price_before,
209 })
210 }
211
212 /// Determines the order side from the perspective of the determined base/quote tokens.
213 ///
214 /// Uses market convention where base is the asset being traded and quote is the pricing currency.
215 ///
216 /// # Returns
217 /// - `OrderSide::Buy` when buying base token (selling quote for base)
218 /// - `OrderSide::Sell` when selling base token (buying quote with base)
219 ///
220 /// # Logic
221 ///
222 /// The order side depends on:
223 /// 1. Which token is being bought/sold (from amount signs)
224 /// 2. Which token is base vs quote (from priority determination)
225 #[must_use]
226 pub fn order_side(&self) -> OrderSide {
227 let zero_for_one = self.zero_for_one();
228
229 if self.is_inverted {
230 // When inverted: token0=quote, token1=base
231 // - zero_for_one (sell token0/quote, buy token1/base) -> BUY base
232 // - one_for_zero (sell token1/base, buy token0/quote -> SELL base
233 if zero_for_one {
234 OrderSide::Buy
235 } else {
236 OrderSide::Sell
237 }
238 } else {
239 // When NOT inverted: token0=base, token1=quote
240 // - zero_for_one (sell token0/base, buy token1/quote) → SELL base
241 // - one_for_zero (sell token1/quote, buy token0/base) → BUY base
242 if zero_for_one {
243 OrderSide::Sell
244 } else {
245 OrderSide::Buy
246 }
247 }
248 }
249
250 /// Returns the quantity of the base token involved in the swap.
251 ///
252 /// This is always the amount of the base asset being traded,
253 /// regardless of whether it's token0 or token1 in the pool.
254 ///
255 /// # Returns
256 /// Absolute value of base token amount with proper decimals
257 ///
258 /// # Errors
259 ///
260 /// Returns an error if the amount cannot be converted to a valid `Quantity`.
261 pub fn quantity_base(&self) -> anyhow::Result<Quantity> {
262 let (amount, precision) = if self.is_inverted {
263 (
264 self.raw_swap_data.amount1.unsigned_abs(),
265 self.token1.decimals,
266 )
267 } else {
268 (
269 self.raw_swap_data.amount0.unsigned_abs(),
270 self.token0.decimals,
271 )
272 };
273
274 Quantity::from_u256(amount, precision).map_err(Into::into)
275 }
276
277 /// Returns the quantity of the quote token involved in the swap.
278 ///
279 /// This is always the amount of the quote (pricing) currency,
280 /// regardless of whether it's token0 or token1 in the pool.
281 ///
282 /// # Returns
283 /// Absolute value of quote token amount with proper decimals
284 ///
285 /// # Errors
286 ///
287 /// Returns an error if the amount cannot be converted to a valid `Quantity`.
288 pub fn quantity_quote(&self) -> anyhow::Result<Quantity> {
289 let (amount, precision) = if self.is_inverted {
290 (
291 self.raw_swap_data.amount0.unsigned_abs(),
292 self.token0.decimals,
293 )
294 } else {
295 (
296 self.raw_swap_data.amount1.unsigned_abs(),
297 self.token1.decimals,
298 )
299 };
300
301 Quantity::from_u256(amount, precision).map_err(Into::into)
302 }
303
304 /// Returns the human-readable spot price in base/quote (market) convention.
305 ///
306 /// This is the instantaneous market price after the swap, adjusted for token decimals
307 /// to provide a human-readable value. This price does NOT include fees or slippage.
308 ///
309 /// # Returns
310 /// Price adjusted for token decimals in quote/base direction (market convention).
311 ///
312 /// # Base/Quote Logic
313 /// - When `is_inverted=false`: token0=base, token1=quote → returns token1/token0 (quote/base)
314 /// - When `is_inverted=true`: token0=quote, token1=base → returns token0/token1 (quote/base)
315 ///
316 /// # Use Cases
317 /// - Displaying current market price to users
318 /// - Calculating price impact: `(spot_after - spot_before) / spot_before`
319 /// - Comparing market rate vs execution rate
320 /// - Real-time price feeds
321 fn spot_price(&self) -> anyhow::Result<Price> {
322 // Pool always stores token1/token0
323 // When is_inverted=false: token0=base, token1=quote → want token1/token0 (quote/base) → don't invert
324 // When is_inverted=true: token0=quote, token1=base → want token0/token1 (quote/base) → invert
325 decode_sqrt_price_x96_to_price_tokens_adjusted(
326 self.raw_swap_data.sqrt_price_x96,
327 self.token0.decimals,
328 self.token1.decimals,
329 self.is_inverted, // invert when base/quote differs from token0/token1
330 )
331 }
332
333 /// Calculates the average execution price for this swap (includes fees and slippage).
334 ///
335 /// This is the actual realized price paid/received in the swap, calculated from
336 /// the input and output amounts. This represents the true cost of the trade.
337 ///
338 /// # Returns
339 /// Price in quote/base direction (market convention), adjusted for token decimals.
340 ///
341 /// # Formula
342 /// ```text
343 /// price = (quote_amount / 10^quote_decimals) / (base_amount / 10^base_decimals)
344 /// = (quote_amount * 10^base_decimals) / (base_amount * 10^quote_decimals)
345 /// ```
346 ///
347 /// To preserve precision in U256 arithmetic, we scale by `10^FIXED_PRECISION`:
348 /// ```text
349 /// price_raw = (quote_amount * 10^base_decimals * 10^FIXED_PRECISION) / (base_amount * 10^quote_decimals)
350 /// ```
351 ///
352 /// # Base/Quote Logic
353 /// - When `is_inverted=false`: quote=token1, base=token0 → price = amount1/amount0
354 /// - When `is_inverted=true`: quote=token0, base=token1 → price = amount0/amount1
355 ///
356 /// # Use Cases
357 /// - Trade accounting and P&L calculation
358 /// - Comparing quoted vs executed prices
359 /// - Cost analysis (includes all fees and price impact)
360 /// - Performance reporting
361 fn execution_price(&self) -> anyhow::Result<Price> {
362 let amount0 = self.raw_swap_data.amount0.unsigned_abs();
363 let amount1 = self.raw_swap_data.amount1.unsigned_abs();
364
365 if amount0.is_zero() || amount1.is_zero() {
366 anyhow::bail!("Cannot calculate execution price with zero amounts");
367 }
368
369 // Determine base and quote amounts/decimals based on inversion
370 let (quote_amount, base_amount, quote_decimals, base_decimals) = if self.is_inverted {
371 // inverted: token0=quote, token1=base
372 (amount0, amount1, self.token0.decimals, self.token1.decimals)
373 } else {
374 // not inverted: token0=base, token1=quote
375 (amount1, amount0, self.token1.decimals, self.token0.decimals)
376 };
377
378 // Create decimal scalars
379 let base_decimals_scalar = U256::from(10u128.pow(u32::from(base_decimals)));
380 let quote_decimals_scalar = U256::from(10u128.pow(u32::from(quote_decimals)));
381 let fixed_scalar = U256::from(10u128.pow(u32::from(FIXED_PRECISION)));
382
383 // Calculate: (quote_amount * 10^base_decimals * 10^FIXED_PRECISION) / (base_amount * 10^quote_decimals)
384 // Use FullMath::mul_div to handle large intermediate values safely
385
386 // Step 1: numerator = quote_amount * 10^base_decimals
387 let numerator_step1 = FullMath::mul_div(quote_amount, base_decimals_scalar, U256::from(1))?;
388
389 // Step 2: numerator = (quote_amount * 10^base_decimals) * 10^FIXED_PRECISION
390 let numerator_final = FullMath::mul_div(numerator_step1, fixed_scalar, U256::from(1))?;
391
392 // Step 3: denominator = base_amount * 10^quote_decimals
393 let denominator = FullMath::mul_div(base_amount, quote_decimals_scalar, U256::from(1))?;
394
395 // Step 4: Final division
396 let price_raw_u256 = FullMath::mul_div(numerator_final, U256::from(1), denominator)?;
397
398 // Convert to PriceRaw (i128)
399 anyhow::ensure!(
400 price_raw_u256 <= U256::from(i128::MAX as u128),
401 "Price overflow: {price_raw_u256} exceeds i128::MAX"
402 );
403
404 let price_raw = price_raw_u256.to::<i128>();
405
406 // price_raw is at FIXED_PRECISION scale, which is what Price expects
407 Ok(Price::from_raw(price_raw, FIXED_PRECISION))
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use std::str::FromStr;
414
415 use alloy_primitives::{I256, U160};
416 use rstest::rstest;
417 use rust_decimal_macros::dec;
418
419 use super::*;
420 use crate::defi::stubs::{usdc, weth};
421
422 #[rstest]
423 fn test_swap_trade_info_calculator_calculations_buy(weth: Token, usdc: Token) {
424 // Real Arbitrum transaction: https://arbiscan.io/tx/0xb9af1fd5eefe82650a5e0f8ff10b3a5e1c7f05f44f255e1335360df97bd1645a
425 let raw_data = RawSwapData::new(
426 I256::from_str("-466341596920355889").unwrap(),
427 I256::from_str("1656236893").unwrap(),
428 U160::from_str("4720799958938693700000000").unwrap(),
429 );
430
431 let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
432 let result = calculator.compute(None).unwrap();
433 // Its not inverted first is WETH(base) and second USDC(quote) as stablecoin
434 assert!(!calculator.is_inverted);
435 // Its buy, as amount0(WETH) < 0 (we received WETH, pool outflow) and amount1 > 0 (USDC sent, pool inflow)
436 assert_eq!(result.order_side, OrderSide::Buy);
437 assert_eq!(
438 result.quantity_base.as_decimal(),
439 dec!(0.466341596920355889)
440 );
441 assert_eq!(result.quantity_quote.as_decimal(), dec!(1656.236893));
442 assert_eq!(result.spot_price.as_decimal(), dec!(3550.3570265047994091));
443 assert_eq!(
444 result.execution_price.as_decimal(),
445 dec!(3551.5529902061477063)
446 );
447 }
448
449 #[rstest]
450 fn test_swap_trade_info_calculator_calculations_sell(weth: Token, usdc: Token) {
451 //Real Arbitrum transaction: https://arbiscan.io/tx/0x1fbedacf4a1cc7f76174d905c93d2f56d42335cadb4a782e2d74e3019107286b
452 let raw_data = RawSwapData::new(
453 I256::from_str("193450074461093702").unwrap(),
454 I256::from_str("-691892530").unwrap(),
455 U160::from_str("4739235524363817533004858").unwrap(),
456 );
457
458 let calculator = SwapTradeInfoCalculator::new(&weth, &usdc, raw_data);
459 let result = calculator.compute(None).unwrap();
460 // Its sell as amount0(WETH) > 0 (we send WETH, pool inflow) and amount1 <0 (USDC received, pool outflow)
461 assert_eq!(result.order_side, OrderSide::Sell);
462 assert_eq!(
463 result.quantity_base.as_decimal(),
464 dec!(0.193450074461093702)
465 );
466 assert_eq!(result.quantity_quote.as_decimal(), dec!(691.89253));
467 assert_eq!(result.spot_price.as_decimal(), dec!(3578.1407251651610105));
468 assert_eq!(
469 result.execution_price.as_decimal(),
470 dec!(3576.5947980503469024)
471 );
472 }
473}