Skip to main content

nautilus_portfolio/python/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings from [PyO3](https://pyo3.rs).
17
18use std::{cell::RefCell, rc::Rc};
19
20use indexmap::{IndexMap, IndexSet};
21use nautilus_analysis::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    /// Configuration for `Portfolio` instances.
39    #[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/// Wrapper providing shared access to [`Portfolio`] from Python.
110#[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    /// Creates a [`PyPortfolio`] from a shared [`Portfolio`].
122    #[must_use]
123    pub fn from_rc(rc: Rc<RefCell<Portfolio>>) -> Self {
124        Self(rc)
125    }
126
127    /// Returns the inner shared [`Portfolio`].
128    #[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            // Closed-only venues still contribute realized PnL.
260            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/// Loaded as `nautilus_pyo3.portfolio`.
438///
439/// # Errors
440///
441/// Returns a `PyErr` if registering any module components fails.
442#[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}