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