1use 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 #[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#[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 #[must_use]
132 pub fn from_rc(rc: Rc<RefCell<Portfolio>>) -> Self {
133 Self(rc)
134 }
135
136 #[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 #[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 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#[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(¤cy) {
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}