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 margin accounts: `balance.total + Σ unrealized_pnl(open positions)` in the same currency.
67    pub total_equity: Vec<Money>,
68    /// The resolved total equity in the account base currency, when conversion is enabled.
69    #[serde(default)]
70    pub base_currency_equity: Option<Money>,
71    /// Whether this sample contains carried or unavailable valuation inputs.
72    #[serde(default)]
73    pub is_stale: bool,
74    /// Instruments valued with a carried price.
75    #[serde(default)]
76    pub stale_instruments: Vec<InstrumentId>,
77    /// Source currencies converted with a carried exchange rate.
78    #[serde(default)]
79    pub stale_currencies: Vec<Currency>,
80    /// Open-position instruments excluded because no complete valid valuation was ever available.
81    #[serde(default)]
82    pub unpriced_instruments: Vec<InstrumentId>,
83    /// The unique identifier for the event.
84    pub event_id: UUID4,
85    /// UNIX timestamp (nanoseconds) when the event occurred.
86    pub ts_event: UnixNanos,
87    /// UNIX timestamp (nanoseconds) when the event was initialized.
88    pub ts_init: UnixNanos,
89}
90
91impl PortfolioSnapshot {
92    /// Creates a new [`PortfolioSnapshot`] instance.
93    #[expect(clippy::too_many_arguments)]
94    #[must_use]
95    pub fn new(
96        account_id: AccountId,
97        account_type: AccountType,
98        base_currency: Option<Currency>,
99        balances: Vec<AccountBalance>,
100        margins: Vec<MarginBalance>,
101        unrealized_pnls: Vec<Money>,
102        realized_pnls: Vec<Money>,
103        total_equity: Vec<Money>,
104        base_currency_equity: Option<Money>,
105        is_stale: bool,
106        stale_instruments: Vec<InstrumentId>,
107        stale_currencies: Vec<Currency>,
108        unpriced_instruments: Vec<InstrumentId>,
109        event_id: UUID4,
110        ts_event: UnixNanos,
111        ts_init: UnixNanos,
112    ) -> Self {
113        Self {
114            account_id,
115            account_type,
116            base_currency,
117            balances,
118            margins,
119            unrealized_pnls,
120            realized_pnls,
121            total_equity,
122            base_currency_equity,
123            is_stale,
124            stale_instruments,
125            stale_currencies,
126            unpriced_instruments,
127            event_id,
128            ts_event,
129            ts_init,
130        }
131    }
132}
133
134impl Display for PortfolioSnapshot {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(
137            f,
138            "{}(account_id={}, account_type={}, total_equity=[{}], unrealized_pnls=[{}], realized_pnls=[{}], event_id={})",
139            stringify!(PortfolioSnapshot),
140            self.account_id,
141            self.account_type,
142            self.total_equity
143                .iter()
144                .map(|m| format!("{m}"))
145                .collect::<Vec<_>>()
146                .join(", "),
147            self.unrealized_pnls
148                .iter()
149                .map(|m| format!("{m}"))
150                .collect::<Vec<_>>()
151                .join(", "),
152            self.realized_pnls
153                .iter()
154                .map(|m| format!("{m}"))
155                .collect::<Vec<_>>()
156                .join(", "),
157            self.event_id,
158        )
159    }
160}
161
162impl PartialEq for PortfolioSnapshot {
163    fn eq(&self, other: &Self) -> bool {
164        self.account_id == other.account_id && self.event_id == other.event_id
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171
172    use super::*;
173
174    #[rstest]
175    fn test_deserialize_legacy_snapshot_defaults_valuation_metadata() {
176        let snapshot = PortfolioSnapshot::new(
177            AccountId::new("SIM-001"),
178            AccountType::Cash,
179            Some(Currency::USD()),
180            Vec::new(),
181            Vec::new(),
182            Vec::new(),
183            Vec::new(),
184            vec![Money::from("100.00 USD")],
185            Some(Money::from("100.00 USD")),
186            true,
187            vec![InstrumentId::from("AUDUSD.SIM")],
188            vec![Currency::AUD()],
189            vec![InstrumentId::from("GBPUSD.SIM")],
190            UUID4::new(),
191            UnixNanos::from(1),
192            UnixNanos::from(2),
193        );
194        let mut value = serde_json::to_value(snapshot).unwrap();
195        let object = value.as_object_mut().unwrap();
196        object.remove("base_currency_equity");
197        object.remove("is_stale");
198        object.remove("stale_instruments");
199        object.remove("stale_currencies");
200        object.remove("unpriced_instruments");
201
202        let decoded: PortfolioSnapshot = serde_json::from_value(value).unwrap();
203
204        assert_eq!(decoded.base_currency_equity, None);
205        assert!(!decoded.is_stale);
206        assert!(decoded.stale_instruments.is_empty());
207        assert!(decoded.stale_currencies.is_empty());
208        assert!(decoded.unpriced_instruments.is_empty());
209    }
210}