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