1use std::{
19 fmt::Display,
20 ops::{Deref, DerefMut},
21};
22
23use ahash::AHashMap;
24use rust_decimal::{Decimal, prelude::ToPrimitive};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28 accounts::{Account, base::BaseAccount},
29 enums::{AccountType, LiquiditySide, OrderSide},
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#[cfg_attr(
39 feature = "python",
40 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
41)]
42pub struct CashAccount {
43 pub base: BaseAccount,
44 pub allow_borrowing: bool,
45}
46
47impl CashAccount {
48 pub fn new(event: AccountState, calculate_account_state: bool, allow_borrowing: bool) -> Self {
50 Self {
51 base: BaseAccount::new(event, calculate_account_state),
52 allow_borrowing,
53 }
54 }
55
56 #[must_use]
57 pub fn is_cash_account(&self) -> bool {
58 self.account_type == AccountType::Cash
59 }
60 #[must_use]
61 pub fn is_margin_account(&self) -> bool {
62 self.account_type == AccountType::Margin
63 }
64
65 #[must_use]
66 pub const fn is_unleveraged(&self) -> bool {
67 true
68 }
69
70 pub fn recalculate_balance(&mut self, currency: Currency) {
76 let current_balance = match self.balances.get(¤cy) {
77 Some(balance) => *balance,
78 None => {
79 return;
80 }
81 };
82
83 let total_locked = self
84 .balances
85 .values()
86 .filter(|balance| balance.currency == currency)
87 .fold(Decimal::ZERO, |acc, balance| {
88 acc + balance.locked.as_decimal()
89 });
90
91 let new_balance = AccountBalance::new(
92 current_balance.total,
93 Money::new(total_locked.to_f64().unwrap(), currency),
94 Money::new(
95 (current_balance.total.as_decimal() - total_locked)
96 .to_f64()
97 .unwrap(),
98 currency,
99 ),
100 );
101
102 self.balances.insert(currency, new_balance);
103 }
104}
105
106impl Account for CashAccount {
107 fn id(&self) -> AccountId {
108 self.id
109 }
110
111 fn account_type(&self) -> AccountType {
112 self.account_type
113 }
114
115 fn base_currency(&self) -> Option<Currency> {
116 self.base_currency
117 }
118
119 fn is_cash_account(&self) -> bool {
120 self.account_type == AccountType::Cash
121 }
122
123 fn is_margin_account(&self) -> bool {
124 self.account_type == AccountType::Margin
125 }
126
127 fn calculated_account_state(&self) -> bool {
128 false }
130
131 fn balance_total(&self, currency: Option<Currency>) -> Option<Money> {
132 self.base_balance_total(currency)
133 }
134
135 fn balances_total(&self) -> AHashMap<Currency, Money> {
136 self.base_balances_total()
137 }
138
139 fn balance_free(&self, currency: Option<Currency>) -> Option<Money> {
140 self.base_balance_free(currency)
141 }
142
143 fn balances_free(&self) -> AHashMap<Currency, Money> {
144 self.base_balances_free()
145 }
146
147 fn balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
148 self.base_balance_locked(currency)
149 }
150
151 fn balances_locked(&self) -> AHashMap<Currency, Money> {
152 self.base_balances_locked()
153 }
154
155 fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
156 self.base_balance(currency)
157 }
158
159 fn last_event(&self) -> Option<AccountState> {
160 self.base_last_event()
161 }
162
163 fn events(&self) -> Vec<AccountState> {
164 self.events.clone()
165 }
166
167 fn event_count(&self) -> usize {
168 self.events.len()
169 }
170
171 fn currencies(&self) -> Vec<Currency> {
172 self.balances.keys().copied().collect()
173 }
174
175 fn starting_balances(&self) -> AHashMap<Currency, Money> {
176 self.balances_starting.clone()
177 }
178
179 fn balances(&self) -> AHashMap<Currency, AccountBalance> {
180 self.balances.clone()
181 }
182
183 fn apply(&mut self, event: AccountState) {
184 if !self.allow_borrowing {
186 for balance in &event.balances {
187 assert!(
188 balance.total.as_decimal() >= rust_decimal::Decimal::ZERO,
189 "Account balance negative: {} {}",
190 balance.total.as_decimal(),
191 balance.currency.code
192 );
193 }
194 }
195 self.base_apply(event);
196 }
197
198 fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
199 self.base.base_purge_account_events(ts_now, lookback_secs);
200 }
201
202 fn calculate_balance_locked(
203 &mut self,
204 instrument: InstrumentAny,
205 side: OrderSide,
206 quantity: Quantity,
207 price: Price,
208 use_quote_for_inverse: Option<bool>,
209 ) -> anyhow::Result<Money> {
210 self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
211 }
212
213 fn calculate_pnls(
214 &self,
215 instrument: InstrumentAny, fill: OrderFilled, position: Option<Position>,
218 ) -> anyhow::Result<Vec<Money>> {
219 self.base_calculate_pnls(instrument, fill, position)
220 }
221
222 fn calculate_commission(
223 &self,
224 instrument: InstrumentAny,
225 last_qty: Quantity,
226 last_px: Price,
227 liquidity_side: LiquiditySide,
228 use_quote_for_inverse: Option<bool>,
229 ) -> anyhow::Result<Money> {
230 self.base_calculate_commission(
231 instrument,
232 last_qty,
233 last_px,
234 liquidity_side,
235 use_quote_for_inverse,
236 )
237 }
238}
239
240impl Deref for CashAccount {
241 type Target = BaseAccount;
242
243 fn deref(&self) -> &Self::Target {
244 &self.base
245 }
246}
247
248impl DerefMut for CashAccount {
249 fn deref_mut(&mut self) -> &mut Self::Target {
250 &mut self.base
251 }
252}
253
254impl PartialEq for CashAccount {
255 fn eq(&self, other: &Self) -> bool {
256 self.id == other.id
257 }
258}
259
260impl Eq for CashAccount {}
261
262impl Display for CashAccount {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 write!(
265 f,
266 "CashAccount(id={}, type={}, base={})",
267 self.id,
268 self.account_type,
269 self.base_currency.map_or_else(
270 || "None".to_string(),
271 |base_currency| format!("{}", base_currency.code)
272 ),
273 )
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use ahash::{AHashMap, AHashSet};
280 use rstest::rstest;
281
282 use crate::{
283 accounts::{Account, CashAccount, stubs::*},
284 enums::{AccountType, LiquiditySide, OrderSide, OrderType},
285 events::{AccountState, account::stubs::*},
286 identifiers::{AccountId, position_id::PositionId},
287 instruments::{CryptoPerpetual, CurrencyPair, Equity, Instrument, InstrumentAny, stubs::*},
288 orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
289 position::Position,
290 types::{Currency, Money, Price, Quantity},
291 };
292
293 #[rstest]
294 fn test_display(cash_account: CashAccount) {
295 assert_eq!(
296 format!("{cash_account}"),
297 "CashAccount(id=SIM-001, type=CASH, base=USD)"
298 );
299 }
300
301 #[rstest]
302 fn test_instantiate_single_asset_cash_account(
303 cash_account: CashAccount,
304 cash_account_state: AccountState,
305 ) {
306 assert_eq!(cash_account.id, AccountId::from("SIM-001"));
307 assert_eq!(cash_account.account_type, AccountType::Cash);
308 assert_eq!(cash_account.base_currency, Some(Currency::from("USD")));
309 assert_eq!(cash_account.last_event(), Some(cash_account_state.clone()));
310 assert_eq!(cash_account.events(), vec![cash_account_state]);
311 assert_eq!(cash_account.event_count(), 1);
312 assert_eq!(
313 cash_account.balance_total(None),
314 Some(Money::from("1525000 USD"))
315 );
316 assert_eq!(
317 cash_account.balance_free(None),
318 Some(Money::from("1500000 USD"))
319 );
320 assert_eq!(
321 cash_account.balance_locked(None),
322 Some(Money::from("25000 USD"))
323 );
324 let mut balances_total_expected = AHashMap::new();
325 balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
326 assert_eq!(cash_account.balances_total(), balances_total_expected);
327 let mut balances_free_expected = AHashMap::new();
328 balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
329 assert_eq!(cash_account.balances_free(), balances_free_expected);
330 let mut balances_locked_expected = AHashMap::new();
331 balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
332 assert_eq!(cash_account.balances_locked(), balances_locked_expected);
333 }
334
335 #[rstest]
336 fn test_instantiate_multi_asset_cash_account(
337 cash_account_multi: CashAccount,
338 cash_account_state_multi: AccountState,
339 ) {
340 assert_eq!(cash_account_multi.id, AccountId::from("SIM-001"));
341 assert_eq!(cash_account_multi.account_type, AccountType::Cash);
342 assert_eq!(
343 cash_account_multi.last_event(),
344 Some(cash_account_state_multi.clone())
345 );
346 assert_eq!(cash_account_state_multi.base_currency, None);
347 assert_eq!(cash_account_multi.events(), vec![cash_account_state_multi]);
348 assert_eq!(cash_account_multi.event_count(), 1);
349 assert_eq!(
350 cash_account_multi.balance_total(Some(Currency::BTC())),
351 Some(Money::from("10 BTC"))
352 );
353 assert_eq!(
354 cash_account_multi.balance_total(Some(Currency::ETH())),
355 Some(Money::from("20 ETH"))
356 );
357 assert_eq!(
358 cash_account_multi.balance_free(Some(Currency::BTC())),
359 Some(Money::from("10 BTC"))
360 );
361 assert_eq!(
362 cash_account_multi.balance_free(Some(Currency::ETH())),
363 Some(Money::from("20 ETH"))
364 );
365 assert_eq!(
366 cash_account_multi.balance_locked(Some(Currency::BTC())),
367 Some(Money::from("0 BTC"))
368 );
369 assert_eq!(
370 cash_account_multi.balance_locked(Some(Currency::ETH())),
371 Some(Money::from("0 ETH"))
372 );
373 let mut balances_total_expected = AHashMap::new();
374 balances_total_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
375 balances_total_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
376 assert_eq!(cash_account_multi.balances_total(), balances_total_expected);
377 let mut balances_free_expected = AHashMap::new();
378 balances_free_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
379 balances_free_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
380 assert_eq!(cash_account_multi.balances_free(), balances_free_expected);
381 let mut balances_locked_expected = AHashMap::new();
382 balances_locked_expected.insert(Currency::from("BTC"), Money::from("0 BTC"));
383 balances_locked_expected.insert(Currency::from("ETH"), Money::from("0 ETH"));
384 assert_eq!(
385 cash_account_multi.balances_locked(),
386 balances_locked_expected
387 );
388 }
389
390 #[rstest]
391 fn test_apply_given_new_state_event_updates_correctly(
392 mut cash_account_multi: CashAccount,
393 cash_account_state_multi: AccountState,
394 cash_account_state_multi_changed_btc: AccountState,
395 ) {
396 cash_account_multi.apply(cash_account_state_multi_changed_btc.clone());
398 assert_eq!(
399 cash_account_multi.last_event(),
400 Some(cash_account_state_multi_changed_btc.clone())
401 );
402 assert_eq!(
403 cash_account_multi.events,
404 vec![
405 cash_account_state_multi,
406 cash_account_state_multi_changed_btc
407 ]
408 );
409 assert_eq!(cash_account_multi.event_count(), 2);
410 assert_eq!(
411 cash_account_multi.balance_total(Some(Currency::BTC())),
412 Some(Money::from("9 BTC"))
413 );
414 assert_eq!(
415 cash_account_multi.balance_free(Some(Currency::BTC())),
416 Some(Money::from("8.5 BTC"))
417 );
418 assert_eq!(
419 cash_account_multi.balance_locked(Some(Currency::BTC())),
420 Some(Money::from("0.5 BTC"))
421 );
422 assert_eq!(
423 cash_account_multi.balance_total(Some(Currency::ETH())),
424 Some(Money::from("20 ETH"))
425 );
426 assert_eq!(
427 cash_account_multi.balance_free(Some(Currency::ETH())),
428 Some(Money::from("20 ETH"))
429 );
430 assert_eq!(
431 cash_account_multi.balance_locked(Some(Currency::ETH())),
432 Some(Money::from("0 ETH"))
433 );
434 }
435
436 #[rstest]
437 fn test_calculate_balance_locked_buy(
438 mut cash_account_million_usd: CashAccount,
439 audusd_sim: CurrencyPair,
440 ) {
441 let balance_locked = cash_account_million_usd
442 .calculate_balance_locked(
443 audusd_sim.into_any(),
444 OrderSide::Buy,
445 Quantity::from("1000000"),
446 Price::from("0.8"),
447 None,
448 )
449 .unwrap();
450 assert_eq!(balance_locked, Money::from("800000 USD"));
451 }
452
453 #[rstest]
454 fn test_calculate_balance_locked_sell(
455 mut cash_account_million_usd: CashAccount,
456 audusd_sim: CurrencyPair,
457 ) {
458 let balance_locked = cash_account_million_usd
459 .calculate_balance_locked(
460 audusd_sim.into_any(),
461 OrderSide::Sell,
462 Quantity::from("1000000"),
463 Price::from("0.8"),
464 None,
465 )
466 .unwrap();
467 assert_eq!(balance_locked, Money::from("1000000 AUD"));
468 }
469
470 #[rstest]
471 fn test_calculate_balance_locked_sell_no_base_currency(
472 mut cash_account_million_usd: CashAccount,
473 equity_aapl: Equity,
474 ) {
475 let balance_locked = cash_account_million_usd
476 .calculate_balance_locked(
477 equity_aapl.into_any(),
478 OrderSide::Sell,
479 Quantity::from("100"),
480 Price::from("1500.0"),
481 None,
482 )
483 .unwrap();
484 assert_eq!(balance_locked, Money::from("100 USD"));
485 }
486
487 #[rstest]
488 fn test_calculate_pnls_for_single_currency_cash_account(
489 cash_account_million_usd: CashAccount,
490 audusd_sim: CurrencyPair,
491 ) {
492 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
493 let order = OrderTestBuilder::new(OrderType::Market)
494 .instrument_id(audusd_sim.id())
495 .side(OrderSide::Buy)
496 .quantity(Quantity::from("1000000"))
497 .build();
498 let fill = TestOrderEventStubs::filled(
499 &order,
500 &audusd_sim,
501 None,
502 Some(PositionId::new("P-123456")),
503 Some(Price::from("0.8")),
504 None,
505 None,
506 None,
507 None,
508 Some(AccountId::from("SIM-001")),
509 );
510 let position = Position::new(&audusd_sim, fill.clone().into());
511 let pnls = cash_account_million_usd
512 .calculate_pnls(audusd_sim, fill.into(), Some(position)) .unwrap();
514 assert_eq!(pnls, vec![Money::from("-800000 USD")]);
515 }
516
517 #[rstest]
518 fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
519 cash_account_multi: CashAccount,
520 currency_pair_btcusdt: CurrencyPair,
521 ) {
522 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
523 let order1 = OrderTestBuilder::new(OrderType::Market)
524 .instrument_id(currency_pair_btcusdt.id)
525 .side(OrderSide::Sell)
526 .quantity(Quantity::from("0.5"))
527 .build();
528 let fill1 = TestOrderEventStubs::filled(
529 &order1,
530 &btcusdt,
531 None,
532 Some(PositionId::new("P-123456")),
533 Some(Price::from("45500.00")),
534 None,
535 None,
536 None,
537 None,
538 Some(AccountId::from("SIM-001")),
539 );
540 let position = Position::new(&btcusdt, fill1.clone().into());
541 let result1 = cash_account_multi
542 .calculate_pnls(
543 currency_pair_btcusdt.into_any(),
544 fill1.into(), Some(position.clone()),
546 )
547 .unwrap();
548 let order2 = OrderTestBuilder::new(OrderType::Market)
549 .instrument_id(currency_pair_btcusdt.id)
550 .side(OrderSide::Buy)
551 .quantity(Quantity::from("0.5"))
552 .build();
553 let fill2 = TestOrderEventStubs::filled(
554 &order2,
555 &btcusdt,
556 None,
557 Some(PositionId::new("P-123456")),
558 Some(Price::from("45500.00")),
559 None,
560 None,
561 None,
562 None,
563 Some(AccountId::from("SIM-001")),
564 );
565 let result2 = cash_account_multi
566 .calculate_pnls(
567 currency_pair_btcusdt.into_any(),
568 fill2.into(),
569 Some(position),
570 )
571 .unwrap();
572 let result1_set: AHashSet<Money> = result1.into_iter().collect();
574 let result1_expected: AHashSet<Money> =
575 vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
576 .into_iter()
577 .collect();
578 let result2_set: AHashSet<Money> = result2.into_iter().collect();
579 let result2_expected: AHashSet<Money> =
580 vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
581 .into_iter()
582 .collect();
583 assert_eq!(result1_set, result1_expected);
584 assert_eq!(result2_set, result2_expected);
585 }
586
587 #[rstest]
588 #[case(false, Money::from("-0.00218331 BTC"))]
589 #[case(true, Money::from("-25.0 USD"))]
590 fn test_calculate_commission_for_inverse_maker_crypto(
591 #[case] use_quote_for_inverse: bool,
592 #[case] expected: Money,
593 cash_account_million_usd: CashAccount,
594 xbtusd_bitmex: CryptoPerpetual,
595 ) {
596 let result = cash_account_million_usd
597 .calculate_commission(
598 xbtusd_bitmex.into_any(),
599 Quantity::from("100000"),
600 Price::from("11450.50"),
601 LiquiditySide::Maker,
602 Some(use_quote_for_inverse),
603 )
604 .unwrap();
605 assert_eq!(result, expected);
606 }
607
608 #[rstest]
609 fn test_calculate_commission_for_taker_fx(
610 cash_account_million_usd: CashAccount,
611 audusd_sim: CurrencyPair,
612 ) {
613 let result = cash_account_million_usd
614 .calculate_commission(
615 audusd_sim.into_any(),
616 Quantity::from("1500000"),
617 Price::from("0.8005"),
618 LiquiditySide::Taker,
619 None,
620 )
621 .unwrap();
622 assert_eq!(result, Money::from("24.02 USD"));
623 }
624
625 #[rstest]
626 fn test_calculate_commission_crypto_taker(
627 cash_account_million_usd: CashAccount,
628 xbtusd_bitmex: CryptoPerpetual,
629 ) {
630 let result = cash_account_million_usd
631 .calculate_commission(
632 xbtusd_bitmex.into_any(),
633 Quantity::from("100000"),
634 Price::from("11450.50"),
635 LiquiditySide::Taker,
636 None,
637 )
638 .unwrap();
639 assert_eq!(result, Money::from("0.00654993 BTC"));
640 }
641
642 #[rstest]
643 fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
644 let instrument = usdjpy_idealpro();
645 let result = cash_account_million_usd
646 .calculate_commission(
647 instrument.into_any(),
648 Quantity::from("2200000"),
649 Price::from("120.310"),
650 LiquiditySide::Taker,
651 None,
652 )
653 .unwrap();
654 assert_eq!(result, Money::from("5294 JPY"));
655 }
656}