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::{
37 config::PortfolioConfig,
38 portfolio::{Portfolio, resolve_account},
39};
40
41#[pyo3_stub_gen::derive::gen_stub_pymethods]
42#[pymethods]
43impl PortfolioConfig {
44 #[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#[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 #[must_use]
137 pub fn from_rc(rc: Rc<RefCell<Portfolio>>) -> Self {
138 Self(rc)
139 }
140
141 #[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 #[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 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 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 #[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 #[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 #[pyo3(name = "deregister_statistics")]
542 fn py_deregister_statistics(&self) {
543 self.0.borrow_mut().deregister_statistics();
544 }
545}
546
547#[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 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(¤cy) {
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}