1use 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
46pub 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 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 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 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 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 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 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
232pub 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
255fn 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}