Skip to main content

nautilus_model/events/account/
state.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::{collections::HashMap, 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},
25};
26
27/// Represents an event which includes information on the state of the account.
28#[repr(C)]
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(
31    feature = "python",
32    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
33)]
34#[cfg_attr(
35    feature = "python",
36    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
37)]
38pub struct AccountState {
39    /// The account ID associated with the event.
40    pub account_id: AccountId,
41    /// The type of the account (e.g., margin, spot, etc.).
42    pub account_type: AccountType,
43    /// The base currency for the account, if applicable.
44    pub base_currency: Option<Currency>,
45    /// The balances in the account.
46    pub balances: Vec<AccountBalance>,
47    /// The margin balances in the account.
48    pub margins: Vec<MarginBalance>,
49    /// Indicates if the account state is reported by the exchange
50    /// (as opposed to system-calculated).
51    pub is_reported: bool,
52    /// The unique identifier for the event.
53    pub event_id: UUID4,
54    /// UNIX timestamp (nanoseconds) when the event occurred.
55    pub ts_event: UnixNanos,
56    /// UNIX timestamp (nanoseconds) when the event was initialized.
57    pub ts_init: UnixNanos,
58}
59
60impl AccountState {
61    /// Creates a new [`AccountState`] instance.
62    #[expect(clippy::too_many_arguments)]
63    #[must_use]
64    pub fn new(
65        account_id: AccountId,
66        account_type: AccountType,
67        balances: Vec<AccountBalance>,
68        margins: Vec<MarginBalance>,
69        is_reported: bool,
70        event_id: UUID4,
71        ts_event: UnixNanos,
72        ts_init: UnixNanos,
73        base_currency: Option<Currency>,
74    ) -> Self {
75        Self {
76            account_id,
77            account_type,
78            base_currency,
79            balances,
80            margins,
81            is_reported,
82            event_id,
83            ts_event,
84            ts_init,
85        }
86    }
87
88    /// Returns `true` if this account state has the same balances and margins as another.
89    ///
90    /// This compares all balances and margins for equality, returning `true` only if
91    /// all balances and margins are equal. If any balance or margin is different or
92    /// missing, returns `false`.
93    ///
94    /// # Note
95    ///
96    /// This method does not compare event IDs, timestamps, or other metadata - only
97    /// the actual balance and margin values.
98    #[must_use]
99    pub fn has_same_balances_and_margins(&self, other: &Self) -> bool {
100        // Quick check - if lengths differ, they can't be equal
101        if self.balances.len() != other.balances.len() || self.margins.len() != other.margins.len()
102        {
103            return false;
104        }
105
106        // Compare balances by currency
107        let self_balances: HashMap<Currency, &AccountBalance> = self
108            .balances
109            .iter()
110            .map(|balance| (balance.currency, balance))
111            .collect();
112
113        let other_balances: HashMap<Currency, &AccountBalance> = other
114            .balances
115            .iter()
116            .map(|balance| (balance.currency, balance))
117            .collect();
118
119        // Check if all balances are equal
120        for (currency, self_balance) in &self_balances {
121            match other_balances.get(currency) {
122                Some(other_balance) => {
123                    if self_balance != other_balance {
124                        return false;
125                    }
126                }
127                None => return false, // Currency missing in other
128            }
129        }
130
131        // Compare margins by (instrument_id, currency) so that account-wide
132        // entries (instrument_id = None) for different collateral currencies
133        // do not collide.
134        let self_margins: HashMap<(Option<InstrumentId>, Currency), &MarginBalance> = self
135            .margins
136            .iter()
137            .map(|margin| ((margin.instrument_id, margin.currency), margin))
138            .collect();
139
140        let other_margins: HashMap<(Option<InstrumentId>, Currency), &MarginBalance> = other
141            .margins
142            .iter()
143            .map(|margin| ((margin.instrument_id, margin.currency), margin))
144            .collect();
145
146        // Check if all margins are equal
147        for (key, self_margin) in &self_margins {
148            match other_margins.get(key) {
149                Some(other_margin) => {
150                    if self_margin != other_margin {
151                        return false;
152                    }
153                }
154                None => return false, // Entry missing in other
155            }
156        }
157
158        true
159    }
160}
161
162impl Display for AccountState {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(
165            f,
166            "{}(account_id={}, account_type={}, base_currency={}, is_reported={}, balances=[{}], margins=[{}], event_id={})",
167            stringify!(AccountState),
168            self.account_id,
169            self.account_type,
170            self.base_currency.map_or_else(
171                || "None".to_string(),
172                |base_currency| format!("{}", base_currency.code)
173            ),
174            self.is_reported,
175            self.balances
176                .iter()
177                .map(|b| format!("{b}"))
178                .collect::<Vec<String>>()
179                .join(", "),
180            self.margins
181                .iter()
182                .map(|m| format!("{m}"))
183                .collect::<Vec<String>>()
184                .join(", "),
185            self.event_id
186        )
187    }
188}
189
190impl PartialEq for AccountState {
191    fn eq(&self, other: &Self) -> bool {
192        self.account_id == other.account_id
193            && self.account_type == other.account_type
194            && self.event_id == other.event_id
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use nautilus_core::{UUID4, UnixNanos};
201    use rstest::rstest;
202
203    use crate::{
204        enums::AccountType,
205        events::{
206            AccountState,
207            account::stubs::{cash_account_state, margin_account_state},
208        },
209        identifiers::{AccountId, InstrumentId},
210        types::{AccountBalance, Currency, MarginBalance, Money},
211    };
212
213    #[rstest]
214    fn test_equality() {
215        let cash_account_state_1 = cash_account_state();
216        let cash_account_state_2 = cash_account_state();
217        assert_eq!(cash_account_state_1, cash_account_state_2);
218    }
219
220    #[rstest]
221    fn test_display_cash_account_state(cash_account_state: AccountState) {
222        let display = format!("{cash_account_state}");
223        assert_eq!(
224            display,
225            "AccountState(account_id=SIM-001, account_type=CASH, base_currency=USD, is_reported=true, \
226            balances=[AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)], \
227            margins=[], event_id=16578139-a945-4b65-b46c-bc131a15d8e7)"
228        );
229    }
230
231    #[rstest]
232    fn test_display_margin_account_state(margin_account_state: AccountState) {
233        let display = format!("{margin_account_state}");
234        assert_eq!(
235            display,
236            "AccountState(account_id=SIM-001, account_type=MARGIN, base_currency=USD, is_reported=true, \
237            balances=[AccountBalance(total=1525000.00 USD, locked=25000.00 USD, free=1500000.00 USD)], \
238            margins=[MarginBalance(initial=5000.00 USD, maintenance=20000.00 USD, instrument_id=BTCUSDT.COINBASE)], \
239            event_id=16578139-a945-4b65-b46c-bc131a15d8e7)"
240        );
241    }
242
243    #[rstest]
244    fn test_has_same_balances_and_margins_when_identical() {
245        let state1 = cash_account_state();
246        let state2 = cash_account_state();
247        assert!(state1.has_same_balances_and_margins(&state2));
248    }
249
250    #[rstest]
251    fn test_has_same_balances_and_margins_when_different_balance_amounts() {
252        let state1 = cash_account_state();
253        let mut state2 = cash_account_state();
254        // Create a different balance with same currency
255        let usd = Currency::USD();
256        let different_balance = AccountBalance::new(
257            Money::new(2_000_000.0, usd),
258            Money::new(50000.0, usd),
259            Money::new(1_950_000.0, usd),
260        );
261        state2.balances = vec![different_balance];
262        assert!(!state1.has_same_balances_and_margins(&state2));
263    }
264
265    #[rstest]
266    fn test_has_same_balances_and_margins_when_different_balance_currencies() {
267        let state1 = cash_account_state();
268        let mut state2 = cash_account_state();
269        // Create a balance with different currency
270        let eur = Currency::EUR();
271        let different_balance = AccountBalance::new(
272            Money::new(1_525_000.0, eur),
273            Money::new(25000.0, eur),
274            Money::new(1_500_000.0, eur),
275        );
276        state2.balances = vec![different_balance];
277        assert!(!state1.has_same_balances_and_margins(&state2));
278    }
279
280    #[rstest]
281    fn test_has_same_balances_and_margins_when_missing_balance() {
282        let state1 = cash_account_state();
283        let mut state2 = cash_account_state();
284        // Add an additional balance to state2
285        let eur = Currency::EUR();
286        let additional_balance = AccountBalance::new(
287            Money::new(1_000_000.0, eur),
288            Money::new(0.0, eur),
289            Money::new(1_000_000.0, eur),
290        );
291        state2.balances.push(additional_balance);
292        assert!(!state1.has_same_balances_and_margins(&state2));
293    }
294
295    #[rstest]
296    fn test_has_same_balances_and_margins_when_different_margin_amounts() {
297        let state1 = margin_account_state();
298        let mut state2 = margin_account_state();
299        // Create a different margin with same instrument_id
300        let usd = Currency::USD();
301        let instrument_id = InstrumentId::from("BTCUSDT.COINBASE");
302        let different_margin = MarginBalance::new(
303            Money::new(10000.0, usd),
304            Money::new(40000.0, usd),
305            Some(instrument_id),
306        );
307        state2.margins = vec![different_margin];
308        assert!(!state1.has_same_balances_and_margins(&state2));
309    }
310
311    #[rstest]
312    fn test_has_same_balances_and_margins_when_different_margin_instruments() {
313        let state1 = margin_account_state();
314        let mut state2 = margin_account_state();
315        // Create a margin with different instrument_id
316        let usd = Currency::USD();
317        let different_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
318        let different_margin = MarginBalance::new(
319            Money::new(5000.0, usd),
320            Money::new(20000.0, usd),
321            Some(different_instrument_id),
322        );
323        state2.margins = vec![different_margin];
324        assert!(!state1.has_same_balances_and_margins(&state2));
325    }
326
327    #[rstest]
328    fn test_has_same_balances_and_margins_when_missing_margin() {
329        let state1 = margin_account_state();
330        let mut state2 = margin_account_state();
331        // Add an additional margin to state2
332        let usd = Currency::USD();
333        let additional_instrument_id = InstrumentId::from("ETHUSDT.BINANCE");
334        let additional_margin = MarginBalance::new(
335            Money::new(3000.0, usd),
336            Money::new(15000.0, usd),
337            Some(additional_instrument_id),
338        );
339        state2.margins.push(additional_margin);
340        assert!(!state1.has_same_balances_and_margins(&state2));
341    }
342
343    #[rstest]
344    fn test_has_same_balances_and_margins_with_empty_collections() {
345        let account_id = AccountId::new("TEST-001");
346        let event_id = UUID4::new();
347        let ts_event = UnixNanos::from(1);
348        let ts_init = UnixNanos::from(2);
349
350        let state1 = AccountState::new(
351            account_id,
352            AccountType::Cash,
353            vec![], // Empty balances
354            vec![], // Empty margins
355            true,
356            event_id,
357            ts_event,
358            ts_init,
359            Some(Currency::USD()),
360        );
361
362        let state2 = AccountState::new(
363            account_id,
364            AccountType::Cash,
365            vec![], // Empty balances
366            vec![], // Empty margins
367            true,
368            UUID4::new(),       // Different event_id
369            UnixNanos::from(3), // Different timestamps
370            UnixNanos::from(4),
371            Some(Currency::USD()),
372        );
373
374        assert!(state1.has_same_balances_and_margins(&state2));
375    }
376
377    #[rstest]
378    fn test_has_same_balances_and_margins_with_multiple_balances_and_margins() {
379        let account_id = AccountId::new("TEST-001");
380        let event_id = UUID4::new();
381        let ts_event = UnixNanos::from(1);
382        let ts_init = UnixNanos::from(2);
383
384        let usd = Currency::USD();
385        let eur = Currency::EUR();
386        let btc_instrument = InstrumentId::from("BTCUSDT.COINBASE");
387        let eth_instrument = InstrumentId::from("ETHUSDT.BINANCE");
388
389        let balances = vec![
390            AccountBalance::new(
391                Money::new(1_000_000.0, usd),
392                Money::new(0.0, usd),
393                Money::new(1_000_000.0, usd),
394            ),
395            AccountBalance::new(
396                Money::new(500_000.0, eur),
397                Money::new(10000.0, eur),
398                Money::new(490_000.0, eur),
399            ),
400        ];
401
402        let margins = vec![
403            MarginBalance::new(
404                Money::new(5000.0, usd),
405                Money::new(20000.0, usd),
406                Some(btc_instrument),
407            ),
408            MarginBalance::new(
409                Money::new(3000.0, usd),
410                Money::new(15000.0, usd),
411                Some(eth_instrument),
412            ),
413        ];
414
415        let state1 = AccountState::new(
416            account_id,
417            AccountType::Margin,
418            balances.clone(),
419            margins.clone(),
420            true,
421            event_id,
422            ts_event,
423            ts_init,
424            Some(usd),
425        );
426
427        let state2 = AccountState::new(
428            account_id,
429            AccountType::Margin,
430            balances,
431            margins,
432            true,
433            UUID4::new(),       // Different event_id
434            UnixNanos::from(3), // Different timestamps
435            UnixNanos::from(4),
436            Some(usd),
437        );
438
439        assert!(state1.has_same_balances_and_margins(&state2));
440    }
441}