nautilus_model/defi/pool_analysis/
position.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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, U256};
17use serde::{Deserialize, Serialize};
18
19use crate::defi::tick_map::full_math::{FullMath, Q128};
20
21/// Represents a concentrated liquidity position in a DEX pool.
22///
23/// This struct tracks a specific liquidity provider's position within a price range,
24/// including the liquidity amount, fee accumulation, and token deposits/withdrawals.
25#[cfg_attr(
26    feature = "python",
27    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
28)]
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct PoolPosition {
31    /// The owner of the position
32    pub owner: Address,
33    /// The lower tick boundary of the position
34    pub tick_lower: i32,
35    /// The upper tick boundary of the position
36    pub tick_upper: i32,
37    /// The amount of liquidity in the position
38    pub liquidity: u128,
39    /// Fee growth per unit of liquidity for token0 as of the last action on the position
40    pub fee_growth_inside_0_last: U256,
41    /// Fee growth per unit of liquidity for token1 as of the last action on the position
42    pub fee_growth_inside_1_last: U256,
43    /// The fees owed to the position for token0
44    pub tokens_owed_0: u128,
45    /// The fees owed to the position for token1
46    pub tokens_owed_1: u128,
47    /// Total amount of token0 deposited into this position
48    pub total_amount0_deposited: U256,
49    /// Total amount of token1 deposited into this position
50    pub total_amount1_deposited: U256,
51    /// Total amount of token0 collected from this position
52    pub total_amount0_collected: u128,
53    /// Total amount of token1 collected from this position
54    pub total_amount1_collected: u128,
55}
56
57impl PoolPosition {
58    /// Creates a [`PoolPosition`] with the specified parameters.
59    #[must_use]
60    pub fn new(owner: Address, tick_lower: i32, tick_upper: i32, liquidity: i128) -> Self {
61        Self {
62            owner,
63            tick_lower,
64            tick_upper,
65            liquidity: liquidity.unsigned_abs(),
66            fee_growth_inside_0_last: U256::ZERO,
67            fee_growth_inside_1_last: U256::ZERO,
68            tokens_owed_0: 0,
69            tokens_owed_1: 0,
70            total_amount0_deposited: U256::ZERO,
71            total_amount1_deposited: U256::ZERO,
72            total_amount0_collected: 0,
73            total_amount1_collected: 0,
74        }
75    }
76
77    /// Generates a unique string key for a position based on owner and tick range.
78    #[must_use]
79    pub fn get_position_key(owner: &Address, tick_lower: i32, tick_upper: i32) -> String {
80        format!("{}:{}:{}", owner, tick_lower, tick_upper)
81    }
82
83    /// Updates the liquidity amount by the given delta.
84    ///
85    /// Positive values increase liquidity, negative values decrease it.
86    /// Uses saturating arithmetic to prevent underflow.
87    pub fn update_liquidity(&mut self, liquidity_delta: i128) {
88        if liquidity_delta < 0 {
89            self.liquidity = self.liquidity.saturating_sub((-liquidity_delta) as u128);
90        } else {
91            self.liquidity = self.liquidity.saturating_add(liquidity_delta as u128);
92        }
93    }
94
95    /// Updates the position's fee tracking based on current fee growth inside the position's range.
96    ///
97    /// Calculates the fees earned since the last update and adds them to tokens_owed.
98    /// Updates the last known fee growth values for future calculations.
99    pub fn update_fees(&mut self, fee_growth_inside_0: U256, fee_growth_inside_1: U256) {
100        if self.liquidity > 0 {
101            // Calculate fee deltas
102            let fee_delta_0 = fee_growth_inside_0.saturating_sub(self.fee_growth_inside_0_last);
103            let fee_delta_1 = fee_growth_inside_1.saturating_sub(self.fee_growth_inside_1_last);
104
105            let tokens_owed_0_full =
106                FullMath::mul_div(fee_delta_0, U256::from(self.liquidity), Q128)
107                    .unwrap_or(U256::ZERO);
108
109            let tokens_owed_1_full =
110                FullMath::mul_div(fee_delta_1, U256::from(self.liquidity), Q128)
111                    .unwrap_or(U256::ZERO);
112
113            self.tokens_owed_0 = self
114                .tokens_owed_0
115                .wrapping_add(FullMath::truncate_to_u128(tokens_owed_0_full));
116            self.tokens_owed_1 = self
117                .tokens_owed_1
118                .wrapping_add(FullMath::truncate_to_u128(tokens_owed_1_full));
119        }
120
121        self.fee_growth_inside_0_last = fee_growth_inside_0;
122        self.fee_growth_inside_1_last = fee_growth_inside_1;
123    }
124
125    /// Collects fees owed to the position, up to the requested amounts.
126    ///
127    /// Reduces tokens_owed by the collected amounts and tracks total collections.
128    /// Cannot collect more than what is currently owed.
129    pub fn collect_fees(&mut self, amount0: u128, amount1: u128) {
130        let collect_amount_0 = amount0.min(self.tokens_owed_0);
131        let collect_amount_1 = amount1.min(self.tokens_owed_1);
132
133        self.tokens_owed_0 -= collect_amount_0;
134        self.tokens_owed_1 -= collect_amount_1;
135
136        self.total_amount0_collected += collect_amount_0;
137        self.total_amount1_collected += collect_amount_1;
138    }
139
140    /// Updates position token amounts based on liquidity delta.
141    ///
142    /// For positive liquidity delta (mint), tracks deposited amounts.
143    /// For negative liquidity delta (burn), adds amounts to tokens owed.
144    pub fn update_amounts(&mut self, liquidity_delta: i128, amount0: U256, amount1: U256) {
145        if liquidity_delta > 0 {
146            // Mint: track deposited amounts
147            self.total_amount0_deposited += amount0;
148            self.total_amount1_deposited += amount1;
149        } else {
150            self.tokens_owed_0 = self
151                .tokens_owed_0
152                .wrapping_add(FullMath::truncate_to_u128(amount0));
153            self.tokens_owed_1 = self
154                .tokens_owed_1
155                .wrapping_add(FullMath::truncate_to_u128(amount1));
156        }
157    }
158
159    /// Checks if the position is completely empty.
160    #[must_use]
161    pub fn is_empty(&self) -> bool {
162        self.liquidity == 0 && self.tokens_owed_0 == 0 && self.tokens_owed_1 == 0
163    }
164}
165
166////////////////////////////////////////////////////////////////////////////////
167// Tests
168////////////////////////////////////////////////////////////////////////////////
169
170#[cfg(test)]
171mod tests {
172    use alloy_primitives::address;
173
174    use super::*;
175
176    #[test]
177    fn test_new_position() {
178        let owner = address!("1234567890123456789012345678901234567890");
179        let tick_lower = -100;
180        let tick_upper = 100;
181        let liquidity = 1000i128;
182
183        let position = PoolPosition::new(owner, tick_lower, tick_upper, liquidity);
184
185        assert_eq!(position.owner, owner);
186        assert_eq!(position.tick_lower, tick_lower);
187        assert_eq!(position.tick_upper, tick_upper);
188        assert_eq!(position.liquidity, liquidity as u128);
189        assert_eq!(position.fee_growth_inside_0_last, U256::ZERO);
190        assert_eq!(position.fee_growth_inside_1_last, U256::ZERO);
191        assert_eq!(position.tokens_owed_0, 0);
192        assert_eq!(position.tokens_owed_1, 0);
193    }
194
195    #[test]
196    fn test_get_position_key() {
197        let owner = address!("1234567890123456789012345678901234567890");
198        let tick_lower = -100;
199        let tick_upper = 100;
200
201        let key = PoolPosition::get_position_key(&owner, tick_lower, tick_upper);
202        let expected = format!("{:?}:{}:{}", owner, tick_lower, tick_upper);
203        assert_eq!(key, expected);
204    }
205
206    #[test]
207    fn test_update_liquidity_positive() {
208        let owner = address!("1234567890123456789012345678901234567890");
209        let mut position = PoolPosition::new(owner, -100, 100, 1000);
210
211        position.update_liquidity(500);
212        assert_eq!(position.liquidity, 1500);
213    }
214
215    #[test]
216    fn test_update_liquidity_negative() {
217        let owner = address!("1234567890123456789012345678901234567890");
218        let mut position = PoolPosition::new(owner, -100, 100, 1000);
219
220        position.update_liquidity(-300);
221        assert_eq!(position.liquidity, 700);
222    }
223
224    #[test]
225    fn test_update_liquidity_negative_saturating() {
226        let owner = address!("1234567890123456789012345678901234567890");
227        let mut position = PoolPosition::new(owner, -100, 100, 1000);
228
229        position.update_liquidity(-2000); // More than current liquidity
230        assert_eq!(position.liquidity, 0);
231    }
232
233    #[test]
234    fn test_update_fees() {
235        let owner = address!("1234567890123456789012345678901234567890");
236        let mut position = PoolPosition::new(owner, -100, 100, 1000);
237
238        let fee_growth_inside_0 = U256::from(100);
239        let fee_growth_inside_1 = U256::from(200);
240
241        position.update_fees(fee_growth_inside_0, fee_growth_inside_1);
242
243        assert_eq!(position.fee_growth_inside_0_last, fee_growth_inside_0);
244        assert_eq!(position.fee_growth_inside_1_last, fee_growth_inside_1);
245        // With liquidity 1000 and fee growth 100, should earn 100*1000/2^128 ≈ 0 (due to division)
246        // In practice this would be larger numbers
247    }
248
249    #[test]
250    fn test_collect_fees() {
251        let owner = address!("1234567890123456789012345678901234567890");
252        let mut position = PoolPosition::new(owner, -100, 100, 1000);
253
254        // Set some owed tokens
255        position.tokens_owed_0 = 100;
256        position.tokens_owed_1 = 200;
257
258        // Collect partial fees
259        position.collect_fees(50, 150);
260
261        assert_eq!(position.total_amount0_collected, 50);
262        assert_eq!(position.total_amount1_collected, 150);
263        assert_eq!(position.tokens_owed_0, 50);
264        assert_eq!(position.tokens_owed_1, 50);
265    }
266
267    #[test]
268    fn test_collect_fees_more_than_owed() {
269        let owner = address!("1234567890123456789012345678901234567890");
270        let mut position = PoolPosition::new(owner, -100, 100, 1000);
271
272        position.tokens_owed_0 = 100;
273        position.tokens_owed_1 = 200;
274
275        // Try to collect more than owed
276        position.collect_fees(150, 300);
277
278        assert_eq!(position.total_amount0_collected, 100); // Can only collect what's owed
279        assert_eq!(position.total_amount1_collected, 200);
280        assert_eq!(position.tokens_owed_0, 0);
281        assert_eq!(position.tokens_owed_1, 0);
282    }
283
284    #[test]
285    fn test_is_empty() {
286        let owner = address!("1234567890123456789012345678901234567890");
287        let mut position = PoolPosition::new(owner, -100, 100, 0);
288
289        assert!(position.is_empty());
290
291        position.liquidity = 100;
292        assert!(!position.is_empty());
293
294        position.liquidity = 0;
295        position.tokens_owed_0 = 50;
296        assert!(!position.is_empty());
297
298        position.tokens_owed_0 = 0;
299        position.tokens_owed_1 = 25;
300        assert!(!position.is_empty());
301
302        position.tokens_owed_1 = 0;
303        assert!(position.is_empty());
304    }
305}