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}