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