Skip to main content

nautilus_portfolio/
portfolio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides a generic `Portfolio` for all environments.
17
18#![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
56// Sized for post-run backtest analysis (e.g. ~11 days at 1s cadence, or years
57// at per-minute cadence), long-lived live deployments should consume snapshots
58// via the message bus instead of relying on this buffer.
59const 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    /// Creates a shallow clone of the Portfolio that shares the same internal state.
221    ///
222    /// This is useful when multiple components need to reference the same Portfolio
223    /// without creating duplicate msgbus handler registrations.
224    #[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        // Typed handlers for subscriptions
243        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    /// Returns a reference to the cache.
407    #[must_use]
408    pub fn cache(&self) -> &Rc<RefCell<Cache>> {
409        &self.cache
410    }
411
412    /// Returns a reference to the clock.
413    #[must_use]
414    pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
415        &self.clock
416    }
417
418    /// Returns `true` if the portfolio has been initialized.
419    #[must_use]
420    pub fn is_initialized(&self) -> bool {
421        self.inner.borrow().initialized
422    }
423
424    /// Returns the locked balances for the given venue.
425    ///
426    /// Locked balances represent funds reserved for open orders.
427    #[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    /// Returns the initial margin requirements for the given venue.
439    ///
440    /// Only applicable for margin accounts. Returns empty map for cash accounts.
441    #[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    /// Returns the maintenance margin requirements for the given venue.
461    ///
462    /// Only applicable for margin accounts. Returns empty map for cash accounts.
463    #[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    /// Returns the unrealized PnLs for all positions at the given venue.
483    ///
484    /// Calculates mark-to-market PnL based on current market prices.
485    #[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            // IndexSet preserves the deterministic order of cache.positions
517            // through the dedup so the returned currency map iterates in a
518            // stable order across runs.
519            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    /// Returns the realized PnLs for all positions at the given venue.
551    ///
552    /// Calculates total realized profit and loss from closed positions.
553    #[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()); // Nothing to calculate
566            }
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()); // Nothing to calculate
617            }
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    /// Returns the total PnL for the given instrument ID.
712    ///
713    /// Total PnL = Realized PnL + Unrealized PnL
714    #[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    /// Returns the total PnLs for the given venue.
735    ///
736    /// Total PnL = Realized PnL + Unrealized PnL for each currency. Pass `account_id`
737    /// to scope the aggregation to a single account when multiple accounts share the venue.
738    #[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    /// Returns the per-currency mark-to-market value of open positions at the given venue.
757    ///
758    /// For each open position the valuation uses the portfolio's internal price
759    /// resolution, which prefers mark prices (when configured), falls back to
760    /// side-appropriate bid/ask, then last trade, then the most recent bar close.
761    /// Instruments without any available price are skipped and the venue is flagged
762    /// for a no-price warning. Pass `account_id` to scope the aggregation to a
763    /// single account when multiple accounts share the venue.
764    #[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    /// Returns the per-currency total equity for the given venue.
774    ///
775    /// For cash accounts: `balance.total + Σ mark_value(open positions)` per currency.
776    /// For margin accounts: `balance.total + Σ unrealized_pnl(open positions)` per currency.
777    ///
778    /// Open-position instruments that cannot be priced are tracked via
779    /// [`Portfolio::missing_price_instruments`] (and warned once) for both branches,
780    /// so equity understatement does not go unnoticed. Pass `account_id` to scope
781    /// the aggregation to a single account when multiple accounts share the venue.
782    #[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            // Sum cached unrealized PnLs; fall through to recalculation on cache miss.
818            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                    // The instrument-keyed cache aggregates across all accounts on
832                    // the same venue, so bypass it when the caller filters by
833                    // account_id.
834                    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    /// Builds a [`PortfolioSnapshot`] for the given account at the current clock time.
878    ///
879    /// Unrealized PnL and mark values span the venues the account currently
880    /// holds open positions on; realized PnL spans every venue the account has
881    /// touched (open or closed) so a multi-venue account where one venue is
882    /// now flat still reports its accumulated realized PnL. Returns `None` if
883    /// no account is registered.
884    #[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        // Collect venues the account has touched. `open_venues` drives the
900        // unrealized PnL and mark-value sums; `all_venues` extends to closed
901        // positions so realized PnL on a venue with no open exposure (a
902        // multi-venue account where one venue is now flat) still rolls up.
903        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(&currency).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    /// Returns the recorded portfolio snapshots for the given account, in order of emission.
1061    ///
1062    /// With `equity_curve` enabled, snapshots are recorded at account registration, every
1063    /// UTC midnight including while flat, and shutdown. Setting `snapshot_interval_ms` adds
1064    /// fine-grained samples while the account holds an open position. The ring is bounded;
1065    /// long-lived live deployments should consume snapshots via the message bus instead of
1066    /// relying on this buffer. Cleared on [`Portfolio::reset`].
1067    #[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    /// Records one final equity-curve sample for every registered account and stops its timer.
1078    ///
1079    /// Has no effect when `equity_curve` is disabled. Calling this method more than once
1080    /// before [`Portfolio::reset`] has no effect.
1081    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    /// Returns the instruments currently flagged as unpriceable for the given venue and account.
1116    ///
1117    /// An entry is added the first time [`Portfolio::mark_values`] cannot value an open position
1118    /// because its price is missing or its notional is invalid (after also emitting a warn log),
1119    /// and removed once the instrument can be valued again so a subsequent drop re-warns.
1120    #[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        // Sort so the public Vec is deterministic even though the underlying
1145        // tracking set is AHash-backed.
1146        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        // Sort first so the warn-log sequence is deterministic across runs.
1181        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    // Returns `true` if at least one open position was seen (priced or not),
1245    // `false` if the venue is flat. Unpriced instruments are written to
1246    // `unpriced` for the caller to flow into `update_missing_price_state`.
1247    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            // Sum exact Decimals; the caller rounds once so sub-precision positions survive
1346            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; // Nothing to calculate
1400                }
1401            };
1402
1403            // Get account for THIS position
1404            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            // Validate consistent base currency across accounts when the caller did not select a
1421            // target. An explicit target provides the common aggregation currency instead.
1422            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        // Net exposure is reported as a magnitude once opposing sides are netted
1521        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    /// Initializes account margin based on existing open orders.
1616    ///
1617    /// # Panics
1618    ///
1619    /// Panics if updating the cache with a mutated account fails.
1620    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            // Ordered so margin recalculation materializes any unreported balance currency
1628            // in a stable sequence; the account balance map preserves insertion order.
1629            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    /// Rebuilds Wallet account reservations from locally active orders.
1720    ///
1721    /// This runs independently of venue reconciliation because Wallet reservations are transient
1722    /// and must be reconstructed after every cache restore.
1723    ///
1724    /// # Errors
1725    ///
1726    /// Returns an error if an active Wallet order cannot be resolved to an instrument or its debit
1727    /// reservation cannot be reconstructed exactly.
1728    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    /// Initializes account margin based on existing open positions.
1813    ///
1814    /// # Panics
1815    ///
1816    /// Panics if calculation of PnL or updating the cache with a mutated account fails.
1817    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        // Ordered for the same reason as `initialize_orders`
1823        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    /// Updates portfolio calculations based on a new quote tick.
1961    ///
1962    /// Recalculates unrealized PnL for positions affected by the quote update.
1963    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    /// Updates portfolio calculations based on a new bar.
1968    ///
1969    /// Updates cached bar close prices and recalculates unrealized PnL.
1970    pub fn update_bar(&mut self, bar: &Bar) {
1971        update_bar(&self.cache, &self.clock, &self.inner, self.config, bar);
1972    }
1973
1974    /// Updates portfolio with a new account state event.
1975    pub fn update_account(&mut self, event: &AccountState) {
1976        update_account(&self.clock, &self.cache, &self.inner, self.config, event);
1977    }
1978
1979    /// Updates portfolio calculations based on an order event.
1980    ///
1981    /// Handles balance updates for order fills and margin calculations for order changes.
1982    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    /// Returns realized PnLs recorded during portfolio event processing.
1994    ///
1995    /// Each record is `(position_id, ts_event, realized_pnl)`.
1996    #[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    /// Computes an owned [`PortfolioStatistics`] snapshot from the portfolio's current cache state.
2002    ///
2003    /// Aggregates balances across every account, includes cached positions and their snapshots, and
2004    /// merges close-time PnLs recorded during processing. Recomputes on each call; callers on hot paths
2005    /// should invoke it sparingly.
2006    #[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    /// Updates portfolio calculations based on a position event.
2038    ///
2039    /// Recalculates net positions, unrealized PnL, and margin requirements.
2040    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; // Nothing to calculate
2257            }
2258
2259            if position.side == PositionSide::Flat {
2260                continue; // Nothing to calculate
2261            }
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                        // TODO: Improve logging
2309                        "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        // Performance: This method maintains an incremental cache of snapshot PnLs
2344        // It only deserializes new snapshots that haven't been processed yet
2345        // Tracks sum and last PnL per position for efficient NETTING OMS support
2346
2347        // Get all position IDs that have snapshots for this instrument
2348        let snapshot_position_ids = self.cache.borrow().position_snapshot_ids(instrument_id);
2349
2350        if snapshot_position_ids.is_empty() {
2351            return; // Nothing to process
2352        }
2353
2354        let mut rebuild = false;
2355
2356        // Detect purge/reset (count regression) or a settle that replaced frames without moving
2357        // the count, both of which invalidate the cached per-position aggregates
2358        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            // Full rebuild: process all snapshots from scratch
2384            for position_id in &snapshot_position_ids {
2385                // Track the raw frame count, not the decoded count: snapshots that fail
2386                // to deserialize are skipped and would otherwise make the incremental
2387                // path reprocess trailing valid frames next time.
2388                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            // Incremental path: only process new snapshots
2466            for position_id in &snapshot_position_ids {
2467                // Compare raw frame counts first so untouched positions skip any
2468                // allocation/serde cost on repeated PnL refreshes.
2469                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        // Ensure snapshot PnLs are cached for this instrument
2583        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        // Filter snapshots by account when requested so closed-position PnL
2607        // from other accounts on the same venue does not leak in. Sort the
2608        // collected IDs so the per-snapshot pending-calcs/early-return path
2609        // and the value accumulation iterate in a deterministic sequence.
2610        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        // Check if we need to use NETTING OMS logic
2676        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            // NETTING OMS: Apply 3-case rule for position cycles
2684
2685            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                // A closed position whose final cycle was snapshotted carries that cycle both in
2695                // its last frame and in its own realized PnL, which the loop below adds; drop
2696                // the frame here so the cycle lands once.
2697                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            // Add realized PnL from current active positions
2765            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            // HEDGING OMS or no snapshots: Simple aggregation
2812            // Add snapshot PnLs (sum all)
2813            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            // Add realized PnL from current positions
2860            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); // No conversion needed
3003        }
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    // Pairs with `calculate_xrate_to_base`, which yields a unit rate when conversion is disabled:
3057    // the output currency must ignore the account base currency for the same reason, otherwise a
3058    // native cost-currency amount is labelled with a currency it was never converted into.
3059    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(&currency) {
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, &quote.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
3157/// Account for an instrument. For broker-routed instruments the account lives
3158/// under the broker venue (e.g. `IB`) while the instrument carries the exchange
3159/// MIC (e.g. `IBIS`); on venue miss, fall back to the position-owning account.
3160fn 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; // No Account Assigned
3369        }
3370    };
3371
3372    // Scoped borrow: must drop before calling AccountsManager (which borrows cache internally)
3373    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; // No Order Found
3427        }
3428
3429        if !is_wallet
3430            && matches!(event, OrderEventAny::Rejected(_))
3431            && order.is_some_and(|order| order.order_type() != OrderType::StopLimit)
3432        {
3433            return; // No change to account state
3434        }
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    // No cache borrow held: AccountsManager borrows cache internally for xrate lookups.
3466    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    // Consumed by the matching `events.order.*` topic handler; engine publishes after every endpoint send
3618    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
3696/// Result of peeking at the cached account inside [`update_position`]: only a margin account
3697/// with `calculate_account_state` set needs the owned recompute path.
3698enum 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    // Peek under a borrow: the account event log grows per fill, so a clone here was O(n)
3774    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
3808/// Recalculates the margin account for `instrument_id` from the currently open positions.
3809///
3810/// Moves the account out of the cache for the recompute instead of cloning it, then moves it
3811/// back without a database write when the recompute produces no new state.
3812fn 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    // Bind the taken account so the mutable cache borrow drops before the recompute
3829    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        // The caller peeked a margin account, so this only restores an unexpected account type
3833        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
3858/// Returns the `account` to the cache without a database write and reports its last state event.
3859fn 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    // Throttled logging logic
3982    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        // Saturating: an out-of-order event carrying an earlier `ts_init` keeps the throttle
3992        // engaged rather than wrapping into an interval that always logs.
3993        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 // Throttling disabled, always log
4005    };
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    // Stamp the snapshot with the timer's scheduled fire time so the cadence
4201    // is preserved even if the dispatcher batches or runs late. ts_init stays
4202    // the construction time set by build_snapshot.
4203    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
4216/// Appends `snapshot` onto the per-account ring, dropping the oldest entry when at `cap`.
4217fn 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}