Skip to main content

nautilus_analysis/python/
statistic.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::{fmt::Debug, sync::Arc};
17
18use nautilus_core::python::to_pyvalue_err;
19use nautilus_model::position::Position;
20use pyo3::{
21    exceptions::PyAttributeError,
22    prelude::*,
23    types::{PyDict, PyList},
24};
25
26use crate::{
27    Returns,
28    analyzer::Statistic,
29    statistic::PortfolioStatistic,
30    statistics::{
31        alpha::Alpha, beta_ratio::BetaRatio, cagr::CAGR, calmar_ratio::CalmarRatio,
32        down_capture_ratio::DownCaptureRatio, expectancy::Expectancy,
33        expected_shortfall::ExpectedShortfall, information_ratio::InformationRatio,
34        long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser, loser_min::MinLoser,
35        max_drawdown::MaxDrawdown, omega_ratio::OmegaRatio, profit_factor::ProfitFactor,
36        returns_avg::ReturnsAverage, returns_avg_loss::ReturnsAverageLoss,
37        returns_avg_win::ReturnsAverageWin, returns_kurtosis::ReturnsKurtosis,
38        returns_skewness::ReturnsSkewness, returns_volatility::ReturnsVolatility,
39        risk_return_ratio::RiskReturnRatio, sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio,
40        tail_ratio::TailRatio, tracking_error::TrackingError, treynor_ratio::TreynorRatio,
41        ulcer_index::UlcerIndex, up_capture_ratio::UpCaptureRatio, value_at_risk::ValueAtRisk,
42        win_rate::WinRate, winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
43    },
44};
45
46/// A [`PortfolioStatistic`] implemented in Python.
47///
48/// Wraps a user-defined Python object and dispatches each input category the analyzer feeds
49/// to the method of the same name. A category the object does not define, or for which it
50/// returns `None`, contributes no value.
51///
52/// Calculated values must be numeric, matching the `f64` item type the analyzer collects.
53pub struct PythonStatistic {
54    name: String,
55    statistic: Py<PyAny>,
56}
57
58impl Debug for PythonStatistic {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct(stringify!(PythonStatistic))
61            .field("name", &self.name)
62            .finish_non_exhaustive()
63    }
64}
65
66impl PythonStatistic {
67    /// Creates a new [`PythonStatistic`] wrapping `statistic`.
68    ///
69    /// The name is resolved once at construction, so it stays stable for the registration key
70    /// and every later lookup.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if `statistic` has no `name` attribute resolving to a non-empty string.
75    pub fn new(py: Python<'_>, statistic: Py<PyAny>) -> PyResult<Self> {
76        let name = statistic
77            .getattr(py, "name")
78            .and_then(|name| name.extract::<String>(py))
79            .map_err(|e| {
80                to_pyvalue_err(format!(
81                    "Invalid statistic: `name` must resolve to a string, was {e}"
82                ))
83            })?;
84
85        if name.trim().is_empty() {
86            return Err(to_pyvalue_err(
87                "Invalid statistic: `name` must not be empty".to_string(),
88            ));
89        }
90
91        Ok(Self { name, statistic })
92    }
93
94    /// Returns the bound `method` callable, or `None` when the statistic does not define it.
95    fn method<'py>(&self, py: Python<'py>, method: &str) -> Option<Bound<'py, PyAny>> {
96        match self.statistic.bind(py).getattr(method) {
97            Ok(callable) => Some(callable),
98            Err(e) if e.is_instance_of::<PyAttributeError>(py) => None,
99            Err(e) => {
100                self.report(py, method, "failed attribute lookup for", e);
101                None
102            }
103        }
104    }
105
106    /// Returns the numeric value from `result`, reporting a raised or non-numeric outcome.
107    ///
108    /// The trait has no error channel, so a failure is reported here and skipped rather than
109    /// propagated, leaving the remaining statistics to calculate.
110    fn value(
111        &self,
112        py: Python<'_>,
113        method: &str,
114        result: PyResult<Bound<'_, PyAny>>,
115    ) -> Option<f64> {
116        let value = match result {
117            Ok(value) => value,
118            Err(e) => {
119                self.report(py, method, "raised in", e);
120                return None;
121            }
122        };
123
124        if value.is_none() {
125            return None;
126        }
127
128        match value.extract::<f64>() {
129            Ok(value) => Some(value),
130            Err(e) => {
131                self.report(py, method, "returned a non-numeric value from", e);
132                None
133            }
134        }
135    }
136
137    /// Reports a Python-side failure through both the log and `sys.unraisablehook`.
138    ///
139    /// The log alone is not enough: the `log` facade is a no-op until a logger is installed,
140    /// which is the usual case for a standalone analyzer, so the traceback also goes to
141    /// `sys.unraisablehook` where Python surfaces uncatchable callback errors.
142    fn report(&self, py: Python<'_>, method: &str, what: &str, e: PyErr) {
143        log::error!("Statistic `{}` {what} `{method}`: {e}", self.name);
144
145        e.write_unraisable(py, Some(&self.statistic.bind(py).clone()));
146    }
147
148    /// Converts `returns` into the `dict[int, float]` shape the Python analyzer surface uses.
149    fn returns_dict<'py>(
150        &self,
151        py: Python<'py>,
152        method: &str,
153        returns: &Returns,
154    ) -> Option<Bound<'py, PyDict>> {
155        let dict = PyDict::new(py);
156
157        for (timestamp, value) in returns {
158            self.converted(method, dict.set_item(timestamp.as_u64(), value))?;
159        }
160
161        Some(dict)
162    }
163
164    /// Returns the converted `value`, logging a conversion failure against `method`.
165    fn converted<T>(&self, method: &str, value: PyResult<T>) -> Option<T> {
166        match value {
167            Ok(value) => Some(value),
168            Err(e) => {
169                log::error!(
170                    "Statistic `{}` could not receive input for `{method}`: {e}",
171                    self.name
172                );
173                None
174            }
175        }
176    }
177}
178
179impl PortfolioStatistic for PythonStatistic {
180    type Item = f64;
181
182    fn name(&self) -> String {
183        self.name.clone()
184    }
185
186    fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
187        const METHOD: &str = "calculate_from_returns";
188
189        Python::attach(|py| {
190            let method = self.method(py, METHOD)?;
191            let returns = self.returns_dict(py, METHOD, returns)?;
192            self.value(py, METHOD, method.call1((returns,)))
193        })
194    }
195
196    fn calculate_from_realized_pnls(&self, realized_pnls: &[f64]) -> Option<f64> {
197        const METHOD: &str = "calculate_from_realized_pnls";
198
199        Python::attach(|py| {
200            let method = self.method(py, METHOD)?;
201            let realized_pnls = self.converted(METHOD, PyList::new(py, realized_pnls))?;
202            self.value(py, METHOD, method.call1((realized_pnls,)))
203        })
204    }
205
206    fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
207        const METHOD: &str = "calculate_from_positions";
208
209        Python::attach(|py| {
210            let method = self.method(py, METHOD)?;
211            let positions = self.converted(METHOD, PyList::new(py, positions.iter().cloned()))?;
212            self.value(py, METHOD, method.call1((positions,)))
213        })
214    }
215
216    fn calculate_from_returns_with_benchmark(
217        &self,
218        returns: &Returns,
219        benchmark: &Returns,
220    ) -> Option<f64> {
221        const METHOD: &str = "calculate_from_returns_with_benchmark";
222
223        Python::attach(|py| {
224            let method = self.method(py, METHOD)?;
225            let returns = self.returns_dict(py, METHOD, returns)?;
226            let benchmark = self.returns_dict(py, METHOD, benchmark)?;
227            self.value(py, METHOD, method.call1((returns, benchmark)))
228        })
229    }
230}
231
232/// Converts `statistic` into a registrable [`Statistic`].
233///
234/// A built-in statistic type converts to its native Rust implementation, keeping calculation in
235/// Rust. Any other object is wrapped as a [`PythonStatistic`] and dispatched back into Python on
236/// calculation, including a user-defined class whose name matches a built-in.
237///
238/// # Errors
239///
240/// Returns an error if the object's class cannot be resolved, or if a user-defined statistic has
241/// no `name` attribute resolving to a non-empty string.
242pub fn statistic_from_pyobject(py: Python<'_>, statistic: Py<PyAny>) -> PyResult<Statistic> {
243    let type_name = statistic
244        .getattr(py, "__class__")?
245        .getattr(py, "__name__")?
246        .extract::<String>(py)?;
247
248    if let Some(statistic) = native_statistic(py, &statistic, &type_name) {
249        return Ok(statistic);
250    }
251
252    Ok(Arc::new(PythonStatistic::new(py, statistic)?))
253}
254
255/// Returns the native implementation when `statistic` is an instance of the built-in `type_name`.
256///
257/// The name selects which built-in type to try, and extraction then confirms the instance. A
258/// user-defined class that only shares a built-in name fails that check and falls through to the
259/// Python bridge, so built-in names stay usable for user-defined statistics.
260fn native_statistic(py: Python<'_>, statistic: &Py<PyAny>, type_name: &str) -> Option<Statistic> {
261    fn extract<T>(py: Python<'_>, statistic: &Py<PyAny>) -> Option<Statistic>
262    where
263        T: PortfolioStatistic<Item = f64>
264            + Send
265            + Sync
266            + 'static
267            + for<'a, 'py> FromPyObject<'a, 'py>,
268    {
269        statistic
270            .extract::<T>(py)
271            .ok()
272            .map(|statistic| Arc::new(statistic) as Statistic)
273    }
274
275    match type_name {
276        "MaxWinner" => extract::<MaxWinner>(py, statistic),
277        "MinWinner" => extract::<MinWinner>(py, statistic),
278        "AvgWinner" => extract::<AvgWinner>(py, statistic),
279        "MaxLoser" => extract::<MaxLoser>(py, statistic),
280        "MinLoser" => extract::<MinLoser>(py, statistic),
281        "AvgLoser" => extract::<AvgLoser>(py, statistic),
282        "Expectancy" => extract::<Expectancy>(py, statistic),
283        "WinRate" => extract::<WinRate>(py, statistic),
284        "ReturnsVolatility" => extract::<ReturnsVolatility>(py, statistic),
285        "ReturnsAverage" => extract::<ReturnsAverage>(py, statistic),
286        "ReturnsAverageLoss" => extract::<ReturnsAverageLoss>(py, statistic),
287        "ReturnsAverageWin" => extract::<ReturnsAverageWin>(py, statistic),
288        "SharpeRatio" => extract::<SharpeRatio>(py, statistic),
289        "SortinoRatio" => extract::<SortinoRatio>(py, statistic),
290        "ProfitFactor" => extract::<ProfitFactor>(py, statistic),
291        "RiskReturnRatio" => extract::<RiskReturnRatio>(py, statistic),
292        "LongRatio" => extract::<LongRatio>(py, statistic),
293        "CAGR" => extract::<CAGR>(py, statistic),
294        "CalmarRatio" => extract::<CalmarRatio>(py, statistic),
295        "MaxDrawdown" => extract::<MaxDrawdown>(py, statistic),
296        "Alpha" => extract::<Alpha>(py, statistic),
297        "BetaRatio" => extract::<BetaRatio>(py, statistic),
298        "DownCaptureRatio" => extract::<DownCaptureRatio>(py, statistic),
299        "InformationRatio" => extract::<InformationRatio>(py, statistic),
300        "TrackingError" => extract::<TrackingError>(py, statistic),
301        "TreynorRatio" => extract::<TreynorRatio>(py, statistic),
302        "ReturnsSkewness" => extract::<ReturnsSkewness>(py, statistic),
303        "ReturnsKurtosis" => extract::<ReturnsKurtosis>(py, statistic),
304        "TailRatio" => extract::<TailRatio>(py, statistic),
305        "UlcerIndex" => extract::<UlcerIndex>(py, statistic),
306        "OmegaRatio" => extract::<OmegaRatio>(py, statistic),
307        "ValueAtRisk" => extract::<ValueAtRisk>(py, statistic),
308        "ExpectedShortfall" => extract::<ExpectedShortfall>(py, statistic),
309        "UpCaptureRatio" => extract::<UpCaptureRatio>(py, statistic),
310        _ => None,
311    }
312}