Skip to main content

nautilus_portfolio/python/
mod.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
16//! Python bindings from [PyO3](https://pyo3.rs).
17
18use std::{cell::RefCell, rc::Rc};
19
20use indexmap::{IndexMap, IndexSet};
21use nautilus_analysis::{
22    python::statistic::statistic_from_pyobject, snapshot::PortfolioStatistics,
23};
24use nautilus_common::python::config_error_to_pyvalue_err;
25use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
26use nautilus_model::{
27    accounts::AccountAny,
28    events::PortfolioSnapshot,
29    identifiers::{AccountId, InstrumentId, Venue},
30    python::account::account_any_to_pyobject,
31    types::{Currency, Money, Price},
32};
33use pyo3::{prelude::*, types::PyDict};
34use rust_decimal::Decimal;
35
36use crate::{config::PortfolioConfig, portfolio::Portfolio};
37
38#[pyo3_stub_gen::derive::gen_stub_pymethods]
39#[pymethods]
40impl PortfolioConfig {
41    /// Configuration for `Portfolio` instances.
42    #[new]
43    #[pyo3(signature = (use_mark_prices=None, use_mark_xrates=None, bar_updates=None, convert_to_account_base_currency=None, equity_curve=None, min_account_state_logging_interval_ms=None, debug=None, snapshot_interval_ms=None))]
44    #[expect(clippy::too_many_arguments)]
45    fn py_new(
46        use_mark_prices: Option<bool>,
47        use_mark_xrates: Option<bool>,
48        bar_updates: Option<bool>,
49        convert_to_account_base_currency: Option<bool>,
50        equity_curve: Option<bool>,
51        min_account_state_logging_interval_ms: Option<u64>,
52        debug: Option<bool>,
53        snapshot_interval_ms: Option<u64>,
54    ) -> PyResult<Self> {
55        let default = Self::default();
56        let config = Self {
57            use_mark_prices: use_mark_prices.unwrap_or(default.use_mark_prices),
58            use_mark_xrates: use_mark_xrates.unwrap_or(default.use_mark_xrates),
59            bar_updates: bar_updates.unwrap_or(default.bar_updates),
60            convert_to_account_base_currency: convert_to_account_base_currency
61                .unwrap_or(default.convert_to_account_base_currency),
62            equity_curve: equity_curve.unwrap_or(default.equity_curve),
63            min_account_state_logging_interval_ms,
64            snapshot_interval_ms,
65            debug: debug.unwrap_or(default.debug),
66        };
67        config.validate().map_err(config_error_to_pyvalue_err)?;
68        Ok(config)
69    }
70
71    fn __repr__(&self) -> String {
72        format!("{self:?}")
73    }
74
75    fn __str__(&self) -> String {
76        format!("{self:?}")
77    }
78
79    #[getter]
80    fn use_mark_prices(&self) -> bool {
81        self.use_mark_prices
82    }
83
84    #[getter]
85    fn use_mark_xrates(&self) -> bool {
86        self.use_mark_xrates
87    }
88
89    #[getter]
90    fn bar_updates(&self) -> bool {
91        self.bar_updates
92    }
93
94    #[getter]
95    fn convert_to_account_base_currency(&self) -> bool {
96        self.convert_to_account_base_currency
97    }
98
99    #[getter]
100    fn equity_curve(&self) -> bool {
101        self.equity_curve
102    }
103
104    #[getter]
105    fn min_account_state_logging_interval_ms(&self) -> Option<u64> {
106        self.min_account_state_logging_interval_ms
107    }
108
109    #[getter]
110    fn snapshot_interval_ms(&self) -> Option<u64> {
111        self.snapshot_interval_ms
112    }
113
114    #[getter]
115    fn debug(&self) -> bool {
116        self.debug
117    }
118}
119
120/// Wrapper providing shared access to [`Portfolio`] from Python.
121#[pyo3::pyclass(
122    module = "nautilus_trader.portfolio",
123    name = "Portfolio",
124    unsendable,
125    from_py_object
126)]
127#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.portfolio")]
128#[derive(Debug, Clone)]
129pub struct PyPortfolio(Rc<RefCell<Portfolio>>);
130
131impl PyPortfolio {
132    /// Creates a [`PyPortfolio`] from a shared [`Portfolio`].
133    #[must_use]
134    pub fn from_rc(rc: Rc<RefCell<Portfolio>>) -> Self {
135        Self(rc)
136    }
137
138    /// Returns the inner shared [`Portfolio`].
139    #[must_use]
140    pub fn portfolio_rc(&self) -> Rc<RefCell<Portfolio>> {
141        self.0.clone()
142    }
143}
144
145#[pyo3::pymethods]
146#[pyo3_stub_gen::derive::gen_stub_pymethods]
147impl PyPortfolio {
148    #[pyo3(name = "is_initialized")]
149    fn py_is_initialized(&self) -> bool {
150        self.0.borrow().is_initialized()
151    }
152
153    /// Returns a detached, point-in-time copy of the account.
154    ///
155    /// The copy does not reflect later account updates, and changing it does not affect the
156    /// Portfolio. Call `account()` again to obtain the latest account state.
157    #[pyo3(name = "account", signature = (venue=None, account_id=None))]
158    fn py_account(
159        &self,
160        py: Python<'_>,
161        venue: Option<Venue>,
162        account_id: Option<AccountId>,
163    ) -> PyResult<Option<Py<PyAny>>> {
164        match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
165            Some(account) => Ok(Some(account_any_to_pyobject(py, account)?)),
166            None => Ok(None),
167        }
168    }
169
170    #[pyo3(name = "balances_locked", signature = (venue=None, account_id=None))]
171    fn py_balances_locked(
172        &self,
173        py: Python<'_>,
174        venue: Option<Venue>,
175        account_id: Option<AccountId>,
176    ) -> PyResult<Option<Py<PyDict>>> {
177        match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
178            Some(account) => Ok(Some(currency_money_map_to_pydict(
179                py,
180                account.balances_locked(),
181            )?)),
182            None => Ok(None),
183        }
184    }
185
186    #[pyo3(name = "instrument_initial_margins", signature = (venue=None, account_id=None))]
187    fn py_instrument_initial_margins(
188        &self,
189        py: Python<'_>,
190        venue: Option<Venue>,
191        account_id: Option<AccountId>,
192    ) -> PyResult<Option<Py<PyDict>>> {
193        match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
194            Some(AccountAny::Margin(account)) => Ok(Some(instrument_money_map_to_pydict(
195                py,
196                account.initial_margins(),
197            )?)),
198            Some(AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_)) | None => {
199                Ok(None)
200            }
201        }
202    }
203
204    #[pyo3(name = "instrument_maintenance_margins", signature = (venue=None, account_id=None))]
205    fn py_instrument_maintenance_margins(
206        &self,
207        py: Python<'_>,
208        venue: Option<Venue>,
209        account_id: Option<AccountId>,
210    ) -> PyResult<Option<Py<PyDict>>> {
211        match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
212            Some(AccountAny::Margin(account)) => Ok(Some(instrument_money_map_to_pydict(
213                py,
214                account.maintenance_margins(),
215            )?)),
216            Some(AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_)) | None => {
217                Ok(None)
218            }
219        }
220    }
221
222    #[pyo3(name = "realized_pnls", signature = (venue=None, account_id=None, target_currency=None))]
223    fn py_realized_pnls(
224        &self,
225        py: Python<'_>,
226        venue: Option<Venue>,
227        account_id: Option<AccountId>,
228        target_currency: Option<Currency>,
229    ) -> PyResult<Py<PyDict>> {
230        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
231        let Some(venue) = venue else {
232            let venues = self.position_venues(false, account_id.as_ref());
233            return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
234                portfolio.realized_pnls(venue, account_id.as_ref(), target_currency)
235            });
236        };
237
238        let map = self
239            .0
240            .borrow_mut()
241            .realized_pnls(&venue, account_id.as_ref(), target_currency)
242            .ok_or_else(|| to_pyruntime_err("failed to calculate realized PnLs"))?;
243        currency_money_map_to_pydict(py, map)
244    }
245
246    #[pyo3(name = "unrealized_pnls", signature = (venue=None, account_id=None, target_currency=None))]
247    fn py_unrealized_pnls(
248        &self,
249        py: Python<'_>,
250        venue: Option<Venue>,
251        account_id: Option<AccountId>,
252        target_currency: Option<Currency>,
253    ) -> PyResult<Py<PyDict>> {
254        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
255        let Some(venue) = venue else {
256            let venues = self.position_venues(true, account_id.as_ref());
257            return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
258                portfolio.unrealized_pnls(venue, account_id.as_ref(), target_currency)
259            });
260        };
261
262        let map = self
263            .0
264            .borrow_mut()
265            .unrealized_pnls(&venue, account_id.as_ref(), target_currency)
266            .ok_or_else(|| to_pyruntime_err("failed to calculate unrealized PnLs"))?;
267        currency_money_map_to_pydict(py, map)
268    }
269
270    #[pyo3(name = "total_pnls", signature = (venue=None, account_id=None, target_currency=None))]
271    fn py_total_pnls(
272        &self,
273        py: Python<'_>,
274        venue: Option<Venue>,
275        account_id: Option<AccountId>,
276        target_currency: Option<Currency>,
277    ) -> PyResult<Py<PyDict>> {
278        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
279        let Some(venue) = venue else {
280            // Closed-only venues still contribute realized PnL.
281            let venues = self.position_venues(false, account_id.as_ref());
282            return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
283                portfolio.total_pnls(venue, account_id.as_ref(), target_currency)
284            });
285        };
286
287        let map = self
288            .0
289            .borrow_mut()
290            .total_pnls(&venue, account_id.as_ref(), target_currency)
291            .ok_or_else(|| to_pyruntime_err("failed to calculate total PnLs"))?;
292        currency_money_map_to_pydict(py, map)
293    }
294
295    #[pyo3(name = "net_exposures", signature = (venue=None, account_id=None, target_currency=None))]
296    fn py_net_exposures(
297        &self,
298        py: Python<'_>,
299        venue: Option<Venue>,
300        account_id: Option<AccountId>,
301        target_currency: Option<Currency>,
302    ) -> PyResult<Option<Py<PyDict>>> {
303        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
304        let Some(venue) = venue else {
305            return self.aggregate_net_exposures(py, account_id.as_ref(), target_currency);
306        };
307
308        match self
309            .0
310            .borrow()
311            .net_exposures(&venue, account_id.as_ref(), target_currency)
312        {
313            Some(map) => Ok(Some(currency_money_map_to_pydict(py, map)?)),
314            None => Ok(None),
315        }
316    }
317
318    #[pyo3(name = "mark_values", signature = (venue=None, account_id=None))]
319    fn py_mark_values(
320        &self,
321        py: Python<'_>,
322        venue: Option<Venue>,
323        account_id: Option<AccountId>,
324    ) -> PyResult<Py<PyDict>> {
325        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
326        let Some(venue) = venue else {
327            let venues = self.position_venues(true, account_id.as_ref());
328            return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
329                Some(portfolio.mark_values(venue, account_id.as_ref()))
330            });
331        };
332
333        let map = self.0.borrow_mut().mark_values(&venue, account_id.as_ref());
334        currency_money_map_to_pydict(py, map)
335    }
336
337    #[pyo3(name = "equity", signature = (venue=None, account_id=None))]
338    fn py_equity(
339        &self,
340        py: Python<'_>,
341        venue: Option<Venue>,
342        account_id: Option<AccountId>,
343    ) -> PyResult<Py<PyDict>> {
344        if venue.is_none() && account_id.is_none() {
345            return Err(to_pyvalue_err("venue or account_id must be provided"));
346        }
347        self.validate_query_scope(venue.as_ref(), account_id.as_ref())?;
348
349        let Some(venue) = venue else {
350            return self.account_equity(py, account_id.as_ref());
351        };
352
353        let map = self.0.borrow_mut().equity(&venue, account_id.as_ref());
354        currency_money_map_to_pydict(py, map)
355    }
356
357    #[pyo3(name = "missing_price_instruments", signature = (venue, account_id=None))]
358    fn py_missing_price_instruments(
359        &self,
360        venue: Venue,
361        account_id: Option<AccountId>,
362    ) -> PyResult<Vec<InstrumentId>> {
363        self.validate_query_scope(Some(&venue), account_id.as_ref())?;
364        Ok(self
365            .0
366            .borrow()
367            .missing_price_instruments(&venue, account_id.as_ref()))
368    }
369
370    #[pyo3(name = "build_snapshot")]
371    fn py_build_snapshot(&self, account_id: AccountId) -> Option<PortfolioSnapshot> {
372        self.0.borrow_mut().build_snapshot(&account_id)
373    }
374
375    #[pyo3(name = "snapshots")]
376    fn py_snapshots(&self, account_id: AccountId) -> Vec<PortfolioSnapshot> {
377        self.0.borrow().snapshots(&account_id)
378    }
379
380    #[pyo3(name = "realized_pnl", signature = (instrument_id, account_id=None, target_currency=None))]
381    fn py_realized_pnl(
382        &self,
383        instrument_id: InstrumentId,
384        account_id: Option<AccountId>,
385        target_currency: Option<Currency>,
386    ) -> Option<Money> {
387        self.0.borrow_mut().realized_pnl_for_account(
388            &instrument_id,
389            account_id.as_ref(),
390            target_currency,
391        )
392    }
393
394    #[pyo3(
395        name = "unrealized_pnl",
396        signature = (instrument_id, price=None, account_id=None, target_currency=None)
397    )]
398    fn py_unrealized_pnl(
399        &self,
400        instrument_id: InstrumentId,
401        price: Option<Price>,
402        account_id: Option<AccountId>,
403        target_currency: Option<Currency>,
404    ) -> Option<Money> {
405        self.0.borrow_mut().unrealized_pnl_for_account(
406            &instrument_id,
407            price,
408            account_id.as_ref(),
409            target_currency,
410        )
411    }
412
413    #[pyo3(
414        name = "total_pnl",
415        signature = (instrument_id, price=None, account_id=None, target_currency=None)
416    )]
417    fn py_total_pnl(
418        &self,
419        instrument_id: InstrumentId,
420        price: Option<Price>,
421        account_id: Option<AccountId>,
422        target_currency: Option<Currency>,
423    ) -> Option<Money> {
424        self.0.borrow_mut().total_pnl_for_account(
425            &instrument_id,
426            price,
427            account_id.as_ref(),
428            target_currency,
429        )
430    }
431
432    #[pyo3(
433        name = "net_exposure",
434        signature = (instrument_id, price=None, account_id=None, target_currency=None)
435    )]
436    fn py_net_exposure(
437        &self,
438        instrument_id: InstrumentId,
439        price: Option<Price>,
440        account_id: Option<AccountId>,
441        target_currency: Option<Currency>,
442    ) -> Option<Money> {
443        self.0
444            .borrow()
445            .net_exposure(&instrument_id, price, account_id.as_ref(), target_currency)
446    }
447
448    #[pyo3(name = "net_position", signature = (instrument_id, account_id=None))]
449    fn py_net_position(
450        &self,
451        instrument_id: InstrumentId,
452        account_id: Option<AccountId>,
453    ) -> PyResult<Decimal> {
454        self.net_position_for_account(&instrument_id, account_id.as_ref())
455    }
456
457    #[pyo3(name = "is_net_long", signature = (instrument_id, account_id=None))]
458    fn py_is_net_long(
459        &self,
460        instrument_id: InstrumentId,
461        account_id: Option<AccountId>,
462    ) -> PyResult<bool> {
463        Ok(self.net_position_for_account(&instrument_id, account_id.as_ref())? > Decimal::ZERO)
464    }
465
466    #[pyo3(name = "is_net_short", signature = (instrument_id, account_id=None))]
467    fn py_is_net_short(
468        &self,
469        instrument_id: InstrumentId,
470        account_id: Option<AccountId>,
471    ) -> PyResult<bool> {
472        Ok(self.net_position_for_account(&instrument_id, account_id.as_ref())? < Decimal::ZERO)
473    }
474
475    #[pyo3(name = "is_net_flat", signature = (instrument_id, account_id=None))]
476    fn py_is_net_flat(
477        &self,
478        instrument_id: InstrumentId,
479        account_id: Option<AccountId>,
480    ) -> PyResult<bool> {
481        Ok(self.net_position_for_account(&instrument_id, account_id.as_ref())? == Decimal::ZERO)
482    }
483
484    #[pyo3(name = "is_completely_net_flat", signature = (account_id=None))]
485    fn py_is_completely_net_flat(&self, account_id: Option<AccountId>) -> PyResult<bool> {
486        self.is_completely_net_flat_for_account(account_id.as_ref())
487    }
488
489    #[pyo3(name = "statistics")]
490    fn py_statistics(&self) -> PortfolioStatistics {
491        self.0.borrow().statistics()
492    }
493
494    /// Registers a portfolio statistic for inclusion in portfolio and backtest analysis.
495    ///
496    /// Accepts a built-in statistic type or any user-defined object exposing a `name` and the
497    /// calculation methods for the input categories it supports. The registration persists
498    /// across `statistics()` calls, so it also reaches backtest results and post-run analysis
499    /// logs. Registering a statistic whose name matches an existing one replaces it.
500    ///
501    /// # Errors
502    ///
503    /// Returns an error if `statistic` cannot be converted into a registrable statistic.
504    #[pyo3(name = "register_statistic")]
505    fn py_register_statistic(&self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
506        let statistic = statistic_from_pyobject(py, statistic)?;
507        self.0.borrow_mut().register_statistic(statistic);
508        Ok(())
509    }
510
511    /// Removes the statistic matching `statistic` by name from analysis.
512    ///
513    /// Deregistering a statistic that was never registered is a no-op.
514    ///
515    /// # Errors
516    ///
517    /// Returns an error if `statistic` cannot be converted into a registrable statistic.
518    #[pyo3(name = "deregister_statistic")]
519    fn py_deregister_statistic(&self, py: Python, statistic: Py<PyAny>) -> PyResult<()> {
520        let statistic = statistic_from_pyobject(py, statistic)?;
521        self.0.borrow_mut().deregister_statistic(&statistic);
522        Ok(())
523    }
524
525    /// Removes all registered statistics, including the built-in defaults.
526    #[pyo3(name = "deregister_statistics")]
527    fn py_deregister_statistics(&self) {
528        self.0.borrow_mut().deregister_statistics();
529    }
530}
531
532/// Exposed through `nautilus_trader.portfolio`.
533///
534/// # Errors
535///
536/// Returns a `PyErr` if registering any module components fails.
537#[pymodule]
538pub fn portfolio(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
539    m.add_class::<PortfolioConfig>()?;
540    m.add_class::<PyPortfolio>()?;
541    Ok(())
542}
543
544impl PyPortfolio {
545    fn account_for_query(
546        &self,
547        venue: Option<&Venue>,
548        account_id: Option<&AccountId>,
549    ) -> PyResult<Option<AccountAny>> {
550        self.validate_query_scope(venue, account_id)?;
551        let portfolio = self.0.borrow();
552        let cache = portfolio.cache().borrow();
553        if let Some(account_id) = account_id {
554            Ok(cache.account_owned(account_id))
555        } else if let Some(venue) = venue {
556            Ok(cache.account_for_venue_owned(venue))
557        } else {
558            Ok(None)
559        }
560    }
561
562    fn validate_query_scope(
563        &self,
564        venue: Option<&Venue>,
565        account_id: Option<&AccountId>,
566    ) -> PyResult<()> {
567        let (Some(venue), Some(account_id)) = (venue, account_id) else {
568            return Ok(());
569        };
570
571        let portfolio = self.0.borrow();
572        let cache = portfolio.cache().borrow();
573        let venue_account_id = cache.account_id(venue);
574        let account_exists = cache.account(account_id).is_some();
575        let account_has_venue_position = !cache
576            .positions(Some(venue), None, None, Some(account_id), None)
577            .is_empty();
578
579        if account_exists && (venue_account_id == Some(account_id) || account_has_venue_position) {
580            return Ok(());
581        }
582
583        Err(to_pyvalue_err(format!(
584            "venue {venue} and account_id {account_id} do not resolve to the same account",
585        )))
586    }
587
588    fn account_for_required_query(
589        &self,
590        venue: Option<&Venue>,
591        account_id: Option<&AccountId>,
592    ) -> PyResult<Option<AccountAny>> {
593        if venue.is_none() && account_id.is_none() {
594            return Err(to_pyvalue_err("venue or account_id must be provided"));
595        }
596
597        self.account_for_query(venue, account_id)
598    }
599
600    fn position_venues(&self, open_only: bool, account_id: Option<&AccountId>) -> Vec<Venue> {
601        let portfolio = self.0.borrow();
602        let cache = portfolio.cache().borrow();
603        let venues: IndexSet<Venue> = if open_only {
604            cache
605                .positions_open(None, None, None, account_id, None)
606                .iter()
607                .map(|position| position.instrument_id.venue)
608                .collect()
609        } else {
610            cache
611                .positions(None, None, None, account_id, None)
612                .iter()
613                .map(|position| position.instrument_id.venue)
614                .collect()
615        };
616        venues.into_iter().collect()
617    }
618
619    fn aggregate_currency_maps<F>(
620        &self,
621        py: Python<'_>,
622        venues: Vec<Venue>,
623        mut query: F,
624    ) -> PyResult<Py<PyDict>>
625    where
626        F: FnMut(&mut Portfolio, &Venue) -> Option<IndexMap<Currency, Money>>,
627    {
628        let mut totals: IndexMap<Currency, Money> = IndexMap::new();
629        let mut portfolio = self.0.borrow_mut();
630
631        for venue in venues {
632            let map = query(&mut portfolio, &venue)
633                .ok_or_else(|| to_pyruntime_err("failed to calculate portfolio query"))?;
634            add_money_map(&mut totals, map)?;
635        }
636
637        currency_money_map_to_pydict(py, totals)
638    }
639
640    fn aggregate_net_exposures(
641        &self,
642        py: Python<'_>,
643        account_id: Option<&AccountId>,
644        target_currency: Option<Currency>,
645    ) -> PyResult<Option<Py<PyDict>>> {
646        let venues = self.position_venues(true, account_id);
647        if venues.is_empty() {
648            let valid_scope = match account_id {
649                Some(account_id) => self.account_for_query(None, Some(account_id))?.is_some(),
650                None => !self
651                    .0
652                    .borrow()
653                    .cache()
654                    .borrow()
655                    .accounts_all_owned()
656                    .is_empty(),
657            };
658            return if valid_scope {
659                Ok(Some(currency_money_map_to_pydict(py, IndexMap::new())?))
660            } else {
661                Ok(None)
662            };
663        }
664
665        let mut totals: IndexMap<Currency, Money> = IndexMap::new();
666        let portfolio = self.0.borrow();
667        for venue in venues {
668            let Some(exposures) = portfolio.net_exposures(&venue, account_id, target_currency)
669            else {
670                return Ok(None);
671            };
672            add_money_map(&mut totals, exposures)?;
673        }
674
675        Ok(Some(currency_money_map_to_pydict(py, totals)?))
676    }
677
678    fn account_equity(
679        &self,
680        py: Python<'_>,
681        account_id: Option<&AccountId>,
682    ) -> PyResult<Py<PyDict>> {
683        let Some(account_id) = account_id else {
684            return Err(to_pyvalue_err("account_id must be provided"));
685        };
686
687        if self.account_for_query(None, Some(account_id))?.is_none() {
688            return currency_money_map_to_pydict(py, IndexMap::new());
689        }
690
691        let snapshot = self
692            .0
693            .borrow_mut()
694            .build_snapshot(account_id)
695            .ok_or_else(|| to_pyruntime_err("failed to calculate account equity"))?;
696
697        let map = snapshot
698            .total_equity
699            .into_iter()
700            .map(|money| (money.currency, money))
701            .collect();
702        currency_money_map_to_pydict(py, map)
703    }
704
705    fn net_position_for_account(
706        &self,
707        instrument_id: &InstrumentId,
708        account_id: Option<&AccountId>,
709    ) -> PyResult<Decimal> {
710        self.0
711            .borrow()
712            .cache()
713            .borrow()
714            .positions_open(None, Some(instrument_id), None, account_id, None)
715            .iter()
716            .try_fold(Decimal::ZERO, |total, position| {
717                total
718                    .checked_add(position.signed_decimal_qty())
719                    .ok_or_else(|| to_pyruntime_err("net position exceeds Decimal bounds"))
720            })
721    }
722
723    fn is_completely_net_flat_for_account(&self, account_id: Option<&AccountId>) -> PyResult<bool> {
724        let portfolio = self.0.borrow();
725        let cache = portfolio.cache().borrow();
726        let mut net_positions: IndexMap<InstrumentId, Decimal> = IndexMap::new();
727
728        for position in cache.positions_open(None, None, None, account_id, None) {
729            let total = net_positions
730                .entry(position.instrument_id)
731                .or_insert(Decimal::ZERO);
732            *total = total
733                .checked_add(position.signed_decimal_qty())
734                .ok_or_else(|| to_pyruntime_err("net position exceeds Decimal bounds"))?;
735        }
736
737        Ok(net_positions
738            .values()
739            .all(|quantity| *quantity == Decimal::ZERO))
740    }
741}
742
743fn currency_money_map_to_pydict(
744    py: Python<'_>,
745    map: IndexMap<Currency, Money>,
746) -> PyResult<Py<PyDict>> {
747    let dict = PyDict::new(py);
748    for (currency, money) in map {
749        dict.set_item(currency, money)?;
750    }
751    Ok(dict.unbind())
752}
753
754fn instrument_money_map_to_pydict(
755    py: Python<'_>,
756    map: IndexMap<InstrumentId, Money>,
757) -> PyResult<Py<PyDict>> {
758    let dict = PyDict::new(py);
759    for (instrument_id, money) in map {
760        dict.set_item(instrument_id, money)?;
761    }
762    Ok(dict.unbind())
763}
764
765fn add_money_map(
766    totals: &mut IndexMap<Currency, Money>,
767    map: IndexMap<Currency, Money>,
768) -> PyResult<()> {
769    for (currency, money) in map {
770        if currency != money.currency {
771            return Err(to_pyruntime_err(format!(
772                "portfolio query returned {currency} key with {} money",
773                money.currency,
774            )));
775        }
776
777        if let Some(total) = totals.get_mut(&currency) {
778            *total = total.checked_add(money).ok_or_else(|| {
779                to_pyruntime_err(format!(
780                    "portfolio query total for {currency} exceeds Money bounds",
781                ))
782            })?;
783        } else {
784            totals.insert(currency, money);
785        }
786    }
787    Ok(())
788}
789
790#[cfg(test)]
791mod tests {
792    use std::{cell::RefCell, rc::Rc};
793
794    use nautilus_common::{cache::Cache, clock::VirtualClock};
795    use nautilus_core::{UUID4, UnixNanos};
796    use nautilus_model::{
797        enums::{AccountType, OmsType, OrderSide},
798        events::{AccountState, order::spec::OrderFilledSpec},
799        identifiers::{AccountId, ClientOrderId, PositionId, Symbol, TradeId, Venue, VenueOrderId},
800        instruments::{Instrument, InstrumentAny, stubs::default_fx_ccy},
801        position::Position,
802        types::{AccountBalance, Currency, Money, Price, Quantity, money::MONEY_MAX},
803    };
804    use pyo3::{
805        Python,
806        exceptions::PyRuntimeError,
807        types::{PyAnyMethods, PyDictMethods},
808    };
809    use rstest::rstest;
810
811    use super::PyPortfolio;
812    use crate::portfolio::Portfolio;
813
814    fn cash_account_state(account_id: AccountId) -> AccountState {
815        let total = Money::from("1000000.00 USD");
816
817        AccountState::new(
818            account_id,
819            AccountType::Cash,
820            vec![AccountBalance::new(
821                total,
822                Money::zero(Currency::USD()),
823                total,
824            )],
825            vec![],
826            true,
827            UUID4::new(),
828            UnixNanos::default(),
829            UnixNanos::default(),
830            Some(Currency::USD()),
831        )
832    }
833
834    fn position_with_realized_pnl(
835        instrument: &InstrumentAny,
836        account_id: AccountId,
837        position_id: PositionId,
838        realized_pnl: Money,
839    ) -> Position {
840        let tag = position_id.as_str();
841        let fill = OrderFilledSpec::builder()
842            .instrument_id(instrument.id())
843            .client_order_id(ClientOrderId::new(format!("O-{tag}")))
844            .venue_order_id(VenueOrderId::new(format!("V-{tag}")))
845            .account_id(account_id)
846            .trade_id(TradeId::new(format!("T-{tag}")))
847            .order_side(OrderSide::Buy)
848            .last_qty(Quantity::from("1"))
849            .last_px(Price::from("1.00"))
850            .currency(instrument.settlement_currency())
851            .position_id(position_id)
852            .build();
853        let mut position = Position::new(instrument, fill);
854        position.realized_pnl = Some(realized_pnl);
855        position
856    }
857
858    #[rstest]
859    fn test_all_scope_python_aggregation_overflow_raises_runtime_error() {
860        Python::initialize();
861        let sim = Venue::from("SIM");
862        let other = Venue::from("OTHER");
863        let instrument_sim =
864            InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from("AUD/USD"), Some(sim)));
865        let instrument_other =
866            InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from("GBP/USD"), Some(other)));
867        let mut cache = Cache::new(None, None);
868        cache.add_instrument(instrument_sim.clone()).unwrap();
869        cache.add_instrument(instrument_other.clone()).unwrap();
870        let mut portfolio = Portfolio::new(
871            Rc::new(RefCell::new(VirtualClock::new())),
872            Rc::new(RefCell::new(cache)),
873            None,
874        );
875
876        for (account_id, instrument, position_id) in [
877            (
878                AccountId::from("SIM-001"),
879                &instrument_sim,
880                PositionId::from("P-PY-OVERFLOW-SIM"),
881            ),
882            (
883                AccountId::from("OTHER-001"),
884                &instrument_other,
885                PositionId::from("P-PY-OVERFLOW-OTHER"),
886            ),
887        ] {
888            portfolio.update_account(&cash_account_state(account_id));
889            portfolio
890                .cache()
891                .borrow_mut()
892                .add_position(
893                    &position_with_realized_pnl(
894                        instrument,
895                        account_id,
896                        position_id,
897                        Money::new(MONEY_MAX, Currency::USD()),
898                    ),
899                    OmsType::Hedging,
900                )
901                .unwrap();
902        }
903
904        let portfolio = PyPortfolio::from_rc(Rc::new(RefCell::new(portfolio)));
905        Python::attach(|py| {
906            let error = portfolio
907                .py_realized_pnls(py, None, None, None)
908                .expect_err("cross-venue aggregation must reject Money overflow");
909
910            assert!(error.is_instance_of::<PyRuntimeError>(py));
911            assert_eq!(
912                error.to_string(),
913                "RuntimeError: portfolio query total for USD exceeds Money bounds"
914            );
915        });
916    }
917
918    #[rstest]
919    fn test_python_scope_accepts_secondary_account_with_position_at_venue() {
920        Python::initialize();
921        let venue = Venue::from("SIM");
922        let instrument =
923            InstrumentAny::CurrencyPair(default_fx_ccy(Symbol::from("AUD/USD"), Some(venue)));
924        let mut cache = Cache::new(None, None);
925        cache.add_instrument(instrument.clone()).unwrap();
926        let mut portfolio = Portfolio::new(
927            Rc::new(RefCell::new(VirtualClock::new())),
928            Rc::new(RefCell::new(cache)),
929            None,
930        );
931        let secondary = AccountId::from("SIM-002");
932        let primary = AccountId::from("SIM-001");
933        portfolio.update_account(&cash_account_state(secondary));
934        portfolio.update_account(&cash_account_state(primary));
935        portfolio
936            .cache()
937            .borrow_mut()
938            .add_position(
939                &position_with_realized_pnl(
940                    &instrument,
941                    secondary,
942                    PositionId::from("P-PY-SECONDARY"),
943                    Money::from("7.00 USD"),
944                ),
945                OmsType::Hedging,
946            )
947            .unwrap();
948        let portfolio = PyPortfolio::from_rc(Rc::new(RefCell::new(portfolio)));
949
950        Python::attach(|py| {
951            let result = portfolio
952                .py_realized_pnls(py, Some(venue), Some(secondary), None)
953                .expect("secondary account position must establish the venue scope");
954            let money = result
955                .bind(py)
956                .get_item(Currency::USD())
957                .unwrap()
958                .unwrap()
959                .extract::<Money>()
960                .unwrap();
961
962            assert_eq!(money, Money::from("7.00 USD"));
963        });
964    }
965}