Skip to main content

nautilus_analysis/python/
analyzer.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::{
17    collections::{BTreeMap, HashMap},
18    sync::Arc,
19};
20
21use nautilus_core::{UnixNanos, python::to_pyvalue_err};
22use nautilus_model::{
23    identifiers::PositionId,
24    position::Position,
25    types::{Currency, Money},
26};
27use pyo3::prelude::*;
28
29use crate::{
30    Returns,
31    analyzer::{PortfolioAnalyzer, Statistic},
32    statistics::{
33        alpha::Alpha, beta_ratio::BetaRatio, expectancy::Expectancy,
34        information_ratio::InformationRatio, long_ratio::LongRatio, loser_avg::AvgLoser,
35        loser_max::MaxLoser, loser_min::MinLoser, profit_factor::ProfitFactor,
36        returns_avg::ReturnsAverage, returns_avg_loss::ReturnsAverageLoss,
37        returns_avg_win::ReturnsAverageWin, returns_volatility::ReturnsVolatility,
38        risk_return_ratio::RiskReturnRatio, sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio,
39        tracking_error::TrackingError, treynor_ratio::TreynorRatio, win_rate::WinRate,
40        winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
41    },
42};
43
44#[pymethods]
45#[pyo3_stub_gen::derive::gen_stub_pymethods]
46impl PortfolioAnalyzer {
47    /// Analyzes portfolio performance and calculates various statistics.
48    ///
49    /// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
50    /// to provide portfolio analysis including returns, PnL calculations,
51    /// and customizable statistics.
52    #[new]
53    #[must_use]
54    pub fn py_new() -> Self {
55        Self::new()
56    }
57
58    fn __repr__(&self) -> String {
59        format!("PortfolioAnalyzer(currencies={})", self.currencies().len())
60    }
61
62    /// Returns all tracked currencies.
63    #[pyo3(name = "currencies")]
64    fn py_currencies(&self) -> Vec<Currency> {
65        self.currencies().into_iter().copied().collect()
66    }
67
68    /// Calculates total PnL including unrealized PnL if provided.
69    #[pyo3(name = "get_performance_stats_returns")]
70    fn py_get_performance_stats_returns(&self) -> HashMap<String, f64> {
71        self.get_performance_stats_returns().into_iter().collect()
72    }
73
74    /// Gets all position-return-based performance statistics.
75    #[pyo3(name = "get_performance_stats_position_returns")]
76    fn py_get_performance_stats_position_returns(&self) -> HashMap<String, f64> {
77        self.get_performance_stats_position_returns()
78            .into_iter()
79            .collect()
80    }
81
82    /// Gets all portfolio-return-based performance statistics.
83    #[pyo3(name = "get_performance_stats_portfolio_returns")]
84    fn py_get_performance_stats_portfolio_returns(&self) -> HashMap<String, f64> {
85        self.get_performance_stats_portfolio_returns()
86            .into_iter()
87            .collect()
88    }
89
90    /// Gets all benchmark-relative return statistics for the primary returns.
91    ///
92    /// This is stateless: the `benchmark` series is supplied by the caller rather
93    /// than stored on the analyzer. Only statistics that override
94    /// `PortfolioStatistic.calculate_from_returns_with_benchmark` (the benchmark-relative
95    /// statistics) contribute values; all others return `None` and are skipped.
96    #[pyo3(name = "get_performance_stats_returns_vs_benchmark")]
97    fn py_get_performance_stats_returns_vs_benchmark(
98        &self,
99        benchmark: BTreeMap<u64, f64>,
100    ) -> HashMap<String, f64> {
101        let benchmark: Returns = benchmark
102            .into_iter()
103            .map(|(k, v)| (UnixNanos::from(k), v))
104            .collect();
105        self.get_performance_stats_returns_vs_benchmark(&benchmark)
106            .into_iter()
107            .collect()
108    }
109
110    #[pyo3(name = "get_performance_stats_pnls")]
111    fn py_get_performance_stats_pnls(
112        &self,
113        currency: Option<&Currency>,
114        unrealized_pnl: Option<&Money>,
115    ) -> PyResult<HashMap<String, f64>> {
116        self.get_performance_stats_pnls(currency, unrealized_pnl)
117            .map(|m| m.into_iter().collect())
118            .map_err(to_pyvalue_err)
119    }
120
121    /// Gets general portfolio statistics.
122    #[pyo3(name = "get_performance_stats_general")]
123    fn py_get_performance_stats_general(&self) -> HashMap<String, f64> {
124        self.get_performance_stats_general().into_iter().collect()
125    }
126
127    /// Records a position return at a specific timestamp.
128    #[pyo3(name = "add_position_return")]
129    fn py_add_position_return(&mut self, timestamp: u64, value: f64) {
130        self.add_position_return(UnixNanos::from(timestamp), value);
131    }
132
133    /// Records a return at a specific timestamp.
134    ///
135    /// This is a backward-compatible alias for `Self.add_position_return`.
136    #[pyo3(name = "add_return")]
137    fn py_add_return(&mut self, timestamp: u64, value: f64) {
138        self.add_return(UnixNanos::from(timestamp), value);
139    }
140
141    /// Resets all analysis data to initial state.
142    #[pyo3(name = "reset")]
143    fn py_reset(&mut self) {
144        self.reset();
145    }
146
147    /// Registers a new portfolio statistic for calculation.
148    #[pyo3(name = "register_statistic")]
149    #[expect(clippy::needless_pass_by_value)]
150    fn py_register_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
151        let type_name = statistic
152            .getattr(py, "__class__")?
153            .getattr(py, "__name__")?
154            .extract::<String>(py)?;
155
156        match type_name.as_str() {
157            "MaxWinner" => {
158                let stat = statistic.extract::<MaxWinner>(py)?;
159                self.register_statistic(Arc::new(stat));
160            }
161            "MinWinner" => {
162                let stat = statistic.extract::<MinWinner>(py)?;
163                self.register_statistic(Arc::new(stat));
164            }
165            "AvgWinner" => {
166                let stat = statistic.extract::<AvgWinner>(py)?;
167                self.register_statistic(Arc::new(stat));
168            }
169            "MaxLoser" => {
170                let stat = statistic.extract::<MaxLoser>(py)?;
171                self.register_statistic(Arc::new(stat));
172            }
173            "MinLoser" => {
174                let stat = statistic.extract::<MinLoser>(py)?;
175                self.register_statistic(Arc::new(stat));
176            }
177            "AvgLoser" => {
178                let stat = statistic.extract::<AvgLoser>(py)?;
179                self.register_statistic(Arc::new(stat));
180            }
181            "Expectancy" => {
182                let stat = statistic.extract::<Expectancy>(py)?;
183                self.register_statistic(Arc::new(stat));
184            }
185            "WinRate" => {
186                let stat = statistic.extract::<WinRate>(py)?;
187                self.register_statistic(Arc::new(stat));
188            }
189            "ReturnsVolatility" => {
190                let stat = statistic.extract::<ReturnsVolatility>(py)?;
191                self.register_statistic(Arc::new(stat));
192            }
193            "ReturnsAverage" => {
194                let stat = statistic.extract::<ReturnsAverage>(py)?;
195                self.register_statistic(Arc::new(stat));
196            }
197            "ReturnsAverageLoss" => {
198                let stat = statistic.extract::<ReturnsAverageLoss>(py)?;
199                self.register_statistic(Arc::new(stat));
200            }
201            "ReturnsAverageWin" => {
202                let stat = statistic.extract::<ReturnsAverageWin>(py)?;
203                self.register_statistic(Arc::new(stat));
204            }
205            "SharpeRatio" => {
206                let stat = statistic.extract::<SharpeRatio>(py)?;
207                self.register_statistic(Arc::new(stat));
208            }
209            "SortinoRatio" => {
210                let stat = statistic.extract::<SortinoRatio>(py)?;
211                self.register_statistic(Arc::new(stat));
212            }
213            "ProfitFactor" => {
214                let stat = statistic.extract::<ProfitFactor>(py)?;
215                self.register_statistic(Arc::new(stat));
216            }
217            "RiskReturnRatio" => {
218                let stat = statistic.extract::<RiskReturnRatio>(py)?;
219                self.register_statistic(Arc::new(stat));
220            }
221            "LongRatio" => {
222                let stat = statistic.extract::<LongRatio>(py)?;
223                self.register_statistic(Arc::new(stat));
224            }
225            "Alpha" => {
226                let stat = statistic.extract::<Alpha>(py)?;
227                self.register_statistic(Arc::new(stat));
228            }
229            "BetaRatio" => {
230                let stat = statistic.extract::<BetaRatio>(py)?;
231                self.register_statistic(Arc::new(stat));
232            }
233            "InformationRatio" => {
234                let stat = statistic.extract::<InformationRatio>(py)?;
235                self.register_statistic(Arc::new(stat));
236            }
237            "TrackingError" => {
238                let stat = statistic.extract::<TrackingError>(py)?;
239                self.register_statistic(Arc::new(stat));
240            }
241            "TreynorRatio" => {
242                let stat = statistic.extract::<TreynorRatio>(py)?;
243                self.register_statistic(Arc::new(stat));
244            }
245            _ => {
246                return Err(to_pyvalue_err(format!(
247                    "Unknown statistic type: {type_name}"
248                )));
249            }
250        }
251
252        Ok(())
253    }
254
255    /// Removes a specific statistic from calculation.
256    #[pyo3(name = "deregister_statistic")]
257    #[expect(clippy::needless_pass_by_value)]
258    fn py_deregister_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
259        let type_name = statistic
260            .getattr(py, "__class__")?
261            .getattr(py, "__name__")?
262            .extract::<String>(py)?;
263
264        match type_name.as_str() {
265            "MaxWinner" => {
266                let stat = statistic.extract::<MaxWinner>(py)?;
267                self.deregister_statistic(&(Arc::new(stat) as Statistic));
268            }
269            "MinWinner" => {
270                let stat = statistic.extract::<MinWinner>(py)?;
271                self.deregister_statistic(&(Arc::new(stat) as Statistic));
272            }
273            "AvgWinner" => {
274                let stat = statistic.extract::<AvgWinner>(py)?;
275                self.deregister_statistic(&(Arc::new(stat) as Statistic));
276            }
277            "MaxLoser" => {
278                let stat = statistic.extract::<MaxLoser>(py)?;
279                self.deregister_statistic(&(Arc::new(stat) as Statistic));
280            }
281            "MinLoser" => {
282                let stat = statistic.extract::<MinLoser>(py)?;
283                self.deregister_statistic(&(Arc::new(stat) as Statistic));
284            }
285            "AvgLoser" => {
286                let stat = statistic.extract::<AvgLoser>(py)?;
287                self.deregister_statistic(&(Arc::new(stat) as Statistic));
288            }
289            "Expectancy" => {
290                let stat = statistic.extract::<Expectancy>(py)?;
291                self.deregister_statistic(&(Arc::new(stat) as Statistic));
292            }
293            "WinRate" => {
294                let stat = statistic.extract::<WinRate>(py)?;
295                self.deregister_statistic(&(Arc::new(stat) as Statistic));
296            }
297            "ReturnsVolatility" => {
298                let stat = statistic.extract::<ReturnsVolatility>(py)?;
299                self.deregister_statistic(&(Arc::new(stat) as Statistic));
300            }
301            "ReturnsAverage" => {
302                let stat = statistic.extract::<ReturnsAverage>(py)?;
303                self.deregister_statistic(&(Arc::new(stat) as Statistic));
304            }
305            "ReturnsAverageLoss" => {
306                let stat = statistic.extract::<ReturnsAverageLoss>(py)?;
307                self.deregister_statistic(&(Arc::new(stat) as Statistic));
308            }
309            "ReturnsAverageWin" => {
310                let stat = statistic.extract::<ReturnsAverageWin>(py)?;
311                self.deregister_statistic(&(Arc::new(stat) as Statistic));
312            }
313            "SharpeRatio" => {
314                let stat = statistic.extract::<SharpeRatio>(py)?;
315                self.deregister_statistic(&(Arc::new(stat) as Statistic));
316            }
317            "SortinoRatio" => {
318                let stat = statistic.extract::<SortinoRatio>(py)?;
319                self.deregister_statistic(&(Arc::new(stat) as Statistic));
320            }
321            "ProfitFactor" => {
322                let stat = statistic.extract::<ProfitFactor>(py)?;
323                self.deregister_statistic(&(Arc::new(stat) as Statistic));
324            }
325            "RiskReturnRatio" => {
326                let stat = statistic.extract::<RiskReturnRatio>(py)?;
327                self.deregister_statistic(&(Arc::new(stat) as Statistic));
328            }
329            "LongRatio" => {
330                let stat = statistic.extract::<LongRatio>(py)?;
331                self.deregister_statistic(&(Arc::new(stat) as Statistic));
332            }
333            "Alpha" => {
334                let stat = statistic.extract::<Alpha>(py)?;
335                self.deregister_statistic(&(Arc::new(stat) as Statistic));
336            }
337            "BetaRatio" => {
338                let stat = statistic.extract::<BetaRatio>(py)?;
339                self.deregister_statistic(&(Arc::new(stat) as Statistic));
340            }
341            "InformationRatio" => {
342                let stat = statistic.extract::<InformationRatio>(py)?;
343                self.deregister_statistic(&(Arc::new(stat) as Statistic));
344            }
345            "TrackingError" => {
346                let stat = statistic.extract::<TrackingError>(py)?;
347                self.deregister_statistic(&(Arc::new(stat) as Statistic));
348            }
349            "TreynorRatio" => {
350                let stat = statistic.extract::<TreynorRatio>(py)?;
351                self.deregister_statistic(&(Arc::new(stat) as Statistic));
352            }
353            _ => {
354                return Err(to_pyvalue_err(format!(
355                    "Unknown statistic type: {type_name}"
356                )));
357            }
358        }
359
360        Ok(())
361    }
362
363    /// Removes all registered statistics.
364    #[pyo3(name = "deregister_statistics")]
365    fn py_deregister_statistics(&mut self) {
366        self.deregister_statistics();
367    }
368
369    /// Adds new positions for analysis.
370    #[pyo3(name = "add_positions")]
371    #[expect(clippy::needless_pass_by_value)]
372    fn py_add_positions(&mut self, py: Python, positions: Vec<Py<PyAny>>) -> PyResult<()> {
373        // Extract Position objects from Cython wrappers
374        let positions: Vec<Position> = positions
375            .iter()
376            .map(|p| {
377                // Try to get the underlying Rust Position
378                // For now, we'll need to handle Cython Position by accessing its _mem field
379                p.getattr(py, "_mem")?
380                    .extract::<Position>(py)
381                    .map_err(Into::into)
382            })
383            .collect::<PyResult<Vec<Position>>>()?;
384
385        self.add_positions(&positions);
386        Ok(())
387    }
388
389    /// Records a trade's PnL realized at `ts_event`.
390    #[pyo3(name = "add_trade")]
391    #[allow(
392        clippy::trivially_copy_pass_by_ref,
393        reason = "matches underlying add_trade signature"
394    )]
395    fn py_add_trade(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
396        self.add_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
397    }
398
399    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
400    #[pyo3(name = "record_trade")]
401    #[allow(
402        clippy::trivially_copy_pass_by_ref,
403        reason = "matches underlying record_trade signature"
404    )]
405    fn py_record_trade(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
406        self.record_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
407    }
408
409    // Note: calculate_statistics is not exposed to Python because it requires
410    // complex conversions of Account and dict types. Use the Python analyzer.py wrapper instead.
411
412    /// Retrieves a specific statistic by name.
413    #[pyo3(name = "statistic")]
414    fn py_statistic(&self, name: &str) -> Option<String> {
415        self.statistic(name).map(|s| s.name())
416    }
417
418    /// Returns the primary calculated returns.
419    ///
420    /// This returns portfolio returns when available, otherwise it falls back
421    /// to position returns for backward compatibility.
422    #[pyo3(name = "returns")]
423    fn py_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
424        // Convert BTreeMap<UnixNanos, f64> to Python dict
425        let dict = pyo3::types::PyDict::new(py);
426        for (timestamp, value) in self.returns() {
427            dict.set_item(timestamp.as_u64(), value)?;
428        }
429        Ok(dict.into())
430    }
431
432    /// Returns the per-position calculated returns.
433    #[pyo3(name = "position_returns")]
434    fn py_position_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
435        let dict = pyo3::types::PyDict::new(py);
436        for (timestamp, value) in self.position_returns() {
437            dict.set_item(timestamp.as_u64(), value)?;
438        }
439        Ok(dict.into())
440    }
441
442    /// Returns the portfolio calculated returns.
443    #[pyo3(name = "portfolio_returns")]
444    fn py_portfolio_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
445        let dict = pyo3::types::PyDict::new(py);
446        for (timestamp, value) in self.portfolio_returns() {
447            dict.set_item(timestamp.as_u64(), value)?;
448        }
449        Ok(dict.into())
450    }
451
452    /// Retrieves realized PnLs for a specific currency.
453    ///
454    /// Each record is `(position_id, ts_event, realized_pnl)`. Returns `None` if no PnLs
455    /// exist, or if multiple currencies exist without an explicit currency specified.
456    #[pyo3(name = "realized_pnls")]
457    fn py_realized_pnls(&self, py: Python, currency: Option<&Currency>) -> PyResult<Py<PyAny>> {
458        match self.realized_pnls(currency) {
459            Some(pnls) => {
460                let list = pyo3::types::PyList::empty(py);
461                for (position_id, ts_event, pnl) in pnls {
462                    list.append((position_id.to_string(), ts_event.as_u64(), pnl))?;
463                }
464                Ok(list.into())
465            }
466            None => Ok(py.None()),
467        }
468    }
469
470    #[pyo3(name = "total_pnl")]
471    fn py_total_pnl(
472        &self,
473        currency: Option<&Currency>,
474        unrealized_pnl: Option<&Money>,
475    ) -> PyResult<f64> {
476        self.total_pnl(currency, unrealized_pnl)
477            .map_err(to_pyvalue_err)
478    }
479
480    #[pyo3(name = "total_pnl_percentage")]
481    fn py_total_pnl_percentage(
482        &self,
483        currency: Option<&Currency>,
484        unrealized_pnl: Option<&Money>,
485    ) -> PyResult<f64> {
486        self.total_pnl_percentage(currency, unrealized_pnl)
487            .map_err(to_pyvalue_err)
488    }
489
490    /// Gets formatted PnL statistics as strings.
491    #[pyo3(name = "get_stats_pnls_formatted")]
492    fn py_get_stats_pnls_formatted(
493        &self,
494        currency: Option<&Currency>,
495        unrealized_pnl: Option<&Money>,
496    ) -> PyResult<Vec<String>> {
497        self.get_stats_pnls_formatted(currency, unrealized_pnl)
498            .map_err(to_pyvalue_err)
499    }
500
501    /// Gets formatted return statistics as strings.
502    #[pyo3(name = "get_stats_returns_formatted")]
503    fn py_get_stats_returns_formatted(&self) -> Vec<String> {
504        self.get_stats_returns_formatted()
505    }
506
507    /// Gets formatted position-return statistics as strings.
508    #[pyo3(name = "get_stats_position_returns_formatted")]
509    fn py_get_stats_position_returns_formatted(&self) -> Vec<String> {
510        self.get_stats_position_returns_formatted()
511    }
512
513    /// Gets formatted portfolio-return statistics as strings.
514    #[pyo3(name = "get_stats_portfolio_returns_formatted")]
515    fn py_get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
516        self.get_stats_portfolio_returns_formatted()
517    }
518
519    /// Gets formatted general statistics as strings.
520    #[pyo3(name = "get_stats_general_formatted")]
521    fn py_get_stats_general_formatted(&self) -> Vec<String> {
522        self.get_stats_general_formatted()
523    }
524}