Skip to main content

nautilus_analysis/
analyzer.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
16use std::{collections::BTreeMap, fmt::Debug, sync::Arc};
17
18use ahash::AHashMap;
19use indexmap::{IndexMap, IndexSet};
20use nautilus_core::{DurationNanos, UUID4, UnixNanos};
21use nautilus_model::{
22    accounts::{Account, AccountAny},
23    events::PortfolioSnapshot,
24    identifiers::{AccountId, PositionId},
25    position::Position,
26    types::{Currency, Money},
27};
28use rust_decimal::Decimal;
29
30use crate::{
31    Returns,
32    snapshot::PortfolioStatistics,
33    statistic::PortfolioStatistic,
34    statistics::{
35        expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
36        loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
37        returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
38        returns_kurtosis::ReturnsKurtosis, returns_skewness::ReturnsSkewness,
39        returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
40        sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, tail_ratio::TailRatio,
41        win_rate::WinRate, winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
42    },
43};
44
45pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
46
47/// Analyzes portfolio performance and calculates various statistics.
48///
49/// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
50/// to provide portfolio analysis including returns, PnL calculations,
51/// and customizable statistics.
52#[repr(C)]
53#[derive(Debug)]
54#[cfg_attr(feature = "python", pyo3::pyclass(module = "nautilus_trader.analysis"))]
55#[cfg_attr(
56    feature = "python",
57    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
58)]
59pub struct PortfolioAnalyzer {
60    pub statistics: AHashMap<String, Statistic>,
61    pub account_balances_starting: IndexMap<Currency, Money>,
62    pub account_balances: IndexMap<Currency, Money>,
63    pub positions: Vec<Position>,
64    pub realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
65    pub recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
66    pub position_returns: Returns,
67    pub portfolio_returns: Returns,
68    /// Alias for the primary returns source.
69    ///
70    /// Contains portfolio returns when available, otherwise position returns.
71    /// Kept as a public field for API stability; prefer the `returns()` accessor.
72    pub returns: Returns,
73}
74
75impl Default for PortfolioAnalyzer {
76    /// Creates a new default [`PortfolioAnalyzer`] instance.
77    fn default() -> Self {
78        let mut analyzer = Self::new();
79        analyzer.register_statistic(Arc::new(MaxWinner {}));
80        analyzer.register_statistic(Arc::new(AvgWinner {}));
81        analyzer.register_statistic(Arc::new(MinWinner {}));
82        analyzer.register_statistic(Arc::new(MinLoser {}));
83        analyzer.register_statistic(Arc::new(AvgLoser {}));
84        analyzer.register_statistic(Arc::new(MaxLoser {}));
85        analyzer.register_statistic(Arc::new(Expectancy {}));
86        analyzer.register_statistic(Arc::new(WinRate {}));
87        analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
88        analyzer.register_statistic(Arc::new(ReturnsSkewness::new()));
89        analyzer.register_statistic(Arc::new(ReturnsKurtosis::new()));
90        analyzer.register_statistic(Arc::new(ReturnsAverage {}));
91        analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
92        analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
93        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
94        analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
95        analyzer.register_statistic(Arc::new(TailRatio {}));
96        analyzer.register_statistic(Arc::new(ProfitFactor {}));
97        analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
98        analyzer.register_statistic(Arc::new(LongRatio::new(None)));
99        analyzer
100    }
101}
102
103impl PortfolioAnalyzer {
104    /// Creates a new [`PortfolioAnalyzer`] instance.
105    ///
106    /// Starts with empty state.
107    #[must_use]
108    pub fn new() -> Self {
109        Self {
110            statistics: AHashMap::new(),
111            account_balances_starting: IndexMap::new(),
112            account_balances: IndexMap::new(),
113            positions: Vec::new(),
114            realized_pnls: AHashMap::new(),
115            recorded_realized_pnls: AHashMap::new(),
116            position_returns: BTreeMap::new(),
117            portfolio_returns: BTreeMap::new(),
118            returns: BTreeMap::new(),
119        }
120    }
121
122    /// Registers a new portfolio statistic for calculation.
123    pub fn register_statistic(&mut self, statistic: Statistic) {
124        self.statistics.insert(statistic.name(), statistic);
125    }
126
127    /// Removes a specific statistic from calculation.
128    pub fn deregister_statistic(&mut self, statistic: &Statistic) {
129        self.statistics.remove(&statistic.name());
130    }
131
132    /// Removes all registered statistics.
133    pub fn deregister_statistics(&mut self) {
134        self.statistics.clear();
135    }
136
137    /// Replaces the registered statistics with `statistics`.
138    ///
139    /// Used to carry a caller's registered set onto an analyzer built for a single
140    /// calculation, so custom statistics participate alongside the built-in defaults.
141    pub fn replace_statistics(&mut self, statistics: AHashMap<String, Statistic>) {
142        self.statistics = statistics;
143    }
144
145    /// Resets all analysis data to initial state.
146    ///
147    /// Registered statistics are retained; use [`Self::deregister_statistics`] to clear them.
148    pub fn reset(&mut self) {
149        self.account_balances_starting.clear();
150        self.account_balances.clear();
151        self.positions.clear();
152        self.realized_pnls.clear();
153        self.recorded_realized_pnls.clear();
154        self.position_returns.clear();
155        self.portfolio_returns.clear();
156        self.returns.clear();
157    }
158
159    /// Returns all tracked currencies.
160    #[must_use]
161    pub fn currencies(&self) -> Vec<&Currency> {
162        self.account_balances.keys().collect()
163    }
164
165    /// Retrieves a specific statistic by name.
166    #[must_use]
167    pub fn statistic(&self, name: &str) -> Option<&Statistic> {
168        self.statistics.get(name)
169    }
170
171    /// Returns the primary calculated returns.
172    ///
173    /// This returns portfolio returns when available, otherwise it falls back
174    /// to position returns for backward compatibility.
175    #[must_use]
176    pub const fn returns(&self) -> &Returns {
177        &self.returns
178    }
179
180    /// Returns the per-position calculated returns.
181    #[must_use]
182    pub const fn position_returns(&self) -> &Returns {
183        &self.position_returns
184    }
185
186    /// Returns the portfolio calculated returns.
187    #[must_use]
188    pub const fn portfolio_returns(&self) -> &Returns {
189        &self.portfolio_returns
190    }
191
192    /// Calculates statistics based on account and position data.
193    ///
194    /// This clears calculated state before calculating, while preserving
195    /// close-time PnLs recorded during portfolio processing.
196    pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
197        self.account_balances_starting = account.starting_balances().into_iter().collect();
198        self.account_balances = account.balances_total().into_iter().collect();
199        self.positions.clear();
200        self.realized_pnls.clear();
201        self.position_returns.clear();
202        self.portfolio_returns.clear();
203        self.returns.clear();
204
205        self.add_positions(positions);
206
207        if let Some(account_returns) = Self::calculate_account_returns(account) {
208            self.portfolio_returns = account_returns;
209            self.sync_returns_alias();
210        }
211    }
212
213    /// Builds a populated analyzer from venue accounts and positions.
214    ///
215    /// Aggregates starting and total balances across all `accounts`, adds `positions` and
216    /// `snapshots`, and seeds `recorded_realized_pnls` (close-time PnLs observed during the run).
217    #[must_use]
218    pub fn from_accounts(
219        accounts: &[AccountAny],
220        positions: &[Position],
221        snapshots: &[Position],
222        recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
223    ) -> Self {
224        Self::from_accounts_with_snapshots(
225            accounts,
226            positions,
227            snapshots,
228            &[],
229            recorded_realized_pnls,
230        )
231    }
232
233    /// Builds a populated analyzer from accounts, positions, and portfolio snapshots.
234    ///
235    /// Portfolio returns use daily mark-to-market equity when at least two UTC dates are
236    /// available and every account resolves to one common currency. Otherwise the primary
237    /// returns source falls back to position returns.
238    #[must_use]
239    pub fn from_accounts_with_snapshots<'a>(
240        accounts: &[AccountAny],
241        positions: &[Position],
242        position_snapshots: &[Position],
243        portfolio_snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
244        recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
245    ) -> Self {
246        let mut analyzer = Self::default();
247        let mut account_ids = Vec::with_capacity(accounts.len());
248
249        for account in accounts {
250            let account_ref: &dyn Account = match account {
251                AccountAny::Margin(margin) => margin,
252                AccountAny::Cash(cash) => cash,
253                AccountAny::Betting(betting) => betting,
254                AccountAny::Wallet(wallet) => wallet,
255            };
256            account_ids.push(account_ref.id());
257
258            for (currency, money) in account_ref.starting_balances() {
259                analyzer
260                    .account_balances_starting
261                    .entry(currency)
262                    .and_modify(|existing| *existing = *existing + money)
263                    .or_insert(money);
264            }
265
266            for (currency, money) in account_ref.balances_total() {
267                analyzer
268                    .account_balances
269                    .entry(currency)
270                    .and_modify(|existing| *existing = *existing + money)
271                    .or_insert(money);
272            }
273        }
274
275        analyzer.add_positions(positions);
276        analyzer.add_positions(position_snapshots);
277        analyzer.recorded_realized_pnls = recorded_realized_pnls;
278        analyzer.set_portfolio_returns_from_snapshots(&account_ids, portfolio_snapshots);
279        analyzer
280    }
281
282    /// Replaces the primary returns source with snapshot-backed portfolio returns when resolvable.
283    pub fn set_portfolio_returns_from_snapshots<'a>(
284        &mut self,
285        account_ids: &[AccountId],
286        snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
287    ) {
288        if let Some(returns) = Self::calculate_snapshot_returns(account_ids, snapshots) {
289            self.portfolio_returns = returns;
290            self.sync_returns_alias();
291        }
292    }
293
294    /// Collects an owned [`PortfolioStatistics`] snapshot from the current analyzer state.
295    #[must_use]
296    pub fn statistics(&self) -> PortfolioStatistics {
297        let mut pnls = AHashMap::new();
298
299        for currency in self.currencies() {
300            if let Ok(stats) = self.get_performance_stats_pnls(Some(currency), None) {
301                pnls.insert(currency.code.to_string(), stats);
302            }
303        }
304        PortfolioStatistics {
305            pnls,
306            returns: self.get_performance_stats_returns(),
307            general: self.get_performance_stats_general(),
308            returns_series: self.returns.clone(),
309        }
310    }
311
312    /// Adds new positions for analysis.
313    pub fn add_positions(&mut self, positions: &[Position]) {
314        self.positions.extend_from_slice(positions);
315        for position in positions {
316            if let Some(ref pnl) = position.realized_pnl {
317                self.add_trade(&position.id, position.ts_last, pnl);
318            }
319
320            if let Some(ts_closed) = position.ts_closed
321                && ts_closed.as_u64() > 0
322                && position.realized_pnl.is_some()
323            {
324                self.add_position_return(ts_closed, position.realized_return);
325            }
326        }
327    }
328
329    /// Records a trade's PnL realized at `ts_event`.
330    pub fn add_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
331        let currency = pnl.currency;
332        let entry = self.realized_pnls.entry(currency).or_default();
333        entry.push((*position_id, ts_event, pnl.as_f64()));
334    }
335
336    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
337    pub fn record_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
338        let currency = pnl.currency;
339        let entry = self.recorded_realized_pnls.entry(currency).or_default();
340        entry.push((*position_id, ts_event, pnl.as_f64()));
341    }
342
343    /// Records a position return at a specific timestamp.
344    pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
345        self.position_returns
346            .entry(timestamp)
347            .and_modify(|existing_value| *existing_value += value)
348            .or_insert(value);
349
350        // Mirror writes into the `returns` alias when no portfolio returns exist.
351        // This avoids calling `sync_returns_alias` (which clones the full map)
352        // on every insert.
353        if self.portfolio_returns.is_empty() {
354            self.returns
355                .entry(timestamp)
356                .and_modify(|existing_value| *existing_value += value)
357                .or_insert(value);
358        }
359    }
360
361    /// Records a return at a specific timestamp.
362    ///
363    /// This is a backward-compatible alias for [`Self::add_position_return`].
364    pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
365        self.add_position_return(timestamp, value);
366    }
367
368    /// Computes daily portfolio returns from account balance snapshots.
369    ///
370    /// Returns `None` (falling back to per-position returns) when:
371    /// - Fewer than two account state events exist.
372    /// - Any event carries multiple balance currencies.
373    /// - The balance currency changes between events.
374    /// - Fewer than two distinct calendar days have balance data.
375    ///
376    /// Multi-currency accounts are not yet supported; the caller silently
377    /// receives per-position returns in that case.
378    fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
379        let mut events = account.events();
380        if events.len() < 2 {
381            return None;
382        }
383
384        events.sort_by_key(|event| event.ts_event);
385
386        let mut currency = None;
387        let mut daily_balances = BTreeMap::new();
388
389        for event in events {
390            if event.balances.is_empty() {
391                continue;
392            }
393
394            if event.balances.len() != 1 {
395                return None;
396            }
397
398            let balance = event.balances[0];
399
400            if let Some(existing_currency) = currency {
401                if existing_currency != balance.currency {
402                    return None;
403                }
404            } else {
405                currency = Some(balance.currency);
406            }
407
408            let day_start = event.ts_event.floor(DurationNanos::from_days(1));
409            daily_balances.insert(day_start, balance.total.as_f64());
410        }
411
412        Self::calculate_daily_returns(&daily_balances)
413    }
414
415    fn calculate_snapshot_returns<'a>(
416        account_ids: &[AccountId],
417        snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
418    ) -> Option<Returns> {
419        let expected_accounts: IndexSet<AccountId> = account_ids.iter().copied().collect();
420        if expected_accounts.is_empty() {
421            return None;
422        }
423
424        let mut currency = None;
425        let mut equity_by_account: AHashMap<AccountId, BTreeMap<UnixNanos, f64>> = AHashMap::new();
426
427        for snapshot in snapshots {
428            if !expected_accounts.contains(&snapshot.account_id) {
429                continue;
430            }
431
432            if !snapshot.unpriced_instruments.is_empty() {
433                continue;
434            }
435
436            if snapshot.total_equity.len() != 1 {
437                return None;
438            }
439
440            let equity = snapshot
441                .base_currency_equity
442                .unwrap_or(snapshot.total_equity[0]);
443
444            if let Some(existing_currency) = currency {
445                if existing_currency != equity.currency {
446                    return None;
447                }
448            } else {
449                currency = Some(equity.currency);
450            }
451
452            let is_registration = !equity_by_account.contains_key(&snapshot.account_id);
453            let day_start = Self::snapshot_day_start(snapshot.ts_event, is_registration);
454            equity_by_account
455                .entry(snapshot.account_id)
456                .or_default()
457                .insert(day_start, equity.as_f64());
458        }
459
460        if equity_by_account.len() != expected_accounts.len() {
461            return None;
462        }
463
464        let first_day = equity_by_account
465            .values()
466            .filter_map(|equity| equity.keys().next().copied())
467            .min()?;
468        let last_day = equity_by_account
469            .values()
470            .filter_map(|equity| equity.keys().next_back().copied())
471            .max()?;
472        let mut daily_equity = BTreeMap::new();
473        let mut current_equity = AHashMap::new();
474        let mut current_day = first_day;
475
476        loop {
477            for account_id in &expected_accounts {
478                if let Some(equity) = equity_by_account
479                    .get(account_id)
480                    .and_then(|values| values.get(&current_day))
481                {
482                    current_equity.insert(*account_id, *equity);
483                }
484            }
485
486            if current_equity.len() == expected_accounts.len() {
487                let total = expected_accounts
488                    .iter()
489                    .map(|account_id| current_equity[account_id])
490                    .sum();
491                daily_equity.insert(current_day, total);
492            }
493
494            if current_day >= last_day {
495                break;
496            }
497
498            current_day += DurationNanos::from_days(1);
499        }
500
501        Self::calculate_daily_returns(&daily_equity)
502    }
503
504    fn snapshot_day_start(ts_event: UnixNanos, is_registration: bool) -> UnixNanos {
505        let day = DurationNanos::from_days(1);
506        let day_start = ts_event.floor(day);
507        if is_registration || (ts_event == day_start && !ts_event.is_zero()) {
508            day_start.saturating_sub(day)
509        } else {
510            day_start
511        }
512    }
513
514    fn calculate_daily_returns(daily_equity: &BTreeMap<UnixNanos, f64>) -> Option<Returns> {
515        if daily_equity.len() < 2 {
516            return None;
517        }
518
519        let mut returns = Returns::new();
520        let mut current_day = *daily_equity.keys().next()?;
521        let last_day = *daily_equity.keys().next_back()?;
522        let mut current_balance: Option<f64> = None;
523        let mut previous_balance: Option<f64> = None;
524
525        loop {
526            if let Some(balance) = daily_equity.get(&current_day) {
527                current_balance = Some(*balance);
528            }
529
530            let balance = current_balance?;
531
532            if let Some(previous) = previous_balance
533                && previous != 0.0
534            {
535                let value: f64 = (balance / previous) - 1.0;
536                if value.is_finite() {
537                    returns.insert(current_day, value);
538                }
539            }
540
541            previous_balance = Some(balance);
542
543            if current_day >= last_day {
544                break;
545            }
546
547            current_day += DurationNanos::from_days(1);
548        }
549
550        (!returns.is_empty()).then_some(returns)
551    }
552
553    /// Retrieves trade PnL records for a specific currency.
554    ///
555    /// Each record is `(position_id, ts_event, realized_pnl)`, where `ts_event` is the
556    /// position's last event time (the close time for closed cycles). Duplicate position
557    /// IDs are preserved for NETTING position cycles. Records are returned in ascending
558    /// `ts_event` order, with ties keeping their source order.
559    ///
560    /// Native PnLs (derived from analyzed positions) and PnLs recorded live during
561    /// portfolio processing are merged per cycle: a native record is excluded only when a
562    /// recorded record shares its `(position_id, ts_event)`. Recorded values therefore take
563    /// precedence for the cycles they cover, while native cycles that were never recorded
564    /// are retained rather than dropped by position ID.
565    ///
566    /// Returns `None` if no PnLs exist, or if multiple currencies exist
567    /// without an explicit currency specified.
568    #[must_use]
569    pub fn trade_pnl_records(
570        &self,
571        currency: Option<&Currency>,
572    ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
573        if self.realized_pnls.is_empty() && self.recorded_realized_pnls.is_empty() {
574            return None;
575        }
576
577        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
578        let currency = self.resolve_pnl_currency(currency).ok()?;
579
580        let realized_pnls = self.realized_pnls.get(&currency);
581        let recorded_realized_pnls = self.recorded_realized_pnls.get(&currency);
582
583        let mut output = match (realized_pnls, recorded_realized_pnls) {
584            (None, None) => return None,
585            (Some(realized_pnls), None) => realized_pnls.clone(),
586            (None, Some(recorded_realized_pnls)) => recorded_realized_pnls.clone(),
587            (Some(realized_pnls), Some(recorded_realized_pnls)) => {
588                let recorded_keys: IndexSet<(PositionId, UnixNanos)> = recorded_realized_pnls
589                    .iter()
590                    .map(|(position_id, ts_event, _)| {
591                        (canonical_position_id(*position_id), *ts_event)
592                    })
593                    .collect();
594                let mut merged: Vec<(PositionId, UnixNanos, f64)> = realized_pnls
595                    .iter()
596                    .copied()
597                    .filter(|(position_id, ts_event, _)| {
598                        let key = (canonical_position_id(*position_id), *ts_event);
599                        !recorded_keys.contains(&key)
600                    })
601                    .collect();
602                merged.extend(recorded_realized_pnls.iter().copied());
603
604                merged
605            }
606        };
607
608        // Stable sort, so records sharing a timestamp keep their source order and the
609        // sequence stays deterministic across runs.
610        output.sort_by_key(|(_, ts_event, _)| *ts_event);
611
612        Some(output)
613    }
614
615    /// Retrieves realized PnLs for a specific currency.
616    ///
617    /// Each record is `(position_id, ts_event, realized_pnl)`, in ascending `ts_event` order.
618    /// Returns `None` if no PnLs exist, or if multiple currencies exist without an explicit
619    /// currency specified.
620    #[must_use]
621    pub fn realized_pnls(
622        &self,
623        currency: Option<&Currency>,
624    ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
625        self.trade_pnl_records(currency)
626    }
627
628    /// Calculates total PnL including unrealized PnL if provided.
629    ///
630    /// # Errors
631    ///
632    /// Returns an error if:
633    /// - No currency is specified in a multi-currency portfolio.
634    /// - The specified currency is not found in account balances.
635    /// - The unrealized PnL currency does not match the specified currency.
636    #[expect(clippy::missing_panics_doc)] // Guarded by length check
637    pub fn total_pnl(
638        &self,
639        currency: Option<&Currency>,
640        unrealized_pnl: Option<&Money>,
641    ) -> Result<f64, &'static str> {
642        if self.account_balances.is_empty() {
643            return Ok(0.0);
644        }
645
646        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
647        let currency = match currency {
648            Some(c) => c,
649            None if self.account_balances.len() == 1 => {
650                self.account_balances.keys().next().expect("len is 1")
651            }
652            None => return Err("Currency must be specified for multi-currency portfolio"),
653        };
654
655        if let Some(unrealized_pnl) = unrealized_pnl
656            && unrealized_pnl.currency != *currency
657        {
658            return Err("Unrealized PnL currency does not match specified currency");
659        }
660
661        let account_balance = self
662            .account_balances
663            .get(currency)
664            .ok_or("Specified currency not found in account balances")?;
665
666        let default_money = &Money::zero(*currency);
667        let account_balance_starting = self
668            .account_balances_starting
669            .get(currency)
670            .unwrap_or(default_money);
671
672        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
673        Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
674    }
675
676    /// Calculates total PnL as a percentage of starting balance.
677    ///
678    /// # Errors
679    ///
680    /// Returns an error if:
681    /// - No currency is specified in a multi-currency portfolio.
682    /// - The specified currency is not found in account balances.
683    /// - The unrealized PnL currency does not match the specified currency.
684    #[expect(clippy::missing_panics_doc)] // Guarded by length check
685    pub fn total_pnl_percentage(
686        &self,
687        currency: Option<&Currency>,
688        unrealized_pnl: Option<&Money>,
689    ) -> Result<f64, &'static str> {
690        if self.account_balances.is_empty() {
691            return Ok(0.0);
692        }
693
694        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
695        let currency = match currency {
696            Some(c) => c,
697            None if self.account_balances.len() == 1 => {
698                self.account_balances.keys().next().expect("len is 1")
699            }
700            None => return Err("Currency must be specified for multi-currency portfolio"),
701        };
702
703        if let Some(unrealized_pnl) = unrealized_pnl
704            && unrealized_pnl.currency != *currency
705        {
706            return Err("Unrealized PnL currency does not match specified currency");
707        }
708
709        let account_balance = self
710            .account_balances
711            .get(currency)
712            .ok_or("Specified currency not found in account balances")?;
713
714        let default_money = &Money::zero(*currency);
715        let account_balance_starting = self
716            .account_balances_starting
717            .get(currency)
718            .unwrap_or(default_money);
719
720        if account_balance_starting.as_decimal() == Decimal::ZERO {
721            return Ok(0.0);
722        }
723
724        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
725        let current = account_balance.as_f64() + unrealized_pnl_f64;
726        let starting = account_balance_starting.as_f64();
727        let difference = current - starting;
728
729        Ok((difference / starting) * 100.0)
730    }
731
732    /// Gets all PnL-related performance statistics.
733    ///
734    /// # Errors
735    ///
736    /// Returns an error if:
737    ///
738    /// - No currency specified for a multi-currency portfolio.
739    /// - Unrealized PnL currency not matching the specified currency.
740    /// - Specified currency not found in account balances.
741    pub fn get_performance_stats_pnls(
742        &self,
743        currency: Option<&Currency>,
744        unrealized_pnl: Option<&Money>,
745    ) -> Result<AHashMap<String, f64>, &'static str> {
746        let mut output = AHashMap::new();
747
748        output.insert(
749            "PnL (total)".to_string(),
750            self.total_pnl(currency, unrealized_pnl)?,
751        );
752        output.insert(
753            "PnL% (total)".to_string(),
754            self.total_pnl_percentage(currency, unrealized_pnl)?,
755        );
756
757        let records = self.trade_pnl_records(currency);
758        let has_records = !self.realized_pnls.is_empty() || !self.recorded_realized_pnls.is_empty();
759
760        // `trade_pnl_records` returns `None` both when the resolved currency has no records
761        // and when an unspecified currency cannot be resolved. Only the first may dispatch on
762        // an empty slice; the second would report values that ignore real PnLs.
763        if records.is_none() && has_records {
764            self.resolve_pnl_currency(currency)?;
765        }
766
767        let realized_pnls: Vec<f64> = records
768            .unwrap_or_default()
769            .iter()
770            .map(|(_, _, pnl)| *pnl)
771            .collect();
772
773        for (name, stat) in &self.statistics {
774            if let Some(value) = stat.calculate_from_realized_pnls(&realized_pnls) {
775                output.insert(name.clone(), value);
776            }
777        }
778
779        Ok(output)
780    }
781
782    /// Gets all return-based performance statistics.
783    #[must_use]
784    pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
785        self.calculate_returns_stats(self.returns())
786    }
787
788    /// Gets all position-return-based performance statistics.
789    #[must_use]
790    pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
791        self.calculate_returns_stats(self.position_returns())
792    }
793
794    /// Gets all portfolio-return-based performance statistics.
795    #[must_use]
796    pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
797        self.calculate_returns_stats(self.portfolio_returns())
798    }
799
800    /// Gets all benchmark-relative return statistics for the primary returns.
801    ///
802    /// This is stateless: the `benchmark` series is supplied by the caller rather
803    /// than stored on the analyzer. Only statistics that override
804    /// [`PortfolioStatistic::calculate_from_returns_with_benchmark`] (the benchmark-relative
805    /// statistics) contribute values; all others return `None` and are skipped.
806    #[must_use]
807    pub fn get_performance_stats_returns_vs_benchmark(
808        &self,
809        benchmark: &Returns,
810    ) -> AHashMap<String, f64> {
811        let mut output = AHashMap::new();
812
813        for (name, stat) in &self.statistics {
814            if let Some(value) =
815                stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
816            {
817                output.insert(name.clone(), value);
818            }
819        }
820
821        output
822    }
823
824    /// Gets general portfolio statistics.
825    #[must_use]
826    pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
827        let mut output = AHashMap::new();
828
829        for (name, stat) in &self.statistics {
830            if let Some(value) = stat.calculate_from_positions(&self.positions) {
831                output.insert(name.clone(), value);
832            }
833        }
834
835        output
836    }
837
838    /// Calculates the maximum length of statistic names for formatting.
839    fn get_max_length_name(&self) -> usize {
840        self.statistics.keys().map(String::len).max().unwrap_or(0)
841    }
842
843    fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
844        let mut output = AHashMap::new();
845
846        for (name, stat) in &self.statistics {
847            if let Some(value) = stat.calculate_from_returns(returns) {
848                output.insert(name.clone(), value);
849            }
850        }
851
852        output
853    }
854
855    fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
856        let max_length = self.get_max_length_name();
857        let mut entries: Vec<_> = stats.into_iter().collect();
858        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
859
860        let mut output = Vec::new();
861
862        for (k, v) in entries {
863            let padding = max_length.saturating_sub(k.len()) + 1;
864            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
865        }
866
867        output
868    }
869
870    fn sync_returns_alias(&mut self) {
871        if self.portfolio_returns.is_empty() {
872            self.returns = self.position_returns.clone();
873            return;
874        }
875
876        self.returns = self.portfolio_returns.clone();
877    }
878
879    /// Resolves the currency for PnL record queries: the explicit currency when given,
880    /// otherwise the single account-balance currency, otherwise the single currency across
881    /// realized PnL records.
882    ///
883    /// # Errors
884    ///
885    /// Returns an error if the currency is unspecified and cannot be resolved to exactly
886    /// one currency.
887    fn resolve_pnl_currency(&self, currency: Option<&Currency>) -> Result<Currency, &'static str> {
888        match currency {
889            Some(c) => Ok(*c),
890            None if self.account_balances.len() == 1 => {
891                Ok(*self.account_balances.keys().next().expect("len is 1"))
892            }
893            None => {
894                let mut currencies: IndexSet<Currency> =
895                    self.realized_pnls.keys().copied().collect();
896                currencies.extend(self.recorded_realized_pnls.keys().copied());
897                if currencies.len() != 1 {
898                    return Err("Currency must be specified for multi-currency portfolio");
899                }
900
901                Ok(*currencies.first().expect("len is 1"))
902            }
903        }
904    }
905
906    /// Gets formatted PnL statistics as strings.
907    ///
908    /// # Errors
909    ///
910    /// Returns an error if PnL statistics calculation fails.
911    pub fn get_stats_pnls_formatted(
912        &self,
913        currency: Option<&Currency>,
914        unrealized_pnl: Option<&Money>,
915    ) -> Result<Vec<String>, String> {
916        let max_length = self.get_max_length_name();
917        let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
918
919        let mut entries: Vec<_> = stats.into_iter().collect();
920        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
921
922        let mut output = Vec::new();
923
924        for (k, v) in entries {
925            let padding = if max_length > k.len() {
926                max_length - k.len() + 1
927            } else {
928                1
929            };
930            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
931        }
932
933        Ok(output)
934    }
935
936    /// Gets formatted return statistics as strings.
937    #[must_use]
938    pub fn get_stats_returns_formatted(&self) -> Vec<String> {
939        self.format_returns_stats(self.get_performance_stats_returns())
940    }
941
942    /// Gets formatted position-return statistics as strings.
943    #[must_use]
944    pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
945        self.format_returns_stats(self.get_performance_stats_position_returns())
946    }
947
948    /// Gets formatted portfolio-return statistics as strings.
949    #[must_use]
950    pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
951        self.format_returns_stats(self.get_performance_stats_portfolio_returns())
952    }
953
954    /// Gets formatted general statistics as strings.
955    #[must_use]
956    pub fn get_stats_general_formatted(&self) -> Vec<String> {
957        let max_length = self.get_max_length_name();
958        let stats = self.get_performance_stats_general();
959
960        let mut entries: Vec<_> = stats.into_iter().collect();
961        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
962
963        let mut output = Vec::new();
964
965        for (k, v) in entries {
966            let padding = max_length - k.len() + 1;
967            output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
968        }
969
970        output
971    }
972}
973
974fn canonical_position_id(position_id: PositionId) -> PositionId {
975    const UUID4_STRING_LEN: usize = 36;
976
977    let value = position_id.as_str();
978    let Some(separator_index) = value.len().checked_sub(UUID4_STRING_LEN + 1) else {
979        return position_id;
980    };
981
982    if separator_index == 0 || value.as_bytes()[separator_index] != b'-' {
983        return position_id;
984    }
985
986    let suffix = &value[separator_index + 1..];
987    if suffix.parse::<UUID4>().is_ok() {
988        PositionId::new(&value[..separator_index])
989    } else {
990        position_id
991    }
992}
993
994#[cfg(test)]
995mod tests {
996    use std::sync::Arc;
997
998    use ahash::{AHashMap, AHashSet};
999    use indexmap::IndexMap;
1000    use nautilus_core::{DurationNanos, UUID4, approx_eq, datetime::NANOSECONDS_IN_DAY};
1001    use nautilus_model::{
1002        accounts::{AccountAny, CashAccount},
1003        enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
1004        events::{AccountState, OrderFilled, PortfolioSnapshot},
1005        fees::MakerTakerFeeRates,
1006        identifiers::{
1007            AccountId, ClientOrderId,
1008            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
1009        },
1010        instruments::InstrumentAny,
1011        stubs::TestDefault,
1012        types::{AccountBalance, Money, Price, Quantity},
1013    };
1014    use rstest::rstest;
1015
1016    use super::*;
1017    use crate::statistics::beta_ratio::BetaRatio;
1018
1019    /// Mock implementation of `PortfolioStatistic` for testing.
1020    #[derive(Debug)]
1021    struct MockStatistic {
1022        name: String,
1023    }
1024
1025    impl MockStatistic {
1026        fn new(name: &str) -> Self {
1027            Self {
1028                name: name.to_string(),
1029            }
1030        }
1031    }
1032
1033    impl PortfolioStatistic for MockStatistic {
1034        type Item = f64;
1035
1036        fn name(&self) -> String {
1037            self.name.clone()
1038        }
1039
1040        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
1041            Some(pnls.iter().sum())
1042        }
1043
1044        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
1045            Some(returns.values().sum())
1046        }
1047
1048        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
1049            Some(positions.len() as f64)
1050        }
1051    }
1052
1053    /// Mock implementation returning a fixed value for every input category.
1054    ///
1055    /// Two instances can share a name while differing in value, which makes
1056    /// duplicate-name replacement observable.
1057    #[derive(Debug)]
1058    struct ConstantStatistic {
1059        name: String,
1060        value: f64,
1061    }
1062
1063    impl ConstantStatistic {
1064        fn new(name: &str, value: f64) -> Self {
1065            Self {
1066                name: name.to_string(),
1067                value,
1068            }
1069        }
1070    }
1071
1072    impl PortfolioStatistic for ConstantStatistic {
1073        type Item = f64;
1074
1075        fn name(&self) -> String {
1076            self.name.clone()
1077        }
1078
1079        fn calculate_from_realized_pnls(&self, _pnls: &[f64]) -> Option<f64> {
1080            Some(self.value)
1081        }
1082
1083        fn calculate_from_returns(&self, _returns: &Returns) -> Option<f64> {
1084            Some(self.value)
1085        }
1086
1087        fn calculate_from_positions(&self, _positions: &[Position]) -> Option<f64> {
1088            Some(self.value)
1089        }
1090    }
1091
1092    fn create_mock_position(
1093        id: &str,
1094        realized_pnl: f64,
1095        realized_return: f64,
1096        currency: Currency,
1097    ) -> Position {
1098        Position {
1099            events: Vec::new(),
1100            adjustments: Vec::new(),
1101            replay_events: Vec::new(),
1102            fill_voids: Vec::new(),
1103            trader_id: trader_id(),
1104            strategy_id: strategy_id_ema_cross(),
1105            instrument_id: instrument_id_aud_usd_sim(),
1106            id: PositionId::new(id),
1107            account_id: AccountId::new("test-account"),
1108            opening_order_id: ClientOrderId::test_default(),
1109            closing_order_id: None,
1110            entry: OrderSide::Buy,
1111            side: PositionSide::Flat,
1112            signed_qty: 0.into(),
1113            quantity: Quantity::default(),
1114            peak_qty: Quantity::default(),
1115            price_precision: 2,
1116            size_precision: 2,
1117            multiplier: Quantity::default(),
1118            is_inverse: false,
1119            is_currency_pair: true,
1120            instrument_class: InstrumentClass::Spot,
1121            base_currency: None,
1122            quote_currency: Currency::USD(),
1123            settlement_currency: Currency::USD(),
1124            ts_init: UnixNanos::default(),
1125            ts_opened: UnixNanos::default(),
1126            ts_last: UnixNanos::default(),
1127            ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
1128            duration_ns: DurationNanos::new(2),
1129            avg_px_open: 0.into(),
1130            avg_px_close: None,
1131            realized_return,
1132            realized_pnl: Some(Money::new(realized_pnl, currency)),
1133            trade_ids: AHashSet::new(),
1134            buy_qty: Quantity::default(),
1135            sell_qty: Quantity::default(),
1136            commissions: IndexMap::new(),
1137            cost_open: 0.into(),
1138            cost_close: 0.into(),
1139            qty_closed: 0.into(),
1140            realized_pnl_remainder: 0.into(),
1141        }
1142    }
1143
1144    struct MockAccount {
1145        starting_balances: AHashMap<Currency, Money>,
1146        current_balances: AHashMap<Currency, Money>,
1147        events: Vec<AccountState>,
1148    }
1149
1150    impl Account for MockAccount {
1151        fn starting_balances(&self) -> IndexMap<Currency, Money> {
1152            self.starting_balances.clone().into_iter().collect()
1153        }
1154        fn balances_total(&self) -> IndexMap<Currency, Money> {
1155            self.current_balances.clone().into_iter().collect()
1156        }
1157        fn id(&self) -> AccountId {
1158            todo!()
1159        }
1160        fn account_type(&self) -> AccountType {
1161            todo!()
1162        }
1163        fn base_currency(&self) -> Option<Currency> {
1164            todo!()
1165        }
1166        fn is_cash_account(&self) -> bool {
1167            todo!()
1168        }
1169        fn is_margin_account(&self) -> bool {
1170            todo!()
1171        }
1172        fn calculated_account_state(&self) -> bool {
1173            todo!()
1174        }
1175        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
1176            todo!()
1177        }
1178        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
1179            todo!()
1180        }
1181        fn balances_free(&self) -> IndexMap<Currency, Money> {
1182            todo!()
1183        }
1184        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
1185            todo!()
1186        }
1187        fn balances_locked(&self) -> IndexMap<Currency, Money> {
1188            todo!()
1189        }
1190        fn last_event(&self) -> Option<AccountState> {
1191            self.events.last().cloned()
1192        }
1193        fn events(&self) -> Vec<AccountState> {
1194            self.events.clone()
1195        }
1196        fn event_count(&self) -> usize {
1197            self.events.len()
1198        }
1199        fn currencies(&self) -> Vec<Currency> {
1200            self.current_balances.keys().copied().collect()
1201        }
1202        fn balances(&self) -> IndexMap<Currency, AccountBalance> {
1203            todo!()
1204        }
1205        fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
1206            todo!()
1207        }
1208        fn calculate_balance_locked(
1209            &self,
1210            _: &InstrumentAny,
1211            _: OrderSide,
1212            _: Quantity,
1213            _: Price,
1214            _: Option<bool>,
1215        ) -> Result<Money, anyhow::Error> {
1216            todo!()
1217        }
1218        fn calculate_pnls(
1219            &self,
1220            _: &InstrumentAny,
1221            _: &OrderFilled,
1222            _: Option<Position>,
1223        ) -> Result<Vec<Money>, anyhow::Error> {
1224            todo!()
1225        }
1226        fn calculate_commission(
1227            &self,
1228            _: &InstrumentAny,
1229            _: Quantity,
1230            _: Price,
1231            _: LiquiditySide,
1232            _: MakerTakerFeeRates,
1233            _: Option<bool>,
1234        ) -> Result<Money, anyhow::Error> {
1235            todo!()
1236        }
1237
1238        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1239            todo!()
1240        }
1241
1242        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1243            // MockAccount doesn't need purging
1244        }
1245    }
1246
1247    fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1248        AccountState::new(
1249            AccountId::new("test-account"),
1250            AccountType::Cash,
1251            vec![AccountBalance::new(
1252                Money::new(total, currency),
1253                Money::new(0.0, currency),
1254                Money::new(total, currency),
1255            )],
1256            vec![],
1257            true,
1258            UUID4::new(),
1259            UnixNanos::from(ts_event),
1260            UnixNanos::from(ts_event),
1261            Some(currency),
1262        )
1263    }
1264
1265    fn create_portfolio_snapshot(
1266        account_id: AccountId,
1267        equity: Decimal,
1268        currency: Currency,
1269        ts_event: u64,
1270    ) -> PortfolioSnapshot {
1271        let equity = Money::from_decimal(equity, currency).unwrap();
1272
1273        PortfolioSnapshot::new(
1274            account_id,
1275            AccountType::Cash,
1276            Some(currency),
1277            vec![],
1278            vec![],
1279            vec![],
1280            vec![],
1281            vec![equity],
1282            Some(equity),
1283            false,
1284            vec![],
1285            vec![],
1286            vec![],
1287            UUID4::new(),
1288            UnixNanos::from(ts_event),
1289            UnixNanos::from(ts_event),
1290        )
1291    }
1292
1293    #[rstest]
1294    fn test_calculate_snapshot_returns_tracks_daily_mark_to_market_equity() {
1295        let account_id = AccountId::new("SIM-001");
1296        let currency = Currency::USD();
1297        let snapshots = [
1298            create_portfolio_snapshot(
1299                account_id,
1300                Decimal::from(10_000),
1301                currency,
1302                NANOSECONDS_IN_DAY + NANOSECONDS_IN_DAY / 2,
1303            ),
1304            create_portfolio_snapshot(
1305                account_id,
1306                Decimal::from(10_500),
1307                currency,
1308                NANOSECONDS_IN_DAY + 3 * NANOSECONDS_IN_DAY / 4,
1309            ),
1310            create_portfolio_snapshot(
1311                account_id,
1312                Decimal::from(11_000),
1313                currency,
1314                2 * NANOSECONDS_IN_DAY,
1315            ),
1316            create_portfolio_snapshot(
1317                account_id,
1318                Decimal::from(12_100),
1319                currency,
1320                3 * NANOSECONDS_IN_DAY,
1321            ),
1322        ];
1323
1324        let returns =
1325            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1326        let values: Vec<f64> = returns.values().copied().collect();
1327        let dates: Vec<UnixNanos> = returns.keys().copied().collect();
1328
1329        assert_eq!(
1330            dates,
1331            vec![
1332                UnixNanos::from(NANOSECONDS_IN_DAY),
1333                UnixNanos::from(2 * NANOSECONDS_IN_DAY),
1334            ]
1335        );
1336        assert_eq!(values.len(), 2);
1337        assert!(approx_eq!(f64, values[0], 0.1, epsilon = 1e-12));
1338        assert!(approx_eq!(f64, values[1], 0.1, epsilon = 1e-12));
1339    }
1340
1341    #[rstest]
1342    fn test_calculate_snapshot_returns_aggregates_accounts_in_one_currency() {
1343        let account_a = AccountId::new("SIM-001");
1344        let account_b = AccountId::new("SIM-002");
1345        let currency = Currency::USD();
1346        let snapshots = [
1347            create_portfolio_snapshot(account_a, Decimal::from(100), currency, NANOSECONDS_IN_DAY),
1348            create_portfolio_snapshot(account_b, Decimal::from(50), currency, NANOSECONDS_IN_DAY),
1349            create_portfolio_snapshot(
1350                account_a,
1351                Decimal::from(110),
1352                currency,
1353                2 * NANOSECONDS_IN_DAY,
1354            ),
1355        ];
1356
1357        let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1358            &[account_a, account_b],
1359            snapshots.iter(),
1360        )
1361        .unwrap();
1362
1363        assert!(approx_eq!(
1364            f64,
1365            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1366            160.0 / 150.0 - 1.0,
1367            epsilon = 1e-12
1368        ));
1369    }
1370
1371    #[rstest]
1372    fn test_calculate_snapshot_returns_uses_single_total_without_base_currency() {
1373        let account_id = AccountId::new("SIM-001");
1374        let currency = Currency::USD();
1375        let mut first =
1376            create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1377        let mut second = create_portfolio_snapshot(
1378            account_id,
1379            Decimal::from(110),
1380            currency,
1381            2 * NANOSECONDS_IN_DAY,
1382        );
1383        first.base_currency_equity = None;
1384        second.base_currency_equity = None;
1385        let snapshots = [first, second];
1386
1387        let returns =
1388            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1389
1390        assert!(approx_eq!(
1391            f64,
1392            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1393            0.1,
1394            epsilon = 1e-12
1395        ));
1396    }
1397
1398    #[rstest]
1399    fn test_calculate_snapshot_returns_rejects_multi_currency_total_with_base_equity() {
1400        let account_id = AccountId::new("SIM-001");
1401        let mut first = create_portfolio_snapshot(
1402            account_id,
1403            Decimal::from(100),
1404            Currency::USD(),
1405            NANOSECONDS_IN_DAY,
1406        );
1407        let mut second = create_portfolio_snapshot(
1408            account_id,
1409            Decimal::from(110),
1410            Currency::USD(),
1411            2 * NANOSECONDS_IN_DAY,
1412        );
1413        first.total_equity.push(Money::new(50.0, Currency::AUD()));
1414        second.total_equity.push(Money::new(55.0, Currency::AUD()));
1415        let snapshots = [first, second];
1416
1417        let returns =
1418            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter());
1419
1420        assert!(returns.is_none());
1421    }
1422
1423    #[rstest]
1424    fn test_calculate_snapshot_returns_forward_fills_unpriced_dates() {
1425        let account_id = AccountId::new("SIM-001");
1426        let currency = Currency::USD();
1427        let first =
1428            create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1429        let mut unpriced =
1430            create_portfolio_snapshot(account_id, Decimal::ZERO, currency, 2 * NANOSECONDS_IN_DAY);
1431        unpriced.unpriced_instruments = vec![instrument_id_aud_usd_sim()];
1432        let last = create_portfolio_snapshot(
1433            account_id,
1434            Decimal::from(110),
1435            currency,
1436            3 * NANOSECONDS_IN_DAY,
1437        );
1438        let snapshots = [first, unpriced, last];
1439
1440        let returns =
1441            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1442
1443        assert!(approx_eq!(
1444            f64,
1445            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1446            0.0,
1447            epsilon = 1e-12
1448        ));
1449        assert!(approx_eq!(
1450            f64,
1451            returns[&UnixNanos::from(2 * NANOSECONDS_IN_DAY)],
1452            0.1,
1453            epsilon = 1e-12
1454        ));
1455    }
1456
1457    #[rstest]
1458    fn test_calculate_snapshot_returns_rejects_mixed_account_currencies() {
1459        let account_a = AccountId::new("SIM-001");
1460        let account_b = AccountId::new("SIM-002");
1461        let snapshots = [
1462            create_portfolio_snapshot(
1463                account_a,
1464                Decimal::from(100),
1465                Currency::USD(),
1466                NANOSECONDS_IN_DAY,
1467            ),
1468            create_portfolio_snapshot(
1469                account_b,
1470                Decimal::from(100),
1471                Currency::AUD(),
1472                NANOSECONDS_IN_DAY,
1473            ),
1474        ];
1475
1476        let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1477            &[account_a, account_b],
1478            snapshots.iter(),
1479        );
1480
1481        assert!(returns.is_none());
1482    }
1483
1484    #[rstest]
1485    fn test_register_and_deregister_statistics() {
1486        let mut analyzer = PortfolioAnalyzer::new();
1487        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1488            Arc::new(MockStatistic::new("test_stat"));
1489
1490        // Test registration
1491        analyzer.register_statistic(Arc::clone(&stat));
1492        assert!(analyzer.statistic("test_stat").is_some());
1493
1494        // Test deregistration
1495        analyzer.deregister_statistic(&stat);
1496        assert!(analyzer.statistic("test_stat").is_none());
1497
1498        // Test deregister all
1499        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1500            Arc::new(MockStatistic::new("stat1"));
1501        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1502            Arc::new(MockStatistic::new("stat2"));
1503        analyzer.register_statistic(Arc::clone(&stat1));
1504        analyzer.register_statistic(Arc::clone(&stat2));
1505        analyzer.deregister_statistics();
1506        assert!(analyzer.statistics.is_empty());
1507    }
1508
1509    #[rstest]
1510    fn test_register_statistic_replaces_matching_name() {
1511        let mut analyzer = PortfolioAnalyzer::new();
1512        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1513        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 2.0)));
1514
1515        let stats = analyzer.get_performance_stats_general();
1516
1517        assert_eq!(analyzer.statistics.len(), 1);
1518        assert_eq!(stats["Custom"], 2.0);
1519    }
1520
1521    #[rstest]
1522    fn test_pnl_statistics_reject_unresolved_currency() {
1523        // Two currencies and no account balances: the currency cannot be resolved, so
1524        // dispatching on an empty slice would report a result that ignores both trades.
1525        let mut analyzer = PortfolioAnalyzer::new();
1526        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1527        analyzer.add_trade(
1528            &PositionId::new("P-USD"),
1529            UnixNanos::from(1),
1530            &Money::new(10.0, Currency::USD()),
1531        );
1532        analyzer.add_trade(
1533            &PositionId::new("P-EUR"),
1534            UnixNanos::from(2),
1535            &Money::new(5.0, Currency::EUR()),
1536        );
1537
1538        let result = analyzer.get_performance_stats_pnls(None, None);
1539
1540        assert_eq!(
1541            result,
1542            Err("Currency must be specified for multi-currency portfolio")
1543        );
1544    }
1545
1546    #[rstest]
1547    fn test_pnl_statistics_resolve_single_pnl_currency() {
1548        // One realized-PnL currency and no explicit currency: resolution falls back to that
1549        // currency, so statistics dispatch on its records.
1550        let mut analyzer = PortfolioAnalyzer::new();
1551        analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1552        let currency = Currency::USD();
1553        let position_id = PositionId::new("P-USD");
1554        analyzer.add_trade(
1555            &position_id,
1556            UnixNanos::from(1),
1557            &Money::new(10.0, currency),
1558        );
1559
1560        let records = analyzer.trade_pnl_records(None).unwrap();
1561        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1562
1563        assert_eq!(records, vec![(position_id, UnixNanos::from(1), 10.0)]);
1564        assert_eq!(stats["test_stat"], 10.0);
1565    }
1566
1567    #[rstest]
1568    fn test_pnl_statistics_prefer_account_balance_currency() {
1569        // A single account-balance currency resolves the query even when the realized-PnL
1570        // records are in another currency: the balance currency wins, has no records, and
1571        // statistics dispatch on an empty slice instead of erroring.
1572        let mut analyzer = PortfolioAnalyzer::new();
1573        analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1574        let currency = Currency::USD();
1575        analyzer
1576            .account_balances
1577            .insert(currency, Money::new(1000.0, currency));
1578        analyzer.add_trade(
1579            &PositionId::new("P-EUR"),
1580            UnixNanos::from(1),
1581            &Money::new(5.0, Currency::EUR()),
1582        );
1583
1584        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1585
1586        assert!(analyzer.trade_pnl_records(None).is_none());
1587        assert_eq!(stats["test_stat"], 0.0);
1588    }
1589
1590    #[rstest]
1591    fn test_trade_pnl_records_sorted_by_event_time() {
1592        // A recorded PnL at t=1 and an unmatched position-derived PnL at t=2 are merged from
1593        // two sources; without sorting the derived record leads and the sequence reads [t2, t1].
1594        let currency = Currency::USD();
1595        let mut analyzer = PortfolioAnalyzer::new();
1596        analyzer.add_trade(
1597            &PositionId::new("P-LATE"),
1598            UnixNanos::from(2),
1599            &Money::new(5.0, currency),
1600        );
1601        analyzer.record_trade(
1602            &PositionId::new("P-EARLY"),
1603            UnixNanos::from(1),
1604            &Money::new(7.0, currency),
1605        );
1606
1607        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1608
1609        assert_eq!(
1610            records,
1611            vec![
1612                (PositionId::new("P-EARLY"), UnixNanos::from(1), 7.0),
1613                (PositionId::new("P-LATE"), UnixNanos::from(2), 5.0),
1614            ]
1615        );
1616    }
1617
1618    #[rstest]
1619    fn test_pnl_statistics_run_without_any_trades() {
1620        // No realized PnLs at all: registered statistics still receive an empty slice, so a
1621        // statistic defined for empty input reports its value instead of vanishing.
1622        let mut analyzer = PortfolioAnalyzer::new();
1623        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1624
1625        let stats = analyzer
1626            .get_performance_stats_pnls(Some(&Currency::USD()), None)
1627            .unwrap();
1628
1629        assert_eq!(stats["Custom"], 0.0);
1630        assert_eq!(stats["PnL (total)"], 0.0);
1631    }
1632
1633    #[rstest]
1634    fn test_pnl_statistics_run_without_any_trades_or_currency() {
1635        // No realized PnLs and no explicit currency: with nothing to resolve, statistics
1636        // still receive an empty slice instead of erroring.
1637        let mut analyzer = PortfolioAnalyzer::new();
1638        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1639
1640        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1641
1642        assert_eq!(stats["Custom"], 0.0);
1643        assert_eq!(stats["PnL (total)"], 0.0);
1644    }
1645
1646    #[rstest]
1647    fn test_reset_retains_registered_statistics() {
1648        let currency = Currency::USD();
1649        let mut analyzer = PortfolioAnalyzer::new();
1650        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 7.5)));
1651        analyzer.add_positions(&[create_mock_position("AUD/USD", 100.0, 0.1, currency)]);
1652
1653        analyzer.reset();
1654
1655        assert!(analyzer.positions.is_empty());
1656        assert_eq!(analyzer.statistics.len(), 1);
1657        assert_eq!(analyzer.get_performance_stats_general()["Custom"], 7.5);
1658    }
1659
1660    #[rstest]
1661    fn test_formatted_general_stats_include_custom_statistic() {
1662        // The post-run analysis log formats from this same method, so a registered
1663        // custom statistic must appear alongside the built-in defaults.
1664        let mut analyzer = PortfolioAnalyzer::new();
1665        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 12.5)));
1666
1667        let lines = analyzer.get_stats_general_formatted();
1668
1669        assert_eq!(lines, vec!["Custom:  12.5".to_string()]);
1670    }
1671
1672    #[rstest]
1673    fn test_replace_statistics_adopts_given_set() {
1674        let mut analyzer = PortfolioAnalyzer::default();
1675        let default_count = analyzer.statistics.len();
1676
1677        let mut replacement: AHashMap<String, Statistic> = AHashMap::new();
1678        replacement.insert(
1679            "Custom".to_string(),
1680            Arc::new(ConstantStatistic::new("Custom", 3.25)),
1681        );
1682        analyzer.replace_statistics(replacement);
1683
1684        let stats = analyzer.get_performance_stats_general();
1685
1686        assert!(default_count > 1);
1687        assert_eq!(analyzer.statistics.len(), 1);
1688        assert_eq!(stats.len(), 1);
1689        assert_eq!(stats["Custom"], 3.25);
1690    }
1691
1692    #[rstest]
1693    fn test_calculate_total_pnl() {
1694        let mut analyzer = PortfolioAnalyzer::new();
1695        let currency = Currency::USD();
1696
1697        // Set up mock account data
1698        let mut starting_balances = AHashMap::new();
1699        starting_balances.insert(currency, Money::new(1000.0, currency));
1700
1701        let mut current_balances = AHashMap::new();
1702        current_balances.insert(currency, Money::new(1500.0, currency));
1703
1704        let account = MockAccount {
1705            starting_balances,
1706            current_balances,
1707            events: vec![],
1708        };
1709
1710        analyzer.calculate_statistics(&account, &[]);
1711
1712        // Test total PnL calculation
1713        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
1714        assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1715
1716        // Test with unrealized PnL
1717        let unrealized_pnl = Money::new(100.0, currency);
1718        let result = analyzer
1719            .total_pnl(Some(&currency), Some(&unrealized_pnl))
1720            .unwrap();
1721        assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1722    }
1723
1724    #[rstest]
1725    fn test_calculate_total_pnl_percentage() {
1726        let mut analyzer = PortfolioAnalyzer::new();
1727        let currency = Currency::USD();
1728
1729        // Set up mock account data
1730        let mut starting_balances = AHashMap::new();
1731        starting_balances.insert(currency, Money::new(1000.0, currency));
1732
1733        let mut current_balances = AHashMap::new();
1734        current_balances.insert(currency, Money::new(1500.0, currency));
1735
1736        let account = MockAccount {
1737            starting_balances,
1738            current_balances,
1739            events: vec![],
1740        };
1741
1742        analyzer.calculate_statistics(&account, &[]);
1743
1744        // Test percentage calculation
1745        let result = analyzer
1746            .total_pnl_percentage(Some(&currency), None)
1747            .unwrap();
1748        assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); // (1500 - 1000) / 1000 * 100
1749
1750        // Test with unrealized PnL
1751        let unrealized_pnl = Money::new(500.0, currency);
1752        let result = analyzer
1753            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
1754            .unwrap();
1755        assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); // (2000 - 1000) / 1000 * 100
1756    }
1757
1758    #[rstest]
1759    fn test_add_positions_and_returns() {
1760        let mut analyzer = PortfolioAnalyzer::new();
1761        let currency = Currency::USD();
1762
1763        let positions = vec![
1764            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1765            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1766        ];
1767
1768        analyzer.add_positions(&positions);
1769
1770        // Verify realized PnLs were recorded
1771        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
1772        assert_eq!(pnls.len(), 2);
1773        assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1774        assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1775
1776        // Verify returns were recorded
1777        let returns = analyzer.returns();
1778        let position_returns = analyzer.position_returns();
1779        assert_eq!(returns.len(), 1);
1780        assert_eq!(position_returns.len(), 1);
1781        assert!(analyzer.portfolio_returns().is_empty());
1782        assert!(approx_eq!(
1783            f64,
1784            *returns.values().next().unwrap(),
1785            0.30000000000000004,
1786            epsilon = 1e-9
1787        ));
1788        assert!(approx_eq!(
1789            f64,
1790            *position_returns.values().next().unwrap(),
1791            0.30000000000000004,
1792            epsilon = 1e-9
1793        ));
1794    }
1795
1796    #[rstest]
1797    fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1798        let mut analyzer = PortfolioAnalyzer::new();
1799        let currency = Currency::USD();
1800        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1801        position.ts_closed = Some(UnixNanos::default());
1802
1803        analyzer.add_positions(&[position]);
1804
1805        assert!(analyzer.position_returns().is_empty());
1806        assert!(analyzer.returns().is_empty());
1807    }
1808
1809    #[rstest]
1810    fn test_add_positions_records_open_position_realized_pnl() {
1811        let mut analyzer = PortfolioAnalyzer::new();
1812        let currency = Currency::USD();
1813        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1814        position.ts_closed = None;
1815        // Distinct from ts_opened (default 0) so the record is keyed by the last event time.
1816        position.ts_last = UnixNanos::from(7);
1817        let position_id = position.id;
1818
1819        analyzer.add_positions(&[position]);
1820
1821        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1822        assert_eq!(records.len(), 1);
1823        assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1824        assert!(analyzer.position_returns().is_empty());
1825    }
1826
1827    #[rstest]
1828    fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1829        // A NETTING id with two native cycles where only the later cycle was recorded:
1830        // the earlier native cycle must survive rather than be dropped by position ID.
1831        let mut analyzer = PortfolioAnalyzer::new();
1832        let currency = Currency::USD();
1833        let position_id = PositionId::new("pos1");
1834
1835        analyzer.add_trade(
1836            &position_id,
1837            UnixNanos::from(1),
1838            &Money::new(10.0, currency),
1839        );
1840        analyzer.add_trade(
1841            &position_id,
1842            UnixNanos::from(2),
1843            &Money::new(20.0, currency),
1844        );
1845        analyzer.record_trade(
1846            &position_id,
1847            UnixNanos::from(2),
1848            &Money::new(25.0, currency),
1849        );
1850
1851        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1852
1853        assert_eq!(
1854            records,
1855            vec![
1856                (position_id, UnixNanos::from(1), 10.0),
1857                (position_id, UnixNanos::from(2), 25.0),
1858            ]
1859        );
1860    }
1861
1862    #[rstest]
1863    fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1864        let mut analyzer = PortfolioAnalyzer::new();
1865        let currency = Currency::USD();
1866        let position_id = PositionId::new("pos1");
1867        let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1868        let ts_event = UnixNanos::from(1);
1869
1870        analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1871        analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1872
1873        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1874
1875        assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1876    }
1877
1878    #[rstest]
1879    fn test_performance_stats_calculation() {
1880        let mut analyzer = PortfolioAnalyzer::new();
1881        let currency = Currency::USD();
1882        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1883            Arc::new(MockStatistic::new("test_stat"));
1884        analyzer.register_statistic(Arc::clone(&stat));
1885
1886        // Add some positions
1887        let positions = vec![
1888            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1889            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1890        ];
1891
1892        let mut starting_balances = AHashMap::new();
1893        starting_balances.insert(currency, Money::new(1000.0, currency));
1894
1895        let mut current_balances = AHashMap::new();
1896        current_balances.insert(currency, Money::new(1500.0, currency));
1897
1898        let account = MockAccount {
1899            starting_balances,
1900            current_balances,
1901            events: vec![],
1902        };
1903
1904        analyzer.calculate_statistics(&account, &positions);
1905
1906        // Test PnL stats
1907        let pnl_stats = analyzer
1908            .get_performance_stats_pnls(Some(&currency), None)
1909            .unwrap();
1910        assert!(pnl_stats.contains_key("PnL (total)"));
1911        assert!(pnl_stats.contains_key("PnL% (total)"));
1912        assert!(pnl_stats.contains_key("test_stat"));
1913
1914        // Test returns stats
1915        let return_stats = analyzer.get_performance_stats_returns();
1916        assert!(return_stats.contains_key("test_stat"));
1917
1918        // Test general stats
1919        let general_stats = analyzer.get_performance_stats_general();
1920        assert!(general_stats.contains_key("test_stat"));
1921    }
1922
1923    #[rstest]
1924    fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1925        let mut analyzer = PortfolioAnalyzer::new();
1926        let account_currency = Currency::EUR();
1927        let native_currency = Currency::USD();
1928        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1929            Arc::new(MockStatistic::new("test_stat"));
1930        analyzer.register_statistic(Arc::clone(&stat));
1931        analyzer.record_trade(
1932            &PositionId::new("pos1"),
1933            UnixNanos::from(1),
1934            &Money::new(90.0, account_currency),
1935        );
1936
1937        let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1938
1939        let mut starting_balances = AHashMap::new();
1940        starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1941
1942        let mut current_balances = AHashMap::new();
1943        current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1944
1945        let account = MockAccount {
1946            starting_balances,
1947            current_balances,
1948            events: vec![],
1949        };
1950
1951        analyzer.calculate_statistics(&account, &positions);
1952
1953        let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1954        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1955        let pnl_stats = analyzer
1956            .get_performance_stats_pnls(Some(&account_currency), None)
1957            .unwrap();
1958
1959        assert_eq!(native_pnls[0].2, 100.0);
1960        assert_eq!(recorded_pnls[0].2, 90.0);
1961        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1962    }
1963
1964    #[rstest]
1965    fn test_record_trade_preserves_duplicate_position_ids() {
1966        let mut analyzer = PortfolioAnalyzer::new();
1967        let account_currency = Currency::EUR();
1968        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1969            Arc::new(MockStatistic::new("test_stat"));
1970        let position_id = PositionId::new("pos1");
1971
1972        analyzer.register_statistic(Arc::clone(&stat));
1973        analyzer.record_trade(
1974            &position_id,
1975            UnixNanos::from(1),
1976            &Money::new(90.0, account_currency),
1977        );
1978        analyzer.record_trade(
1979            &position_id,
1980            UnixNanos::from(2),
1981            &Money::new(-45.0, account_currency),
1982        );
1983
1984        let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1985        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1986        let pnl_stats = analyzer
1987            .get_performance_stats_pnls(Some(&account_currency), None)
1988            .unwrap();
1989
1990        assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1991        assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1992        assert_eq!(
1993            recorded_pnls,
1994            vec![
1995                (position_id, UnixNanos::from(1), 90.0),
1996                (position_id, UnixNanos::from(2), -45.0),
1997            ]
1998        );
1999        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
2000    }
2001    #[rstest]
2002    fn test_formatted_output() {
2003        let mut analyzer = PortfolioAnalyzer::new();
2004        let currency = Currency::USD();
2005        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2006            Arc::new(MockStatistic::new("test_stat"));
2007        analyzer.register_statistic(Arc::clone(&stat));
2008
2009        let positions = vec![
2010            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2011            create_mock_position("AUD/USD", 200.0, 0.2, currency),
2012        ];
2013
2014        let mut starting_balances = AHashMap::new();
2015        starting_balances.insert(currency, Money::new(1000.0, currency));
2016
2017        let mut current_balances = AHashMap::new();
2018        current_balances.insert(currency, Money::new(1500.0, currency));
2019
2020        let account = MockAccount {
2021            starting_balances,
2022            current_balances,
2023            events: vec![],
2024        };
2025
2026        analyzer.calculate_statistics(&account, &positions);
2027
2028        // Test formatted outputs
2029        let pnl_formatted = analyzer
2030            .get_stats_pnls_formatted(Some(&currency), None)
2031            .unwrap();
2032        assert!(!pnl_formatted.is_empty());
2033        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
2034
2035        let returns_formatted = analyzer.get_stats_returns_formatted();
2036        assert!(!returns_formatted.is_empty());
2037        assert!(returns_formatted.iter().all(|s| s.contains(':')));
2038
2039        let general_formatted = analyzer.get_stats_general_formatted();
2040        assert!(!general_formatted.is_empty());
2041        assert!(general_formatted.iter().all(|s| s.contains(':')));
2042    }
2043
2044    #[rstest]
2045    fn test_reset() {
2046        let mut analyzer = PortfolioAnalyzer::new();
2047        let currency = Currency::USD();
2048
2049        let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
2050        let mut starting_balances = AHashMap::new();
2051        starting_balances.insert(currency, Money::new(1000.0, currency));
2052        let mut current_balances = AHashMap::new();
2053        current_balances.insert(currency, Money::new(1500.0, currency));
2054
2055        let account = MockAccount {
2056            starting_balances,
2057            current_balances,
2058            events: vec![],
2059        };
2060
2061        analyzer.calculate_statistics(&account, &positions);
2062
2063        analyzer.reset();
2064
2065        assert!(analyzer.account_balances_starting.is_empty());
2066        assert!(analyzer.account_balances.is_empty());
2067        assert!(analyzer.positions.is_empty());
2068        assert!(analyzer.realized_pnls.is_empty());
2069        assert!(analyzer.recorded_realized_pnls.is_empty());
2070        assert!(analyzer.position_returns.is_empty());
2071        assert!(analyzer.portfolio_returns.is_empty());
2072        assert!(analyzer.returns.is_empty());
2073    }
2074
2075    #[rstest]
2076    fn test_currencies_preserve_account_balance_order() {
2077        // Pin IndexMap iteration on PortfolioAnalyzer::account_balances:
2078        // currencies() drives the per-currency stat computation in
2079        // BacktestEngine::run, so the returned Vec must reflect the
2080        // upstream account balance order across runs.
2081        let mut analyzer = PortfolioAnalyzer::new();
2082        let inserts = [
2083            (Currency::BTC(), Money::new(1.0, Currency::BTC())),
2084            (Currency::USD(), Money::new(2.0, Currency::USD())),
2085            (Currency::ETH(), Money::new(3.0, Currency::ETH())),
2086        ];
2087
2088        for (currency, money) in inserts {
2089            analyzer.account_balances.insert(currency, money);
2090        }
2091
2092        let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
2093        assert_eq!(
2094            returned,
2095            vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
2096        );
2097    }
2098
2099    #[rstest]
2100    fn test_calculate_statistics_clears_previous_positions() {
2101        let mut analyzer = PortfolioAnalyzer::new();
2102        let currency = Currency::USD();
2103
2104        let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
2105        let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
2106
2107        let mut starting_balances = AHashMap::new();
2108        starting_balances.insert(currency, Money::new(1000.0, currency));
2109        let mut current_balances = AHashMap::new();
2110        current_balances.insert(currency, Money::new(1500.0, currency));
2111
2112        let account = MockAccount {
2113            starting_balances,
2114            current_balances,
2115            events: vec![],
2116        };
2117
2118        // First calculation
2119        analyzer.calculate_statistics(&account, &positions1);
2120        assert_eq!(analyzer.positions.len(), 1);
2121
2122        // Second calculation should NOT accumulate
2123        analyzer.calculate_statistics(&account, &positions2);
2124        assert_eq!(analyzer.positions.len(), 1);
2125    }
2126
2127    #[rstest]
2128    fn test_calculate_statistics_uses_account_state_returns_when_available() {
2129        let mut analyzer = PortfolioAnalyzer::new();
2130        let currency = Currency::USD();
2131        let positions = vec![
2132            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2133            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2134        ];
2135
2136        let mut starting_balances = AHashMap::new();
2137        starting_balances.insert(currency, Money::new(1000.0, currency));
2138
2139        let mut current_balances = AHashMap::new();
2140        current_balances.insert(currency, Money::new(1100.0, currency));
2141
2142        let account = MockAccount {
2143            starting_balances,
2144            current_balances,
2145            events: vec![
2146                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2147                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2148                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2149            ],
2150        };
2151
2152        analyzer.calculate_statistics(&account, &positions);
2153
2154        let position_returns = analyzer.position_returns();
2155        let portfolio_returns = analyzer.portfolio_returns();
2156        let returns = analyzer.returns();
2157        assert_eq!(position_returns.len(), 1);
2158        assert_eq!(portfolio_returns.len(), 30);
2159        assert_eq!(returns, portfolio_returns);
2160        assert!(approx_eq!(
2161            f64,
2162            *portfolio_returns
2163                .get(&UnixNanos::from(1_704_153_600_000_000_000))
2164                .unwrap(),
2165            0.0,
2166            epsilon = 1e-9
2167        ));
2168        assert!(approx_eq!(
2169            f64,
2170            *portfolio_returns
2171                .get(&UnixNanos::from(1_704_844_800_000_000_000))
2172                .unwrap(),
2173            0.05,
2174            epsilon = 1e-9
2175        ));
2176        assert!(approx_eq!(
2177            f64,
2178            *portfolio_returns
2179                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2180                .unwrap(),
2181            (1100.0 / 1050.0) - 1.0,
2182            epsilon = 1e-9
2183        ));
2184        assert!(approx_eq!(
2185            f64,
2186            *position_returns.values().next().unwrap(),
2187            0.30000000000000004,
2188            epsilon = 1e-9
2189        ));
2190    }
2191
2192    #[rstest]
2193    fn test_calculate_statistics_skips_empty_balance_events() {
2194        let mut analyzer = PortfolioAnalyzer::new();
2195        let currency = Currency::USD();
2196        let mut starting_balances = AHashMap::new();
2197        starting_balances.insert(currency, Money::new(1000.0, currency));
2198        let mut current_balances = AHashMap::new();
2199        current_balances.insert(currency, Money::new(1050.0, currency));
2200        let empty_event = AccountState::new(
2201            AccountId::new("test-account"),
2202            AccountType::Cash,
2203            vec![],
2204            vec![],
2205            true,
2206            UUID4::new(),
2207            UnixNanos::from(1_705_276_800_000_000_000),
2208            UnixNanos::from(1_705_276_800_000_000_000),
2209            Some(currency),
2210        );
2211        let account = MockAccount {
2212            starting_balances,
2213            current_balances,
2214            events: vec![
2215                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2216                empty_event,
2217                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2218            ],
2219        };
2220
2221        analyzer.calculate_statistics(&account, &[]);
2222
2223        let portfolio_returns = analyzer.portfolio_returns();
2224        assert_eq!(portfolio_returns.len(), 30);
2225        assert_eq!(analyzer.returns(), portfolio_returns);
2226        assert!(approx_eq!(
2227            f64,
2228            *portfolio_returns
2229                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2230                .unwrap(),
2231            0.05,
2232            epsilon = 1e-9
2233        ));
2234    }
2235
2236    #[rstest]
2237    fn test_calculate_statistics_skips_non_finite_account_returns() {
2238        let mut analyzer = PortfolioAnalyzer::new();
2239        let currency = Currency::USD();
2240
2241        let mut starting_balances = AHashMap::new();
2242        starting_balances.insert(currency, Money::new(0.0, currency));
2243
2244        let mut current_balances = AHashMap::new();
2245        current_balances.insert(currency, Money::new(1050.0, currency));
2246
2247        let account = MockAccount {
2248            starting_balances,
2249            current_balances,
2250            events: vec![
2251                create_account_state(0.0, currency, 1_704_067_200_000_000_000),
2252                create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
2253                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2254            ],
2255        };
2256
2257        analyzer.calculate_statistics(&account, &[]);
2258
2259        let returns = analyzer.returns();
2260        assert!(returns.values().all(|value| value.is_finite()));
2261        assert!(approx_eq!(
2262            f64,
2263            *returns
2264                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2265                .unwrap(),
2266            0.05,
2267            epsilon = 1e-9
2268        ));
2269    }
2270
2271    #[rstest]
2272    fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
2273        let mut analyzer = PortfolioAnalyzer::new();
2274        let currency = Currency::USD();
2275        let positions = vec![
2276            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2277            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2278        ];
2279
2280        let mut starting_balances = AHashMap::new();
2281        starting_balances.insert(currency, Money::new(1000.0, currency));
2282
2283        let mut current_balances = AHashMap::new();
2284        current_balances.insert(currency, Money::new(1100.0, currency));
2285
2286        let account = MockAccount {
2287            starting_balances,
2288            current_balances,
2289            events: vec![],
2290        };
2291
2292        analyzer.calculate_statistics(&account, &positions);
2293
2294        let returns = analyzer.returns();
2295        assert!(analyzer.portfolio_returns().is_empty());
2296        assert_eq!(returns, analyzer.position_returns());
2297        assert_eq!(returns.len(), 1);
2298        assert!(approx_eq!(
2299            f64,
2300            *returns.values().next().unwrap(),
2301            0.30000000000000004,
2302            epsilon = 1e-9
2303        ));
2304    }
2305
2306    #[rstest]
2307    fn test_get_performance_stats_returns_prefers_portfolio_returns() {
2308        let mut analyzer = PortfolioAnalyzer::new();
2309        let currency = Currency::USD();
2310        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2311            Arc::new(MockStatistic::new("test_stat"));
2312        analyzer.register_statistic(Arc::clone(&stat));
2313
2314        let positions = vec![
2315            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2316            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2317        ];
2318
2319        let mut starting_balances = AHashMap::new();
2320        starting_balances.insert(currency, Money::new(1000.0, currency));
2321
2322        let mut current_balances = AHashMap::new();
2323        current_balances.insert(currency, Money::new(1100.0, currency));
2324
2325        let account = MockAccount {
2326            starting_balances,
2327            current_balances,
2328            events: vec![
2329                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2330                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2331                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2332            ],
2333        };
2334
2335        analyzer.calculate_statistics(&account, &positions);
2336
2337        let position_stats = analyzer.get_performance_stats_position_returns();
2338        let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
2339        let returns_stats = analyzer.get_performance_stats_returns();
2340
2341        assert!(approx_eq!(
2342            f64,
2343            *position_stats.get("test_stat").unwrap(),
2344            0.30000000000000004,
2345            epsilon = 1e-9
2346        ));
2347        assert_eq!(returns_stats, portfolio_stats);
2348    }
2349
2350    #[rstest]
2351    fn test_from_accounts_aggregates_balances_and_positions() {
2352        let currency = Currency::USD();
2353        let positions = vec![
2354            create_mock_position("pos1", 100.0, 0.1, currency),
2355            create_mock_position("pos2", 200.0, 0.2, currency),
2356        ];
2357
2358        let analyzer = PortfolioAnalyzer::from_accounts(
2359            &[AccountAny::Cash(CashAccount::default())],
2360            &positions,
2361            &[],
2362            AHashMap::new(),
2363        );
2364
2365        assert_eq!(analyzer.positions.len(), positions.len());
2366        assert!(!analyzer.account_balances.is_empty());
2367    }
2368
2369    #[rstest]
2370    fn test_from_accounts_sums_balances_across_accounts() {
2371        let usd = Currency::USD();
2372        let one = PortfolioAnalyzer::from_accounts(
2373            &[AccountAny::Cash(CashAccount::default())],
2374            &[],
2375            &[],
2376            AHashMap::new(),
2377        );
2378        let two = PortfolioAnalyzer::from_accounts(
2379            &[
2380                AccountAny::Cash(CashAccount::default()),
2381                AccountAny::Cash(CashAccount::default()),
2382            ],
2383            &[],
2384            &[],
2385            AHashMap::new(),
2386        );
2387
2388        let single = one.account_balances.get(&usd).unwrap().as_decimal();
2389        let summed = two.account_balances.get(&usd).unwrap().as_decimal();
2390        let single_start = one
2391            .account_balances_starting
2392            .get(&usd)
2393            .unwrap()
2394            .as_decimal();
2395        let summed_start = two
2396            .account_balances_starting
2397            .get(&usd)
2398            .unwrap()
2399            .as_decimal();
2400
2401        assert_eq!(summed, single + single);
2402        assert_eq!(summed_start, single_start + single_start);
2403        assert_ne!(summed, single);
2404    }
2405
2406    #[rstest]
2407    fn test_statistics_snapshot_matches_getters() {
2408        let currency = Currency::USD();
2409        let positions = vec![
2410            create_mock_position("pos1", 100.0, 0.1, currency),
2411            create_mock_position("pos2", 200.0, 0.2, currency),
2412        ];
2413
2414        let analyzer = PortfolioAnalyzer::from_accounts(
2415            &[AccountAny::Cash(CashAccount::default())],
2416            &positions,
2417            &[],
2418            AHashMap::new(),
2419        );
2420
2421        let snapshot = analyzer.statistics();
2422        assert!(maps_equal_nan_aware(
2423            &snapshot.returns,
2424            &analyzer.get_performance_stats_returns()
2425        ));
2426        assert!(maps_equal_nan_aware(
2427            &snapshot.general,
2428            &analyzer.get_performance_stats_general()
2429        ));
2430        assert_eq!(&snapshot.returns_series, analyzer.returns());
2431
2432        for currency in analyzer.currencies() {
2433            let expected = analyzer
2434                .get_performance_stats_pnls(Some(currency), None)
2435                .unwrap();
2436            let actual = snapshot.pnls.get(&currency.code.to_string()).unwrap();
2437            assert!(maps_equal_nan_aware(actual, &expected));
2438        }
2439    }
2440
2441    fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
2442        if a.len() != b.len() {
2443            return false;
2444        }
2445        a.iter().all(|(k, v)| {
2446            b.get(k)
2447                .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
2448        })
2449    }
2450
2451    #[rstest]
2452    fn test_get_performance_stats_returns_vs_benchmark() {
2453        let mut analyzer = PortfolioAnalyzer::new();
2454        analyzer.register_statistic(Arc::new(BetaRatio::new()));
2455        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
2456
2457        let one_day = 86_400_000_000_000_u64;
2458        let start = 1_600_000_000_000_000_000_u64;
2459        for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
2460            analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
2461        }
2462
2463        let mut benchmark: Returns = BTreeMap::new();
2464        for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
2465            benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
2466        }
2467
2468        let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
2469
2470        // r = [0.03, -0.01, 0.02, 0.04], b = [0.01, 0.005, 0.005, 0.01]:
2471        //   mean_r = 0.02, mean_b = 0.0075
2472        //   Cov = 1.5e-4 / 3 = 5e-5, Var(b) = 2.5e-5 / 3 -> beta = 6.0
2473        // Only the benchmark-relative statistic contributes; SharpeRatio
2474        // returns None from the default and is skipped.
2475        assert_eq!(stats.len(), 1);
2476        assert!(approx_eq!(
2477            f64,
2478            *stats.get("Beta").unwrap(),
2479            6.0,
2480            epsilon = 1e-9
2481        ));
2482        assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
2483    }
2484}