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