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