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::{cell::RefCell, collections::VecDeque, fmt::Debug, rc::Rc};
19
20use ahash::{AHashMap, AHashSet};
21use indexmap::{IndexMap, IndexSet};
22use nautilus_analysis::{analyzer::PortfolioAnalyzer, snapshot::PortfolioStatistics};
23use nautilus_common::{
24    cache::{AccountLookupError, Cache},
25    clock::Clock,
26    enums::LogColor,
27    msgbus::{self, MessagingSwitchboard, TypedHandler, TypedIntoHandler},
28    timer::{TimeEvent, TimeEventCallback},
29};
30use nautilus_core::{UUID4, UnixNanos, WeakCell, datetime::NANOSECONDS_IN_MILLISECOND};
31use nautilus_model::{
32    accounts::{Account, AccountAny},
33    data::{Bar, MarkPriceUpdate, QuoteTick},
34    enums::{OmsType, OrderType, PositionSide, PriceType},
35    events::{AccountState, OrderEventAny, PortfolioSnapshot, position::PositionEvent},
36    identifiers::{AccountId, InstrumentId, PositionId, Venue},
37    instruments::{Instrument, InstrumentAny},
38    orders::{Order, OrderAny},
39    position::Position,
40    types::{AccountBalance, Currency, MarginBalance, Money, Price},
41};
42use rust_decimal::Decimal;
43
44use crate::{config::PortfolioConfig, manager::AccountsManager};
45
46// Sized for post-run backtest analysis (e.g. ~11 days at 1s cadence, or years
47// at per-minute cadence), long-lived live deployments should consume snapshots
48// via the message bus instead of relying on this buffer.
49const SNAPSHOT_BUFFER_CAP: usize = 1_000_000;
50
51struct PortfolioState {
52    accounts: AccountsManager,
53    analyzer: PortfolioAnalyzer,
54    unrealized_pnls: IndexMap<InstrumentId, Money>,
55    realized_pnls: IndexMap<InstrumentId, Money>,
56    recorded_closed_position_cycles: AHashSet<(PositionId, UnixNanos)>,
57    snapshot_sum_per_position: AHashMap<PositionId, Money>,
58    snapshot_last_per_position: AHashMap<PositionId, Money>,
59    snapshot_processed_counts: AHashMap<PositionId, usize>,
60    snapshot_account_ids: AHashMap<PositionId, AccountId>,
61    net_positions: IndexMap<InstrumentId, Decimal>,
62    pending_calcs: AHashSet<InstrumentId>,
63    bar_close_prices: AHashMap<InstrumentId, Price>,
64    initialized: bool,
65    last_account_state_log_ts: AHashMap<AccountId, u64>,
66    min_account_state_logging_interval_ns: u64,
67    venues_missing_price: AHashMap<Venue, AHashSet<InstrumentId>>,
68    account_open_positions: AHashMap<AccountId, usize>,
69    portfolio_snapshots: AHashMap<AccountId, VecDeque<PortfolioSnapshot>>,
70    pre_position_fill_events: AHashSet<UUID4>,
71}
72
73#[derive(Clone, Copy)]
74enum OrderUpdateSource {
75    Endpoint,
76    Topic,
77}
78
79impl PortfolioState {
80    fn new(
81        clock: Rc<RefCell<dyn Clock>>,
82        cache: Rc<RefCell<Cache>>,
83        config: &PortfolioConfig,
84    ) -> Self {
85        let min_account_state_logging_interval_ns = config
86            .min_account_state_logging_interval_ms
87            .map_or(0, |ms| ms * NANOSECONDS_IN_MILLISECOND);
88
89        Self {
90            accounts: AccountsManager::new(clock, cache),
91            analyzer: PortfolioAnalyzer::default(),
92            unrealized_pnls: IndexMap::new(),
93            realized_pnls: IndexMap::new(),
94            recorded_closed_position_cycles: AHashSet::new(),
95            snapshot_sum_per_position: AHashMap::new(),
96            snapshot_last_per_position: AHashMap::new(),
97            snapshot_processed_counts: AHashMap::new(),
98            snapshot_account_ids: AHashMap::new(),
99            net_positions: IndexMap::new(),
100            pending_calcs: AHashSet::new(),
101            bar_close_prices: AHashMap::new(),
102            initialized: false,
103            last_account_state_log_ts: AHashMap::new(),
104            min_account_state_logging_interval_ns,
105            venues_missing_price: AHashMap::new(),
106            account_open_positions: AHashMap::new(),
107            portfolio_snapshots: AHashMap::new(),
108            pre_position_fill_events: AHashSet::new(),
109        }
110    }
111
112    fn reset(&mut self) {
113        log::debug!("RESETTING");
114        self.net_positions.clear();
115        self.unrealized_pnls.clear();
116        self.realized_pnls.clear();
117        self.recorded_closed_position_cycles.clear();
118        self.snapshot_sum_per_position.clear();
119        self.snapshot_last_per_position.clear();
120        self.snapshot_processed_counts.clear();
121        self.snapshot_account_ids.clear();
122        self.pending_calcs.clear();
123        self.bar_close_prices.clear();
124        self.last_account_state_log_ts.clear();
125        self.venues_missing_price.clear();
126        self.account_open_positions.clear();
127        self.portfolio_snapshots.clear();
128        self.pre_position_fill_events.clear();
129        self.analyzer.reset();
130        self.initialized = false;
131        log::debug!("READY");
132    }
133}
134
135pub struct Portfolio {
136    pub(crate) clock: Rc<RefCell<dyn Clock>>,
137    pub(crate) cache: Rc<RefCell<Cache>>,
138    inner: Rc<RefCell<PortfolioState>>,
139    config: PortfolioConfig,
140}
141
142impl Debug for Portfolio {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct(stringify!(Portfolio)).finish()
145    }
146}
147
148impl Portfolio {
149    pub fn new(
150        clock: Rc<RefCell<dyn Clock>>,
151        cache: Rc<RefCell<Cache>>,
152        config: Option<PortfolioConfig>,
153    ) -> Self {
154        let config = config.unwrap_or_default();
155        let inner = Rc::new(RefCell::new(PortfolioState::new(
156            clock.clone(),
157            cache.clone(),
158            &config,
159        )));
160
161        Self::register_message_handlers(&cache, &clock, &inner, config);
162
163        Self {
164            clock,
165            cache,
166            inner,
167            config,
168        }
169    }
170
171    /// Creates a shallow clone of the Portfolio that shares the same internal state.
172    ///
173    /// This is useful when multiple components need to reference the same Portfolio
174    /// without creating duplicate msgbus handler registrations.
175    #[must_use]
176    pub fn clone_shallow(&self) -> Self {
177        Self {
178            clock: self.clock.clone(),
179            cache: self.cache.clone(),
180            inner: self.inner.clone(),
181            config: self.config,
182        }
183    }
184
185    fn register_message_handlers(
186        cache: &Rc<RefCell<Cache>>,
187        clock: &Rc<RefCell<dyn Clock>>,
188        inner: &Rc<RefCell<PortfolioState>>,
189        config: PortfolioConfig,
190    ) {
191        let inner_weak = WeakCell::from(Rc::downgrade(inner));
192
193        // Typed handlers for subscriptions
194        let update_account_handler = {
195            let cache = cache.clone();
196            let inner = inner_weak.clone();
197            TypedHandler::from(move |event: &AccountState| {
198                if let Some(inner_rc) = inner.upgrade() {
199                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
200                    update_account(&cache, &inner_rc, event);
201                }
202            })
203        };
204
205        let update_position_handler = {
206            let cache = cache.clone();
207            let clock = clock.clone();
208            let inner = inner_weak.clone();
209            TypedHandler::from(move |event: &PositionEvent| {
210                if let Some(inner_rc) = inner.upgrade() {
211                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
212                    update_position(&cache, &clock, &inner_rc, config, event);
213                }
214            })
215        };
216
217        let update_quote_handler = {
218            let cache = cache.clone();
219            let clock = clock.clone();
220            let inner = inner_weak.clone();
221            TypedHandler::from(move |quote: &QuoteTick| {
222                if let Some(inner_rc) = inner.upgrade() {
223                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
224                    update_quote_tick(&cache, &clock, &inner_rc, config, quote);
225                }
226            })
227        };
228
229        let update_bar_handler = {
230            let cache = cache.clone();
231            let clock = clock.clone();
232            let inner = inner_weak.clone();
233            TypedHandler::from(move |bar: &Bar| {
234                if let Some(inner_rc) = inner.upgrade() {
235                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
236                    update_bar(&cache, &clock, &inner_rc, config, bar);
237                }
238            })
239        };
240
241        let update_mark_price_handler = {
242            let cache = cache.clone();
243            let clock = clock.clone();
244            let inner = inner_weak.clone();
245            TypedHandler::from(move |mark_price: &MarkPriceUpdate| {
246                if let Some(inner_rc) = inner.upgrade() {
247                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
248                    update_instrument_id(
249                        &cache,
250                        &clock,
251                        &inner_rc,
252                        config,
253                        &mark_price.instrument_id,
254                    );
255                }
256            })
257        };
258
259        let update_order_handler = {
260            let cache = cache.clone();
261            let inner = inner_weak.clone();
262            TypedHandler::from(move |event: &OrderEventAny| {
263                if let Some(inner_rc) = inner.upgrade() {
264                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
265                    on_order_event(&cache, &inner_rc, event);
266                }
267            })
268        };
269
270        let endpoint = MessagingSwitchboard::portfolio_update_account();
271        msgbus::register_account_state_endpoint(endpoint, update_account_handler.clone());
272
273        let update_order_endpoint_handler = {
274            let cache = cache.clone();
275            let clock = clock.clone();
276            let inner = inner_weak;
277            TypedIntoHandler::from(move |event: OrderEventAny| {
278                if let Some(inner_rc) = inner.upgrade() {
279                    let inner_rc: Rc<RefCell<PortfolioState>> = inner_rc.into();
280                    update_order(
281                        &cache,
282                        &clock,
283                        &inner_rc,
284                        config,
285                        &event,
286                        OrderUpdateSource::Endpoint,
287                    );
288                }
289            })
290        };
291        msgbus::register_order_event_endpoint(
292            MessagingSwitchboard::portfolio_update_order(),
293            update_order_endpoint_handler,
294        );
295
296        msgbus::subscribe_quotes("data.quotes.*".into(), update_quote_handler, Some(10));
297
298        if config.bar_updates {
299            msgbus::subscribe_bars("data.bars.*EXTERNAL".into(), update_bar_handler, Some(10));
300        }
301
302        if config.use_mark_prices {
303            msgbus::subscribe_mark_prices(
304                "data.mark_prices.*".into(),
305                update_mark_price_handler,
306                Some(10),
307            );
308        }
309        msgbus::subscribe_order_events("events.order.*".into(), update_order_handler, Some(10));
310        msgbus::subscribe_position_events(
311            "events.position.*".into(),
312            update_position_handler,
313            Some(10),
314        );
315        msgbus::subscribe_account_state(
316            "events.account.*".into(),
317            update_account_handler,
318            Some(10),
319        );
320    }
321
322    pub fn reset(&mut self) {
323        log::debug!("RESETTING");
324        let account_ids: Vec<AccountId> = self
325            .inner
326            .borrow()
327            .account_open_positions
328            .keys()
329            .copied()
330            .collect();
331
332        for account_id in account_ids {
333            self.clock
334                .borrow_mut()
335                .cancel_timer(&snapshot_timer_name(account_id));
336        }
337        self.inner.borrow_mut().reset();
338        log::debug!("READY");
339    }
340
341    /// Returns a reference to the cache.
342    #[must_use]
343    pub fn cache(&self) -> &Rc<RefCell<Cache>> {
344        &self.cache
345    }
346
347    /// Returns a reference to the clock.
348    #[must_use]
349    pub fn clock(&self) -> &Rc<RefCell<dyn Clock>> {
350        &self.clock
351    }
352
353    /// Returns `true` if the portfolio has been initialized.
354    #[must_use]
355    pub fn is_initialized(&self) -> bool {
356        self.inner.borrow().initialized
357    }
358
359    /// Returns the locked balances for the given venue.
360    ///
361    /// Locked balances represent funds reserved for open orders.
362    #[must_use]
363    pub fn balances_locked(&self, venue: &Venue) -> IndexMap<Currency, Money> {
364        self.cache.borrow().account_for_venue(venue).map_or_else(
365            || {
366                log::error!("Cannot get balances locked: no account generated for {venue}");
367                IndexMap::new()
368            },
369            |account| account.balances_locked(),
370        )
371    }
372
373    /// Returns the initial margin requirements for the given venue.
374    ///
375    /// Only applicable for margin accounts. Returns empty map for cash accounts.
376    #[must_use]
377    pub fn margins_init(&self, venue: &Venue) -> IndexMap<InstrumentId, Money> {
378        self.cache.borrow().account_for_venue(venue).map_or_else(
379            || {
380                log::error!(
381                    "Cannot get initial (order) margins: no account registered for {venue}"
382                );
383                IndexMap::new()
384            },
385            |account| match &*account {
386                AccountAny::Margin(margin_account) => margin_account.initial_margins(),
387                AccountAny::Cash(_) | AccountAny::Betting(_) => {
388                    log::warn!("Initial margins not applicable for cash account");
389                    IndexMap::new()
390                }
391            },
392        )
393    }
394
395    /// Returns the maintenance margin requirements for the given venue.
396    ///
397    /// Only applicable for margin accounts. Returns empty map for cash accounts.
398    #[must_use]
399    pub fn margins_maint(&self, venue: &Venue) -> IndexMap<InstrumentId, Money> {
400        self.cache.borrow().account_for_venue(venue).map_or_else(
401            || {
402                log::error!(
403                    "Cannot get maintenance (position) margins: no account registered for {venue}"
404                );
405                IndexMap::new()
406            },
407            |account| match &*account {
408                AccountAny::Margin(margin_account) => margin_account.maintenance_margins(),
409                AccountAny::Cash(_) | AccountAny::Betting(_) => {
410                    log::warn!("Maintenance margins not applicable for cash account");
411                    IndexMap::new()
412                }
413            },
414        )
415    }
416
417    /// Returns the unrealized PnLs for all positions at the given venue.
418    ///
419    /// Calculates mark-to-market PnL based on current market prices.
420    #[must_use]
421    pub fn unrealized_pnls(
422        &mut self,
423        venue: &Venue,
424        account_id: Option<&AccountId>,
425    ) -> IndexMap<Currency, Money> {
426        let instrument_ids = {
427            let cache = self.cache.borrow();
428            let positions = cache.positions(Some(venue), None, None, account_id, None);
429
430            if positions.is_empty() {
431                return IndexMap::new(); // Nothing to calculate
432            }
433
434            // IndexSet preserves the deterministic order of cache.positions
435            // through the dedup so the returned currency map iterates in a
436            // stable order across runs.
437            let instrument_ids: IndexSet<InstrumentId> =
438                positions.iter().map(|p| p.instrument_id).collect();
439
440            instrument_ids
441        };
442
443        let mut unrealized_pnls: IndexMap<Currency, Money> = IndexMap::new();
444
445        for instrument_id in instrument_ids {
446            // The instrument-keyed cache aggregates across all accounts on the
447            // same venue, so bypass it when the caller filters by account_id.
448            if account_id.is_none()
449                && let Some(&pnl) = self.inner.borrow_mut().unrealized_pnls.get(&instrument_id)
450            {
451                unrealized_pnls
452                    .entry(pnl.currency)
453                    .and_modify(|total| *total = *total + pnl)
454                    .or_insert(pnl);
455                continue;
456            }
457
458            if let Some(pnl) = self.calculate_unrealized_pnl(&instrument_id, account_id) {
459                unrealized_pnls
460                    .entry(pnl.currency)
461                    .and_modify(|total| *total = *total + pnl)
462                    .or_insert(pnl);
463            }
464        }
465
466        unrealized_pnls
467    }
468
469    /// Returns the realized PnLs for all positions at the given venue.
470    ///
471    /// Calculates total realized profit and loss from closed positions.
472    #[must_use]
473    pub fn realized_pnls(
474        &mut self,
475        venue: &Venue,
476        account_id: Option<&AccountId>,
477    ) -> IndexMap<Currency, Money> {
478        let instrument_ids = {
479            let cache = self.cache.borrow();
480            let positions = cache.positions(Some(venue), None, None, account_id, None);
481
482            if positions.is_empty() {
483                return IndexMap::new(); // Nothing to calculate
484            }
485
486            let instrument_ids: IndexSet<InstrumentId> =
487                positions.iter().map(|p| p.instrument_id).collect();
488
489            instrument_ids
490        };
491
492        let mut realized_pnls: IndexMap<Currency, Money> = IndexMap::new();
493
494        for instrument_id in instrument_ids {
495            // The instrument-keyed cache aggregates across all accounts on the
496            // same venue, so bypass it when the caller filters by account_id.
497            if account_id.is_none()
498                && let Some(&pnl) = self.inner.borrow_mut().realized_pnls.get(&instrument_id)
499            {
500                realized_pnls
501                    .entry(pnl.currency)
502                    .and_modify(|total| *total = *total + pnl)
503                    .or_insert(pnl);
504                continue;
505            }
506
507            if let Some(pnl) = self.calculate_realized_pnl(&instrument_id, account_id) {
508                realized_pnls
509                    .entry(pnl.currency)
510                    .and_modify(|total| *total = *total + pnl)
511                    .or_insert(pnl);
512            }
513        }
514
515        realized_pnls
516    }
517
518    #[must_use]
519    pub fn net_exposures(
520        &self,
521        venue: &Venue,
522        account_id: Option<&AccountId>,
523    ) -> Option<IndexMap<Currency, Money>> {
524        let cache = self.cache.borrow();
525        let account = if let Some(id) = account_id {
526            if let Some(account) = cache.account(id) {
527                account
528            } else {
529                log::error!("Cannot calculate net exposures: no account for {id}");
530                return None;
531            }
532        } else if let Some(account) = cache.account_for_venue(venue) {
533            account
534        } else {
535            log::error!("Cannot calculate net exposures: no account registered for {venue}");
536            return None;
537        };
538
539        let positions_open = cache.positions_open(Some(venue), None, None, account_id, None);
540        if positions_open.is_empty() {
541            return Some(IndexMap::new()); // Nothing to calculate
542        }
543
544        let mut net_exposures: IndexMap<Currency, Money> = IndexMap::new();
545
546        for position in positions_open {
547            let instrument = if let Some(instrument) = cache.instrument(&position.instrument_id) {
548                instrument
549            } else {
550                log::error!(
551                    "Cannot calculate net exposures: no instrument for {}",
552                    position.instrument_id
553                );
554                return None; // Cannot calculate
555            };
556
557            if position.side == PositionSide::Flat {
558                log::error!(
559                    "Cannot calculate net exposures: position is flat for {}",
560                    position.instrument_id
561                );
562                continue; // Nothing to calculate
563            }
564
565            let price = self.get_price(&position)?;
566            let xrate = if let Some(xrate) = self.calculate_xrate_to_base(instrument, &account) {
567                xrate
568            } else {
569                log::error!(
570                    // TODO: Improve logging
571                    "Cannot calculate net exposures: insufficient data for {}/{:?}",
572                    instrument.settlement_currency(),
573                    account.base_currency()
574                );
575                return None; // Cannot calculate
576            };
577
578            let settlement_currency = account
579                .base_currency()
580                .unwrap_or_else(|| instrument.settlement_currency());
581
582            let net_exposure = match Money::from_decimal(
583                instrument
584                    .calculate_notional_value(position.quantity, price, None)
585                    .as_decimal()
586                    * xrate,
587                settlement_currency,
588            ) {
589                Ok(money) => money,
590                Err(e) => {
591                    log::error!("Cannot calculate net exposures: {e}");
592                    return None;
593                }
594            };
595
596            net_exposures
597                .entry(settlement_currency)
598                .and_modify(|total| *total = *total + net_exposure)
599                .or_insert(net_exposure);
600        }
601
602        Some(net_exposures)
603    }
604
605    #[must_use]
606    pub fn unrealized_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
607        self.unrealized_pnl_for_account(instrument_id, None)
608    }
609
610    #[must_use]
611    pub fn unrealized_pnl_for_account(
612        &mut self,
613        instrument_id: &InstrumentId,
614        account_id: Option<&AccountId>,
615    ) -> Option<Money> {
616        if account_id.is_some() {
617            return self.calculate_unrealized_pnl(instrument_id, account_id);
618        }
619
620        if let Some(pnl) = self
621            .inner
622            .borrow()
623            .unrealized_pnls
624            .get(instrument_id)
625            .copied()
626        {
627            return Some(pnl);
628        }
629
630        let pnl = self.calculate_unrealized_pnl(instrument_id, None)?;
631        self.inner
632            .borrow_mut()
633            .unrealized_pnls
634            .insert(*instrument_id, pnl);
635        Some(pnl)
636    }
637
638    #[must_use]
639    pub fn realized_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
640        self.realized_pnl_for_account(instrument_id, None)
641    }
642
643    #[must_use]
644    pub fn realized_pnl_for_account(
645        &mut self,
646        instrument_id: &InstrumentId,
647        account_id: Option<&AccountId>,
648    ) -> Option<Money> {
649        if account_id.is_some() {
650            return self.calculate_realized_pnl(instrument_id, account_id);
651        }
652
653        if let Some(pnl) = self
654            .inner
655            .borrow()
656            .realized_pnls
657            .get(instrument_id)
658            .copied()
659        {
660            return Some(pnl);
661        }
662
663        let pnl = self.calculate_realized_pnl(instrument_id, None)?;
664        self.inner
665            .borrow_mut()
666            .realized_pnls
667            .insert(*instrument_id, pnl);
668        Some(pnl)
669    }
670
671    /// Returns the total PnL for the given instrument ID.
672    ///
673    /// Total PnL = Realized PnL + Unrealized PnL
674    #[must_use]
675    pub fn total_pnl(&mut self, instrument_id: &InstrumentId) -> Option<Money> {
676        self.total_pnl_for_account(instrument_id, None)
677    }
678
679    #[must_use]
680    pub fn total_pnl_for_account(
681        &mut self,
682        instrument_id: &InstrumentId,
683        account_id: Option<&AccountId>,
684    ) -> Option<Money> {
685        let realized = self.realized_pnl_for_account(instrument_id, account_id)?;
686        let unrealized = self.unrealized_pnl_for_account(instrument_id, account_id)?;
687
688        if realized.currency != unrealized.currency {
689            log::error!(
690                "Cannot calculate total PnL: currency mismatch {} vs {}",
691                realized.currency,
692                unrealized.currency
693            );
694            return None;
695        }
696
697        Some(realized + unrealized)
698    }
699
700    /// Returns the total PnLs for the given venue.
701    ///
702    /// Total PnL = Realized PnL + Unrealized PnL for each currency. Pass `account_id`
703    /// to scope the aggregation to a single account when multiple accounts share the venue.
704    #[must_use]
705    pub fn total_pnls(
706        &mut self,
707        venue: &Venue,
708        account_id: Option<&AccountId>,
709    ) -> IndexMap<Currency, Money> {
710        let realized_pnls = self.realized_pnls(venue, account_id);
711        let unrealized_pnls = self.unrealized_pnls(venue, account_id);
712
713        let mut total_pnls: IndexMap<Currency, Money> = IndexMap::new();
714
715        // Add realized PnLs
716        for (currency, realized) in realized_pnls {
717            total_pnls.insert(currency, realized);
718        }
719
720        // Add unrealized PnLs
721        for (currency, unrealized) in unrealized_pnls {
722            match total_pnls.get_mut(&currency) {
723                Some(total) => {
724                    *total = *total + unrealized;
725                }
726                None => {
727                    total_pnls.insert(currency, unrealized);
728                }
729            }
730        }
731
732        total_pnls
733    }
734
735    /// Returns the per-currency mark-to-market value of open positions at the given venue.
736    ///
737    /// For each open position the valuation uses the portfolio's internal price
738    /// resolution, which prefers mark prices (when configured), falls back to
739    /// side-appropriate bid/ask, then last trade, then the most recent bar close.
740    /// Instruments without any available price are skipped and the venue is flagged
741    /// for a no-price warning. Pass `account_id` to scope the aggregation to a
742    /// single account when multiple accounts share the venue.
743    #[must_use]
744    pub fn mark_values(
745        &mut self,
746        venue: &Venue,
747        account_id: Option<&AccountId>,
748    ) -> IndexMap<Currency, Money> {
749        let mut values: IndexMap<Currency, Decimal> = IndexMap::new();
750        let mut unpriced: AHashSet<InstrumentId> = AHashSet::new();
751
752        if self.accumulate_mark_values(*venue, account_id, &mut values, &mut unpriced) {
753            self.update_missing_price_state(*venue, &unpriced);
754        } else if account_id.is_none() {
755            // Only clear the tracker on an unfiltered sweep; otherwise we could
756            // wipe another account's flags on the same venue.
757            self.inner.borrow_mut().venues_missing_price.remove(venue);
758        }
759
760        decimal_map_to_money(values)
761    }
762
763    /// Returns the per-currency total equity for the given venue.
764    ///
765    /// For cash accounts: `balance.total + Σ mark_value(open positions)` per currency.
766    /// For margin accounts: `balance.total + Σ unrealized_pnl(open positions)` per currency.
767    ///
768    /// Open-position instruments that cannot be priced are tracked via
769    /// [`Portfolio::missing_price_instruments`] (and warned once) for both branches,
770    /// so equity understatement does not go unnoticed. Pass `account_id` to scope
771    /// the aggregation to a single account when multiple accounts share the venue.
772    #[must_use]
773    pub fn equity(
774        &mut self,
775        venue: &Venue,
776        account_id: Option<&AccountId>,
777    ) -> IndexMap<Currency, Money> {
778        let (mut equity, is_margin) = {
779            let cache = self.cache.borrow();
780            let account = match account_id {
781                Some(id) => cache.account(id),
782                None => cache.account_for_venue(venue),
783            };
784
785            match account {
786                Some(account) => {
787                    let equity: IndexMap<Currency, Decimal> = account
788                        .balances_total()
789                        .into_iter()
790                        .map(|(c, m)| (c, m.as_decimal()))
791                        .collect();
792                    (equity, matches!(&*account, AccountAny::Margin(_)))
793                }
794                None => return IndexMap::new(),
795            }
796        };
797
798        let mut unpriced: AHashSet<InstrumentId> = AHashSet::new();
799
800        if is_margin {
801            // Sum cached unrealized PnLs; fall through to recalculation on cache miss.
802            let instrument_ids: IndexSet<InstrumentId> = {
803                let cache = self.cache.borrow();
804                cache
805                    .positions_open(Some(venue), None, None, account_id, None)
806                    .iter()
807                    .map(|p| p.instrument_id)
808                    .collect()
809            };
810
811            if instrument_ids.is_empty() {
812                if account_id.is_none() {
813                    self.inner.borrow_mut().venues_missing_price.remove(venue);
814                }
815            } else {
816                for instrument_id in instrument_ids {
817                    // The instrument-keyed cache aggregates across all accounts on
818                    // the same venue, so bypass it when the caller filters by
819                    // account_id.
820                    let cached = if account_id.is_none() {
821                        self.inner
822                            .borrow()
823                            .unrealized_pnls
824                            .get(&instrument_id)
825                            .copied()
826                    } else {
827                        None
828                    };
829                    let pnl = match cached {
830                        Some(pnl) => Some(pnl),
831                        None => self.calculate_unrealized_pnl(&instrument_id, account_id),
832                    };
833
834                    match pnl {
835                        Some(pnl) => {
836                            *equity.entry(pnl.currency).or_insert(Decimal::ZERO) +=
837                                pnl.as_decimal();
838                        }
839                        None => {
840                            unpriced.insert(instrument_id);
841                        }
842                    }
843                }
844                self.update_missing_price_state(*venue, &unpriced);
845            }
846        } else if self.accumulate_mark_values(*venue, account_id, &mut equity, &mut unpriced) {
847            self.update_missing_price_state(*venue, &unpriced);
848        } else if account_id.is_none() {
849            self.inner.borrow_mut().venues_missing_price.remove(venue);
850        }
851
852        decimal_map_to_money(equity)
853    }
854
855    /// Builds a [`PortfolioSnapshot`] for the given account at the current clock time.
856    ///
857    /// Unrealized PnL and mark values span the venues the account currently
858    /// holds open positions on; realized PnL spans every venue the account has
859    /// touched (open or closed) so a multi-venue account where one venue is
860    /// now flat still reports its accumulated realized PnL. Returns `None` if
861    /// no account is registered.
862    #[must_use]
863    pub fn build_snapshot(&mut self, account_id: &AccountId) -> Option<PortfolioSnapshot> {
864        let account = self.cache.borrow().account_owned(account_id)?;
865
866        let balances: Vec<AccountBalance> = account.balances().into_values().collect();
867        let margins: Vec<MarginBalance> = match &account {
868            AccountAny::Margin(m) => m
869                .margins
870                .values()
871                .copied()
872                .chain(m.account_margins.values().copied())
873                .collect(),
874            AccountAny::Cash(_) | AccountAny::Betting(_) => Vec::new(),
875        };
876
877        // Collect venues the account has touched. `open_venues` drives the
878        // unrealized PnL and mark-value sums; `all_venues` extends to closed
879        // positions so realized PnL on a venue with no open exposure (a
880        // multi-venue account where one venue is now flat) still rolls up.
881        let open_venues: AHashSet<Venue> = self
882            .cache
883            .borrow()
884            .positions_open(None, None, None, Some(account_id), None)
885            .iter()
886            .map(|p| p.instrument_id.venue)
887            .collect();
888        let all_venues: AHashSet<Venue> = self
889            .cache
890            .borrow()
891            .positions(None, None, None, Some(account_id), None)
892            .iter()
893            .map(|p| p.instrument_id.venue)
894            .collect();
895
896        let mut unrealized: IndexMap<Currency, Money> = IndexMap::new();
897        let mut realized: IndexMap<Currency, Money> = IndexMap::new();
898        let mut equity: IndexMap<Currency, Money> = account.balances_total().into_iter().collect();
899
900        for venue in &open_venues {
901            for (currency, money) in self.unrealized_pnls(venue, Some(account_id)) {
902                unrealized
903                    .entry(currency)
904                    .and_modify(|total| *total = *total + money)
905                    .or_insert(money);
906            }
907        }
908
909        for venue in &all_venues {
910            for (currency, money) in self.realized_pnls(venue, Some(account_id)) {
911                realized
912                    .entry(currency)
913                    .and_modify(|total| *total = *total + money)
914                    .or_insert(money);
915            }
916        }
917
918        match &account {
919            AccountAny::Margin(_) => {
920                for (currency, value) in &unrealized {
921                    equity
922                        .entry(*currency)
923                        .and_modify(|total| *total = *total + *value)
924                        .or_insert(*value);
925                }
926            }
927            AccountAny::Cash(_) | AccountAny::Betting(_) => {
928                for venue in &open_venues {
929                    for (currency, money) in self.mark_values(venue, Some(account_id)) {
930                        equity
931                            .entry(currency)
932                            .and_modify(|total| *total = *total + money)
933                            .or_insert(money);
934                    }
935                }
936            }
937        }
938
939        let unrealized_pnls: Vec<Money> = unrealized.into_values().collect();
940        let realized_pnls: Vec<Money> = realized.into_values().collect();
941        let total_equity: Vec<Money> = equity.into_values().collect();
942
943        let ts_now = self.clock.borrow().timestamp_ns();
944
945        Some(PortfolioSnapshot::new(
946            account.id(),
947            account.account_type(),
948            account.base_currency(),
949            balances,
950            margins,
951            unrealized_pnls,
952            realized_pnls,
953            total_equity,
954            UUID4::new(),
955            ts_now,
956            ts_now,
957        ))
958    }
959
960    /// Returns the recorded portfolio snapshots for the given account, in order of emission.
961    ///
962    /// Snapshots accumulate whenever `snapshot_interval_ms` is set and the account
963    /// holds at least one open position. The ring is bounded; long-lived live
964    /// deployments should consume snapshots via the message bus instead of relying
965    /// on this buffer. Cleared on [`Portfolio::reset`].
966    #[must_use]
967    pub fn snapshots(&self, account_id: &AccountId) -> Vec<PortfolioSnapshot> {
968        self.inner
969            .borrow()
970            .portfolio_snapshots
971            .get(account_id)
972            .map(|ring| ring.iter().cloned().collect())
973            .unwrap_or_default()
974    }
975
976    /// Returns the instruments currently flagged as unpriced for the given venue.
977    ///
978    /// An entry is added the first time [`Portfolio::mark_values`] cannot source a
979    /// price for an open position (after also emitting a warn log), and removed
980    /// once the instrument is priced again so a subsequent drop re-warns.
981    #[must_use]
982    pub fn missing_price_instruments(&self, venue: &Venue) -> Vec<InstrumentId> {
983        let mut ids: Vec<InstrumentId> = self
984            .inner
985            .borrow()
986            .venues_missing_price
987            .get(venue)
988            .map(|set| set.iter().copied().collect())
989            .unwrap_or_default();
990        // Sort so the public Vec is deterministic even though the underlying
991        // tracking set is AHash-backed.
992        ids.sort();
993        ids
994    }
995
996    fn update_missing_price_state(&self, venue: Venue, unpriced: &AHashSet<InstrumentId>) {
997        let mut inner = self.inner.borrow_mut();
998        let tracked = inner.venues_missing_price.entry(venue).or_default();
999
1000        // Sort first so the warn-log sequence is deterministic across runs.
1001        let mut ids: Vec<InstrumentId> = unpriced.iter().copied().collect();
1002        ids.sort();
1003        for instrument_id in ids {
1004            if tracked.insert(instrument_id) {
1005                log::warn!(
1006                    "No price available for open position {instrument_id}; \
1007                    subscribe to quotes, trades or bars for continuous mark-to-market equity"
1008                );
1009            }
1010        }
1011
1012        // Instruments that are now priced should be removed so a future price drop re-warns
1013        tracked.retain(|id| unpriced.contains(id));
1014    }
1015
1016    // Returns `true` if at least one open position was seen (priced or not),
1017    // `false` if the venue is flat. Unpriced instruments are written to
1018    // `unpriced` for the caller to flow into `update_missing_price_state`.
1019    fn accumulate_mark_values(
1020        &self,
1021        venue: Venue,
1022        account_id: Option<&AccountId>,
1023        values: &mut IndexMap<Currency, Decimal>,
1024        unpriced: &mut AHashSet<InstrumentId>,
1025    ) -> bool {
1026        let cache = self.cache.borrow();
1027        let positions = cache.positions_open(Some(&venue), None, None, account_id, None);
1028
1029        if positions.is_empty() {
1030            return false;
1031        }
1032
1033        let account = match account_id {
1034            Some(id) => cache.account(id),
1035            None => cache.account_for_venue(&venue),
1036        };
1037        let mut xrate_cache: AHashMap<Currency, Option<Decimal>> = AHashMap::new();
1038
1039        for position in positions {
1040            let sign = match position.side {
1041                PositionSide::Long => Decimal::ONE,
1042                PositionSide::Short => Decimal::NEGATIVE_ONE,
1043                PositionSide::Flat | PositionSide::NoPositionSide => continue,
1044            };
1045
1046            let instrument = match cache.instrument(&position.instrument_id) {
1047                Some(i) => i,
1048                None => {
1049                    unpriced.insert(position.instrument_id);
1050                    continue;
1051                }
1052            };
1053
1054            let price = match self.get_price(&position) {
1055                Some(p) => p,
1056                None => {
1057                    unpriced.insert(position.instrument_id);
1058                    continue;
1059                }
1060            };
1061
1062            let settlement = instrument.settlement_currency();
1063            let (xrate, currency) = if self.config.convert_to_account_base_currency
1064                && let Some(account) = account.as_ref()
1065                && let Some(base_currency) = account.base_currency()
1066            {
1067                let xrate_opt = *xrate_cache
1068                    .entry(settlement)
1069                    .or_insert_with(|| self.calculate_xrate_to_base(instrument, account));
1070                let xrate = match xrate_opt {
1071                    Some(x) => x,
1072                    None => {
1073                        unpriced.insert(position.instrument_id);
1074                        continue;
1075                    }
1076                };
1077                (xrate, base_currency)
1078            } else {
1079                (Decimal::ONE, settlement)
1080            };
1081
1082            // Sum exact Decimals; the caller rounds once so sub-precision positions survive
1083            let notional = position.notional_value(price).as_decimal() * xrate * sign;
1084            *values.entry(currency).or_insert(Decimal::ZERO) += notional;
1085        }
1086
1087        true
1088    }
1089
1090    #[must_use]
1091    pub fn net_exposure(
1092        &self,
1093        instrument_id: &InstrumentId,
1094        account_id: Option<&AccountId>,
1095    ) -> Option<Money> {
1096        let cache = self.cache.borrow();
1097
1098        let instrument = if let Some(instrument) = cache.instrument(instrument_id) {
1099            instrument
1100        } else {
1101            log::error!("Cannot calculate net exposure: no instrument for {instrument_id}");
1102            return None;
1103        };
1104
1105        let positions_open =
1106            cache.positions_open(None, Some(instrument_id), None, account_id, None);
1107
1108        if positions_open.is_empty() {
1109            return Some(Money::zero(instrument.settlement_currency()));
1110        }
1111
1112        let mut net_exposure = Decimal::ZERO;
1113        let mut first_base_currency: Option<Currency> = None;
1114
1115        for position in &positions_open {
1116            // Get account for THIS position
1117            let account = match cache.try_account(&position.account_id) {
1118                Ok(account) => account,
1119                Err(e) => {
1120                    log::error!("Cannot calculate net exposure: {e}");
1121                    return None;
1122                }
1123            };
1124
1125            // Validate consistent base currency across accounts
1126            if let Some(base) = account.base_currency() {
1127                match first_base_currency {
1128                    None => {
1129                        first_base_currency = Some(base);
1130                    }
1131                    Some(first) if first != base => {
1132                        log::error!(
1133                            "Cannot calculate net exposure: accounts have different base \
1134                            currencies ({first} vs {base}); multi-account aggregation requires \
1135                            consistent base currencies"
1136                        );
1137                        return None;
1138                    }
1139                    _ => {}
1140                }
1141            }
1142
1143            let price = self.get_price(position)?;
1144            let xrate = if let Some(xrate) = self.calculate_xrate_to_base(instrument, &account) {
1145                xrate
1146            } else {
1147                log::error!(
1148                    "Cannot calculate net exposures: insufficient data for {}/{:?}",
1149                    instrument.settlement_currency(),
1150                    account.base_currency()
1151                );
1152                return None;
1153            };
1154
1155            let notional_value =
1156                instrument.calculate_notional_value(position.quantity, price, None);
1157            net_exposure += notional_value.as_decimal() * xrate;
1158        }
1159
1160        let settlement_currency =
1161            first_base_currency.unwrap_or_else(|| instrument.settlement_currency());
1162
1163        match Money::from_decimal(net_exposure, settlement_currency) {
1164            Ok(money) => Some(money),
1165            Err(e) => {
1166                log::error!("Cannot calculate net exposure: {e}");
1167                None
1168            }
1169        }
1170    }
1171
1172    #[must_use]
1173    pub fn net_position(&self, instrument_id: &InstrumentId) -> Decimal {
1174        self.inner
1175            .borrow()
1176            .net_positions
1177            .get(instrument_id)
1178            .copied()
1179            .unwrap_or(Decimal::ZERO)
1180    }
1181
1182    #[must_use]
1183    pub fn is_net_long(&self, instrument_id: &InstrumentId) -> bool {
1184        self.inner
1185            .borrow()
1186            .net_positions
1187            .get(instrument_id)
1188            .copied()
1189            .map_or_else(|| false, |net_position| net_position > Decimal::ZERO)
1190    }
1191
1192    #[must_use]
1193    pub fn is_net_short(&self, instrument_id: &InstrumentId) -> bool {
1194        self.inner
1195            .borrow()
1196            .net_positions
1197            .get(instrument_id)
1198            .copied()
1199            .map_or_else(|| false, |net_position| net_position < Decimal::ZERO)
1200    }
1201
1202    #[must_use]
1203    pub fn is_flat(&self, instrument_id: &InstrumentId) -> bool {
1204        self.inner
1205            .borrow()
1206            .net_positions
1207            .get(instrument_id)
1208            .copied()
1209            .map_or_else(|| true, |net_position| net_position == Decimal::ZERO)
1210    }
1211
1212    #[must_use]
1213    pub fn is_completely_flat(&self) -> bool {
1214        for net_position in self.inner.borrow().net_positions.values() {
1215            if *net_position != Decimal::ZERO {
1216                return false;
1217            }
1218        }
1219        true
1220    }
1221
1222    /// Initializes account margin based on existing open orders.
1223    ///
1224    /// # Panics
1225    ///
1226    /// Panics if updating the cache with a mutated account fails.
1227    pub fn initialize_orders(&mut self) {
1228        let mut initialized = true;
1229        let orders_and_instruments = {
1230            let cache = self.cache.borrow();
1231
1232            let mut instruments_with_orders = Vec::new();
1233            let mut instruments = AHashSet::new();
1234
1235            for client_order_id in cache.iter_client_order_ids_open(None, None, None, None) {
1236                if let Some(order) = cache.order(&client_order_id) {
1237                    instruments.insert(order.instrument_id());
1238                }
1239            }
1240
1241            for instrument_id in instruments {
1242                if let Some(instrument) = cache.instrument(&instrument_id) {
1243                    let orders = cache
1244                        .orders_open(None, Some(&instrument_id), None, None, None)
1245                        .into_iter()
1246                        .map(|order| order.clone())
1247                        .collect::<Vec<OrderAny>>();
1248                    instruments_with_orders.push((instrument.clone(), orders));
1249                } else {
1250                    log::error!(
1251                        "Cannot update initial (order) margin: no instrument found for {instrument_id}"
1252                    );
1253                    initialized = false;
1254                    break;
1255                }
1256            }
1257            instruments_with_orders
1258        };
1259
1260        for (instrument, orders_open) in &orders_and_instruments {
1261            let account = {
1262                let cache = self.cache.borrow();
1263                if let Some(account) = cache.account_for_venue(&instrument.id().venue) {
1264                    account.clone()
1265                } else {
1266                    log::error!(
1267                        "Cannot update initial (order) margin: no account registered for {}",
1268                        instrument.id().venue
1269                    );
1270                    initialized = false;
1271                    break;
1272                }
1273            };
1274
1275            let orders_open_refs: Vec<&OrderAny> = orders_open.iter().collect();
1276            let result = self.inner.borrow_mut().accounts.update_orders(
1277                &account,
1278                instrument,
1279                &orders_open_refs,
1280                self.clock.borrow().timestamp_ns(),
1281            );
1282
1283            match result {
1284                Some((updated_account, _)) => {
1285                    self.cache
1286                        .borrow_mut()
1287                        .update_account(&updated_account)
1288                        .unwrap();
1289                }
1290                None => {
1291                    initialized = false;
1292                }
1293            }
1294        }
1295
1296        let total_orders = orders_and_instruments
1297            .into_iter()
1298            .map(|(_, orders)| orders.len())
1299            .sum::<usize>();
1300
1301        log::info!(
1302            color = if total_orders > 0 { LogColor::Blue as u8 } else { LogColor::Normal as u8 };
1303            "Initialized {} open order{}",
1304            total_orders,
1305            if total_orders == 1 { "" } else { "s" }
1306        );
1307
1308        self.inner.borrow_mut().initialized = initialized;
1309    }
1310
1311    /// Initializes account margin based on existing open positions.
1312    ///
1313    /// # Panics
1314    ///
1315    /// Panics if calculation of PnL or updating the cache with a mutated account fails.
1316    pub fn initialize_positions(&mut self) {
1317        self.inner.borrow_mut().unrealized_pnls.clear();
1318        self.inner.borrow_mut().realized_pnls.clear();
1319        let all_positions_open: Vec<Position>;
1320        let mut instruments = AHashSet::new();
1321        {
1322            let cache = self.cache.borrow();
1323            all_positions_open = cache
1324                .positions_open(None, None, None, None, None)
1325                .into_iter()
1326                .map(|p| p.cloned())
1327                .collect();
1328
1329            for position in &all_positions_open {
1330                instruments.insert(position.instrument_id);
1331            }
1332        }
1333
1334        let mut initialized = true;
1335
1336        for instrument_id in instruments {
1337            let positions_open: Vec<Position> = {
1338                let cache = self.cache.borrow();
1339                cache
1340                    .positions_open(None, Some(&instrument_id), None, None, None)
1341                    .into_iter()
1342                    .map(|p| p.cloned())
1343                    .collect()
1344            };
1345
1346            let position_refs: Vec<&Position> = positions_open.iter().collect();
1347            self.update_net_position(&instrument_id, &position_refs);
1348
1349            if let Some(calculated_unrealized_pnl) =
1350                self.calculate_unrealized_pnl(&instrument_id, None)
1351            {
1352                self.inner
1353                    .borrow_mut()
1354                    .unrealized_pnls
1355                    .insert(instrument_id, calculated_unrealized_pnl);
1356            } else {
1357                log::debug!(
1358                    "Failed to calculate unrealized PnL for {instrument_id}, marking as pending"
1359                );
1360                self.inner.borrow_mut().pending_calcs.insert(instrument_id);
1361            }
1362
1363            if let Some(calculated_realized_pnl) = self.calculate_realized_pnl(&instrument_id, None)
1364            {
1365                self.inner
1366                    .borrow_mut()
1367                    .realized_pnls
1368                    .insert(instrument_id, calculated_realized_pnl);
1369            } else {
1370                log::warn!(
1371                    "Failed to calculate realized PnL for {instrument_id}, marking as pending"
1372                );
1373                self.inner.borrow_mut().pending_calcs.insert(instrument_id);
1374            }
1375
1376            let cache = self.cache.borrow();
1377            let Some(account) = cache.account_for_venue_owned(&instrument_id.venue) else {
1378                log::error!(
1379                    "Cannot update maintenance (position) margin: no account registered for {}",
1380                    instrument_id.venue
1381                );
1382                initialized = false;
1383                break;
1384            };
1385
1386            let account = match account {
1387                AccountAny::Cash(_) | AccountAny::Betting(_) => continue,
1388                AccountAny::Margin(margin_account) => margin_account,
1389            };
1390
1391            let Some(instrument) = cache.instrument(&instrument_id).cloned() else {
1392                log::error!(
1393                    "Cannot update maintenance (position) margin: no instrument found for {instrument_id}"
1394                );
1395                initialized = false;
1396                break;
1397            };
1398            let positions: Vec<Position> = cache
1399                .positions_open(None, Some(&instrument_id), None, None, None)
1400                .into_iter()
1401                .map(|p| p.cloned())
1402                .collect();
1403            drop(cache);
1404
1405            let result = self.inner.borrow_mut().accounts.update_positions(
1406                &account,
1407                &instrument,
1408                positions.iter().collect(),
1409                self.clock.borrow().timestamp_ns(),
1410            );
1411
1412            match result {
1413                Some((updated_account, _)) => {
1414                    self.cache
1415                        .borrow_mut()
1416                        .update_account(&AccountAny::Margin(updated_account))
1417                        .unwrap();
1418                }
1419                None => {
1420                    initialized = false;
1421                }
1422            }
1423        }
1424
1425        let open_count = all_positions_open.len();
1426        self.inner.borrow_mut().initialized = initialized;
1427        log::info!(
1428            color = if open_count > 0 { LogColor::Blue as u8 } else { LogColor::Normal as u8 };
1429            "Initialized {} open position{}",
1430            open_count,
1431            if open_count == 1 { "" } else { "s" }
1432        );
1433
1434        if self.config.snapshot_interval_ms.is_some() {
1435            let account_ids: AHashSet<AccountId> =
1436                all_positions_open.iter().map(|p| p.account_id).collect();
1437
1438            for account_id in account_ids {
1439                update_snapshot_timer_state(
1440                    &self.cache,
1441                    &self.clock,
1442                    &self.inner,
1443                    self.config,
1444                    account_id,
1445                );
1446            }
1447        }
1448    }
1449
1450    /// Updates portfolio calculations based on a new quote tick.
1451    ///
1452    /// Recalculates unrealized PnL for positions affected by the quote update.
1453    pub fn update_quote_tick(&mut self, quote: &QuoteTick) {
1454        update_quote_tick(&self.cache, &self.clock, &self.inner, self.config, quote);
1455    }
1456
1457    /// Updates portfolio calculations based on a new bar.
1458    ///
1459    /// Updates cached bar close prices and recalculates unrealized PnL.
1460    pub fn update_bar(&mut self, bar: &Bar) {
1461        update_bar(&self.cache, &self.clock, &self.inner, self.config, bar);
1462    }
1463
1464    /// Updates portfolio with a new account state event.
1465    pub fn update_account(&mut self, event: &AccountState) {
1466        update_account(&self.cache, &self.inner, event);
1467    }
1468
1469    /// Updates portfolio calculations based on an order event.
1470    ///
1471    /// Handles balance updates for order fills and margin calculations for order changes.
1472    pub fn update_order(&mut self, event: &OrderEventAny) {
1473        update_order(
1474            &self.cache,
1475            &self.clock,
1476            &self.inner,
1477            self.config,
1478            event,
1479            OrderUpdateSource::Topic,
1480        );
1481    }
1482
1483    /// Returns realized PnLs recorded during portfolio event processing.
1484    ///
1485    /// Each record is `(position_id, ts_event, realized_pnl)`.
1486    #[must_use]
1487    pub fn recorded_realized_pnls(&self) -> AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>> {
1488        self.inner.borrow().analyzer.recorded_realized_pnls.clone()
1489    }
1490
1491    /// Computes an owned [`PortfolioStatistics`] snapshot from the portfolio's current cache state.
1492    ///
1493    /// Aggregates balances across every account, includes cached positions and their snapshots, and
1494    /// merges close-time PnLs recorded during processing. Recomputes on each call; callers on hot paths
1495    /// should invoke it sparingly.
1496    #[must_use]
1497    pub fn statistics(&self) -> PortfolioStatistics {
1498        let cache = self.cache.borrow();
1499        let accounts = cache.accounts_all_owned();
1500        let positions: Vec<Position> = cache
1501            .positions(None, None, None, None, None)
1502            .into_iter()
1503            .map(|p| p.cloned())
1504            .collect();
1505        let mut snapshots = Vec::new();
1506        for position in &positions {
1507            snapshots.extend(cache.position_snapshots(Some(&position.id), None));
1508        }
1509        let recorded = self.inner.borrow().analyzer.recorded_realized_pnls.clone();
1510        PortfolioAnalyzer::from_accounts(&accounts, &positions, &snapshots, recorded).statistics()
1511    }
1512
1513    /// Updates portfolio calculations based on a position event.
1514    ///
1515    /// Recalculates net positions, unrealized PnL, and margin requirements.
1516    pub fn update_position(&mut self, event: &PositionEvent) {
1517        update_position(&self.cache, &self.clock, &self.inner, self.config, event);
1518    }
1519
1520    fn update_net_position(&self, instrument_id: &InstrumentId, positions: &[&Position]) {
1521        let mut net_position = Decimal::ZERO;
1522
1523        for open_position in positions {
1524            log::debug!("open_position: {}", *open_position);
1525            net_position += open_position.signed_decimal_qty();
1526        }
1527
1528        let existing_position = self.net_position(instrument_id);
1529        if existing_position != net_position {
1530            self.inner
1531                .borrow_mut()
1532                .net_positions
1533                .insert(*instrument_id, net_position);
1534            log::info!("{instrument_id} net_position={net_position}");
1535        }
1536    }
1537
1538    fn calculate_unrealized_pnl(
1539        &self,
1540        instrument_id: &InstrumentId,
1541        account_id: Option<&AccountId>,
1542    ) -> Option<Money> {
1543        let cache = self.cache.borrow();
1544        let account = match account_id {
1545            Some(id) => cache.account(id),
1546            None => cache.account_for_venue(&instrument_id.venue),
1547        };
1548        let account = if let Some(account) = account {
1549            account
1550        } else {
1551            log::error!(
1552                "Cannot calculate unrealized PnL: no account for {} / {account_id:?}",
1553                instrument_id.venue,
1554            );
1555            return None;
1556        };
1557
1558        let instrument = if let Some(instrument) = cache.instrument(instrument_id) {
1559            instrument
1560        } else {
1561            log::error!("Cannot calculate unrealized PnL: no instrument for {instrument_id}");
1562            return None;
1563        };
1564
1565        let currency = account
1566            .base_currency()
1567            .unwrap_or_else(|| instrument.settlement_currency());
1568
1569        let positions_open =
1570            cache.positions_open(None, Some(instrument_id), None, account_id, None);
1571
1572        if positions_open.is_empty() {
1573            return Some(Money::zero(currency));
1574        }
1575
1576        let mut total_pnl = Decimal::ZERO;
1577
1578        for position in positions_open {
1579            if position.instrument_id != *instrument_id {
1580                continue; // Nothing to calculate
1581            }
1582
1583            if position.side == PositionSide::Flat {
1584                continue; // Nothing to calculate
1585            }
1586
1587            let price = if let Some(price) = self.get_price(&position) {
1588                price
1589            } else {
1590                log::debug!("Cannot calculate unrealized PnL: no prices for {instrument_id}");
1591                self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1592                return None; // Cannot calculate
1593            };
1594
1595            let mut pnl = position.unrealized_pnl(price).as_decimal();
1596
1597            if let Some(base_currency) = account.base_currency() {
1598                let xrate = if let Some(xrate) = self.calculate_xrate_to_base(instrument, &account)
1599                {
1600                    xrate
1601                } else {
1602                    log::warn!(
1603                        // TODO: Improve logging
1604                        "Cannot calculate unrealized PnL: insufficient data for {}/{}",
1605                        instrument.settlement_currency(),
1606                        base_currency
1607                    );
1608                    self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1609                    return None; // Cannot calculate
1610                };
1611
1612                pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
1613            }
1614
1615            total_pnl += pnl;
1616        }
1617
1618        match Money::from_decimal(total_pnl, currency) {
1619            Ok(money) => Some(money),
1620            Err(e) => {
1621                log::error!("Cannot calculate unrealized PnL: {e}");
1622                None
1623            }
1624        }
1625    }
1626
1627    fn ensure_snapshot_pnls_cached_for(&self, instrument_id: &InstrumentId) {
1628        // Performance: This method maintains an incremental cache of snapshot PnLs
1629        // It only deserializes new snapshots that haven't been processed yet
1630        // Tracks sum and last PnL per position for efficient NETTING OMS support
1631
1632        // Get all position IDs that have snapshots for this instrument
1633        let snapshot_position_ids = self.cache.borrow().position_snapshot_ids(instrument_id);
1634
1635        if snapshot_position_ids.is_empty() {
1636            return; // Nothing to process
1637        }
1638
1639        let mut rebuild = false;
1640
1641        // Detect purge/reset (count regression) to trigger full rebuild
1642        for position_id in &snapshot_position_ids {
1643            let curr_count = self.cache.borrow().position_snapshot_count(position_id);
1644            let prev_count = self
1645                .inner
1646                .borrow()
1647                .snapshot_processed_counts
1648                .get(position_id)
1649                .copied()
1650                .unwrap_or(0);
1651
1652            if prev_count > curr_count {
1653                rebuild = true;
1654                break;
1655            }
1656        }
1657
1658        if rebuild {
1659            // Full rebuild: process all snapshots from scratch
1660            for position_id in &snapshot_position_ids {
1661                // Track the raw frame count, not the decoded count: snapshots that fail
1662                // to deserialize are skipped and would otherwise make the incremental
1663                // path reprocess trailing valid frames next time.
1664                let snapshot_count = self.cache.borrow().position_snapshot_count(position_id);
1665                let snapshots = self
1666                    .cache
1667                    .borrow()
1668                    .position_snapshots(Some(position_id), None);
1669
1670                let mut sum_pnl: Option<Money> = None;
1671                let mut last_pnl: Option<Money> = None;
1672                let mut snapshot_account_id: Option<AccountId> = None;
1673
1674                for snapshot in snapshots {
1675                    snapshot_account_id.get_or_insert(snapshot.account_id);
1676                    if let Some(realized_pnl) = snapshot.realized_pnl {
1677                        if let Some(sum) = sum_pnl {
1678                            if sum.currency == realized_pnl.currency {
1679                                sum_pnl = Some(sum + realized_pnl);
1680                            }
1681                        } else {
1682                            sum_pnl = Some(realized_pnl);
1683                        }
1684                        last_pnl = Some(realized_pnl);
1685                    }
1686                }
1687
1688                let mut inner = self.inner.borrow_mut();
1689
1690                if let Some(sum) = sum_pnl {
1691                    inner.snapshot_sum_per_position.insert(*position_id, sum);
1692
1693                    if let Some(last) = last_pnl {
1694                        inner.snapshot_last_per_position.insert(*position_id, last);
1695                    }
1696                } else {
1697                    inner.snapshot_sum_per_position.remove(position_id);
1698                    inner.snapshot_last_per_position.remove(position_id);
1699                }
1700
1701                if let Some(account_id) = snapshot_account_id {
1702                    inner.snapshot_account_ids.insert(*position_id, account_id);
1703                } else {
1704                    inner.snapshot_account_ids.remove(position_id);
1705                }
1706
1707                inner
1708                    .snapshot_processed_counts
1709                    .insert(*position_id, snapshot_count);
1710            }
1711        } else {
1712            // Incremental path: only process new snapshots
1713            for position_id in &snapshot_position_ids {
1714                // Compare raw frame counts first so untouched positions skip any
1715                // allocation/serde cost on repeated PnL refreshes.
1716                let curr_count = self.cache.borrow().position_snapshot_count(position_id);
1717                let prev_count = self
1718                    .inner
1719                    .borrow()
1720                    .snapshot_processed_counts
1721                    .get(position_id)
1722                    .copied()
1723                    .unwrap_or(0);
1724
1725                if prev_count >= curr_count {
1726                    continue;
1727                }
1728
1729                let mut sum_pnl = self
1730                    .inner
1731                    .borrow()
1732                    .snapshot_sum_per_position
1733                    .get(position_id)
1734                    .copied();
1735                let mut last_pnl = self
1736                    .inner
1737                    .borrow()
1738                    .snapshot_last_per_position
1739                    .get(position_id)
1740                    .copied();
1741                let mut snapshot_account_id: Option<AccountId> = None;
1742
1743                let new_snapshots = self
1744                    .cache
1745                    .borrow()
1746                    .position_snapshots_from(position_id, prev_count);
1747
1748                for snapshot in new_snapshots {
1749                    snapshot_account_id.get_or_insert(snapshot.account_id);
1750                    if let Some(realized_pnl) = snapshot.realized_pnl {
1751                        if let Some(sum) = sum_pnl {
1752                            if sum.currency == realized_pnl.currency {
1753                                sum_pnl = Some(sum + realized_pnl);
1754                            }
1755                        } else {
1756                            sum_pnl = Some(realized_pnl);
1757                        }
1758                        last_pnl = Some(realized_pnl);
1759                    }
1760                }
1761
1762                let mut inner = self.inner.borrow_mut();
1763
1764                if let Some(sum) = sum_pnl {
1765                    inner.snapshot_sum_per_position.insert(*position_id, sum);
1766
1767                    if let Some(last) = last_pnl {
1768                        inner.snapshot_last_per_position.insert(*position_id, last);
1769                    }
1770                }
1771
1772                if let Some(account_id) = snapshot_account_id
1773                    && !inner.snapshot_account_ids.contains_key(position_id)
1774                {
1775                    inner.snapshot_account_ids.insert(*position_id, account_id);
1776                }
1777
1778                inner
1779                    .snapshot_processed_counts
1780                    .insert(*position_id, curr_count);
1781            }
1782        }
1783    }
1784
1785    fn calculate_realized_pnl(
1786        &self,
1787        instrument_id: &InstrumentId,
1788        account_id: Option<&AccountId>,
1789    ) -> Option<Money> {
1790        // Ensure snapshot PnLs are cached for this instrument
1791        self.ensure_snapshot_pnls_cached_for(instrument_id);
1792
1793        let cache = self.cache.borrow();
1794        let account = match account_id {
1795            Some(id) => cache.account(id),
1796            None => cache.account_for_venue(&instrument_id.venue),
1797        };
1798        let account = if let Some(account) = account {
1799            account
1800        } else {
1801            log::error!(
1802                "Cannot calculate realized PnL: no account for {} / {account_id:?}",
1803                instrument_id.venue,
1804            );
1805            return None;
1806        };
1807
1808        let instrument = if let Some(instrument) = cache.instrument(instrument_id) {
1809            instrument
1810        } else {
1811            log::error!("Cannot calculate realized PnL: no instrument for {instrument_id}");
1812            return None;
1813        };
1814
1815        let currency = account
1816            .base_currency()
1817            .unwrap_or_else(|| instrument.settlement_currency());
1818
1819        let positions = cache.positions(None, Some(instrument_id), None, account_id, None);
1820
1821        // Filter snapshots by account when requested so closed-position PnL
1822        // from other accounts on the same venue does not leak in. Sort the
1823        // collected IDs so the per-snapshot pending-calcs/early-return path
1824        // and the value accumulation iterate in a deterministic sequence.
1825        let mut snapshot_position_ids: Vec<PositionId> = if let Some(filter_id) = account_id {
1826            let inner = self.inner.borrow();
1827            cache
1828                .position_snapshot_ids(instrument_id)
1829                .into_iter()
1830                .filter(|pid| {
1831                    inner
1832                        .snapshot_account_ids
1833                        .get(pid)
1834                        .is_some_and(|id| id == filter_id)
1835                })
1836                .collect()
1837        } else {
1838            cache
1839                .position_snapshot_ids(instrument_id)
1840                .into_iter()
1841                .collect()
1842        };
1843        snapshot_position_ids.sort();
1844
1845        // Check if we need to use NETTING OMS logic
1846        let is_netting = positions
1847            .iter()
1848            .any(|p| cache.oms_type(&p.id) == Some(OmsType::Netting));
1849
1850        let mut total_pnl = Decimal::ZERO;
1851
1852        if is_netting && !snapshot_position_ids.is_empty() {
1853            // NETTING OMS: Apply 3-case rule for position cycles
1854
1855            for position_id in &snapshot_position_ids {
1856                let is_active = positions.iter().any(|p| p.id == *position_id);
1857
1858                if is_active {
1859                    // Case 1 & 2: Active position - use only the last snapshot PnL
1860                    let last_pnl = self
1861                        .inner
1862                        .borrow()
1863                        .snapshot_last_per_position
1864                        .get(position_id)
1865                        .copied();
1866
1867                    if let Some(last_pnl) = last_pnl {
1868                        let mut pnl = last_pnl.as_decimal();
1869
1870                        if let Some(base_currency) = account.base_currency()
1871                            && positions.iter().any(|p| p.id == *position_id)
1872                        {
1873                            let xrate = if let Some(xrate) =
1874                                self.calculate_xrate_to_base(instrument, &account)
1875                            {
1876                                xrate
1877                            } else {
1878                                log::warn!(
1879                                    "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
1880                                    instrument.settlement_currency(),
1881                                    base_currency
1882                                );
1883                                self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1884                                return Some(Money::zero(currency));
1885                            };
1886
1887                            pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
1888                        }
1889
1890                        total_pnl += pnl;
1891                    }
1892                } else {
1893                    // Case 3: Closed position - use sum of all snapshot PnLs
1894                    let sum_pnl = self
1895                        .inner
1896                        .borrow()
1897                        .snapshot_sum_per_position
1898                        .get(position_id)
1899                        .copied();
1900
1901                    if let Some(sum_pnl) = sum_pnl {
1902                        let mut pnl = sum_pnl.as_decimal();
1903
1904                        if let Some(base_currency) = account.base_currency() {
1905                            // For closed positions, we don't have entry price, use current rates
1906                            let xrate = cache.get_xrate(
1907                                instrument_id.venue,
1908                                instrument.settlement_currency(),
1909                                base_currency,
1910                                PriceType::Mid,
1911                            );
1912
1913                            if let Some(xrate) = xrate {
1914                                pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
1915                            } else {
1916                                log::warn!(
1917                                    "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
1918                                    instrument.settlement_currency(),
1919                                    base_currency
1920                                );
1921                                self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1922                                return Some(Money::zero(currency));
1923                            }
1924                        }
1925
1926                        total_pnl += pnl;
1927                    }
1928                }
1929            }
1930
1931            // Add realized PnL from current active positions
1932            for position in positions {
1933                if position.instrument_id != *instrument_id {
1934                    continue;
1935                }
1936
1937                if let Some(realized_pnl) = position.realized_pnl {
1938                    let mut pnl = realized_pnl.as_decimal();
1939
1940                    if let Some(base_currency) = account.base_currency() {
1941                        let xrate = if let Some(xrate) =
1942                            self.calculate_xrate_to_base(instrument, &account)
1943                        {
1944                            xrate
1945                        } else {
1946                            log::warn!(
1947                                "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
1948                                instrument.settlement_currency(),
1949                                base_currency
1950                            );
1951                            self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1952                            return Some(Money::zero(currency));
1953                        };
1954
1955                        pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
1956                    }
1957
1958                    total_pnl += pnl;
1959                }
1960            }
1961        } else {
1962            // HEDGING OMS or no snapshots: Simple aggregation
1963            // Add snapshot PnLs (sum all)
1964            for position_id in &snapshot_position_ids {
1965                let sum_pnl = self
1966                    .inner
1967                    .borrow()
1968                    .snapshot_sum_per_position
1969                    .get(position_id)
1970                    .copied();
1971
1972                if let Some(sum_pnl) = sum_pnl {
1973                    let mut pnl = sum_pnl.as_decimal();
1974
1975                    if let Some(base_currency) = account.base_currency() {
1976                        let xrate = cache.get_xrate(
1977                            instrument_id.venue,
1978                            instrument.settlement_currency(),
1979                            base_currency,
1980                            PriceType::Mid,
1981                        );
1982
1983                        if let Some(xrate) = xrate {
1984                            pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
1985                        } else {
1986                            log::warn!(
1987                                "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
1988                                instrument.settlement_currency(),
1989                                base_currency
1990                            );
1991                            self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
1992                            return Some(Money::zero(currency));
1993                        }
1994                    }
1995
1996                    total_pnl += pnl;
1997                }
1998            }
1999
2000            // Add realized PnL from current positions
2001            for position in positions {
2002                if position.instrument_id != *instrument_id {
2003                    continue;
2004                }
2005
2006                if let Some(realized_pnl) = position.realized_pnl {
2007                    let mut pnl = realized_pnl.as_decimal();
2008
2009                    if let Some(base_currency) = account.base_currency() {
2010                        let xrate = if let Some(xrate) =
2011                            self.calculate_xrate_to_base(instrument, &account)
2012                        {
2013                            xrate
2014                        } else {
2015                            log::warn!(
2016                                "Cannot calculate realized PnL: insufficient exchange rate data for {}/{}, marking as pending calculation",
2017                                instrument.settlement_currency(),
2018                                base_currency
2019                            );
2020                            self.inner.borrow_mut().pending_calcs.insert(*instrument_id);
2021                            return Some(Money::zero(currency));
2022                        };
2023
2024                        pnl = (pnl * xrate).round_dp(u32::from(currency.precision));
2025                    }
2026
2027                    total_pnl += pnl;
2028                }
2029            }
2030        }
2031
2032        match Money::from_decimal(total_pnl, currency) {
2033            Ok(money) => Some(money),
2034            Err(e) => {
2035                log::error!("Cannot calculate realized PnL: {e}");
2036                Some(Money::zero(currency))
2037            }
2038        }
2039    }
2040
2041    fn get_price(&self, position: &Position) -> Option<Price> {
2042        let cache = self.cache.borrow();
2043        let instrument_id = &position.instrument_id;
2044
2045        // Check for mark price first if configured
2046        if self.config.use_mark_prices
2047            && let Some(mark_price) = cache.mark_price(instrument_id)
2048        {
2049            return Some(mark_price.value);
2050        }
2051
2052        // Fall back to bid/ask based on position side
2053        let price_type = match position.side {
2054            PositionSide::Long => PriceType::Bid,
2055            PositionSide::Short => PriceType::Ask,
2056            _ => panic!("invalid `PositionSide`, was {}", position.side),
2057        };
2058
2059        cache
2060            .price(instrument_id, price_type)
2061            .or_else(|| cache.price(instrument_id, PriceType::Last))
2062            .or_else(|| {
2063                self.inner
2064                    .borrow()
2065                    .bar_close_prices
2066                    .get(instrument_id)
2067                    .copied()
2068            })
2069    }
2070
2071    fn calculate_xrate_to_base(
2072        &self,
2073        instrument: &InstrumentAny,
2074        account: &AccountAny,
2075    ) -> Option<Decimal> {
2076        if !self.config.convert_to_account_base_currency {
2077            return Some(Decimal::ONE); // No conversion needed
2078        }
2079
2080        let base_currency = match account.base_currency() {
2081            Some(base_currency) => base_currency,
2082            None => return Some(Decimal::ONE),
2083        };
2084
2085        let settlement = instrument.settlement_currency();
2086        let cache = self.cache.borrow();
2087
2088        if self.config.use_mark_xrates
2089            && let Some(xrate) = cache.get_mark_xrate(settlement, base_currency)
2090        {
2091            // Mark exchange rates are stored as f64 in the cache; convert at the boundary
2092            return Decimal::try_from(xrate).ok();
2093        }
2094
2095        cache.get_xrate(
2096            instrument.id().venue,
2097            settlement,
2098            base_currency,
2099            PriceType::Mid,
2100        )
2101    }
2102}
2103
2104// Helper functions
2105
2106fn decimal_map_to_money(map: IndexMap<Currency, Decimal>) -> IndexMap<Currency, Money> {
2107    map.into_iter()
2108        .filter_map(
2109            |(currency, amount)| match Money::from_decimal(amount, currency) {
2110                Ok(money) => Some((currency, money)),
2111                Err(e) => {
2112                    log::error!("Cannot convert {currency} amount to Money: {e}");
2113                    None
2114                }
2115            },
2116        )
2117        .collect()
2118}
2119
2120fn update_quote_tick(
2121    cache: &Rc<RefCell<Cache>>,
2122    clock: &Rc<RefCell<dyn Clock>>,
2123    inner: &Rc<RefCell<PortfolioState>>,
2124    config: PortfolioConfig,
2125    quote: &QuoteTick,
2126) {
2127    update_instrument_id(cache, clock, inner, config, &quote.instrument_id);
2128}
2129
2130fn update_bar(
2131    cache: &Rc<RefCell<Cache>>,
2132    clock: &Rc<RefCell<dyn Clock>>,
2133    inner: &Rc<RefCell<PortfolioState>>,
2134    config: PortfolioConfig,
2135    bar: &Bar,
2136) {
2137    let instrument_id = bar.bar_type.instrument_id();
2138    inner
2139        .borrow_mut()
2140        .bar_close_prices
2141        .insert(instrument_id, bar.close);
2142    update_instrument_id(cache, clock, inner, config, &instrument_id);
2143}
2144
2145fn update_instrument_id(
2146    cache: &Rc<RefCell<Cache>>,
2147    clock: &Rc<RefCell<dyn Clock>>,
2148    inner: &Rc<RefCell<PortfolioState>>,
2149    config: PortfolioConfig,
2150    instrument_id: &InstrumentId,
2151) {
2152    inner
2153        .borrow_mut()
2154        .unrealized_pnls
2155        .shift_remove(instrument_id);
2156
2157    if inner.borrow().initialized || !inner.borrow().pending_calcs.contains(instrument_id) {
2158        return;
2159    }
2160
2161    let mut result_maint = None;
2162
2163    // Scoped borrow: must drop before calling AccountsManager (which borrows cache internally)
2164    let (account, instrument, orders_open, positions_open) = {
2165        let cache_ref = cache.borrow();
2166        let account = if let Some(account) = cache_ref.account_for_venue(&instrument_id.venue) {
2167            account.clone()
2168        } else {
2169            log::error!(
2170                "Cannot update tick: no account registered for {}",
2171                instrument_id.venue
2172            );
2173            return;
2174        };
2175        let instrument = if let Some(instrument) = cache_ref.instrument(instrument_id) {
2176            instrument.clone()
2177        } else {
2178            log::error!("Cannot update tick: no instrument found for {instrument_id}");
2179            return;
2180        };
2181        let orders_open: Vec<OrderAny> = cache_ref
2182            .orders_open(None, Some(instrument_id), None, None, None)
2183            .iter()
2184            .map(|o| (*o).clone())
2185            .collect();
2186        let positions_open: Vec<Position> = cache_ref
2187            .positions_open(None, Some(instrument_id), None, None, None)
2188            .iter()
2189            .map(|p| (*p).clone())
2190            .collect();
2191        (account, instrument, orders_open, positions_open)
2192    };
2193
2194    // No cache borrow held: AccountsManager borrows cache internally for xrate lookups
2195    let orders_open_refs: Vec<&OrderAny> = orders_open.iter().collect();
2196    let result_init = inner.borrow().accounts.update_orders(
2197        &account,
2198        &instrument,
2199        &orders_open_refs,
2200        clock.borrow().timestamp_ns(),
2201    );
2202
2203    if let AccountAny::Margin(ref margin_account) = account {
2204        result_maint = inner.borrow().accounts.update_positions(
2205            margin_account,
2206            &instrument,
2207            positions_open.iter().collect(),
2208            clock.borrow().timestamp_ns(),
2209        );
2210    }
2211
2212    if let Some((ref updated_account, _)) = result_init {
2213        cache.borrow_mut().update_account(updated_account).unwrap();
2214    }
2215
2216    let portfolio_clone = Portfolio {
2217        clock: clock.clone(),
2218        cache: cache.clone(),
2219        inner: inner.clone(),
2220        config,
2221    };
2222
2223    let result_unrealized_pnl: Option<Money> =
2224        portfolio_clone.calculate_unrealized_pnl(instrument_id, None);
2225
2226    if result_init.is_some()
2227        && (matches!(account, AccountAny::Cash(_) | AccountAny::Betting(_))
2228            || (result_maint.is_some() && result_unrealized_pnl.is_some()))
2229    {
2230        inner.borrow_mut().pending_calcs.remove(instrument_id);
2231        if inner.borrow().pending_calcs.is_empty() {
2232            inner.borrow_mut().initialized = true;
2233        }
2234    }
2235}
2236
2237fn update_order(
2238    cache: &Rc<RefCell<Cache>>,
2239    clock: &Rc<RefCell<dyn Clock>>,
2240    inner: &Rc<RefCell<PortfolioState>>,
2241    config: PortfolioConfig,
2242    event: &OrderEventAny,
2243    source: OrderUpdateSource,
2244) {
2245    let mut mark_pre_position_fill_event = None;
2246
2247    if let OrderEventAny::Filled(order_filled) = event {
2248        match source {
2249            OrderUpdateSource::Endpoint => {
2250                mark_pre_position_fill_event = Some(order_filled.event_id);
2251            }
2252            OrderUpdateSource::Topic => {
2253                if inner
2254                    .borrow_mut()
2255                    .pre_position_fill_events
2256                    .remove(&order_filled.event_id)
2257                {
2258                    return;
2259                }
2260            }
2261        }
2262    }
2263
2264    let account_id = match event.account_id() {
2265        Some(account_id) => account_id,
2266        None => {
2267            return; // No Account Assigned
2268        }
2269    };
2270
2271    // Scoped borrow: must drop before calling AccountsManager (which borrows cache internally)
2272    let (instrument, orders_open) = {
2273        let cache_ref = cache.borrow();
2274
2275        let account = match cache_ref.try_account(&account_id) {
2276            Ok(account) => account,
2277            Err(e) => {
2278                log::error!("Cannot update order: {e}");
2279                return;
2280            }
2281        };
2282
2283        match &*account {
2284            AccountAny::Margin(margin_account) => {
2285                if !margin_account.base.calculate_account_state {
2286                    return;
2287                }
2288            }
2289            AccountAny::Cash(cash_account) => {
2290                if !cash_account.base.calculate_account_state {
2291                    return;
2292                }
2293            }
2294            AccountAny::Betting(betting_account) => {
2295                if !betting_account.base.calculate_account_state {
2296                    return;
2297                }
2298            }
2299        }
2300
2301        match event {
2302            OrderEventAny::Accepted(_)
2303            | OrderEventAny::Canceled(_)
2304            | OrderEventAny::Expired(_)
2305            | OrderEventAny::Rejected(_)
2306            | OrderEventAny::Updated(_)
2307            | OrderEventAny::Filled(_) => {}
2308            _ => {
2309                return;
2310            }
2311        }
2312
2313        let order = cache_ref.order(&event.client_order_id());
2314        if order.is_none() && !matches!(event, OrderEventAny::Filled(_)) {
2315            log::error!(
2316                "Cannot update order: {} not found in the cache",
2317                event.client_order_id()
2318            );
2319            return; // No Order Found
2320        }
2321
2322        if matches!(event, OrderEventAny::Rejected(_))
2323            && order.is_some_and(|order| order.order_type() != OrderType::StopLimit)
2324        {
2325            return; // No change to account state
2326        }
2327
2328        let instrument = if let Some(instrument) = cache_ref.instrument(&event.instrument_id()) {
2329            instrument.clone()
2330        } else {
2331            log::error!(
2332                "Cannot update order: no instrument found for {}",
2333                event.instrument_id()
2334            );
2335            return;
2336        };
2337
2338        let orders_open: Vec<OrderAny> = cache_ref
2339            .orders_open(
2340                None,
2341                Some(&event.instrument_id()),
2342                None,
2343                Some(&account_id),
2344                None,
2345            )
2346            .iter()
2347            .map(|o| (*o).clone())
2348            .collect();
2349
2350        (instrument, orders_open)
2351    };
2352
2353    // No cache borrow held: AccountsManager borrows cache internally for xrate lookups.
2354    let mut working_account = match cache.borrow_mut().take_account(&account_id) {
2355        Some(account) => account,
2356        None => {
2357            log::error!(
2358                "Cannot update order: {}",
2359                AccountLookupError::not_found(account_id)
2360            );
2361            return;
2362        }
2363    };
2364
2365    if let OrderEventAny::Filled(order_filled) = event {
2366        if !instrument.is_spread() {
2367            let (post_balance, _state) = inner.borrow().accounts.update_balances(
2368                working_account,
2369                &instrument,
2370                *order_filled,
2371            );
2372            working_account = post_balance;
2373        }
2374
2375        cache.borrow_mut().cache_account_owned(working_account);
2376
2377        let portfolio_clone = Portfolio {
2378            clock: clock.clone(),
2379            cache: cache.clone(),
2380            inner: inner.clone(),
2381            config,
2382        };
2383
2384        match portfolio_clone.calculate_unrealized_pnl(&order_filled.instrument_id, None) {
2385            Some(unrealized_pnl) => {
2386                inner
2387                    .borrow_mut()
2388                    .unrealized_pnls
2389                    .insert(event.instrument_id(), unrealized_pnl);
2390            }
2391            None => {
2392                log::error!(
2393                    "Failed to calculate unrealized PnL for instrument {}",
2394                    event.instrument_id()
2395                );
2396            }
2397        }
2398
2399        working_account = cache
2400            .borrow_mut()
2401            .take_account(&account_id)
2402            .expect("account restored before unrealized PnL calculation");
2403    }
2404
2405    let orders_open_refs: Vec<&OrderAny> = orders_open.iter().collect();
2406    let account_state = inner.borrow().accounts.update_orders_in_place(
2407        &mut working_account,
2408        &instrument,
2409        &orders_open_refs,
2410        clock.borrow().timestamp_ns(),
2411    );
2412
2413    let is_fill = matches!(event, OrderEventAny::Filled(_));
2414    let suppress_margin_fill_account_state =
2415        is_fill && matches!(working_account, AccountAny::Margin(_));
2416    let publish_account_state = !matches!(source, OrderUpdateSource::Endpoint) && !is_fill;
2417
2418    if !publish_account_state
2419        && !suppress_margin_fill_account_state
2420        && let Some(account_state) = account_state.as_ref()
2421        && let Err(e) = working_account.apply(account_state.clone())
2422    {
2423        log::error!("Cannot apply generated account state: {e}");
2424    }
2425
2426    let updated_account_id = working_account.id();
2427
2428    if account_state.is_some() || matches!(event, OrderEventAny::Filled(_)) {
2429        cache
2430            .borrow_mut()
2431            .update_account_owned(working_account)
2432            .unwrap();
2433    } else {
2434        cache.borrow_mut().cache_account_owned(working_account);
2435    }
2436
2437    // Consumed by the matching `events.order.*` topic handler; engine publishes after every endpoint send
2438    if let Some(event_id) = mark_pre_position_fill_event {
2439        inner.borrow_mut().pre_position_fill_events.insert(event_id);
2440    }
2441
2442    if let Some(account_state) = account_state {
2443        if publish_account_state {
2444            msgbus::publish_account_state(
2445                format!("events.account.{updated_account_id}").into(),
2446                &account_state,
2447            );
2448        }
2449    } else {
2450        log::debug!("Added pending calculation for {}", instrument.id());
2451        inner.borrow_mut().pending_calcs.insert(instrument.id());
2452    }
2453
2454    log::debug!("Updated {event}");
2455}
2456
2457fn on_order_event(
2458    cache: &Rc<RefCell<Cache>>,
2459    inner: &Rc<RefCell<PortfolioState>>,
2460    event: &OrderEventAny,
2461) {
2462    if let OrderEventAny::Filled(order_filled) = event {
2463        inner
2464            .borrow_mut()
2465            .pre_position_fill_events
2466            .remove(&order_filled.event_id);
2467        return;
2468    }
2469
2470    let account_id = match event.account_id() {
2471        Some(account_id) => account_id,
2472        None => return,
2473    };
2474
2475    match event {
2476        OrderEventAny::Accepted(_)
2477        | OrderEventAny::Canceled(_)
2478        | OrderEventAny::Expired(_)
2479        | OrderEventAny::Rejected(_)
2480        | OrderEventAny::Updated(_) => {}
2481        _ => return,
2482    }
2483
2484    let account_state = cache
2485        .borrow()
2486        .account(&account_id)
2487        .and_then(|account| account.last_event());
2488
2489    if let Some(account_state) = account_state {
2490        msgbus::publish_account_state(
2491            format!("events.account.{account_id}").into(),
2492            &account_state,
2493        );
2494    }
2495}
2496
2497fn update_position(
2498    cache: &Rc<RefCell<Cache>>,
2499    clock: &Rc<RefCell<dyn Clock>>,
2500    inner: &Rc<RefCell<PortfolioState>>,
2501    config: PortfolioConfig,
2502    event: &PositionEvent,
2503) {
2504    let instrument_id = event.instrument_id();
2505    let account_id = event.account_id();
2506
2507    update_snapshot_timer_state(cache, clock, inner, config, account_id);
2508
2509    let portfolio_clone = Portfolio {
2510        clock: clock.clone(),
2511        cache: cache.clone(),
2512        inner: inner.clone(),
2513        config,
2514    };
2515
2516    {
2517        let cache_ref = cache.borrow();
2518        let refs = cache_ref.positions_open(None, Some(&instrument_id), None, None, None);
2519        log::debug!("position fresh from cache -> {refs:?}");
2520        let positions: Vec<&Position> = refs.iter().map(|r| &**r).collect();
2521        portfolio_clone.update_net_position(&instrument_id, &positions);
2522    }
2523
2524    record_closed_position_pnl(cache, inner, config, event);
2525
2526    if let Some(calculated_unrealized_pnl) =
2527        portfolio_clone.calculate_unrealized_pnl(&instrument_id, None)
2528    {
2529        inner
2530            .borrow_mut()
2531            .unrealized_pnls
2532            .insert(event.instrument_id(), calculated_unrealized_pnl);
2533    } else {
2534        log::debug!(
2535            "Failed to calculate unrealized PnL for {}, marking as pending",
2536            event.instrument_id()
2537        );
2538        inner
2539            .borrow_mut()
2540            .pending_calcs
2541            .insert(event.instrument_id());
2542    }
2543
2544    if let Some(calculated_realized_pnl) =
2545        portfolio_clone.calculate_realized_pnl(&instrument_id, None)
2546    {
2547        inner
2548            .borrow_mut()
2549            .realized_pnls
2550            .insert(event.instrument_id(), calculated_realized_pnl);
2551    } else {
2552        log::warn!(
2553            "Failed to calculate realized PnL for {}, marking as pending",
2554            event.instrument_id()
2555        );
2556        inner
2557            .borrow_mut()
2558            .pending_calcs
2559            .insert(event.instrument_id());
2560    }
2561
2562    let account = { cache.borrow().account_owned(&account_id) };
2563    let account_state_to_publish = match account {
2564        Some(AccountAny::Margin(margin_account)) => {
2565            if margin_account.calculate_account_state {
2566                let instrument = { cache.borrow().instrument(&instrument_id).cloned() };
2567                if let Some(instrument) = instrument {
2568                    let result = {
2569                        let cache_ref = cache.borrow();
2570                        let refs = cache_ref.positions_open(
2571                            None,
2572                            Some(&instrument_id),
2573                            None,
2574                            Some(&account_id),
2575                            None,
2576                        );
2577                        let positions: Vec<&Position> = refs.iter().map(|r| &**r).collect();
2578                        inner.borrow_mut().accounts.update_positions(
2579                            &margin_account,
2580                            &instrument,
2581                            positions,
2582                            clock.borrow().timestamp_ns(),
2583                        )
2584                    };
2585
2586                    if let Some((margin_account, account_state)) = result {
2587                        cache
2588                            .borrow_mut()
2589                            .update_account(&AccountAny::Margin(margin_account))
2590                            .unwrap();
2591                        Some(account_state)
2592                    } else {
2593                        margin_account.last_event()
2594                    }
2595                } else {
2596                    log::error!("Cannot update position: no instrument found for {instrument_id}");
2597                    margin_account.last_event()
2598                }
2599            } else {
2600                margin_account.last_event()
2601            }
2602        }
2603        Some(account) => account.last_event(),
2604        None => {
2605            log::error!(
2606                "Cannot update position: no account registered for {}",
2607                event.account_id()
2608            );
2609            None
2610        }
2611    };
2612
2613    if let Some(account_state) = account_state_to_publish {
2614        msgbus::publish_account_state(
2615            format!("events.account.{account_id}").into(),
2616            &account_state,
2617        );
2618    }
2619}
2620
2621fn record_closed_position_pnl(
2622    cache: &Rc<RefCell<Cache>>,
2623    inner: &Rc<RefCell<PortfolioState>>,
2624    config: PortfolioConfig,
2625    event: &PositionEvent,
2626) {
2627    let position_id = match event {
2628        PositionEvent::PositionOpened(event) => event.position_id,
2629        PositionEvent::PositionChanged(event) => event.position_id,
2630        PositionEvent::PositionClosed(event) => event.position_id,
2631        PositionEvent::PositionAdjusted(event) => event.position_id,
2632    };
2633
2634    let cache_ref = cache.borrow();
2635    let Some(position) = cache_ref.position(&position_id) else {
2636        return;
2637    };
2638
2639    if !position.is_closed() {
2640        return;
2641    }
2642
2643    let Some(realized_pnl) = position.realized_pnl else {
2644        return;
2645    };
2646
2647    let mut inner_ref = inner.borrow_mut();
2648
2649    if !inner_ref
2650        .recorded_closed_position_cycles
2651        .insert((position.id, position.ts_opened))
2652    {
2653        return;
2654    }
2655
2656    let converted_pnl =
2657        converted_realized_pnl(&cache_ref, config, event, position_id, realized_pnl);
2658
2659    let ts_event = position.ts_last;
2660    inner_ref
2661        .analyzer
2662        .record_trade(&position.id, ts_event, &realized_pnl);
2663
2664    if let Some(converted_pnl) = converted_pnl {
2665        inner_ref
2666            .analyzer
2667            .record_trade(&position.id, ts_event, &converted_pnl);
2668    }
2669}
2670
2671fn converted_realized_pnl(
2672    cache_ref: &Cache,
2673    config: PortfolioConfig,
2674    event: &PositionEvent,
2675    position_id: PositionId,
2676    realized_pnl: Money,
2677) -> Option<Money> {
2678    let account = cache_ref.account(&event.account_id())?;
2679    let base_currency = account.base_currency()?;
2680
2681    if realized_pnl.currency == base_currency {
2682        return None;
2683    }
2684
2685    let xrate = if config.use_mark_xrates {
2686        cache_ref
2687            .get_mark_xrate(realized_pnl.currency, base_currency)
2688            .and_then(|xrate| Decimal::try_from(xrate).ok())
2689    } else {
2690        cache_ref.get_xrate(
2691            event.instrument_id().venue,
2692            realized_pnl.currency,
2693            base_currency,
2694            PriceType::Mid,
2695        )
2696    };
2697
2698    let Some(xrate) = xrate else {
2699        log::warn!(
2700            "Cannot record account-currency realized PnL for {position_id}: conversion failed from {} to {base_currency}",
2701            realized_pnl.currency
2702        );
2703        return None;
2704    };
2705
2706    let amount = (realized_pnl.as_decimal() * xrate).round_dp(u32::from(base_currency.precision));
2707    match Money::from_decimal(amount, base_currency) {
2708        Ok(amount) => Some(amount),
2709        Err(e) => {
2710            log::warn!("Cannot record account-currency realized PnL for {position_id}: {e}");
2711            None
2712        }
2713    }
2714}
2715
2716fn update_account(
2717    cache: &Rc<RefCell<Cache>>,
2718    inner: &Rc<RefCell<PortfolioState>>,
2719    event: &AccountState,
2720) {
2721    let already_applied = {
2722        cache
2723            .borrow()
2724            .account(&event.account_id)
2725            .and_then(|account| account.last_event())
2726            .is_some_and(|last_event| last_event.event_id == event.event_id)
2727    };
2728
2729    if !already_applied && let Err(e) = cache.borrow_mut().update_account_state(event) {
2730        log::error!("Failed to update account state: {e}");
2731        return;
2732    }
2733
2734    // Throttled logging logic
2735    let mut inner_ref = inner.borrow_mut();
2736    let should_log = if inner_ref.min_account_state_logging_interval_ns > 0 {
2737        let current_ts = event.ts_init.as_u64();
2738        let last_ts = inner_ref
2739            .last_account_state_log_ts
2740            .get(&event.account_id)
2741            .copied()
2742            .unwrap_or(0);
2743
2744        if last_ts == 0 || (current_ts - last_ts) >= inner_ref.min_account_state_logging_interval_ns
2745        {
2746            inner_ref
2747                .last_account_state_log_ts
2748                .insert(event.account_id, current_ts);
2749            true
2750        } else {
2751            false
2752        }
2753    } else {
2754        true // Throttling disabled, always log
2755    };
2756
2757    if should_log {
2758        log::info!("Updated {event}");
2759    }
2760}
2761
2762fn snapshot_timer_name(account_id: AccountId) -> String {
2763    format!("portfolio_snapshot.{account_id}")
2764}
2765
2766fn update_snapshot_timer_state(
2767    cache: &Rc<RefCell<Cache>>,
2768    clock: &Rc<RefCell<dyn Clock>>,
2769    inner: &Rc<RefCell<PortfolioState>>,
2770    config: PortfolioConfig,
2771    account_id: AccountId,
2772) {
2773    if config.snapshot_interval_ms.is_none() {
2774        return;
2775    }
2776
2777    let current_count = cache
2778        .borrow()
2779        .positions_open(None, None, None, Some(&account_id), None)
2780        .len();
2781
2782    let prev_count = inner
2783        .borrow()
2784        .account_open_positions
2785        .get(&account_id)
2786        .copied()
2787        .unwrap_or(0);
2788
2789    inner
2790        .borrow_mut()
2791        .account_open_positions
2792        .insert(account_id, current_count);
2793
2794    if prev_count == 0 && current_count > 0 {
2795        arm_snapshot_timer(cache, clock, inner, config, account_id);
2796    } else if prev_count > 0 && current_count == 0 {
2797        clock
2798            .borrow_mut()
2799            .cancel_timer(&snapshot_timer_name(account_id));
2800    }
2801}
2802
2803fn arm_snapshot_timer(
2804    cache: &Rc<RefCell<Cache>>,
2805    clock: &Rc<RefCell<dyn Clock>>,
2806    inner: &Rc<RefCell<PortfolioState>>,
2807    config: PortfolioConfig,
2808    account_id: AccountId,
2809) {
2810    let interval_ms = match config.snapshot_interval_ms {
2811        Some(ms) if ms > 0 => ms,
2812        _ => return,
2813    };
2814    let interval_ns = interval_ms * NANOSECONDS_IN_MILLISECOND;
2815    let timer_name = snapshot_timer_name(account_id);
2816
2817    let cache_weak = Rc::downgrade(cache);
2818    let clock_weak = Rc::downgrade(clock);
2819    let inner_weak = Rc::downgrade(inner);
2820
2821    let callback: Rc<dyn Fn(TimeEvent)> = Rc::new(move |event| {
2822        let cache = match cache_weak.upgrade() {
2823            Some(c) => c,
2824            None => return,
2825        };
2826        let clock = match clock_weak.upgrade() {
2827            Some(c) => c,
2828            None => return,
2829        };
2830        let inner = match inner_weak.upgrade() {
2831            Some(i) => i,
2832            None => return,
2833        };
2834        emit_snapshot(&cache, &clock, &inner, config, account_id, event.ts_event);
2835    });
2836
2837    if let Err(e) = clock.borrow_mut().set_timer_ns(
2838        &timer_name,
2839        interval_ns,
2840        None,
2841        None,
2842        Some(TimeEventCallback::from(callback)),
2843        Some(true),
2844        Some(false),
2845    ) {
2846        log::error!("Failed to arm portfolio snapshot timer for {account_id}: {e}");
2847    }
2848}
2849
2850fn emit_snapshot(
2851    cache: &Rc<RefCell<Cache>>,
2852    clock: &Rc<RefCell<dyn Clock>>,
2853    inner: &Rc<RefCell<PortfolioState>>,
2854    config: PortfolioConfig,
2855    account_id: AccountId,
2856    ts_event: nautilus_core::UnixNanos,
2857) {
2858    let mut portfolio = Portfolio {
2859        cache: cache.clone(),
2860        clock: clock.clone(),
2861        inner: inner.clone(),
2862        config,
2863    };
2864
2865    let mut snapshot = match portfolio.build_snapshot(&account_id) {
2866        Some(snapshot) => snapshot,
2867        None => return,
2868    };
2869    // Stamp the snapshot with the timer's scheduled fire time so the cadence
2870    // is preserved even if the dispatcher batches or runs late. ts_init stays
2871    // the construction time set by build_snapshot.
2872    snapshot.ts_event = ts_event;
2873
2874    msgbus::publish_portfolio_snapshot(format!("events.portfolio.{account_id}").into(), &snapshot);
2875
2876    let mut inner_mut = inner.borrow_mut();
2877    push_bounded(
2878        &mut inner_mut.portfolio_snapshots,
2879        account_id,
2880        snapshot,
2881        SNAPSHOT_BUFFER_CAP,
2882    );
2883}
2884
2885/// Appends `snapshot` onto the per-account ring, dropping the oldest entry when at `cap`.
2886fn push_bounded(
2887    snapshots: &mut AHashMap<AccountId, VecDeque<PortfolioSnapshot>>,
2888    account_id: AccountId,
2889    snapshot: PortfolioSnapshot,
2890    cap: usize,
2891) {
2892    let ring = snapshots.entry(account_id).or_default();
2893    if ring.len() == cap {
2894        ring.pop_front();
2895    }
2896    ring.push_back(snapshot);
2897}
2898
2899#[cfg(test)]
2900mod tests {
2901    use nautilus_core::{UUID4, UnixNanos};
2902    use nautilus_model::{enums::AccountType, identifiers::AccountId};
2903    use rstest::rstest;
2904
2905    use super::*;
2906
2907    fn mk_snapshot(seq: u64) -> PortfolioSnapshot {
2908        PortfolioSnapshot::new(
2909            AccountId::new("SIM-001"),
2910            AccountType::Cash,
2911            None,
2912            Vec::new(),
2913            Vec::new(),
2914            Vec::new(),
2915            Vec::new(),
2916            Vec::new(),
2917            UUID4::new(),
2918            UnixNanos::from(seq),
2919            UnixNanos::from(seq),
2920        )
2921    }
2922
2923    #[rstest]
2924    fn push_bounded_drops_oldest_when_at_cap() {
2925        let account_id = AccountId::new("SIM-001");
2926        let mut snapshots: AHashMap<AccountId, VecDeque<PortfolioSnapshot>> = AHashMap::new();
2927
2928        for seq in 0..5 {
2929            push_bounded(&mut snapshots, account_id, mk_snapshot(seq), 3);
2930        }
2931
2932        let ring = snapshots.get(&account_id).expect("ring exists");
2933        assert_eq!(ring.len(), 3);
2934        assert_eq!(ring.front().unwrap().ts_event, UnixNanos::from(2));
2935        assert_eq!(ring.back().unwrap().ts_event, UnixNanos::from(4));
2936    }
2937}