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