1use 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 #[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 #[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(¤cy)
89 }
90
91 #[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(¤cy);
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 #[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(¤cy);
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 #[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(¤cy);
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 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 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 #[must_use]
189 pub fn commission(&self, currency: &Currency) -> Option<Money> {
190 self.commissions.get(currency).copied()
191 }
192
193 #[must_use]
195 pub fn commissions(&self) -> AHashMap<Currency, Money> {
196 self.commissions.clone()
197 }
198
199 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 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 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 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 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 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 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 #[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 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 account.update_commissions(Money::from_raw(1, usd));
452
453 assert!(account.commission(&usd).is_none());
454 }
455}