Skip to main content

nautilus_model/events/portfolio/
snapshot.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
16use std::fmt::Display;
17
18use nautilus_core::{UUID4, UnixNanos};
19use serde::{Deserialize, Serialize};
20
21use crate::{
22    enums::AccountType,
23    identifiers::{AccountId, InstrumentId},
24    types::{AccountBalance, Currency, MarginBalance, Money},
25};
26
27/// Represents a point-in-time snapshot of portfolio state for a single account.
28///
29/// Unlike [`AccountState`](crate::events::AccountState), which fires only on
30/// balance or margin changes, `PortfolioSnapshot` carries a continuous
31/// mark-to-market view by folding open-position valuations into the totals.
32/// The default equity curve records one snapshot at account registration, at every
33/// UTC midnight, and at shutdown, including while the account is flat. An optional
34/// fine-grained stream records additional snapshots while positions are open.
35/// Totals span every venue the account holds positions on, so multi-venue
36/// accounts (e.g., a prime broker routing across exchanges) produce a single
37/// account-wide snapshot rather than per-venue slices.
38#[repr(C)]
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(
41    feature = "python",
42    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
43)]
44#[cfg_attr(
45    feature = "python",
46    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
47)]
48pub struct PortfolioSnapshot {
49    /// The account ID this snapshot belongs to.
50    pub account_id: AccountId,
51    /// The type of the account (e.g., cash, margin).
52    pub account_type: AccountType,
53    /// The base currency for the account, if applicable.
54    pub base_currency: Option<Currency>,
55    /// The per-currency account balances at snapshot time.
56    pub balances: Vec<AccountBalance>,
57    /// The per-instrument margin balances at snapshot time (margin accounts only).
58    pub margins: Vec<MarginBalance>,
59    /// The per-currency unrealized PnL across all open positions at snapshot time.
60    pub unrealized_pnls: Vec<Money>,
61    /// The per-currency realized PnL accumulated for positions opened in this session.
62    pub realized_pnls: Vec<Money>,
63    /// The per-currency total equity (mark-to-market).
64    ///
65    /// For cash accounts: `balance.total + Σ mark_value(open positions)` in the same currency.
66    /// For calculated margin accounts: balance total plus signed mark values of premium-based positions
67    /// and unrealized PnL of other open positions, in the same currency.
68    /// For reported margin accounts: balance total plus unrealized PnL of all open positions.
69    /// Calculated accounts have `calculate_account_state` enabled and update balances from fills.
70    pub total_equity: Vec<Money>,
71    /// The resolved total equity in the account base currency, when conversion is enabled.
72    #[serde(default)]
73    pub base_currency_equity: Option<Money>,
74    /// Whether this sample contains carried or unavailable valuation inputs.
75    #[serde(default)]
76    pub is_stale: bool,
77    /// Instruments valued with a carried price.
78    #[serde(default)]
79    pub stale_instruments: Vec<InstrumentId>,
80    /// Source currencies converted with a carried exchange rate.
81    #[serde(default)]
82    pub stale_currencies: Vec<Currency>,
83    /// Open-position instruments excluded because no complete valid valuation was ever available.
84    #[serde(default)]
85    pub unpriced_instruments: Vec<InstrumentId>,
86    /// The unique identifier for the event.
87    pub event_id: UUID4,
88    /// UNIX timestamp (nanoseconds) when the event occurred.
89    pub ts_event: UnixNanos,
90    /// UNIX timestamp (nanoseconds) when the event was initialized.
91    pub ts_init: UnixNanos,
92}
93
94impl PortfolioSnapshot {
95    /// Creates a new [`PortfolioSnapshot`] instance.
96    #[expect(clippy::too_many_arguments)]
97    #[must_use]
98    pub fn new(
99        account_id: AccountId,
100        account_type: AccountType,
101        base_currency: Option<Currency>,
102        balances: Vec<AccountBalance>,
103        margins: Vec<MarginBalance>,
104        unrealized_pnls: Vec<Money>,
105        realized_pnls: Vec<Money>,
106        total_equity: Vec<Money>,
107        base_currency_equity: Option<Money>,
108        is_stale: bool,
109        stale_instruments: Vec<InstrumentId>,
110        stale_currencies: Vec<Currency>,
111        unpriced_instruments: Vec<InstrumentId>,
112        event_id: UUID4,
113        ts_event: UnixNanos,
114        ts_init: UnixNanos,
115    ) -> Self {
116        Self {
117            account_id,
118            account_type,
119            base_currency,
120            balances,
121            margins,
122            unrealized_pnls,
123            realized_pnls,
124            total_equity,
125            base_currency_equity,
126            is_stale,
127            stale_instruments,
128            stale_currencies,
129            unpriced_instruments,
130            event_id,
131            ts_event,
132            ts_init,
133        }
134    }
135}
136
137impl Display for PortfolioSnapshot {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(
140            f,
141            "{}(account_id={}, account_type={}, total_equity=[{}], unrealized_pnls=[{}], realized_pnls=[{}], event_id={})",
142            stringify!(PortfolioSnapshot),
143            self.account_id,
144            self.account_type,
145            self.total_equity
146                .iter()
147                .map(|m| format!("{m}"))
148                .collect::<Vec<_>>()
149                .join(", "),
150            self.unrealized_pnls
151                .iter()
152                .map(|m| format!("{m}"))
153                .collect::<Vec<_>>()
154                .join(", "),
155            self.realized_pnls
156                .iter()
157                .map(|m| format!("{m}"))
158                .collect::<Vec<_>>()
159                .join(", "),
160            self.event_id,
161        )
162    }
163}
164
165impl PartialEq for PortfolioSnapshot {
166    fn eq(&self, other: &Self) -> bool {
167        self.account_id == other.account_id && self.event_id == other.event_id
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use rstest::rstest;
174
175    use super::*;
176
177    #[rstest]
178    fn test_deserialize_legacy_snapshot_defaults_valuation_metadata() {
179        let snapshot = PortfolioSnapshot::new(
180            AccountId::new("SIM-001"),
181            AccountType::Cash,
182            Some(Currency::USD()),
183            Vec::new(),
184            Vec::new(),
185            Vec::new(),
186            Vec::new(),
187            vec![Money::from("100.00 USD")],
188            Some(Money::from("100.00 USD")),
189            true,
190            vec![InstrumentId::from("AUDUSD.SIM")],
191            vec![Currency::AUD()],
192            vec![InstrumentId::from("GBPUSD.SIM")],
193            UUID4::new(),
194            UnixNanos::from(1),
195            UnixNanos::from(2),
196        );
197        let mut value = serde_json::to_value(snapshot).unwrap();
198        let object = value.as_object_mut().unwrap();
199        object.remove("base_currency_equity");
200        object.remove("is_stale");
201        object.remove("stale_instruments");
202        object.remove("stale_currencies");
203        object.remove("unpriced_instruments");
204
205        let decoded: PortfolioSnapshot = serde_json::from_value(value).unwrap();
206
207        assert_eq!(decoded.base_currency_equity, None);
208        assert!(!decoded.is_stale);
209        assert!(decoded.stale_instruments.is_empty());
210        assert!(decoded.stale_currencies.is_empty());
211        assert!(decoded.unpriced_instruments.is_empty());
212    }
213}