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