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