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