nautilus_portfolio/
manager.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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//! Provides account management functionality.
17
18use std::{cell::RefCell, fmt::Debug, rc::Rc};
19
20use nautilus_common::{cache::Cache, clock::Clock};
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23    accounts::{Account, AccountAny, CashAccount, MarginAccount},
24    enums::{AccountType, OrderSide, OrderSideSpecified, PriceType},
25    events::{AccountState, OrderFilled},
26    instruments::{Instrument, InstrumentAny},
27    orders::{Order, OrderAny},
28    position::Position,
29    types::{AccountBalance, Money},
30};
31use rust_decimal::{Decimal, prelude::ToPrimitive};
32/// Manages account balance updates and calculations for portfolio management.
33///
34/// The accounts manager handles balance updates for different account types,
35/// including cash and margin accounts, based on order fills and position changes.
36pub struct AccountsManager {
37    clock: Rc<RefCell<dyn Clock>>,
38    cache: Rc<RefCell<Cache>>,
39}
40
41impl Debug for AccountsManager {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct(stringify!(AccountsManager)).finish()
44    }
45}
46
47impl AccountsManager {
48    /// Creates a new [`AccountsManager`] instance.
49    pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
50        Self { clock, cache }
51    }
52
53    /// Updates the given account state based on a filled order.
54    ///
55    /// # Panics
56    ///
57    /// Panics if the position list for the filled instrument is empty.
58    #[must_use]
59    pub fn update_balances(
60        &self,
61        account: AccountAny,
62        instrument: InstrumentAny,
63        fill: OrderFilled,
64    ) -> AccountState {
65        let cache = self.cache.borrow();
66        let position_id = if let Some(position_id) = fill.position_id {
67            position_id
68        } else {
69            let positions_open = cache.positions_open(None, Some(&fill.instrument_id), None, None);
70            positions_open
71                .first()
72                .unwrap_or_else(|| panic!("List of Positions is empty"))
73                .id
74        };
75
76        let position = cache.position(&position_id);
77
78        let pnls = account.calculate_pnls(instrument, fill, position.cloned());
79
80        // Calculate final PnL including commissions
81        match account.base_currency() {
82            Some(base_currency) => {
83                let pnl = pnls.map_or_else(
84                    |_| Money::new(0.0, base_currency),
85                    |pnl_list| {
86                        pnl_list
87                            .first()
88                            .copied()
89                            .unwrap_or_else(|| Money::new(0.0, base_currency))
90                    },
91                );
92
93                self.update_balance_single_currency(account.clone(), &fill, pnl);
94            }
95            None => {
96                if let Ok(mut pnl_list) = pnls {
97                    self.update_balance_multi_currency(account.clone(), fill, &mut pnl_list);
98                }
99            }
100        }
101
102        // Generate and return account state
103        self.generate_account_state(account, fill.ts_event)
104    }
105
106    /// Updates account balances based on open orders.
107    ///
108    /// For cash accounts, updates the balance locked by open orders.
109    /// For margin accounts, updates the initial margin requirements.
110    #[must_use]
111    pub fn update_orders(
112        &self,
113        account: &AccountAny,
114        instrument: InstrumentAny,
115        orders_open: Vec<&OrderAny>,
116        ts_event: UnixNanos,
117    ) -> Option<(AccountAny, AccountState)> {
118        match account.clone() {
119            AccountAny::Cash(cash_account) => self
120                .update_balance_locked(&cash_account, instrument, orders_open, ts_event)
121                .map(|(updated_cash_account, state)| {
122                    (AccountAny::Cash(updated_cash_account), state)
123                }),
124            AccountAny::Margin(margin_account) => self
125                .update_margin_init(&margin_account, instrument, orders_open, ts_event)
126                .map(|(updated_margin_account, state)| {
127                    (AccountAny::Margin(updated_margin_account), state)
128                }),
129        }
130    }
131
132    /// Updates the account based on current open positions.
133    ///
134    /// # Panics
135    ///
136    /// Panics if any position's `instrument_id` does not match the provided `instrument`.
137    #[must_use]
138    pub fn update_positions(
139        &self,
140        account: &MarginAccount,
141        instrument: InstrumentAny,
142        positions: Vec<&Position>,
143        ts_event: UnixNanos,
144    ) -> Option<(MarginAccount, AccountState)> {
145        let mut total_margin_maint = 0.0;
146        let mut base_xrate: Option<f64> = None;
147        let mut currency = instrument.settlement_currency();
148        let mut account = account.clone();
149
150        for position in positions {
151            assert_eq!(
152                position.instrument_id,
153                instrument.id(),
154                "Position not for instrument {}",
155                instrument.id()
156            );
157
158            if !position.is_open() {
159                continue;
160            }
161
162            let margin_maint = match instrument {
163                InstrumentAny::Betting(i) => account.calculate_maintenance_margin(
164                    i,
165                    position.quantity,
166                    instrument.make_price(position.avg_px_open),
167                    None,
168                ),
169                InstrumentAny::BinaryOption(i) => account.calculate_maintenance_margin(
170                    i,
171                    position.quantity,
172                    instrument.make_price(position.avg_px_open),
173                    None,
174                ),
175                InstrumentAny::CryptoFuture(i) => account.calculate_maintenance_margin(
176                    i,
177                    position.quantity,
178                    instrument.make_price(position.avg_px_open),
179                    None,
180                ),
181                InstrumentAny::CryptoOption(i) => account.calculate_maintenance_margin(
182                    i,
183                    position.quantity,
184                    instrument.make_price(position.avg_px_open),
185                    None,
186                ),
187                InstrumentAny::CryptoPerpetual(i) => account.calculate_maintenance_margin(
188                    i,
189                    position.quantity,
190                    instrument.make_price(position.avg_px_open),
191                    None,
192                ),
193                InstrumentAny::CurrencyPair(i) => account.calculate_maintenance_margin(
194                    i,
195                    position.quantity,
196                    instrument.make_price(position.avg_px_open),
197                    None,
198                ),
199                InstrumentAny::Equity(i) => account.calculate_maintenance_margin(
200                    i,
201                    position.quantity,
202                    instrument.make_price(position.avg_px_open),
203                    None,
204                ),
205                InstrumentAny::FuturesContract(i) => account.calculate_maintenance_margin(
206                    i,
207                    position.quantity,
208                    instrument.make_price(position.avg_px_open),
209                    None,
210                ),
211                InstrumentAny::FuturesSpread(i) => account.calculate_maintenance_margin(
212                    i,
213                    position.quantity,
214                    instrument.make_price(position.avg_px_open),
215                    None,
216                ),
217                InstrumentAny::OptionContract(i) => account.calculate_maintenance_margin(
218                    i,
219                    position.quantity,
220                    instrument.make_price(position.avg_px_open),
221                    None,
222                ),
223                InstrumentAny::OptionSpread(i) => account.calculate_maintenance_margin(
224                    i,
225                    position.quantity,
226                    instrument.make_price(position.avg_px_open),
227                    None,
228                ),
229            };
230
231            let mut margin_maint = margin_maint.as_f64();
232
233            if let Some(base_currency) = account.base_currency {
234                if base_xrate.is_none() {
235                    currency = base_currency;
236                    base_xrate = self.calculate_xrate_to_base(
237                        AccountAny::Margin(account.clone()),
238                        instrument.clone(),
239                        position.entry.as_specified(),
240                    );
241                }
242
243                if let Some(xrate) = base_xrate {
244                    margin_maint *= xrate;
245                } else {
246                    log::debug!(
247                        "Cannot calculate maintenance (position) margin: insufficient data for {}/{}",
248                        instrument.settlement_currency(),
249                        base_currency
250                    );
251                    return None;
252                }
253            }
254
255            total_margin_maint += margin_maint;
256        }
257
258        let margin_maint = Money::new(total_margin_maint, currency);
259        account.update_maintenance_margin(instrument.id(), margin_maint);
260
261        log::info!("{} margin_maint={margin_maint}", instrument.id());
262
263        // Generate and return account state
264        Some((
265            account.clone(),
266            self.generate_account_state(AccountAny::Margin(account), ts_event),
267        ))
268    }
269
270    fn update_balance_locked(
271        &self,
272        account: &CashAccount,
273        instrument: InstrumentAny,
274        orders_open: Vec<&OrderAny>,
275        ts_event: UnixNanos,
276    ) -> Option<(CashAccount, AccountState)> {
277        let mut account = account.clone();
278        if orders_open.is_empty() {
279            let balance = account.balances.remove(&instrument.quote_currency());
280            if let Some(balance) = balance {
281                account.recalculate_balance(balance.currency);
282            }
283            return Some((
284                account.clone(),
285                self.generate_account_state(AccountAny::Cash(account), ts_event),
286            ));
287        }
288
289        let mut total_locked = 0.0;
290        let mut base_xrate: Option<f64> = None;
291
292        let mut currency = instrument.settlement_currency();
293
294        for order in orders_open {
295            assert_eq!(
296                order.instrument_id(),
297                instrument.id(),
298                "Order not for instrument {}",
299                instrument.id()
300            );
301            assert!(order.is_open(), "Order is not open");
302
303            if order.price().is_none() && order.trigger_price().is_none() {
304                continue;
305            }
306
307            if order.is_reduce_only() {
308                continue; // Does not contribute to locked balance
309            }
310
311            let price = if order.price().is_some() {
312                order.price()
313            } else {
314                order.trigger_price()
315            };
316
317            let mut locked = account
318                .calculate_balance_locked(
319                    instrument.clone(),
320                    order.order_side(),
321                    order.quantity(),
322                    price?,
323                    None,
324                )
325                .unwrap()
326                .as_f64();
327
328            if let Some(base_curr) = account.base_currency() {
329                if base_xrate.is_none() {
330                    currency = base_curr;
331                    base_xrate = self.calculate_xrate_to_base(
332                        AccountAny::Cash(account.clone()),
333                        instrument.clone(),
334                        order.order_side_specified(),
335                    );
336                }
337
338                if let Some(xrate) = base_xrate {
339                    locked *= xrate;
340                } else {
341                    // TODO: Revisit error handling
342                    panic!("Cannot calculate base xrate");
343                }
344            }
345
346            total_locked += locked;
347        }
348
349        let balance_locked = Money::new(total_locked.to_f64()?, currency);
350
351        if let Some(balance) = account.balances.get_mut(&instrument.quote_currency()) {
352            balance.locked = balance_locked;
353            let currency = balance.currency;
354            account.recalculate_balance(currency);
355        }
356
357        log::info!("{} balance_locked={balance_locked}", instrument.id());
358
359        Some((
360            account.clone(),
361            self.generate_account_state(AccountAny::Cash(account), ts_event),
362        ))
363    }
364
365    fn update_margin_init(
366        &self,
367        account: &MarginAccount,
368        instrument: InstrumentAny,
369        orders_open: Vec<&OrderAny>,
370        ts_event: UnixNanos,
371    ) -> Option<(MarginAccount, AccountState)> {
372        let mut total_margin_init = 0.0;
373        let mut base_xrate: Option<f64> = None;
374        let mut currency = instrument.settlement_currency();
375        let mut account = account.clone();
376
377        for order in orders_open {
378            assert_eq!(
379                order.instrument_id(),
380                instrument.id(),
381                "Order not for instrument {}",
382                instrument.id()
383            );
384
385            if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
386                continue;
387            }
388
389            if order.is_reduce_only() {
390                continue; // Does not contribute to margin
391            }
392
393            let price = if order.price().is_some() {
394                order.price()
395            } else {
396                order.trigger_price()
397            };
398
399            let margin_init = match instrument {
400                InstrumentAny::Betting(i) => {
401                    account.calculate_initial_margin(i, order.quantity(), price?, None)
402                }
403                InstrumentAny::BinaryOption(i) => {
404                    account.calculate_initial_margin(i, order.quantity(), price?, None)
405                }
406                InstrumentAny::CryptoFuture(i) => {
407                    account.calculate_initial_margin(i, order.quantity(), price?, None)
408                }
409                InstrumentAny::CryptoOption(i) => {
410                    account.calculate_initial_margin(i, order.quantity(), price?, None)
411                }
412                InstrumentAny::CryptoPerpetual(i) => {
413                    account.calculate_initial_margin(i, order.quantity(), price?, None)
414                }
415                InstrumentAny::CurrencyPair(i) => {
416                    account.calculate_initial_margin(i, order.quantity(), price?, None)
417                }
418                InstrumentAny::Equity(i) => {
419                    account.calculate_initial_margin(i, order.quantity(), price?, None)
420                }
421                InstrumentAny::FuturesContract(i) => {
422                    account.calculate_initial_margin(i, order.quantity(), price?, None)
423                }
424                InstrumentAny::FuturesSpread(i) => {
425                    account.calculate_initial_margin(i, order.quantity(), price?, None)
426                }
427                InstrumentAny::OptionContract(i) => {
428                    account.calculate_initial_margin(i, order.quantity(), price?, None)
429                }
430                InstrumentAny::OptionSpread(i) => {
431                    account.calculate_initial_margin(i, order.quantity(), price?, None)
432                }
433            };
434
435            let mut margin_init = margin_init.as_f64();
436
437            if let Some(base_currency) = account.base_currency {
438                if base_xrate.is_none() {
439                    currency = base_currency;
440                    base_xrate = self.calculate_xrate_to_base(
441                        AccountAny::Margin(account.clone()),
442                        instrument.clone(),
443                        order.order_side_specified(),
444                    );
445                }
446
447                if let Some(xrate) = base_xrate {
448                    margin_init *= xrate;
449                } else {
450                    log::debug!(
451                        "Cannot calculate initial margin: insufficient data for {}/{}",
452                        instrument.settlement_currency(),
453                        base_currency
454                    );
455                    continue;
456                }
457            }
458
459            total_margin_init += margin_init;
460        }
461
462        let money = Money::new(total_margin_init, currency);
463        let margin_init = {
464            account.update_initial_margin(instrument.id(), money);
465            money
466        };
467
468        log::info!("{} margin_init={margin_init}", instrument.id());
469
470        Some((
471            account.clone(),
472            self.generate_account_state(AccountAny::Margin(account), ts_event),
473        ))
474    }
475
476    fn update_balance_single_currency(
477        &self,
478        account: AccountAny,
479        fill: &OrderFilled,
480        mut pnl: Money,
481    ) {
482        let base_currency = if let Some(currency) = account.base_currency() {
483            currency
484        } else {
485            log::error!("Account has no base currency set");
486            return;
487        };
488
489        let mut balances = Vec::new();
490        let mut commission = fill.commission;
491
492        if let Some(ref mut comm) = commission {
493            if comm.currency != base_currency {
494                let xrate = self.cache.borrow().get_xrate(
495                    fill.instrument_id.venue,
496                    comm.currency,
497                    base_currency,
498                    if fill.order_side == OrderSide::Sell {
499                        PriceType::Bid
500                    } else {
501                        PriceType::Ask
502                    },
503                );
504
505                if let Some(xrate) = xrate {
506                    *comm = Money::new(comm.as_f64() * xrate, base_currency);
507                } else {
508                    log::error!(
509                        "Cannot calculate account state: insufficient data for {}/{}",
510                        comm.currency,
511                        base_currency
512                    );
513                    return;
514                }
515            }
516        }
517
518        if pnl.currency != base_currency {
519            let xrate = self.cache.borrow().get_xrate(
520                fill.instrument_id.venue,
521                pnl.currency,
522                base_currency,
523                if fill.order_side == OrderSide::Sell {
524                    PriceType::Bid
525                } else {
526                    PriceType::Ask
527                },
528            );
529
530            if let Some(xrate) = xrate {
531                pnl = Money::new(pnl.as_f64() * xrate, base_currency);
532            } else {
533                log::error!(
534                    "Cannot calculate account state: insufficient data for {}/{}",
535                    pnl.currency,
536                    base_currency
537                );
538                return;
539            }
540        }
541
542        if let Some(comm) = commission {
543            pnl -= comm;
544        }
545
546        if pnl.is_zero() {
547            return;
548        }
549
550        let existing_balances = account.balances();
551        let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
552            b
553        } else {
554            log::error!(
555                "Cannot complete transaction: no balance for {}",
556                pnl.currency
557            );
558            return;
559        };
560
561        let new_balance =
562            AccountBalance::new(balance.total + pnl, balance.locked, balance.free + pnl);
563        balances.push(new_balance);
564
565        match account {
566            AccountAny::Cash(mut cash) => {
567                cash.update_balances(balances);
568                if let Some(comm) = commission {
569                    cash.update_commissions(comm);
570                }
571            }
572            AccountAny::Margin(mut margin) => {
573                margin.update_balances(balances);
574                if let Some(comm) = commission {
575                    margin.update_commissions(comm);
576                }
577            }
578        }
579    }
580
581    fn update_balance_multi_currency(
582        &self,
583        account: AccountAny,
584        fill: OrderFilled,
585        pnls: &mut [Money],
586    ) {
587        let mut new_balances = Vec::new();
588        let commission = fill.commission;
589        let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
590
591        for pnl in pnls.iter_mut() {
592            if apply_commission && pnl.currency == commission.unwrap().currency {
593                *pnl -= commission.unwrap();
594                apply_commission = false;
595            }
596
597            if pnl.is_zero() {
598                continue; // No Adjustment
599            }
600
601            let currency = pnl.currency;
602            let balances = account.balances();
603
604            let new_balance = if let Some(balance) = balances.get(&currency) {
605                let new_total = balance.total.as_f64() + pnl.as_f64();
606                let new_free = balance.free.as_f64() + pnl.as_f64();
607                let total = Money::new(new_total, currency);
608                let free = Money::new(new_free, currency);
609
610                if new_total < 0.0 {
611                    log::error!(
612                        "AccountBalanceNegative: balance = {}, currency = {}",
613                        total.as_decimal(),
614                        currency
615                    );
616                    return;
617                }
618                if new_free < 0.0 {
619                    log::error!(
620                        "AccountMarginExceeded: balance = {}, margin = {}, currency = {}",
621                        total.as_decimal(),
622                        balance.locked.as_decimal(),
623                        currency
624                    );
625                    return;
626                }
627
628                AccountBalance::new(total, balance.locked, free)
629            } else {
630                if pnl.as_decimal() < Decimal::ZERO {
631                    log::error!(
632                        "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
633                    );
634                    return;
635                }
636                AccountBalance::new(*pnl, Money::new(0.0, currency), *pnl)
637            };
638
639            new_balances.push(new_balance);
640        }
641
642        if apply_commission {
643            let commission = commission.unwrap();
644            let currency = commission.currency;
645            let balances = account.balances();
646
647            let commission_balance = if let Some(balance) = balances.get(&currency) {
648                let new_total = balance.total.as_decimal() - commission.as_decimal();
649                let new_free = balance.free.as_decimal() - commission.as_decimal();
650                AccountBalance::new(
651                    Money::new(new_total.to_f64().unwrap(), currency),
652                    balance.locked,
653                    Money::new(new_free.to_f64().unwrap(), currency),
654                )
655            } else {
656                if commission.as_decimal() > Decimal::ZERO {
657                    log::error!(
658                        "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
659                    );
660                    return;
661                }
662                AccountBalance::new(
663                    Money::new(0.0, currency),
664                    Money::new(0.0, currency),
665                    Money::new(0.0, currency),
666                )
667            };
668            new_balances.push(commission_balance);
669        }
670
671        if new_balances.is_empty() {
672            return;
673        }
674
675        match account {
676            AccountAny::Cash(mut cash) => {
677                cash.update_balances(new_balances);
678                if let Some(commission) = commission {
679                    cash.update_commissions(commission);
680                }
681            }
682            AccountAny::Margin(mut margin) => {
683                margin.update_balances(new_balances);
684                if let Some(commission) = commission {
685                    margin.update_commissions(commission);
686                }
687            }
688        }
689    }
690
691    fn generate_account_state(&self, account: AccountAny, ts_event: UnixNanos) -> AccountState {
692        match account {
693            AccountAny::Cash(cash_account) => AccountState::new(
694                cash_account.id,
695                AccountType::Cash,
696                cash_account.balances.clone().into_values().collect(),
697                vec![],
698                false,
699                UUID4::new(),
700                ts_event,
701                self.clock.borrow().timestamp_ns(),
702                cash_account.base_currency(),
703            ),
704            AccountAny::Margin(margin_account) => AccountState::new(
705                margin_account.id,
706                AccountType::Cash,
707                vec![],
708                margin_account.margins.clone().into_values().collect(),
709                false,
710                UUID4::new(),
711                ts_event,
712                self.clock.borrow().timestamp_ns(),
713                margin_account.base_currency(),
714            ),
715        }
716    }
717
718    fn calculate_xrate_to_base(
719        &self,
720        account: AccountAny,
721        instrument: InstrumentAny,
722        side: OrderSideSpecified,
723    ) -> Option<f64> {
724        match account.base_currency() {
725            None => Some(1.0),
726            Some(base_curr) => self.cache.borrow().get_xrate(
727                instrument.id().venue,
728                instrument.settlement_currency(),
729                base_curr,
730                match side {
731                    OrderSideSpecified::Sell => PriceType::Bid,
732                    OrderSideSpecified::Buy => PriceType::Ask,
733                },
734            ),
735        }
736    }
737}