1use 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#[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 #[getter]
59 fn instrument<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
60 self.instrument.bind(py).clone()
61 }
62
63 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 #[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#[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 #[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}