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