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