Skip to main content

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