Skip to main content

nautilus_model/accounts/
base.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//! Base traits and common types shared by all account implementations.
17//!
18//! Concrete account types (`CashAccount`, `MarginAccount`, etc.) build on the abstractions defined
19//! in this file.
20
21use ahash::AHashMap;
22use indexmap::IndexMap;
23use nautilus_core::{
24    UnixNanos,
25    correctness::{FAILED, check_equal},
26    datetime::secs_to_nanos_unchecked,
27};
28use rust_decimal::prelude::ToPrimitive;
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    enums::{AccountType, LiquiditySide, OrderSide},
33    events::{AccountState, OrderFilled},
34    identifiers::AccountId,
35    instruments::{Instrument, InstrumentAny},
36    position::Position,
37    types::{AccountBalance, Currency, Money, Price, Quantity},
38};
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
44)]
45pub struct BaseAccount {
46    pub id: AccountId,
47    pub account_type: AccountType,
48    pub base_currency: Option<Currency>,
49    pub calculate_account_state: bool,
50    pub events: Vec<AccountState>,
51    pub commissions: AHashMap<Currency, Money>,
52    pub balances: IndexMap<Currency, AccountBalance>,
53    pub balances_starting: IndexMap<Currency, Money>,
54}
55
56impl BaseAccount {
57    /// Creates a new [`BaseAccount`] instance.
58    #[must_use]
59    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
60        let mut balances_starting: IndexMap<Currency, Money> = IndexMap::new();
61        let mut balances: IndexMap<Currency, AccountBalance> = IndexMap::new();
62        event.balances.iter().for_each(|balance| {
63            balances_starting.insert(balance.currency, balance.total);
64            balances.insert(balance.currency, *balance);
65        });
66        Self {
67            id: event.account_id,
68            account_type: event.account_type,
69            base_currency: event.base_currency,
70            calculate_account_state,
71            events: vec![event],
72            commissions: AHashMap::new(),
73            balances,
74            balances_starting,
75        }
76    }
77
78    /// Returns a reference to the `AccountBalance` for the specified currency, or `None` if absent.
79    ///
80    /// # Panics
81    ///
82    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
83    #[must_use]
84    pub fn base_balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
85        let currency = currency
86            .or(self.base_currency)
87            .expect("Currency must be specified");
88        self.balances.get(&currency)
89    }
90
91    /// Returns the total `Money` balance for the specified currency, or `None` if absent.
92    ///
93    /// # Panics
94    ///
95    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
96    #[must_use]
97    pub fn base_balance_total(&self, currency: Option<Currency>) -> Option<Money> {
98        let currency = currency
99            .or(self.base_currency)
100            .expect("Currency must be specified");
101        let account_balance = self.balances.get(&currency);
102        account_balance.map(|balance| balance.total)
103    }
104
105    #[must_use]
106    pub fn base_balances_total(&self) -> IndexMap<Currency, Money> {
107        self.balances
108            .iter()
109            .map(|(currency, balance)| (*currency, balance.total))
110            .collect()
111    }
112
113    /// Returns the free `Money` balance for the specified currency, or `None` if absent.
114    ///
115    /// # Panics
116    ///
117    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
118    #[must_use]
119    pub fn base_balance_free(&self, currency: Option<Currency>) -> Option<Money> {
120        let currency = currency
121            .or(self.base_currency)
122            .expect("Currency must be specified");
123        let account_balance = self.balances.get(&currency);
124        account_balance.map(|balance| balance.free)
125    }
126
127    #[must_use]
128    pub fn base_balances_free(&self) -> IndexMap<Currency, Money> {
129        self.balances
130            .iter()
131            .map(|(currency, balance)| (*currency, balance.free))
132            .collect()
133    }
134
135    /// Returns the locked `Money` balance for the specified currency, or `None` if absent.
136    ///
137    /// # Panics
138    ///
139    /// Panics if `currency` is `None` and `self.base_currency` is `None`.
140    #[must_use]
141    pub fn base_balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
142        let currency = currency
143            .or(self.base_currency)
144            .expect("Currency must be specified");
145        let account_balance = self.balances.get(&currency);
146        account_balance.map(|balance| balance.locked)
147    }
148
149    #[must_use]
150    pub fn base_balances_locked(&self) -> IndexMap<Currency, Money> {
151        self.balances
152            .iter()
153            .map(|(currency, balance)| (*currency, balance.locked))
154            .collect()
155    }
156
157    #[must_use]
158    pub fn base_last_event(&self) -> Option<AccountState> {
159        self.events.last().cloned()
160    }
161
162    /// Updates the account balances with the provided list of `AccountBalance` instances.
163    ///
164    /// Note: This method does NOT validate negative balances. Derived account types
165    /// (`CashAccount`, `MarginAccount`) should perform their own validation in `apply()`:
166    /// - `MarginAccount`: allows negative balances (normal for margin trading)
167    /// - `CashAccount`: rejects negative unless `allow_borrowing` is true
168    pub fn update_balances(&mut self, balances: &[AccountBalance]) {
169        for balance in balances {
170            self.balances.insert(balance.currency, *balance);
171        }
172    }
173
174    pub fn update_commissions(&mut self, commission: Money) {
175        // TODO: Remove once from_raw enforces canonical precision alignment (v2)
176        let commission = commission.normalized();
177        if commission.is_zero() {
178            return;
179        }
180        let currency = commission.currency;
181        self.commissions
182            .entry(currency)
183            .and_modify(|total| *total = *total + commission)
184            .or_insert(commission);
185    }
186
187    /// Returns the total commission for the specified currency.
188    #[must_use]
189    pub fn commission(&self, currency: &Currency) -> Option<Money> {
190        self.commissions.get(currency).copied()
191    }
192
193    /// Returns a map of all commissions by currency.
194    #[must_use]
195    pub fn commissions(&self) -> AHashMap<Currency, Money> {
196        self.commissions.clone()
197    }
198
199    /// Applies an [`AccountState`] event, updating balances.
200    ///
201    /// # Panics
202    ///
203    /// Panics if `event.account_id` does not match this account's ID.
204    pub fn base_apply(&mut self, event: AccountState) {
205        check_equal(&event.account_id, &self.id, "event.account_id", "self.id").expect(FAILED);
206        self.update_balances(&event.balances);
207        self.events.push(event);
208    }
209
210    /// Purges all account state events which are outside the lookback window.
211    ///
212    /// Guaranteed to retain at least the latest event.
213    ///
214    /// # Panics
215    ///
216    /// Panics if the purging implementation is changed and all events are purged.
217    pub fn base_purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64) {
218        let lookback_ns = UnixNanos::from(secs_to_nanos_unchecked(lookback_secs as f64));
219
220        let mut retained_events = Vec::new();
221
222        for event in &self.events {
223            if event.ts_event + lookback_ns > ts_now {
224                retained_events.push(event.clone());
225            }
226        }
227
228        // Guarantee ≥ 1 event
229        if retained_events.is_empty() && !self.events.is_empty() {
230            retained_events.push(self.events.last().expect("events not empty").clone());
231        }
232
233        self.events = retained_events;
234    }
235
236    /// Calculates the amount of balance to lock for a new order based on the given side, quantity, and price.
237    ///
238    /// # Errors
239    ///
240    /// This function never returns an error (TBD).
241    ///
242    pub fn base_calculate_balance_locked(
243        &mut self,
244        instrument: &InstrumentAny,
245        side: OrderSide,
246        quantity: Quantity,
247        price: Price,
248        use_quote_for_inverse: Option<bool>,
249    ) -> anyhow::Result<Money> {
250        let base_currency = instrument
251            .base_currency()
252            .unwrap_or(instrument.quote_currency());
253        let quote_currency = instrument.quote_currency();
254        let notional: f64 = match side {
255            OrderSide::Buy => instrument
256                .calculate_notional_value(quantity, price, use_quote_for_inverse)
257                .as_f64(),
258            OrderSide::Sell => quantity.as_f64(),
259            OrderSide::NoOrderSide => {
260                anyhow::bail!("Invalid `OrderSide` in `base_calculate_balance_locked`: {side}")
261            }
262        };
263
264        // Handle inverse
265        if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
266            Ok(Money::new(notional, base_currency))
267        } else if side == OrderSide::Buy {
268            Ok(Money::new(notional, quote_currency))
269        } else if side == OrderSide::Sell {
270            Ok(Money::new(notional, base_currency))
271        } else {
272            anyhow::bail!("Invalid `OrderSide` in `base_calculate_balance_locked`: {side}")
273        }
274    }
275
276    /// Calculates profit and loss amounts for a filled order.
277    ///
278    /// For cash accounts, this calculates the balance impact of a fill:
279    /// - BUY: gain base currency quantity, lose quote currency notional.
280    /// - SELL: lose base currency quantity, gain quote currency notional.
281    ///
282    /// Note: Unlike betting accounts, cash accounts do NOT cap to position quantity.
283    /// The full fill quantity is used for PnL calculation.
284    ///
285    /// # Errors
286    ///
287    /// This function never returns an error (TBD).
288    ///
289    pub fn base_calculate_pnls(
290        &self,
291        instrument: &InstrumentAny,
292        fill: &OrderFilled,
293        _position: Option<Position>,
294    ) -> anyhow::Result<Vec<Money>> {
295        let mut pnls: IndexMap<Currency, Money> = IndexMap::new();
296        let base_currency = instrument.base_currency();
297
298        // No quantity capping (betting accounts cap to position qty, cash accounts don't)
299        let fill_qty = fill.last_qty;
300        let fill_qty_value = fill_qty.as_f64();
301
302        let notional = instrument.calculate_notional_value(fill_qty, fill.last_px, None);
303
304        if fill.order_side == OrderSide::Buy {
305            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
306                pnls.insert(
307                    base_currency_value,
308                    Money::new(fill_qty_value, base_currency_value),
309                );
310            }
311            pnls.insert(
312                notional.currency,
313                Money::new(-notional.as_f64(), notional.currency),
314            );
315        } else if fill.order_side == OrderSide::Sell {
316            if let (Some(base_currency_value), None) = (base_currency, self.base_currency) {
317                pnls.insert(
318                    base_currency_value,
319                    Money::new(-fill_qty_value, base_currency_value),
320                );
321            }
322            pnls.insert(
323                notional.currency,
324                Money::new(notional.as_f64(), notional.currency),
325            );
326        } else {
327            anyhow::bail!(
328                "Invalid `OrderSide` in base_calculate_pnls: {}",
329                fill.order_side
330            );
331        }
332        Ok(pnls.into_values().collect())
333    }
334
335    /// Calculates commission fees for a filled order.
336    ///
337    /// # Panics
338    ///
339    /// Panics if instrument fees cannot be converted to f64, or if base currency is unavailable for inverse instruments.
340    #[expect(
341        clippy::missing_errors_doc,
342        reason = "Error conditions documented inline"
343    )]
344    pub fn base_calculate_commission(
345        &self,
346        instrument: &InstrumentAny,
347        last_qty: Quantity,
348        last_px: Price,
349        liquidity_side: LiquiditySide,
350        use_quote_for_inverse: Option<bool>,
351    ) -> anyhow::Result<Money> {
352        anyhow::ensure!(
353            liquidity_side != LiquiditySide::NoLiquiditySide,
354            "Invalid `LiquiditySide`: {liquidity_side}"
355        );
356        let notional = instrument
357            .calculate_notional_value(last_qty, last_px, use_quote_for_inverse)
358            .as_f64();
359        let commission = if liquidity_side == LiquiditySide::Maker {
360            notional * instrument.maker_fee().to_f64().unwrap()
361        } else if liquidity_side == LiquiditySide::Taker {
362            notional * instrument.taker_fee().to_f64().unwrap()
363        } else {
364            anyhow::bail!("Invalid `LiquiditySide`: {liquidity_side}");
365        };
366
367        if instrument.is_inverse() && !use_quote_for_inverse.unwrap_or(false) {
368            Ok(Money::new(commission, instrument.base_currency().unwrap()))
369        } else {
370            Ok(Money::new(commission, instrument.quote_currency()))
371        }
372    }
373}
374
375#[cfg(all(test, feature = "stubs"))]
376mod tests {
377    use rstest::rstest;
378
379    use super::*;
380
381    #[rstest]
382    fn test_base_purge_account_events_retains_latest_when_all_purged() {
383        use crate::{
384            enums::AccountType,
385            events::account::stubs::cash_account_state,
386            identifiers::stubs::{account_id, uuid4},
387            types::{Currency, stubs::stub_account_balance},
388        };
389
390        let mut account = BaseAccount::new(cash_account_state(), true);
391
392        // Create events with different timestamps manually
393        let event1 = AccountState::new(
394            account_id(),
395            AccountType::Cash,
396            vec![stub_account_balance()],
397            vec![],
398            true,
399            uuid4(),
400            UnixNanos::from(100_000_000),
401            UnixNanos::from(100_000_000),
402            Some(Currency::USD()),
403        );
404        let event2 = AccountState::new(
405            account_id(),
406            AccountType::Cash,
407            vec![stub_account_balance()],
408            vec![],
409            true,
410            uuid4(),
411            UnixNanos::from(200_000_000),
412            UnixNanos::from(200_000_000),
413            Some(Currency::USD()),
414        );
415        let event3 = AccountState::new(
416            account_id(),
417            AccountType::Cash,
418            vec![stub_account_balance()],
419            vec![],
420            true,
421            uuid4(),
422            UnixNanos::from(300_000_000),
423            UnixNanos::from(300_000_000),
424            Some(Currency::USD()),
425        );
426
427        account.base_apply(event1);
428        account.base_apply(event2);
429        account.base_apply(event3.clone());
430
431        assert_eq!(account.events.len(), 4);
432
433        account.base_purge_account_events(UnixNanos::from(1_000_000_000), 0);
434
435        assert_eq!(account.events.len(), 1);
436        assert_eq!(account.events[0].ts_event, event3.ts_event);
437        assert_eq!(account.base_last_event().unwrap().ts_event, event3.ts_event);
438    }
439
440    #[rstest]
441    fn test_update_commissions_sub_canonical_raw_skipped() {
442        use crate::{
443            events::account::stubs::cash_account_state,
444            types::{Currency, Money},
445        };
446
447        let mut account = BaseAccount::new(cash_account_state(), true);
448        let usd = Currency::USD();
449
450        // Sub-canonical raw (1 < tick size for USD precision 2) normalizes to zero
451        account.update_commissions(Money::from_raw(1, usd));
452
453        assert!(account.commission(&usd).is_none());
454    }
455}