Skip to main content

nautilus_risk/python/
sizing.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
16//! Python bindings for position sizing.
17
18use nautilus_core::{
19    correctness::{check_equal, check_positive_i64},
20    python::{
21        correctness_error_to_pyvalue_err, to_pynotimplemented_err, to_pytype_err, to_pyvalue_err,
22    },
23};
24use nautilus_model::{
25    instruments::{Instrument, InstrumentAny},
26    python::instruments::pyobject_to_instrument_any,
27    types::{Money, Price, Quantity},
28};
29use pyo3::{
30    prelude::*,
31    sync::PyOnceLock,
32    types::{PyAny, PyType},
33};
34use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
35use rust_decimal::Decimal;
36
37use crate::sizing::calculate_fixed_risk_position_size;
38
39/// Base class for position sizers.
40#[allow(missing_debug_implementations)]
41#[gen_stub_pyclass(module = "nautilus_trader.risk")]
42#[pyclass(module = "nautilus_trader.risk", subclass)]
43pub struct PositionSizer {
44    instrument: Py<PyAny>,
45    instrument_any: InstrumentAny,
46}
47
48#[gen_stub_pymethods]
49#[pymethods]
50impl PositionSizer {
51    #[new]
52    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
53    fn py_new(py: Python<'_>, instrument: Py<PyAny>) -> PyResult<Self> {
54        Self::from_instrument(py, instrument)
55    }
56
57    /// Returns the instrument used for position sizing.
58    #[getter]
59    fn instrument<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
60        self.instrument.bind(py).clone()
61    }
62
63    /// Updates the instrument used for position sizing.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if `instrument` is invalid or its ID differs from the
68    /// current instrument ID.
69    fn update_instrument(&mut self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
70        let updated = Self::from_instrument(py, instrument)?;
71        check_equal(
72            &self.instrument_any.id(),
73            &updated.instrument_any.id(),
74            "instrument.id",
75            "instrument.id",
76        )
77        .map_err(correctness_error_to_pyvalue_err)?;
78        *self = updated;
79        Ok(())
80    }
81
82    /// Calculates the position size quantity for the given risk parameters.
83    ///
84    /// # Errors
85    ///
86    /// Always returns `NotImplementedError`; subclasses override this method.
87    #[pyo3(signature = (
88        entry,
89        stop_loss,
90        equity,
91        risk,
92        commission_rate = Decimal::ZERO,
93        exchange_rate = Decimal::ONE,
94        hard_limit = None,
95        unit_batch_size = Decimal::ONE,
96        units = 1
97    ))]
98    #[expect(
99        clippy::too_many_arguments,
100        reason = "position sizing API takes fixed-risk inputs used by callers"
101    )]
102    #[allow(unused_variables, clippy::unused_self)]
103    fn calculate(
104        &self,
105        entry: Price,
106        stop_loss: Price,
107        equity: Money,
108        #[pyo3(from_py_with = extract_decimal)] risk: Decimal,
109        #[pyo3(from_py_with = extract_decimal)] commission_rate: Decimal,
110        #[pyo3(from_py_with = extract_decimal)] exchange_rate: Decimal,
111        #[pyo3(from_py_with = extract_optional_decimal)] hard_limit: Option<Decimal>,
112        #[pyo3(from_py_with = extract_decimal)] unit_batch_size: Decimal,
113        units: i64,
114    ) -> PyResult<Quantity> {
115        Err(to_pynotimplemented_err(
116            "PositionSizer subclasses must implement `calculate`",
117        ))
118    }
119}
120
121/// Fixed-risk position sizer.
122#[allow(missing_debug_implementations)]
123#[gen_stub_pyclass(module = "nautilus_trader.risk")]
124#[pyclass(module = "nautilus_trader.risk", extends = PositionSizer)]
125pub struct FixedRiskSizer;
126
127#[gen_stub_pymethods]
128#[pymethods]
129#[expect(
130    clippy::use_self,
131    reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
132)]
133impl FixedRiskSizer {
134    #[new]
135    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
136    fn py_new(
137        py: Python<'_>,
138        instrument: Py<PyAny>,
139    ) -> PyResult<PyClassInitializer<FixedRiskSizer>> {
140        Ok(
141            PyClassInitializer::from(PositionSizer::from_instrument(py, instrument)?)
142                .add_subclass(FixedRiskSizer),
143        )
144    }
145
146    /// Calculates the position size quantity for the given risk parameters.
147    ///
148    /// Returns zero when no position is riskable, including for zero exchange
149    /// rates and equal entry and stop-loss prices.
150    ///
151    /// # Parameters
152    ///
153    /// - `entry`: The entry price.
154    /// - `stop_loss`: The stop-loss price.
155    /// - `equity`: The account equity.
156    /// - `risk`: The positive risk fraction.
157    /// - `commission_rate`: The non-negative commission rate.
158    /// - `exchange_rate`: The non-negative exchange rate between the instrument
159    ///   quote currency and the account currency.
160    /// - `hard_limit`: The optional positive limit for the total quantity.
161    /// - `unit_batch_size`: The non-negative unit batch size.
162    /// - `units`: The positive number of units to divide the position into.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if:
167    /// - A decimal argument is not a `decimal.Decimal`.
168    /// - A parameter violates the constraints above.
169    /// - Decimal arithmetic overflows.
170    /// - The final size rounds to zero or cannot be represented as a `Quantity`.
171    #[pyo3(signature = (
172        entry,
173        stop_loss,
174        equity,
175        risk,
176        commission_rate = Decimal::ZERO,
177        exchange_rate = Decimal::ONE,
178        hard_limit = None,
179        unit_batch_size = Decimal::ONE,
180        units = 1
181    ))]
182    #[expect(
183        clippy::too_many_arguments,
184        reason = "position sizing API takes fixed-risk inputs used by callers"
185    )]
186    fn calculate(
187        slf: PyRef<'_, Self>,
188        entry: Price,
189        stop_loss: Price,
190        equity: Money,
191        #[pyo3(from_py_with = extract_decimal)] risk: Decimal,
192        #[pyo3(from_py_with = extract_decimal)] commission_rate: Decimal,
193        #[pyo3(from_py_with = extract_decimal)] exchange_rate: Decimal,
194        #[pyo3(from_py_with = extract_optional_decimal)] hard_limit: Option<Decimal>,
195        #[pyo3(from_py_with = extract_decimal)] unit_batch_size: Decimal,
196        units: i64,
197    ) -> PyResult<Quantity> {
198        check_positive_i64(units, "units").map_err(correctness_error_to_pyvalue_err)?;
199        let units = usize::try_from(units).map_err(to_pyvalue_err)?;
200
201        let base = slf.into_super();
202        calculate_fixed_risk_position_size(
203            &base.instrument_any,
204            entry,
205            stop_loss,
206            equity,
207            risk,
208            commission_rate,
209            exchange_rate,
210            hard_limit,
211            unit_batch_size,
212            units,
213        )
214        .map_err(correctness_error_to_pyvalue_err)
215    }
216}
217
218impl PositionSizer {
219    fn from_instrument(py: Python<'_>, instrument: Py<PyAny>) -> PyResult<Self> {
220        let instrument_any =
221            pyobject_to_instrument_any(py, instrument.clone_ref(py)).map_err(|_| {
222                let type_name = instrument
223                    .bind(py)
224                    .get_type()
225                    .name()
226                    .map_or_else(|_| "unknown".to_string(), |name| name.to_string());
227                to_pytype_err(format!(
228                    "`instrument` must be an `Instrument`, was `{type_name}`"
229                ))
230            })?;
231        Ok(Self {
232            instrument,
233            instrument_any,
234        })
235    }
236}
237
238static DECIMAL_TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();
239
240fn extract_decimal(value: &Bound<'_, PyAny>) -> PyResult<Decimal> {
241    let decimal_type = DECIMAL_TYPE.import(value.py(), "decimal", "Decimal")?;
242    if !value.is_instance(decimal_type)? {
243        return Err(to_pytype_err(format!(
244            "expected decimal.Decimal, was {}",
245            value.get_type().name()?
246        )));
247    }
248    value.extract()
249}
250
251fn extract_optional_decimal(value: &Bound<'_, PyAny>) -> PyResult<Option<Decimal>> {
252    if value.is_none() {
253        Ok(None)
254    } else {
255        extract_decimal(value).map(Some)
256    }
257}