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},
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 PnL calculations fail, for example due to:
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(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
182        self.add_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
183    }
184
185    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
186    #[pyo3(name = "record_trade")]
187    #[allow(
188        clippy::trivially_copy_pass_by_ref,
189        reason = "matches underlying record_trade signature"
190    )]
191    fn py_record_trade(&mut self, position_id: &PositionId, ts_event: u64, realized_pnl: &Money) {
192        self.record_trade(position_id, UnixNanos::from(ts_event), realized_pnl);
193    }
194
195    // Note: calculate_statistics is not exposed to Python because it requires
196    // complex conversions of Account and dict types. Use the Python analyzer.py wrapper instead.
197
198    /// Retrieves a specific statistic by name.
199    #[pyo3(name = "statistic")]
200    fn py_statistic(&self, name: &str) -> Option<String> {
201        self.statistic(name).map(|s| s.name())
202    }
203
204    /// Returns the primary calculated returns.
205    ///
206    /// This returns portfolio returns when available, otherwise it falls back
207    /// to position returns for backward compatibility.
208    #[pyo3(name = "returns")]
209    fn py_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
210        // Convert BTreeMap<UnixNanos, f64> to Python dict
211        let dict = pyo3::types::PyDict::new(py);
212        for (timestamp, value) in self.returns() {
213            dict.set_item(timestamp.as_u64(), value)?;
214        }
215        Ok(dict.into())
216    }
217
218    /// Returns the per-position calculated returns.
219    #[pyo3(name = "position_returns")]
220    fn py_position_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
221        let dict = pyo3::types::PyDict::new(py);
222        for (timestamp, value) in self.position_returns() {
223            dict.set_item(timestamp.as_u64(), value)?;
224        }
225        Ok(dict.into())
226    }
227
228    /// Returns the portfolio calculated returns.
229    #[pyo3(name = "portfolio_returns")]
230    fn py_portfolio_returns(&self, py: Python) -> PyResult<Py<PyAny>> {
231        let dict = pyo3::types::PyDict::new(py);
232        for (timestamp, value) in self.portfolio_returns() {
233            dict.set_item(timestamp.as_u64(), value)?;
234        }
235        Ok(dict.into())
236    }
237
238    /// Retrieves realized PnLs for a specific currency.
239    ///
240    /// Each record is `(position_id, ts_event, realized_pnl)`, in ascending `ts_event` order.
241    /// Returns `None` if no PnLs exist, or if multiple currencies exist without an explicit
242    /// currency specified.
243    #[pyo3(name = "realized_pnls")]
244    fn py_realized_pnls(&self, py: Python, currency: Option<&Currency>) -> PyResult<Py<PyAny>> {
245        match self.realized_pnls(currency) {
246            Some(pnls) => {
247                let list = pyo3::types::PyList::empty(py);
248                for (position_id, ts_event, pnl) in pnls {
249                    list.append((position_id.to_string(), ts_event.as_u64(), pnl))?;
250                }
251                Ok(list.into())
252            }
253            None => Ok(py.None()),
254        }
255    }
256
257    /// Calculates total PnL including unrealized PnL if provided.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if:
262    /// - No currency is specified in a multi-currency portfolio.
263    /// - The specified currency is not found in account balances.
264    /// - The unrealized PnL currency does not match the specified currency.
265    #[pyo3(name = "total_pnl")]
266    fn py_total_pnl(
267        &self,
268        currency: Option<&Currency>,
269        unrealized_pnl: Option<&Money>,
270    ) -> PyResult<f64> {
271        self.total_pnl(currency, unrealized_pnl)
272            .map_err(to_pyvalue_err)
273    }
274
275    /// Calculates total PnL as a percentage of starting balance.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if:
280    /// - No currency is specified in a multi-currency portfolio.
281    /// - The specified currency is not found in account balances.
282    /// - The unrealized PnL currency does not match the specified currency.
283    #[pyo3(name = "total_pnl_percentage")]
284    fn py_total_pnl_percentage(
285        &self,
286        currency: Option<&Currency>,
287        unrealized_pnl: Option<&Money>,
288    ) -> PyResult<f64> {
289        self.total_pnl_percentage(currency, unrealized_pnl)
290            .map_err(to_pyvalue_err)
291    }
292
293    /// Gets formatted PnL statistics as strings.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if PnL statistics calculation fails.
298    #[pyo3(name = "get_stats_pnls_formatted")]
299    fn py_get_stats_pnls_formatted(
300        &self,
301        currency: Option<&Currency>,
302        unrealized_pnl: Option<&Money>,
303    ) -> PyResult<Vec<String>> {
304        self.get_stats_pnls_formatted(currency, unrealized_pnl)
305            .map_err(to_pyvalue_err)
306    }
307
308    /// Gets formatted return statistics as strings.
309    #[pyo3(name = "get_stats_returns_formatted")]
310    fn py_get_stats_returns_formatted(&self) -> Vec<String> {
311        self.get_stats_returns_formatted()
312    }
313
314    /// Gets formatted position-return statistics as strings.
315    #[pyo3(name = "get_stats_position_returns_formatted")]
316    fn py_get_stats_position_returns_formatted(&self) -> Vec<String> {
317        self.get_stats_position_returns_formatted()
318    }
319
320    /// Gets formatted portfolio-return statistics as strings.
321    #[pyo3(name = "get_stats_portfolio_returns_formatted")]
322    fn py_get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
323        self.get_stats_portfolio_returns_formatted()
324    }
325
326    /// Gets formatted general statistics as strings.
327    #[pyo3(name = "get_stats_general_formatted")]
328    fn py_get_stats_general_formatted(&self) -> Vec<String> {
329        self.get_stats_general_formatted()
330    }
331}