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