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#[cfg(test)]
167mod tests {
168    use alloy_primitives::address;
169    use rstest::rstest;
170
171    use super::*;
172
173    #[rstest]
174    fn test_new_position() {
175        let owner = address!("1234567890123456789012345678901234567890");
176        let tick_lower = -100;
177        let tick_upper = 100;
178        let liquidity = 1000i128;
179
180        let position = PoolPosition::new(owner, tick_lower, tick_upper, liquidity);
181
182        assert_eq!(position.owner, owner);
183        assert_eq!(position.tick_lower, tick_lower);
184        assert_eq!(position.tick_upper, tick_upper);
185        assert_eq!(position.liquidity, liquidity as u128);
186        assert_eq!(position.fee_growth_inside_0_last, U256::ZERO);
187        assert_eq!(position.fee_growth_inside_1_last, U256::ZERO);
188        assert_eq!(position.tokens_owed_0, 0);
189        assert_eq!(position.tokens_owed_1, 0);
190    }
191
192    #[rstest]
193    fn test_get_position_key() {
194        let owner = address!("1234567890123456789012345678901234567890");
195        let tick_lower = -100;
196        let tick_upper = 100;
197
198        let key = PoolPosition::get_position_key(&owner, tick_lower, tick_upper);
199        let expected = format!("{owner:?}:{tick_lower}:{tick_upper}");
200        assert_eq!(key, expected);
201    }
202
203    #[rstest]
204    fn test_update_liquidity_positive() {
205        let owner = address!("1234567890123456789012345678901234567890");
206        let mut position = PoolPosition::new(owner, -100, 100, 1000);
207
208        position.update_liquidity(500);
209        assert_eq!(position.liquidity, 1500);
210    }
211
212    #[rstest]
213    fn test_update_liquidity_negative() {
214        let owner = address!("1234567890123456789012345678901234567890");
215        let mut position = PoolPosition::new(owner, -100, 100, 1000);
216
217        position.update_liquidity(-300);
218        assert_eq!(position.liquidity, 700);
219    }
220
221    #[rstest]
222    fn test_update_liquidity_negative_saturating() {
223        let owner = address!("1234567890123456789012345678901234567890");
224        let mut position = PoolPosition::new(owner, -100, 100, 1000);
225
226        position.update_liquidity(-2000); // More than current liquidity
227        assert_eq!(position.liquidity, 0);
228    }
229
230    #[rstest]
231    fn test_update_fees() {
232        let owner = address!("1234567890123456789012345678901234567890");
233        let mut position = PoolPosition::new(owner, -100, 100, 1000);
234
235        let fee_growth_inside_0 = U256::from(100);
236        let fee_growth_inside_1 = U256::from(200);
237
238        position.update_fees(fee_growth_inside_0, fee_growth_inside_1);
239
240        assert_eq!(position.fee_growth_inside_0_last, fee_growth_inside_0);
241        assert_eq!(position.fee_growth_inside_1_last, fee_growth_inside_1);
242        // With liquidity 1000 and fee growth 100, should earn 100*1000/2^128 ≈ 0 (due to division)
243        // In practice this would be larger numbers
244    }
245
246    #[rstest]
247    fn test_collect_fees() {
248        let owner = address!("1234567890123456789012345678901234567890");
249        let mut position = PoolPosition::new(owner, -100, 100, 1000);
250
251        // Set some owed tokens
252        position.tokens_owed_0 = 100;
253        position.tokens_owed_1 = 200;
254
255        // Collect partial fees
256        position.collect_fees(50, 150);
257
258        assert_eq!(position.total_amount0_collected, 50);
259        assert_eq!(position.total_amount1_collected, 150);
260        assert_eq!(position.tokens_owed_0, 50);
261        assert_eq!(position.tokens_owed_1, 50);
262    }
263
264    #[rstest]
265    fn test_collect_fees_more_than_owed() {
266        let owner = address!("1234567890123456789012345678901234567890");
267        let mut position = PoolPosition::new(owner, -100, 100, 1000);
268
269        position.tokens_owed_0 = 100;
270        position.tokens_owed_1 = 200;
271
272        // Try to collect more than owed
273        position.collect_fees(150, 300);
274
275        assert_eq!(position.total_amount0_collected, 100); // Can only collect what's owed
276        assert_eq!(position.total_amount1_collected, 200);
277        assert_eq!(position.tokens_owed_0, 0);
278        assert_eq!(position.tokens_owed_1, 0);
279    }
280
281    #[rstest]
282    fn test_is_empty() {
283        let owner = address!("1234567890123456789012345678901234567890");
284        let mut position = PoolPosition::new(owner, -100, 100, 0);
285
286        assert!(position.is_empty());
287
288        position.liquidity = 100;
289        assert!(!position.is_empty());
290
291        position.liquidity = 0;
292        position.tokens_owed_0 = 50;
293        assert!(!position.is_empty());
294
295        position.tokens_owed_0 = 0;
296        position.tokens_owed_1 = 25;
297        assert!(!position.is_empty());
298
299        position.tokens_owed_1 = 0;
300        assert!(position.is_empty());
301    }
302}