nautilus_portfolio/
manager.rs

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