1use 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 #[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 #[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(¤cy)
88 }
89
90 #[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(¤cy);
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 #[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(¤cy);
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 #[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(¤cy);
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 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 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 #[must_use]
188 pub fn commission(&self, currency: &Currency) -> Option<Money> {
189 self.commissions.get(currency).copied()
190 }
191
192 #[must_use]
194 pub fn commissions(&self) -> AHashMap<Currency, Money> {
195 self.commissions.clone()
196 }
197
198 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 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 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 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 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 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 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 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 account.update_commissions(Money::from_raw(1, usd));
446
447 assert!(account.commission(&usd).is_none());
448 }
449}