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 PnL calculations fail, for example due to:
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        identifiers::{
1006            AccountId, ClientOrderId,
1007            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
1008        },
1009        instruments::InstrumentAny,
1010        stubs::TestDefault,
1011        types::{AccountBalance, Money, Price, Quantity},
1012    };
1013    use rstest::rstest;
1014
1015    use super::*;
1016    use crate::statistics::beta_ratio::BetaRatio;
1017
1018    /// Mock implementation of `PortfolioStatistic` for testing.
1019    #[derive(Debug)]
1020    struct MockStatistic {
1021        name: String,
1022    }
1023
1024    impl MockStatistic {
1025        fn new(name: &str) -> Self {
1026            Self {
1027                name: name.to_string(),
1028            }
1029        }
1030    }
1031
1032    impl PortfolioStatistic for MockStatistic {
1033        type Item = f64;
1034
1035        fn name(&self) -> String {
1036            self.name.clone()
1037        }
1038
1039        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
1040            Some(pnls.iter().sum())
1041        }
1042
1043        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
1044            Some(returns.values().sum())
1045        }
1046
1047        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
1048            Some(positions.len() as f64)
1049        }
1050    }
1051
1052    /// Mock implementation returning a fixed value for every input category.
1053    ///
1054    /// Two instances can share a name while differing in value, which makes
1055    /// duplicate-name replacement observable.
1056    #[derive(Debug)]
1057    struct ConstantStatistic {
1058        name: String,
1059        value: f64,
1060    }
1061
1062    impl ConstantStatistic {
1063        fn new(name: &str, value: f64) -> Self {
1064            Self {
1065                name: name.to_string(),
1066                value,
1067            }
1068        }
1069    }
1070
1071    impl PortfolioStatistic for ConstantStatistic {
1072        type Item = f64;
1073
1074        fn name(&self) -> String {
1075            self.name.clone()
1076        }
1077
1078        fn calculate_from_realized_pnls(&self, _pnls: &[f64]) -> Option<f64> {
1079            Some(self.value)
1080        }
1081
1082        fn calculate_from_returns(&self, _returns: &Returns) -> Option<f64> {
1083            Some(self.value)
1084        }
1085
1086        fn calculate_from_positions(&self, _positions: &[Position]) -> Option<f64> {
1087            Some(self.value)
1088        }
1089    }
1090
1091    fn create_mock_position(
1092        id: &str,
1093        realized_pnl: f64,
1094        realized_return: f64,
1095        currency: Currency,
1096    ) -> Position {
1097        Position {
1098            events: Vec::new(),
1099            adjustments: Vec::new(),
1100            replay_events: Vec::new(),
1101            fill_voids: Vec::new(),
1102            trader_id: trader_id(),
1103            strategy_id: strategy_id_ema_cross(),
1104            instrument_id: instrument_id_aud_usd_sim(),
1105            id: PositionId::new(id),
1106            account_id: AccountId::new("test-account"),
1107            opening_order_id: ClientOrderId::test_default(),
1108            closing_order_id: None,
1109            entry: OrderSide::Buy,
1110            side: PositionSide::Flat,
1111            signed_qty: 0.0,
1112            quantity: Quantity::default(),
1113            peak_qty: Quantity::default(),
1114            price_precision: 2,
1115            size_precision: 2,
1116            multiplier: Quantity::default(),
1117            is_inverse: false,
1118            is_currency_pair: true,
1119            instrument_class: InstrumentClass::Spot,
1120            base_currency: None,
1121            quote_currency: Currency::USD(),
1122            settlement_currency: Currency::USD(),
1123            ts_init: UnixNanos::default(),
1124            ts_opened: UnixNanos::default(),
1125            ts_last: UnixNanos::default(),
1126            ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
1127            duration_ns: DurationNanos::new(2),
1128            avg_px_open: 0.0,
1129            avg_px_close: None,
1130            realized_return,
1131            realized_pnl: Some(Money::new(realized_pnl, currency)),
1132            trade_ids: AHashSet::new(),
1133            buy_qty: Quantity::default(),
1134            sell_qty: Quantity::default(),
1135            commissions: IndexMap::new(),
1136        }
1137    }
1138
1139    struct MockAccount {
1140        starting_balances: AHashMap<Currency, Money>,
1141        current_balances: AHashMap<Currency, Money>,
1142        events: Vec<AccountState>,
1143    }
1144
1145    impl Account for MockAccount {
1146        fn starting_balances(&self) -> IndexMap<Currency, Money> {
1147            self.starting_balances.clone().into_iter().collect()
1148        }
1149        fn balances_total(&self) -> IndexMap<Currency, Money> {
1150            self.current_balances.clone().into_iter().collect()
1151        }
1152        fn id(&self) -> AccountId {
1153            todo!()
1154        }
1155        fn account_type(&self) -> AccountType {
1156            todo!()
1157        }
1158        fn base_currency(&self) -> Option<Currency> {
1159            todo!()
1160        }
1161        fn is_cash_account(&self) -> bool {
1162            todo!()
1163        }
1164        fn is_margin_account(&self) -> bool {
1165            todo!()
1166        }
1167        fn calculated_account_state(&self) -> bool {
1168            todo!()
1169        }
1170        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
1171            todo!()
1172        }
1173        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
1174            todo!()
1175        }
1176        fn balances_free(&self) -> IndexMap<Currency, Money> {
1177            todo!()
1178        }
1179        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
1180            todo!()
1181        }
1182        fn balances_locked(&self) -> IndexMap<Currency, Money> {
1183            todo!()
1184        }
1185        fn last_event(&self) -> Option<AccountState> {
1186            self.events.last().cloned()
1187        }
1188        fn events(&self) -> Vec<AccountState> {
1189            self.events.clone()
1190        }
1191        fn event_count(&self) -> usize {
1192            self.events.len()
1193        }
1194        fn currencies(&self) -> Vec<Currency> {
1195            self.current_balances.keys().copied().collect()
1196        }
1197        fn balances(&self) -> IndexMap<Currency, AccountBalance> {
1198            todo!()
1199        }
1200        fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
1201            todo!()
1202        }
1203        fn calculate_balance_locked(
1204            &self,
1205            _: &InstrumentAny,
1206            _: OrderSide,
1207            _: Quantity,
1208            _: Price,
1209            _: Option<bool>,
1210        ) -> Result<Money, anyhow::Error> {
1211            todo!()
1212        }
1213        fn calculate_pnls(
1214            &self,
1215            _: &InstrumentAny,
1216            _: &OrderFilled,
1217            _: Option<Position>,
1218        ) -> Result<Vec<Money>, anyhow::Error> {
1219            todo!()
1220        }
1221        fn calculate_commission(
1222            &self,
1223            _: &InstrumentAny,
1224            _: Quantity,
1225            _: Price,
1226            _: LiquiditySide,
1227            _: Option<bool>,
1228        ) -> Result<Money, anyhow::Error> {
1229            todo!()
1230        }
1231
1232        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1233            todo!()
1234        }
1235
1236        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1237            // MockAccount doesn't need purging
1238        }
1239    }
1240
1241    fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1242        AccountState::new(
1243            AccountId::new("test-account"),
1244            AccountType::Cash,
1245            vec![AccountBalance::new(
1246                Money::new(total, currency),
1247                Money::new(0.0, currency),
1248                Money::new(total, currency),
1249            )],
1250            vec![],
1251            true,
1252            UUID4::new(),
1253            UnixNanos::from(ts_event),
1254            UnixNanos::from(ts_event),
1255            Some(currency),
1256        )
1257    }
1258
1259    fn create_portfolio_snapshot(
1260        account_id: AccountId,
1261        equity: Decimal,
1262        currency: Currency,
1263        ts_event: u64,
1264    ) -> PortfolioSnapshot {
1265        let equity = Money::from_decimal(equity, currency).unwrap();
1266
1267        PortfolioSnapshot::new(
1268            account_id,
1269            AccountType::Cash,
1270            Some(currency),
1271            vec![],
1272            vec![],
1273            vec![],
1274            vec![],
1275            vec![equity],
1276            Some(equity),
1277            false,
1278            vec![],
1279            vec![],
1280            vec![],
1281            UUID4::new(),
1282            UnixNanos::from(ts_event),
1283            UnixNanos::from(ts_event),
1284        )
1285    }
1286
1287    #[rstest]
1288    fn test_calculate_snapshot_returns_tracks_daily_mark_to_market_equity() {
1289        let account_id = AccountId::new("SIM-001");
1290        let currency = Currency::USD();
1291        let snapshots = [
1292            create_portfolio_snapshot(
1293                account_id,
1294                Decimal::from(10_000),
1295                currency,
1296                NANOSECONDS_IN_DAY + NANOSECONDS_IN_DAY / 2,
1297            ),
1298            create_portfolio_snapshot(
1299                account_id,
1300                Decimal::from(10_500),
1301                currency,
1302                NANOSECONDS_IN_DAY + 3 * NANOSECONDS_IN_DAY / 4,
1303            ),
1304            create_portfolio_snapshot(
1305                account_id,
1306                Decimal::from(11_000),
1307                currency,
1308                2 * NANOSECONDS_IN_DAY,
1309            ),
1310            create_portfolio_snapshot(
1311                account_id,
1312                Decimal::from(12_100),
1313                currency,
1314                3 * NANOSECONDS_IN_DAY,
1315            ),
1316        ];
1317
1318        let returns =
1319            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1320        let values: Vec<f64> = returns.values().copied().collect();
1321        let dates: Vec<UnixNanos> = returns.keys().copied().collect();
1322
1323        assert_eq!(
1324            dates,
1325            vec![
1326                UnixNanos::from(NANOSECONDS_IN_DAY),
1327                UnixNanos::from(2 * NANOSECONDS_IN_DAY),
1328            ]
1329        );
1330        assert_eq!(values.len(), 2);
1331        assert!(approx_eq!(f64, values[0], 0.1, epsilon = 1e-12));
1332        assert!(approx_eq!(f64, values[1], 0.1, epsilon = 1e-12));
1333    }
1334
1335    #[rstest]
1336    fn test_calculate_snapshot_returns_aggregates_accounts_in_one_currency() {
1337        let account_a = AccountId::new("SIM-001");
1338        let account_b = AccountId::new("SIM-002");
1339        let currency = Currency::USD();
1340        let snapshots = [
1341            create_portfolio_snapshot(account_a, Decimal::from(100), currency, NANOSECONDS_IN_DAY),
1342            create_portfolio_snapshot(account_b, Decimal::from(50), currency, NANOSECONDS_IN_DAY),
1343            create_portfolio_snapshot(
1344                account_a,
1345                Decimal::from(110),
1346                currency,
1347                2 * NANOSECONDS_IN_DAY,
1348            ),
1349        ];
1350
1351        let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1352            &[account_a, account_b],
1353            snapshots.iter(),
1354        )
1355        .unwrap();
1356
1357        assert!(approx_eq!(
1358            f64,
1359            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1360            160.0 / 150.0 - 1.0,
1361            epsilon = 1e-12
1362        ));
1363    }
1364
1365    #[rstest]
1366    fn test_calculate_snapshot_returns_uses_single_total_without_base_currency() {
1367        let account_id = AccountId::new("SIM-001");
1368        let currency = Currency::USD();
1369        let mut first =
1370            create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1371        let mut second = create_portfolio_snapshot(
1372            account_id,
1373            Decimal::from(110),
1374            currency,
1375            2 * NANOSECONDS_IN_DAY,
1376        );
1377        first.base_currency_equity = None;
1378        second.base_currency_equity = None;
1379        let snapshots = [first, second];
1380
1381        let returns =
1382            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1383
1384        assert!(approx_eq!(
1385            f64,
1386            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1387            0.1,
1388            epsilon = 1e-12
1389        ));
1390    }
1391
1392    #[rstest]
1393    fn test_calculate_snapshot_returns_rejects_multi_currency_total_with_base_equity() {
1394        let account_id = AccountId::new("SIM-001");
1395        let mut first = create_portfolio_snapshot(
1396            account_id,
1397            Decimal::from(100),
1398            Currency::USD(),
1399            NANOSECONDS_IN_DAY,
1400        );
1401        let mut second = create_portfolio_snapshot(
1402            account_id,
1403            Decimal::from(110),
1404            Currency::USD(),
1405            2 * NANOSECONDS_IN_DAY,
1406        );
1407        first.total_equity.push(Money::new(50.0, Currency::AUD()));
1408        second.total_equity.push(Money::new(55.0, Currency::AUD()));
1409        let snapshots = [first, second];
1410
1411        let returns =
1412            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter());
1413
1414        assert!(returns.is_none());
1415    }
1416
1417    #[rstest]
1418    fn test_calculate_snapshot_returns_forward_fills_unpriced_dates() {
1419        let account_id = AccountId::new("SIM-001");
1420        let currency = Currency::USD();
1421        let first =
1422            create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1423        let mut unpriced =
1424            create_portfolio_snapshot(account_id, Decimal::ZERO, currency, 2 * NANOSECONDS_IN_DAY);
1425        unpriced.unpriced_instruments = vec![instrument_id_aud_usd_sim()];
1426        let last = create_portfolio_snapshot(
1427            account_id,
1428            Decimal::from(110),
1429            currency,
1430            3 * NANOSECONDS_IN_DAY,
1431        );
1432        let snapshots = [first, unpriced, last];
1433
1434        let returns =
1435            PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1436
1437        assert!(approx_eq!(
1438            f64,
1439            returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1440            0.0,
1441            epsilon = 1e-12
1442        ));
1443        assert!(approx_eq!(
1444            f64,
1445            returns[&UnixNanos::from(2 * NANOSECONDS_IN_DAY)],
1446            0.1,
1447            epsilon = 1e-12
1448        ));
1449    }
1450
1451    #[rstest]
1452    fn test_calculate_snapshot_returns_rejects_mixed_account_currencies() {
1453        let account_a = AccountId::new("SIM-001");
1454        let account_b = AccountId::new("SIM-002");
1455        let snapshots = [
1456            create_portfolio_snapshot(
1457                account_a,
1458                Decimal::from(100),
1459                Currency::USD(),
1460                NANOSECONDS_IN_DAY,
1461            ),
1462            create_portfolio_snapshot(
1463                account_b,
1464                Decimal::from(100),
1465                Currency::AUD(),
1466                NANOSECONDS_IN_DAY,
1467            ),
1468        ];
1469
1470        let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1471            &[account_a, account_b],
1472            snapshots.iter(),
1473        );
1474
1475        assert!(returns.is_none());
1476    }
1477
1478    #[rstest]
1479    fn test_register_and_deregister_statistics() {
1480        let mut analyzer = PortfolioAnalyzer::new();
1481        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1482            Arc::new(MockStatistic::new("test_stat"));
1483
1484        // Test registration
1485        analyzer.register_statistic(Arc::clone(&stat));
1486        assert!(analyzer.statistic("test_stat").is_some());
1487
1488        // Test deregistration
1489        analyzer.deregister_statistic(&stat);
1490        assert!(analyzer.statistic("test_stat").is_none());
1491
1492        // Test deregister all
1493        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1494            Arc::new(MockStatistic::new("stat1"));
1495        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1496            Arc::new(MockStatistic::new("stat2"));
1497        analyzer.register_statistic(Arc::clone(&stat1));
1498        analyzer.register_statistic(Arc::clone(&stat2));
1499        analyzer.deregister_statistics();
1500        assert!(analyzer.statistics.is_empty());
1501    }
1502
1503    #[rstest]
1504    fn test_register_statistic_replaces_matching_name() {
1505        let mut analyzer = PortfolioAnalyzer::new();
1506        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1507        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 2.0)));
1508
1509        let stats = analyzer.get_performance_stats_general();
1510
1511        assert_eq!(analyzer.statistics.len(), 1);
1512        assert_eq!(stats["Custom"], 2.0);
1513    }
1514
1515    #[rstest]
1516    fn test_pnl_statistics_reject_unresolved_currency() {
1517        // Two currencies and no account balances: the currency cannot be resolved, so
1518        // dispatching on an empty slice would report a result that ignores both trades.
1519        let mut analyzer = PortfolioAnalyzer::new();
1520        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1521        analyzer.add_trade(
1522            &PositionId::new("P-USD"),
1523            UnixNanos::from(1),
1524            &Money::new(10.0, Currency::USD()),
1525        );
1526        analyzer.add_trade(
1527            &PositionId::new("P-EUR"),
1528            UnixNanos::from(2),
1529            &Money::new(5.0, Currency::EUR()),
1530        );
1531
1532        let result = analyzer.get_performance_stats_pnls(None, None);
1533
1534        assert_eq!(
1535            result,
1536            Err("Currency must be specified for multi-currency portfolio")
1537        );
1538    }
1539
1540    #[rstest]
1541    fn test_pnl_statistics_resolve_single_pnl_currency() {
1542        // One realized-PnL currency and no explicit currency: resolution falls back to that
1543        // currency, so statistics dispatch on its records.
1544        let mut analyzer = PortfolioAnalyzer::new();
1545        analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1546        let currency = Currency::USD();
1547        let position_id = PositionId::new("P-USD");
1548        analyzer.add_trade(
1549            &position_id,
1550            UnixNanos::from(1),
1551            &Money::new(10.0, currency),
1552        );
1553
1554        let records = analyzer.trade_pnl_records(None).unwrap();
1555        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1556
1557        assert_eq!(records, vec![(position_id, UnixNanos::from(1), 10.0)]);
1558        assert_eq!(stats["test_stat"], 10.0);
1559    }
1560
1561    #[rstest]
1562    fn test_pnl_statistics_prefer_account_balance_currency() {
1563        // A single account-balance currency resolves the query even when the realized-PnL
1564        // records are in another currency: the balance currency wins, has no records, and
1565        // statistics dispatch on an empty slice instead of erroring.
1566        let mut analyzer = PortfolioAnalyzer::new();
1567        analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1568        let currency = Currency::USD();
1569        analyzer
1570            .account_balances
1571            .insert(currency, Money::new(1000.0, currency));
1572        analyzer.add_trade(
1573            &PositionId::new("P-EUR"),
1574            UnixNanos::from(1),
1575            &Money::new(5.0, Currency::EUR()),
1576        );
1577
1578        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1579
1580        assert!(analyzer.trade_pnl_records(None).is_none());
1581        assert_eq!(stats["test_stat"], 0.0);
1582    }
1583
1584    #[rstest]
1585    fn test_trade_pnl_records_sorted_by_event_time() {
1586        // A recorded PnL at t=1 and an unmatched position-derived PnL at t=2 are merged from
1587        // two sources; without sorting the derived record leads and the sequence reads [t2, t1].
1588        let currency = Currency::USD();
1589        let mut analyzer = PortfolioAnalyzer::new();
1590        analyzer.add_trade(
1591            &PositionId::new("P-LATE"),
1592            UnixNanos::from(2),
1593            &Money::new(5.0, currency),
1594        );
1595        analyzer.record_trade(
1596            &PositionId::new("P-EARLY"),
1597            UnixNanos::from(1),
1598            &Money::new(7.0, currency),
1599        );
1600
1601        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1602
1603        assert_eq!(
1604            records,
1605            vec![
1606                (PositionId::new("P-EARLY"), UnixNanos::from(1), 7.0),
1607                (PositionId::new("P-LATE"), UnixNanos::from(2), 5.0),
1608            ]
1609        );
1610    }
1611
1612    #[rstest]
1613    fn test_pnl_statistics_run_without_any_trades() {
1614        // No realized PnLs at all: registered statistics still receive an empty slice, so a
1615        // statistic defined for empty input reports its value instead of vanishing.
1616        let mut analyzer = PortfolioAnalyzer::new();
1617        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1618
1619        let stats = analyzer
1620            .get_performance_stats_pnls(Some(&Currency::USD()), None)
1621            .unwrap();
1622
1623        assert_eq!(stats["Custom"], 0.0);
1624        assert_eq!(stats["PnL (total)"], 0.0);
1625    }
1626
1627    #[rstest]
1628    fn test_pnl_statistics_run_without_any_trades_or_currency() {
1629        // No realized PnLs and no explicit currency: with nothing to resolve, statistics
1630        // still receive an empty slice instead of erroring.
1631        let mut analyzer = PortfolioAnalyzer::new();
1632        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1633
1634        let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1635
1636        assert_eq!(stats["Custom"], 0.0);
1637        assert_eq!(stats["PnL (total)"], 0.0);
1638    }
1639
1640    #[rstest]
1641    fn test_reset_retains_registered_statistics() {
1642        let currency = Currency::USD();
1643        let mut analyzer = PortfolioAnalyzer::new();
1644        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 7.5)));
1645        analyzer.add_positions(&[create_mock_position("AUD/USD", 100.0, 0.1, currency)]);
1646
1647        analyzer.reset();
1648
1649        assert!(analyzer.positions.is_empty());
1650        assert_eq!(analyzer.statistics.len(), 1);
1651        assert_eq!(analyzer.get_performance_stats_general()["Custom"], 7.5);
1652    }
1653
1654    #[rstest]
1655    fn test_formatted_general_stats_include_custom_statistic() {
1656        // The post-run analysis log formats from this same method, so a registered
1657        // custom statistic must appear alongside the built-in defaults.
1658        let mut analyzer = PortfolioAnalyzer::new();
1659        analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 12.5)));
1660
1661        let lines = analyzer.get_stats_general_formatted();
1662
1663        assert_eq!(lines, vec!["Custom:  12.5".to_string()]);
1664    }
1665
1666    #[rstest]
1667    fn test_replace_statistics_adopts_given_set() {
1668        let mut analyzer = PortfolioAnalyzer::default();
1669        let default_count = analyzer.statistics.len();
1670
1671        let mut replacement: AHashMap<String, Statistic> = AHashMap::new();
1672        replacement.insert(
1673            "Custom".to_string(),
1674            Arc::new(ConstantStatistic::new("Custom", 3.25)),
1675        );
1676        analyzer.replace_statistics(replacement);
1677
1678        let stats = analyzer.get_performance_stats_general();
1679
1680        assert!(default_count > 1);
1681        assert_eq!(analyzer.statistics.len(), 1);
1682        assert_eq!(stats.len(), 1);
1683        assert_eq!(stats["Custom"], 3.25);
1684    }
1685
1686    #[rstest]
1687    fn test_calculate_total_pnl() {
1688        let mut analyzer = PortfolioAnalyzer::new();
1689        let currency = Currency::USD();
1690
1691        // Set up mock account data
1692        let mut starting_balances = AHashMap::new();
1693        starting_balances.insert(currency, Money::new(1000.0, currency));
1694
1695        let mut current_balances = AHashMap::new();
1696        current_balances.insert(currency, Money::new(1500.0, currency));
1697
1698        let account = MockAccount {
1699            starting_balances,
1700            current_balances,
1701            events: vec![],
1702        };
1703
1704        analyzer.calculate_statistics(&account, &[]);
1705
1706        // Test total PnL calculation
1707        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
1708        assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1709
1710        // Test with unrealized PnL
1711        let unrealized_pnl = Money::new(100.0, currency);
1712        let result = analyzer
1713            .total_pnl(Some(&currency), Some(&unrealized_pnl))
1714            .unwrap();
1715        assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1716    }
1717
1718    #[rstest]
1719    fn test_calculate_total_pnl_percentage() {
1720        let mut analyzer = PortfolioAnalyzer::new();
1721        let currency = Currency::USD();
1722
1723        // Set up mock account data
1724        let mut starting_balances = AHashMap::new();
1725        starting_balances.insert(currency, Money::new(1000.0, currency));
1726
1727        let mut current_balances = AHashMap::new();
1728        current_balances.insert(currency, Money::new(1500.0, currency));
1729
1730        let account = MockAccount {
1731            starting_balances,
1732            current_balances,
1733            events: vec![],
1734        };
1735
1736        analyzer.calculate_statistics(&account, &[]);
1737
1738        // Test percentage calculation
1739        let result = analyzer
1740            .total_pnl_percentage(Some(&currency), None)
1741            .unwrap();
1742        assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); // (1500 - 1000) / 1000 * 100
1743
1744        // Test with unrealized PnL
1745        let unrealized_pnl = Money::new(500.0, currency);
1746        let result = analyzer
1747            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
1748            .unwrap();
1749        assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); // (2000 - 1000) / 1000 * 100
1750    }
1751
1752    #[rstest]
1753    fn test_add_positions_and_returns() {
1754        let mut analyzer = PortfolioAnalyzer::new();
1755        let currency = Currency::USD();
1756
1757        let positions = vec![
1758            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1759            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1760        ];
1761
1762        analyzer.add_positions(&positions);
1763
1764        // Verify realized PnLs were recorded
1765        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
1766        assert_eq!(pnls.len(), 2);
1767        assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1768        assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1769
1770        // Verify returns were recorded
1771        let returns = analyzer.returns();
1772        let position_returns = analyzer.position_returns();
1773        assert_eq!(returns.len(), 1);
1774        assert_eq!(position_returns.len(), 1);
1775        assert!(analyzer.portfolio_returns().is_empty());
1776        assert!(approx_eq!(
1777            f64,
1778            *returns.values().next().unwrap(),
1779            0.30000000000000004,
1780            epsilon = 1e-9
1781        ));
1782        assert!(approx_eq!(
1783            f64,
1784            *position_returns.values().next().unwrap(),
1785            0.30000000000000004,
1786            epsilon = 1e-9
1787        ));
1788    }
1789
1790    #[rstest]
1791    fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1792        let mut analyzer = PortfolioAnalyzer::new();
1793        let currency = Currency::USD();
1794        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1795        position.ts_closed = Some(UnixNanos::default());
1796
1797        analyzer.add_positions(&[position]);
1798
1799        assert!(analyzer.position_returns().is_empty());
1800        assert!(analyzer.returns().is_empty());
1801    }
1802
1803    #[rstest]
1804    fn test_add_positions_records_open_position_realized_pnl() {
1805        let mut analyzer = PortfolioAnalyzer::new();
1806        let currency = Currency::USD();
1807        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1808        position.ts_closed = None;
1809        // Distinct from ts_opened (default 0) so the record is keyed by the last event time.
1810        position.ts_last = UnixNanos::from(7);
1811        let position_id = position.id;
1812
1813        analyzer.add_positions(&[position]);
1814
1815        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1816        assert_eq!(records.len(), 1);
1817        assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1818        assert!(analyzer.position_returns().is_empty());
1819    }
1820
1821    #[rstest]
1822    fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1823        // A NETTING id with two native cycles where only the later cycle was recorded:
1824        // the earlier native cycle must survive rather than be dropped by position ID.
1825        let mut analyzer = PortfolioAnalyzer::new();
1826        let currency = Currency::USD();
1827        let position_id = PositionId::new("pos1");
1828
1829        analyzer.add_trade(
1830            &position_id,
1831            UnixNanos::from(1),
1832            &Money::new(10.0, currency),
1833        );
1834        analyzer.add_trade(
1835            &position_id,
1836            UnixNanos::from(2),
1837            &Money::new(20.0, currency),
1838        );
1839        analyzer.record_trade(
1840            &position_id,
1841            UnixNanos::from(2),
1842            &Money::new(25.0, currency),
1843        );
1844
1845        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1846
1847        assert_eq!(
1848            records,
1849            vec![
1850                (position_id, UnixNanos::from(1), 10.0),
1851                (position_id, UnixNanos::from(2), 25.0),
1852            ]
1853        );
1854    }
1855
1856    #[rstest]
1857    fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1858        let mut analyzer = PortfolioAnalyzer::new();
1859        let currency = Currency::USD();
1860        let position_id = PositionId::new("pos1");
1861        let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1862        let ts_event = UnixNanos::from(1);
1863
1864        analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1865        analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1866
1867        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1868
1869        assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1870    }
1871
1872    #[rstest]
1873    fn test_performance_stats_calculation() {
1874        let mut analyzer = PortfolioAnalyzer::new();
1875        let currency = Currency::USD();
1876        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1877            Arc::new(MockStatistic::new("test_stat"));
1878        analyzer.register_statistic(Arc::clone(&stat));
1879
1880        // Add some positions
1881        let positions = vec![
1882            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1883            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1884        ];
1885
1886        let mut starting_balances = AHashMap::new();
1887        starting_balances.insert(currency, Money::new(1000.0, currency));
1888
1889        let mut current_balances = AHashMap::new();
1890        current_balances.insert(currency, Money::new(1500.0, currency));
1891
1892        let account = MockAccount {
1893            starting_balances,
1894            current_balances,
1895            events: vec![],
1896        };
1897
1898        analyzer.calculate_statistics(&account, &positions);
1899
1900        // Test PnL stats
1901        let pnl_stats = analyzer
1902            .get_performance_stats_pnls(Some(&currency), None)
1903            .unwrap();
1904        assert!(pnl_stats.contains_key("PnL (total)"));
1905        assert!(pnl_stats.contains_key("PnL% (total)"));
1906        assert!(pnl_stats.contains_key("test_stat"));
1907
1908        // Test returns stats
1909        let return_stats = analyzer.get_performance_stats_returns();
1910        assert!(return_stats.contains_key("test_stat"));
1911
1912        // Test general stats
1913        let general_stats = analyzer.get_performance_stats_general();
1914        assert!(general_stats.contains_key("test_stat"));
1915    }
1916
1917    #[rstest]
1918    fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1919        let mut analyzer = PortfolioAnalyzer::new();
1920        let account_currency = Currency::EUR();
1921        let native_currency = Currency::USD();
1922        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1923            Arc::new(MockStatistic::new("test_stat"));
1924        analyzer.register_statistic(Arc::clone(&stat));
1925        analyzer.record_trade(
1926            &PositionId::new("pos1"),
1927            UnixNanos::from(1),
1928            &Money::new(90.0, account_currency),
1929        );
1930
1931        let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1932
1933        let mut starting_balances = AHashMap::new();
1934        starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1935
1936        let mut current_balances = AHashMap::new();
1937        current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1938
1939        let account = MockAccount {
1940            starting_balances,
1941            current_balances,
1942            events: vec![],
1943        };
1944
1945        analyzer.calculate_statistics(&account, &positions);
1946
1947        let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1948        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1949        let pnl_stats = analyzer
1950            .get_performance_stats_pnls(Some(&account_currency), None)
1951            .unwrap();
1952
1953        assert_eq!(native_pnls[0].2, 100.0);
1954        assert_eq!(recorded_pnls[0].2, 90.0);
1955        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1956    }
1957
1958    #[rstest]
1959    fn test_record_trade_preserves_duplicate_position_ids() {
1960        let mut analyzer = PortfolioAnalyzer::new();
1961        let account_currency = Currency::EUR();
1962        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1963            Arc::new(MockStatistic::new("test_stat"));
1964        let position_id = PositionId::new("pos1");
1965
1966        analyzer.register_statistic(Arc::clone(&stat));
1967        analyzer.record_trade(
1968            &position_id,
1969            UnixNanos::from(1),
1970            &Money::new(90.0, account_currency),
1971        );
1972        analyzer.record_trade(
1973            &position_id,
1974            UnixNanos::from(2),
1975            &Money::new(-45.0, account_currency),
1976        );
1977
1978        let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1979        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1980        let pnl_stats = analyzer
1981            .get_performance_stats_pnls(Some(&account_currency), None)
1982            .unwrap();
1983
1984        assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1985        assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1986        assert_eq!(
1987            recorded_pnls,
1988            vec![
1989                (position_id, UnixNanos::from(1), 90.0),
1990                (position_id, UnixNanos::from(2), -45.0),
1991            ]
1992        );
1993        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
1994    }
1995    #[rstest]
1996    fn test_formatted_output() {
1997        let mut analyzer = PortfolioAnalyzer::new();
1998        let currency = Currency::USD();
1999        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2000            Arc::new(MockStatistic::new("test_stat"));
2001        analyzer.register_statistic(Arc::clone(&stat));
2002
2003        let positions = vec![
2004            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2005            create_mock_position("AUD/USD", 200.0, 0.2, currency),
2006        ];
2007
2008        let mut starting_balances = AHashMap::new();
2009        starting_balances.insert(currency, Money::new(1000.0, currency));
2010
2011        let mut current_balances = AHashMap::new();
2012        current_balances.insert(currency, Money::new(1500.0, currency));
2013
2014        let account = MockAccount {
2015            starting_balances,
2016            current_balances,
2017            events: vec![],
2018        };
2019
2020        analyzer.calculate_statistics(&account, &positions);
2021
2022        // Test formatted outputs
2023        let pnl_formatted = analyzer
2024            .get_stats_pnls_formatted(Some(&currency), None)
2025            .unwrap();
2026        assert!(!pnl_formatted.is_empty());
2027        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
2028
2029        let returns_formatted = analyzer.get_stats_returns_formatted();
2030        assert!(!returns_formatted.is_empty());
2031        assert!(returns_formatted.iter().all(|s| s.contains(':')));
2032
2033        let general_formatted = analyzer.get_stats_general_formatted();
2034        assert!(!general_formatted.is_empty());
2035        assert!(general_formatted.iter().all(|s| s.contains(':')));
2036    }
2037
2038    #[rstest]
2039    fn test_reset() {
2040        let mut analyzer = PortfolioAnalyzer::new();
2041        let currency = Currency::USD();
2042
2043        let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
2044        let mut starting_balances = AHashMap::new();
2045        starting_balances.insert(currency, Money::new(1000.0, currency));
2046        let mut current_balances = AHashMap::new();
2047        current_balances.insert(currency, Money::new(1500.0, currency));
2048
2049        let account = MockAccount {
2050            starting_balances,
2051            current_balances,
2052            events: vec![],
2053        };
2054
2055        analyzer.calculate_statistics(&account, &positions);
2056
2057        analyzer.reset();
2058
2059        assert!(analyzer.account_balances_starting.is_empty());
2060        assert!(analyzer.account_balances.is_empty());
2061        assert!(analyzer.positions.is_empty());
2062        assert!(analyzer.realized_pnls.is_empty());
2063        assert!(analyzer.recorded_realized_pnls.is_empty());
2064        assert!(analyzer.position_returns.is_empty());
2065        assert!(analyzer.portfolio_returns.is_empty());
2066        assert!(analyzer.returns.is_empty());
2067    }
2068
2069    #[rstest]
2070    fn test_currencies_preserve_account_balance_order() {
2071        // Pin IndexMap iteration on PortfolioAnalyzer::account_balances:
2072        // currencies() drives the per-currency stat computation in
2073        // BacktestEngine::run, so the returned Vec must reflect the
2074        // upstream account balance order across runs.
2075        let mut analyzer = PortfolioAnalyzer::new();
2076        let inserts = [
2077            (Currency::BTC(), Money::new(1.0, Currency::BTC())),
2078            (Currency::USD(), Money::new(2.0, Currency::USD())),
2079            (Currency::ETH(), Money::new(3.0, Currency::ETH())),
2080        ];
2081
2082        for (currency, money) in inserts {
2083            analyzer.account_balances.insert(currency, money);
2084        }
2085
2086        let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
2087        assert_eq!(
2088            returned,
2089            vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
2090        );
2091    }
2092
2093    #[rstest]
2094    fn test_calculate_statistics_clears_previous_positions() {
2095        let mut analyzer = PortfolioAnalyzer::new();
2096        let currency = Currency::USD();
2097
2098        let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
2099        let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
2100
2101        let mut starting_balances = AHashMap::new();
2102        starting_balances.insert(currency, Money::new(1000.0, currency));
2103        let mut current_balances = AHashMap::new();
2104        current_balances.insert(currency, Money::new(1500.0, currency));
2105
2106        let account = MockAccount {
2107            starting_balances,
2108            current_balances,
2109            events: vec![],
2110        };
2111
2112        // First calculation
2113        analyzer.calculate_statistics(&account, &positions1);
2114        assert_eq!(analyzer.positions.len(), 1);
2115
2116        // Second calculation should NOT accumulate
2117        analyzer.calculate_statistics(&account, &positions2);
2118        assert_eq!(analyzer.positions.len(), 1);
2119    }
2120
2121    #[rstest]
2122    fn test_calculate_statistics_uses_account_state_returns_when_available() {
2123        let mut analyzer = PortfolioAnalyzer::new();
2124        let currency = Currency::USD();
2125        let positions = vec![
2126            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2127            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2128        ];
2129
2130        let mut starting_balances = AHashMap::new();
2131        starting_balances.insert(currency, Money::new(1000.0, currency));
2132
2133        let mut current_balances = AHashMap::new();
2134        current_balances.insert(currency, Money::new(1100.0, currency));
2135
2136        let account = MockAccount {
2137            starting_balances,
2138            current_balances,
2139            events: vec![
2140                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2141                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2142                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2143            ],
2144        };
2145
2146        analyzer.calculate_statistics(&account, &positions);
2147
2148        let position_returns = analyzer.position_returns();
2149        let portfolio_returns = analyzer.portfolio_returns();
2150        let returns = analyzer.returns();
2151        assert_eq!(position_returns.len(), 1);
2152        assert_eq!(portfolio_returns.len(), 30);
2153        assert_eq!(returns, portfolio_returns);
2154        assert!(approx_eq!(
2155            f64,
2156            *portfolio_returns
2157                .get(&UnixNanos::from(1_704_153_600_000_000_000))
2158                .unwrap(),
2159            0.0,
2160            epsilon = 1e-9
2161        ));
2162        assert!(approx_eq!(
2163            f64,
2164            *portfolio_returns
2165                .get(&UnixNanos::from(1_704_844_800_000_000_000))
2166                .unwrap(),
2167            0.05,
2168            epsilon = 1e-9
2169        ));
2170        assert!(approx_eq!(
2171            f64,
2172            *portfolio_returns
2173                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2174                .unwrap(),
2175            (1100.0 / 1050.0) - 1.0,
2176            epsilon = 1e-9
2177        ));
2178        assert!(approx_eq!(
2179            f64,
2180            *position_returns.values().next().unwrap(),
2181            0.30000000000000004,
2182            epsilon = 1e-9
2183        ));
2184    }
2185
2186    #[rstest]
2187    fn test_calculate_statistics_skips_empty_balance_events() {
2188        let mut analyzer = PortfolioAnalyzer::new();
2189        let currency = Currency::USD();
2190        let mut starting_balances = AHashMap::new();
2191        starting_balances.insert(currency, Money::new(1000.0, currency));
2192        let mut current_balances = AHashMap::new();
2193        current_balances.insert(currency, Money::new(1050.0, currency));
2194        let empty_event = AccountState::new(
2195            AccountId::new("test-account"),
2196            AccountType::Cash,
2197            vec![],
2198            vec![],
2199            true,
2200            UUID4::new(),
2201            UnixNanos::from(1_705_276_800_000_000_000),
2202            UnixNanos::from(1_705_276_800_000_000_000),
2203            Some(currency),
2204        );
2205        let account = MockAccount {
2206            starting_balances,
2207            current_balances,
2208            events: vec![
2209                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2210                empty_event,
2211                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2212            ],
2213        };
2214
2215        analyzer.calculate_statistics(&account, &[]);
2216
2217        let portfolio_returns = analyzer.portfolio_returns();
2218        assert_eq!(portfolio_returns.len(), 30);
2219        assert_eq!(analyzer.returns(), portfolio_returns);
2220        assert!(approx_eq!(
2221            f64,
2222            *portfolio_returns
2223                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2224                .unwrap(),
2225            0.05,
2226            epsilon = 1e-9
2227        ));
2228    }
2229
2230    #[rstest]
2231    fn test_calculate_statistics_skips_non_finite_account_returns() {
2232        let mut analyzer = PortfolioAnalyzer::new();
2233        let currency = Currency::USD();
2234
2235        let mut starting_balances = AHashMap::new();
2236        starting_balances.insert(currency, Money::new(0.0, currency));
2237
2238        let mut current_balances = AHashMap::new();
2239        current_balances.insert(currency, Money::new(1050.0, currency));
2240
2241        let account = MockAccount {
2242            starting_balances,
2243            current_balances,
2244            events: vec![
2245                create_account_state(0.0, currency, 1_704_067_200_000_000_000),
2246                create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
2247                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2248            ],
2249        };
2250
2251        analyzer.calculate_statistics(&account, &[]);
2252
2253        let returns = analyzer.returns();
2254        assert!(returns.values().all(|value| value.is_finite()));
2255        assert!(approx_eq!(
2256            f64,
2257            *returns
2258                .get(&UnixNanos::from(1_706_659_200_000_000_000))
2259                .unwrap(),
2260            0.05,
2261            epsilon = 1e-9
2262        ));
2263    }
2264
2265    #[rstest]
2266    fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
2267        let mut analyzer = PortfolioAnalyzer::new();
2268        let currency = Currency::USD();
2269        let positions = vec![
2270            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2271            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2272        ];
2273
2274        let mut starting_balances = AHashMap::new();
2275        starting_balances.insert(currency, Money::new(1000.0, currency));
2276
2277        let mut current_balances = AHashMap::new();
2278        current_balances.insert(currency, Money::new(1100.0, currency));
2279
2280        let account = MockAccount {
2281            starting_balances,
2282            current_balances,
2283            events: vec![],
2284        };
2285
2286        analyzer.calculate_statistics(&account, &positions);
2287
2288        let returns = analyzer.returns();
2289        assert!(analyzer.portfolio_returns().is_empty());
2290        assert_eq!(returns, analyzer.position_returns());
2291        assert_eq!(returns.len(), 1);
2292        assert!(approx_eq!(
2293            f64,
2294            *returns.values().next().unwrap(),
2295            0.30000000000000004,
2296            epsilon = 1e-9
2297        ));
2298    }
2299
2300    #[rstest]
2301    fn test_get_performance_stats_returns_prefers_portfolio_returns() {
2302        let mut analyzer = PortfolioAnalyzer::new();
2303        let currency = Currency::USD();
2304        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2305            Arc::new(MockStatistic::new("test_stat"));
2306        analyzer.register_statistic(Arc::clone(&stat));
2307
2308        let positions = vec![
2309            create_mock_position("AUD/USD", 100.0, 0.1, currency),
2310            create_mock_position("EUR/USD", 200.0, 0.2, currency),
2311        ];
2312
2313        let mut starting_balances = AHashMap::new();
2314        starting_balances.insert(currency, Money::new(1000.0, currency));
2315
2316        let mut current_balances = AHashMap::new();
2317        current_balances.insert(currency, Money::new(1100.0, currency));
2318
2319        let account = MockAccount {
2320            starting_balances,
2321            current_balances,
2322            events: vec![
2323                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2324                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2325                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2326            ],
2327        };
2328
2329        analyzer.calculate_statistics(&account, &positions);
2330
2331        let position_stats = analyzer.get_performance_stats_position_returns();
2332        let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
2333        let returns_stats = analyzer.get_performance_stats_returns();
2334
2335        assert!(approx_eq!(
2336            f64,
2337            *position_stats.get("test_stat").unwrap(),
2338            0.30000000000000004,
2339            epsilon = 1e-9
2340        ));
2341        assert_eq!(returns_stats, portfolio_stats);
2342    }
2343
2344    #[rstest]
2345    fn test_from_accounts_aggregates_balances_and_positions() {
2346        let currency = Currency::USD();
2347        let positions = vec![
2348            create_mock_position("pos1", 100.0, 0.1, currency),
2349            create_mock_position("pos2", 200.0, 0.2, currency),
2350        ];
2351
2352        let analyzer = PortfolioAnalyzer::from_accounts(
2353            &[AccountAny::Cash(CashAccount::default())],
2354            &positions,
2355            &[],
2356            AHashMap::new(),
2357        );
2358
2359        assert_eq!(analyzer.positions.len(), positions.len());
2360        assert!(!analyzer.account_balances.is_empty());
2361    }
2362
2363    #[rstest]
2364    fn test_from_accounts_sums_balances_across_accounts() {
2365        let usd = Currency::USD();
2366        let one = PortfolioAnalyzer::from_accounts(
2367            &[AccountAny::Cash(CashAccount::default())],
2368            &[],
2369            &[],
2370            AHashMap::new(),
2371        );
2372        let two = PortfolioAnalyzer::from_accounts(
2373            &[
2374                AccountAny::Cash(CashAccount::default()),
2375                AccountAny::Cash(CashAccount::default()),
2376            ],
2377            &[],
2378            &[],
2379            AHashMap::new(),
2380        );
2381
2382        let single = one.account_balances.get(&usd).unwrap().as_decimal();
2383        let summed = two.account_balances.get(&usd).unwrap().as_decimal();
2384        let single_start = one
2385            .account_balances_starting
2386            .get(&usd)
2387            .unwrap()
2388            .as_decimal();
2389        let summed_start = two
2390            .account_balances_starting
2391            .get(&usd)
2392            .unwrap()
2393            .as_decimal();
2394
2395        assert_eq!(summed, single + single);
2396        assert_eq!(summed_start, single_start + single_start);
2397        assert_ne!(summed, single);
2398    }
2399
2400    #[rstest]
2401    fn test_statistics_snapshot_matches_getters() {
2402        let currency = Currency::USD();
2403        let positions = vec![
2404            create_mock_position("pos1", 100.0, 0.1, currency),
2405            create_mock_position("pos2", 200.0, 0.2, currency),
2406        ];
2407
2408        let analyzer = PortfolioAnalyzer::from_accounts(
2409            &[AccountAny::Cash(CashAccount::default())],
2410            &positions,
2411            &[],
2412            AHashMap::new(),
2413        );
2414
2415        let snapshot = analyzer.statistics();
2416        assert!(maps_equal_nan_aware(
2417            &snapshot.returns,
2418            &analyzer.get_performance_stats_returns()
2419        ));
2420        assert!(maps_equal_nan_aware(
2421            &snapshot.general,
2422            &analyzer.get_performance_stats_general()
2423        ));
2424        assert_eq!(&snapshot.returns_series, analyzer.returns());
2425
2426        for currency in analyzer.currencies() {
2427            let expected = analyzer
2428                .get_performance_stats_pnls(Some(currency), None)
2429                .unwrap();
2430            let actual = snapshot.pnls.get(&currency.code.to_string()).unwrap();
2431            assert!(maps_equal_nan_aware(actual, &expected));
2432        }
2433    }
2434
2435    fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
2436        if a.len() != b.len() {
2437            return false;
2438        }
2439        a.iter().all(|(k, v)| {
2440            b.get(k)
2441                .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
2442        })
2443    }
2444
2445    #[rstest]
2446    fn test_get_performance_stats_returns_vs_benchmark() {
2447        let mut analyzer = PortfolioAnalyzer::new();
2448        analyzer.register_statistic(Arc::new(BetaRatio::new()));
2449        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
2450
2451        let one_day = 86_400_000_000_000_u64;
2452        let start = 1_600_000_000_000_000_000_u64;
2453        for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
2454            analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
2455        }
2456
2457        let mut benchmark: Returns = BTreeMap::new();
2458        for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
2459            benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
2460        }
2461
2462        let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
2463
2464        // r = [0.03, -0.01, 0.02, 0.04], b = [0.01, 0.005, 0.005, 0.01]:
2465        //   mean_r = 0.02, mean_b = 0.0075
2466        //   Cov = 1.5e-4 / 3 = 5e-5, Var(b) = 2.5e-5 / 3 -> beta = 6.0
2467        // Only the benchmark-relative statistic contributes; SharpeRatio
2468        // returns None from the default and is skipped.
2469        assert_eq!(stats.len(), 1);
2470        assert!(approx_eq!(
2471            f64,
2472            *stats.get("Beta").unwrap(),
2473            6.0,
2474            epsilon = 1e-9
2475        ));
2476        assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
2477    }
2478}