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::collections::{BTreeMap, HashMap};
17
18use nautilus_core::{UnixNanos, python::to_pyvalue_err};
19use nautilus_model::{
20    identifiers::PositionId,
21    position::Position,
22    types::{Currency, Money, fixed::check_float_precision},
23};
24use pyo3::prelude::*;
25
26use crate::{Returns, analyzer::PortfolioAnalyzer, python::statistic::statistic_from_pyobject};
27
28#[pymethods]
29#[pyo3_stub_gen::derive::gen_stub_pymethods]
30impl PortfolioAnalyzer {
31    /// Analyzes portfolio performance and calculates various statistics.
32    ///
33    /// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
34    /// to provide portfolio analysis including returns, PnL calculations,
35    /// and customizable statistics.
36    #[new]
37    #[must_use]
38    pub fn py_new() -> Self {
39        Self::new()
40    }
41
42    fn __repr__(&self) -> String {
43        format!("PortfolioAnalyzer(currencies={})", self.currencies().len())
44    }
45
46    /// Returns all tracked currencies.
47    #[pyo3(name = "currencies")]
48    fn py_currencies(&self) -> Vec<Currency> {
49        self.currencies().into_iter().copied().collect()
50    }
51
52    /// Gets all return-based performance statistics.
53    #[pyo3(name = "get_performance_stats_returns")]
54    fn py_get_performance_stats_returns(&self) -> HashMap<String, f64> {
55        self.get_performance_stats_returns().into_iter().collect()
56    }
57
58    /// Gets all position-return-based performance statistics.
59    #[pyo3(name = "get_performance_stats_position_returns")]
60    fn py_get_performance_stats_position_returns(&self) -> HashMap<String, f64> {
61        self.get_performance_stats_position_returns()
62            .into_iter()
63            .collect()
64    }
65
66    /// Gets all portfolio-return-based performance statistics.
67    #[pyo3(name = "get_performance_stats_portfolio_returns")]
68    fn py_get_performance_stats_portfolio_returns(&self) -> HashMap<String, f64> {
69        self.get_performance_stats_portfolio_returns()
70            .into_iter()
71            .collect()
72    }
73
74    /// Gets all benchmark-relative return statistics for the primary returns.
75    ///
76    /// This is stateless: the `benchmark` series is supplied by the caller rather
77    /// than stored on the analyzer. Only statistics that override
78    /// `PortfolioStatistic.calculate_from_returns_with_benchmark` (the benchmark-relative
79    /// statistics) contribute values; all others return `None` and are skipped.
80    #[pyo3(name = "get_performance_stats_returns_vs_benchmark")]
81    fn py_get_performance_stats_returns_vs_benchmark(
82        &self,
83        benchmark: BTreeMap<u64, f64>,
84    ) -> HashMap<String, f64> {
85        let benchmark: Returns = benchmark
86            .into_iter()
87            .map(|(k, v)| (UnixNanos::from(k), v))
88            .collect();
89        self.get_performance_stats_returns_vs_benchmark(&benchmark)
90            .into_iter()
91            .collect()
92    }
93
94    /// Gets all PnL-related performance statistics.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if:
99    ///
100    /// - No currency specified for a multi-currency portfolio.
101    /// - Unrealized PnL currency not matching the specified currency.
102    /// - Specified currency not found in account balances.
103    #[pyo3(name = "get_performance_stats_pnls")]
104    fn py_get_performance_stats_pnls(
105        &self,
106        currency: Option<&Currency>,
107        unrealized_pnl: Option<&Money>,
108    ) -> PyResult<HashMap<String, f64>> {
109        self.get_performance_stats_pnls(currency, unrealized_pnl)
110            .map(|m| m.into_iter().collect())
111            .map_err(to_pyvalue_err)
112    }
113
114    /// Gets general portfolio statistics.
115    #[pyo3(name = "get_performance_stats_general")]
116    fn py_get_performance_stats_general(&self) -> HashMap<String, f64> {
117        self.get_performance_stats_general().into_iter().collect()
118    }
119
120    /// Records a position return at a specific timestamp.
121    #[pyo3(name = "add_position_return")]
122    fn py_add_position_return(&mut self, timestamp: u64, value: f64) {
123        self.add_position_return(UnixNanos::from(timestamp), value);
124    }
125
126    /// Records a return at a specific timestamp.
127    ///
128    /// This is a backward-compatible alias for `Self.add_position_return`.
129    #[pyo3(name = "add_return")]
130    fn py_add_return(&mut self, timestamp: u64, value: f64) {
131        self.add_return(UnixNanos::from(timestamp), value);
132    }
133
134    /// Resets all analysis data to initial state.
135    ///
136    /// Registered statistics are retained; use `Self.deregister_statistics` to clear them.
137    #[pyo3(name = "reset")]
138    fn py_reset(&mut self) {
139        self.reset();
140    }
141
142    /// Registers a new portfolio statistic for calculation.
143    #[pyo3(name = "register_statistic")]
144    fn py_register_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
145        self.register_statistic(statistic_from_pyobject(py, statistic)?);
146        Ok(())
147    }
148
149    /// Removes a specific statistic from calculation.
150    #[pyo3(name = "deregister_statistic")]
151    fn py_deregister_statistic(&mut self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
152        self.deregister_statistic(&statistic_from_pyobject(py, statistic)?);
153        Ok(())
154    }
155
156    /// Removes all registered statistics.
157    #[pyo3(name = "deregister_statistics")]
158    fn py_deregister_statistics(&mut self) {
159        self.deregister_statistics();
160    }
161
162    /// Adds new positions for analysis.
163    #[pyo3(name = "add_positions")]
164    #[expect(clippy::needless_pass_by_value)]
165    fn py_add_positions(&mut self, py: Python, positions: Vec<Py<PyAny>>) -> PyResult<()> {
166        let positions: Vec<Position> = positions
167            .iter()
168            .map(|position| position.extract::<Position>(py).map_err(Into::into))
169            .collect::<PyResult<Vec<Position>>>()?;
170
171        self.add_positions(&positions);
172        Ok(())
173    }
174
175    /// Records a trade's PnL realized at `ts_event`.
176    #[pyo3(name = "add_trade")]
177    #[allow(
178        clippy::trivially_copy_pass_by_ref,
179        reason = "matches underlying add_trade signature"
180    )]
181    fn py_add_trade(
182        &mut self,
183        position_id: &PositionId,
184        ts_event: u64,
185        realized_pnl: &Money,
186    ) -> PyResult<()> {
187        check_float_precision(realized_pnl.currency.precision).map_err(to_pyvalue_err)?;
188        self.add_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
189        Ok(())
190    }
191
192    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
193    #[pyo3(name = "record_trade")]
194    #[allow(
195        clippy::trivially_copy_pass_by_ref,
196        reason = "matches underlying record_trade signature"
197    )]
198    fn py_record_trade(
199        &mut self,
200        position_id: &PositionId,
201        ts_event: u64,
202        realized_pnl: &Money,
203    ) -> PyResult<()> {
204        check_float_precision(realized_pnl.currency.precision).map_err(to_pyvalue_err)?;
205        self.record_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
206        Ok(())
207    }
208
209    // Note: calculate_statistics is not exposed to Python because it requires
210    // complex conversions of Account and dict types. Use the Python analyzer.py wrapper instead.
211
212    /// Retrieves a specific statistic by name.
213    #[pyo3(name = "statistic")]
214    fn py_statistic(&self, name: &str) -> Option<String> {
215        self.statistic(name).map(|s| s.name())
216    }
217
218    /// Returns the primary calculated returns.
219    ///
220    /// This returns portfolio returns when available, otherwise it falls back
221    /// to position returns for backward compatibility.
222    #[pyo3(name = "returns")]
223    fn py_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
224        // Convert BTreeMap<UnixNanos, f64> to Python dict
225        let dict = pyo3::types::PyDict::new(py);
226        for (timestamp, value) in self.returns() {
227            dict.set_item(timestamp.as_u64(), value)?;
228        }
229        Ok(dict.into())
230    }
231
232    /// Returns the per-position calculated returns.
233    #[pyo3(name = "position_returns")]
234    fn py_position_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
235        let dict = pyo3::types::PyDict::new(py);
236        for (timestamp, value) in self.position_returns() {
237            dict.set_item(timestamp.as_u64(), value)?;
238        }
239        Ok(dict.into())
240    }
241
242    /// Returns the portfolio calculated returns.
243    #[pyo3(name = "portfolio_returns")]
244    fn py_portfolio_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
245        let dict = pyo3::types::PyDict::new(py);
246        for (timestamp, value) in self.portfolio_returns() {
247            dict.set_item(timestamp.as_u64(), value)?;
248        }
249        Ok(dict.into())
250    }
251
252    /// Retrieves realized PnLs for a specific currency.
253    ///
254    /// Each record is `(position_id, ts_event, realized_pnl)`, in ascending `ts_event` order.
255    /// Returns `None` if no PnLs exist, or if multiple currencies exist without an explicit
256    /// currency specified.
257    #[pyo3(name = "realized_pnls")]
258    fn py_realized_pnls(&self, py: Python, currency: Option<&Currency>) -> PyResult<Py<PyAny>> {
259        match self.realized_pnls(currency) {
260            Some(pnls) => {
261                let list = pyo3::types::PyList::empty(py);
262                for (position_id, ts_event, pnl) in pnls {
263                    list.append((position_id.to_string(), ts_event.as_u64(), pnl))?;
264                }
265                Ok(list.into())
266            }
267            None => Ok(py.None()),
268        }
269    }
270
271    /// Calculates total PnL including unrealized PnL if provided.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if:
276    /// - No currency is specified in a multi-currency portfolio.
277    /// - The specified currency is not found in account balances.
278    /// - The unrealized PnL currency does not match the specified currency.
279    #[pyo3(name = "total_pnl")]
280    fn py_total_pnl(
281        &self,
282        currency: Option<&Currency>,
283        unrealized_pnl: Option<&Money>,
284    ) -> PyResult<f64> {
285        self.total_pnl(currency, unrealized_pnl)
286            .map_err(to_pyvalue_err)
287    }
288
289    /// Calculates total PnL as a percentage of starting balance.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if:
294    /// - No currency is specified in a multi-currency portfolio.
295    /// - The specified currency is not found in account balances.
296    /// - The unrealized PnL currency does not match the specified currency.
297    #[pyo3(name = "total_pnl_percentage")]
298    fn py_total_pnl_percentage(
299        &self,
300        currency: Option<&Currency>,
301        unrealized_pnl: Option<&Money>,
302    ) -> PyResult<f64> {
303        self.total_pnl_percentage(currency, unrealized_pnl)
304            .map_err(to_pyvalue_err)
305    }
306
307    /// Gets formatted PnL statistics as strings.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if PnL statistics calculation fails.
312    #[pyo3(name = "get_stats_pnls_formatted")]
313    fn py_get_stats_pnls_formatted(
314        &self,
315        currency: Option<&Currency>,
316        unrealized_pnl: Option<&Money>,
317    ) -> PyResult<Vec<String>> {
318        self.get_stats_pnls_formatted(currency, unrealized_pnl)
319            .map_err(to_pyvalue_err)
320    }
321
322    /// Gets formatted return statistics as strings.
323    #[pyo3(name = "get_stats_returns_formatted")]
324    fn py_get_stats_returns_formatted(&self) -> Vec<String> {
325        self.get_stats_returns_formatted()
326    }
327
328    /// Gets formatted position-return statistics as strings.
329    #[pyo3(name = "get_stats_position_returns_formatted")]
330    fn py_get_stats_position_returns_formatted(&self) -> Vec<String> {
331        self.get_stats_position_returns_formatted()
332    }
333
334    /// Gets formatted portfolio-return statistics as strings.
335    #[pyo3(name = "get_stats_portfolio_returns_formatted")]
336    fn py_get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
337        self.get_stats_portfolio_returns_formatted()
338    }
339
340    /// Gets formatted general statistics as strings.
341    #[pyo3(name = "get_stats_general_formatted")]
342    fn py_get_stats_general_formatted(&self) -> Vec<String> {
343        self.get_stats_general_formatted()
344    }
345}