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