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