Skip to main content

nautilus_model/python/types/
quantity.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::{
17    collections::hash_map::DefaultHasher,
18    hash::{Hash, Hasher},
19    ops::Neg,
20    str::FromStr,
21};
22
23use nautilus_core::python::{get_pytype_name, to_pytype_err, to_pyvalue_err};
24use pyo3::{basic::CompareOp, conversion::IntoPyObjectExt, prelude::*, types::PyFloat};
25use rust_decimal::{Decimal, RoundingStrategy};
26
27use crate::types::{Quantity, quantity::QuantityRaw};
28
29#[pymethods]
30#[pyo3_stub_gen::derive::gen_stub_pymethods]
31impl Quantity {
32    /// Represents a quantity with a non-negative value and specified precision.
33    ///
34    /// Capable of storing either a whole number (no decimal places) of 'contracts'
35    /// or 'shares' (instruments denominated in whole units) or a decimal value
36    /// containing decimal places for instruments denominated in fractional units.
37    ///
38    /// Handles up to `FIXED_PRECISION` decimals of precision.
39    ///
40    /// - `QUANTITY_MAX` - Maximum representable quantity value.
41    /// - `QUANTITY_MIN` - 0 (non-negative values only).
42    #[new]
43    fn py_new(value: f64, precision: u8) -> PyResult<Self> {
44        Self::new_checked(value, precision).map_err(to_pyvalue_err)
45    }
46
47    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
48        let from_raw = py.get_type::<Self>().getattr("from_raw")?;
49        let args = (self.raw, self.precision).into_py_any(py)?;
50        (from_raw, args).into_py_any(py)
51    }
52
53    fn __richcmp__(
54        &self,
55        other: &Bound<'_, PyAny>,
56        op: CompareOp,
57        py: Python<'_>,
58    ) -> PyResult<Py<PyAny>> {
59        if let Ok(other_qty) = other.extract::<Self>() {
60            let result = match op {
61                CompareOp::Eq => self.eq(&other_qty),
62                CompareOp::Ne => self.ne(&other_qty),
63                CompareOp::Ge => self.ge(&other_qty),
64                CompareOp::Gt => self.gt(&other_qty),
65                CompareOp::Le => self.le(&other_qty),
66                CompareOp::Lt => self.lt(&other_qty),
67            };
68            result.into_py_any(py)
69        } else if let Ok(other_dec) = other.extract::<Decimal>() {
70            let result = match op {
71                CompareOp::Eq => self.as_decimal() == other_dec,
72                CompareOp::Ne => self.as_decimal() != other_dec,
73                CompareOp::Ge => self.as_decimal() >= other_dec,
74                CompareOp::Gt => self.as_decimal() > other_dec,
75                CompareOp::Le => self.as_decimal() <= other_dec,
76                CompareOp::Lt => self.as_decimal() < other_dec,
77            };
78            result.into_py_any(py)
79        } else {
80            Ok(py.NotImplemented())
81        }
82    }
83
84    fn __hash__(&self) -> isize {
85        let mut h = DefaultHasher::new();
86        self.hash(&mut h);
87        h.finish() as isize
88    }
89
90    fn __add__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
91        if other.is_instance_of::<PyFloat>() {
92            let other_float: f64 = other.extract()?;
93            (self.as_f64() + other_float).into_py_any(py)
94        } else if let Ok(other_qty) = other.extract::<Self>() {
95            (*self + other_qty).into_py_any(py)
96        } else if let Ok(other_dec) = other.extract::<Decimal>() {
97            (self.as_decimal() + other_dec).into_py_any(py)
98        } else {
99            let pytype_name = get_pytype_name(other)?;
100            Err(to_pytype_err(format!(
101                "Unsupported type for __add__, was `{pytype_name}`"
102            )))
103        }
104    }
105
106    fn __radd__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
107        if other.is_instance_of::<PyFloat>() {
108            let other_float: f64 = other.extract()?;
109            (other_float + self.as_f64()).into_py_any(py)
110        } else if let Ok(other_qty) = other.extract::<Self>() {
111            (other_qty + *self).into_py_any(py)
112        } else if let Ok(other_dec) = other.extract::<Decimal>() {
113            (other_dec + self.as_decimal()).into_py_any(py)
114        } else {
115            let pytype_name = get_pytype_name(other)?;
116            Err(to_pytype_err(format!(
117                "Unsupported type for __radd__, was `{pytype_name}`"
118            )))
119        }
120    }
121
122    fn __sub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
123        if other.is_instance_of::<PyFloat>() {
124            let other_float: f64 = other.extract()?;
125            (self.as_f64() - other_float).into_py_any(py)
126        } else if let Ok(other_qty) = other.extract::<Self>() {
127            if other_qty.raw > self.raw {
128                return Err(to_pyvalue_err(format!(
129                    "Quantity subtraction would result in negative value: {self} - {other_qty}"
130                )));
131            }
132            (*self - other_qty).into_py_any(py)
133        } else if let Ok(other_dec) = other.extract::<Decimal>() {
134            (self.as_decimal() - other_dec).into_py_any(py)
135        } else {
136            let pytype_name = get_pytype_name(other)?;
137            Err(to_pytype_err(format!(
138                "Unsupported type for __sub__, was `{pytype_name}`"
139            )))
140        }
141    }
142
143    fn __rsub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
144        if other.is_instance_of::<PyFloat>() {
145            let other_float: f64 = other.extract()?;
146            (other_float - self.as_f64()).into_py_any(py)
147        } else if let Ok(other_qty) = other.extract::<Self>() {
148            if self.raw > other_qty.raw {
149                return Err(to_pyvalue_err(format!(
150                    "Quantity subtraction would result in negative value: {other_qty} - {self}"
151                )));
152            }
153            (other_qty - *self).into_py_any(py)
154        } else if let Ok(other_dec) = other.extract::<Decimal>() {
155            (other_dec - self.as_decimal()).into_py_any(py)
156        } else {
157            let pytype_name = get_pytype_name(other)?;
158            Err(to_pytype_err(format!(
159                "Unsupported type for __rsub__, was `{pytype_name}`"
160            )))
161        }
162    }
163
164    fn __mul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
165        if other.is_instance_of::<PyFloat>() {
166            let other_float: f64 = other.extract()?;
167            (self.as_f64() * other_float).into_py_any(py)
168        } else if let Ok(other_qty) = other.extract::<Self>() {
169            (self.as_decimal() * other_qty.as_decimal()).into_py_any(py)
170        } else if let Ok(other_dec) = other.extract::<Decimal>() {
171            (self.as_decimal() * other_dec).into_py_any(py)
172        } else {
173            let pytype_name = get_pytype_name(other)?;
174            Err(to_pytype_err(format!(
175                "Unsupported type for __mul__, was `{pytype_name}`"
176            )))
177        }
178    }
179
180    fn __rmul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
181        if other.is_instance_of::<PyFloat>() {
182            let other_float: f64 = other.extract()?;
183            (other_float * self.as_f64()).into_py_any(py)
184        } else if let Ok(other_qty) = other.extract::<Self>() {
185            (other_qty.as_decimal() * self.as_decimal()).into_py_any(py)
186        } else if let Ok(other_dec) = other.extract::<Decimal>() {
187            (other_dec * self.as_decimal()).into_py_any(py)
188        } else {
189            let pytype_name = get_pytype_name(other)?;
190            Err(to_pytype_err(format!(
191                "Unsupported type for __rmul__, was `{pytype_name}`"
192            )))
193        }
194    }
195
196    fn __truediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
197        if other.is_instance_of::<PyFloat>() {
198            let other_float: f64 = other.extract()?;
199            (self.as_f64() / other_float).into_py_any(py)
200        } else if let Ok(other_qty) = other.extract::<Self>() {
201            (self.as_decimal() / other_qty.as_decimal()).into_py_any(py)
202        } else if let Ok(other_dec) = other.extract::<Decimal>() {
203            (self.as_decimal() / other_dec).into_py_any(py)
204        } else {
205            let pytype_name = get_pytype_name(other)?;
206            Err(to_pytype_err(format!(
207                "Unsupported type for __truediv__, was `{pytype_name}`"
208            )))
209        }
210    }
211
212    fn __rtruediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
213        if other.is_instance_of::<PyFloat>() {
214            let other_float: f64 = other.extract()?;
215            (other_float / self.as_f64()).into_py_any(py)
216        } else if let Ok(other_qty) = other.extract::<Self>() {
217            (other_qty.as_decimal() / self.as_decimal()).into_py_any(py)
218        } else if let Ok(other_dec) = other.extract::<Decimal>() {
219            (other_dec / self.as_decimal()).into_py_any(py)
220        } else {
221            let pytype_name = get_pytype_name(other)?;
222            Err(to_pytype_err(format!(
223                "Unsupported type for __rtruediv__, was `{pytype_name}`"
224            )))
225        }
226    }
227
228    fn __floordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
229        if other.is_instance_of::<PyFloat>() {
230            let other_float: f64 = other.extract()?;
231            (self.as_f64() / other_float).floor().into_py_any(py)
232        } else if let Ok(other_qty) = other.extract::<Self>() {
233            (self.as_decimal() / other_qty.as_decimal())
234                .floor()
235                .into_py_any(py)
236        } else if let Ok(other_dec) = other.extract::<Decimal>() {
237            (self.as_decimal() / other_dec).floor().into_py_any(py)
238        } else {
239            let pytype_name = get_pytype_name(other)?;
240            Err(to_pytype_err(format!(
241                "Unsupported type for __floordiv__, was `{pytype_name}`"
242            )))
243        }
244    }
245
246    fn __rfloordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
247        if other.is_instance_of::<PyFloat>() {
248            let other_float: f64 = other.extract()?;
249            (other_float / self.as_f64()).floor().into_py_any(py)
250        } else if let Ok(other_qty) = other.extract::<Self>() {
251            (other_qty.as_decimal() / self.as_decimal())
252                .floor()
253                .into_py_any(py)
254        } else if let Ok(other_dec) = other.extract::<Decimal>() {
255            (other_dec / self.as_decimal()).floor().into_py_any(py)
256        } else {
257            let pytype_name = get_pytype_name(other)?;
258            Err(to_pytype_err(format!(
259                "Unsupported type for __rfloordiv__, was `{pytype_name}`"
260            )))
261        }
262    }
263
264    fn __mod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
265        if other.is_instance_of::<PyFloat>() {
266            let other_float: f64 = other.extract()?;
267            (self.as_f64() % other_float).into_py_any(py)
268        } else if let Ok(other_qty) = other.extract::<Self>() {
269            (self.as_decimal() % other_qty.as_decimal()).into_py_any(py)
270        } else if let Ok(other_dec) = other.extract::<Decimal>() {
271            (self.as_decimal() % other_dec).into_py_any(py)
272        } else {
273            let pytype_name = get_pytype_name(other)?;
274            Err(to_pytype_err(format!(
275                "Unsupported type for __mod__, was `{pytype_name}`"
276            )))
277        }
278    }
279
280    fn __rmod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
281        if other.is_instance_of::<PyFloat>() {
282            let other_float: f64 = other.extract()?;
283            (other_float % self.as_f64()).into_py_any(py)
284        } else if let Ok(other_qty) = other.extract::<Self>() {
285            (other_qty.as_decimal() % self.as_decimal()).into_py_any(py)
286        } else if let Ok(other_dec) = other.extract::<Decimal>() {
287            (other_dec % self.as_decimal()).into_py_any(py)
288        } else {
289            let pytype_name = get_pytype_name(other)?;
290            Err(to_pytype_err(format!(
291                "Unsupported type for __rmod__, was `{pytype_name}`"
292            )))
293        }
294    }
295
296    fn __neg__(&self) -> Decimal {
297        self.as_decimal().neg()
298    }
299
300    fn __pos__(&self) -> Self {
301        *self
302    }
303
304    fn __abs__(&self) -> Self {
305        *self
306    }
307
308    fn __int__(&self) -> u64 {
309        self.as_f64() as u64
310    }
311
312    fn __float__(&self) -> f64 {
313        self.as_f64()
314    }
315
316    #[pyo3(signature = (ndigits=None))]
317    fn __round__(&self, ndigits: Option<u32>) -> Decimal {
318        self.as_decimal()
319            .round_dp_with_strategy(ndigits.unwrap_or(0), RoundingStrategy::MidpointNearestEven)
320    }
321
322    fn __repr__(&self) -> String {
323        format!("{self:?}")
324    }
325
326    fn __str__(&self) -> String {
327        self.to_string()
328    }
329
330    #[getter]
331    fn raw(&self) -> QuantityRaw {
332        self.raw
333    }
334
335    #[getter]
336    fn precision(&self) -> u8 {
337        self.precision
338    }
339
340    /// Creates a new `Quantity` instance from the given `raw` fixed-point value and `precision`.
341    #[staticmethod]
342    #[pyo3(name = "from_raw")]
343    fn py_from_raw(raw: QuantityRaw, precision: u8) -> Self {
344        Self::from_raw(raw, precision)
345    }
346
347    /// Creates a new `Quantity` instance with a value of zero with the given `precision`.
348    #[staticmethod]
349    #[pyo3(name = "zero")]
350    #[pyo3(signature = (precision = 0))]
351    fn py_zero(precision: u8) -> PyResult<Self> {
352        Self::new_checked(0.0, precision).map_err(to_pyvalue_err)
353    }
354
355    #[staticmethod]
356    #[pyo3(name = "from_int")]
357    fn py_from_int(value: u64) -> PyResult<Self> {
358        Self::new_checked(value as f64, 0).map_err(to_pyvalue_err)
359    }
360
361    #[staticmethod]
362    #[pyo3(name = "from_str")]
363    fn py_from_str(value: &str) -> PyResult<Self> {
364        Self::from_str(value).map_err(to_pyvalue_err)
365    }
366
367    /// Creates a new `Quantity` from a `Decimal` value with precision inferred from the decimal's scale.
368    ///
369    /// The precision is determined by the scale of the decimal (number of decimal places).
370    /// The value is rounded to the inferred precision using banker's rounding (round half to even).
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if:
375    /// - The inferred precision exceeds `FIXED_PRECISION`.
376    /// - The decimal value cannot be converted to the raw representation.
377    /// - Overflow occurs during scaling.
378    #[staticmethod]
379    #[pyo3(name = "from_decimal")]
380    fn py_from_decimal(decimal: Decimal) -> PyResult<Self> {
381        Self::from_decimal(decimal).map_err(to_pyvalue_err)
382    }
383
384    /// Creates a new `Quantity` from a `Decimal` value with specified precision.
385    ///
386    /// Uses pure integer arithmetic on the Decimal's mantissa and scale for fast conversion.
387    /// The value is rounded to the specified precision using banker's rounding (round half to even).
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if:
392    /// - `precision` exceeds `FIXED_PRECISION`.
393    /// - The decimal value is negative.
394    /// - The decimal value cannot be converted to the raw representation.
395    /// - Overflow occurs during scaling.
396    #[staticmethod]
397    #[pyo3(name = "from_decimal_dp")]
398    fn py_from_decimal_dp(decimal: Decimal, precision: u8) -> PyResult<Self> {
399        Self::from_decimal_dp(decimal, precision).map_err(to_pyvalue_err)
400    }
401
402    /// Creates a new `Quantity` from a mantissa/exponent pair using pure integer arithmetic.
403    ///
404    /// The value is `mantissa * 10^exponent`. This avoids all floating-point and Decimal
405    /// operations, making it ideal for exchange data that arrives as mantissa/exponent pairs.
406    #[staticmethod]
407    #[pyo3(name = "from_mantissa_exponent")]
408    fn py_from_mantissa_exponent(mantissa: u64, exponent: i8, precision: u8) -> Self {
409        Self::from_mantissa_exponent(mantissa, exponent, precision)
410    }
411
412    /// Returns `true` if the value of this instance is zero.
413    #[pyo3(name = "is_zero")]
414    fn py_is_zero(&self) -> bool {
415        self.is_zero()
416    }
417
418    /// Returns `true` if the value of this instance is position (> 0).
419    #[pyo3(name = "is_positive")]
420    fn py_is_positive(&self) -> bool {
421        self.is_positive()
422    }
423
424    /// Returns the value of this instance as a `Decimal`.
425    #[pyo3(name = "as_decimal")]
426    fn py_as_decimal(&self) -> Decimal {
427        self.as_decimal()
428    }
429
430    #[pyo3(name = "as_double")]
431    fn py_as_double(&self) -> f64 {
432        self.as_f64()
433    }
434
435    #[pyo3(name = "to_formatted_str")]
436    fn py_to_formatted_str(&self) -> String {
437        self.to_formatted_string()
438    }
439
440    /// Computes a saturating subtraction between two quantities, logging when clamped.
441    ///
442    /// When `rhs` is greater than `self`, the result is clamped to zero and a warning is logged.
443    /// Precision follows the `Sub` implementation: uses the maximum precision of both operands.
444    #[pyo3(name = "saturating_sub")]
445    fn py_saturating_sub(&self, other: Self) -> Self {
446        self.saturating_sub(other)
447    }
448}