Skip to main content

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