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