1use 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]
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 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 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 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 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 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 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 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}