Skip to main content

nautilus_model/accounts/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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//! Account types such as `CashAccount` and `MarginAccount`.
17
18#[macro_use]
19mod macros;
20
21pub mod any;
22pub mod base;
23pub mod betting;
24pub mod cash;
25pub mod margin;
26pub mod margin_model;
27pub mod wallet;
28
29#[cfg(any(test, feature = "test-support"))]
30pub mod stubs;
31
32use enum_dispatch::enum_dispatch;
33use indexmap::IndexMap;
34use nautilus_core::UnixNanos;
35
36// Re-exports
37pub use crate::accounts::{
38    any::AccountAny, base::BaseAccount, betting::BettingAccount, cash::CashAccount,
39    margin::MarginAccount, wallet::WalletAccount,
40};
41use crate::{
42    enums::{AccountType, LiquiditySide, OrderSide},
43    events::{AccountState, OrderFilled},
44    fees::MakerTakerFeeRates,
45    identifiers::AccountId,
46    instruments::InstrumentAny,
47    position::{Position, mul_rounded, price, qty},
48    types::{AccountBalance, Currency, Money, Price, Quantity},
49};
50
51#[enum_dispatch]
52pub trait Account: 'static + Send {
53    fn id(&self) -> AccountId;
54    fn account_type(&self) -> AccountType;
55    fn base_currency(&self) -> Option<Currency>;
56    fn is_cash_account(&self) -> bool;
57    fn is_margin_account(&self) -> bool;
58    fn calculated_account_state(&self) -> bool;
59    fn balance_total(&self, currency: Option<Currency>) -> Option<Money>;
60    fn balances_total(&self) -> IndexMap<Currency, Money>;
61    fn balance_free(&self, currency: Option<Currency>) -> Option<Money>;
62    fn balances_free(&self) -> IndexMap<Currency, Money>;
63    fn balance_locked(&self, currency: Option<Currency>) -> Option<Money>;
64    fn balances_locked(&self) -> IndexMap<Currency, Money>;
65    fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance>;
66    fn last_event(&self) -> Option<AccountState>;
67    fn events(&self) -> Vec<AccountState>;
68    fn event_count(&self) -> usize;
69    fn currencies(&self) -> Vec<Currency>;
70    fn starting_balances(&self) -> IndexMap<Currency, Money>;
71    fn balances(&self) -> IndexMap<Currency, AccountBalance>;
72    /// Applies an account state event to update the account.
73    ///
74    /// Implementations reject the event before mutating any state, so a rejected event leaves
75    /// the account unchanged.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if `event.account_id` does not match this account's ID, or if the
80    /// account state cannot be applied (e.g., negative balance when borrowing is not allowed
81    /// for a cash account).
82    fn apply(&mut self, event: AccountState) -> anyhow::Result<()>;
83    fn purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64);
84
85    /// Calculates locked balance for the order parameters.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if calculating locked balance fails.
90    fn calculate_balance_locked(
91        &self,
92        instrument: &InstrumentAny,
93        side: OrderSide,
94        quantity: Quantity,
95        price: Price,
96        use_quote_for_inverse: Option<bool>,
97    ) -> anyhow::Result<Money>;
98
99    /// Calculates PnLs for the fill and position.
100    ///
101    /// A supplied position represents its state before the fill.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if calculating PnLs fails.
106    fn calculate_pnls(
107        &self,
108        instrument: &InstrumentAny,
109        fill: &OrderFilled,
110        position: Option<Position>,
111    ) -> anyhow::Result<Vec<Money>>;
112
113    /// Calculates commission for the order fill parameters from explicitly resolved fee rates.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if calculating commission fails.
118    fn calculate_commission(
119        &self,
120        instrument: &InstrumentAny,
121        last_qty: Quantity,
122        last_px: Price,
123        liquidity_side: LiquiditySide,
124        fee_rates: MakerTakerFeeRates,
125        use_quote_for_inverse: Option<bool>,
126    ) -> anyhow::Result<Money>;
127}
128
129fn fill_position_pnl(
130    instrument: &InstrumentAny,
131    fill: &OrderFilled,
132    position: Option<&Position>,
133) -> anyhow::Result<Option<Money>> {
134    let Some(position) = position else {
135        if fill.position_id.is_some() {
136            let position = Position::new_checked(instrument, fill.clone())?;
137            return Ok(position.realized_pnl);
138        }
139
140        mul_rounded(price(fill.last_px)?, qty(fill.last_qty)?)?;
141        return Ok(None);
142    };
143
144    let mut position = position.clone_without_events();
145    let prior = if position.is_closed() {
146        Money::zero(position.settlement_currency)
147    } else {
148        position
149            .realized_pnl
150            .unwrap_or(Money::zero(position.settlement_currency))
151    };
152
153    let mut fill = fill.clone();
154    fill.position_id.get_or_insert(position.id);
155
156    let opening_pnl = if !position.is_closed()
157        && position.is_opposite_side(fill.order_side)
158        && fill.last_qty > position.quantity
159    {
160        let (closing, opening) =
161            fill.split_for_position_flip(position.quantity, fill.position_id, fill.event_id)?;
162        let opening = Position::new_checked(instrument, opening)?;
163        position.try_apply(&closing)?;
164        opening
165            .realized_pnl
166            .unwrap_or(Money::zero(position.settlement_currency))
167    } else {
168        position.try_apply(&fill)?;
169        Money::zero(position.settlement_currency)
170    };
171
172    let pnl = position
173        .realized_pnl
174        .unwrap_or(Money::zero(position.settlement_currency));
175    let increment = pnl
176        .checked_sub(prior)
177        .and_then(|pnl| pnl.checked_add(opening_pnl))
178        .ok_or_else(|| anyhow::anyhow!("position net PnL increment overflow"))?;
179    Ok(Some(increment))
180}
181
182#[cfg(test)]
183mod tests {
184    use rstest::rstest;
185
186    use super::{Account, AccountAny, CashAccount, MarginAccount, WalletAccount};
187    use crate::{
188        enums::OrderSide,
189        events::{
190            OrderFilled,
191            account::stubs::{cash_account_state, margin_account_state, wallet_account_state},
192            order::spec::OrderFilledSpec,
193        },
194        identifiers::{ClientOrderId, PositionId, TradeId},
195        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
196        position::Position,
197        types::{Currency, Money, Price, Quantity},
198    };
199
200    #[cfg(feature = "high-precision")]
201    #[rstest]
202    #[case::cash(CashAccount::new(cash_account_state(), true, false).into(), vec!["-0.0152415787532388 USD"])]
203    #[case::margin(MarginAccount::new(margin_account_state(), true).into(), vec![])]
204    #[case::wallet(WalletAccount::new(wallet_account_state(), true).into(), vec!["0.1234567890123456 AUD", "-0.0152415787532388 USD"])]
205    fn test_calculate_pnls_accepts_bounded_opening_product(
206        #[case] account: AccountAny,
207        #[case] expected: Vec<&str>,
208        #[values(false, true)] position_id: bool,
209    ) {
210        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
211        let mut fill = OrderFilledSpec::builder()
212            .instrument_id(instrument.id())
213            .last_qty(Quantity::from("0.1234567890123456"))
214            .last_px(Price::from("0.1234567890123456"))
215            .commission(Money::from("0 USD"))
216            .build();
217        fill.position_id = position_id.then(|| PositionId::from("P-BOUNDED-PRODUCT"));
218
219        let result = account.calculate_pnls(&instrument, &fill, None).unwrap();
220        let expected: Vec<_> = expected
221            .into_iter()
222            .map(|value| Money::from_str_exact(value).unwrap())
223            .collect();
224
225        assert_eq!(result, expected);
226    }
227
228    #[rstest]
229    #[case::cash(CashAccount::new(cash_account_state(), true, false).into(), vec!["18 USD"], vec!["7.20 USD"])]
230    #[case::margin(MarginAccount::new(margin_account_state(), true).into(), vec!["11.40 USD"], vec!["0 USD"])]
231    #[case::wallet(WalletAccount::new(wallet_account_state(), true).into(), vec!["-9 AUD", "18 USD"], vec!["-10 AUD", "7.20 USD"])]
232    fn test_calculate_pnls_accepts_break_even_residual(
233        #[case] account: AccountAny,
234        #[case] expected: Vec<&str>,
235        #[case] expected_at_average: Vec<&str>,
236        #[values(false, true)] close_at_average: bool,
237    ) {
238        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
239        let opening = OrderFilledSpec::builder()
240            .instrument_id(instrument.id())
241            .position_id(PositionId::from("P-BREAK-EVEN"))
242            .last_qty(Quantity::from(5))
243            .last_px(Price::from("1.00000"))
244            .commission(Money::zero(Currency::USD()))
245            .build();
246        let mut position = Position::new(&instrument, opening);
247
248        let mut fills = vec![
249            ("T-ADD", OrderSide::Buy, 10, "0.60000"),
250            ("T-REDUCE-1", OrderSide::Sell, 1, "0.70000"),
251            ("T-REDUCE-2", OrderSide::Sell, 5, "0.74000"),
252        ];
253
254        if close_at_average {
255            fills[1].3 = "10.00000";
256            fills[2].3 = "1.00000";
257            fills.push(("T-ADD-2", OrderSide::Buy, 1, "0.60000"));
258        }
259
260        for (trade_id, side, qty, px) in fills {
261            let fill = OrderFilledSpec::builder()
262                .instrument_id(instrument.id())
263                .position_id(position.id)
264                .trade_id(TradeId::from(trade_id))
265                .order_side(side)
266                .last_qty(Quantity::from(qty))
267                .last_px(Price::from(px))
268                .commission(Money::zero(Currency::USD()))
269                .build();
270            position.try_apply(&fill).unwrap();
271        }
272
273        let (closing_qty, closing_px, expected) = if close_at_average {
274            (10, "0.72000", expected_at_average)
275        } else {
276            (9, "2.00000", expected)
277        };
278
279        let closing = OrderFilledSpec::builder()
280            .instrument_id(instrument.id())
281            .position_id(position.id)
282            .trade_id(TradeId::from("T-CLOSE"))
283            .order_side(OrderSide::Sell)
284            .last_qty(Quantity::from(closing_qty))
285            .last_px(Price::from(closing_px))
286            .commission(Money::zero(Currency::USD()))
287            .build();
288        let result = account
289            .calculate_pnls(&instrument, &closing, Some(position))
290            .unwrap();
291        let expected: Vec<_> = expected.into_iter().map(Money::from).collect();
292
293        assert_eq!(result, expected);
294    }
295
296    #[rstest]
297    #[case::cash(CashAccount::new(cash_account_state(), true, false).into())]
298    #[case::margin(MarginAccount::new(margin_account_state(), true).into())]
299    #[case::wallet(WalletAccount::new(wallet_account_state(), true).into())]
300    fn test_calculate_pnls_reopens_closed_position(
301        #[case] account: AccountAny,
302        #[values(OrderSide::Buy, OrderSide::Sell)] entry: OrderSide,
303    ) {
304        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
305
306        let opposite = if entry == OrderSide::Buy {
307            OrderSide::Sell
308        } else {
309            OrderSide::Buy
310        };
311
312        let opening = OrderFilledSpec::builder()
313            .instrument_id(instrument.id())
314            .position_id(PositionId::from("P-REOPEN"))
315            .client_order_id(ClientOrderId::from("O-OPEN"))
316            .trade_id(TradeId::from("T-OPEN"))
317            .order_side(entry)
318            .last_qty(Quantity::from(2))
319            .last_px(Price::from("1"))
320            .commission(Money::from("1 USD"))
321            .build();
322        let mut position = Position::new(&instrument, opening.clone());
323
324        let closing = OrderFilled {
325            client_order_id: ClientOrderId::from("O-CLOSE"),
326            trade_id: TradeId::from("T-CLOSE"),
327            order_side: opposite,
328            last_px: Price::from("2"),
329            ..opening.clone()
330        };
331
332        position.try_apply(&closing).unwrap();
333        assert!(position.is_closed());
334
335        let reopening = OrderFilled {
336            client_order_id: ClientOrderId::from("O-REOPEN"),
337            trade_id: TradeId::from("T-REOPEN"),
338            order_side: opposite,
339            last_qty: Quantity::from(3),
340            last_px: Price::from("4"),
341            commission: Some(Money::from("3 USD")),
342            ..opening
343        };
344
345        let mut expected = if account.is_margin_account() {
346            Vec::new()
347        } else if opposite == OrderSide::Buy {
348            vec![Money::from("-12 USD")]
349        } else {
350            vec![Money::from("12 USD")]
351        };
352
353        if account.base_currency().is_none() {
354            expected.insert(
355                0,
356                if opposite == OrderSide::Buy {
357                    Money::from("3 AUD")
358                } else {
359                    Money::from("-3 AUD")
360                },
361            );
362        }
363
364        let pnls = account
365            .calculate_pnls(&instrument, &reopening, Some(position))
366            .unwrap();
367
368        assert_eq!(pnls, expected);
369    }
370}