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