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, cagr::CAGR, calmar_ratio::CalmarRatio,
34        down_capture_ratio::DownCaptureRatio, expectancy::Expectancy,
35        expected_shortfall::ExpectedShortfall, information_ratio::InformationRatio,
36        long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser, loser_min::MinLoser,
37        max_drawdown::MaxDrawdown, omega_ratio::OmegaRatio, profit_factor::ProfitFactor,
38        returns_avg::ReturnsAverage, returns_avg_loss::ReturnsAverageLoss,
39        returns_avg_win::ReturnsAverageWin, returns_kurtosis::ReturnsKurtosis,
40        returns_skewness::ReturnsSkewness, returns_volatility::ReturnsVolatility,
41        risk_return_ratio::RiskReturnRatio, sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio,
42        tail_ratio::TailRatio, tracking_error::TrackingError, treynor_ratio::TreynorRatio,
43        ulcer_index::UlcerIndex, up_capture_ratio::UpCaptureRatio, value_at_risk::ValueAtRisk,
44        win_rate::WinRate, winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
45    },
46};
47
48#[pymethods]
49#[pyo3_stub_gen::derive::gen_stub_pymethods]
50impl PortfolioAnalyzer {
51    /// Analyzes portfolio performance and calculates various statistics.
52    ///
53    /// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
54    /// to provide portfolio analysis including returns, PnL calculations,
55    /// and customizable statistics.
56    #[new]
57    #[must_use]
58    pub fn py_new() -> Self {
59        Self::new()
60    }
61
62    fn __repr__(&self) -> String {
63        format!("PortfolioAnalyzer(currencies={})", self.currencies().len())
64    }
65
66    /// Returns all tracked currencies.
67    #[pyo3(name = "currencies")]
68    fn py_currencies(&self) -> Vec<Currency> {
69        self.currencies().into_iter().copied().collect()
70    }
71
72    /// Gets all return-based performance statistics.
73    #[pyo3(name = "get_performance_stats_returns")]
74    fn py_get_performance_stats_returns(&self) -> HashMap<String, f64> {
75        self.get_performance_stats_returns().into_iter().collect()
76    }
77
78    /// Gets all position-return-based performance statistics.
79    #[pyo3(name = "get_performance_stats_position_returns")]
80    fn py_get_performance_stats_position_returns(&self) -> HashMap<String, f64> {
81        self.get_performance_stats_position_returns()
82            .into_iter()
83            .collect()
84    }
85
86    /// Gets all portfolio-return-based performance statistics.
87    #[pyo3(name = "get_performance_stats_portfolio_returns")]
88    fn py_get_performance_stats_portfolio_returns(&self) -> HashMap<String, f64> {
89        self.get_performance_stats_portfolio_returns()
90            .into_iter()
91            .collect()
92    }
93
94    /// Gets all benchmark-relative return statistics for the primary returns.
95    ///
96    /// This is stateless: the `benchmark` series is supplied by the caller rather
97    /// than stored on the analyzer. Only statistics that override
98    /// `PortfolioStatistic.calculate_from_returns_with_benchmark` (the benchmark-relative
99    /// statistics) contribute values; all others return `None` and are skipped.
100    #[pyo3(name = "get_performance_stats_returns_vs_benchmark")]
101    fn py_get_performance_stats_returns_vs_benchmark(
102        &self,
103        benchmark: BTreeMap<u64, f64>,
104    ) -> HashMap<String, f64> {
105        let benchmark: Returns = benchmark
106            .into_iter()
107            .map(|(k, v)| (UnixNanos::from(k), v))
108            .collect();
109        self.get_performance_stats_returns_vs_benchmark(&benchmark)
110            .into_iter()
111            .collect()
112    }
113
114    /// Gets all PnL-related performance statistics.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if PnL calculations fail, for example due to:
119    ///
120    /// - No currency specified for a multi-currency portfolio.
121    /// - Unrealized PnL currency not matching the specified currency.
122    /// - Specified currency not found in account balances.
123    #[pyo3(name = "get_performance_stats_pnls")]
124    fn py_get_performance_stats_pnls(
125        &self,
126        currency: Option<&Currency>,
127        unrealized_pnl: Option<&Money>,
128    ) -> PyResult<HashMap<String, f64>> {
129        self.get_performance_stats_pnls(currency, unrealized_pnl)
130            .map(|m| m.into_iter().collect())
131            .map_err(to_pyvalue_err)
132    }
133
134    /// Gets general portfolio statistics.
135    #[pyo3(name = "get_performance_stats_general")]
136    fn py_get_performance_stats_general(&self) -> HashMap<String, f64> {
137        self.get_performance_stats_general().into_iter().collect()
138    }
139
140    /// Records a position return at a specific timestamp.
141    #[pyo3(name = "add_position_return")]
142    fn py_add_position_return(&mut self, timestamp: u64, value: f64) {
143        self.add_position_return(UnixNanos::from(timestamp), value);
144    }
145
146    /// Records a return at a specific timestamp.
147    ///
148    /// This is a backward-compatible alias for `Self.add_position_return`.
149    #[pyo3(name = "add_return")]
150    fn py_add_return(&mut self, timestamp: u64, value: f64) {
151        self.add_return(UnixNanos::from(timestamp), value);
152    }
153
154    /// Resets all analysis data to initial state.
155    #[pyo3(name = "reset")]
156    fn py_reset(&mut self) {
157        self.reset();
158    }
159
160    /// Registers a new portfolio statistic for calculation.
161    #[pyo3(name = "register_statistic")]
162    #[expect(clippy::needless_pass_by_value)]
163    #[expect(clippy::too_many_lines)]
164    fn py_register_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
165        let type_name = statistic
166            .getattr(py, "__class__")?
167            .getattr(py, "__name__")?
168            .extract::<String>(py)?;
169
170        match type_name.as_str() {
171            "MaxWinner" => {
172                let stat = statistic.extract::<MaxWinner>(py)?;
173                self.register_statistic(Arc::new(stat));
174            }
175            "MinWinner" => {
176                let stat = statistic.extract::<MinWinner>(py)?;
177                self.register_statistic(Arc::new(stat));
178            }
179            "AvgWinner" => {
180                let stat = statistic.extract::<AvgWinner>(py)?;
181                self.register_statistic(Arc::new(stat));
182            }
183            "MaxLoser" => {
184                let stat = statistic.extract::<MaxLoser>(py)?;
185                self.register_statistic(Arc::new(stat));
186            }
187            "MinLoser" => {
188                let stat = statistic.extract::<MinLoser>(py)?;
189                self.register_statistic(Arc::new(stat));
190            }
191            "AvgLoser" => {
192                let stat = statistic.extract::<AvgLoser>(py)?;
193                self.register_statistic(Arc::new(stat));
194            }
195            "Expectancy" => {
196                let stat = statistic.extract::<Expectancy>(py)?;
197                self.register_statistic(Arc::new(stat));
198            }
199            "WinRate" => {
200                let stat = statistic.extract::<WinRate>(py)?;
201                self.register_statistic(Arc::new(stat));
202            }
203            "ReturnsVolatility" => {
204                let stat = statistic.extract::<ReturnsVolatility>(py)?;
205                self.register_statistic(Arc::new(stat));
206            }
207            "ReturnsAverage" => {
208                let stat = statistic.extract::<ReturnsAverage>(py)?;
209                self.register_statistic(Arc::new(stat));
210            }
211            "ReturnsAverageLoss" => {
212                let stat = statistic.extract::<ReturnsAverageLoss>(py)?;
213                self.register_statistic(Arc::new(stat));
214            }
215            "ReturnsAverageWin" => {
216                let stat = statistic.extract::<ReturnsAverageWin>(py)?;
217                self.register_statistic(Arc::new(stat));
218            }
219            "SharpeRatio" => {
220                let stat = statistic.extract::<SharpeRatio>(py)?;
221                self.register_statistic(Arc::new(stat));
222            }
223            "SortinoRatio" => {
224                let stat = statistic.extract::<SortinoRatio>(py)?;
225                self.register_statistic(Arc::new(stat));
226            }
227            "ProfitFactor" => {
228                let stat = statistic.extract::<ProfitFactor>(py)?;
229                self.register_statistic(Arc::new(stat));
230            }
231            "RiskReturnRatio" => {
232                let stat = statistic.extract::<RiskReturnRatio>(py)?;
233                self.register_statistic(Arc::new(stat));
234            }
235            "LongRatio" => {
236                let stat = statistic.extract::<LongRatio>(py)?;
237                self.register_statistic(Arc::new(stat));
238            }
239            "CAGR" => {
240                let stat = statistic.extract::<CAGR>(py)?;
241                self.register_statistic(Arc::new(stat));
242            }
243            "CalmarRatio" => {
244                let stat = statistic.extract::<CalmarRatio>(py)?;
245                self.register_statistic(Arc::new(stat));
246            }
247            "MaxDrawdown" => {
248                let stat = statistic.extract::<MaxDrawdown>(py)?;
249                self.register_statistic(Arc::new(stat));
250            }
251            "Alpha" => {
252                let stat = statistic.extract::<Alpha>(py)?;
253                self.register_statistic(Arc::new(stat));
254            }
255            "BetaRatio" => {
256                let stat = statistic.extract::<BetaRatio>(py)?;
257                self.register_statistic(Arc::new(stat));
258            }
259            "DownCaptureRatio" => {
260                let stat = statistic.extract::<DownCaptureRatio>(py)?;
261                self.register_statistic(Arc::new(stat));
262            }
263            "InformationRatio" => {
264                let stat = statistic.extract::<InformationRatio>(py)?;
265                self.register_statistic(Arc::new(stat));
266            }
267            "TrackingError" => {
268                let stat = statistic.extract::<TrackingError>(py)?;
269                self.register_statistic(Arc::new(stat));
270            }
271            "TreynorRatio" => {
272                let stat = statistic.extract::<TreynorRatio>(py)?;
273                self.register_statistic(Arc::new(stat));
274            }
275            "ReturnsSkewness" => {
276                let stat = statistic.extract::<ReturnsSkewness>(py)?;
277                self.register_statistic(Arc::new(stat));
278            }
279            "ReturnsKurtosis" => {
280                let stat = statistic.extract::<ReturnsKurtosis>(py)?;
281                self.register_statistic(Arc::new(stat));
282            }
283            "TailRatio" => {
284                let stat = statistic.extract::<TailRatio>(py)?;
285                self.register_statistic(Arc::new(stat));
286            }
287            "UlcerIndex" => {
288                let stat = statistic.extract::<UlcerIndex>(py)?;
289                self.register_statistic(Arc::new(stat));
290            }
291            "OmegaRatio" => {
292                let stat = statistic.extract::<OmegaRatio>(py)?;
293                self.register_statistic(Arc::new(stat));
294            }
295            "ValueAtRisk" => {
296                let stat = statistic.extract::<ValueAtRisk>(py)?;
297                self.register_statistic(Arc::new(stat));
298            }
299            "ExpectedShortfall" => {
300                let stat = statistic.extract::<ExpectedShortfall>(py)?;
301                self.register_statistic(Arc::new(stat));
302            }
303            "UpCaptureRatio" => {
304                let stat = statistic.extract::<UpCaptureRatio>(py)?;
305                self.register_statistic(Arc::new(stat));
306            }
307            _ => {
308                return Err(to_pyvalue_err(format!(
309                    "Unknown statistic type: {type_name}"
310                )));
311            }
312        }
313
314        Ok(())
315    }
316
317    /// Removes a specific statistic from calculation.
318    #[pyo3(name = "deregister_statistic")]
319    #[expect(clippy::needless_pass_by_value)]
320    #[expect(clippy::too_many_lines)]
321    fn py_deregister_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
322        let type_name = statistic
323            .getattr(py, "__class__")?
324            .getattr(py, "__name__")?
325            .extract::<String>(py)?;
326
327        match type_name.as_str() {
328            "MaxWinner" => {
329                let stat = statistic.extract::<MaxWinner>(py)?;
330                self.deregister_statistic(&(Arc::new(stat) as Statistic));
331            }
332            "MinWinner" => {
333                let stat = statistic.extract::<MinWinner>(py)?;
334                self.deregister_statistic(&(Arc::new(stat) as Statistic));
335            }
336            "AvgWinner" => {
337                let stat = statistic.extract::<AvgWinner>(py)?;
338                self.deregister_statistic(&(Arc::new(stat) as Statistic));
339            }
340            "MaxLoser" => {
341                let stat = statistic.extract::<MaxLoser>(py)?;
342                self.deregister_statistic(&(Arc::new(stat) as Statistic));
343            }
344            "MinLoser" => {
345                let stat = statistic.extract::<MinLoser>(py)?;
346                self.deregister_statistic(&(Arc::new(stat) as Statistic));
347            }
348            "AvgLoser" => {
349                let stat = statistic.extract::<AvgLoser>(py)?;
350                self.deregister_statistic(&(Arc::new(stat) as Statistic));
351            }
352            "Expectancy" => {
353                let stat = statistic.extract::<Expectancy>(py)?;
354                self.deregister_statistic(&(Arc::new(stat) as Statistic));
355            }
356            "WinRate" => {
357                let stat = statistic.extract::<WinRate>(py)?;
358                self.deregister_statistic(&(Arc::new(stat) as Statistic));
359            }
360            "ReturnsVolatility" => {
361                let stat = statistic.extract::<ReturnsVolatility>(py)?;
362                self.deregister_statistic(&(Arc::new(stat) as Statistic));
363            }
364            "ReturnsAverage" => {
365                let stat = statistic.extract::<ReturnsAverage>(py)?;
366                self.deregister_statistic(&(Arc::new(stat) as Statistic));
367            }
368            "ReturnsAverageLoss" => {
369                let stat = statistic.extract::<ReturnsAverageLoss>(py)?;
370                self.deregister_statistic(&(Arc::new(stat) as Statistic));
371            }
372            "ReturnsAverageWin" => {
373                let stat = statistic.extract::<ReturnsAverageWin>(py)?;
374                self.deregister_statistic(&(Arc::new(stat) as Statistic));
375            }
376            "SharpeRatio" => {
377                let stat = statistic.extract::<SharpeRatio>(py)?;
378                self.deregister_statistic(&(Arc::new(stat) as Statistic));
379            }
380            "SortinoRatio" => {
381                let stat = statistic.extract::<SortinoRatio>(py)?;
382                self.deregister_statistic(&(Arc::new(stat) as Statistic));
383            }
384            "ProfitFactor" => {
385                let stat = statistic.extract::<ProfitFactor>(py)?;
386                self.deregister_statistic(&(Arc::new(stat) as Statistic));
387            }
388            "RiskReturnRatio" => {
389                let stat = statistic.extract::<RiskReturnRatio>(py)?;
390                self.deregister_statistic(&(Arc::new(stat) as Statistic));
391            }
392            "LongRatio" => {
393                let stat = statistic.extract::<LongRatio>(py)?;
394                self.deregister_statistic(&(Arc::new(stat) as Statistic));
395            }
396            "CAGR" => {
397                let stat = statistic.extract::<CAGR>(py)?;
398                self.deregister_statistic(&(Arc::new(stat) as Statistic));
399            }
400            "CalmarRatio" => {
401                let stat = statistic.extract::<CalmarRatio>(py)?;
402                self.deregister_statistic(&(Arc::new(stat) as Statistic));
403            }
404            "MaxDrawdown" => {
405                let stat = statistic.extract::<MaxDrawdown>(py)?;
406                self.deregister_statistic(&(Arc::new(stat) as Statistic));
407            }
408            "Alpha" => {
409                let stat = statistic.extract::<Alpha>(py)?;
410                self.deregister_statistic(&(Arc::new(stat) as Statistic));
411            }
412            "BetaRatio" => {
413                let stat = statistic.extract::<BetaRatio>(py)?;
414                self.deregister_statistic(&(Arc::new(stat) as Statistic));
415            }
416            "DownCaptureRatio" => {
417                let stat = statistic.extract::<DownCaptureRatio>(py)?;
418                self.deregister_statistic(&(Arc::new(stat) as Statistic));
419            }
420            "InformationRatio" => {
421                let stat = statistic.extract::<InformationRatio>(py)?;
422                self.deregister_statistic(&(Arc::new(stat) as Statistic));
423            }
424            "TrackingError" => {
425                let stat = statistic.extract::<TrackingError>(py)?;
426                self.deregister_statistic(&(Arc::new(stat) as Statistic));
427            }
428            "TreynorRatio" => {
429                let stat = statistic.extract::<TreynorRatio>(py)?;
430                self.deregister_statistic(&(Arc::new(stat) as Statistic));
431            }
432            "ReturnsSkewness" => {
433                let stat = statistic.extract::<ReturnsSkewness>(py)?;
434                self.deregister_statistic(&(Arc::new(stat) as Statistic));
435            }
436            "ReturnsKurtosis" => {
437                let stat = statistic.extract::<ReturnsKurtosis>(py)?;
438                self.deregister_statistic(&(Arc::new(stat) as Statistic));
439            }
440            "TailRatio" => {
441                let stat = statistic.extract::<TailRatio>(py)?;
442                self.deregister_statistic(&(Arc::new(stat) as Statistic));
443            }
444            "UlcerIndex" => {
445                let stat = statistic.extract::<UlcerIndex>(py)?;
446                self.deregister_statistic(&(Arc::new(stat) as Statistic));
447            }
448            "OmegaRatio" => {
449                let stat = statistic.extract::<OmegaRatio>(py)?;
450                self.deregister_statistic(&(Arc::new(stat) as Statistic));
451            }
452            "ValueAtRisk" => {
453                let stat = statistic.extract::<ValueAtRisk>(py)?;
454                self.deregister_statistic(&(Arc::new(stat) as Statistic));
455            }
456            "ExpectedShortfall" => {
457                let stat = statistic.extract::<ExpectedShortfall>(py)?;
458                self.deregister_statistic(&(Arc::new(stat) as Statistic));
459            }
460            "UpCaptureRatio" => {
461                let stat = statistic.extract::<UpCaptureRatio>(py)?;
462                self.deregister_statistic(&(Arc::new(stat) as Statistic));
463            }
464            _ => {
465                return Err(to_pyvalue_err(format!(
466                    "Unknown statistic type: {type_name}"
467                )));
468            }
469        }
470
471        Ok(())
472    }
473
474    /// Removes all registered statistics.
475    #[pyo3(name = "deregister_statistics")]
476    fn py_deregister_statistics(&mut self) {
477        self.deregister_statistics();
478    }
479
480    /// Adds new positions for analysis.
481    #[pyo3(name = "add_positions")]
482    #[expect(clippy::needless_pass_by_value)]
483    fn py_add_positions(&mut self, py: Python, positions: Vec<Py<PyAny>>) -> PyResult<()> {
484        let positions: Vec<Position> = positions
485            .iter()
486            .map(|position| position.extract::<Position>(py).map_err(Into::into))
487            .collect::<PyResult<Vec<Position>>>()?;
488
489        self.add_positions(&positions);
490        Ok(())
491    }
492
493    /// Records a trade's PnL realized at `ts_event`.
494    #[pyo3(name = "add_trade")]
495    #[allow(
496        clippy::trivially_copy_pass_by_ref,
497        reason = "matches underlying add_trade signature"
498    )]
499    fn py_add_trade(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
500        self.add_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
501    }
502
503    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
504    #[pyo3(name = "record_trade")]
505    #[allow(
506        clippy::trivially_copy_pass_by_ref,
507        reason = "matches underlying record_trade signature"
508    )]
509    fn py_record_trade(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
510        self.record_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
511    }
512
513    // Note: calculate_statistics is not exposed to Python because it requires
514    // complex conversions of Account and dict types. Use the Python analyzer.py wrapper instead.
515
516    /// Retrieves a specific statistic by name.
517    #[pyo3(name = "statistic")]
518    fn py_statistic(&self, name: &str) -> Option<String> {
519        self.statistic(name).map(|s| s.name())
520    }
521
522    /// Returns the primary calculated returns.
523    ///
524    /// This returns portfolio returns when available, otherwise it falls back
525    /// to position returns for backward compatibility.
526    #[pyo3(name = "returns")]
527    fn py_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
528        // Convert BTreeMap<UnixNanos, f64> to Python dict
529        let dict = pyo3::types::PyDict::new(py);
530        for (timestamp, value) in self.returns() {
531            dict.set_item(timestamp.as_u64(), value)?;
532        }
533        Ok(dict.into())
534    }
535
536    /// Returns the per-position calculated returns.
537    #[pyo3(name = "position_returns")]
538    fn py_position_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
539        let dict = pyo3::types::PyDict::new(py);
540        for (timestamp, value) in self.position_returns() {
541            dict.set_item(timestamp.as_u64(), value)?;
542        }
543        Ok(dict.into())
544    }
545
546    /// Returns the portfolio calculated returns.
547    #[pyo3(name = "portfolio_returns")]
548    fn py_portfolio_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
549        let dict = pyo3::types::PyDict::new(py);
550        for (timestamp, value) in self.portfolio_returns() {
551            dict.set_item(timestamp.as_u64(), value)?;
552        }
553        Ok(dict.into())
554    }
555
556    /// Retrieves realized PnLs for a specific currency.
557    ///
558    /// Each record is `(position_id, ts_event, realized_pnl)`. Returns `None` if no PnLs
559    /// exist, or if multiple currencies exist without an explicit currency specified.
560    #[pyo3(name = "realized_pnls")]
561    fn py_realized_pnls(&self, py: Python, currency: Option<&Currency>) -> PyResult<Py<PyAny>> {
562        match self.realized_pnls(currency) {
563            Some(pnls) => {
564                let list = pyo3::types::PyList::empty(py);
565                for (position_id, ts_event, pnl) in pnls {
566                    list.append((position_id.to_string(), ts_event.as_u64(), pnl))?;
567                }
568                Ok(list.into())
569            }
570            None => Ok(py.None()),
571        }
572    }
573
574    /// Calculates total PnL including unrealized PnL if provided.
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if:
579    /// - No currency is specified in a multi-currency portfolio.
580    /// - The specified currency is not found in account balances.
581    /// - The unrealized PnL currency does not match the specified currency.
582    #[pyo3(name = "total_pnl")]
583    fn py_total_pnl(
584        &self,
585        currency: Option<&Currency>,
586        unrealized_pnl: Option<&Money>,
587    ) -> PyResult<f64> {
588        self.total_pnl(currency, unrealized_pnl)
589            .map_err(to_pyvalue_err)
590    }
591
592    /// Calculates total PnL as a percentage of starting balance.
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if:
597    /// - No currency is specified in a multi-currency portfolio.
598    /// - The specified currency is not found in account balances.
599    /// - The unrealized PnL currency does not match the specified currency.
600    #[pyo3(name = "total_pnl_percentage")]
601    fn py_total_pnl_percentage(
602        &self,
603        currency: Option<&Currency>,
604        unrealized_pnl: Option<&Money>,
605    ) -> PyResult<f64> {
606        self.total_pnl_percentage(currency, unrealized_pnl)
607            .map_err(to_pyvalue_err)
608    }
609
610    /// Gets formatted PnL statistics as strings.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if PnL statistics calculation fails.
615    #[pyo3(name = "get_stats_pnls_formatted")]
616    fn py_get_stats_pnls_formatted(
617        &self,
618        currency: Option<&Currency>,
619        unrealized_pnl: Option<&Money>,
620    ) -> PyResult<Vec<String>> {
621        self.get_stats_pnls_formatted(currency, unrealized_pnl)
622            .map_err(to_pyvalue_err)
623    }
624
625    /// Gets formatted return statistics as strings.
626    #[pyo3(name = "get_stats_returns_formatted")]
627    fn py_get_stats_returns_formatted(&self) -> Vec<String> {
628        self.get_stats_returns_formatted()
629    }
630
631    /// Gets formatted position-return statistics as strings.
632    #[pyo3(name = "get_stats_position_returns_formatted")]
633    fn py_get_stats_position_returns_formatted(&self) -> Vec<String> {
634        self.get_stats_position_returns_formatted()
635    }
636
637    /// Gets formatted portfolio-return statistics as strings.
638    #[pyo3(name = "get_stats_portfolio_returns_formatted")]
639    fn py_get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
640        self.get_stats_portfolio_returns_formatted()
641    }
642
643    /// Gets formatted general statistics as strings.
644    #[pyo3(name = "get_stats_general_formatted")]
645    fn py_get_stats_general_formatted(&self) -> Vec<String> {
646        self.get_stats_general_formatted()
647    }
648}