1use std::{
19 cell::RefCell,
20 collections::{BTreeSet, VecDeque},
21 fmt::Debug,
22 rc::Rc,
23};
24
25use ahash::{AHashMap, AHashSet};
26use indexmap::{IndexMap, IndexSet};
27use nautilus_analysis::{
28 analyzer::{PortfolioAnalyzer, Statistic},
29 snapshot::PortfolioStatistics,
30};
31use nautilus_common::{
32 cache::{AccountLookupError, AccountRef, Cache, OrderRef},
33 clock::Clock,
34 enums::LogColor,
35 msgbus::{self, MessagingSwitchboard, TypedHandler, TypedIntoHandler},
36 timer::{TimeEvent, TimeEventCallback},
37};
38use nautilus_core::{DurationNanos, UUID4, UnixNanos, WeakCell};
39use nautilus_model::{
40 accounts::{Account, AccountAny},
41 data::{Bar, MarkPriceUpdate, QuoteTick},
42 enums::{OmsType, OrderType, PositionSide, PriceType},
43 events::{AccountState, OrderEventAny, PortfolioSnapshot, position::PositionEvent},
44 identifiers::{AccountId, InstrumentId, PositionId, Venue},
45 instruments::{Instrument, InstrumentAny},
46 orders::{Order, OrderAny},
47 position::Position,
48 types::{AccountBalance, Currency, MarginBalance, Money, Price},
49};
50use rust_decimal::Decimal;
51
52use crate::{config::PortfolioConfig, manager::AccountsManager};
53
54const SNAPSHOT_BUFFER_CAP: usize = 1_000_000;
58const EQUITY_CURVE_TIMER_PREFIX: &str = "portfolio_equity_curve.";
59const SNAPSHOT_TIMER_PREFIX: &str = "portfolio_snapshot.";
60
61struct PortfolioState {
62 accounts: AccountsManager,
63 analyzer: PortfolioAnalyzer,
64 unrealized_pnls: IndexMap<InstrumentId, Money>,
65 realized_pnls: IndexMap<InstrumentId, Money>,
66 recorded_closed_position_cycles: AHashSet<(PositionId, UnixNanos)>,
67 snapshot_sum_per_position: AHashMap<PositionId, Money>,
68 snapshot_last_per_position: AHashMap<PositionId, Money>,
69 snapshot_currency_mismatches: AHashSet<PositionId>,
70 snapshot_aggregation_overflows: AHashSet<PositionId>,
71 snapshot_processed_counts: AHashMap<PositionId, usize>,
72 snapshot_processed_revisions: AHashMap<PositionId, u64>,
73 snapshot_account_ids: AHashMap<PositionId, AccountId>,
74 net_positions: IndexMap<InstrumentId, Decimal>,
75 pending_calcs: AHashSet<InstrumentId>,
76 bar_close_prices: AHashMap<InstrumentId, Price>,
77 last_prices: AHashMap<(InstrumentId, PositionSide), Price>,
78 last_xrates: AHashMap<(Venue, Currency, Currency), Decimal>,
79 stale_prices: AHashSet<(InstrumentId, PositionSide)>,
80 stale_xrates: AHashSet<(Venue, Currency, Currency)>,
81 initialized: bool,
82 last_account_state_log_ts: AHashMap<AccountId, UnixNanos>,
83 last_published_account_states: AHashMap<AccountId, UUID4>,
84 min_account_state_logging_interval_ns: DurationNanos,
85 venues_missing_price: AHashMap<Venue, AHashMap<Option<AccountId>, AHashSet<InstrumentId>>>,
86 account_open_positions: AHashMap<AccountId, usize>,
87 equity_curve_accounts: AHashSet<AccountId>,
88 equity_curve_finalized: bool,
89 portfolio_snapshots: AHashMap<AccountId, VecDeque<PortfolioSnapshot>>,
90 pre_position_fill_events: AHashSet<UUID4>,
91 balance_error: Option<String>,
92}
93
94#[derive(Clone, Copy)]
95enum OrderUpdateSource {
96 Endpoint,
97 Topic,
98}
99
100#[derive(Clone, Copy, PartialEq, Eq)]
101enum MarkValueMode {
102 Gross,
103 Equity,
104 Premium,
105}
106
107#[derive(Clone, Copy, PartialEq, Eq)]
108enum UnrealizedPnlError {
109 MissingInput,
110 Invalid,
111}
112
113impl PortfolioState {
114 fn new(
115 clock: Rc<RefCell<dyn Clock>>,
116 cache: Rc<RefCell<Cache>>,
117 config: &PortfolioConfig,
118 ) -> Self {
119 let min_account_state_logging_interval_ns = config
120 .min_account_state_logging_interval_ms
121 .map(DurationNanos::from_millis)
122 .unwrap_or_default();
123
124 Self {
125 accounts: AccountsManager::new(clock, cache),
126 analyzer: PortfolioAnalyzer::default(),
127 unrealized_pnls: IndexMap::new(),
128 realized_pnls: IndexMap::new(),
129 recorded_closed_position_cycles: AHashSet::new(),
130 snapshot_sum_per_position: AHashMap::new(),
131 snapshot_last_per_position: AHashMap::new(),
132 snapshot_currency_mismatches: AHashSet::new(),
133 snapshot_aggregation_overflows: AHashSet::new(),
134 snapshot_processed_counts: AHashMap::new(),
135 snapshot_processed_revisions: AHashMap::new(),
136 snapshot_account_ids: AHashMap::new(),
137 net_positions: IndexMap::new(),
138 pending_calcs: AHashSet::new(),
139 bar_close_prices: AHashMap::new(),
140 last_prices: AHashMap::new(),
141 last_xrates: AHashMap::new(),
142 stale_prices: AHashSet::new(),
143 stale_xrates: AHashSet::new(),
144 initialized: false,
145 last_account_state_log_ts: AHashMap::new(),
146 last_published_account_states: AHashMap::new(),
147 min_account_state_logging_interval_ns,
148 venues_missing_price: AHashMap::new(),
149 account_open_positions: AHashMap::new(),
150 equity_curve_accounts: AHashSet::new(),
151 equity_curve_finalized: false,
152 portfolio_snapshots: AHashMap::new(),
153 pre_position_fill_events: AHashSet::new(),
154 balance_error: None,
155 }
156 }
157
158 fn reset(&mut self) {
159 log::debug!("RESETTING");
160 self.net_positions.clear();
161 self.unrealized_pnls.clear();
162 self.realized_pnls.clear();
163 self.recorded_closed_position_cycles.clear();
164 self.snapshot_sum_per_position.clear();
165 self.snapshot_last_per_position.clear();
166 self.snapshot_currency_mismatches.clear();
167 self.snapshot_aggregation_overflows.clear();
168 self.snapshot_processed_counts.clear();
169 self.snapshot_processed_revisions.clear();
170 self.snapshot_account_ids.clear();
171 self.pending_calcs.clear();
172 self.bar_close_prices.clear();
173 self.last_prices.clear();
174 self.last_xrates.clear();
175 self.stale_prices.clear();
176 self.stale_xrates.clear();
177 self.last_account_state_log_ts.clear();
178 self.last_published_account_states.clear();
179 self.venues_missing_price.clear();
180 self.account_open_positions.clear();
181 self.equity_curve_accounts.clear();
182 self.equity_curve_finalized = false;
183 self.portfolio_snapshots.clear();
184 self.pre_position_fill_events.clear();
185 self.balance_error = None;
186 self.analyzer.reset();
187 self.initialized = false;
188 log::debug!("READY");
189 }
190}
191
192pub struct Portfolio {
193 pub(crate) clock: Rc<RefCell<dyn Clock>>,
194 pub(crate) cache: Rc<RefCell<Cache>>,
195 inner: Rc<RefCell<PortfolioState>>,
196 config: PortfolioConfig,
197}
198
199impl Debug for Portfolio {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct(stringify!(Portfolio)).finish()
202 }
203}
204
205impl Portfolio {
206 pub fn new(
207 clock: Rc<RefCell<dyn Clock>>,
208 cache: Rc<RefCell<Cache>>,
209 config: Option<PortfolioConfig>,
210 ) -> Self {
211 let config = config.unwrap_or_default();
212 let inner = Rc::new(RefCell::new(PortfolioState::new(
213 Rc::clone(&clock),
214 Rc::clone(&cache),
215 &config,
216 )));
217
218 Self::register_message_handlers(&cache, &clock, &inner, config);
219
220 Self {
221 clock,
222 cache,
223 inner,
224 config,
225 }
226 }
227
228 #[must_use]
233 pub fn clone_shallow(&self) -> Self {
234 Self {
235 clock: Rc::clone(&self.clock),
236 cache: Rc::clone(&self.cache),
237 inner: Rc::clone(&self.inner),
238 config: self.config,
239 }
240 }
241
242 fn register_message_handlers(
243 cache: &Rc<RefCell<Cache>>,
244 clock: &Rc<RefCell<dyn Clock>>,
245 inner: &Rc<RefCell<PortfolioState>>,
246 config: PortfolioConfig,
247 ) {
248 let inner_weak = WeakCell::from(Rc::downgrade(inner));
249
250 let update_account_handler = {
252 let cache = Rc::clone(cache);
253 let clock = Rc::clone(clock);
254 let inner = WeakCell::clone(&inner_weak);
255
256 TypedHandler::from(move |event: &AccountState| {
257 if let Some(inner_rc) = inner.upgrade() {
258 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
259 update_account(&clock, &cache, &inner_rc, config, event);
260 }
261 })
262 };
263
264 let update_position_handler = {
265 let cache = Rc::clone(cache);
266 let clock = Rc::clone(clock);
267 let inner = WeakCell::clone(&inner_weak);
268 TypedHandler::from(move |event: &PositionEvent| {
269 if let Some(inner_rc) = inner.upgrade() {
270 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
271 update_position(&cache, &clock, &inner_rc, config, event);
272 }
273 })
274 };
275
276 let update_quote_handler = {
277 let cache = Rc::clone(cache);
278 let clock = Rc::clone(clock);
279 let inner = WeakCell::clone(&inner_weak);
280 TypedHandler::from(move |quote: &QuoteTick| {
281 if let Some(inner_rc) = inner.upgrade() {
282 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
283 update_quote_tick(&cache, &clock, &inner_rc, config, quote);
284 }
285 })
286 };
287
288 let update_bar_handler = {
289 let cache = Rc::clone(cache);
290 let clock = Rc::clone(clock);
291 let inner = WeakCell::clone(&inner_weak);
292 TypedHandler::from(move |bar: &Bar| {
293 if let Some(inner_rc) = inner.upgrade() {
294 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
295 update_bar(&cache, &clock, &inner_rc, config, bar);
296 }
297 })
298 };
299
300 let update_mark_price_handler = {
301 let cache = Rc::clone(cache);
302 let clock = Rc::clone(clock);
303 let inner = WeakCell::clone(&inner_weak);
304 TypedHandler::from(move |mark_price: &MarkPriceUpdate| {
305 if let Some(inner_rc) = inner.upgrade() {
306 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
307 update_instrument_id(
308 &cache,
309 &clock,
310 &inner_rc,
311 config,
312 &mark_price.instrument_id,
313 );
314 }
315 })
316 };
317
318 let update_order_handler = {
319 let cache = Rc::clone(cache);
320 let inner = WeakCell::clone(&inner_weak);
321 TypedHandler::from(move |event: &OrderEventAny| {
322 if let Some(inner_rc) = inner.upgrade() {
323 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
324 on_order_event(&cache, &inner_rc, event);
325 }
326 })
327 };
328
329 let endpoint = MessagingSwitchboard::portfolio_update_account();
330 msgbus::register_account_state_endpoint(endpoint, update_account_handler.clone());
331
332 let update_order_endpoint_handler = {
333 let cache = Rc::clone(cache);
334 let clock = Rc::clone(clock);
335 let inner = inner_weak;
336 TypedIntoHandler::from(move |event: OrderEventAny| {
337 if let Some(inner_rc) = inner.upgrade() {
338 let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
339 update_order(
340 &cache,
341 &clock,
342 &inner_rc,
343 config,
344 &event,
345 OrderUpdateSource::Endpoint,
346 );
347 }
348 })
349 };
350 msgbus::register_order_event_endpoint(
351 MessagingSwitchboard::portfolio_update_order(),
352 update_order_endpoint_handler,
353 );
354
355 msgbus::subscribe_quotes("data.quotes.*".into(), update_quote_handler, Some(10));
356
357 if config.bar_updates {
358 msgbus::subscribe_bars("data.bars.*EXTERNAL".into(), update_bar_handler, Some(10));
359 }
360
361 if config.use_mark_prices {
362 msgbus::subscribe_mark_prices(
363 "data.mark_prices.*".into(),
364 update_mark_price_handler,
365 Some(10),
366 );
367 }
368 msgbus::subscribe_order_events("events.order.*".into(), update_order_handler, Some(10));
369 msgbus::subscribe_position_events(
370 "events.position.*".into(),
371 update_position_handler,
372 Some(10),
373 );
374 msgbus::subscribe_account_state(
375 "events.account.*".into(),
376 update_account_handler,
377 Some(10),
378 );
379 }
380
381 pub fn reset(&mut self) {
382 log::debug!("RESETTING");
383 let (snapshot_accounts, equity_curve_accounts) = {
384 let inner = self.inner.borrow();
385 (
386 inner
387 .account_open_positions
388 .keys()
389 .copied()
390 .collect::<Vec<_>>(),
391 inner
392 .equity_curve_accounts
393 .iter()
394 .copied()
395 .collect::<Vec<_>>(),
396 )
397 };
398
399 for account_id in snapshot_accounts {
400 self.clock
401 .borrow_mut()
402 .cancel_timer(&snapshot_timer_name(account_id));
403 }
404
405 for account_id in equity_curve_accounts {
406 self.clock
407 .borrow_mut()
408 .cancel_timer(&equity_curve_timer_name(account_id));
409 }
410 self.inner.borrow_mut().reset();
411 log::debug!("READY");
412 }
413
414 #[must_use]
416 pub fn cache(&self) -> &Rc<RefCell<Cache>> {
417 &self.cache
418 }
419
420 #[must_use]
422 pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
423 &self.clock
424 }
425
426 #[must_use]
428 pub fn is_initialized(&self) -> bool {
429 self.inner.borrow().initialized
430 }
431
432 #[must_use]
438 pub fn balances_locked(
439 &self,
440 venue: &Venue,
441 account_id: Option<&AccountId>,
442 ) -> IndexMap<Currency, Money> {
443 let cache = self.cache.borrow();
444 resolve_account(&cache, Some(venue), account_id).map_or_else(
445 || {
446 log::error!(
447 "Cannot get balances locked: no account resolved for venue={venue}, account_id={account_id:?}"
448 );
449 IndexMap::new()
450 },
451 |account| account.balances_locked(),
452 )
453 }
454
455 #[must_use]
462 pub fn instrument_initial_margins(
463 &self,
464 venue: &Venue,
465 account_id: Option<&AccountId>,
466 ) -> IndexMap<InstrumentId, Money> {
467 let cache = self.cache.borrow();
468 resolve_account(&cache, Some(venue), account_id).map_or_else(
469 || {
470 log::error!(
471 "Cannot get initial (order) margins: no account resolved for venue={venue}, account_id={account_id:?}"
472 );
473 IndexMap::new()
474 },
475 |account| match &*account {
476 AccountAny::Margin(margin_account) => margin_account.initial_margins(),
477 AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => {
478 log::warn!("Initial margins not applicable for unleveraged account");
479 IndexMap::new()
480 }
481 },
482 )
483 }
484
485 #[must_use]
492 pub fn instrument_maintenance_margins(
493 &self,
494 venue: &Venue,
495 account_id: Option<&AccountId>,
496 ) -> IndexMap<InstrumentId, Money> {
497 let cache = self.cache.borrow();
498 resolve_account(&cache, Some(venue), account_id).map_or_else(
499 || {
500 log::error!(
501 "Cannot get maintenance (position) margins: no account resolved for venue={venue}, account_id={account_id:?}"
502 );
503 IndexMap::new()
504 },
505 |account| match &*account {
506 AccountAny::Margin(margin_account) => margin_account.maintenance_margins(),
507 AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => {
508 log::warn!("Maintenance margins not applicable for unleveraged account");
509 IndexMap::new()
510 }
511 },
512 )
513 }
514
515 #[must_use]
519 pub fn unrealized_pnls(
520 &mut self,
521 venue: &Venue,
522 account_id: Option<&AccountId>,
523 target_currency: Option<Currency>,
524 ) -> Option<IndexMap<Currency, Money>> {
525 let (unrealized_pnls, unpriced) =
526 self.unrealized_pnls_with_missing(*venue, account_id, target_currency)?;
527
528 if account_id.is_some() {
529 self.update_missing_price_state(*venue, account_id.copied(), &unpriced);
530 }
531
532 if unpriced.is_empty() {
533 Some(unrealized_pnls)
534 } else {
535 None
536 }
537 }
538
539 fn unrealized_pnls_with_missing(
540 &self,
541 venue: Venue,
542 account_id: Option<&AccountId>,
543 target_currency: Option<Currency>,
544 ) -> Option<(IndexMap<Currency, Money>, AHashSet<InstrumentId>)> {
545 let instrument_ids = {
546 let cache = self.cache.borrow();
547 let positions = cache.positions_open(Some(&venue), None, None, account_id, None);
548
549 if positions.is_empty() {
550 return Some((IndexMap::new(), AHashSet::new()));
551 }
552
553 let instrument_ids: IndexSet<InstrumentId> =
557 positions.iter().map(|p| p.instrument_id).collect();
558
559 instrument_ids
560 };
561
562 let mut unrealized_pnls: IndexMap<Currency, Money> = IndexMap::new();
563 let mut unpriced: AHashSet<InstrumentId> = AHashSet::new();
564
565 for instrument_id in instrument_ids {
566 match self.unrealized_pnls_by_account(&instrument_id, None, account_id, target_currency)
567 {
568 Ok(pnls) => {
569 for pnl in pnls {
570 checked_add_money_map(&mut unrealized_pnls, pnl, "unrealized PnLs")?;
571 }
572 }
573 Err(UnrealizedPnlError::MissingInput) => {
574 unpriced.insert(instrument_id);
575 }
576 Err(UnrealizedPnlError::Invalid) => return None,
577 }
578 }
579
580 Some((unrealized_pnls, unpriced))
581 }
582
583 #[must_use]
587 pub fn realized_pnls(
588 &mut self,
589 venue: &Venue,
590 account_id: Option<&AccountId>,
591 target_currency: Option<Currency>,
592 ) -> Option<IndexMap<Currency, Money>> {
593 let instrument_ids = {
594 let cache = self.cache.borrow();
595 let positions = cache.positions(Some(venue), None, None, account_id, None);
596
597 if positions.is_empty() {
598 return Some(IndexMap::new()); }
600
601 let instrument_ids: IndexSet<InstrumentId> =
602 positions.iter().map(|p| p.instrument_id).collect();
603
604 instrument_ids
605 };
606
607 let mut realized_pnls: IndexMap<Currency, Money> = IndexMap::new();
608
609 for instrument_id in instrument_ids {
610 self.ensure_snapshot_pnls_cached_for(&instrument_id);
611 for pnl in self.realized_pnls_by_account(&instrument_id, account_id, target_currency)? {
612 checked_add_money_map(&mut realized_pnls, pnl, "realized PnLs")?;
613 }
614 }
615
616 Some(realized_pnls)
617 }
618
619 #[must_use]
620 pub fn net_exposures(
621 &self,
622 venue: &Venue,
623 account_id: Option<&AccountId>,
624 target_currency: Option<Currency>,
625 ) -> Option<IndexMap<Currency, Money>> {
626 let cache = self.cache.borrow();
627
628 if let Some(id) = account_id
629 && cache.account(id).is_none()
630 {
631 log::error!("Cannot calculate net exposures: no account for {id}");
632 return None;
633 }
634
635 if account_id.is_none() && cache.account_for_venue(venue).is_none() {
636 let has_position_account = cache
637 .positions(Some(venue), None, None, None, None)
638 .iter()
639 .any(|position| cache.account(&position.account_id).is_some());
640 if !has_position_account {
641 log::error!("Cannot calculate net exposures: no account registered for {venue}");
642 return None;
643 }
644 }
645
646 let instrument_ids: IndexSet<InstrumentId> = {
647 let positions_open = cache.positions_open(Some(venue), None, None, account_id, None);
648 if positions_open.is_empty() {
649 return Some(IndexMap::new()); }
651 positions_open
652 .iter()
653 .map(|position| position.instrument_id)
654 .collect()
655 };
656 drop(cache);
657
658 let mut net_exposures = IndexMap::new();
659
660 for instrument_id in instrument_ids {
661 let exposure = self.net_exposure(&instrument_id, None, account_id, target_currency)?;
662 if exposure.is_zero() {
663 continue;
664 }
665 checked_add_money_map(&mut net_exposures, exposure, "net exposures")?;
666 }
667
668 Some(net_exposures)
669 }
670
671 #[must_use]
672 pub fn unrealized_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
673 self.unrealized_pnl_for_account(instrument_id, None, None, None)
674 }
675
676 #[must_use]
677 pub fn unrealized_pnl_for_account(
678 &mut self,
679 instrument_id: &InstrumentId,
680 price: Option<Price>,
681 account_id: Option<&AccountId>,
682 target_currency: Option<Currency>,
683 ) -> Option<Money> {
684 let use_cache = price.is_none() && account_id.is_none() && target_currency.is_none();
685 if use_cache {
686 let has_open_position = !self
687 .cache
688 .borrow()
689 .positions_open(None, Some(instrument_id), None, None, None)
690 .is_empty();
691
692 if !has_open_position
693 && let Some(pnl) = self
694 .inner
695 .borrow()
696 .unrealized_pnls
697 .get(instrument_id)
698 .copied()
699 {
700 return Some(pnl);
701 }
702 }
703
704 let pnl = self
705 .aggregate_unrealized_pnl_by_account(instrument_id, price, account_id, target_currency)
706 .ok()?;
707
708 if use_cache {
709 self.inner
710 .borrow_mut()
711 .unrealized_pnls
712 .insert(*instrument_id, pnl);
713 }
714 Some(pnl)
715 }
716
717 #[must_use]
718 pub fn realized_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
719 self.realized_pnl_for_account(instrument_id, None, None)
720 }
721
722 #[must_use]
723 pub fn realized_pnl_for_account(
724 &mut self,
725 instrument_id: &InstrumentId,
726 account_id: Option<&AccountId>,
727 target_currency: Option<Currency>,
728 ) -> Option<Money> {
729 self.ensure_snapshot_pnls_cached_for(instrument_id);
730
731 let use_cache = account_id.is_none() && target_currency.is_none();
732 let pnl =
733 self.aggregate_realized_pnl_by_account(instrument_id, account_id, target_currency)?;
734
735 if use_cache {
736 self.inner
737 .borrow_mut()
738 .realized_pnls
739 .insert(*instrument_id, pnl);
740 }
741 Some(pnl)
742 }
743
744 #[must_use]
748 pub fn total_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
749 self.total_pnl_for_account(instrument_id, None, None, None)
750 }
751
752 #[must_use]
753 pub fn total_pnl_for_account(
754 &mut self,
755 instrument_id: &InstrumentId,
756 price: Option<Price>,
757 account_id: Option<&AccountId>,
758 target_currency: Option<Currency>,
759 ) -> Option<Money> {
760 let realized = self.realized_pnl_for_account(instrument_id, account_id, target_currency)?;
761 let unrealized =
762 self.unrealized_pnl_for_account(instrument_id, price, account_id, target_currency)?;
763
764 checked_add_money(realized, unrealized, "total PnL")
765 }
766
767 #[must_use]
772 pub fn total_pnls(
773 &mut self,
774 venue: &Venue,
775 account_id: Option<&AccountId>,
776 target_currency: Option<Currency>,
777 ) -> Option<IndexMap<Currency, Money>> {
778 let realized_pnls = self.realized_pnls(venue, account_id, target_currency)?;
779 let unrealized_pnls = self.unrealized_pnls(venue, account_id, target_currency)?;
780
781 let mut total_pnls = realized_pnls;
782 for unrealized in unrealized_pnls.into_values() {
783 checked_add_money_map(&mut total_pnls, unrealized, "total PnLs")?;
784 }
785
786 Some(total_pnls)
787 }
788
789 #[must_use]
798 pub fn mark_values(
799 &mut self,
800 venue: &Venue,
801 account_id: Option<&AccountId>,
802 ) -> IndexMap<Currency, Money> {
803 self.mark_values_with_mode(*venue, account_id, MarkValueMode::Gross)
804 }
805
806 #[must_use]
820 pub fn equity(
821 &mut self,
822 venue: &Venue,
823 account_id: Option<&AccountId>,
824 ) -> IndexMap<Currency, Money> {
825 let (mut equity, is_margin) = {
826 let cache = self.cache.borrow();
827 let account = match account_id {
828 Some(id) => cache.account(id),
829 None => cache.account_for_venue(venue).or_else(|| {
830 cache
831 .positions(Some(venue), None, None, None, None)
832 .into_iter()
833 .next()
834 .and_then(|p| cache.account(&p.account_id))
835 }),
836 };
837
838 match account {
839 Some(account) => {
840 let equity: IndexMap<Currency, Decimal> = account
841 .balances_total()
842 .into_iter()
843 .map(|(c, m)| (c, m.as_decimal()))
844 .collect();
845 (equity, matches!(&*account, AccountAny::Margin(_)))
846 }
847 None => return IndexMap::new(),
848 }
849 };
850
851 let mut unpriced: AHashSet<InstrumentId> = AHashSet::new();
852 if is_margin {
853 if self.accumulate_margin_equity(*venue, account_id, &mut equity, &mut unpriced) {
854 self.update_missing_price_state(*venue, account_id.copied(), &unpriced);
855 } else {
856 self.clear_missing_price_state(*venue, account_id.copied());
857 }
858 } else if self.accumulate_mark_values(
859 *venue,
860 account_id,
861 &mut equity,
862 &mut unpriced,
863 MarkValueMode::Equity,
864 ) {
865 self.update_missing_price_state(*venue, account_id.copied(), &unpriced);
866 } else {
867 self.clear_missing_price_state(*venue, account_id.copied());
868 }
869
870 decimal_map_to_money(equity)
871 }
872
873 #[must_use]
884 pub fn build_snapshot(&mut self, account_id: &AccountId) -> Option<PortfolioSnapshot> {
885 self.build_snapshot_inner(*account_id, true)
886 }
887
888 fn build_snapshot_inner(
889 &mut self,
890 account_id: AccountId,
891 track_missing_prices: bool,
892 ) -> Option<PortfolioSnapshot> {
893 let account_id = &account_id;
894 let account = self
897 .cache
898 .borrow()
899 .account_ref(account_id)
900 .map(|account| account.clone_without_events())?;
901
902 let balances: Vec<AccountBalance> = account.balances().into_values().collect();
903 let margins: Vec<MarginBalance> = match &account {
904 AccountAny::Margin(m) => m
905 .margins
906 .values()
907 .copied()
908 .chain(m.account_margins.values().copied())
909 .collect(),
910 AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => Vec::new(),
911 };
912
913 let (open_venues, open_instrument_ids, open_price_keys) = {
918 let cache = self.cache.borrow();
919 let positions = cache.positions_open(None, None, None, Some(account_id), None);
920 let venues: AHashSet<Venue> = positions
921 .iter()
922 .map(|position| position.instrument_id.venue)
923 .collect();
924 let instrument_ids: AHashSet<InstrumentId> = positions
925 .iter()
926 .map(|position| position.instrument_id)
927 .collect();
928 let price_keys: AHashSet<(InstrumentId, PositionSide)> = positions
929 .iter()
930 .map(|position| (position.instrument_id, position.side))
931 .collect();
932 (venues, instrument_ids, price_keys)
933 };
934 let all_venues: AHashSet<Venue> = self
935 .cache
936 .borrow()
937 .positions(None, None, None, Some(account_id), None)
938 .iter()
939 .map(|p| p.instrument_id.venue)
940 .collect();
941 let mut unrealized: IndexMap<Currency, Money> = IndexMap::new();
942 let mut realized: IndexMap<Currency, Money> = IndexMap::new();
943 let mut snapshot_unpriced = AHashSet::new();
944
945 for venue in &open_venues {
946 let (unrealized_pnls, venue_unpriced) =
947 self.unrealized_pnls_with_missing(*venue, Some(account_id), None)?;
948
949 if track_missing_prices {
950 self.update_missing_price_state(*venue, Some(*account_id), &venue_unpriced);
951 }
952 snapshot_unpriced.extend(venue_unpriced);
953
954 for money in unrealized_pnls.into_values() {
955 checked_add_money_map(&mut unrealized, money, "snapshot unrealized PnL")?;
956 }
957 }
958
959 for venue in &all_venues {
960 let realized_pnls = match self.realized_pnls(venue, Some(account_id), None) {
961 Some(pnls) => pnls,
962 None if !self.has_nonzero_realized_pnl(*venue, *account_id) => IndexMap::new(),
963 None => return None,
964 };
965
966 for money in realized_pnls.into_values() {
967 checked_add_money_map(&mut realized, money, "snapshot realized PnL")?;
968 }
969 }
970
971 let equity = self.calculate_snapshot_equity(
972 &account,
973 &open_venues,
974 &unrealized,
975 &mut snapshot_unpriced,
976 track_missing_prices,
977 )?;
978
979 let base_currency_equity = if self.config.convert_to_account_base_currency {
980 account
981 .base_currency()
982 .and_then(|currency| equity.get(¤cy).copied())
983 } else {
984 None
985 };
986 let (mut stale_instruments, mut stale_currencies, mut unpriced_instruments) = {
987 let inner = self.inner.borrow();
988 let stale_instruments = inner
989 .stale_prices
990 .iter()
991 .filter(|key| open_price_keys.contains(key))
992 .map(|(instrument_id, _)| *instrument_id)
993 .collect::<AHashSet<_>>()
994 .into_iter()
995 .collect::<Vec<_>>();
996 let stale_currencies = account
997 .base_currency()
998 .map_or_else(Vec::new, |base_currency| {
999 inner
1000 .stale_xrates
1001 .iter()
1002 .filter(|(venue, _, target)| {
1003 open_venues.contains(venue) && *target == base_currency
1004 })
1005 .map(|(_, source, _)| *source)
1006 .collect::<AHashSet<_>>()
1007 .into_iter()
1008 .collect()
1009 });
1010 let unpriced_instruments: Vec<InstrumentId> = snapshot_unpriced
1011 .iter()
1012 .filter(|instrument_id| open_instrument_ids.contains(instrument_id))
1013 .copied()
1014 .collect();
1015 (stale_instruments, stale_currencies, unpriced_instruments)
1016 };
1017 stale_instruments.sort_unstable();
1018 stale_currencies.sort_unstable_by_key(|currency| currency.code);
1019 unpriced_instruments.sort_unstable();
1020 let is_stale = !stale_instruments.is_empty()
1021 || !stale_currencies.is_empty()
1022 || !unpriced_instruments.is_empty();
1023
1024 let unrealized_pnls: Vec<Money> = unrealized.into_values().collect();
1025 let realized_pnls: Vec<Money> = realized.into_values().collect();
1026 let total_equity: Vec<Money> = equity.into_values().collect();
1027
1028 let ts_now = self.clock.borrow().timestamp_ns();
1029
1030 Some(PortfolioSnapshot::new(
1031 account.id(),
1032 account.account_type(),
1033 account.base_currency(),
1034 balances,
1035 margins,
1036 unrealized_pnls,
1037 realized_pnls,
1038 total_equity,
1039 base_currency_equity,
1040 is_stale,
1041 stale_instruments,
1042 stale_currencies,
1043 unpriced_instruments,
1044 UUID4::new(),
1045 ts_now,
1046 ts_now,
1047 ))
1048 }
1049
1050 fn calculate_snapshot_equity(
1051 &self,
1052 account: &AccountAny,
1053 open_venues: &AHashSet<Venue>,
1054 unrealized: &IndexMap<Currency, Money>,
1055 snapshot_unpriced: &mut AHashSet<InstrumentId>,
1056 track_missing_prices: bool,
1057 ) -> Option<IndexMap<Currency, Money>> {
1058 let account_id = account.id();
1059 let mut equity: IndexMap<Currency, Money> = account.balances_total().into_iter().collect();
1060
1061 if matches!(account, AccountAny::Margin(_)) && !account.calculated_account_state() {
1062 for value in unrealized.values() {
1063 checked_add_money_map(&mut equity, *value, "snapshot equity")?;
1064 }
1065
1066 return Some(equity);
1067 }
1068
1069 let mut margin_equity = matches!(account, AccountAny::Margin(_)).then(|| {
1070 equity
1071 .iter()
1072 .map(|(currency, money)| (*currency, money.as_decimal()))
1073 .collect::<IndexMap<Currency, Decimal>>()
1074 });
1075
1076 for venue in open_venues {
1077 let mut values = IndexMap::new();
1078 let mut venue_unpriced = AHashSet::new();
1079
1080 match account {
1081 AccountAny::Margin(_) => {
1082 self.accumulate_margin_equity(
1083 *venue,
1084 Some(&account_id),
1085 margin_equity.as_mut().unwrap_or(&mut values),
1086 &mut venue_unpriced,
1087 );
1088 }
1089 AccountAny::Cash(_) | AccountAny::Betting(_) | AccountAny::Wallet(_) => {
1090 self.accumulate_mark_values(
1091 *venue,
1092 Some(&account_id),
1093 &mut values,
1094 &mut venue_unpriced,
1095 MarkValueMode::Equity,
1096 );
1097 }
1098 }
1099
1100 if track_missing_prices {
1101 self.update_missing_price_state(*venue, Some(account_id), &venue_unpriced);
1102 }
1103
1104 if margin_equity.is_none() {
1105 for money in decimal_map_to_money(values).into_values() {
1106 checked_add_money_map(&mut equity, money, "snapshot equity")?;
1107 }
1108 }
1109
1110 snapshot_unpriced.extend(venue_unpriced);
1111 }
1112
1113 if let Some(margin_equity) = margin_equity {
1114 equity = margin_equity
1115 .into_iter()
1116 .map(|(currency, value)| {
1117 Money::from_decimal(value, currency).map(|money| (currency, money))
1118 })
1119 .collect::<Result<IndexMap<_, _>, _>>()
1120 .inspect_err(|e| log::error!("Cannot calculate snapshot equity: {e}"))
1121 .ok()?;
1122 }
1123
1124 Some(equity)
1125 }
1126
1127 fn has_nonzero_realized_pnl(&self, venue: Venue, account_id: AccountId) -> bool {
1128 let cache = self.cache.borrow();
1129 cache
1130 .positions(Some(&venue), None, None, Some(&account_id), None)
1131 .iter()
1132 .any(|position| position.realized_pnl.is_some_and(|pnl| !pnl.is_zero()))
1133 || cache
1134 .position_snapshots(None, Some(&account_id))
1135 .iter()
1136 .any(|position| {
1137 position.instrument_id.venue == venue
1138 && position.realized_pnl.is_some_and(|pnl| !pnl.is_zero())
1139 })
1140 }
1141
1142 #[must_use]
1150 pub fn snapshots(&self, account_id: &AccountId) -> Vec<PortfolioSnapshot> {
1151 self.inner
1152 .borrow()
1153 .portfolio_snapshots
1154 .get(account_id)
1155 .map(|ring| ring.iter().cloned().collect())
1156 .unwrap_or_default()
1157 }
1158
1159 #[doc(hidden)]
1164 #[must_use]
1165 pub fn balance_error(&self) -> Option<String> {
1166 self.inner.borrow().balance_error.clone()
1167 }
1168
1169 pub fn finalize_equity_curve(&mut self) {
1174 if !self.config.equity_curve {
1175 return;
1176 }
1177
1178 let account_ids = {
1179 let mut inner = self.inner.borrow_mut();
1180 if inner.equity_curve_finalized {
1181 return;
1182 }
1183 inner.equity_curve_finalized = true;
1184 inner
1185 .equity_curve_accounts
1186 .iter()
1187 .copied()
1188 .collect::<Vec<_>>()
1189 };
1190 let ts_event = self.clock.borrow().timestamp_ns();
1191
1192 for account_id in account_ids {
1193 emit_snapshot(
1194 &self.cache,
1195 &self.clock,
1196 &self.inner,
1197 self.config,
1198 account_id,
1199 ts_event,
1200 );
1201 self.clock
1202 .borrow_mut()
1203 .cancel_timer(&equity_curve_timer_name(account_id));
1204 }
1205 }
1206
1207 #[must_use]
1213 pub fn missing_price_instruments(
1214 &self,
1215 venue: &Venue,
1216 account_id: Option<&AccountId>,
1217 ) -> Vec<InstrumentId> {
1218 let inner = self.inner.borrow();
1219 let observations = inner.venues_missing_price.get(venue);
1220 let mut ids: Vec<InstrumentId> = match account_id {
1221 Some(account_id) => observations
1222 .and_then(|observations| observations.get(&Some(*account_id)))
1223 .map(|ids| ids.iter().copied().collect())
1224 .unwrap_or_default(),
1225 None => observations
1226 .map(|observations| {
1227 observations
1228 .values()
1229 .flat_map(|ids| ids.iter().copied())
1230 .collect::<AHashSet<_>>()
1231 .into_iter()
1232 .collect()
1233 })
1234 .unwrap_or_default(),
1235 };
1236 ids.sort();
1239 ids
1240 }
1241
1242 fn update_missing_price_state(
1243 &self,
1244 venue: Venue,
1245 account_id: Option<AccountId>,
1246 unpriced: &AHashSet<InstrumentId>,
1247 ) {
1248 let mut inner = self.inner.borrow_mut();
1249 let tracked: AHashSet<InstrumentId> = inner
1250 .venues_missing_price
1251 .get(&venue)
1252 .into_iter()
1253 .flat_map(|observations| observations.values())
1254 .flatten()
1255 .copied()
1256 .collect();
1257
1258 let mut ids: Vec<InstrumentId> = unpriced.iter().copied().collect();
1260 ids.sort();
1261 for instrument_id in ids {
1262 if !tracked.contains(&instrument_id) {
1263 log::warn!(
1264 "Cannot value open position {instrument_id}; ensure its notional inputs are \
1265 valid and subscribe to quotes, trades, or bars for continuous mark-to-market \
1266 equity"
1267 );
1268 }
1269 }
1270
1271 let remove_venue = {
1272 let observations = inner.venues_missing_price.entry(venue).or_default();
1273 if unpriced.is_empty() {
1274 observations.remove(&account_id);
1275 } else {
1276 observations.insert(account_id, unpriced.clone());
1277 }
1278 observations.is_empty()
1279 };
1280
1281 if remove_venue {
1282 inner.venues_missing_price.remove(&venue);
1283 }
1284 }
1285
1286 fn clear_missing_price_state(&self, venue: Venue, account_id: Option<AccountId>) {
1287 let mut inner = self.inner.borrow_mut();
1288 let Some(account_id) = account_id else {
1289 inner.venues_missing_price.remove(&venue);
1290 return;
1291 };
1292 let remove_venue = if let Some(observations) = inner.venues_missing_price.get_mut(&venue) {
1293 observations.remove(&Some(account_id));
1294 observations.is_empty()
1295 } else {
1296 false
1297 };
1298
1299 if remove_venue {
1300 inner.venues_missing_price.remove(&venue);
1301 }
1302 }
1303
1304 fn mark_values_with_mode(
1305 &self,
1306 venue: Venue,
1307 account_id: Option<&AccountId>,
1308 mode: MarkValueMode,
1309 ) -> IndexMap<Currency, Money> {
1310 let mut values: IndexMap<Currency, Decimal> = IndexMap::new();
1311 let mut unpriced: AHashSet<InstrumentId> = AHashSet::new();
1312
1313 if self.accumulate_mark_values(venue, account_id, &mut values, &mut unpriced, mode) {
1314 self.update_missing_price_state(venue, account_id.copied(), &unpriced);
1315 } else {
1316 self.clear_missing_price_state(venue, account_id.copied());
1317 }
1318
1319 decimal_map_to_money(values)
1320 }
1321
1322 fn accumulate_margin_equity(
1323 &self,
1324 venue: Venue,
1325 account_id: Option<&AccountId>,
1326 values: &mut IndexMap<Currency, Decimal>,
1327 unpriced: &mut AHashSet<InstrumentId>,
1328 ) -> bool {
1329 let mut instrument_ids = IndexSet::new();
1330 let mut instrument_accounts_reported = IndexSet::new();
1331 let mut has_premium = false;
1332
1333 let has_positions = {
1334 let cache = self.cache.borrow();
1335 let positions = cache.positions_open(Some(&venue), None, None, account_id, None);
1336 for position in &positions {
1337 if position_uses_premium_mark(position, &cache) {
1338 has_premium = true;
1339 } else if position.instrument_class.is_premium_based() {
1340 instrument_accounts_reported
1341 .insert((position.instrument_id, position.account_id));
1342 } else {
1343 instrument_ids.insert(position.instrument_id);
1344 }
1345 }
1346
1347 !positions.is_empty()
1348 };
1349
1350 if has_premium {
1351 self.accumulate_mark_values(
1352 venue,
1353 account_id,
1354 values,
1355 unpriced,
1356 MarkValueMode::Premium,
1357 );
1358 }
1359
1360 let instruments = instrument_ids
1361 .into_iter()
1362 .map(|id| (id, account_id.copied()))
1363 .chain(
1364 instrument_accounts_reported
1365 .into_iter()
1366 .map(|(id, account)| (id, Some(account))),
1367 );
1368
1369 for (instrument_id, pnl_account_id) in instruments {
1370 let cached = pnl_account_id
1372 .is_none()
1373 .then(|| {
1374 self.inner
1375 .borrow()
1376 .unrealized_pnls
1377 .get(&instrument_id)
1378 .copied()
1379 })
1380 .flatten();
1381
1382 let pnl = cached.or_else(|| {
1383 self.calculate_unrealized_pnl(&instrument_id, None, pnl_account_id.as_ref(), None)
1384 });
1385
1386 match pnl {
1387 Some(pnl) => {
1388 *values.entry(pnl.currency).or_insert(Decimal::ZERO) += pnl.as_decimal();
1389 }
1390 None => {
1391 unpriced.insert(instrument_id);
1392 }
1393 }
1394 }
1395
1396 has_positions
1397 }
1398
1399 fn accumulate_mark_values(
1403 &self,
1404 venue: Venue,
1405 account_id: Option<&AccountId>,
1406 values: &mut IndexMap<Currency, Decimal>,
1407 unpriced: &mut AHashSet<InstrumentId>,
1408 mode: MarkValueMode,
1409 ) -> bool {
1410 let cache = self.cache.borrow();
1411 let mut positions = cache.positions_open(Some(&venue), None, None, account_id, None);
1412
1413 if positions.is_empty() {
1414 return false;
1415 }
1416
1417 let valuation_account = account_id.map_or_else(
1418 || {
1419 cache
1420 .account_for_venue(&venue)
1421 .or_else(|| positions.first().and_then(|p| cache.account(&p.account_id)))
1422 },
1423 |id| cache.account(id),
1424 );
1425
1426 let equity_account_id = if mode == MarkValueMode::Equity {
1427 valuation_account.as_ref().map(|a| a.id())
1428 } else {
1429 None
1430 };
1431 let mut xrate_cache: AHashMap<Currency, Option<Decimal>> = AHashMap::new();
1432
1433 positions
1434 .retain(|p| mode != MarkValueMode::Premium || position_uses_premium_mark(p, &cache));
1435
1436 for position in positions {
1437 let sign = match position.side {
1438 PositionSide::Long => Decimal::ONE,
1439 PositionSide::Short => Decimal::NEGATIVE_ONE,
1440 PositionSide::Flat => continue,
1441 };
1442
1443 let instrument = match cache.instrument(&position.instrument_id) {
1444 Some(i) => i,
1445 None => {
1446 unpriced.insert(position.instrument_id);
1447 continue;
1448 }
1449 };
1450
1451 let position_account = cache.account(&position.account_id);
1452 let base_currency_is_credited = mode == MarkValueMode::Equity
1453 && equity_account_id == Some(position.account_id)
1454 && position_account.as_ref().is_some_and(|account| {
1455 matches!(&**account, AccountAny::Cash(_) | AccountAny::Wallet(_))
1456 && account.base_currency().is_none()
1457 && position.base_currency.is_some_and(|base| {
1458 position.settlement_currency != base
1459 && account.balances().contains_key(&base)
1460 })
1461 });
1462
1463 if base_currency_is_credited {
1464 continue;
1465 }
1466
1467 let price = match self.get_price(&position) {
1468 Some(p) => p,
1469 None => {
1470 unpriced.insert(position.instrument_id);
1471 continue;
1472 }
1473 };
1474
1475 let notional = match position.try_notional_value(price) {
1476 Ok(notional) => notional,
1477 Err(e) => {
1478 log::error!(
1479 "Cannot calculate mark value: invalid notional value for {}: {e}",
1480 position.instrument_id
1481 );
1482 unpriced.insert(position.instrument_id);
1483 continue;
1484 }
1485 };
1486 let cost_currency = notional.currency;
1487
1488 let conversion = valuation_account.as_ref().and_then(|account| {
1489 self.conversion_base_currency(account)
1490 .map(|currency| (account, currency))
1491 });
1492
1493 let (xrate, currency) = if let Some((account, base_currency)) = conversion {
1494 let xrate_opt = *xrate_cache.entry(cost_currency).or_insert_with(|| {
1495 self.calculate_xrate_to_base(instrument, account, cost_currency)
1496 });
1497 let xrate = match xrate_opt {
1498 Some(x) => x,
1499 None => {
1500 unpriced.insert(position.instrument_id);
1501 continue;
1502 }
1503 };
1504 (xrate, base_currency)
1505 } else {
1506 (Decimal::ONE, cost_currency)
1507 };
1508
1509 let value = notional.as_decimal() * xrate * sign;
1511 *values.entry(currency).or_insert(Decimal::ZERO) += value;
1512 }
1513
1514 true
1515 }
1516
1517 #[must_use]
1518 pub fn net_exposure(
1519 &self,
1520 instrument_id: &InstrumentId,
1521 price: Option<Price>,
1522 account_id: Option<&AccountId>,
1523 target_currency: Option<Currency>,
1524 ) -> Option<Money> {
1525 let cache = self.cache.borrow();
1526
1527 let instrument = if let Some(instrument) = cache.instrument(instrument_id) {
1528 instrument
1529 } else {
1530 log::error!("Cannot calculate net exposure: no instrument for {instrument_id}");
1531 return None;
1532 };
1533
1534 if let Some(account_id) = account_id
1535 && cache.account(account_id).is_none()
1536 {
1537 log::error!("Cannot calculate net exposure: no account for {account_id}");
1538 return None;
1539 }
1540
1541 let positions_open =
1542 cache.positions_open(None, Some(instrument_id), None, account_id, None);
1543
1544 if positions_open.is_empty() {
1545 return Some(Money::zero(
1546 target_currency.unwrap_or_else(|| instrument.cost_currency()),
1547 ));
1548 }
1549
1550 let mut net_exposure = Decimal::ZERO;
1551 let mut output_currency = target_currency;
1552 let mut native_currency: Option<Currency> = None;
1553
1554 for position in &positions_open {
1555 let sign = match position.side {
1556 PositionSide::Long => Decimal::ONE,
1557 PositionSide::Short => Decimal::NEGATIVE_ONE,
1558 PositionSide::Flat => {
1559 log::error!(
1560 "Cannot calculate net exposure: position is flat for {}",
1561 position.instrument_id
1562 );
1563 continue; }
1565 };
1566
1567 let account = if let Some(account) = cache.account(&position.account_id) {
1569 account
1570 } else if account_id.is_none()
1571 && let Some(account) = cache.account_for_venue(&instrument.id().venue)
1572 {
1573 account
1574 } else {
1575 log::error!(
1576 "Cannot calculate net exposure: no account for {}",
1577 position.account_id
1578 );
1579 return None;
1580 };
1581
1582 let base_currency = self.conversion_base_currency(&account);
1583
1584 if target_currency.is_none()
1587 && let Some(base) = base_currency
1588 {
1589 match output_currency {
1590 None => {
1591 output_currency = Some(base);
1592 }
1593 Some(first) if first != base => {
1594 log::error!(
1595 "Cannot calculate net exposure: accounts have different base \
1596 currencies ({first} vs {base}); multi-account aggregation requires \
1597 consistent base currencies"
1598 );
1599 return None;
1600 }
1601 _ => {}
1602 }
1603 }
1604
1605 let price = price.or_else(|| self.get_price(position))?;
1606 let notional_value = match position.try_notional_value(price) {
1607 Ok(notional) => notional,
1608 Err(e) => {
1609 log::error!(
1610 "Cannot calculate net exposure: invalid notional value for {}: {e}",
1611 position.instrument_id
1612 );
1613 return None;
1614 }
1615 };
1616 let source_currency = notional_value.currency;
1617
1618 if target_currency.is_some() {
1619 match native_currency {
1620 None => native_currency = Some(source_currency),
1621 Some(first) if first != source_currency => {
1622 log::error!(
1623 "Cannot calculate net exposure: positions have different cost \
1624 currencies ({first} vs {source_currency})"
1625 );
1626 return None;
1627 }
1628 _ => {}
1629 }
1630
1631 let Some(signed) = notional_value.as_decimal().checked_mul(sign) else {
1632 log::error!("Cannot calculate net exposure: signed notional overflow");
1633 return None;
1634 };
1635 let Some(updated) = net_exposure.checked_add(signed) else {
1636 log::error!("Cannot calculate net exposure: total overflow");
1637 return None;
1638 };
1639 net_exposure = updated;
1640 continue;
1641 }
1642
1643 if base_currency.is_none() {
1644 match output_currency {
1645 None => output_currency = Some(source_currency),
1646 Some(first) if first != source_currency => {
1647 log::error!(
1648 "Cannot calculate net exposure: positions have different cost \
1649 currencies ({first} vs {source_currency})"
1650 );
1651 return None;
1652 }
1653 _ => {}
1654 }
1655 }
1656
1657 let xrate = self.calculate_xrate_to_base(instrument, &account, source_currency);
1658 let xrate = if let Some(xrate) = xrate {
1659 xrate
1660 } else {
1661 log::error!(
1662 "Cannot calculate net exposures: insufficient data for {}/{:?}",
1663 source_currency,
1664 account.base_currency()
1665 );
1666 return None;
1667 };
1668
1669 let Some(converted) = notional_value.as_decimal().checked_mul(xrate) else {
1670 log::error!("Cannot calculate net exposure: currency conversion overflow");
1671 return None;
1672 };
1673 let Some(signed) = converted.checked_mul(sign) else {
1674 log::error!("Cannot calculate net exposure: signed notional overflow");
1675 return None;
1676 };
1677 let Some(updated) = net_exposure.checked_add(signed) else {
1678 log::error!("Cannot calculate net exposure: total overflow");
1679 return None;
1680 };
1681 net_exposure = updated;
1682 }
1683
1684 let mut net_exposure = if net_exposure.is_sign_negative() {
1686 let Some(value) = net_exposure.checked_mul(Decimal::NEGATIVE_ONE) else {
1687 log::error!("Cannot calculate net exposure: absolute value overflow");
1688 return None;
1689 };
1690 value
1691 } else {
1692 net_exposure
1693 };
1694
1695 if let Some(target_currency) = target_currency {
1696 if net_exposure == Decimal::ZERO {
1697 return Some(Money::zero(target_currency));
1698 }
1699
1700 let source_currency = native_currency.unwrap_or_else(|| instrument.cost_currency());
1701 let Some(xrate) = self.calculate_xrate(
1702 instrument.id().venue,
1703 source_currency,
1704 target_currency,
1705 false,
1706 ) else {
1707 log::error!(
1708 "Cannot calculate net exposure: insufficient data for {source_currency}/{target_currency}"
1709 );
1710 return None;
1711 };
1712 let Some(converted) = net_exposure.checked_mul(xrate) else {
1713 log::error!("Cannot calculate net exposure: currency conversion overflow");
1714 return None;
1715 };
1716 net_exposure = converted.round_dp(u32::from(target_currency.precision));
1717 }
1718
1719 let output_currency = output_currency.unwrap_or_else(|| instrument.cost_currency());
1720 match Money::from_decimal(net_exposure, output_currency) {
1721 Ok(money) => Some(money),
1722 Err(e) => {
1723 log::error!("Cannot calculate net exposure: {e}");
1724 None
1725 }
1726 }
1727 }
1728
1729 #[must_use]
1730 pub fn net_position(&self, instrument_id: &InstrumentId) -> Decimal {
1731 self.inner
1732 .borrow()
1733 .net_positions
1734 .get(instrument_id)
1735 .copied()
1736 .unwrap_or(Decimal::ZERO)
1737 }
1738
1739 #[must_use]
1740 pub fn is_net_long(&self, instrument_id: &InstrumentId) -> bool {
1741 self.inner
1742 .borrow()
1743 .net_positions
1744 .get(instrument_id)
1745 .copied()
1746 .map_or_else(|| false, |net_position| net_position > Decimal::ZERO)
1747 }
1748
1749 #[must_use]
1750 pub fn is_net_short(&self, instrument_id: &InstrumentId) -> bool {
1751 self.inner
1752 .borrow()
1753 .net_positions
1754 .get(instrument_id)
1755 .copied()
1756 .map_or_else(|| false, |net_position| net_position < Decimal::ZERO)
1757 }
1758
1759 #[must_use]
1760 pub fn is_net_flat(&self, instrument_id: &InstrumentId) -> bool {
1761 self.inner
1762 .borrow()
1763 .net_positions
1764 .get(instrument_id)
1765 .copied()
1766 .map_or_else(|| true, |net_position| net_position == Decimal::ZERO)
1767 }
1768
1769 #[must_use]
1770 pub fn is_completely_net_flat(&self) -> bool {
1771 for net_position in self.inner.borrow().net_positions.values() {
1772 if *net_position != Decimal::ZERO {
1773 return false;
1774 }
1775 }
1776 true
1777 }
1778
1779 pub fn initialize_orders(&mut self) {
1785 let mut initialized = true;
1786 let orders_and_instruments = {
1787 let cache = self.cache.borrow();
1788
1789 let mut instruments_with_orders = Vec::new();
1790
1791 let mut instruments = BTreeSet::new();
1794
1795 for client_order_id in cache.iter_client_order_ids_open(None, None, None, None) {
1796 if let Some(order) = cache.order(&client_order_id) {
1797 instruments.insert(order.instrument_id());
1798 }
1799 }
1800
1801 for instrument_id in instruments {
1802 if let Some(instrument) = cache.instrument(&instrument_id) {
1803 let orders = cache
1804 .orders_open(None, Some(&instrument_id), None, None, None)
1805 .into_iter()
1806 .map(|order| order.clone())
1807 .collect::<Vec<OrderAny>>();
1808 instruments_with_orders.push((instrument.clone(), orders));
1809 } else {
1810 log::error!(
1811 "Cannot update initial (order) margin: no instrument found for {instrument_id}"
1812 );
1813 initialized = false;
1814 break;
1815 }
1816 }
1817 instruments_with_orders
1818 };
1819
1820 for (instrument, orders_open) in &orders_and_instruments {
1821 let mut by_account: IndexMap<Option<AccountId>, Vec<&OrderAny>> = IndexMap::new();
1822 for order in orders_open {
1823 by_account
1824 .entry(order.account_id())
1825 .or_default()
1826 .push(order);
1827 }
1828
1829 for (account_id, orders) in by_account {
1830 let account = {
1831 let cache = self.cache.borrow();
1832 match resolve_account_for_instrument(
1833 &cache,
1834 &instrument.id(),
1835 account_id.as_ref(),
1836 ) {
1837 Some(account) => account.cloned(),
1838 None => {
1839 log::error!(
1840 "Cannot update initial (order) margin: no account registered for {}",
1841 instrument.id().venue
1842 );
1843 initialized = false;
1844 continue;
1845 }
1846 }
1847 };
1848
1849 let result = self.inner.borrow_mut().accounts.update_orders(
1850 &account,
1851 instrument,
1852 &orders,
1853 self.clock.borrow().timestamp_ns(),
1854 );
1855
1856 match result {
1857 Some((updated_account, _)) => {
1858 self.cache
1859 .borrow_mut()
1860 .update_account(&updated_account)
1861 .unwrap();
1862 }
1863 None => initialized = false,
1864 }
1865 }
1866 }
1867
1868 let total_orders = orders_and_instruments
1869 .into_iter()
1870 .map(|(_, orders)| orders.len())
1871 .sum::<usize>();
1872
1873 log::info!(
1874 color = if total_orders > 0 { LogColor::Blue as u8 } else { LogColor::Normal as u8 };
1875 "Initialized {} open order{}",
1876 total_orders,
1877 if total_orders == 1 { "" } else { "s" }
1878 );
1879
1880 self.inner.borrow_mut().initialized = initialized;
1881 }
1882
1883 pub fn initialize_wallet_orders(&mut self) -> anyhow::Result<()> {
1893 let grouped_orders = {
1894 let cache = self.cache.borrow();
1895 let mut client_order_ids = BTreeSet::new();
1896 client_order_ids.extend(cache.iter_client_order_ids_open(None, None, None, None));
1897 client_order_ids.extend(cache.iter_client_order_ids_inflight(None, None, None, None));
1898
1899 let mut grouped: IndexMap<(AccountId, InstrumentId), Vec<OrderAny>> = IndexMap::new();
1900
1901 for client_order_id in client_order_ids {
1902 let Some(order) = cache.order(&client_order_id) else {
1903 continue;
1904 };
1905
1906 if !wallet_order_reserves_balance(&order) {
1907 continue;
1908 }
1909
1910 let Some(account) = resolve_account_for_instrument(
1911 &cache,
1912 &order.instrument_id(),
1913 order.account_id().as_ref(),
1914 ) else {
1915 continue;
1916 };
1917
1918 if !matches!(&*account, AccountAny::Wallet(_)) {
1919 continue;
1920 }
1921
1922 if cache.instrument(&order.instrument_id()).is_none() {
1923 anyhow::bail!(
1924 "cannot rebuild Wallet reservations: no instrument found for {}",
1925 order.instrument_id()
1926 );
1927 }
1928
1929 grouped
1930 .entry((account.id(), order.instrument_id()))
1931 .or_default()
1932 .push((*order).clone());
1933 }
1934 grouped
1935 };
1936
1937 let total_orders = grouped_orders.values().map(Vec::len).sum::<usize>();
1938 for ((account_id, instrument_id), orders) in grouped_orders {
1939 let (account, instrument) = {
1940 let cache = self.cache.borrow();
1941 let account = cache.account_owned(&account_id).ok_or_else(|| {
1942 anyhow::anyhow!(
1943 "cannot rebuild Wallet reservations: account {account_id} not found"
1944 )
1945 })?;
1946 let instrument = cache.instrument(&instrument_id).cloned().ok_or_else(|| {
1947 anyhow::anyhow!(
1948 "cannot rebuild Wallet reservations: instrument {instrument_id} not found"
1949 )
1950 })?;
1951 (account, instrument)
1952 };
1953 let order_refs = orders.iter().collect::<Vec<_>>();
1954 let Some((updated_account, _)) = self.inner.borrow().accounts.update_orders(
1955 &account,
1956 &instrument,
1957 &order_refs,
1958 self.clock.borrow().timestamp_ns(),
1959 ) else {
1960 anyhow::bail!(
1961 "cannot rebuild Wallet reservations for account {account_id} and instrument {instrument_id}"
1962 );
1963 };
1964 self.cache.borrow_mut().update_account(&updated_account)?;
1965 }
1966
1967 log::info!(
1968 color = if total_orders > 0 { LogColor::Blue as u8 } else { LogColor::Normal as u8 };
1969 "Initialized {} Wallet reservation{}",
1970 total_orders,
1971 if total_orders == 1 { "" } else { "s" }
1972 );
1973 Ok(())
1974 }
1975
1976 pub fn initialize_positions(&mut self) {
1982 self.inner.borrow_mut().unrealized_pnls.clear();
1983 self.inner.borrow_mut().realized_pnls.clear();
1984 let all_positions_open: Vec<Position>;
1985
1986 let mut instruments = BTreeSet::new();
1988 {
1989 let cache = self.cache.borrow();
1990 all_positions_open = cache
1991 .positions_open(None, None, None, None, None)
1992 .into_iter()
1993 .map(|p| p.cloned())
1994 .collect();
1995
1996 for position in &all_positions_open {
1997 instruments.insert(position.instrument_id);
1998 }
1999 }
2000
2001 let mut initialized = true;
2002
2003 for instrument_id in instruments {
2004 let positions_open: Vec<Position> = {
2005 let cache = self.cache.borrow();
2006 cache
2007 .positions_open(None, Some(&instrument_id), None, None, None)
2008 .into_iter()
2009 .map(|p| p.cloned())
2010 .collect()
2011 };
2012
2013 let position_refs: Vec<&Position> = positions_open.iter().collect();
2014 self.update_net_position(&instrument_id, &position_refs);
2015
2016 if let Some(calculated_unrealized_pnl) =
2017 self.calculate_unrealized_pnl(&instrument_id, None, None, None)
2018 {
2019 self.inner
2020 .borrow_mut()
2021 .unrealized_pnls
2022 .insert(instrument_id, calculated_unrealized_pnl);
2023 } else {
2024 log::debug!(
2025 "Failed to calculate unrealized PnL for {instrument_id}, marking as pending"
2026 );
2027 self.inner.borrow_mut().pending_calcs.insert(instrument_id);
2028 }
2029
2030 if let Some(calculated_realized_pnl) =
2031 self.calculate_realized_pnl(&instrument_id, None, None)
2032 {
2033 self.inner
2034 .borrow_mut()
2035 .realized_pnls
2036 .insert(instrument_id, calculated_realized_pnl);
2037 } else {
2038 log::warn!(
2039 "Failed to calculate realized PnL for {instrument_id}, marking as pending"
2040 );
2041 self.inner.borrow_mut().pending_calcs.insert(instrument_id);
2042 }
2043
2044 let instrument = {
2045 let cache = self.cache.borrow();
2046 let Some(instrument) = cache.instrument(&instrument_id).cloned() else {
2047 log::error!(
2048 "Cannot update maintenance (position) margin: no instrument found for {instrument_id}"
2049 );
2050 initialized = false;
2051 break;
2052 };
2053 instrument
2054 };
2055
2056 let mut by_account: IndexMap<AccountId, Vec<&Position>> = IndexMap::new();
2057 for position in &positions_open {
2058 by_account
2059 .entry(position.account_id)
2060 .or_default()
2061 .push(position);
2062 }
2063
2064 for (account_id, positions) in by_account {
2065 let account = {
2066 let cache = self.cache.borrow();
2067 let Some(account) = cache.account(&account_id).map(|a| a.cloned()) else {
2068 log::error!(
2069 "Cannot update maintenance (position) margin: no account registered for {account_id}"
2070 );
2071 initialized = false;
2072 continue;
2073 };
2074 account
2075 };
2076 let AccountAny::Margin(margin_account) = account else {
2077 continue;
2078 };
2079
2080 let result = self.inner.borrow_mut().accounts.update_positions(
2081 &margin_account,
2082 &instrument,
2083 positions,
2084 self.clock.borrow().timestamp_ns(),
2085 );
2086
2087 match result {
2088 Some((updated_account, _)) => {
2089 self.cache
2090 .borrow_mut()
2091 .update_account(&AccountAny::Margin(updated_account))
2092 .unwrap();
2093 }
2094 None => initialized = false,
2095 }
2096 }
2097 }
2098
2099 let open_count = all_positions_open.len();
2100 self.inner.borrow_mut().initialized = initialized;
2101 log::info!(
2102 color = if open_count > 0 { LogColor::Blue as u8 } else { LogColor::Normal as u8 };
2103 "Initialized {} open position{}",
2104 open_count,
2105 if open_count == 1 { "" } else { "s" }
2106 );
2107
2108 if self.config.snapshot_interval_ms.is_some() {
2109 let account_ids: AHashSet<AccountId> =
2110 all_positions_open.iter().map(|p| p.account_id).collect();
2111
2112 for account_id in account_ids {
2113 update_snapshot_timer_state(
2114 &self.cache,
2115 &self.clock,
2116 &self.inner,
2117 self.config,
2118 account_id,
2119 );
2120 }
2121 }
2122 }
2123
2124 pub fn update_quote_tick(&mut self, quote: &QuoteTick) {
2128 update_quote_tick(&self.cache, &self.clock, &self.inner, self.config, quote);
2129 }
2130
2131 pub fn update_bar(&mut self, bar: &Bar) {
2135 update_bar(&self.cache, &self.clock, &self.inner, self.config, bar);
2136 }
2137
2138 pub fn update_account(&mut self, event: &AccountState) {
2140 update_account(&self.clock, &self.cache, &self.inner, self.config, event);
2141 }
2142
2143 pub fn update_order(&mut self, event: &OrderEventAny) {
2147 update_order(
2148 &self.cache,
2149 &self.clock,
2150 &self.inner,
2151 self.config,
2152 event,
2153 OrderUpdateSource::Topic,
2154 );
2155 }
2156
2157 #[must_use]
2161 pub fn recorded_realized_pnls(&self) -> AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>> {
2162 self.inner.borrow().analyzer.recorded_realized_pnls.clone()
2163 }
2164
2165 #[must_use]
2171 pub fn statistics(&self) -> PortfolioStatistics {
2172 self.analyzer().statistics()
2173 }
2174
2175 #[must_use]
2183 pub fn analyzer(&self) -> PortfolioAnalyzer {
2184 let cache = self.cache.borrow();
2185 let accounts = cache.accounts_all_owned();
2186 let positions: Vec<Position> = cache
2187 .positions(None, None, None, None, None)
2188 .into_iter()
2189 .map(|p| p.cloned())
2190 .collect();
2191 let mut snapshots = Vec::new();
2192 for position in &positions {
2193 snapshots.extend(cache.position_snapshots(Some(&position.id), None));
2194 }
2195
2196 let inner = self.inner.borrow();
2197 let recorded = inner.analyzer.recorded_realized_pnls.clone();
2198 let portfolio_snapshots = inner
2199 .portfolio_snapshots
2200 .values()
2201 .flat_map(|ring| ring.iter())
2202 .collect::<Vec<_>>();
2203 let mut analyzer = PortfolioAnalyzer::from_accounts_with_snapshots(
2204 &accounts,
2205 &positions,
2206 &snapshots,
2207 portfolio_snapshots,
2208 recorded,
2209 );
2210 analyzer.replace_statistics(inner.analyzer.statistics.clone());
2211
2212 analyzer
2213 }
2214
2215 #[must_use]
2220 pub fn registered_statistics(&self) -> AHashMap<String, Statistic> {
2221 self.inner.borrow().analyzer.statistics.clone()
2222 }
2223
2224 pub fn register_statistic(&mut self, statistic: Statistic) {
2229 self.inner
2230 .borrow_mut()
2231 .analyzer
2232 .register_statistic(statistic);
2233 }
2234
2235 pub fn deregister_statistic(&mut self, statistic: &Statistic) {
2237 self.inner
2238 .borrow_mut()
2239 .analyzer
2240 .deregister_statistic(statistic);
2241 }
2242
2243 pub fn deregister_statistics(&mut self) {
2245 self.inner.borrow_mut().analyzer.deregister_statistics();
2246 }
2247
2248 pub fn update_position(&mut self, event: &PositionEvent) {
2252 update_position(&self.cache, &self.clock, &self.inner, self.config, event);
2253 }
2254
2255 fn update_net_position(&self, instrument_id: &InstrumentId, positions: &[&Position]) {
2256 let mut net_position = Decimal::ZERO;
2257
2258 for open_position in positions {
2259 log::debug!("open_position: {}", *open_position);
2260 net_position += open_position.signed_decimal_qty();
2261 }
2262
2263 let existing_position = self.net_position(instrument_id);
2264 if existing_position != net_position {
2265 self.inner
2266 .borrow_mut()
2267 .net_positions
2268 .insert(*instrument_id, net_position);
2269 log::info!("{instrument_id} net_position={net_position}");
2270 }
2271 }
2272
2273 fn aggregate_unrealized_pnl_by_account(
2274 &self,
2275 instrument_id: &InstrumentId,
2276 price: Option<Price>,
2277 account_id: Option<&AccountId>,
2278 target_currency: Option<Currency>,
2279 ) -> Result<Money, UnrealizedPnlError> {
2280 let pnls =
2281 self.unrealized_pnls_by_account(instrument_id, price, account_id, target_currency)?;
2282 let mut total: Option<Money> = None;
2283 for pnl in pnls {
2284 total = Some(match total {
2285 Some(total) => checked_add_money(total, pnl, "PnL aggregation")
2286 .ok_or(UnrealizedPnlError::Invalid)?,
2287 None => pnl,
2288 });
2289 }
2290
2291 total.ok_or(UnrealizedPnlError::Invalid)
2292 }
2293
2294 fn unrealized_pnls_by_account(
2295 &self,
2296 instrument_id: &InstrumentId,
2297 price: Option<Price>,
2298 account_id: Option<&AccountId>,
2299 target_currency: Option<Currency>,
2300 ) -> Result<Vec<Money>, UnrealizedPnlError> {
2301 let mut account_ids = if let Some(account_id) = account_id {
2302 vec![*account_id]
2303 } else {
2304 let cache = self.cache.borrow();
2305 cache
2306 .positions_open(None, Some(instrument_id), None, None, None)
2307 .iter()
2308 .map(|position| position.account_id)
2309 .collect::<Vec<_>>()
2310 };
2311
2312 account_ids.sort();
2313 account_ids.dedup();
2314 if account_ids.is_empty() {
2315 let cache = self.cache.borrow();
2316 let account_id = if let Some(account_id) = account_id {
2317 cache.account(account_id).map(|account| account.id())
2318 } else {
2319 cache
2320 .account_for_venue(&instrument_id.venue)
2321 .map(|account| account.id())
2322 }
2323 .ok_or(UnrealizedPnlError::Invalid)?;
2324 account_ids.push(account_id);
2325 }
2326
2327 let mut pnls = Vec::with_capacity(account_ids.len());
2328 for account_id in account_ids {
2329 let pnl = self.calculate_unrealized_pnl_result(
2330 instrument_id,
2331 price,
2332 Some(&account_id),
2333 target_currency,
2334 )?;
2335 pnls.push(pnl);
2336 }
2337
2338 Ok(pnls)
2339 }
2340
2341 fn aggregate_realized_pnl_by_account(
2342 &self,
2343 instrument_id: &InstrumentId,
2344 account_id: Option<&AccountId>,
2345 target_currency: Option<Currency>,
2346 ) -> Option<Money> {
2347 let pnls = self.realized_pnls_by_account(instrument_id, account_id, target_currency)?;
2348 let mut total: Option<Money> = None;
2349 for pnl in pnls {
2350 total = Some(match total {
2351 Some(total) => checked_add_money(total, pnl, "PnL aggregation")?,
2352 None => pnl,
2353 });
2354 }
2355
2356 total
2357 }
2358
2359 fn realized_pnls_by_account(
2360 &self,
2361 instrument_id: &InstrumentId,
2362 account_id: Option<&AccountId>,
2363 target_currency: Option<Currency>,
2364 ) -> Option<Vec<Money>> {
2365 let mut account_ids = if let Some(account_id) = account_id {
2366 vec![*account_id]
2367 } else {
2368 let cache = self.cache.borrow();
2369 cache
2370 .positions(None, Some(instrument_id), None, None, None)
2371 .iter()
2372 .map(|position| position.account_id)
2373 .collect::<Vec<_>>()
2374 };
2375
2376 if account_id.is_none() {
2377 let inner = self.inner.borrow();
2378 account_ids.extend(
2379 self.cache
2380 .borrow()
2381 .position_snapshot_ids(instrument_id)
2382 .iter()
2383 .filter_map(|position_id| inner.snapshot_account_ids.get(position_id))
2384 .copied(),
2385 );
2386 }
2387
2388 account_ids.sort();
2389 account_ids.dedup();
2390 if account_ids.is_empty() {
2391 let cache = self.cache.borrow();
2392 let account_id = if let Some(account_id) = account_id {
2393 cache.account(account_id).map(|account| account.id())
2394 } else {
2395 cache
2396 .account_for_venue(&instrument_id.venue)
2397 .map(|account| account.id())
2398 }?;
2399 account_ids.push(account_id);
2400 }
2401
2402 let mut pnls = Vec::with_capacity(account_ids.len());
2403 for account_id in account_ids {
2404 let pnl =
2405 self.calculate_realized_pnl(instrument_id, Some(&account_id), target_currency)?;
2406 pnls.push(pnl);
2407 }
2408
2409 Some(pnls)
2410 }
2411
2412 fn calculate_unrealized_pnl(
2413 &self,
2414 instrument_id: &InstrumentId,
2415 price: Option<Price>,
2416 account_id: Option<&AccountId>,
2417 target_currency: Option<Currency>,
2418 ) -> Option<Money> {
2419 self.calculate_unrealized_pnl_result(instrument_id, price, account_id, target_currency)
2420 .ok()
2421 }
2422
2423 fn calculate_unrealized_pnl_result(
2424 &self,
2425 instrument_id: &InstrumentId,
2426 price: Option<Price>,
2427 account_id: Option<&AccountId>,
2428 target_currency: Option<Currency>,
2429 ) -> Result<Money, UnrealizedPnlError> {
2430 let cache = self.cache.borrow();
2431 let account = resolve_account_for_instrument(&cache, instrument_id, account_id);
2432 let account = if let Some(account) = account {
2433 account
2434 } else {
2435 log::error!(
2436 "Cannot calculate unrealized PnL: no account for {} / {account_id:?}",
2437 instrument_id.venue,
2438 );
2439 return Err(UnrealizedPnlError::Invalid);
2440 };
2441
2442 let conversion_currency =
2443 target_currency.or_else(|| self.conversion_base_currency(&account));
2444 let allow_stale_xrate = target_currency.is_none();
2445 let mut output_currency = conversion_currency;
2446
2447 let positions_open =
2448 cache.positions_open(None, Some(instrument_id), None, account_id, None);
2449
2450 if positions_open.is_empty()
2451 && let Some(currency) = cache
2452 .instrument(instrument_id)
2453 .map(Instrument::cost_currency)
2454 .or_else(|| {
2455 cache
2456 .positions(None, Some(instrument_id), None, account_id, None)
2457 .first()
2458 .map(|position| position.settlement_currency)
2459 })
2460 {
2461 return Ok(Money::zero(output_currency.unwrap_or(currency)));
2462 }
2463
2464 let Some(instrument) = cache.instrument(instrument_id) else {
2465 log::error!("Cannot calculate unrealized PnL: no instrument for {instrument_id}");
2466 return Err(UnrealizedPnlError::Invalid);
2467 };
2468
2469 let mut total_pnl = Decimal::ZERO;
2470
2471 for position in positions_open {
2472 if position.instrument_id != *instrument_id {
2473 continue; }
2475
2476 if position.side == PositionSide::Flat {
2477 continue; }
2479
2480 let price = if let Some(price) = price.or_else(|| self.get_price(&position)) {
2481 price
2482 } else {
2483 log::debug!("Cannot calculate unrealized PnL: no prices for {instrument_id}");
2484 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2485 return Err(UnrealizedPnlError::MissingInput);
2486 };
2487
2488 let position_pnl = match position.try_unrealized_pnl(price) {
2489 Ok(pnl) => pnl,
2490 Err(e) => {
2491 log::error!(
2492 "Cannot calculate unrealized PnL for {}: {e}",
2493 position.instrument_id
2494 );
2495 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2496 return Err(UnrealizedPnlError::Invalid);
2497 }
2498 };
2499 let source_currency = position_pnl.currency;
2500 let currency = conversion_currency.unwrap_or(source_currency);
2501 match output_currency {
2502 None => output_currency = Some(currency),
2503 Some(first) if first != currency => {
2504 log::error!(
2505 "Cannot calculate unrealized PnL: positions have different output \
2506 currencies ({first} vs {currency})"
2507 );
2508 return Err(UnrealizedPnlError::Invalid);
2509 }
2510 _ => {}
2511 }
2512
2513 let mut pnl = position_pnl.as_decimal();
2514
2515 if let Some(conversion_currency) = conversion_currency {
2516 let xrate = if let Some(xrate) = self.calculate_xrate(
2517 instrument.id().venue,
2518 source_currency,
2519 conversion_currency,
2520 allow_stale_xrate,
2521 ) {
2522 xrate
2523 } else {
2524 log::warn!(
2525 "Cannot calculate unrealized PnL: insufficient data for \
2527 {source_currency}/{conversion_currency}"
2528 );
2529 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2530 return Err(UnrealizedPnlError::MissingInput);
2531 };
2532
2533 let Some(converted) = pnl.checked_mul(xrate) else {
2534 log::error!("Cannot calculate unrealized PnL: currency conversion overflow");
2535 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2536 return Err(UnrealizedPnlError::Invalid);
2537 };
2538 pnl = converted.round_dp(u32::from(currency.precision));
2539 }
2540
2541 let Some(updated_total) = total_pnl.checked_add(pnl) else {
2542 log::error!("Cannot calculate unrealized PnL: total overflow");
2543 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2544 return Err(UnrealizedPnlError::Invalid);
2545 };
2546 total_pnl = updated_total;
2547 }
2548
2549 let currency = output_currency.unwrap_or_else(|| instrument.cost_currency());
2550 match Money::from_decimal(total_pnl, currency) {
2551 Ok(money) => Ok(money),
2552 Err(e) => {
2553 log::error!("Cannot calculate unrealized PnL: {e}");
2554 Err(UnrealizedPnlError::Invalid)
2555 }
2556 }
2557 }
2558
2559 fn ensure_snapshot_pnls_cached_for(&self, instrument_id: &InstrumentId) {
2560 let snapshot_position_ids = self.cache.borrow().position_snapshot_ids(instrument_id);
2566
2567 if snapshot_position_ids.is_empty() {
2568 return; }
2570
2571 let mut rebuild = false;
2572
2573 for position_id in &snapshot_position_ids {
2576 let curr_count = self.cache.borrow().position_snapshot_count(position_id);
2577 let curr_revision = self.cache.borrow().position_snapshot_revision(position_id);
2578 let prev_count = self
2579 .inner
2580 .borrow()
2581 .snapshot_processed_counts
2582 .get(position_id)
2583 .copied()
2584 .unwrap_or(0);
2585 let prev_revision = self
2586 .inner
2587 .borrow()
2588 .snapshot_processed_revisions
2589 .get(position_id)
2590 .copied()
2591 .unwrap_or(0);
2592
2593 if prev_count > curr_count || prev_revision != curr_revision {
2594 rebuild = true;
2595 break;
2596 }
2597 }
2598
2599 if rebuild {
2600 for position_id in &snapshot_position_ids {
2602 let snapshot_count = self.cache.borrow().position_snapshot_count(position_id);
2606 let snapshot_revision = self.cache.borrow().position_snapshot_revision(position_id);
2607 let snapshots = self
2608 .cache
2609 .borrow()
2610 .position_snapshots(Some(position_id), None);
2611
2612 let mut sum_pnl: Option<Money> = None;
2613 let mut last_pnl: Option<Money> = None;
2614 let mut snapshot_account_id: Option<AccountId> = None;
2615 let mut currency_mismatch = false;
2616 let mut aggregation_overflow = false;
2617
2618 for snapshot in snapshots {
2619 snapshot_account_id.get_or_insert(snapshot.account_id);
2620 if let Some(realized_pnl) = snapshot.realized_pnl {
2621 if let Some(sum) = sum_pnl {
2622 if sum.currency == realized_pnl.currency {
2623 if let Some(updated) = sum.checked_add(realized_pnl) {
2624 sum_pnl = Some(updated);
2625 } else {
2626 aggregation_overflow = true;
2627 }
2628 } else {
2629 currency_mismatch = true;
2630 }
2631 } else {
2632 sum_pnl = Some(realized_pnl);
2633 }
2634 last_pnl = Some(realized_pnl);
2635 }
2636 }
2637
2638 let mut inner = self.inner.borrow_mut();
2639
2640 if !aggregation_overflow && let Some(sum) = sum_pnl {
2641 inner.snapshot_sum_per_position.insert(*position_id, sum);
2642
2643 if let Some(last) = last_pnl {
2644 inner.snapshot_last_per_position.insert(*position_id, last);
2645 }
2646 } else {
2647 inner.snapshot_sum_per_position.remove(position_id);
2648 inner.snapshot_last_per_position.remove(position_id);
2649 }
2650
2651 if currency_mismatch {
2652 inner.snapshot_currency_mismatches.insert(*position_id);
2653 } else {
2654 inner.snapshot_currency_mismatches.remove(position_id);
2655 }
2656
2657 if aggregation_overflow {
2658 inner.snapshot_aggregation_overflows.insert(*position_id);
2659 } else {
2660 inner.snapshot_aggregation_overflows.remove(position_id);
2661 }
2662
2663 if let Some(account_id) = snapshot_account_id {
2664 inner.snapshot_account_ids.insert(*position_id, account_id);
2665 } else {
2666 inner.snapshot_account_ids.remove(position_id);
2667 }
2668
2669 inner
2670 .snapshot_processed_counts
2671 .insert(*position_id, snapshot_count);
2672 inner
2673 .snapshot_processed_revisions
2674 .insert(*position_id, snapshot_revision);
2675 }
2676 self.inner
2677 .borrow_mut()
2678 .realized_pnls
2679 .shift_remove(instrument_id);
2680 } else {
2681 let mut cache_changed = false;
2682 for position_id in &snapshot_position_ids {
2684 let curr_count = self.cache.borrow().position_snapshot_count(position_id);
2687 let curr_revision = self.cache.borrow().position_snapshot_revision(position_id);
2688 let prev_count = self
2689 .inner
2690 .borrow()
2691 .snapshot_processed_counts
2692 .get(position_id)
2693 .copied()
2694 .unwrap_or(0);
2695
2696 if prev_count >= curr_count {
2697 continue;
2698 }
2699 cache_changed = true;
2700
2701 let mut sum_pnl = self
2702 .inner
2703 .borrow()
2704 .snapshot_sum_per_position
2705 .get(position_id)
2706 .copied();
2707 let mut last_pnl = self
2708 .inner
2709 .borrow()
2710 .snapshot_last_per_position
2711 .get(position_id)
2712 .copied();
2713 let mut snapshot_account_id: Option<AccountId> = None;
2714 let mut currency_mismatch = self
2715 .inner
2716 .borrow()
2717 .snapshot_currency_mismatches
2718 .contains(position_id);
2719 let mut aggregation_overflow = self
2720 .inner
2721 .borrow()
2722 .snapshot_aggregation_overflows
2723 .contains(position_id);
2724
2725 let new_snapshots = self
2726 .cache
2727 .borrow()
2728 .position_snapshots_from(position_id, prev_count);
2729
2730 for snapshot in new_snapshots {
2731 snapshot_account_id.get_or_insert(snapshot.account_id);
2732 if let Some(realized_pnl) = snapshot.realized_pnl {
2733 if let Some(sum) = sum_pnl {
2734 if sum.currency == realized_pnl.currency {
2735 if let Some(updated) = sum.checked_add(realized_pnl) {
2736 sum_pnl = Some(updated);
2737 } else {
2738 aggregation_overflow = true;
2739 }
2740 } else {
2741 currency_mismatch = true;
2742 }
2743 } else {
2744 sum_pnl = Some(realized_pnl);
2745 }
2746 last_pnl = Some(realized_pnl);
2747 }
2748 }
2749
2750 let mut inner = self.inner.borrow_mut();
2751
2752 if !aggregation_overflow && let Some(sum) = sum_pnl {
2753 inner.snapshot_sum_per_position.insert(*position_id, sum);
2754
2755 if let Some(last) = last_pnl {
2756 inner.snapshot_last_per_position.insert(*position_id, last);
2757 }
2758 }
2759
2760 if currency_mismatch {
2761 inner.snapshot_currency_mismatches.insert(*position_id);
2762 }
2763
2764 if aggregation_overflow {
2765 inner.snapshot_aggregation_overflows.insert(*position_id);
2766 inner.snapshot_sum_per_position.remove(position_id);
2767 inner.snapshot_last_per_position.remove(position_id);
2768 }
2769
2770 if let Some(account_id) = snapshot_account_id
2771 && !inner.snapshot_account_ids.contains_key(position_id)
2772 {
2773 inner.snapshot_account_ids.insert(*position_id, account_id);
2774 }
2775
2776 inner
2777 .snapshot_processed_counts
2778 .insert(*position_id, curr_count);
2779 inner
2780 .snapshot_processed_revisions
2781 .insert(*position_id, curr_revision);
2782 }
2783
2784 if cache_changed {
2785 self.inner
2786 .borrow_mut()
2787 .realized_pnls
2788 .shift_remove(instrument_id);
2789 }
2790 }
2791 }
2792
2793 fn calculate_realized_pnl(
2794 &self,
2795 instrument_id: &InstrumentId,
2796 account_id: Option<&AccountId>,
2797 target_currency: Option<Currency>,
2798 ) -> Option<Money> {
2799 self.ensure_snapshot_pnls_cached_for(instrument_id);
2801
2802 let cache = self.cache.borrow();
2803 let account = resolve_account_for_instrument(&cache, instrument_id, account_id);
2804 let account = if let Some(account) = account {
2805 account
2806 } else {
2807 log::error!(
2808 "Cannot calculate realized PnL: no account for {} / {account_id:?}",
2809 instrument_id.venue,
2810 );
2811 return None;
2812 };
2813
2814 let instrument = cache.instrument(instrument_id);
2815 let positions = cache.positions(None, Some(instrument_id), None, account_id, None);
2816
2817 let mut snapshot_position_ids: Vec<PositionId> = if let Some(filter_id) = account_id {
2822 let inner = self.inner.borrow();
2823 cache
2824 .position_snapshot_ids(instrument_id)
2825 .into_iter()
2826 .filter(|pid| {
2827 inner
2828 .snapshot_account_ids
2829 .get(pid)
2830 .is_some_and(|id| id == filter_id)
2831 })
2832 .collect()
2833 } else {
2834 cache
2835 .position_snapshot_ids(instrument_id)
2836 .into_iter()
2837 .collect()
2838 };
2839 snapshot_position_ids.sort();
2840
2841 if snapshot_position_ids.iter().any(|position_id| {
2842 self.inner
2843 .borrow()
2844 .snapshot_currency_mismatches
2845 .contains(position_id)
2846 }) {
2847 log::error!(
2848 "Cannot calculate realized PnL: snapshots for {instrument_id} contain mixed \
2849 cost currencies"
2850 );
2851 return None;
2852 }
2853
2854 if snapshot_position_ids.iter().any(|position_id| {
2855 self.inner
2856 .borrow()
2857 .snapshot_aggregation_overflows
2858 .contains(position_id)
2859 }) {
2860 log::error!(
2861 "Cannot calculate realized PnL: snapshot aggregation for {instrument_id} exceeds Money bounds"
2862 );
2863 return None;
2864 }
2865
2866 let conversion_currency =
2867 target_currency.or_else(|| self.conversion_base_currency(&account));
2868 let allow_stale_xrate = target_currency.is_none();
2869
2870 let currency = positions
2872 .first()
2873 .map(|position| position.settlement_currency)
2874 .or_else(|| {
2875 let inner = self.inner.borrow();
2876 snapshot_position_ids.iter().find_map(|position_id| {
2877 inner
2878 .snapshot_sum_per_position
2879 .get(position_id)
2880 .or_else(|| inner.snapshot_last_per_position.get(position_id))
2881 .map(|pnl| pnl.currency)
2882 })
2883 })
2884 .or_else(|| instrument.map(Instrument::cost_currency))
2885 .or_else(|| {
2886 log::error!("Cannot calculate realized PnL: no instrument for {instrument_id}");
2887 None
2888 })?;
2889 let currency = conversion_currency.unwrap_or(currency);
2890
2891 let is_netting = positions
2893 .iter()
2894 .any(|p| cache.oms_type(&p.id) == Some(OmsType::Netting));
2895
2896 let mut total_pnl = Decimal::ZERO;
2897
2898 if is_netting && !snapshot_position_ids.is_empty() {
2899 for position_id in &snapshot_position_ids {
2902 let position = positions.iter().find(|p| p.id == *position_id);
2903 let sum_pnl = self
2904 .inner
2905 .borrow()
2906 .snapshot_sum_per_position
2907 .get(position_id)
2908 .copied();
2909
2910 let sum_pnl = if let Some(sum_pnl) = sum_pnl {
2914 let closed_position_pnl = position
2915 .filter(|position| !position.is_open())
2916 .and_then(|position| position.realized_pnl);
2917 let last_pnl = self
2918 .inner
2919 .borrow()
2920 .snapshot_last_per_position
2921 .get(position_id)
2922 .copied();
2923
2924 Some(match (closed_position_pnl, last_pnl) {
2925 (Some(realized_pnl), Some(last_pnl)) if last_pnl == realized_pnl => {
2926 match sum_pnl.checked_sub(last_pnl) {
2927 Some(remaining) => remaining,
2928 None => {
2929 log::error!(
2930 "Cannot calculate realized PnL: snapshot adjustment exceeds Money bounds"
2931 );
2932 return None;
2933 }
2934 }
2935 }
2936 _ => sum_pnl,
2937 })
2938 } else {
2939 None
2940 };
2941
2942 if let Some(sum_pnl) = sum_pnl {
2943 if !pnl_currency_is_compatible(conversion_currency, currency, sum_pnl.currency)
2944 {
2945 return None;
2946 }
2947
2948 let mut pnl = sum_pnl.as_decimal();
2949
2950 if let Some(conversion_currency) = conversion_currency {
2951 let xrate = if let Some(xrate) = self.calculate_xrate(
2952 instrument_id.venue,
2953 sum_pnl.currency,
2954 conversion_currency,
2955 allow_stale_xrate,
2956 ) {
2957 xrate
2958 } else {
2959 log::warn!(
2960 "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
2961 sum_pnl.currency,
2962 conversion_currency
2963 );
2964 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2965 return None;
2966 };
2967
2968 pnl = self.checked_convert_realized_pnl(
2969 pnl,
2970 xrate,
2971 currency,
2972 *instrument_id,
2973 )?;
2974 }
2975
2976 total_pnl = self.checked_add_realized_pnl(total_pnl, pnl, *instrument_id)?;
2977 }
2978 }
2979
2980 for position in positions {
2982 if position.instrument_id != *instrument_id {
2983 continue;
2984 }
2985
2986 if let Some(realized_pnl) = position.realized_pnl {
2987 if !pnl_currency_is_compatible(
2988 conversion_currency,
2989 currency,
2990 realized_pnl.currency,
2991 ) {
2992 return None;
2993 }
2994
2995 let mut pnl = realized_pnl.as_decimal();
2996
2997 if let Some(conversion_currency) = conversion_currency {
2998 let xrate = if let Some(xrate) = self.calculate_xrate(
2999 instrument_id.venue,
3000 realized_pnl.currency,
3001 conversion_currency,
3002 allow_stale_xrate,
3003 ) {
3004 xrate
3005 } else {
3006 log::warn!(
3007 "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
3008 realized_pnl.currency,
3009 conversion_currency
3010 );
3011 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
3012 return None;
3013 };
3014
3015 pnl = self.checked_convert_realized_pnl(
3016 pnl,
3017 xrate,
3018 currency,
3019 *instrument_id,
3020 )?;
3021 }
3022
3023 total_pnl = self.checked_add_realized_pnl(total_pnl, pnl, *instrument_id)?;
3024 }
3025 }
3026 } else {
3027 for position_id in &snapshot_position_ids {
3030 let sum_pnl = self
3031 .inner
3032 .borrow()
3033 .snapshot_sum_per_position
3034 .get(position_id)
3035 .copied();
3036
3037 if let Some(sum_pnl) = sum_pnl {
3038 if !pnl_currency_is_compatible(conversion_currency, currency, sum_pnl.currency)
3039 {
3040 return None;
3041 }
3042
3043 let mut pnl = sum_pnl.as_decimal();
3044
3045 if let Some(conversion_currency) = conversion_currency {
3046 let xrate = if let Some(xrate) = self.calculate_xrate(
3047 instrument_id.venue,
3048 sum_pnl.currency,
3049 conversion_currency,
3050 allow_stale_xrate,
3051 ) {
3052 xrate
3053 } else {
3054 log::warn!(
3055 "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
3056 sum_pnl.currency,
3057 conversion_currency
3058 );
3059 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
3060 return None;
3061 };
3062
3063 pnl = self.checked_convert_realized_pnl(
3064 pnl,
3065 xrate,
3066 currency,
3067 *instrument_id,
3068 )?;
3069 }
3070
3071 total_pnl = self.checked_add_realized_pnl(total_pnl, pnl, *instrument_id)?;
3072 }
3073 }
3074
3075 for position in positions {
3077 if position.instrument_id != *instrument_id {
3078 continue;
3079 }
3080
3081 if let Some(realized_pnl) = position.realized_pnl {
3082 if !pnl_currency_is_compatible(
3083 conversion_currency,
3084 currency,
3085 realized_pnl.currency,
3086 ) {
3087 return None;
3088 }
3089
3090 let mut pnl = realized_pnl.as_decimal();
3091
3092 if let Some(conversion_currency) = conversion_currency {
3093 let xrate = if let Some(xrate) = self.calculate_xrate(
3094 instrument_id.venue,
3095 realized_pnl.currency,
3096 conversion_currency,
3097 allow_stale_xrate,
3098 ) {
3099 xrate
3100 } else {
3101 log::warn!(
3102 "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
3103 realized_pnl.currency,
3104 conversion_currency
3105 );
3106 self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
3107 return None;
3108 };
3109
3110 pnl = self.checked_convert_realized_pnl(
3111 pnl,
3112 xrate,
3113 currency,
3114 *instrument_id,
3115 )?;
3116 }
3117
3118 total_pnl = self.checked_add_realized_pnl(total_pnl, pnl, *instrument_id)?;
3119 }
3120 }
3121 }
3122
3123 match Money::from_decimal(total_pnl, currency) {
3124 Ok(money) => Some(money),
3125 Err(e) => {
3126 log::error!("Cannot calculate realized PnL: {e}");
3127 None
3128 }
3129 }
3130 }
3131
3132 fn checked_convert_realized_pnl(
3133 &self,
3134 pnl: Decimal,
3135 xrate: Decimal,
3136 currency: Currency,
3137 instrument_id: InstrumentId,
3138 ) -> Option<Decimal> {
3139 let Some(converted) = pnl.checked_mul(xrate) else {
3140 log::error!("Cannot calculate realized PnL: currency conversion overflow");
3141 self.inner.borrow_mut().pending_calcs.insert(instrument_id);
3142 return None;
3143 };
3144 Some(converted.round_dp(u32::from(currency.precision)))
3145 }
3146
3147 fn checked_add_realized_pnl(
3148 &self,
3149 total: Decimal,
3150 pnl: Decimal,
3151 instrument_id: InstrumentId,
3152 ) -> Option<Decimal> {
3153 let Some(total) = total.checked_add(pnl) else {
3154 log::error!("Cannot calculate realized PnL: total overflow");
3155 self.inner.borrow_mut().pending_calcs.insert(instrument_id);
3156 return None;
3157 };
3158 Some(total)
3159 }
3160
3161 fn get_price(&self, position: &Position) -> Option<Price> {
3162 let cache = self.cache.borrow();
3163 let instrument_id = &position.instrument_id;
3164
3165 let price_type = match position.side {
3166 PositionSide::Long => PriceType::Bid,
3167 PositionSide::Short => PriceType::Ask,
3168 PositionSide::Flat => {
3169 log::error!(
3170 "Cannot get price for invalid position side {}",
3171 position.side
3172 );
3173 return None;
3174 }
3175 };
3176
3177 let allows_non_positive = || {
3179 cache
3180 .instrument(instrument_id)
3181 .is_some_and(Instrument::allows_negative_price)
3182 };
3183 let is_valid = |price: &Price| price.as_decimal() > Decimal::ZERO || allows_non_positive();
3184 let mark_price = if self.config.use_mark_prices {
3185 cache.mark_price(instrument_id).map(|mark| mark.value)
3186 } else {
3187 None
3188 };
3189 let quote_price = || {
3192 cache.quote(instrument_id).and_then(|quote| {
3193 let (price, size) = match price_type {
3194 PriceType::Bid => (quote.bid_price, quote.bid_size),
3195 _ => (quote.ask_price, quote.ask_size),
3196 };
3197 (is_valid(&price) && (price.as_decimal() > Decimal::ZERO || size.is_positive()))
3198 .then_some(price)
3199 })
3200 };
3201 let current = mark_price
3202 .filter(is_valid)
3203 .or_else(quote_price)
3204 .or_else(|| cache.price(instrument_id, PriceType::Last).filter(is_valid))
3205 .or_else(|| {
3206 self.inner
3207 .borrow()
3208 .bar_close_prices
3209 .get(instrument_id)
3210 .filter(|price| is_valid(price))
3211 .copied()
3212 });
3213 drop(cache);
3214
3215 let key = (*instrument_id, position.side);
3216 let mut inner = self.inner.borrow_mut();
3217 if let Some(price) = current {
3218 inner.last_prices.insert(key, price);
3219 inner.stale_prices.remove(&key);
3220 Some(price)
3221 } else if let Some(price) = inner.last_prices.get(&key).copied() {
3222 inner.stale_prices.insert(key);
3223 Some(price)
3224 } else {
3225 inner.stale_prices.remove(&key);
3226 None
3227 }
3228 }
3229
3230 fn calculate_xrate_to_base(
3231 &self,
3232 instrument: &InstrumentAny,
3233 account: &AccountAny,
3234 source_currency: Currency,
3235 ) -> Option<Decimal> {
3236 if !self.config.convert_to_account_base_currency {
3237 return Some(Decimal::ONE); }
3239
3240 let base_currency = match account.base_currency() {
3241 Some(base_currency) => base_currency,
3242 None => return Some(Decimal::ONE),
3243 };
3244
3245 self.calculate_xrate(instrument.id().venue, source_currency, base_currency, true)
3246 }
3247
3248 fn calculate_xrate(
3249 &self,
3250 venue: Venue,
3251 source_currency: Currency,
3252 target_currency: Currency,
3253 allow_stale: bool,
3254 ) -> Option<Decimal> {
3255 if source_currency == target_currency {
3256 return Some(Decimal::ONE);
3257 }
3258
3259 let cache = self.cache.borrow();
3260 let mark_xrate = if self.config.use_mark_xrates {
3261 cache
3262 .get_mark_xrate(source_currency, target_currency)
3263 .and_then(|xrate| Decimal::try_from(xrate).ok())
3264 } else {
3265 None
3266 };
3267 let current = mark_xrate
3268 .filter(|xrate| *xrate > Decimal::ZERO)
3269 .or_else(|| {
3270 cache
3271 .get_xrate(venue, source_currency, target_currency, PriceType::Mid)
3272 .filter(|xrate| *xrate > Decimal::ZERO)
3273 });
3274 drop(cache);
3275
3276 let key = (venue, source_currency, target_currency);
3277 let mut inner = self.inner.borrow_mut();
3278 if let Some(xrate) = current {
3279 inner.last_xrates.insert(key, xrate);
3280 inner.stale_xrates.remove(&key);
3281 Some(xrate)
3282 } else if allow_stale && let Some(xrate) = inner.last_xrates.get(&key).copied() {
3283 inner.stale_xrates.insert(key);
3284 Some(xrate)
3285 } else {
3286 inner.stale_xrates.remove(&key);
3287 None
3288 }
3289 }
3290
3291 fn conversion_base_currency(&self, account: &AccountAny) -> Option<Currency> {
3295 if self.config.convert_to_account_base_currency {
3296 account.base_currency()
3297 } else {
3298 None
3299 }
3300 }
3301}
3302
3303fn checked_add_money(lhs: Money, rhs: Money, context: &str) -> Option<Money> {
3304 if lhs.currency != rhs.currency {
3305 log::error!(
3306 "Cannot calculate {context}: currency mismatch {} vs {}",
3307 lhs.currency,
3308 rhs.currency
3309 );
3310 return None;
3311 }
3312
3313 match lhs.checked_add(rhs) {
3314 Some(total) => Some(total),
3315 None => {
3316 log::error!("Cannot calculate {context}: total exceeds Money bounds");
3317 None
3318 }
3319 }
3320}
3321
3322fn checked_add_money_map(
3323 totals: &mut IndexMap<Currency, Money>,
3324 money: Money,
3325 context: &str,
3326) -> Option<()> {
3327 let currency = money.currency;
3328 match totals.get_mut(¤cy) {
3329 Some(total) => *total = checked_add_money(*total, money, context)?,
3330 None => {
3331 totals.insert(currency, money);
3332 }
3333 }
3334 Some(())
3335}
3336
3337fn pnl_currency_is_compatible(
3338 base_currency: Option<Currency>,
3339 output_currency: Currency,
3340 source_currency: Currency,
3341) -> bool {
3342 if base_currency.is_none() && source_currency != output_currency {
3343 log::error!(
3344 "Cannot calculate realized PnL: records have different cost currencies \
3345 ({output_currency} vs {source_currency})"
3346 );
3347 false
3348 } else {
3349 true
3350 }
3351}
3352
3353fn position_uses_premium_mark(position: &Position, cache: &Cache) -> bool {
3354 position.instrument_class.is_premium_based()
3355 && cache
3356 .account_ref(&position.account_id)
3357 .is_some_and(|account| account.calculated_account_state())
3358}
3359
3360fn decimal_map_to_money(map: IndexMap<Currency, Decimal>) -> IndexMap<Currency, Money> {
3361 map.into_iter()
3362 .filter_map(
3363 |(currency, amount)| match Money::from_decimal(amount, currency) {
3364 Ok(money) => Some((currency, money)),
3365 Err(e) => {
3366 log::error!("Cannot convert {currency} amount to Money: {e}");
3367 None
3368 }
3369 },
3370 )
3371 .collect()
3372}
3373
3374fn update_quote_tick(
3375 cache: &Rc<RefCell<Cache>>,
3376 clock: &Rc<RefCell<dyn Clock>>,
3377 inner: &Rc<RefCell<PortfolioState>>,
3378 config: PortfolioConfig,
3379 quote: &QuoteTick,
3380) {
3381 update_instrument_id(cache, clock, inner, config, "e.instrument_id);
3382}
3383
3384fn update_bar(
3385 cache: &Rc<RefCell<Cache>>,
3386 clock: &Rc<RefCell<dyn Clock>>,
3387 inner: &Rc<RefCell<PortfolioState>>,
3388 config: PortfolioConfig,
3389 bar: &Bar,
3390) {
3391 let instrument_id = bar.bar_type.instrument_id();
3392 inner
3393 .borrow_mut()
3394 .bar_close_prices
3395 .insert(instrument_id, bar.close);
3396 update_instrument_id(cache, clock, inner, config, &instrument_id);
3397}
3398
3399pub(crate) fn resolve_account<'a>(
3400 cache: &'a Cache,
3401 venue: Option<&Venue>,
3402 account_id: Option<&AccountId>,
3403) -> Option<AccountRef<'a>> {
3404 match (account_id, venue) {
3405 (Some(account_id), _) => cache.account(account_id),
3406 (None, Some(venue)) => cache.account_for_venue(venue),
3407 (None, None) => None,
3408 }
3409}
3410
3411fn resolve_account_for_instrument<'a>(
3415 cache: &'a Cache,
3416 instrument_id: &InstrumentId,
3417 account_id: Option<&AccountId>,
3418) -> Option<AccountRef<'a>> {
3419 match account_id {
3420 Some(id) => cache.account(id),
3421 None => cache.account_for_venue(&instrument_id.venue).or_else(|| {
3422 cache
3423 .positions(None, Some(instrument_id), None, None, None)
3424 .into_iter()
3425 .next()
3426 .and_then(|p| cache.account(&p.account_id))
3427 }),
3428 }
3429}
3430
3431fn wallet_order_reserves_balance(order: &OrderAny) -> bool {
3432 order.is_open() || order.is_inflight()
3433}
3434
3435fn wallet_reservation_orders<'a>(
3436 cache: &'a Cache,
3437 instrument_id: &InstrumentId,
3438 account_id: AccountId,
3439) -> Vec<OrderRef<'a>> {
3440 let mut client_order_ids = BTreeSet::new();
3441 client_order_ids.extend(cache.iter_client_order_ids_open(
3442 None,
3443 Some(instrument_id),
3444 None,
3445 Some(&account_id),
3446 ));
3447 client_order_ids.extend(cache.iter_client_order_ids_inflight(
3448 None,
3449 Some(instrument_id),
3450 None,
3451 Some(&account_id),
3452 ));
3453
3454 client_order_ids
3455 .into_iter()
3456 .filter_map(|client_order_id| cache.order(&client_order_id))
3457 .filter(|order| wallet_order_reserves_balance(order))
3458 .collect()
3459}
3460
3461fn update_instrument_id(
3462 cache: &Rc<RefCell<Cache>>,
3463 clock: &Rc<RefCell<dyn Clock>>,
3464 inner: &Rc<RefCell<PortfolioState>>,
3465 config: PortfolioConfig,
3466 instrument_id: &InstrumentId,
3467) {
3468 inner
3469 .borrow_mut()
3470 .unrealized_pnls
3471 .shift_remove(instrument_id);
3472
3473 if inner.borrow().initialized || !inner.borrow().pending_calcs.contains(instrument_id) {
3474 return;
3475 }
3476
3477 let instrument = match cache.borrow().instrument(instrument_id) {
3478 Some(instrument) => instrument.clone(),
3479 None => {
3480 log::error!("Cannot update tick: no instrument found for {instrument_id}");
3481 return;
3482 }
3483 };
3484
3485 let mut by_account: IndexMap<AccountId, (Vec<OrderAny>, Vec<Position>)> = IndexMap::new();
3486 {
3487 let cache_ref = cache.borrow();
3488 for order in cache_ref
3489 .orders_open(None, Some(instrument_id), None, None, None)
3490 .iter()
3491 .map(|o| (*o).clone())
3492 {
3493 if let Some(account_id) = order.account_id() {
3494 by_account.entry(account_id).or_default().0.push(order);
3495 }
3496 }
3497
3498 for position in cache_ref
3499 .positions_open(None, Some(instrument_id), None, None, None)
3500 .iter()
3501 .map(|p| (*p).clone())
3502 {
3503 by_account
3504 .entry(position.account_id)
3505 .or_default()
3506 .1
3507 .push(position);
3508 }
3509
3510 if by_account.is_empty()
3511 && let Some(account) =
3512 resolve_account_for_instrument(&cache_ref, instrument_id, None).map(|a| a.cloned())
3513 {
3514 by_account.entry(account.id()).or_default();
3515 }
3516 }
3517
3518 if by_account.is_empty() {
3519 log::error!(
3520 "Cannot update tick: no account registered for {}",
3521 instrument_id.venue
3522 );
3523 return;
3524 }
3525
3526 let ts_event = clock.borrow().timestamp_ns();
3527 let mut ok = true;
3528 let mut any_margin = false;
3529
3530 for (account_id, (orders, positions)) in by_account {
3531 let Some(mut account) = cache.borrow().account(&account_id).map(|a| a.cloned()) else {
3532 log::error!("Cannot update tick: no account registered for {account_id}");
3533 ok = false;
3534 continue;
3535 };
3536
3537 let orders_refs: Vec<&OrderAny> = orders.iter().collect();
3538 let mut account_updated = inner
3539 .borrow()
3540 .accounts
3541 .update_orders_in_place(&mut account, &instrument, &orders_refs, ts_event)
3542 .is_some();
3543
3544 if !account_updated {
3545 ok = false;
3546 }
3547
3548 if let AccountAny::Margin(margin_account) = &mut account {
3549 any_margin = true;
3550
3551 if inner
3552 .borrow()
3553 .accounts
3554 .update_positions_in_place(
3555 margin_account,
3556 &instrument,
3557 positions.iter().collect(),
3558 ts_event,
3559 )
3560 .is_some()
3561 {
3562 account_updated = true;
3563 } else {
3564 ok = false;
3565 }
3566 }
3567
3568 if account_updated {
3569 cache.borrow_mut().update_account(&account).unwrap();
3570 }
3571 }
3572
3573 let portfolio_clone = Portfolio {
3574 clock: Rc::clone(clock),
3575 cache: Rc::clone(cache),
3576 inner: Rc::clone(inner),
3577 config,
3578 };
3579
3580 let result_unrealized_pnl: Option<Money> =
3581 portfolio_clone.calculate_unrealized_pnl(instrument_id, None, None, None);
3582
3583 if ok && (!any_margin || result_unrealized_pnl.is_some()) {
3584 inner.borrow_mut().pending_calcs.remove(instrument_id);
3585 if inner.borrow().pending_calcs.is_empty() {
3586 inner.borrow_mut().initialized = true;
3587 }
3588 }
3589}
3590
3591fn update_order(
3592 cache: &Rc<RefCell<Cache>>,
3593 clock: &Rc<RefCell<dyn Clock>>,
3594 inner: &Rc<RefCell<PortfolioState>>,
3595 config: PortfolioConfig,
3596 event: &OrderEventAny,
3597 source: OrderUpdateSource,
3598) {
3599 let mut mark_pre_position_fill_event = None;
3600
3601 if let OrderEventAny::Filled(order_filled) = event {
3602 match source {
3603 OrderUpdateSource::Endpoint => {
3604 mark_pre_position_fill_event = Some(order_filled.event_id);
3605 }
3606 OrderUpdateSource::Topic => {
3607 if inner
3608 .borrow_mut()
3609 .pre_position_fill_events
3610 .remove(&order_filled.event_id)
3611 {
3612 return;
3613 }
3614 }
3615 }
3616 }
3617
3618 let account_id = match event.account_id() {
3619 Some(account_id) => account_id,
3620 None => {
3621 return; }
3623 };
3624
3625 let (instrument, calculate_account_state, is_wallet) = {
3627 let cache_ref = cache.borrow();
3628
3629 let account = match cache_ref.try_account(&account_id) {
3630 Ok(account) => account,
3631 Err(e) => {
3632 log::error!("Cannot update order: {e}");
3633 return;
3634 }
3635 };
3636
3637 let (calculate_account_state, is_wallet) = match &*account {
3638 AccountAny::Margin(margin_account) => {
3639 (margin_account.base.calculate_account_state, false)
3640 }
3641 AccountAny::Cash(cash_account) => (cash_account.base.calculate_account_state, false),
3642 AccountAny::Betting(betting_account) => {
3643 (betting_account.base.calculate_account_state, false)
3644 }
3645 AccountAny::Wallet(wallet_account) => {
3646 (wallet_account.base.calculate_account_state, true)
3647 }
3648 };
3649
3650 if !calculate_account_state && !is_wallet {
3651 return;
3652 }
3653
3654 match event {
3655 OrderEventAny::Submitted(_)
3656 | OrderEventAny::Accepted(_)
3657 | OrderEventAny::Canceled(_)
3658 | OrderEventAny::Expired(_)
3659 | OrderEventAny::Rejected(_)
3660 | OrderEventAny::Triggered(_)
3661 | OrderEventAny::PendingUpdate(_)
3662 | OrderEventAny::PendingCancel(_)
3663 | OrderEventAny::ModifyRejected(_)
3664 | OrderEventAny::CancelRejected(_)
3665 | OrderEventAny::Updated(_)
3666 | OrderEventAny::Filled(_)
3667 | OrderEventAny::FillVoided(_) => {}
3668 _ => {
3669 return;
3670 }
3671 }
3672
3673 let order = cache_ref.order(&event.client_order_id());
3674 if order.is_none() && !matches!(event, OrderEventAny::Filled(_)) {
3675 log::error!(
3676 "Cannot update order: {} not found in the cache",
3677 event.client_order_id()
3678 );
3679 return; }
3681
3682 if !is_wallet
3683 && matches!(event, OrderEventAny::Rejected(_))
3684 && order.is_some_and(|order| order.order_type() != OrderType::StopLimit)
3685 {
3686 return; }
3688
3689 let instrument = if let Some(instrument) = cache_ref.instrument(&event.instrument_id()) {
3690 instrument.clone()
3691 } else {
3692 log::error!(
3693 "Cannot update order: no instrument found for {}",
3694 event.instrument_id()
3695 );
3696 return;
3697 };
3698
3699 (instrument, calculate_account_state, is_wallet)
3700 };
3701
3702 let mut working_account = match take_or_clone_account(cache, account_id) {
3703 Some(account) => account,
3704 None => {
3705 log::error!(
3706 "Cannot update order: {}",
3707 AccountLookupError::not_found(account_id)
3708 );
3709 return;
3710 }
3711 };
3712
3713 if let OrderEventAny::Filled(order_filled) = event
3714 && calculate_account_state
3715 {
3716 if !instrument.is_spread() {
3717 let (post_balance, result) =
3718 inner
3719 .borrow()
3720 .accounts
3721 .update_balances(working_account, &instrument, order_filled);
3722 working_account = post_balance;
3723
3724 if let Err(e) = result {
3725 log::error!("{e}");
3726 inner
3727 .borrow_mut()
3728 .balance_error
3729 .get_or_insert_with(|| e.to_string());
3730 }
3731 }
3732
3733 cache.borrow_mut().cache_account_owned(working_account);
3734
3735 let portfolio_clone = Portfolio {
3736 clock: Rc::clone(clock),
3737 cache: Rc::clone(cache),
3738 inner: Rc::clone(inner),
3739 config,
3740 };
3741
3742 match portfolio_clone.calculate_unrealized_pnl(
3743 &order_filled.instrument_id,
3744 None,
3745 Some(&account_id),
3746 None,
3747 ) {
3748 Some(unrealized_pnl) => {
3749 inner
3750 .borrow_mut()
3751 .unrealized_pnls
3752 .insert(event.instrument_id(), unrealized_pnl);
3753 }
3754 None => {
3755 log::debug!(
3758 "Failed to calculate unrealized PnL for {}, marking as pending",
3759 event.instrument_id()
3760 );
3761 }
3762 }
3763
3764 let Some(restored_account) = take_or_clone_account(cache, account_id) else {
3765 log::error!(
3766 "Cannot finish fill account update: account {account_id} could not be restored"
3767 );
3768 return;
3769 };
3770 working_account = restored_account;
3771 } else if let OrderEventAny::FillVoided(fill_voided) = event
3772 && calculate_account_state
3773 {
3774 cache.borrow_mut().cache_account_owned(working_account);
3775
3776 let portfolio = Portfolio {
3777 clock: Rc::clone(clock),
3778 cache: Rc::clone(cache),
3779 inner: Rc::clone(inner),
3780 config,
3781 };
3782 {
3783 let cache_ref = cache.borrow();
3784 let positions =
3785 cache_ref.positions_open(None, Some(&fill_voided.instrument_id), None, None, None);
3786 let positions: Vec<&Position> = positions.iter().map(|position| &**position).collect();
3787 portfolio.update_net_position(&fill_voided.instrument_id, &positions);
3788 }
3789
3790 if let Some(pnl) =
3791 portfolio.calculate_unrealized_pnl(&fill_voided.instrument_id, None, None, None)
3792 {
3793 inner
3794 .borrow_mut()
3795 .unrealized_pnls
3796 .insert(fill_voided.instrument_id, pnl);
3797 } else {
3798 inner
3799 .borrow_mut()
3800 .unrealized_pnls
3801 .shift_remove(&fill_voided.instrument_id);
3802 }
3803
3804 if let Some(pnl) = portfolio.calculate_realized_pnl(&fill_voided.instrument_id, None, None)
3805 {
3806 inner
3807 .borrow_mut()
3808 .realized_pnls
3809 .insert(fill_voided.instrument_id, pnl);
3810 } else {
3811 inner
3812 .borrow_mut()
3813 .realized_pnls
3814 .shift_remove(&fill_voided.instrument_id);
3815 }
3816
3817 if !is_wallet {
3818 log::debug!("Updated {event}");
3819 return;
3820 }
3821
3822 let Some(restored_account) = take_or_clone_account(cache, account_id) else {
3823 log::error!(
3824 "Cannot recalculate Wallet reservations: account {account_id} was not restored after fill void"
3825 );
3826 return;
3827 };
3828 working_account = restored_account;
3829 }
3830
3831 let account_state = {
3833 let cache_ref = cache.borrow();
3834
3835 let orders_open = if is_wallet {
3836 wallet_reservation_orders(&cache_ref, &event.instrument_id(), account_id)
3837 } else {
3838 cache_ref.orders_open(
3839 None,
3840 Some(&event.instrument_id()),
3841 None,
3842 Some(&account_id),
3843 None,
3844 )
3845 };
3846
3847 let orders_open_refs: Vec<&OrderAny> = orders_open.iter().map(|order| &**order).collect();
3848
3849 inner.borrow().accounts.update_orders_in_place(
3850 &mut working_account,
3851 &instrument,
3852 &orders_open_refs,
3853 clock.borrow().timestamp_ns(),
3854 )
3855 };
3856
3857 let is_fill = matches!(event, OrderEventAny::Filled(_));
3858 let suppress_margin_fill_account_state =
3859 is_fill && matches!(working_account, AccountAny::Margin(_));
3860 let publish_account_state = !matches!(source, OrderUpdateSource::Endpoint) && !is_fill;
3861
3862 if !publish_account_state
3863 && !suppress_margin_fill_account_state
3864 && let Some(account_state) = account_state.as_ref()
3865 && let Err(e) = working_account.apply(account_state.clone())
3866 {
3867 log::error!("Cannot apply generated account state: {e}");
3868 }
3869
3870 let updated_account_id = working_account.id();
3871
3872 if account_state.is_some() || matches!(event, OrderEventAny::Filled(_)) {
3873 if let Err(e) = cache.borrow_mut().update_account_owned(working_account) {
3874 log::error!("Cannot persist updated account {updated_account_id}: {e}");
3875 return;
3876 }
3877 } else {
3878 cache.borrow_mut().cache_account_owned(working_account);
3879 }
3880
3881 if let Some(event_id) = mark_pre_position_fill_event {
3883 inner.borrow_mut().pre_position_fill_events.insert(event_id);
3884 }
3885
3886 if let Some(account_state) = account_state {
3887 if publish_account_state {
3888 record_and_publish_account_state(inner, &account_state);
3889 }
3890 } else {
3891 log::debug!("Added pending calculation for {}", instrument.id());
3892 inner.borrow_mut().pending_calcs.insert(instrument.id());
3893 }
3894
3895 log::debug!("Updated {event}");
3896}
3897
3898fn take_or_clone_account(cache: &Rc<RefCell<Cache>>, account_id: AccountId) -> Option<AccountAny> {
3899 let account = cache.borrow_mut().take_account(&account_id);
3900
3901 account.or_else(|| cache.borrow().account_owned(&account_id))
3902}
3903
3904fn on_order_event(
3905 cache: &Rc<RefCell<Cache>>,
3906 inner: &Rc<RefCell<PortfolioState>>,
3907 event: &OrderEventAny,
3908) {
3909 if let OrderEventAny::Filled(order_filled) = event {
3910 inner
3911 .borrow_mut()
3912 .pre_position_fill_events
3913 .remove(&order_filled.event_id);
3914 return;
3915 }
3916
3917 let account_id = match event.account_id() {
3918 Some(account_id) => account_id,
3919 None => return,
3920 };
3921
3922 let is_wallet = cache
3923 .borrow()
3924 .account(&account_id)
3925 .is_some_and(|account| matches!(&*account, AccountAny::Wallet(_)));
3926
3927 match event {
3928 OrderEventAny::Accepted(_)
3929 | OrderEventAny::Canceled(_)
3930 | OrderEventAny::Expired(_)
3931 | OrderEventAny::Rejected(_)
3932 | OrderEventAny::Updated(_) => {}
3933 OrderEventAny::Submitted(_)
3934 | OrderEventAny::Triggered(_)
3935 | OrderEventAny::PendingUpdate(_)
3936 | OrderEventAny::PendingCancel(_)
3937 | OrderEventAny::ModifyRejected(_)
3938 | OrderEventAny::CancelRejected(_)
3939 | OrderEventAny::FillVoided(_)
3940 if is_wallet => {}
3941 _ => return,
3942 }
3943
3944 let account_state = cache
3945 .borrow()
3946 .account(&account_id)
3947 .and_then(|account| account.last_event());
3948
3949 if let Some(account_state) = account_state {
3950 record_and_publish_account_state(inner, &account_state);
3951 }
3952}
3953
3954enum AccountPeek {
3957 MarginRecompute,
3958 LastEvent(Box<Option<AccountState>>),
3959 Missing,
3960}
3961
3962fn update_position(
3963 cache: &Rc<RefCell<Cache>>,
3964 clock: &Rc<RefCell<dyn Clock>>,
3965 inner: &Rc<RefCell<PortfolioState>>,
3966 config: PortfolioConfig,
3967 event: &PositionEvent,
3968) {
3969 let instrument_id = event.instrument_id();
3970 let account_id = event.account_id();
3971
3972 update_snapshot_timer_state(cache, clock, inner, config, account_id);
3973
3974 let portfolio_clone = Portfolio {
3975 clock: Rc::clone(clock),
3976 cache: Rc::clone(cache),
3977 inner: Rc::clone(inner),
3978 config,
3979 };
3980
3981 {
3982 let cache_ref = cache.borrow();
3983 let refs = cache_ref.positions_open(None, Some(&instrument_id), None, None, None);
3984 log::debug!("position fresh from cache -> {refs:?}");
3985 let positions: Vec<&Position> = refs.iter().map(|r| &**r).collect();
3986 portfolio_clone.update_net_position(&instrument_id, &positions);
3987 }
3988
3989 record_closed_position_pnl(cache, inner, config, event);
3990
3991 if let Some(calculated_unrealized_pnl) =
3992 portfolio_clone.calculate_unrealized_pnl(&instrument_id, None, None, None)
3993 {
3994 inner
3995 .borrow_mut()
3996 .unrealized_pnls
3997 .insert(event.instrument_id(), calculated_unrealized_pnl);
3998 } else {
3999 log::debug!(
4000 "Failed to calculate unrealized PnL for {}, marking as pending",
4001 event.instrument_id()
4002 );
4003 inner
4004 .borrow_mut()
4005 .pending_calcs
4006 .insert(event.instrument_id());
4007 }
4008
4009 if let Some(calculated_realized_pnl) =
4010 portfolio_clone.calculate_realized_pnl(&instrument_id, None, None)
4011 {
4012 inner
4013 .borrow_mut()
4014 .realized_pnls
4015 .insert(event.instrument_id(), calculated_realized_pnl);
4016 } else {
4017 inner
4018 .borrow_mut()
4019 .realized_pnls
4020 .shift_remove(&event.instrument_id());
4021 log::warn!(
4022 "Failed to calculate realized PnL for {}, marking as pending",
4023 event.instrument_id()
4024 );
4025 inner
4026 .borrow_mut()
4027 .pending_calcs
4028 .insert(event.instrument_id());
4029 }
4030
4031 let pnl_only = matches!(
4035 event,
4036 PositionEvent::PositionAdjusted(adjustment) if adjustment.quantity_change.is_none()
4037 );
4038
4039 let peek = {
4041 let cache_ref = cache.borrow();
4042 match cache_ref.account(&account_id) {
4043 Some(account) => match &*account {
4044 AccountAny::Margin(margin_account)
4045 if margin_account.calculate_account_state && !pnl_only =>
4046 {
4047 AccountPeek::MarginRecompute
4048 }
4049 account => AccountPeek::LastEvent(Box::new(account.last_event())),
4050 },
4051 None => AccountPeek::Missing,
4052 }
4053 };
4054 let account_state_to_publish = match peek {
4055 AccountPeek::MarginRecompute => {
4056 recompute_margin_account(cache, clock, inner, account_id, &instrument_id)
4057 }
4058 AccountPeek::LastEvent(last_event) => *last_event,
4059 AccountPeek::Missing => {
4060 log::error!(
4061 "Cannot update position: no account registered for {}",
4062 event.account_id()
4063 );
4064 None
4065 }
4066 };
4067
4068 if let Some(account_state) = account_state_to_publish {
4069 let already_published = pnl_only
4071 && inner
4072 .borrow()
4073 .last_published_account_states
4074 .get(&account_id)
4075 .is_some_and(|event_id| *event_id == account_state.event_id);
4076
4077 if !already_published {
4078 record_and_publish_account_state(inner, &account_state);
4079 }
4080 }
4081}
4082
4083fn record_and_publish_account_state(
4084 inner: &Rc<RefCell<PortfolioState>>,
4085 account_state: &AccountState,
4086) {
4087 let account_id = account_state.account_id;
4088 inner
4089 .borrow_mut()
4090 .last_published_account_states
4091 .insert(account_id, account_state.event_id);
4092 msgbus::publish_account_state(format!("events.account.{account_id}").into(), account_state);
4093}
4094
4095fn recompute_margin_account(
4100 cache: &Rc<RefCell<Cache>>,
4101 clock: &Rc<RefCell<dyn Clock>>,
4102 inner: &Rc<RefCell<PortfolioState>>,
4103 account_id: AccountId,
4104 instrument_id: &InstrumentId,
4105) -> Option<AccountState> {
4106 let instrument = { cache.borrow().instrument(instrument_id).cloned() };
4107 let Some(instrument) = instrument else {
4108 log::error!("Cannot update position: no instrument found for {instrument_id}");
4109 let cache_ref = cache.borrow();
4110 return cache_ref
4111 .account(&account_id)
4112 .and_then(|account| account.last_event());
4113 };
4114
4115 let taken_account = cache.borrow_mut().take_account(&account_id);
4117 let mut account = taken_account?;
4118 let AccountAny::Margin(margin_account) = &mut account else {
4119 return restore_cached_account(cache, account);
4121 };
4122
4123 let recomputed = {
4124 let cache_ref = cache.borrow();
4125 let refs =
4126 cache_ref.positions_open(None, Some(instrument_id), None, Some(&account_id), None);
4127 let positions: Vec<&Position> = refs.iter().map(|r| &**r).collect();
4128 inner.borrow_mut().accounts.update_positions_in_place(
4129 margin_account,
4130 &instrument,
4131 positions,
4132 clock.borrow().timestamp_ns(),
4133 )
4134 };
4135
4136 match recomputed {
4137 Some(account_state) => {
4138 cache.borrow_mut().update_account_owned(account).unwrap();
4139 Some(account_state)
4140 }
4141 None => restore_cached_account(cache, account),
4142 }
4143}
4144
4145fn restore_cached_account(cache: &Rc<RefCell<Cache>>, account: AccountAny) -> Option<AccountState> {
4147 let last_event = account.last_event();
4148 cache.borrow_mut().cache_account_owned(account);
4149
4150 last_event
4151}
4152
4153fn record_closed_position_pnl(
4154 cache: &Rc<RefCell<Cache>>,
4155 inner: &Rc<RefCell<PortfolioState>>,
4156 config: PortfolioConfig,
4157 event: &PositionEvent,
4158) {
4159 let position_id = match event {
4160 PositionEvent::PositionOpened(event) => event.position_id,
4161 PositionEvent::PositionChanged(event) => event.position_id,
4162 PositionEvent::PositionClosed(event) => event.position_id,
4163 PositionEvent::PositionAdjusted(event) => event.position_id,
4164 };
4165
4166 let cache_ref = cache.borrow();
4167 let Some(position) = cache_ref.position(&position_id) else {
4168 return;
4169 };
4170
4171 if !position.is_closed() {
4172 return;
4173 }
4174
4175 let Some(realized_pnl) = position.realized_pnl else {
4176 return;
4177 };
4178
4179 let mut inner_ref = inner.borrow_mut();
4180
4181 if !inner_ref
4182 .recorded_closed_position_cycles
4183 .insert((position.id, position.ts_opened))
4184 {
4185 return;
4186 }
4187
4188 let converted_pnl =
4189 converted_realized_pnl(&cache_ref, config, event, position_id, realized_pnl);
4190
4191 let ts_event = position.ts_last;
4192 inner_ref
4193 .analyzer
4194 .record_trade(&position.id, ts_event, &realized_pnl);
4195
4196 if let Some(converted_pnl) = converted_pnl {
4197 inner_ref
4198 .analyzer
4199 .record_trade(&position.id, ts_event, &converted_pnl);
4200 }
4201}
4202
4203fn converted_realized_pnl(
4204 cache_ref: &Cache,
4205 config: PortfolioConfig,
4206 event: &PositionEvent,
4207 position_id: PositionId,
4208 realized_pnl: Money,
4209) -> Option<Money> {
4210 let account = cache_ref.account(&event.account_id())?;
4211 let base_currency = account.base_currency()?;
4212
4213 if realized_pnl.currency == base_currency {
4214 return None;
4215 }
4216
4217 let xrate = if config.use_mark_xrates {
4218 cache_ref
4219 .get_mark_xrate(realized_pnl.currency, base_currency)
4220 .and_then(|xrate| Decimal::try_from(xrate).ok())
4221 } else {
4222 cache_ref.get_xrate(
4223 event.instrument_id().venue,
4224 realized_pnl.currency,
4225 base_currency,
4226 PriceType::Mid,
4227 )
4228 };
4229
4230 let Some(xrate) = xrate else {
4231 log::warn!(
4232 "Cannot record account-currency realized PnL for {position_id}: conversion failed from {} to {base_currency}",
4233 realized_pnl.currency
4234 );
4235 return None;
4236 };
4237
4238 let amount = (realized_pnl.as_decimal() * xrate).round_dp(u32::from(base_currency.precision));
4239 match Money::from_decimal(amount, base_currency) {
4240 Ok(amount) => Some(amount),
4241 Err(e) => {
4242 log::warn!("Cannot record account-currency realized PnL for {position_id}: {e}");
4243 None
4244 }
4245 }
4246}
4247
4248fn update_account(
4249 clock: &Rc<RefCell<dyn Clock>>,
4250 cache: &Rc<RefCell<Cache>>,
4251 inner: &Rc<RefCell<PortfolioState>>,
4252 config: PortfolioConfig,
4253 event: &AccountState,
4254) {
4255 let already_applied = {
4256 cache
4257 .borrow()
4258 .account(&event.account_id)
4259 .and_then(|account| account.last_event())
4260 .is_some_and(|last_event| last_event.event_id == event.event_id)
4261 };
4262
4263 if !already_applied && let Err(e) = cache.borrow_mut().update_account_state(event) {
4264 log::error!("Failed to update account state: {e}");
4265 return;
4266 }
4267
4268 let mut inner_ref = inner.borrow_mut();
4270 let should_log = if inner_ref.min_account_state_logging_interval_ns.is_zero() {
4271 true
4272 } else {
4273 let current_ts = event.ts_init;
4274 let last_ts = inner_ref
4275 .last_account_state_log_ts
4276 .get(&event.account_id)
4277 .copied()
4278 .unwrap_or_default();
4279
4280 if last_ts.is_zero()
4283 || current_ts.saturating_duration_since(last_ts)
4284 >= inner_ref.min_account_state_logging_interval_ns
4285 {
4286 inner_ref
4287 .last_account_state_log_ts
4288 .insert(event.account_id, current_ts);
4289 true
4290 } else {
4291 false
4292 }
4293 };
4294
4295 if should_log {
4296 log::info!("Updated {event}");
4297 }
4298 drop(inner_ref);
4299
4300 register_equity_curve_account(clock, cache, inner, config, event.account_id);
4301}
4302
4303fn equity_curve_timer_name(account_id: AccountId) -> String {
4304 format!("{EQUITY_CURVE_TIMER_PREFIX}{account_id}")
4305}
4306
4307fn register_equity_curve_account(
4308 clock: &Rc<RefCell<dyn Clock>>,
4309 cache: &Rc<RefCell<Cache>>,
4310 inner: &Rc<RefCell<PortfolioState>>,
4311 config: PortfolioConfig,
4312 account_id: AccountId,
4313) {
4314 if !config.equity_curve {
4315 return;
4316 }
4317
4318 let is_new = {
4319 let mut inner = inner.borrow_mut();
4320 if inner.equity_curve_finalized {
4321 return;
4322 }
4323 inner.equity_curve_accounts.insert(account_id)
4324 };
4325
4326 if !is_new {
4327 return;
4328 }
4329
4330 arm_equity_curve_timer(clock, cache, inner, config, account_id);
4331 let ts_event = clock.borrow().timestamp_ns();
4332 emit_snapshot(cache, clock, inner, config, account_id, ts_event);
4333}
4334
4335fn arm_equity_curve_timer(
4336 clock: &Rc<RefCell<dyn Clock>>,
4337 cache: &Rc<RefCell<Cache>>,
4338 inner: &Rc<RefCell<PortfolioState>>,
4339 config: PortfolioConfig,
4340 account_id: AccountId,
4341) {
4342 let day = DurationNanos::from_days(1);
4343 let Some(next_day) = clock.borrow().timestamp_ns().floor(day).checked_add(day) else {
4344 log::error!("Failed to calculate next equity curve sample for {account_id}");
4345 return;
4346 };
4347 let timer_name = equity_curve_timer_name(account_id);
4348 let cache_weak = Rc::downgrade(cache);
4349 let clock_weak = Rc::downgrade(clock);
4350 let inner_weak = Rc::downgrade(inner);
4351
4352 let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |event| {
4353 let Some(cache) = cache_weak.upgrade() else {
4354 return;
4355 };
4356 let Some(clock) = clock_weak.upgrade() else {
4357 return;
4358 };
4359 let Some(inner) = inner_weak.upgrade() else {
4360 return;
4361 };
4362 emit_snapshot(&cache, &clock, &inner, config, account_id, event.ts_event);
4363 });
4364
4365 if let Err(e) = clock.borrow_mut().set_timer_ns(
4366 &timer_name,
4367 day,
4368 Some(next_day),
4369 None,
4370 Some(TimeEventCallback::from(callback)),
4371 Some(false),
4372 Some(true),
4373 ) {
4374 log::error!("Failed to arm portfolio equity curve timer for {account_id}: {e}");
4375 }
4376}
4377
4378fn snapshot_timer_name(account_id: AccountId) -> String {
4379 format!("{SNAPSHOT_TIMER_PREFIX}{account_id}")
4380}
4381
4382#[doc(hidden)]
4387#[must_use]
4388pub fn is_snapshot_timer(name: &str) -> bool {
4389 name.starts_with(EQUITY_CURVE_TIMER_PREFIX) || name.starts_with(SNAPSHOT_TIMER_PREFIX)
4390}
4391
4392fn update_snapshot_timer_state(
4393 cache: &Rc<RefCell<Cache>>,
4394 clock: &Rc<RefCell<dyn Clock>>,
4395 inner: &Rc<RefCell<PortfolioState>>,
4396 config: PortfolioConfig,
4397 account_id: AccountId,
4398) {
4399 if config.snapshot_interval_ms.is_none() {
4400 return;
4401 }
4402
4403 let current_count = cache
4404 .borrow()
4405 .positions_open(None, None, None, Some(&account_id), None)
4406 .len();
4407
4408 let prev_count = inner
4409 .borrow()
4410 .account_open_positions
4411 .get(&account_id)
4412 .copied()
4413 .unwrap_or(0);
4414
4415 inner
4416 .borrow_mut()
4417 .account_open_positions
4418 .insert(account_id, current_count);
4419
4420 if prev_count == 0 && current_count > 0 {
4421 arm_snapshot_timer(cache, clock, inner, config, account_id);
4422 } else if prev_count > 0 && current_count == 0 {
4423 clock
4424 .borrow_mut()
4425 .cancel_timer(&snapshot_timer_name(account_id));
4426 }
4427}
4428
4429fn arm_snapshot_timer(
4430 cache: &Rc<RefCell<Cache>>,
4431 clock: &Rc<RefCell<dyn Clock>>,
4432 inner: &Rc<RefCell<PortfolioState>>,
4433 config: PortfolioConfig,
4434 account_id: AccountId,
4435) {
4436 let interval_ms = match config.snapshot_interval_ms {
4437 Some(ms) if ms > 0 => ms,
4438 _ => return,
4439 };
4440 let Ok(interval_ns) = DurationNanos::try_from_millis(interval_ms) else {
4441 log::error!("Failed to calculate portfolio snapshot interval for {account_id}");
4442 return;
4443 };
4444 let timer_name = snapshot_timer_name(account_id);
4445
4446 let cache_weak = Rc::downgrade(cache);
4447 let clock_weak = Rc::downgrade(clock);
4448 let inner_weak = Rc::downgrade(inner);
4449
4450 let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |event| {
4451 let cache = match cache_weak.upgrade() {
4452 Some(c) => c,
4453 None => return,
4454 };
4455 let clock = match clock_weak.upgrade() {
4456 Some(c) => c,
4457 None => return,
4458 };
4459 let inner = match inner_weak.upgrade() {
4460 Some(i) => i,
4461 None => return,
4462 };
4463 emit_snapshot(&cache, &clock, &inner, config, account_id, event.ts_event);
4464 });
4465
4466 if let Err(e) = clock.borrow_mut().set_timer_ns(
4467 &timer_name,
4468 interval_ns,
4469 None,
4470 None,
4471 Some(TimeEventCallback::from(callback)),
4472 Some(true),
4473 Some(false),
4474 ) {
4475 log::error!("Failed to arm portfolio snapshot timer for {account_id}: {e}");
4476 }
4477}
4478
4479fn emit_snapshot(
4480 cache: &Rc<RefCell<Cache>>,
4481 clock: &Rc<RefCell<dyn Clock>>,
4482 inner: &Rc<RefCell<PortfolioState>>,
4483 config: PortfolioConfig,
4484 account_id: AccountId,
4485 ts_event: nautilus_core::UnixNanos,
4486) {
4487 let mut portfolio = Portfolio {
4488 cache: Rc::clone(cache),
4489 clock: Rc::clone(clock),
4490 inner: Rc::clone(inner),
4491 config,
4492 };
4493
4494 let mut snapshot = match portfolio.build_snapshot_inner(account_id, false) {
4497 Some(snapshot) => snapshot,
4498 None => return,
4499 };
4500 snapshot.ts_event = ts_event;
4504
4505 msgbus::publish_portfolio_snapshot(format!("events.portfolio.{account_id}").into(), &snapshot);
4506
4507 let mut inner_mut = inner.borrow_mut();
4508 push_bounded(
4509 &mut inner_mut.portfolio_snapshots,
4510 account_id,
4511 snapshot,
4512 SNAPSHOT_BUFFER_CAP,
4513 );
4514}
4515
4516fn push_bounded(
4518 snapshots: &mut AHashMap<AccountId, VecDeque<PortfolioSnapshot>>,
4519 account_id: AccountId,
4520 snapshot: PortfolioSnapshot,
4521 cap: usize,
4522) {
4523 let ring = snapshots.entry(account_id).or_default();
4524 if ring.len() == cap {
4525 ring.pop_front();
4526 }
4527 ring.push_back(snapshot);
4528}
4529
4530#[cfg(test)]
4531mod tests {
4532 use nautilus_common::clock::VirtualClock;
4533 use nautilus_core::{UUID4, UnixNanos};
4534 use nautilus_model::{
4535 enums::{AccountType, OrderSide},
4536 events::order::spec::OrderFilledSpec,
4537 identifiers::{AccountId, Symbol},
4538 instruments::stubs::option_contract_appl,
4539 types::{Quantity, money::MONEY_RAW_MAX},
4540 };
4541 use rstest::rstest;
4542
4543 use super::*;
4544
4545 #[rstest]
4546 #[case::positive_cash(true)]
4547 #[case::negative_cash(false)]
4548 fn test_reported_snapshot_nets_venue_pnl_before_cash(#[case] positive_cash: bool) {
4549 let mut portfolio = Portfolio::new(
4550 Rc::new(RefCell::new(VirtualClock::new())),
4551 Rc::new(RefCell::new(Cache::new(None, None))),
4552 None,
4553 );
4554 let account_id = AccountId::new("BROKER-001");
4555 let cash_value = Money::from_raw(MONEY_RAW_MAX / 4 * 3, Currency::USD())
4556 .as_decimal()
4557 .round_dp(2);
4558
4559 let cash = Money::from_decimal(
4560 if positive_cash {
4561 cash_value
4562 } else {
4563 -cash_value
4564 },
4565 Currency::USD(),
4566 )
4567 .unwrap();
4568
4569 portfolio.update_account(&AccountState::new(
4570 account_id,
4571 AccountType::Margin,
4572 vec![AccountBalance::new(
4573 cash,
4574 Money::zero(Currency::USD()),
4575 cash,
4576 )],
4577 vec![],
4578 true,
4579 UUID4::new(),
4580 0.into(),
4581 0.into(),
4582 Some(Currency::USD()),
4583 ));
4584 let pnl = Money::from_raw(MONEY_RAW_MAX / 3, Currency::USD())
4585 .as_decimal()
4586 .round_dp(2);
4587 let mark = Price::from_decimal(pnl + Decimal::ONE).unwrap();
4588 let venues = AHashSet::from_iter([Venue::new("VENUE1"), Venue::new("VENUE2")]);
4589
4590 for (index, venue) in venues.iter().enumerate() {
4592 let mut option = option_contract_appl();
4593 option.id = InstrumentId::new(Symbol::new("OPTION"), *venue);
4594 let instrument = InstrumentAny::OptionContract(option);
4595
4596 let side = if (index == 0) == positive_cash {
4597 OrderSide::Buy
4598 } else {
4599 OrderSide::Sell
4600 };
4601
4602 let fill = OrderFilledSpec::builder()
4603 .instrument_id(instrument.id())
4604 .account_id(account_id)
4605 .order_side(side)
4606 .last_qty(Quantity::from("1"))
4607 .last_px(Price::from("1.00"))
4608 .currency(Currency::USD())
4609 .position_id(PositionId::new(format!("P-{venue}")))
4610 .build();
4611 let position = Position::new(&instrument, fill);
4612 let mut cache = portfolio.cache.borrow_mut();
4613 cache.add_instrument(instrument.clone()).unwrap();
4614 cache.add_position(&position, OmsType::Hedging).unwrap();
4615 cache
4616 .add_mark_price(MarkPriceUpdate::new(
4617 instrument.id(),
4618 mark,
4619 0.into(),
4620 0.into(),
4621 ))
4622 .unwrap();
4623 }
4624
4625 let account = portfolio
4626 .cache
4627 .borrow()
4628 .account_ref(&account_id)
4629 .unwrap()
4630 .clone_without_events();
4631 let mut unpriced = AHashSet::new();
4632 let unrealized = IndexMap::from([(Currency::USD(), Money::zero(Currency::USD()))]);
4633 let equity = portfolio.calculate_snapshot_equity(
4634 &account,
4635 &venues,
4636 &unrealized,
4637 &mut unpriced,
4638 false,
4639 );
4640 let snapshot = portfolio.build_snapshot(&account_id).unwrap();
4641
4642 assert_eq!(equity, Some(IndexMap::from([(Currency::USD(), cash)])));
4643 assert_eq!(snapshot.unrealized_pnls, vec![Money::zero(Currency::USD())]);
4644 assert_eq!(snapshot.total_equity, vec![cash]);
4645 assert_eq!(unpriced, AHashSet::new());
4646 assert_eq!(snapshot.unpriced_instruments, Vec::<InstrumentId>::new());
4647 assert!(!snapshot.is_stale);
4648 }
4649
4650 #[rstest]
4651 #[case::equity_curve(equity_curve_timer_name(AccountId::new("SIM-001")), true)]
4652 #[case::snapshot(snapshot_timer_name(AccountId::new("SIM-001")), true)]
4653 #[case::strategy("settlement".to_string(), false)]
4654 #[case::similar_name("portfolio_snapshot_strategy".to_string(), false)]
4655 fn test_is_snapshot_timer(#[case] name: String, #[case] expected: bool) {
4656 assert_eq!(is_snapshot_timer(&name), expected);
4657 }
4658
4659 fn mk_snapshot(seq: u64) -> PortfolioSnapshot {
4660 PortfolioSnapshot::new(
4661 AccountId::new("SIM-001"),
4662 AccountType::Cash,
4663 None,
4664 Vec::new(),
4665 Vec::new(),
4666 Vec::new(),
4667 Vec::new(),
4668 Vec::new(),
4669 None,
4670 false,
4671 Vec::new(),
4672 Vec::new(),
4673 Vec::new(),
4674 UUID4::new(),
4675 UnixNanos::from(seq),
4676 UnixNanos::from(seq),
4677 )
4678 }
4679
4680 #[rstest]
4681 fn push_bounded_drops_oldest_when_at_cap() {
4682 let account_id = AccountId::new("SIM-001");
4683 let mut snapshots: AHashMap<AccountId, VecDeque<PortfolioSnapshot>> = AHashMap::new();
4684
4685 for seq in 0..5 {
4686 push_bounded(&mut snapshots, account_id, mk_snapshot(seq), 3);
4687 }
4688
4689 let ring = snapshots.get(&account_id).expect("ring exists");
4690 assert_eq!(ring.len(), 3);
4691 assert_eq!(ring.front().unwrap().ts_event, UnixNanos::from(2));
4692 assert_eq!(ring.back().unwrap().ts_event, UnixNanos::from(4));
4693 }
4694}