1#[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
36pub 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 fn apply(&mut self, event: AccountState) -> anyhow::Result<()>;
83 fn purge_account_events(&mut self, ts_now: UnixNanos, lookback_secs: u64);
84
85 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 fn calculate_pnls(
107 &self,
108 instrument: &InstrumentAny,
109 fill: &OrderFilled,
110 position: Option<Position>,
111 ) -> anyhow::Result<Vec<Money>>;
112
113 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}