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