Skip to main content

nautilus_model/accounts/
any.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//! Enum wrapper providing a type-erased view over the various concrete [`Account`] implementations.
17//!
18//! The `AccountAny` enum is primarily used when heterogeneous account types need to be stored in a
19//! single collection (e.g. `Vec<AccountAny>`).  Each variant simply embeds one of the concrete
20//! account structs defined in this module.
21
22use enum_dispatch::enum_dispatch;
23use indexmap::IndexMap;
24use nautilus_core::correctness::{CorrectnessResult, CorrectnessResultExt, FAILED};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28    accounts::{Account, BettingAccount, CashAccount, MarginAccount, WalletAccount},
29    enums::{AccountType, LiquiditySide},
30    events::{AccountState, OrderFilled},
31    fees::MakerTakerFeeRates,
32    identifiers::AccountId,
33    instruments::InstrumentAny,
34    position::Position,
35    types::{AccountBalance, Currency, Money, Price, Quantity},
36};
37
38/// Represents any account type, so accounts can be held in one collection.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[enum_dispatch(Account)]
41pub enum AccountAny {
42    /// A margin account holding leveraged positions.
43    Margin(MarginAccount),
44    /// A cash account holding unleveraged positions.
45    Cash(CashAccount),
46    /// A betting account holding backed and laid stakes.
47    Betting(BettingAccount),
48    /// A blockchain wallet account holding native and token balances.
49    Wallet(WalletAccount),
50}
51
52impl AccountAny {
53    /// Returns a copy without stored account state events.
54    #[must_use]
55    pub fn clone_without_events(&self) -> Self {
56        match self {
57            Self::Margin(margin) => Self::Margin(margin.clone_without_events()),
58            Self::Cash(cash) => Self::Cash(cash.clone_without_events()),
59            Self::Betting(betting) => Self::Betting(betting.clone_without_events()),
60            Self::Wallet(wallet) => Self::Wallet(wallet.clone_without_events()),
61        }
62    }
63
64    #[must_use]
65    pub fn id(&self) -> AccountId {
66        Account::id(self)
67    }
68
69    #[must_use]
70    pub fn last_event(&self) -> Option<AccountState> {
71        Account::last_event(self)
72    }
73
74    #[must_use]
75    pub fn events(&self) -> Vec<AccountState> {
76        Account::events(self)
77    }
78
79    /// Applies an account state event to update the account.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the event belongs to a different account or the account state cannot be
84    /// applied (e.g., negative balance when borrowing is not allowed for a cash account).
85    pub fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
86        Account::apply(self, event)
87    }
88
89    /// Sets whether account state should be recalculated from order fills.
90    pub fn set_calculate_account_state(&mut self, calculate_account_state: bool) {
91        match self {
92            Self::Margin(margin) => margin.base.calculate_account_state = calculate_account_state,
93            Self::Cash(cash) => cash.base.calculate_account_state = calculate_account_state,
94            Self::Betting(betting) => {
95                betting.base.calculate_account_state = calculate_account_state;
96            }
97            Self::Wallet(wallet) => {
98                wallet.base.calculate_account_state = calculate_account_state;
99            }
100        }
101    }
102
103    #[must_use]
104    pub fn balances(&self) -> IndexMap<Currency, AccountBalance> {
105        Account::balances(self)
106    }
107
108    #[must_use]
109    pub fn balances_locked(&self) -> IndexMap<Currency, Money> {
110        Account::balances_locked(self)
111    }
112
113    #[must_use]
114    pub fn base_currency(&self) -> Option<Currency> {
115        Account::base_currency(self)
116    }
117
118    /// # Errors
119    ///
120    /// Returns an error if `events` is empty or an account state cannot be created or applied.
121    pub fn from_events(events: &[AccountState]) -> anyhow::Result<Self> {
122        let Some((init_event, remaining_events)) = events.split_first() else {
123            anyhow::bail!("No account events provided to create `AccountAny`");
124        };
125
126        let mut account = Self::from_state_checked(init_event.clone())?;
127
128        for event in remaining_events {
129            account.apply(event.clone())?;
130        }
131
132        Ok(account)
133    }
134
135    /// # Errors
136    ///
137    /// Returns an error if calculating P&Ls fails for the underlying account.
138    pub fn calculate_pnls(
139        &self,
140        instrument: &InstrumentAny,
141        fill: &OrderFilled,
142        position: Option<Position>,
143    ) -> anyhow::Result<Vec<Money>> {
144        Account::calculate_pnls(self, instrument, fill, position)
145    }
146
147    /// # Errors
148    ///
149    /// Returns an error if calculating commission fails for the underlying account.
150    pub fn calculate_commission(
151        &self,
152        instrument: &InstrumentAny,
153        last_qty: Quantity,
154        last_px: Price,
155        liquidity_side: LiquiditySide,
156        fee_rates: MakerTakerFeeRates,
157        use_quote_for_inverse: Option<bool>,
158    ) -> anyhow::Result<Money> {
159        Account::calculate_commission(
160            self,
161            instrument,
162            last_qty,
163            last_px,
164            liquidity_side,
165            fee_rates,
166            use_quote_for_inverse,
167        )
168    }
169
170    #[must_use]
171    pub fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
172        Account::balance(self, currency)
173    }
174}
175
176impl AccountAny {
177    /// Creates an `AccountAny` from an `AccountState`.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if a wallet account state is invalid.
182    pub fn try_from_state(event: AccountState) -> Result<Self, &'static str> {
183        Self::from_state_checked(event).map_err(|_| "Invalid wallet account state")
184    }
185
186    fn from_state_checked(event: AccountState) -> CorrectnessResult<Self> {
187        match event.account_type {
188            AccountType::Margin => Ok(Self::Margin(MarginAccount::new(event, false))),
189            AccountType::Cash => Ok(Self::Cash(CashAccount::new(event, false, false))),
190            AccountType::Betting => Ok(Self::Betting(BettingAccount::new(event, false))),
191            AccountType::Wallet => Ok(Self::Wallet(WalletAccount::new_checked(event, false)?)),
192        }
193    }
194}
195
196impl From<AccountState> for AccountAny {
197    /// Creates an `AccountAny` from an `AccountState`.
198    ///
199    /// # Panics
200    ///
201    /// Panics if a wallet account state is invalid.
202    /// Use [`AccountAny::try_from_state`] for fallible conversion.
203    fn from(event: AccountState) -> Self {
204        Self::from_state_checked(event).expect_display(FAILED)
205    }
206}
207
208impl PartialEq for AccountAny {
209    fn eq(&self, other: &Self) -> bool {
210        self.id() == other.id()
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use rstest::rstest;
217    use rust_decimal::Decimal;
218
219    use crate::{
220        accounts::{
221            Account, AccountAny,
222            margin_model::{MarginModel, MarginModelAny, StandardMarginModel},
223        },
224        events::{AccountState, account::stubs::*},
225        identifiers::{AccountId, InstrumentId},
226        types::Money,
227    };
228
229    #[rstest]
230    fn test_from_events_empty_returns_error() {
231        let events: Vec<AccountState> = vec![];
232        let result = AccountAny::from_events(&events);
233
234        assert_eq!(
235            result.unwrap_err().to_string(),
236            "No account events provided to create `AccountAny`"
237        );
238    }
239
240    #[rstest]
241    fn test_from_events_single_cash_event(cash_account_state: AccountState) {
242        let result = AccountAny::from_events(&[cash_account_state]);
243        assert!(result.is_ok());
244        assert!(matches!(result.unwrap(), AccountAny::Cash(_)));
245    }
246
247    #[rstest]
248    fn test_from_events_rejects_different_account(cash_account_state: AccountState) {
249        let mut different_account = cash_account_state.clone();
250        different_account.account_id = AccountId::from("OTHER-001");
251
252        let result = AccountAny::from_events(&[cash_account_state, different_account]);
253
254        assert_eq!(
255            result.unwrap_err().to_string(),
256            "Account event had a different account ID: expected SIM-001, received OTHER-001"
257        );
258    }
259
260    #[rstest]
261    #[case::cash(cash_account_state())]
262    #[case::margin(margin_account_state())]
263    #[case::betting(betting_account_state())]
264    #[case::wallet(wallet_account_state())]
265    fn test_apply_rejects_different_account_without_mutation(#[case] state: AccountState) {
266        let mut account = AccountAny::try_from_state(state.clone()).unwrap();
267        let balances_before = account.balances();
268        let mut foreign = state;
269        foreign.account_id = AccountId::from("OTHER-001");
270
271        let error = account.apply(foreign).unwrap_err();
272
273        assert_eq!(
274            error.to_string(),
275            "Account event had a different account ID: expected SIM-001, received OTHER-001"
276        );
277        assert_eq!(account.event_count(), 1);
278        assert_eq!(account.balances(), balances_before);
279    }
280
281    #[rstest]
282    fn test_delegated_state_accessors(cash_account_state: AccountState) {
283        let balance = cash_account_state.balances[0];
284        let account = AccountAny::try_from_state(cash_account_state.clone()).unwrap();
285
286        assert_eq!(account.last_event(), Some(cash_account_state.clone()));
287        assert_eq!(account.events(), vec![cash_account_state.clone()]);
288        assert_eq!(account.base_currency(), cash_account_state.base_currency);
289        assert_eq!(
290            account.balances_locked().get(&balance.currency),
291            Some(&balance.locked)
292        );
293        assert_eq!(account.balances().get(&balance.currency), Some(&balance));
294    }
295
296    #[rstest]
297    fn test_equality_compares_account_ids(cash_account_state: AccountState) {
298        let account = AccountAny::try_from_state(cash_account_state.clone()).unwrap();
299        let same = AccountAny::try_from_state(cash_account_state.clone()).unwrap();
300        let mut other_state = cash_account_state;
301        other_state.account_id = AccountId::from("OTHER-001");
302        let other = AccountAny::try_from_state(other_state).unwrap();
303
304        assert_eq!(account, same);
305        assert_ne!(account, other);
306    }
307
308    #[rstest]
309    fn test_from_events_single_margin_event(margin_account_state: AccountState) {
310        let result = AccountAny::from_events(&[margin_account_state]);
311        assert!(result.is_ok());
312        assert!(matches!(result.unwrap(), AccountAny::Margin(_)));
313    }
314
315    #[rstest]
316    #[case::cash(cash_account_state())]
317    #[case::margin(margin_account_state())]
318    #[case::betting(betting_account_state())]
319    #[case::wallet(wallet_account_state())]
320    fn test_clone_without_events_preserves_current_state(#[case] state: AccountState) {
321        let currency = state.balances[0].currency;
322        let instrument_id = InstrumentId::from("CLONE-TEST.SIM");
323        let locked = Money::from_decimal(Decimal::new(725, 2), currency).unwrap();
324        let commission = Money::from_decimal(Decimal::new(135, 2), currency).unwrap();
325        let mut account = AccountAny::try_from_state(state.clone()).unwrap();
326        account.apply(state).unwrap();
327
328        let base = match &mut account {
329            AccountAny::Margin(account) => &mut account.base,
330            AccountAny::Cash(account) => &mut account.base,
331            AccountAny::Betting(account) => &mut account.base,
332            AccountAny::Wallet(account) => &mut account.base,
333        };
334        base.calculate_account_state = true;
335        base.commissions.insert(currency, commission);
336
337        match &mut account {
338            AccountAny::Margin(account) => {
339                account.set_default_leverage(Decimal::new(7, 0));
340                account.set_leverage(instrument_id, Decimal::new(3, 0));
341                account.set_margin_model(MarginModelAny::Standard(StandardMarginModel).into());
342            }
343            AccountAny::Cash(account) => {
344                account.allow_borrowing = true;
345                account
346                    .balances_locked
347                    .insert((instrument_id, currency), locked);
348            }
349            AccountAny::Betting(account) => {
350                account
351                    .balances_locked
352                    .insert((instrument_id, currency), locked);
353            }
354            AccountAny::Wallet(account) => {
355                account
356                    .balances_locked
357                    .insert((instrument_id, currency), locked);
358            }
359        }
360
361        let cloned = account.clone_without_events();
362        let mut expected = account.clone();
363
364        match &mut expected {
365            AccountAny::Margin(account) => account.base.events.clear(),
366            AccountAny::Cash(account) => account.base.events.clear(),
367            AccountAny::Betting(account) => account.base.events.clear(),
368            AccountAny::Wallet(account) => account.base.events.clear(),
369        }
370
371        assert_eq!(account.event_count(), 2);
372        assert_eq!(cloned.event_count(), 0);
373        match (&account, &cloned) {
374            (AccountAny::Margin(source), AccountAny::Margin(cloned)) => {
375                assert_eq!(cloned.margin_model().name(), source.margin_model().name());
376                assert_eq!(cloned.margin_model().name(), "standard");
377            }
378            (AccountAny::Cash(source), AccountAny::Cash(cloned)) => {
379                assert_eq!(cloned.balances_locked, source.balances_locked);
380                assert!(cloned.allow_borrowing);
381            }
382            (AccountAny::Betting(source), AccountAny::Betting(cloned)) => {
383                assert_eq!(cloned.balances_locked, source.balances_locked);
384            }
385            (AccountAny::Wallet(source), AccountAny::Wallet(cloned)) => {
386                assert_eq!(cloned.balances_locked, source.balances_locked);
387            }
388            _ => panic!("cloned account variant changed"),
389        }
390        assert_eq!(
391            serde_json::to_value(&cloned).unwrap(),
392            serde_json::to_value(&expected).unwrap()
393        );
394    }
395
396    #[rstest]
397    fn test_try_from_state_cash(cash_account_state: AccountState) {
398        let result: Result<AccountAny, &'static str> =
399            AccountAny::try_from_state(cash_account_state);
400        assert!(result.is_ok());
401        assert!(matches!(result.unwrap(), AccountAny::Cash(_)));
402    }
403
404    #[rstest]
405    fn test_try_from_state_margin(margin_account_state: AccountState) {
406        let result = AccountAny::try_from_state(margin_account_state);
407        assert!(result.is_ok());
408        assert!(matches!(result.unwrap(), AccountAny::Margin(_)));
409    }
410
411    #[rstest]
412    fn test_try_from_state_betting(betting_account_state: AccountState) {
413        let result = AccountAny::try_from_state(betting_account_state);
414        assert!(result.is_ok());
415        assert!(matches!(result.unwrap(), AccountAny::Betting(_)));
416    }
417
418    #[rstest]
419    fn test_try_from_state_wallet(wallet_account_state: AccountState) {
420        let result = AccountAny::try_from_state(wallet_account_state);
421        assert!(result.is_ok());
422        assert!(matches!(result.unwrap(), AccountAny::Wallet(_)));
423    }
424
425    #[rstest]
426    fn test_try_from_state_invalid_wallet_returns_static_error() {
427        let result: Result<AccountAny, &'static str> =
428            AccountAny::try_from_state(invalid_wallet_state());
429
430        assert_eq!(result.unwrap_err(), "Invalid wallet account state");
431    }
432
433    #[rstest]
434    fn test_from_events_wallet_applies_sequence(
435        wallet_account_state: AccountState,
436        wallet_account_state_changed: AccountState,
437    ) {
438        let result = AccountAny::from_events(&[wallet_account_state, wallet_account_state_changed]);
439        assert!(result.is_ok());
440        let account = result.unwrap();
441        assert!(matches!(account, AccountAny::Wallet(_)));
442        assert_eq!(account.event_count(), 2);
443    }
444
445    #[rstest]
446    fn test_from_events_wallet_rejects_negative_initial_balance() {
447        let result = AccountAny::from_events(&[invalid_wallet_state()]);
448
449        assert!(result.is_err());
450        assert_eq!(
451            result.unwrap_err().to_string(),
452            "Wallet account balance total was negative"
453        );
454    }
455
456    #[rstest]
457    #[case::cash(cash_account_state(), "Cash")]
458    #[case::margin(margin_account_state(), "Margin")]
459    #[case::betting(betting_account_state(), "Betting")]
460    #[case::wallet(wallet_account_state(), "Wallet")]
461    fn test_serde_round_trip_preserves_variant_payload(
462        #[case] state: AccountState,
463        #[case] expected_variant: &str,
464    ) {
465        let account = AccountAny::try_from_state(state).unwrap();
466
467        let value = serde_json::to_value(&account).unwrap();
468        let object = value.as_object().unwrap();
469        assert_eq!(object.len(), 1);
470        assert!(object.contains_key(expected_variant));
471
472        let deserialized: AccountAny = serde_json::from_value(value).unwrap();
473        assert_eq!(deserialized.id(), account.id());
474        assert_eq!(deserialized.events(), account.events());
475        assert_eq!(deserialized.balances(), account.balances());
476    }
477
478    #[rstest]
479    #[case::cash(include_str!("../../test_data/account_legacy_cash.json"), "Cash")]
480    #[case::margin(include_str!("../../test_data/account_legacy_margin.json"), "Margin")]
481    #[case::betting(include_str!("../../test_data/account_legacy_betting.json"), "Betting")]
482    fn test_deserializes_legacy_payload(#[case] json: &str, #[case] expected_variant: &str) {
483        let account: AccountAny = serde_json::from_str(json).unwrap();
484        let variant = match &account {
485            AccountAny::Cash(_) => "Cash",
486            AccountAny::Margin(_) => "Margin",
487            AccountAny::Betting(_) => "Betting",
488            AccountAny::Wallet(_) => "Wallet",
489        };
490        assert_eq!(variant, expected_variant);
491        assert_eq!(account.event_count(), 1);
492    }
493
494    fn invalid_wallet_state() -> AccountState {
495        AccountState::new(
496            AccountId::from("WALLET-001"),
497            crate::enums::AccountType::Wallet,
498            vec![crate::types::AccountBalance::new(
499                crate::types::Money::from("-1 ETH"),
500                crate::types::Money::from("0 ETH"),
501                crate::types::Money::from("-1 ETH"),
502            )],
503            vec![],
504            true,
505            crate::identifiers::stubs::uuid4(),
506            0.into(),
507            0.into(),
508            None,
509        )
510    }
511}