nautilus_model/defi/tick_map/
tick.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 std::cmp::Ord;
17
18use alloy_primitives::U256;
19
20use crate::defi::tick_map::liquidity_math::liquidity_math_add;
21
22/// Represents a tick in a Uniswap V3-style AMM with liquidity tracking and fee accounting.
23#[cfg_attr(
24    feature = "python",
25    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
26)]
27#[derive(
28    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
29)]
30pub struct PoolTick {
31    /// The referenced tick,
32    pub value: i32,
33    /// Total liquidity referencing this tick.
34    pub liquidity_gross: u128,
35    /// Net liquidity change when crossing this tick.
36    pub liquidity_net: i128,
37    /// Accumulated fees for token0 that have been collected outside this tick.
38    pub fee_growth_outside_0: U256,
39    /// Accumulated fees for token1 that have been collected outside this tick.
40    pub fee_growth_outside_1: U256,
41    /// Indicating whether this tick has been used.
42    pub initialized: bool,
43    /// Last block when this tick was used.
44    pub last_updated_block: u64,
45    /// Count of times this tick was updated.
46    pub updates_count: usize,
47}
48
49impl PoolTick {
50    /// Minimum valid tick value for Uniswap V3 pools.
51    pub const MIN_TICK: i32 = -887272;
52    /// Maximum valid tick value for Uniswap V3 pools.
53    pub const MAX_TICK: i32 = -Self::MIN_TICK;
54
55    /// Creates a new [`PoolTick`] with all specified parameters.
56    #[must_use]
57    pub fn new(
58        value: i32,
59        liquidity_gross: u128,
60        liquidity_net: i128,
61        fee_growth_outside_0: U256,
62        fee_growth_outside_1: U256,
63        initialized: bool,
64        last_updated_block: u64,
65    ) -> Self {
66        Self {
67            value,
68            liquidity_gross,
69            liquidity_net,
70            fee_growth_outside_0,
71            fee_growth_outside_1,
72            initialized,
73            last_updated_block,
74            updates_count: 0,
75        }
76    }
77
78    /// Creates a tick with default values for a given tick value.
79    pub fn from_tick(tick: i32) -> Self {
80        Self::new(tick, 0, 0, U256::ZERO, U256::ZERO, false, 0)
81    }
82
83    /// Updates liquidity amounts when positions are added/removed.
84    pub fn update_liquidity(&mut self, liquidity_delta: i128, upper: bool) -> u128 {
85        let liquidity_gross_before = self.liquidity_gross;
86        self.liquidity_gross = liquidity_math_add(self.liquidity_gross, liquidity_delta);
87
88        // liquidity_net tracks the net change when crossing this tick
89        if upper {
90            self.liquidity_net -= liquidity_delta;
91        } else {
92            self.liquidity_net += liquidity_delta;
93        }
94        self.updates_count += 1;
95
96        liquidity_gross_before
97    }
98
99    /// Resets tick to the default state.
100    pub fn clear(&mut self) {
101        self.liquidity_gross = 0;
102        self.liquidity_net = 0;
103        self.fee_growth_outside_0 = U256::ZERO;
104        self.fee_growth_outside_1 = U256::ZERO;
105        self.initialized = false;
106    }
107
108    /// Checks if the tick is initialized and has liquidity.
109    #[must_use]
110    pub fn is_active(&self) -> bool {
111        self.initialized && self.liquidity_gross > 0
112    }
113
114    /// Updates fee growth outside this tick.
115    pub fn update_fee_growth(&mut self, fee_growth_global_0: U256, fee_growth_global_1: U256) {
116        self.fee_growth_outside_0 = fee_growth_global_0 - self.fee_growth_outside_0;
117        self.fee_growth_outside_1 = fee_growth_global_1 - self.fee_growth_outside_1;
118    }
119
120    /// Gets maximum valid tick for given spacing.
121    pub fn get_max_tick(tick_spacing: i32) -> i32 {
122        // Find the largest tick that is divisible by tick_spacing and <= MAX_TICK
123        (Self::MAX_TICK / tick_spacing) * tick_spacing
124    }
125
126    /// Gets minimum valid tick for given spacing.
127    pub fn get_min_tick(tick_spacing: i32) -> i32 {
128        // Find the smallest tick that is divisible by tick_spacing and >= MIN_TICK
129        (Self::MIN_TICK / tick_spacing) * tick_spacing
130    }
131}
132
133////////////////////////////////////////////////////////////////////////////////
134// Tests
135////////////////////////////////////////////////////////////////////////////////
136
137#[cfg(test)]
138mod tests {
139    use rstest::rstest;
140
141    use super::*;
142
143    #[rstest]
144    fn test_update_liquidity_add_remove() {
145        let mut tick = PoolTick::from_tick(100);
146        tick.initialized = true;
147
148        // Add liquidity
149        tick.update_liquidity(1000, false); // lower tick
150        assert_eq!(tick.liquidity_gross, 1000);
151        assert_eq!(tick.liquidity_net, 1000); // lower tick: net = +delta
152        assert!(tick.is_active());
153
154        // Add more liquidity
155        tick.update_liquidity(500, false);
156        assert_eq!(tick.liquidity_gross, 1500);
157        assert_eq!(tick.liquidity_net, 1500);
158        assert!(tick.is_active());
159
160        // Remove some liquidity
161        tick.update_liquidity(-300, false);
162        assert_eq!(tick.liquidity_gross, 1200);
163        assert_eq!(tick.liquidity_net, 1200);
164        assert!(tick.is_active());
165
166        // Remove all remaining liquidity
167        tick.update_liquidity(-1200, false);
168        assert_eq!(tick.liquidity_gross, 0);
169        assert_eq!(tick.liquidity_net, 0);
170        assert!(!tick.is_active()); // Should not be active when liquidity_gross == 0
171    }
172
173    #[rstest]
174    fn test_update_liquidity_upper_tick() {
175        let mut tick = PoolTick::from_tick(200);
176        tick.initialized = true;
177
178        // Add liquidity (upper tick)
179        tick.update_liquidity(1000, true);
180        assert_eq!(tick.liquidity_gross, 1000);
181        assert_eq!(tick.liquidity_net, -1000); // upper tick: net = -delta
182        assert!(tick.is_active());
183
184        // Remove liquidity (upper tick)
185        tick.update_liquidity(-500, true);
186        assert_eq!(tick.liquidity_gross, 500);
187        assert_eq!(tick.liquidity_net, -500); // upper tick: net = -delta
188        assert!(tick.is_active());
189    }
190
191    #[rstest]
192    fn test_get_max_tick() {
193        // Test with common Uniswap V3 tick spacings
194
195        // Tick spacing 1 (0.01% fee tier)
196        let max_tick_1 = PoolTick::get_max_tick(1);
197        assert_eq!(max_tick_1, 887272); // Should be exactly MAX_TICK since it's divisible by 1
198
199        // Tick spacing 10 (0.05% fee tier)
200        let max_tick_10 = PoolTick::get_max_tick(10);
201        assert_eq!(max_tick_10, 887270); // 887272 / 10 * 10 = 887270
202        assert_eq!(max_tick_10 % 10, 0);
203        assert!(max_tick_10 <= PoolTick::MAX_TICK);
204
205        // Tick spacing 60 (0.3% fee tier)
206        let max_tick_60 = PoolTick::get_max_tick(60);
207        assert_eq!(max_tick_60, 887220); // 887272 / 60 * 60 = 887220
208        assert_eq!(max_tick_60 % 60, 0);
209        assert!(max_tick_60 <= PoolTick::MAX_TICK);
210
211        // Tick spacing 200 (1% fee tier)
212        let max_tick_200 = PoolTick::get_max_tick(200);
213        assert_eq!(max_tick_200, 887200); // 887272 / 200 * 200 = 887200
214        assert_eq!(max_tick_200 % 200, 0);
215        assert!(max_tick_200 <= PoolTick::MAX_TICK);
216    }
217
218    #[rstest]
219    fn test_get_min_tick() {
220        // Test with common Uniswap V3 tick spacings
221
222        // Tick spacing 1 (0.01% fee tier)
223        let min_tick_1 = PoolTick::get_min_tick(1);
224        assert_eq!(min_tick_1, -887272); // Should be exactly MIN_TICK since it's divisible by 1
225
226        // Tick spacing 10 (0.05% fee tier)
227        let min_tick_10 = PoolTick::get_min_tick(10);
228        assert_eq!(min_tick_10, -887270); // -887272 / 10 * 10 = -887270
229        assert_eq!(min_tick_10 % 10, 0);
230        assert!(min_tick_10 >= PoolTick::MIN_TICK);
231
232        // Tick spacing 60 (0.3% fee tier)
233        let min_tick_60 = PoolTick::get_min_tick(60);
234        assert_eq!(min_tick_60, -887220); // -887272 / 60 * 60 = -887220
235        assert_eq!(min_tick_60 % 60, 0);
236        assert!(min_tick_60 >= PoolTick::MIN_TICK);
237
238        // Tick spacing 200 (1% fee tier)
239        let min_tick_200 = PoolTick::get_min_tick(200);
240        assert_eq!(min_tick_200, -887200); // -887272 / 200 * 200 = -887200
241        assert_eq!(min_tick_200 % 200, 0);
242        assert!(min_tick_200 >= PoolTick::MIN_TICK);
243    }
244
245    #[rstest]
246    fn test_tick_spacing_symmetry() {
247        // Test that max and min ticks are symmetric for all common spacings
248        let spacings = [1, 10, 60, 200];
249
250        for spacing in spacings {
251            let max_tick = PoolTick::get_max_tick(spacing);
252            let min_tick = PoolTick::get_min_tick(spacing);
253
254            // Should be symmetric (max = -min)
255            assert_eq!(max_tick, -min_tick, "Asymmetry for spacing {}", spacing);
256
257            // Both should be divisible by spacing
258            assert_eq!(max_tick % spacing, 0);
259            assert_eq!(min_tick % spacing, 0);
260
261            // Should be within bounds
262            assert!(max_tick <= PoolTick::MAX_TICK);
263            assert!(min_tick >= PoolTick::MIN_TICK);
264        }
265    }
266}