use nautilus_core::{
correctness::{check_equal, check_positive_i64},
python::{
correctness_error_to_pyvalue_err, to_pynotimplemented_err, to_pytype_err, to_pyvalue_err,
},
};
use nautilus_model::{
instruments::{Instrument, InstrumentAny},
python::instruments::pyobject_to_instrument_any,
types::{Money, Price, Quantity},
};
use pyo3::{
prelude::*,
sync::PyOnceLock,
types::{PyAny, PyType},
};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use rust_decimal::Decimal;
use crate::sizing::calculate_fixed_risk_position_size;
#[allow(missing_debug_implementations)]
#[gen_stub_pyclass(module = "nautilus_trader.risk")]
#[pyclass(module = "nautilus_trader.risk", subclass)]
pub struct PositionSizer {
instrument: Py<PyAny>,
instrument_any: InstrumentAny,
}
#[gen_stub_pymethods]
#[pymethods]
impl PositionSizer {
#[new]
#[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
fn py_new(py: Python<'_>, instrument: Py<PyAny>) -> PyResult<Self> {
Self::from_instrument(py, instrument)
}
#[getter]
fn instrument<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
self.instrument.bind(py).clone()
}
fn update_instrument(&mut self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
let updated = Self::from_instrument(py, instrument)?;
check_equal(
&self.instrument_any.id(),
&updated.instrument_any.id(),
"instrument.id",
"instrument.id",
)
.map_err(correctness_error_to_pyvalue_err)?;
*self = updated;
Ok(())
}
#[pyo3(signature = (
entry,
stop_loss,
equity,
risk,
commission_rate = Decimal::ZERO,
exchange_rate = Decimal::ONE,
hard_limit = None,
unit_batch_size = Decimal::ONE,
units = 1
))]
#[expect(
clippy::too_many_arguments,
reason = "position sizing API takes fixed-risk inputs used by callers"
)]
#[allow(unused_variables, clippy::unused_self)]
fn calculate(
&self,
entry: Price,
stop_loss: Price,
equity: Money,
#[pyo3(from_py_with = extract_decimal)] risk: Decimal,
#[pyo3(from_py_with = extract_decimal)] commission_rate: Decimal,
#[pyo3(from_py_with = extract_decimal)] exchange_rate: Decimal,
#[pyo3(from_py_with = extract_optional_decimal)] hard_limit: Option<Decimal>,
#[pyo3(from_py_with = extract_decimal)] unit_batch_size: Decimal,
units: i64,
) -> PyResult<Quantity> {
Err(to_pynotimplemented_err(
"PositionSizer subclasses must implement `calculate`",
))
}
}
#[allow(missing_debug_implementations)]
#[gen_stub_pyclass(module = "nautilus_trader.risk")]
#[pyclass(module = "nautilus_trader.risk", extends = PositionSizer)]
pub struct FixedRiskSizer;
#[gen_stub_pymethods]
#[pymethods]
#[expect(
clippy::use_self,
reason = "`Self` breaks pyo3-stub-gen derive for subclass pyclasses"
)]
impl FixedRiskSizer {
#[new]
#[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
fn py_new(
py: Python<'_>,
instrument: Py<PyAny>,
) -> PyResult<PyClassInitializer<FixedRiskSizer>> {
Ok(
PyClassInitializer::from(PositionSizer::from_instrument(py, instrument)?)
.add_subclass(FixedRiskSizer),
)
}
#[pyo3(signature = (
entry,
stop_loss,
equity,
risk,
commission_rate = Decimal::ZERO,
exchange_rate = Decimal::ONE,
hard_limit = None,
unit_batch_size = Decimal::ONE,
units = 1
))]
#[expect(
clippy::too_many_arguments,
reason = "position sizing API takes fixed-risk inputs used by callers"
)]
fn calculate(
slf: PyRef<'_, Self>,
entry: Price,
stop_loss: Price,
equity: Money,
#[pyo3(from_py_with = extract_decimal)] risk: Decimal,
#[pyo3(from_py_with = extract_decimal)] commission_rate: Decimal,
#[pyo3(from_py_with = extract_decimal)] exchange_rate: Decimal,
#[pyo3(from_py_with = extract_optional_decimal)] hard_limit: Option<Decimal>,
#[pyo3(from_py_with = extract_decimal)] unit_batch_size: Decimal,
units: i64,
) -> PyResult<Quantity> {
check_positive_i64(units, "units").map_err(correctness_error_to_pyvalue_err)?;
let units = usize::try_from(units).map_err(to_pyvalue_err)?;
let base = slf.into_super();
calculate_fixed_risk_position_size(
&base.instrument_any,
entry,
stop_loss,
equity,
risk,
commission_rate,
exchange_rate,
hard_limit,
unit_batch_size,
units,
)
.map_err(correctness_error_to_pyvalue_err)
}
}
impl PositionSizer {
fn from_instrument(py: Python<'_>, instrument: Py<PyAny>) -> PyResult<Self> {
let instrument_any =
pyobject_to_instrument_any(py, instrument.clone_ref(py)).map_err(|_| {
let type_name = instrument
.bind(py)
.get_type()
.name()
.map_or_else(|_| "unknown".to_string(), |name| name.to_string());
to_pytype_err(format!(
"`instrument` must be an `Instrument`, was `{type_name}`"
))
})?;
Ok(Self {
instrument,
instrument_any,
})
}
}
static DECIMAL_TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();
fn extract_decimal(value: &Bound<'_, PyAny>) -> PyResult<Decimal> {
let decimal_type = DECIMAL_TYPE.import(value.py(), "decimal", "Decimal")?;
if !value.is_instance(decimal_type)? {
return Err(to_pytype_err(format!(
"expected decimal.Decimal, was {}",
value.get_type().name()?
)));
}
value.extract()
}
fn extract_optional_decimal(value: &Bound<'_, PyAny>) -> PyResult<Option<Decimal>> {
if value.is_none() {
Ok(None)
} else {
extract_decimal(value).map(Some)
}
}