nautilus_model/accounts/
cash.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Implementation of a simple *cash* account – an account that cannot hold leveraged positions.
17
18use 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    /// Creates a new [`CashAccount`] instance.
49    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    /// Recalculates the account balance for the specified currency based on current margins.
71    ///
72    /// # Panics
73    ///
74    /// Panics if conversion from `Decimal` to `f64` fails during balance update.
75    pub fn recalculate_balance(&mut self, currency: Currency) {
76        let current_balance = match self.balances.get(&currency) {
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 // TODO (implement this logic)
129    }
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        // Check for negative balances if borrowing is not allowed
185        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, // TODO: Make this a reference
216        fill: OrderFilled,         // TODO: Make this a reference
217        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        // apply second account event
397        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)) // TODO: Remove clone
513            .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(), // TODO: This doesn't need to be owned
545                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        // use hash set to ignore order of results
573        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}