nautilus_model/accounts/
cash.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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                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, // TODO: Make this a reference
217        fill: OrderFilled,         // TODO: Make this a reference
218        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        // apply second account event
398        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)) // TODO: Remove clone
514            .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(), // TODO: This doesn't need to be owned
546                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        // use hash set to ignore order of results
574        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}