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_from_events_single_margin_event(margin_account_state: AccountState) {
280        let result = AccountAny::from_events(&[margin_account_state]);
281        assert!(result.is_ok());
282        assert!(matches!(result.unwrap(), AccountAny::Margin(_)));
283    }
284
285    #[rstest]
286    #[case::cash(cash_account_state())]
287    #[case::margin(margin_account_state())]
288    #[case::betting(betting_account_state())]
289    #[case::wallet(wallet_account_state())]
290    fn test_clone_without_events_preserves_current_state(#[case] state: AccountState) {
291        let currency = state.balances[0].currency;
292        let instrument_id = InstrumentId::from("CLONE-TEST.SIM");
293        let locked = Money::from_decimal(Decimal::new(725, 2), currency).unwrap();
294        let commission = Money::from_decimal(Decimal::new(135, 2), currency).unwrap();
295        let mut account = AccountAny::try_from_state(state.clone()).unwrap();
296        account.apply(state).unwrap();
297
298        let base = match &mut account {
299            AccountAny::Margin(account) => &mut account.base,
300            AccountAny::Cash(account) => &mut account.base,
301            AccountAny::Betting(account) => &mut account.base,
302            AccountAny::Wallet(account) => &mut account.base,
303        };
304        base.calculate_account_state = true;
305        base.commissions.insert(currency, commission);
306
307        match &mut account {
308            AccountAny::Margin(account) => {
309                account.set_default_leverage(Decimal::new(7, 0));
310                account.set_leverage(instrument_id, Decimal::new(3, 0));
311                account.set_margin_model(MarginModelAny::Standard(StandardMarginModel).into());
312            }
313            AccountAny::Cash(account) => {
314                account.allow_borrowing = true;
315                account
316                    .balances_locked
317                    .insert((instrument_id, currency), locked);
318            }
319            AccountAny::Betting(account) => {
320                account
321                    .balances_locked
322                    .insert((instrument_id, currency), locked);
323            }
324            AccountAny::Wallet(account) => {
325                account
326                    .balances_locked
327                    .insert((instrument_id, currency), locked);
328            }
329        }
330
331        let cloned = account.clone_without_events();
332        let mut expected = account.clone();
333
334        match &mut expected {
335            AccountAny::Margin(account) => account.base.events.clear(),
336            AccountAny::Cash(account) => account.base.events.clear(),
337            AccountAny::Betting(account) => account.base.events.clear(),
338            AccountAny::Wallet(account) => account.base.events.clear(),
339        }
340
341        assert_eq!(account.event_count(), 2);
342        assert_eq!(cloned.event_count(), 0);
343        match (&account, &cloned) {
344            (AccountAny::Margin(source), AccountAny::Margin(cloned)) => {
345                assert_eq!(cloned.margin_model().name(), source.margin_model().name());
346                assert_eq!(cloned.margin_model().name(), "standard");
347            }
348            (AccountAny::Cash(source), AccountAny::Cash(cloned)) => {
349                assert_eq!(cloned.balances_locked, source.balances_locked);
350                assert!(cloned.allow_borrowing);
351            }
352            (AccountAny::Betting(source), AccountAny::Betting(cloned)) => {
353                assert_eq!(cloned.balances_locked, source.balances_locked);
354            }
355            (AccountAny::Wallet(source), AccountAny::Wallet(cloned)) => {
356                assert_eq!(cloned.balances_locked, source.balances_locked);
357            }
358            _ => panic!("cloned account variant changed"),
359        }
360        assert_eq!(
361            serde_json::to_value(&cloned).unwrap(),
362            serde_json::to_value(&expected).unwrap()
363        );
364    }
365
366    #[rstest]
367    fn test_try_from_state_cash(cash_account_state: AccountState) {
368        let result: Result<AccountAny, &'static str> =
369            AccountAny::try_from_state(cash_account_state);
370        assert!(result.is_ok());
371        assert!(matches!(result.unwrap(), AccountAny::Cash(_)));
372    }
373
374    #[rstest]
375    fn test_try_from_state_margin(margin_account_state: AccountState) {
376        let result = AccountAny::try_from_state(margin_account_state);
377        assert!(result.is_ok());
378        assert!(matches!(result.unwrap(), AccountAny::Margin(_)));
379    }
380
381    #[rstest]
382    fn test_try_from_state_betting(betting_account_state: AccountState) {
383        let result = AccountAny::try_from_state(betting_account_state);
384        assert!(result.is_ok());
385        assert!(matches!(result.unwrap(), AccountAny::Betting(_)));
386    }
387
388    #[rstest]
389    fn test_try_from_state_wallet(wallet_account_state: AccountState) {
390        let result = AccountAny::try_from_state(wallet_account_state);
391        assert!(result.is_ok());
392        assert!(matches!(result.unwrap(), AccountAny::Wallet(_)));
393    }
394
395    #[rstest]
396    fn test_try_from_state_invalid_wallet_returns_static_error() {
397        let result: Result<AccountAny, &'static str> =
398            AccountAny::try_from_state(invalid_wallet_state());
399
400        assert_eq!(result.unwrap_err(), "Invalid wallet account state");
401    }
402
403    #[rstest]
404    fn test_from_events_wallet_applies_sequence(
405        wallet_account_state: AccountState,
406        wallet_account_state_changed: AccountState,
407    ) {
408        let result = AccountAny::from_events(&[wallet_account_state, wallet_account_state_changed]);
409        assert!(result.is_ok());
410        let account = result.unwrap();
411        assert!(matches!(account, AccountAny::Wallet(_)));
412        assert_eq!(account.event_count(), 2);
413    }
414
415    #[rstest]
416    fn test_from_events_wallet_rejects_negative_initial_balance() {
417        let result = AccountAny::from_events(&[invalid_wallet_state()]);
418
419        assert!(result.is_err());
420        assert_eq!(
421            result.unwrap_err().to_string(),
422            "Wallet account balance total was negative"
423        );
424    }
425
426    #[rstest]
427    #[case::cash(cash_account_state(), "Cash")]
428    #[case::margin(margin_account_state(), "Margin")]
429    #[case::betting(betting_account_state(), "Betting")]
430    #[case::wallet(wallet_account_state(), "Wallet")]
431    fn test_serde_round_trip_preserves_variant_payload(
432        #[case] state: AccountState,
433        #[case] expected_variant: &str,
434    ) {
435        let account = AccountAny::try_from_state(state).unwrap();
436
437        let value = serde_json::to_value(&account).unwrap();
438        let object = value.as_object().unwrap();
439        assert_eq!(object.len(), 1);
440        assert!(object.contains_key(expected_variant));
441
442        let deserialized: AccountAny = serde_json::from_value(value).unwrap();
443        assert_eq!(deserialized.id(), account.id());
444        assert_eq!(deserialized.events(), account.events());
445        assert_eq!(deserialized.balances(), account.balances());
446    }
447
448    #[rstest]
449    #[case::cash(include_str!("../../test_data/account_legacy_cash.json"), "Cash")]
450    #[case::margin(include_str!("../../test_data/account_legacy_margin.json"), "Margin")]
451    #[case::betting(include_str!("../../test_data/account_legacy_betting.json"), "Betting")]
452    fn test_deserializes_legacy_payload(#[case] json: &str, #[case] expected_variant: &str) {
453        let account: AccountAny = serde_json::from_str(json).unwrap();
454        let variant = match &account {
455            AccountAny::Cash(_) => "Cash",
456            AccountAny::Margin(_) => "Margin",
457            AccountAny::Betting(_) => "Betting",
458            AccountAny::Wallet(_) => "Wallet",
459        };
460        assert_eq!(variant, expected_variant);
461        assert_eq!(account.event_count(), 1);
462    }
463
464    fn invalid_wallet_state() -> AccountState {
465        AccountState::new(
466            AccountId::from("WALLET-001"),
467            crate::enums::AccountType::Wallet,
468            vec![crate::types::AccountBalance::new(
469                crate::types::Money::from("-1 ETH"),
470                crate::types::Money::from("0 ETH"),
471                crate::types::Money::from("-1 ETH"),
472            )],
473            vec![],
474            true,
475            crate::identifiers::stubs::uuid4(),
476            0.into(),
477            0.into(),
478            None,
479        )
480    }
481}