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