Skip to main content

nautilus_analysis/
analyzer.rs

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