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////////////////////////////////////////////////////////////////////////////////
163// Tests
164////////////////////////////////////////////////////////////////////////////////
165
166#[cfg(test)]
167mod tests {
168    use rstest::rstest;
169
170    use super::*;
171
172    #[rstest]
173    fn test_update_liquidity_add_remove() {
174        let mut tick = PoolTick::from_tick(100);
175        tick.initialized = true;
176
177        // Add liquidity
178        tick.update_liquidity(1000, false); // lower tick
179        assert_eq!(tick.liquidity_gross, 1000);
180        assert_eq!(tick.liquidity_net, 1000); // lower tick: net = +delta
181        assert!(tick.is_active());
182
183        // Add more liquidity
184        tick.update_liquidity(500, false);
185        assert_eq!(tick.liquidity_gross, 1500);
186        assert_eq!(tick.liquidity_net, 1500);
187        assert!(tick.is_active());
188
189        // Remove some liquidity
190        tick.update_liquidity(-300, false);
191        assert_eq!(tick.liquidity_gross, 1200);
192        assert_eq!(tick.liquidity_net, 1200);
193        assert!(tick.is_active());
194
195        // Remove all remaining liquidity
196        tick.update_liquidity(-1200, false);
197        assert_eq!(tick.liquidity_gross, 0);
198        assert_eq!(tick.liquidity_net, 0);
199        assert!(!tick.is_active()); // Should not be active when liquidity_gross == 0
200    }
201
202    #[rstest]
203    fn test_update_liquidity_upper_tick() {
204        let mut tick = PoolTick::from_tick(200);
205        tick.initialized = true;
206
207        // Add liquidity (upper tick)
208        tick.update_liquidity(1000, true);
209        assert_eq!(tick.liquidity_gross, 1000);
210        assert_eq!(tick.liquidity_net, -1000); // upper tick: net = -delta
211        assert!(tick.is_active());
212
213        // Remove liquidity (upper tick)
214        tick.update_liquidity(-500, true);
215        assert_eq!(tick.liquidity_gross, 500);
216        assert_eq!(tick.liquidity_net, -500); // upper tick: net = -delta
217        assert!(tick.is_active());
218    }
219
220    #[rstest]
221    fn test_get_max_tick() {
222        // Test with common Uniswap V3 tick spacings
223
224        // Tick spacing 1 (0.01% fee tier)
225        let max_tick_1 = PoolTick::get_max_tick(1);
226        assert_eq!(max_tick_1, 887272); // Should be exactly MAX_TICK since it's divisible by 1
227
228        // Tick spacing 10 (0.05% fee tier)
229        let max_tick_10 = PoolTick::get_max_tick(10);
230        assert_eq!(max_tick_10, 887270); // 887272 / 10 * 10 = 887270
231        assert_eq!(max_tick_10 % 10, 0);
232        assert!(max_tick_10 <= PoolTick::MAX_TICK);
233
234        // Tick spacing 60 (0.3% fee tier)
235        let max_tick_60 = PoolTick::get_max_tick(60);
236        assert_eq!(max_tick_60, 887220); // 887272 / 60 * 60 = 887220
237        assert_eq!(max_tick_60 % 60, 0);
238        assert!(max_tick_60 <= PoolTick::MAX_TICK);
239
240        // Tick spacing 200 (1% fee tier)
241        let max_tick_200 = PoolTick::get_max_tick(200);
242        assert_eq!(max_tick_200, 887200); // 887272 / 200 * 200 = 887200
243        assert_eq!(max_tick_200 % 200, 0);
244        assert!(max_tick_200 <= PoolTick::MAX_TICK);
245    }
246
247    #[rstest]
248    fn test_get_min_tick() {
249        // Test with common Uniswap V3 tick spacings
250
251        // Tick spacing 1 (0.01% fee tier)
252        let min_tick_1 = PoolTick::get_min_tick(1);
253        assert_eq!(min_tick_1, -887272); // Should be exactly MIN_TICK since it's divisible by 1
254
255        // Tick spacing 10 (0.05% fee tier)
256        let min_tick_10 = PoolTick::get_min_tick(10);
257        assert_eq!(min_tick_10, -887270); // -887272 / 10 * 10 = -887270
258        assert_eq!(min_tick_10 % 10, 0);
259        assert!(min_tick_10 >= PoolTick::MIN_TICK);
260
261        // Tick spacing 60 (0.3% fee tier)
262        let min_tick_60 = PoolTick::get_min_tick(60);
263        assert_eq!(min_tick_60, -887220); // -887272 / 60 * 60 = -887220
264        assert_eq!(min_tick_60 % 60, 0);
265        assert!(min_tick_60 >= PoolTick::MIN_TICK);
266
267        // Tick spacing 200 (1% fee tier)
268        let min_tick_200 = PoolTick::get_min_tick(200);
269        assert_eq!(min_tick_200, -887200); // -887272 / 200 * 200 = -887200
270        assert_eq!(min_tick_200 % 200, 0);
271        assert!(min_tick_200 >= PoolTick::MIN_TICK);
272    }
273
274    #[rstest]
275    fn test_tick_spacing_symmetry() {
276        // Test that max and min ticks are symmetric for all common spacings
277        let spacings = [1, 10, 60, 200];
278
279        for spacing in spacings {
280            let max_tick = PoolTick::get_max_tick(spacing);
281            let min_tick = PoolTick::get_min_tick(spacing);
282
283            // Should be symmetric (max = -min)
284            assert_eq!(max_tick, -min_tick, "Asymmetry for spacing {}", spacing);
285
286            // Both should be divisible by spacing
287            assert_eq!(max_tick % spacing, 0);
288            assert_eq!(min_tick % spacing, 0);
289
290            // Should be within bounds
291            assert!(max_tick <= PoolTick::MAX_TICK);
292            assert!(min_tick >= PoolTick::MIN_TICK);
293        }
294    }
295}