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_pynotimplemented_err, to_pyvalue_err};
24use nautilus_model::{
25 accounts::AccountAny,
26 identifiers::{AccountId, InstrumentId, Venue},
27 python::account::account_any_to_pyobject,
28 types::{Currency, Money, Price},
29};
30use pyo3::{prelude::*, types::PyDict};
31use rust_decimal::Decimal;
32
33use crate::{config::PortfolioConfig, portfolio::Portfolio};
34
35#[pyo3_stub_gen::derive::gen_stub_pymethods]
36#[pymethods]
37impl PortfolioConfig {
38 #[new]
40 #[pyo3(signature = (use_mark_prices=None, use_mark_xrates=None, bar_updates=None, convert_to_account_base_currency=None, min_account_state_logging_interval_ms=None, debug=None, snapshot_interval_ms=None))]
41 fn py_new(
42 use_mark_prices: Option<bool>,
43 use_mark_xrates: Option<bool>,
44 bar_updates: Option<bool>,
45 convert_to_account_base_currency: Option<bool>,
46 min_account_state_logging_interval_ms: Option<u64>,
47 debug: Option<bool>,
48 snapshot_interval_ms: Option<u64>,
49 ) -> PyResult<Self> {
50 let default = Self::default();
51 let config = Self {
52 use_mark_prices: use_mark_prices.unwrap_or(default.use_mark_prices),
53 use_mark_xrates: use_mark_xrates.unwrap_or(default.use_mark_xrates),
54 bar_updates: bar_updates.unwrap_or(default.bar_updates),
55 convert_to_account_base_currency: convert_to_account_base_currency
56 .unwrap_or(default.convert_to_account_base_currency),
57 min_account_state_logging_interval_ms,
58 snapshot_interval_ms,
59 debug: debug.unwrap_or(default.debug),
60 };
61 config.validate().map_err(config_error_to_pyvalue_err)?;
62 Ok(config)
63 }
64
65 fn __repr__(&self) -> String {
66 format!("{self:?}")
67 }
68
69 fn __str__(&self) -> String {
70 format!("{self:?}")
71 }
72
73 #[getter]
74 fn use_mark_prices(&self) -> bool {
75 self.use_mark_prices
76 }
77
78 #[getter]
79 fn use_mark_xrates(&self) -> bool {
80 self.use_mark_xrates
81 }
82
83 #[getter]
84 fn bar_updates(&self) -> bool {
85 self.bar_updates
86 }
87
88 #[getter]
89 fn convert_to_account_base_currency(&self) -> bool {
90 self.convert_to_account_base_currency
91 }
92
93 #[getter]
94 fn min_account_state_logging_interval_ms(&self) -> Option<u64> {
95 self.min_account_state_logging_interval_ms
96 }
97
98 #[getter]
99 fn snapshot_interval_ms(&self) -> Option<u64> {
100 self.snapshot_interval_ms
101 }
102
103 #[getter]
104 fn debug(&self) -> bool {
105 self.debug
106 }
107}
108
109#[pyo3::pyclass(
111 module = "nautilus_trader.core.nautilus_pyo3.portfolio",
112 name = "Portfolio",
113 unsendable,
114 from_py_object
115)]
116#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.portfolio")]
117#[derive(Debug, Clone)]
118pub struct PyPortfolio(Rc<RefCell<Portfolio>>);
119
120impl PyPortfolio {
121 #[must_use]
123 pub fn from_rc(rc: Rc<RefCell<Portfolio>>) -> Self {
124 Self(rc)
125 }
126
127 #[must_use]
129 pub fn portfolio_rc(&self) -> Rc<RefCell<Portfolio>> {
130 self.0.clone()
131 }
132}
133
134#[pyo3::pymethods]
135#[pyo3_stub_gen::derive::gen_stub_pymethods]
136impl PyPortfolio {
137 #[pyo3(name = "is_initialized")]
138 fn py_is_initialized(&self) -> bool {
139 self.0.borrow().is_initialized()
140 }
141
142 #[pyo3(name = "account", signature = (venue=None, account_id=None))]
143 fn py_account(
144 &self,
145 py: Python<'_>,
146 venue: Option<Venue>,
147 account_id: Option<AccountId>,
148 ) -> PyResult<Option<Py<PyAny>>> {
149 match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
150 Some(account) => Ok(Some(account_any_to_pyobject(py, account)?)),
151 None => Ok(None),
152 }
153 }
154
155 #[pyo3(name = "balances_locked", signature = (venue=None, account_id=None))]
156 fn py_balances_locked(
157 &self,
158 py: Python<'_>,
159 venue: Option<Venue>,
160 account_id: Option<AccountId>,
161 ) -> PyResult<Option<Py<PyDict>>> {
162 match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
163 Some(account) => Ok(Some(currency_money_map_to_pydict(
164 py,
165 account.balances_locked(),
166 )?)),
167 None => Ok(None),
168 }
169 }
170
171 #[pyo3(name = "margins_init", signature = (venue=None, account_id=None))]
172 fn py_margins_init(
173 &self,
174 py: Python<'_>,
175 venue: Option<Venue>,
176 account_id: Option<AccountId>,
177 ) -> PyResult<Option<Py<PyDict>>> {
178 match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
179 Some(AccountAny::Margin(account)) => Ok(Some(instrument_money_map_to_pydict(
180 py,
181 account.initial_margins(),
182 )?)),
183 Some(AccountAny::Cash(_) | AccountAny::Betting(_)) | None => Ok(None),
184 }
185 }
186
187 #[pyo3(name = "margins_maint", signature = (venue=None, account_id=None))]
188 fn py_margins_maint(
189 &self,
190 py: Python<'_>,
191 venue: Option<Venue>,
192 account_id: Option<AccountId>,
193 ) -> PyResult<Option<Py<PyDict>>> {
194 match self.account_for_required_query(venue.as_ref(), account_id.as_ref())? {
195 Some(AccountAny::Margin(account)) => Ok(Some(instrument_money_map_to_pydict(
196 py,
197 account.maintenance_margins(),
198 )?)),
199 Some(AccountAny::Cash(_) | AccountAny::Betting(_)) | None => Ok(None),
200 }
201 }
202
203 #[pyo3(name = "realized_pnls", signature = (venue=None, account_id=None, target_currency=None))]
204 fn py_realized_pnls(
205 &self,
206 py: Python<'_>,
207 venue: Option<Venue>,
208 account_id: Option<AccountId>,
209 target_currency: Option<Currency>,
210 ) -> PyResult<Py<PyDict>> {
211 unsupported_target_currency(target_currency.as_ref())?;
212 let Some(venue) = venue else {
213 let venues = self.position_venues(false, account_id.as_ref());
214 return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
215 portfolio.realized_pnls(venue, account_id.as_ref())
216 });
217 };
218
219 let map = self
220 .0
221 .borrow_mut()
222 .realized_pnls(&venue, account_id.as_ref());
223 currency_money_map_to_pydict(py, map)
224 }
225
226 #[pyo3(name = "unrealized_pnls", signature = (venue=None, account_id=None, target_currency=None))]
227 fn py_unrealized_pnls(
228 &self,
229 py: Python<'_>,
230 venue: Option<Venue>,
231 account_id: Option<AccountId>,
232 target_currency: Option<Currency>,
233 ) -> PyResult<Py<PyDict>> {
234 unsupported_target_currency(target_currency.as_ref())?;
235 let Some(venue) = venue else {
236 let venues = self.position_venues(true, account_id.as_ref());
237 return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
238 portfolio.unrealized_pnls(venue, account_id.as_ref())
239 });
240 };
241
242 let map = self
243 .0
244 .borrow_mut()
245 .unrealized_pnls(&venue, account_id.as_ref());
246 currency_money_map_to_pydict(py, map)
247 }
248
249 #[pyo3(name = "total_pnls", signature = (venue=None, account_id=None, target_currency=None))]
250 fn py_total_pnls(
251 &self,
252 py: Python<'_>,
253 venue: Option<Venue>,
254 account_id: Option<AccountId>,
255 target_currency: Option<Currency>,
256 ) -> PyResult<Py<PyDict>> {
257 unsupported_target_currency(target_currency.as_ref())?;
258 let Some(venue) = venue else {
259 let venues = self.position_venues(false, account_id.as_ref());
261 return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
262 portfolio.total_pnls(venue, account_id.as_ref())
263 });
264 };
265
266 let map = self.0.borrow_mut().total_pnls(&venue, account_id.as_ref());
267 currency_money_map_to_pydict(py, map)
268 }
269
270 #[pyo3(name = "net_exposures", signature = (venue=None, account_id=None, target_currency=None))]
271 fn py_net_exposures(
272 &self,
273 py: Python<'_>,
274 venue: Option<Venue>,
275 account_id: Option<AccountId>,
276 target_currency: Option<Currency>,
277 ) -> PyResult<Option<Py<PyDict>>> {
278 unsupported_target_currency(target_currency.as_ref())?;
279 let Some(venue) = venue else {
280 return self.aggregate_net_exposures(py, account_id.as_ref());
281 };
282
283 match self.0.borrow().net_exposures(&venue, account_id.as_ref()) {
284 Some(map) => Ok(Some(currency_money_map_to_pydict(py, map)?)),
285 None => Ok(None),
286 }
287 }
288
289 #[pyo3(name = "mark_values", signature = (venue=None, account_id=None))]
290 fn py_mark_values(
291 &self,
292 py: Python<'_>,
293 venue: Option<Venue>,
294 account_id: Option<AccountId>,
295 ) -> PyResult<Py<PyDict>> {
296 let Some(venue) = venue else {
297 let venues = self.position_venues(true, account_id.as_ref());
298 return self.aggregate_currency_maps(py, venues, |portfolio, venue| {
299 portfolio.mark_values(venue, account_id.as_ref())
300 });
301 };
302
303 let map = self.0.borrow_mut().mark_values(&venue, account_id.as_ref());
304 currency_money_map_to_pydict(py, map)
305 }
306
307 #[pyo3(name = "equity", signature = (venue=None, account_id=None))]
308 fn py_equity(
309 &self,
310 py: Python<'_>,
311 venue: Option<Venue>,
312 account_id: Option<AccountId>,
313 ) -> PyResult<Py<PyDict>> {
314 if venue.is_none() && account_id.is_none() {
315 return Err(to_pyvalue_err("venue or account_id must be provided"));
316 }
317
318 let Some(venue) = venue else {
319 return self.account_equity(py, account_id.as_ref());
320 };
321
322 let map = self.0.borrow_mut().equity(&venue, account_id.as_ref());
323 currency_money_map_to_pydict(py, map)
324 }
325
326 #[pyo3(name = "missing_price_instruments")]
327 fn py_missing_price_instruments(&self, venue: Venue) -> Vec<InstrumentId> {
328 self.0.borrow().missing_price_instruments(&venue)
329 }
330
331 #[pyo3(name = "realized_pnl", signature = (instrument_id, account_id=None, target_currency=None))]
332 fn py_realized_pnl(
333 &self,
334 instrument_id: InstrumentId,
335 account_id: Option<AccountId>,
336 target_currency: Option<Currency>,
337 ) -> PyResult<Option<Money>> {
338 unsupported_target_currency(target_currency.as_ref())?;
339 Ok(self
340 .0
341 .borrow_mut()
342 .realized_pnl_for_account(&instrument_id, account_id.as_ref()))
343 }
344
345 #[pyo3(
346 name = "unrealized_pnl",
347 signature = (instrument_id, price=None, account_id=None, target_currency=None)
348 )]
349 fn py_unrealized_pnl(
350 &self,
351 instrument_id: InstrumentId,
352 price: Option<Price>,
353 account_id: Option<AccountId>,
354 target_currency: Option<Currency>,
355 ) -> PyResult<Option<Money>> {
356 unsupported_price(price.as_ref())?;
357 unsupported_target_currency(target_currency.as_ref())?;
358 Ok(self
359 .0
360 .borrow_mut()
361 .unrealized_pnl_for_account(&instrument_id, account_id.as_ref()))
362 }
363
364 #[pyo3(
365 name = "total_pnl",
366 signature = (instrument_id, price=None, account_id=None, target_currency=None)
367 )]
368 fn py_total_pnl(
369 &self,
370 instrument_id: InstrumentId,
371 price: Option<Price>,
372 account_id: Option<AccountId>,
373 target_currency: Option<Currency>,
374 ) -> PyResult<Option<Money>> {
375 unsupported_price(price.as_ref())?;
376 unsupported_target_currency(target_currency.as_ref())?;
377 Ok(self
378 .0
379 .borrow_mut()
380 .total_pnl_for_account(&instrument_id, account_id.as_ref()))
381 }
382
383 #[pyo3(
384 name = "net_exposure",
385 signature = (instrument_id, price=None, account_id=None, target_currency=None)
386 )]
387 fn py_net_exposure(
388 &self,
389 instrument_id: InstrumentId,
390 price: Option<Price>,
391 account_id: Option<AccountId>,
392 target_currency: Option<Currency>,
393 ) -> PyResult<Option<Money>> {
394 unsupported_price(price.as_ref())?;
395 unsupported_target_currency(target_currency.as_ref())?;
396 Ok(self
397 .0
398 .borrow()
399 .net_exposure(&instrument_id, account_id.as_ref()))
400 }
401
402 #[pyo3(name = "net_position", signature = (instrument_id, account_id=None))]
403 fn py_net_position(
404 &self,
405 instrument_id: InstrumentId,
406 account_id: Option<AccountId>,
407 ) -> Decimal {
408 self.net_position_for_account(&instrument_id, account_id.as_ref())
409 }
410
411 #[pyo3(name = "is_net_long", signature = (instrument_id, account_id=None))]
412 fn py_is_net_long(&self, instrument_id: InstrumentId, account_id: Option<AccountId>) -> bool {
413 self.net_position_for_account(&instrument_id, account_id.as_ref()) > Decimal::ZERO
414 }
415
416 #[pyo3(name = "is_net_short", signature = (instrument_id, account_id=None))]
417 fn py_is_net_short(&self, instrument_id: InstrumentId, account_id: Option<AccountId>) -> bool {
418 self.net_position_for_account(&instrument_id, account_id.as_ref()) < Decimal::ZERO
419 }
420
421 #[pyo3(name = "is_flat", signature = (instrument_id, account_id=None))]
422 fn py_is_flat(&self, instrument_id: InstrumentId, account_id: Option<AccountId>) -> bool {
423 self.net_position_for_account(&instrument_id, account_id.as_ref()) == Decimal::ZERO
424 }
425
426 #[pyo3(name = "is_completely_flat", signature = (account_id=None))]
427 fn py_is_completely_flat(&self, account_id: Option<AccountId>) -> bool {
428 self.is_completely_flat_for_account(account_id.as_ref())
429 }
430
431 #[pyo3(name = "statistics")]
432 fn py_statistics(&self) -> PortfolioStatistics {
433 self.0.borrow().statistics()
434 }
435}
436
437#[pymodule]
443pub fn portfolio(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
444 m.add_class::<PortfolioConfig>()?;
445 m.add_class::<PyPortfolio>()?;
446 Ok(())
447}
448
449impl PyPortfolio {
450 fn account_for_query(
451 &self,
452 venue: Option<&Venue>,
453 account_id: Option<&AccountId>,
454 ) -> Option<AccountAny> {
455 let portfolio = self.0.borrow();
456 let cache = portfolio.cache().borrow();
457 if let Some(account_id) = account_id {
458 cache.account(account_id).map(|account| account.clone())
459 } else if let Some(venue) = venue {
460 cache
461 .account_for_venue(venue)
462 .map(|account| account.clone())
463 } else {
464 None
465 }
466 }
467
468 fn account_for_required_query(
469 &self,
470 venue: Option<&Venue>,
471 account_id: Option<&AccountId>,
472 ) -> PyResult<Option<AccountAny>> {
473 if venue.is_none() && account_id.is_none() {
474 return Err(to_pyvalue_err("venue or account_id must be provided"));
475 }
476
477 Ok(self.account_for_query(venue, account_id))
478 }
479
480 fn position_venues(&self, open_only: bool, account_id: Option<&AccountId>) -> Vec<Venue> {
481 let portfolio = self.0.borrow();
482 let cache = portfolio.cache().borrow();
483 let venues: IndexSet<Venue> = if open_only {
484 cache
485 .positions_open(None, None, None, account_id, None)
486 .iter()
487 .map(|position| position.instrument_id.venue)
488 .collect()
489 } else {
490 cache
491 .positions(None, None, None, account_id, None)
492 .iter()
493 .map(|position| position.instrument_id.venue)
494 .collect()
495 };
496 venues.into_iter().collect()
497 }
498
499 fn aggregate_currency_maps<F>(
500 &self,
501 py: Python<'_>,
502 venues: Vec<Venue>,
503 mut query: F,
504 ) -> PyResult<Py<PyDict>>
505 where
506 F: FnMut(&mut Portfolio, &Venue) -> IndexMap<Currency, Money>,
507 {
508 let mut totals: IndexMap<Currency, Money> = IndexMap::new();
509 let mut portfolio = self.0.borrow_mut();
510
511 for venue in venues {
512 add_money_map(&mut totals, query(&mut portfolio, &venue));
513 }
514
515 currency_money_map_to_pydict(py, totals)
516 }
517
518 fn aggregate_net_exposures(
519 &self,
520 py: Python<'_>,
521 account_id: Option<&AccountId>,
522 ) -> PyResult<Option<Py<PyDict>>> {
523 let venues = self.position_venues(true, account_id);
524 if venues.is_empty() {
525 return match account_id {
526 Some(account_id) if self.account_for_query(None, Some(account_id)).is_some() => {
527 Ok(Some(currency_money_map_to_pydict(py, IndexMap::new())?))
528 }
529 _ => Ok(None),
530 };
531 }
532
533 let mut totals: IndexMap<Currency, Money> = IndexMap::new();
534 let portfolio = self.0.borrow();
535 for venue in venues {
536 let Some(exposures) = portfolio.net_exposures(&venue, account_id) else {
537 return Ok(None);
538 };
539 add_money_map(&mut totals, exposures);
540 }
541
542 Ok(Some(currency_money_map_to_pydict(py, totals)?))
543 }
544
545 fn account_equity(
546 &self,
547 py: Python<'_>,
548 account_id: Option<&AccountId>,
549 ) -> PyResult<Py<PyDict>> {
550 let Some(account_id) = account_id else {
551 return Err(to_pyvalue_err("account_id must be provided"));
552 };
553
554 let Some(snapshot) = self.0.borrow_mut().build_snapshot(account_id) else {
555 return currency_money_map_to_pydict(py, IndexMap::new());
556 };
557
558 let map = snapshot
559 .total_equity
560 .into_iter()
561 .map(|money| (money.currency, money))
562 .collect();
563 currency_money_map_to_pydict(py, map)
564 }
565
566 fn net_position_for_account(
567 &self,
568 instrument_id: &InstrumentId,
569 account_id: Option<&AccountId>,
570 ) -> Decimal {
571 self.0
572 .borrow()
573 .cache()
574 .borrow()
575 .positions_open(None, Some(instrument_id), None, account_id, None)
576 .iter()
577 .map(|position| position.signed_decimal_qty())
578 .sum()
579 }
580
581 fn is_completely_flat_for_account(&self, account_id: Option<&AccountId>) -> bool {
582 let portfolio = self.0.borrow();
583 let cache = portfolio.cache().borrow();
584 let mut net_positions: IndexMap<InstrumentId, Decimal> = IndexMap::new();
585
586 for position in cache.positions_open(None, None, None, account_id, None) {
587 *net_positions
588 .entry(position.instrument_id)
589 .or_insert(Decimal::ZERO) += position.signed_decimal_qty();
590 }
591
592 net_positions
593 .values()
594 .all(|quantity| *quantity == Decimal::ZERO)
595 }
596}
597
598fn currency_money_map_to_pydict(
599 py: Python<'_>,
600 map: IndexMap<Currency, Money>,
601) -> PyResult<Py<PyDict>> {
602 let dict = PyDict::new(py);
603 for (currency, money) in map {
604 dict.set_item(currency, money)?;
605 }
606 Ok(dict.unbind())
607}
608
609fn instrument_money_map_to_pydict(
610 py: Python<'_>,
611 map: IndexMap<InstrumentId, Money>,
612) -> PyResult<Py<PyDict>> {
613 let dict = PyDict::new(py);
614 for (instrument_id, money) in map {
615 dict.set_item(instrument_id, money)?;
616 }
617 Ok(dict.unbind())
618}
619
620fn add_money_map(totals: &mut IndexMap<Currency, Money>, map: IndexMap<Currency, Money>) {
621 for (currency, money) in map {
622 totals
623 .entry(currency)
624 .and_modify(|total| *total = *total + money)
625 .or_insert(money);
626 }
627}
628
629fn unsupported_price(price: Option<&Price>) -> PyResult<()> {
630 match price {
631 Some(_) => Err(to_pynotimplemented_err(
632 "price override is not yet supported by the Rust Portfolio",
633 )),
634 None => Ok(()),
635 }
636}
637
638fn unsupported_target_currency(target_currency: Option<&Currency>) -> PyResult<()> {
639 match target_currency {
640 Some(_) => Err(to_pynotimplemented_err(
641 "target_currency conversion is not yet supported by the Rust Portfolio",
642 )),
643 None => Ok(()),
644 }
645}