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 if balance.total.as_decimal() < rust_decimal::Decimal::ZERO {
188 panic!(
189 "Account balance negative: {} {}",
190 balance.total.as_decimal(),
191 balance.currency.code
192 );
193 }
194 }
195 }
196 self.base_apply(event);
197 }
198
199 fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
200 self.base.base_purge_account_events(ts_now, lookback_secs);
201 }
202
203 fn calculate_balance_locked(
204 &mut self,
205 instrument: InstrumentAny,
206 side: OrderSide,
207 quantity: Quantity,
208 price: Price,
209 use_quote_for_inverse: Option<bool>,
210 ) -> anyhow::Result<Money> {
211 self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
212 }
213
214 fn calculate_pnls(
215 &self,
216 instrument: InstrumentAny, fill: OrderFilled, position: Option<Position>,
219 ) -> anyhow::Result<Vec<Money>> {
220 self.base_calculate_pnls(instrument, fill, position)
221 }
222
223 fn calculate_commission(
224 &self,
225 instrument: InstrumentAny,
226 last_qty: Quantity,
227 last_px: Price,
228 liquidity_side: LiquiditySide,
229 use_quote_for_inverse: Option<bool>,
230 ) -> anyhow::Result<Money> {
231 self.base_calculate_commission(
232 instrument,
233 last_qty,
234 last_px,
235 liquidity_side,
236 use_quote_for_inverse,
237 )
238 }
239}
240
241impl Deref for CashAccount {
242 type Target = BaseAccount;
243
244 fn deref(&self) -> &Self::Target {
245 &self.base
246 }
247}
248
249impl DerefMut for CashAccount {
250 fn deref_mut(&mut self) -> &mut Self::Target {
251 &mut self.base
252 }
253}
254
255impl PartialEq for CashAccount {
256 fn eq(&self, other: &Self) -> bool {
257 self.id == other.id
258 }
259}
260
261impl Eq for CashAccount {}
262
263impl Display for CashAccount {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 write!(
266 f,
267 "CashAccount(id={}, type={}, base={})",
268 self.id,
269 self.account_type,
270 self.base_currency.map_or_else(
271 || "None".to_string(),
272 |base_currency| format!("{}", base_currency.code)
273 ),
274 )
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use ahash::{AHashMap, AHashSet};
281 use rstest::rstest;
282
283 use crate::{
284 accounts::{Account, CashAccount, stubs::*},
285 enums::{AccountType, LiquiditySide, OrderSide, OrderType},
286 events::{AccountState, account::stubs::*},
287 identifiers::{AccountId, position_id::PositionId},
288 instruments::{CryptoPerpetual, CurrencyPair, Equity, Instrument, InstrumentAny, stubs::*},
289 orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
290 position::Position,
291 types::{Currency, Money, Price, Quantity},
292 };
293
294 #[rstest]
295 fn test_display(cash_account: CashAccount) {
296 assert_eq!(
297 format!("{cash_account}"),
298 "CashAccount(id=SIM-001, type=CASH, base=USD)"
299 );
300 }
301
302 #[rstest]
303 fn test_instantiate_single_asset_cash_account(
304 cash_account: CashAccount,
305 cash_account_state: AccountState,
306 ) {
307 assert_eq!(cash_account.id, AccountId::from("SIM-001"));
308 assert_eq!(cash_account.account_type, AccountType::Cash);
309 assert_eq!(cash_account.base_currency, Some(Currency::from("USD")));
310 assert_eq!(cash_account.last_event(), Some(cash_account_state.clone()));
311 assert_eq!(cash_account.events(), vec![cash_account_state]);
312 assert_eq!(cash_account.event_count(), 1);
313 assert_eq!(
314 cash_account.balance_total(None),
315 Some(Money::from("1525000 USD"))
316 );
317 assert_eq!(
318 cash_account.balance_free(None),
319 Some(Money::from("1500000 USD"))
320 );
321 assert_eq!(
322 cash_account.balance_locked(None),
323 Some(Money::from("25000 USD"))
324 );
325 let mut balances_total_expected = AHashMap::new();
326 balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
327 assert_eq!(cash_account.balances_total(), balances_total_expected);
328 let mut balances_free_expected = AHashMap::new();
329 balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
330 assert_eq!(cash_account.balances_free(), balances_free_expected);
331 let mut balances_locked_expected = AHashMap::new();
332 balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
333 assert_eq!(cash_account.balances_locked(), balances_locked_expected);
334 }
335
336 #[rstest]
337 fn test_instantiate_multi_asset_cash_account(
338 cash_account_multi: CashAccount,
339 cash_account_state_multi: AccountState,
340 ) {
341 assert_eq!(cash_account_multi.id, AccountId::from("SIM-001"));
342 assert_eq!(cash_account_multi.account_type, AccountType::Cash);
343 assert_eq!(
344 cash_account_multi.last_event(),
345 Some(cash_account_state_multi.clone())
346 );
347 assert_eq!(cash_account_state_multi.base_currency, None);
348 assert_eq!(cash_account_multi.events(), vec![cash_account_state_multi]);
349 assert_eq!(cash_account_multi.event_count(), 1);
350 assert_eq!(
351 cash_account_multi.balance_total(Some(Currency::BTC())),
352 Some(Money::from("10 BTC"))
353 );
354 assert_eq!(
355 cash_account_multi.balance_total(Some(Currency::ETH())),
356 Some(Money::from("20 ETH"))
357 );
358 assert_eq!(
359 cash_account_multi.balance_free(Some(Currency::BTC())),
360 Some(Money::from("10 BTC"))
361 );
362 assert_eq!(
363 cash_account_multi.balance_free(Some(Currency::ETH())),
364 Some(Money::from("20 ETH"))
365 );
366 assert_eq!(
367 cash_account_multi.balance_locked(Some(Currency::BTC())),
368 Some(Money::from("0 BTC"))
369 );
370 assert_eq!(
371 cash_account_multi.balance_locked(Some(Currency::ETH())),
372 Some(Money::from("0 ETH"))
373 );
374 let mut balances_total_expected = AHashMap::new();
375 balances_total_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
376 balances_total_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
377 assert_eq!(cash_account_multi.balances_total(), balances_total_expected);
378 let mut balances_free_expected = AHashMap::new();
379 balances_free_expected.insert(Currency::from("BTC"), Money::from("10 BTC"));
380 balances_free_expected.insert(Currency::from("ETH"), Money::from("20 ETH"));
381 assert_eq!(cash_account_multi.balances_free(), balances_free_expected);
382 let mut balances_locked_expected = AHashMap::new();
383 balances_locked_expected.insert(Currency::from("BTC"), Money::from("0 BTC"));
384 balances_locked_expected.insert(Currency::from("ETH"), Money::from("0 ETH"));
385 assert_eq!(
386 cash_account_multi.balances_locked(),
387 balances_locked_expected
388 );
389 }
390
391 #[rstest]
392 fn test_apply_given_new_state_event_updates_correctly(
393 mut cash_account_multi: CashAccount,
394 cash_account_state_multi: AccountState,
395 cash_account_state_multi_changed_btc: AccountState,
396 ) {
397 cash_account_multi.apply(cash_account_state_multi_changed_btc.clone());
399 assert_eq!(
400 cash_account_multi.last_event(),
401 Some(cash_account_state_multi_changed_btc.clone())
402 );
403 assert_eq!(
404 cash_account_multi.events,
405 vec![
406 cash_account_state_multi,
407 cash_account_state_multi_changed_btc
408 ]
409 );
410 assert_eq!(cash_account_multi.event_count(), 2);
411 assert_eq!(
412 cash_account_multi.balance_total(Some(Currency::BTC())),
413 Some(Money::from("9 BTC"))
414 );
415 assert_eq!(
416 cash_account_multi.balance_free(Some(Currency::BTC())),
417 Some(Money::from("8.5 BTC"))
418 );
419 assert_eq!(
420 cash_account_multi.balance_locked(Some(Currency::BTC())),
421 Some(Money::from("0.5 BTC"))
422 );
423 assert_eq!(
424 cash_account_multi.balance_total(Some(Currency::ETH())),
425 Some(Money::from("20 ETH"))
426 );
427 assert_eq!(
428 cash_account_multi.balance_free(Some(Currency::ETH())),
429 Some(Money::from("20 ETH"))
430 );
431 assert_eq!(
432 cash_account_multi.balance_locked(Some(Currency::ETH())),
433 Some(Money::from("0 ETH"))
434 );
435 }
436
437 #[rstest]
438 fn test_calculate_balance_locked_buy(
439 mut cash_account_million_usd: CashAccount,
440 audusd_sim: CurrencyPair,
441 ) {
442 let balance_locked = cash_account_million_usd
443 .calculate_balance_locked(
444 audusd_sim.into_any(),
445 OrderSide::Buy,
446 Quantity::from("1000000"),
447 Price::from("0.8"),
448 None,
449 )
450 .unwrap();
451 assert_eq!(balance_locked, Money::from("800000 USD"));
452 }
453
454 #[rstest]
455 fn test_calculate_balance_locked_sell(
456 mut cash_account_million_usd: CashAccount,
457 audusd_sim: CurrencyPair,
458 ) {
459 let balance_locked = cash_account_million_usd
460 .calculate_balance_locked(
461 audusd_sim.into_any(),
462 OrderSide::Sell,
463 Quantity::from("1000000"),
464 Price::from("0.8"),
465 None,
466 )
467 .unwrap();
468 assert_eq!(balance_locked, Money::from("1000000 AUD"));
469 }
470
471 #[rstest]
472 fn test_calculate_balance_locked_sell_no_base_currency(
473 mut cash_account_million_usd: CashAccount,
474 equity_aapl: Equity,
475 ) {
476 let balance_locked = cash_account_million_usd
477 .calculate_balance_locked(
478 equity_aapl.into_any(),
479 OrderSide::Sell,
480 Quantity::from("100"),
481 Price::from("1500.0"),
482 None,
483 )
484 .unwrap();
485 assert_eq!(balance_locked, Money::from("100 USD"));
486 }
487
488 #[rstest]
489 fn test_calculate_pnls_for_single_currency_cash_account(
490 cash_account_million_usd: CashAccount,
491 audusd_sim: CurrencyPair,
492 ) {
493 let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim);
494 let order = OrderTestBuilder::new(OrderType::Market)
495 .instrument_id(audusd_sim.id())
496 .side(OrderSide::Buy)
497 .quantity(Quantity::from("1000000"))
498 .build();
499 let fill = TestOrderEventStubs::filled(
500 &order,
501 &audusd_sim,
502 None,
503 Some(PositionId::new("P-123456")),
504 Some(Price::from("0.8")),
505 None,
506 None,
507 None,
508 None,
509 Some(AccountId::from("SIM-001")),
510 );
511 let position = Position::new(&audusd_sim, fill.clone().into());
512 let pnls = cash_account_million_usd
513 .calculate_pnls(audusd_sim, fill.into(), Some(position)) .unwrap();
515 assert_eq!(pnls, vec![Money::from("-800000 USD")]);
516 }
517
518 #[rstest]
519 fn test_calculate_pnls_for_multi_currency_cash_account_btcusdt(
520 cash_account_multi: CashAccount,
521 currency_pair_btcusdt: CurrencyPair,
522 ) {
523 let btcusdt = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
524 let order1 = OrderTestBuilder::new(OrderType::Market)
525 .instrument_id(currency_pair_btcusdt.id)
526 .side(OrderSide::Sell)
527 .quantity(Quantity::from("0.5"))
528 .build();
529 let fill1 = TestOrderEventStubs::filled(
530 &order1,
531 &btcusdt,
532 None,
533 Some(PositionId::new("P-123456")),
534 Some(Price::from("45500.00")),
535 None,
536 None,
537 None,
538 None,
539 Some(AccountId::from("SIM-001")),
540 );
541 let position = Position::new(&btcusdt, fill1.clone().into());
542 let result1 = cash_account_multi
543 .calculate_pnls(
544 currency_pair_btcusdt.into_any(),
545 fill1.into(), Some(position.clone()),
547 )
548 .unwrap();
549 let order2 = OrderTestBuilder::new(OrderType::Market)
550 .instrument_id(currency_pair_btcusdt.id)
551 .side(OrderSide::Buy)
552 .quantity(Quantity::from("0.5"))
553 .build();
554 let fill2 = TestOrderEventStubs::filled(
555 &order2,
556 &btcusdt,
557 None,
558 Some(PositionId::new("P-123456")),
559 Some(Price::from("45500.00")),
560 None,
561 None,
562 None,
563 None,
564 Some(AccountId::from("SIM-001")),
565 );
566 let result2 = cash_account_multi
567 .calculate_pnls(
568 currency_pair_btcusdt.into_any(),
569 fill2.into(),
570 Some(position),
571 )
572 .unwrap();
573 let result1_set: AHashSet<Money> = result1.into_iter().collect();
575 let result1_expected: AHashSet<Money> =
576 vec![Money::from("22750 USDT"), Money::from("-0.5 BTC")]
577 .into_iter()
578 .collect();
579 let result2_set: AHashSet<Money> = result2.into_iter().collect();
580 let result2_expected: AHashSet<Money> =
581 vec![Money::from("-22750 USDT"), Money::from("0.5 BTC")]
582 .into_iter()
583 .collect();
584 assert_eq!(result1_set, result1_expected);
585 assert_eq!(result2_set, result2_expected);
586 }
587
588 #[rstest]
589 #[case(false, Money::from("-0.00218331 BTC"))]
590 #[case(true, Money::from("-25.0 USD"))]
591 fn test_calculate_commission_for_inverse_maker_crypto(
592 #[case] use_quote_for_inverse: bool,
593 #[case] expected: Money,
594 cash_account_million_usd: CashAccount,
595 xbtusd_bitmex: CryptoPerpetual,
596 ) {
597 let result = cash_account_million_usd
598 .calculate_commission(
599 xbtusd_bitmex.into_any(),
600 Quantity::from("100000"),
601 Price::from("11450.50"),
602 LiquiditySide::Maker,
603 Some(use_quote_for_inverse),
604 )
605 .unwrap();
606 assert_eq!(result, expected);
607 }
608
609 #[rstest]
610 fn test_calculate_commission_for_taker_fx(
611 cash_account_million_usd: CashAccount,
612 audusd_sim: CurrencyPair,
613 ) {
614 let result = cash_account_million_usd
615 .calculate_commission(
616 audusd_sim.into_any(),
617 Quantity::from("1500000"),
618 Price::from("0.8005"),
619 LiquiditySide::Taker,
620 None,
621 )
622 .unwrap();
623 assert_eq!(result, Money::from("24.02 USD"));
624 }
625
626 #[rstest]
627 fn test_calculate_commission_crypto_taker(
628 cash_account_million_usd: CashAccount,
629 xbtusd_bitmex: CryptoPerpetual,
630 ) {
631 let result = cash_account_million_usd
632 .calculate_commission(
633 xbtusd_bitmex.into_any(),
634 Quantity::from("100000"),
635 Price::from("11450.50"),
636 LiquiditySide::Taker,
637 None,
638 )
639 .unwrap();
640 assert_eq!(result, Money::from("0.00654993 BTC"));
641 }
642
643 #[rstest]
644 fn test_calculate_commission_fx_taker(cash_account_million_usd: CashAccount) {
645 let instrument = usdjpy_idealpro();
646 let result = cash_account_million_usd
647 .calculate_commission(
648 instrument.into_any(),
649 Quantity::from("2200000"),
650 Price::from("120.310"),
651 LiquiditySide::Taker,
652 None,
653 )
654 .unwrap();
655 assert_eq!(result, Money::from("5294 JPY"));
656 }
657}