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    identifiers::PositionId,
24    position::Position,
25    types::{Currency, Money},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30    Returns,
31    snapshot::PortfolioStatistics,
32    statistic::PortfolioStatistic,
33    statistics::{
34        expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
35        loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
36        returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
37        returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
38        sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
39        winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
40    },
41};
42
43pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
44
45/// Analyzes portfolio performance and calculates various statistics.
46///
47/// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
48/// to provide portfolio analysis including returns, PnL calculations,
49/// and customizable statistics.
50#[repr(C)]
51#[derive(Debug)]
52#[cfg_attr(
53    feature = "python",
54    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
55)]
56#[cfg_attr(
57    feature = "python",
58    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
59)]
60pub struct PortfolioAnalyzer {
61    pub statistics: AHashMap<String, Statistic>,
62    pub account_balances_starting: IndexMap<Currency, Money>,
63    pub account_balances: IndexMap<Currency, Money>,
64    pub positions: Vec<Position>,
65    pub realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
66    pub recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
67    pub position_returns: Returns,
68    pub portfolio_returns: Returns,
69    /// Alias for the primary returns source.
70    ///
71    /// Contains portfolio returns when available, otherwise position returns.
72    /// Kept as a public field for API stability; prefer the `returns()` accessor.
73    pub returns: Returns,
74}
75
76impl Default for PortfolioAnalyzer {
77    /// Creates a new default [`PortfolioAnalyzer`] instance.
78    fn default() -> Self {
79        let mut analyzer = Self::new();
80        analyzer.register_statistic(Arc::new(MaxWinner {}));
81        analyzer.register_statistic(Arc::new(AvgWinner {}));
82        analyzer.register_statistic(Arc::new(MinWinner {}));
83        analyzer.register_statistic(Arc::new(MinLoser {}));
84        analyzer.register_statistic(Arc::new(AvgLoser {}));
85        analyzer.register_statistic(Arc::new(MaxLoser {}));
86        analyzer.register_statistic(Arc::new(Expectancy {}));
87        analyzer.register_statistic(Arc::new(WinRate {}));
88        analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
89        analyzer.register_statistic(Arc::new(ReturnsAverage {}));
90        analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
91        analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
92        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
93        analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
94        analyzer.register_statistic(Arc::new(ProfitFactor {}));
95        analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
96        analyzer.register_statistic(Arc::new(LongRatio::new(None)));
97        analyzer
98    }
99}
100
101impl PortfolioAnalyzer {
102    /// Creates a new [`PortfolioAnalyzer`] instance.
103    ///
104    /// Starts with empty state.
105    #[must_use]
106    pub fn new() -> Self {
107        Self {
108            statistics: AHashMap::new(),
109            account_balances_starting: IndexMap::new(),
110            account_balances: IndexMap::new(),
111            positions: Vec::new(),
112            realized_pnls: AHashMap::new(),
113            recorded_realized_pnls: AHashMap::new(),
114            position_returns: BTreeMap::new(),
115            portfolio_returns: BTreeMap::new(),
116            returns: BTreeMap::new(),
117        }
118    }
119
120    /// Registers a new portfolio statistic for calculation.
121    pub fn register_statistic(&mut self, statistic: Statistic) {
122        self.statistics.insert(statistic.name(), statistic);
123    }
124
125    /// Removes a specific statistic from calculation.
126    pub fn deregister_statistic(&mut self, statistic: &Statistic) {
127        self.statistics.remove(&statistic.name());
128    }
129
130    /// Removes all registered statistics.
131    pub fn deregister_statistics(&mut self) {
132        self.statistics.clear();
133    }
134
135    /// Resets all analysis data to initial state.
136    pub fn reset(&mut self) {
137        self.account_balances_starting.clear();
138        self.account_balances.clear();
139        self.positions.clear();
140        self.realized_pnls.clear();
141        self.recorded_realized_pnls.clear();
142        self.position_returns.clear();
143        self.portfolio_returns.clear();
144        self.returns.clear();
145    }
146
147    /// Returns all tracked currencies.
148    #[must_use]
149    pub fn currencies(&self) -> Vec<&Currency> {
150        self.account_balances.keys().collect()
151    }
152
153    /// Retrieves a specific statistic by name.
154    #[must_use]
155    pub fn statistic(&self, name: &str) -> Option<&Statistic> {
156        self.statistics.get(name)
157    }
158
159    /// Returns the primary calculated returns.
160    ///
161    /// This returns portfolio returns when available, otherwise it falls back
162    /// to position returns for backward compatibility.
163    #[must_use]
164    pub const fn returns(&self) -> &Returns {
165        &self.returns
166    }
167
168    /// Returns the per-position calculated returns.
169    #[must_use]
170    pub const fn position_returns(&self) -> &Returns {
171        &self.position_returns
172    }
173
174    /// Returns the portfolio calculated returns.
175    #[must_use]
176    pub const fn portfolio_returns(&self) -> &Returns {
177        &self.portfolio_returns
178    }
179
180    /// Calculates statistics based on account and position data.
181    ///
182    /// This clears calculated state before calculating, while preserving
183    /// close-time PnLs recorded during portfolio processing.
184    pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
185        self.account_balances_starting = account.starting_balances().into_iter().collect();
186        self.account_balances = account.balances_total().into_iter().collect();
187        self.positions.clear();
188        self.realized_pnls.clear();
189        self.position_returns.clear();
190        self.portfolio_returns.clear();
191        self.returns.clear();
192
193        self.add_positions(positions);
194
195        if let Some(account_returns) = Self::calculate_account_returns(account) {
196            self.portfolio_returns = account_returns;
197            self.sync_returns_alias();
198        }
199    }
200
201    /// Builds a populated analyzer from venue accounts and positions.
202    ///
203    /// Aggregates starting and total balances across all `accounts`, adds `positions` and
204    /// `snapshots`, and seeds `recorded_realized_pnls` (close-time PnLs observed during the run).
205    #[must_use]
206    pub fn from_accounts(
207        accounts: &[AccountAny],
208        positions: &[Position],
209        snapshots: &[Position],
210        recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
211    ) -> Self {
212        let mut analyzer = Self::default();
213
214        for account in accounts {
215            let account_ref: &dyn Account = match account {
216                AccountAny::Margin(margin) => margin,
217                AccountAny::Cash(cash) => cash,
218                AccountAny::Betting(betting) => betting,
219            };
220
221            for (currency, money) in account_ref.starting_balances() {
222                analyzer
223                    .account_balances_starting
224                    .entry(currency)
225                    .and_modify(|existing| *existing = *existing + money)
226                    .or_insert(money);
227            }
228
229            for (currency, money) in account_ref.balances_total() {
230                analyzer
231                    .account_balances
232                    .entry(currency)
233                    .and_modify(|existing| *existing = *existing + money)
234                    .or_insert(money);
235            }
236        }
237
238        analyzer.add_positions(positions);
239        analyzer.add_positions(snapshots);
240        analyzer.recorded_realized_pnls = recorded_realized_pnls;
241        analyzer
242    }
243
244    /// Collects an owned [`PortfolioStatistics`] snapshot from the current analyzer state.
245    #[must_use]
246    pub fn statistics(&self) -> PortfolioStatistics {
247        let mut pnls = AHashMap::new();
248
249        for currency in self.currencies() {
250            if let Ok(stats) = self.get_performance_stats_pnls(Some(currency), None) {
251                pnls.insert(currency.code.to_string(), stats);
252            }
253        }
254        PortfolioStatistics {
255            pnls,
256            returns: self.get_performance_stats_returns(),
257            general: self.get_performance_stats_general(),
258        }
259    }
260
261    /// Adds new positions for analysis.
262    pub fn add_positions(&mut self, positions: &[Position]) {
263        self.positions.extend_from_slice(positions);
264        for position in positions {
265            if let Some(ref pnl) = position.realized_pnl {
266                self.add_trade(&position.id, position.ts_last, pnl);
267            }
268
269            if let Some(ts_closed) = position.ts_closed
270                && ts_closed.as_u64() > 0
271                && position.realized_pnl.is_some()
272            {
273                self.add_position_return(ts_closed, position.realized_return);
274            }
275        }
276    }
277
278    /// Records a trade's PnL realized at `ts_event`.
279    pub fn add_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
280        let currency = pnl.currency;
281        let entry = self.realized_pnls.entry(currency).or_default();
282        entry.push((*position_id, ts_event, pnl.as_f64()));
283    }
284
285    /// Records a trade's PnL realized at `ts_event`, observed during portfolio processing.
286    pub fn record_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
287        let currency = pnl.currency;
288        let entry = self.recorded_realized_pnls.entry(currency).or_default();
289        entry.push((*position_id, ts_event, pnl.as_f64()));
290    }
291
292    /// Records a position return at a specific timestamp.
293    pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
294        self.position_returns
295            .entry(timestamp)
296            .and_modify(|existing_value| *existing_value += value)
297            .or_insert(value);
298
299        // Mirror writes into the `returns` alias when no portfolio returns exist.
300        // This avoids calling `sync_returns_alias` (which clones the full map)
301        // on every insert.
302        if self.portfolio_returns.is_empty() {
303            self.returns
304                .entry(timestamp)
305                .and_modify(|existing_value| *existing_value += value)
306                .or_insert(value);
307        }
308    }
309
310    /// Records a return at a specific timestamp.
311    ///
312    /// This is a backward-compatible alias for [`Self::add_position_return`].
313    pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
314        self.add_position_return(timestamp, value);
315    }
316
317    /// Computes daily portfolio returns from account balance snapshots.
318    ///
319    /// Returns `None` (falling back to per-position returns) when:
320    /// - Fewer than two account state events exist.
321    /// - Any event carries multiple balance currencies.
322    /// - The balance currency changes between events.
323    /// - Fewer than two distinct calendar days have balance data.
324    ///
325    /// Multi-currency accounts are not yet supported; the caller silently
326    /// receives per-position returns in that case.
327    fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
328        let mut events = account.events();
329        if events.len() < 2 {
330            return None;
331        }
332
333        events.sort_by_key(|event| event.ts_event);
334
335        let mut currency = None;
336        let mut daily_balances = BTreeMap::new();
337
338        for event in events {
339            if event.balances.len() != 1 {
340                return None;
341            }
342
343            let balance = event.balances[0];
344
345            if let Some(existing_currency) = currency {
346                if existing_currency != balance.currency {
347                    return None;
348                }
349            } else {
350                currency = Some(balance.currency);
351            }
352
353            let day_start = UnixNanos::from(
354                event.ts_event.as_u64() - (event.ts_event.as_u64() % NANOSECONDS_IN_DAY),
355            );
356            daily_balances.insert(day_start, balance.total.as_f64());
357        }
358
359        if daily_balances.len() < 2 {
360            return None;
361        }
362
363        let mut returns = Returns::new();
364        let mut current_day = *daily_balances.keys().next()?;
365        let last_day = *daily_balances.keys().next_back()?;
366        let mut current_balance: Option<f64> = None;
367        let mut previous_balance: Option<f64> = None;
368
369        loop {
370            if let Some(balance) = daily_balances.get(&current_day) {
371                current_balance = Some(*balance);
372            }
373
374            let balance = current_balance?;
375
376            if let Some(previous) = previous_balance
377                && previous != 0.0
378            {
379                let value: f64 = (balance / previous) - 1.0;
380                if value.is_finite() {
381                    returns.insert(current_day, value);
382                }
383            }
384
385            previous_balance = Some(balance);
386
387            if current_day >= last_day {
388                break;
389            }
390
391            current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
392        }
393
394        (!returns.is_empty()).then_some(returns)
395    }
396
397    /// Retrieves trade PnL records for a specific currency.
398    ///
399    /// Each record is `(position_id, ts_event, realized_pnl)`, where `ts_event` is the
400    /// position's last event time (the close time for closed cycles). Duplicate position
401    /// IDs are preserved for NETTING position cycles.
402    ///
403    /// Native PnLs (derived from analyzed positions) and PnLs recorded live during
404    /// portfolio processing are merged per cycle: a native record is excluded only when a
405    /// recorded record shares its `(position_id, ts_event)`. Recorded values therefore take
406    /// precedence for the cycles they cover, while native cycles that were never recorded
407    /// are retained rather than dropped by position ID.
408    ///
409    /// Returns `None` if no PnLs exist, or if multiple currencies exist
410    /// without an explicit currency specified.
411    #[must_use]
412    pub fn trade_pnl_records(
413        &self,
414        currency: Option<&Currency>,
415    ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
416        if self.realized_pnls.is_empty() && self.recorded_realized_pnls.is_empty() {
417            return None;
418        }
419
420        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
421        let currency = match currency {
422            Some(c) => *c,
423            None if self.account_balances.len() == 1 => *self.account_balances.keys().next()?,
424            None => {
425                let mut currencies: IndexSet<Currency> =
426                    self.realized_pnls.keys().copied().collect();
427                currencies.extend(self.recorded_realized_pnls.keys().copied());
428                if currencies.len() != 1 {
429                    return None;
430                }
431
432                *currencies.first()?
433            }
434        };
435
436        let realized_pnls = self.realized_pnls.get(&currency);
437        let recorded_realized_pnls = self.recorded_realized_pnls.get(&currency);
438
439        match (realized_pnls, recorded_realized_pnls) {
440            (None, None) => None,
441            (Some(realized_pnls), None) => Some(realized_pnls.clone()),
442            (None, Some(recorded_realized_pnls)) => Some(recorded_realized_pnls.clone()),
443            (Some(realized_pnls), Some(recorded_realized_pnls)) => {
444                let recorded_keys: IndexSet<(PositionId, UnixNanos)> = recorded_realized_pnls
445                    .iter()
446                    .map(|(position_id, ts_event, _)| {
447                        (canonical_position_id(*position_id), *ts_event)
448                    })
449                    .collect();
450                let mut output: Vec<(PositionId, UnixNanos, f64)> = realized_pnls
451                    .iter()
452                    .copied()
453                    .filter(|(position_id, ts_event, _)| {
454                        let key = (canonical_position_id(*position_id), *ts_event);
455                        !recorded_keys.contains(&key)
456                    })
457                    .collect();
458                output.extend(recorded_realized_pnls.iter().copied());
459
460                Some(output)
461            }
462        }
463    }
464
465    /// Retrieves realized PnLs for a specific currency.
466    ///
467    /// Each record is `(position_id, ts_event, realized_pnl)`. Returns `None` if no PnLs
468    /// exist, or if multiple currencies exist without an explicit currency specified.
469    #[must_use]
470    pub fn realized_pnls(
471        &self,
472        currency: Option<&Currency>,
473    ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
474        self.trade_pnl_records(currency)
475    }
476
477    /// Calculates total PnL including unrealized PnL if provided.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if:
482    /// - No currency is specified in a multi-currency portfolio.
483    /// - The specified currency is not found in account balances.
484    /// - The unrealized PnL currency does not match the specified currency.
485    #[expect(clippy::missing_panics_doc)] // Guarded by length check
486    pub fn total_pnl(
487        &self,
488        currency: Option<&Currency>,
489        unrealized_pnl: Option<&Money>,
490    ) -> Result<f64, &'static str> {
491        if self.account_balances.is_empty() {
492            return Ok(0.0);
493        }
494
495        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
496        let currency = match currency {
497            Some(c) => c,
498            None if self.account_balances.len() == 1 => {
499                self.account_balances.keys().next().expect("len is 1")
500            }
501            None => return Err("Currency must be specified for multi-currency portfolio"),
502        };
503
504        if let Some(unrealized_pnl) = unrealized_pnl
505            && unrealized_pnl.currency != *currency
506        {
507            return Err("Unrealized PnL currency does not match specified currency");
508        }
509
510        let account_balance = self
511            .account_balances
512            .get(currency)
513            .ok_or("Specified currency not found in account balances")?;
514
515        let default_money = &Money::zero(*currency);
516        let account_balance_starting = self
517            .account_balances_starting
518            .get(currency)
519            .unwrap_or(default_money);
520
521        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
522        Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
523    }
524
525    /// Calculates total PnL as a percentage of starting balance.
526    ///
527    /// # Errors
528    ///
529    /// Returns an error if:
530    /// - No currency is specified in a multi-currency portfolio.
531    /// - The specified currency is not found in account balances.
532    /// - The unrealized PnL currency does not match the specified currency.
533    #[expect(clippy::missing_panics_doc)] // Guarded by length check
534    pub fn total_pnl_percentage(
535        &self,
536        currency: Option<&Currency>,
537        unrealized_pnl: Option<&Money>,
538    ) -> Result<f64, &'static str> {
539        if self.account_balances.is_empty() {
540            return Ok(0.0);
541        }
542
543        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
544        let currency = match currency {
545            Some(c) => c,
546            None if self.account_balances.len() == 1 => {
547                self.account_balances.keys().next().expect("len is 1")
548            }
549            None => return Err("Currency must be specified for multi-currency portfolio"),
550        };
551
552        if let Some(unrealized_pnl) = unrealized_pnl
553            && unrealized_pnl.currency != *currency
554        {
555            return Err("Unrealized PnL currency does not match specified currency");
556        }
557
558        let account_balance = self
559            .account_balances
560            .get(currency)
561            .ok_or("Specified currency not found in account balances")?;
562
563        let default_money = &Money::zero(*currency);
564        let account_balance_starting = self
565            .account_balances_starting
566            .get(currency)
567            .unwrap_or(default_money);
568
569        if account_balance_starting.as_decimal() == Decimal::ZERO {
570            return Ok(0.0);
571        }
572
573        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
574        let current = account_balance.as_f64() + unrealized_pnl_f64;
575        let starting = account_balance_starting.as_f64();
576        let difference = current - starting;
577
578        Ok((difference / starting) * 100.0)
579    }
580
581    /// Gets all PnL-related performance statistics.
582    ///
583    /// # Errors
584    ///
585    /// Returns an error if PnL calculations fail, for example due to:
586    ///
587    /// - No currency specified for a multi-currency portfolio.
588    /// - Unrealized PnL currency not matching the specified currency.
589    /// - Specified currency not found in account balances.
590    pub fn get_performance_stats_pnls(
591        &self,
592        currency: Option<&Currency>,
593        unrealized_pnl: Option<&Money>,
594    ) -> Result<AHashMap<String, f64>, &'static str> {
595        let mut output = AHashMap::new();
596
597        output.insert(
598            "PnL (total)".to_string(),
599            self.total_pnl(currency, unrealized_pnl)?,
600        );
601        output.insert(
602            "PnL% (total)".to_string(),
603            self.total_pnl_percentage(currency, unrealized_pnl)?,
604        );
605
606        if let Some(trade_pnl_records) = self.trade_pnl_records(currency) {
607            for (name, stat) in &self.statistics {
608                if let Some(value) = stat.calculate_from_realized_pnls(
609                    &trade_pnl_records
610                        .iter()
611                        .map(|(_, _, pnl)| *pnl)
612                        .collect::<Vec<f64>>(),
613                ) {
614                    output.insert(name.clone(), value);
615                }
616            }
617        }
618
619        Ok(output)
620    }
621
622    /// Gets all return-based performance statistics.
623    #[must_use]
624    pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
625        self.calculate_returns_stats(self.returns())
626    }
627
628    /// Gets all position-return-based performance statistics.
629    #[must_use]
630    pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
631        self.calculate_returns_stats(self.position_returns())
632    }
633
634    /// Gets all portfolio-return-based performance statistics.
635    #[must_use]
636    pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
637        self.calculate_returns_stats(self.portfolio_returns())
638    }
639
640    /// Gets all benchmark-relative return statistics for the primary returns.
641    ///
642    /// This is stateless: the `benchmark` series is supplied by the caller rather
643    /// than stored on the analyzer. Only statistics that override
644    /// [`PortfolioStatistic::calculate_from_returns_with_benchmark`] (the benchmark-relative
645    /// statistics) contribute values; all others return `None` and are skipped.
646    #[must_use]
647    pub fn get_performance_stats_returns_vs_benchmark(
648        &self,
649        benchmark: &Returns,
650    ) -> AHashMap<String, f64> {
651        let mut output = AHashMap::new();
652
653        for (name, stat) in &self.statistics {
654            if let Some(value) =
655                stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
656            {
657                output.insert(name.clone(), value);
658            }
659        }
660
661        output
662    }
663
664    /// Gets general portfolio statistics.
665    #[must_use]
666    pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
667        let mut output = AHashMap::new();
668
669        for (name, stat) in &self.statistics {
670            if let Some(value) = stat.calculate_from_positions(&self.positions) {
671                output.insert(name.clone(), value);
672            }
673        }
674
675        output
676    }
677
678    /// Calculates the maximum length of statistic names for formatting.
679    fn get_max_length_name(&self) -> usize {
680        self.statistics.keys().map(String::len).max().unwrap_or(0)
681    }
682
683    fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
684        let mut output = AHashMap::new();
685
686        for (name, stat) in &self.statistics {
687            if let Some(value) = stat.calculate_from_returns(returns) {
688                output.insert(name.clone(), value);
689            }
690        }
691
692        output
693    }
694
695    fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
696        let max_length = self.get_max_length_name();
697        let mut entries: Vec<_> = stats.into_iter().collect();
698        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
699
700        let mut output = Vec::new();
701
702        for (k, v) in entries {
703            let padding = max_length.saturating_sub(k.len()) + 1;
704            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
705        }
706
707        output
708    }
709
710    fn sync_returns_alias(&mut self) {
711        if self.portfolio_returns.is_empty() {
712            self.returns = self.position_returns.clone();
713            return;
714        }
715
716        self.returns = self.portfolio_returns.clone();
717    }
718
719    /// Gets formatted PnL statistics as strings.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if PnL statistics calculation fails.
724    pub fn get_stats_pnls_formatted(
725        &self,
726        currency: Option<&Currency>,
727        unrealized_pnl: Option<&Money>,
728    ) -> Result<Vec<String>, String> {
729        let max_length = self.get_max_length_name();
730        let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
731
732        let mut entries: Vec<_> = stats.into_iter().collect();
733        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
734
735        let mut output = Vec::new();
736
737        for (k, v) in entries {
738            let padding = if max_length > k.len() {
739                max_length - k.len() + 1
740            } else {
741                1
742            };
743            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
744        }
745
746        Ok(output)
747    }
748
749    /// Gets formatted return statistics as strings.
750    #[must_use]
751    pub fn get_stats_returns_formatted(&self) -> Vec<String> {
752        self.format_returns_stats(self.get_performance_stats_returns())
753    }
754
755    /// Gets formatted position-return statistics as strings.
756    #[must_use]
757    pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
758        self.format_returns_stats(self.get_performance_stats_position_returns())
759    }
760
761    /// Gets formatted portfolio-return statistics as strings.
762    #[must_use]
763    pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
764        self.format_returns_stats(self.get_performance_stats_portfolio_returns())
765    }
766
767    /// Gets formatted general statistics as strings.
768    #[must_use]
769    pub fn get_stats_general_formatted(&self) -> Vec<String> {
770        let max_length = self.get_max_length_name();
771        let stats = self.get_performance_stats_general();
772
773        let mut entries: Vec<_> = stats.into_iter().collect();
774        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
775
776        let mut output = Vec::new();
777
778        for (k, v) in entries {
779            let padding = max_length - k.len() + 1;
780            output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
781        }
782
783        output
784    }
785}
786
787fn canonical_position_id(position_id: PositionId) -> PositionId {
788    const UUID4_STRING_LEN: usize = 36;
789
790    let value = position_id.as_str();
791    let Some(separator_index) = value.len().checked_sub(UUID4_STRING_LEN + 1) else {
792        return position_id;
793    };
794
795    if separator_index == 0 || value.as_bytes()[separator_index] != b'-' {
796        return position_id;
797    }
798
799    let suffix = &value[separator_index + 1..];
800    if suffix.parse::<UUID4>().is_ok() {
801        PositionId::new(&value[..separator_index])
802    } else {
803        position_id
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use std::sync::Arc;
810
811    use ahash::{AHashMap, AHashSet};
812    use indexmap::IndexMap;
813    use nautilus_core::{UUID4, approx_eq};
814    use nautilus_model::{
815        accounts::{AccountAny, CashAccount},
816        enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
817        events::{AccountState, OrderFilled},
818        identifiers::{
819            AccountId, ClientOrderId,
820            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
821        },
822        instruments::InstrumentAny,
823        stubs::TestDefault,
824        types::{AccountBalance, Money, Price, Quantity},
825    };
826    use rstest::rstest;
827
828    use super::*;
829    use crate::statistics::beta_ratio::BetaRatio;
830
831    /// Mock implementation of `PortfolioStatistic` for testing.
832    #[derive(Debug)]
833    struct MockStatistic {
834        name: String,
835    }
836
837    impl MockStatistic {
838        fn new(name: &str) -> Self {
839            Self {
840                name: name.to_string(),
841            }
842        }
843    }
844
845    impl PortfolioStatistic for MockStatistic {
846        type Item = f64;
847
848        fn name(&self) -> String {
849            self.name.clone()
850        }
851
852        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
853            Some(pnls.iter().sum())
854        }
855
856        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
857            Some(returns.values().sum())
858        }
859
860        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
861            Some(positions.len() as f64)
862        }
863    }
864
865    fn create_mock_position(
866        id: &str,
867        realized_pnl: f64,
868        realized_return: f64,
869        currency: Currency,
870    ) -> Position {
871        Position {
872            events: Vec::new(),
873            adjustments: Vec::new(),
874            trader_id: trader_id(),
875            strategy_id: strategy_id_ema_cross(),
876            instrument_id: instrument_id_aud_usd_sim(),
877            id: PositionId::new(id),
878            account_id: AccountId::new("test-account"),
879            opening_order_id: ClientOrderId::test_default(),
880            closing_order_id: None,
881            entry: OrderSide::NoOrderSide,
882            side: PositionSide::NoPositionSide,
883            signed_qty: 0.0,
884            quantity: Quantity::default(),
885            peak_qty: Quantity::default(),
886            price_precision: 2,
887            size_precision: 2,
888            multiplier: Quantity::default(),
889            is_inverse: false,
890            is_currency_pair: true,
891            instrument_class: InstrumentClass::Spot,
892            base_currency: None,
893            quote_currency: Currency::USD(),
894            settlement_currency: Currency::USD(),
895            ts_init: UnixNanos::default(),
896            ts_opened: UnixNanos::default(),
897            ts_last: UnixNanos::default(),
898            ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
899            duration_ns: 2,
900            avg_px_open: 0.0,
901            avg_px_close: None,
902            realized_return,
903            realized_pnl: Some(Money::new(realized_pnl, currency)),
904            trade_ids: AHashSet::new(),
905            buy_qty: Quantity::default(),
906            sell_qty: Quantity::default(),
907            commissions: IndexMap::new(),
908        }
909    }
910
911    struct MockAccount {
912        starting_balances: AHashMap<Currency, Money>,
913        current_balances: AHashMap<Currency, Money>,
914        events: Vec<AccountState>,
915    }
916
917    impl Account for MockAccount {
918        fn starting_balances(&self) -> IndexMap<Currency, Money> {
919            self.starting_balances.clone().into_iter().collect()
920        }
921        fn balances_total(&self) -> IndexMap<Currency, Money> {
922            self.current_balances.clone().into_iter().collect()
923        }
924        fn id(&self) -> AccountId {
925            todo!()
926        }
927        fn account_type(&self) -> AccountType {
928            todo!()
929        }
930        fn base_currency(&self) -> Option<Currency> {
931            todo!()
932        }
933        fn is_cash_account(&self) -> bool {
934            todo!()
935        }
936        fn is_margin_account(&self) -> bool {
937            todo!()
938        }
939        fn calculated_account_state(&self) -> bool {
940            todo!()
941        }
942        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
943            todo!()
944        }
945        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
946            todo!()
947        }
948        fn balances_free(&self) -> IndexMap<Currency, Money> {
949            todo!()
950        }
951        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
952            todo!()
953        }
954        fn balances_locked(&self) -> IndexMap<Currency, Money> {
955            todo!()
956        }
957        fn last_event(&self) -> Option<AccountState> {
958            self.events.last().cloned()
959        }
960        fn events(&self) -> Vec<AccountState> {
961            self.events.clone()
962        }
963        fn event_count(&self) -> usize {
964            self.events.len()
965        }
966        fn currencies(&self) -> Vec<Currency> {
967            self.current_balances.keys().copied().collect()
968        }
969        fn balances(&self) -> IndexMap<Currency, AccountBalance> {
970            todo!()
971        }
972        fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
973            todo!()
974        }
975        fn calculate_balance_locked(
976            &mut self,
977            _: &InstrumentAny,
978            _: OrderSide,
979            _: Quantity,
980            _: Price,
981            _: Option<bool>,
982        ) -> Result<Money, anyhow::Error> {
983            todo!()
984        }
985        fn calculate_pnls(
986            &self,
987            _: &InstrumentAny,
988            _: &OrderFilled,
989            _: Option<Position>,
990        ) -> Result<Vec<Money>, anyhow::Error> {
991            todo!()
992        }
993        fn calculate_commission(
994            &self,
995            _: &InstrumentAny,
996            _: Quantity,
997            _: Price,
998            _: LiquiditySide,
999            _: Option<bool>,
1000        ) -> Result<Money, anyhow::Error> {
1001            todo!()
1002        }
1003
1004        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1005            todo!()
1006        }
1007
1008        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1009            // MockAccount doesn't need purging
1010        }
1011    }
1012
1013    fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1014        AccountState::new(
1015            AccountId::new("test-account"),
1016            AccountType::Cash,
1017            vec![AccountBalance::new(
1018                Money::new(total, currency),
1019                Money::new(0.0, currency),
1020                Money::new(total, currency),
1021            )],
1022            vec![],
1023            true,
1024            UUID4::new(),
1025            UnixNanos::from(ts_event),
1026            UnixNanos::from(ts_event),
1027            Some(currency),
1028        )
1029    }
1030
1031    #[rstest]
1032    fn test_register_and_deregister_statistics() {
1033        let mut analyzer = PortfolioAnalyzer::new();
1034        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1035            Arc::new(MockStatistic::new("test_stat"));
1036
1037        // Test registration
1038        analyzer.register_statistic(Arc::clone(&stat));
1039        assert!(analyzer.statistic("test_stat").is_some());
1040
1041        // Test deregistration
1042        analyzer.deregister_statistic(&stat);
1043        assert!(analyzer.statistic("test_stat").is_none());
1044
1045        // Test deregister all
1046        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1047            Arc::new(MockStatistic::new("stat1"));
1048        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1049            Arc::new(MockStatistic::new("stat2"));
1050        analyzer.register_statistic(Arc::clone(&stat1));
1051        analyzer.register_statistic(Arc::clone(&stat2));
1052        analyzer.deregister_statistics();
1053        assert!(analyzer.statistics.is_empty());
1054    }
1055
1056    #[rstest]
1057    fn test_calculate_total_pnl() {
1058        let mut analyzer = PortfolioAnalyzer::new();
1059        let currency = Currency::USD();
1060
1061        // Set up mock account data
1062        let mut starting_balances = AHashMap::new();
1063        starting_balances.insert(currency, Money::new(1000.0, currency));
1064
1065        let mut current_balances = AHashMap::new();
1066        current_balances.insert(currency, Money::new(1500.0, currency));
1067
1068        let account = MockAccount {
1069            starting_balances,
1070            current_balances,
1071            events: vec![],
1072        };
1073
1074        analyzer.calculate_statistics(&account, &[]);
1075
1076        // Test total PnL calculation
1077        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
1078        assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1079
1080        // Test with unrealized PnL
1081        let unrealized_pnl = Money::new(100.0, currency);
1082        let result = analyzer
1083            .total_pnl(Some(&currency), Some(&unrealized_pnl))
1084            .unwrap();
1085        assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1086    }
1087
1088    #[rstest]
1089    fn test_calculate_total_pnl_percentage() {
1090        let mut analyzer = PortfolioAnalyzer::new();
1091        let currency = Currency::USD();
1092
1093        // Set up mock account data
1094        let mut starting_balances = AHashMap::new();
1095        starting_balances.insert(currency, Money::new(1000.0, currency));
1096
1097        let mut current_balances = AHashMap::new();
1098        current_balances.insert(currency, Money::new(1500.0, currency));
1099
1100        let account = MockAccount {
1101            starting_balances,
1102            current_balances,
1103            events: vec![],
1104        };
1105
1106        analyzer.calculate_statistics(&account, &[]);
1107
1108        // Test percentage calculation
1109        let result = analyzer
1110            .total_pnl_percentage(Some(&currency), None)
1111            .unwrap();
1112        assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); // (1500 - 1000) / 1000 * 100
1113
1114        // Test with unrealized PnL
1115        let unrealized_pnl = Money::new(500.0, currency);
1116        let result = analyzer
1117            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
1118            .unwrap();
1119        assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); // (2000 - 1000) / 1000 * 100
1120    }
1121
1122    #[rstest]
1123    fn test_add_positions_and_returns() {
1124        let mut analyzer = PortfolioAnalyzer::new();
1125        let currency = Currency::USD();
1126
1127        let positions = vec![
1128            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1129            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1130        ];
1131
1132        analyzer.add_positions(&positions);
1133
1134        // Verify realized PnLs were recorded
1135        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
1136        assert_eq!(pnls.len(), 2);
1137        assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1138        assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1139
1140        // Verify returns were recorded
1141        let returns = analyzer.returns();
1142        let position_returns = analyzer.position_returns();
1143        assert_eq!(returns.len(), 1);
1144        assert_eq!(position_returns.len(), 1);
1145        assert!(analyzer.portfolio_returns().is_empty());
1146        assert!(approx_eq!(
1147            f64,
1148            *returns.values().next().unwrap(),
1149            0.30000000000000004,
1150            epsilon = 1e-9
1151        ));
1152        assert!(approx_eq!(
1153            f64,
1154            *position_returns.values().next().unwrap(),
1155            0.30000000000000004,
1156            epsilon = 1e-9
1157        ));
1158    }
1159
1160    #[rstest]
1161    fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1162        let mut analyzer = PortfolioAnalyzer::new();
1163        let currency = Currency::USD();
1164        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1165        position.ts_closed = Some(UnixNanos::default());
1166
1167        analyzer.add_positions(&[position]);
1168
1169        assert!(analyzer.position_returns().is_empty());
1170        assert!(analyzer.returns().is_empty());
1171    }
1172
1173    #[rstest]
1174    fn test_add_positions_records_open_position_realized_pnl() {
1175        let mut analyzer = PortfolioAnalyzer::new();
1176        let currency = Currency::USD();
1177        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1178        position.ts_closed = None;
1179        // Distinct from ts_opened (default 0) so the record is keyed by the last event time.
1180        position.ts_last = UnixNanos::from(7);
1181        let position_id = position.id;
1182
1183        analyzer.add_positions(&[position]);
1184
1185        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1186        assert_eq!(records.len(), 1);
1187        assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1188        assert!(analyzer.position_returns().is_empty());
1189    }
1190
1191    #[rstest]
1192    fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1193        // A NETTING id with two native cycles where only the later cycle was recorded:
1194        // the earlier native cycle must survive rather than be dropped by position ID.
1195        let mut analyzer = PortfolioAnalyzer::new();
1196        let currency = Currency::USD();
1197        let position_id = PositionId::new("pos1");
1198
1199        analyzer.add_trade(
1200            &position_id,
1201            UnixNanos::from(1),
1202            &Money::new(10.0, currency),
1203        );
1204        analyzer.add_trade(
1205            &position_id,
1206            UnixNanos::from(2),
1207            &Money::new(20.0, currency),
1208        );
1209        analyzer.record_trade(
1210            &position_id,
1211            UnixNanos::from(2),
1212            &Money::new(25.0, currency),
1213        );
1214
1215        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1216
1217        assert_eq!(
1218            records,
1219            vec![
1220                (position_id, UnixNanos::from(1), 10.0),
1221                (position_id, UnixNanos::from(2), 25.0),
1222            ]
1223        );
1224    }
1225
1226    #[rstest]
1227    fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1228        let mut analyzer = PortfolioAnalyzer::new();
1229        let currency = Currency::USD();
1230        let position_id = PositionId::new("pos1");
1231        let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1232        let ts_event = UnixNanos::from(1);
1233
1234        analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1235        analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1236
1237        let records = analyzer.trade_pnl_records(Some(&currency)).unwrap();
1238
1239        assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1240    }
1241
1242    #[rstest]
1243    fn test_performance_stats_calculation() {
1244        let mut analyzer = PortfolioAnalyzer::new();
1245        let currency = Currency::USD();
1246        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1247            Arc::new(MockStatistic::new("test_stat"));
1248        analyzer.register_statistic(Arc::clone(&stat));
1249
1250        // Add some positions
1251        let positions = vec![
1252            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1253            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1254        ];
1255
1256        let mut starting_balances = AHashMap::new();
1257        starting_balances.insert(currency, Money::new(1000.0, currency));
1258
1259        let mut current_balances = AHashMap::new();
1260        current_balances.insert(currency, Money::new(1500.0, currency));
1261
1262        let account = MockAccount {
1263            starting_balances,
1264            current_balances,
1265            events: vec![],
1266        };
1267
1268        analyzer.calculate_statistics(&account, &positions);
1269
1270        // Test PnL stats
1271        let pnl_stats = analyzer
1272            .get_performance_stats_pnls(Some(&currency), None)
1273            .unwrap();
1274        assert!(pnl_stats.contains_key("PnL (total)"));
1275        assert!(pnl_stats.contains_key("PnL% (total)"));
1276        assert!(pnl_stats.contains_key("test_stat"));
1277
1278        // Test returns stats
1279        let return_stats = analyzer.get_performance_stats_returns();
1280        assert!(return_stats.contains_key("test_stat"));
1281
1282        // Test general stats
1283        let general_stats = analyzer.get_performance_stats_general();
1284        assert!(general_stats.contains_key("test_stat"));
1285    }
1286
1287    #[rstest]
1288    fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1289        let mut analyzer = PortfolioAnalyzer::new();
1290        let account_currency = Currency::EUR();
1291        let native_currency = Currency::USD();
1292        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1293            Arc::new(MockStatistic::new("test_stat"));
1294        analyzer.register_statistic(Arc::clone(&stat));
1295        analyzer.record_trade(
1296            &PositionId::new("pos1"),
1297            UnixNanos::from(1),
1298            &Money::new(90.0, account_currency),
1299        );
1300
1301        let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1302
1303        let mut starting_balances = AHashMap::new();
1304        starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1305
1306        let mut current_balances = AHashMap::new();
1307        current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1308
1309        let account = MockAccount {
1310            starting_balances,
1311            current_balances,
1312            events: vec![],
1313        };
1314
1315        analyzer.calculate_statistics(&account, &positions);
1316
1317        let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1318        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1319        let pnl_stats = analyzer
1320            .get_performance_stats_pnls(Some(&account_currency), None)
1321            .unwrap();
1322
1323        assert_eq!(native_pnls[0].2, 100.0);
1324        assert_eq!(recorded_pnls[0].2, 90.0);
1325        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1326    }
1327
1328    #[rstest]
1329    fn test_record_trade_preserves_duplicate_position_ids() {
1330        let mut analyzer = PortfolioAnalyzer::new();
1331        let account_currency = Currency::EUR();
1332        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1333            Arc::new(MockStatistic::new("test_stat"));
1334        let position_id = PositionId::new("pos1");
1335
1336        analyzer.register_statistic(Arc::clone(&stat));
1337        analyzer.record_trade(
1338            &position_id,
1339            UnixNanos::from(1),
1340            &Money::new(90.0, account_currency),
1341        );
1342        analyzer.record_trade(
1343            &position_id,
1344            UnixNanos::from(2),
1345            &Money::new(-45.0, account_currency),
1346        );
1347
1348        let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1349        let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1350        let pnl_stats = analyzer
1351            .get_performance_stats_pnls(Some(&account_currency), None)
1352            .unwrap();
1353
1354        assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1355        assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1356        assert_eq!(
1357            recorded_pnls,
1358            vec![
1359                (position_id, UnixNanos::from(1), 90.0),
1360                (position_id, UnixNanos::from(2), -45.0),
1361            ]
1362        );
1363        assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
1364    }
1365    #[rstest]
1366    fn test_formatted_output() {
1367        let mut analyzer = PortfolioAnalyzer::new();
1368        let currency = Currency::USD();
1369        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1370            Arc::new(MockStatistic::new("test_stat"));
1371        analyzer.register_statistic(Arc::clone(&stat));
1372
1373        let positions = vec![
1374            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1375            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1376        ];
1377
1378        let mut starting_balances = AHashMap::new();
1379        starting_balances.insert(currency, Money::new(1000.0, currency));
1380
1381        let mut current_balances = AHashMap::new();
1382        current_balances.insert(currency, Money::new(1500.0, currency));
1383
1384        let account = MockAccount {
1385            starting_balances,
1386            current_balances,
1387            events: vec![],
1388        };
1389
1390        analyzer.calculate_statistics(&account, &positions);
1391
1392        // Test formatted outputs
1393        let pnl_formatted = analyzer
1394            .get_stats_pnls_formatted(Some(&currency), None)
1395            .unwrap();
1396        assert!(!pnl_formatted.is_empty());
1397        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
1398
1399        let returns_formatted = analyzer.get_stats_returns_formatted();
1400        assert!(!returns_formatted.is_empty());
1401        assert!(returns_formatted.iter().all(|s| s.contains(':')));
1402
1403        let general_formatted = analyzer.get_stats_general_formatted();
1404        assert!(!general_formatted.is_empty());
1405        assert!(general_formatted.iter().all(|s| s.contains(':')));
1406    }
1407
1408    #[rstest]
1409    fn test_reset() {
1410        let mut analyzer = PortfolioAnalyzer::new();
1411        let currency = Currency::USD();
1412
1413        let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
1414        let mut starting_balances = AHashMap::new();
1415        starting_balances.insert(currency, Money::new(1000.0, currency));
1416        let mut current_balances = AHashMap::new();
1417        current_balances.insert(currency, Money::new(1500.0, currency));
1418
1419        let account = MockAccount {
1420            starting_balances,
1421            current_balances,
1422            events: vec![],
1423        };
1424
1425        analyzer.calculate_statistics(&account, &positions);
1426
1427        analyzer.reset();
1428
1429        assert!(analyzer.account_balances_starting.is_empty());
1430        assert!(analyzer.account_balances.is_empty());
1431        assert!(analyzer.positions.is_empty());
1432        assert!(analyzer.realized_pnls.is_empty());
1433        assert!(analyzer.recorded_realized_pnls.is_empty());
1434        assert!(analyzer.position_returns.is_empty());
1435        assert!(analyzer.portfolio_returns.is_empty());
1436        assert!(analyzer.returns.is_empty());
1437    }
1438
1439    #[rstest]
1440    fn test_currencies_preserve_account_balance_order() {
1441        // Pin IndexMap iteration on PortfolioAnalyzer::account_balances:
1442        // currencies() drives the per-currency stat computation in
1443        // BacktestEngine::run, so the returned Vec must reflect the
1444        // upstream account balance order across runs.
1445        let mut analyzer = PortfolioAnalyzer::new();
1446        let inserts = [
1447            (Currency::BTC(), Money::new(1.0, Currency::BTC())),
1448            (Currency::USD(), Money::new(2.0, Currency::USD())),
1449            (Currency::ETH(), Money::new(3.0, Currency::ETH())),
1450        ];
1451
1452        for (currency, money) in inserts {
1453            analyzer.account_balances.insert(currency, money);
1454        }
1455
1456        let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
1457        assert_eq!(
1458            returned,
1459            vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
1460        );
1461    }
1462
1463    #[rstest]
1464    fn test_calculate_statistics_clears_previous_positions() {
1465        let mut analyzer = PortfolioAnalyzer::new();
1466        let currency = Currency::USD();
1467
1468        let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
1469        let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
1470
1471        let mut starting_balances = AHashMap::new();
1472        starting_balances.insert(currency, Money::new(1000.0, currency));
1473        let mut current_balances = AHashMap::new();
1474        current_balances.insert(currency, Money::new(1500.0, currency));
1475
1476        let account = MockAccount {
1477            starting_balances,
1478            current_balances,
1479            events: vec![],
1480        };
1481
1482        // First calculation
1483        analyzer.calculate_statistics(&account, &positions1);
1484        assert_eq!(analyzer.positions.len(), 1);
1485
1486        // Second calculation should NOT accumulate
1487        analyzer.calculate_statistics(&account, &positions2);
1488        assert_eq!(analyzer.positions.len(), 1);
1489    }
1490
1491    #[rstest]
1492    fn test_calculate_statistics_uses_account_state_returns_when_available() {
1493        let mut analyzer = PortfolioAnalyzer::new();
1494        let currency = Currency::USD();
1495        let positions = vec![
1496            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1497            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1498        ];
1499
1500        let mut starting_balances = AHashMap::new();
1501        starting_balances.insert(currency, Money::new(1000.0, currency));
1502
1503        let mut current_balances = AHashMap::new();
1504        current_balances.insert(currency, Money::new(1100.0, currency));
1505
1506        let account = MockAccount {
1507            starting_balances,
1508            current_balances,
1509            events: vec![
1510                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1511                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1512                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1513            ],
1514        };
1515
1516        analyzer.calculate_statistics(&account, &positions);
1517
1518        let position_returns = analyzer.position_returns();
1519        let portfolio_returns = analyzer.portfolio_returns();
1520        let returns = analyzer.returns();
1521        assert_eq!(position_returns.len(), 1);
1522        assert_eq!(portfolio_returns.len(), 30);
1523        assert_eq!(returns, portfolio_returns);
1524        assert!(approx_eq!(
1525            f64,
1526            *portfolio_returns
1527                .get(&UnixNanos::from(1_704_153_600_000_000_000))
1528                .unwrap(),
1529            0.0,
1530            epsilon = 1e-9
1531        ));
1532        assert!(approx_eq!(
1533            f64,
1534            *portfolio_returns
1535                .get(&UnixNanos::from(1_704_844_800_000_000_000))
1536                .unwrap(),
1537            0.05,
1538            epsilon = 1e-9
1539        ));
1540        assert!(approx_eq!(
1541            f64,
1542            *portfolio_returns
1543                .get(&UnixNanos::from(1_706_659_200_000_000_000))
1544                .unwrap(),
1545            (1100.0 / 1050.0) - 1.0,
1546            epsilon = 1e-9
1547        ));
1548        assert!(approx_eq!(
1549            f64,
1550            *position_returns.values().next().unwrap(),
1551            0.30000000000000004,
1552            epsilon = 1e-9
1553        ));
1554    }
1555
1556    #[rstest]
1557    fn test_calculate_statistics_skips_non_finite_account_returns() {
1558        let mut analyzer = PortfolioAnalyzer::new();
1559        let currency = Currency::USD();
1560
1561        let mut starting_balances = AHashMap::new();
1562        starting_balances.insert(currency, Money::new(0.0, currency));
1563
1564        let mut current_balances = AHashMap::new();
1565        current_balances.insert(currency, Money::new(1050.0, currency));
1566
1567        let account = MockAccount {
1568            starting_balances,
1569            current_balances,
1570            events: vec![
1571                create_account_state(0.0, currency, 1_704_067_200_000_000_000),
1572                create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
1573                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1574            ],
1575        };
1576
1577        analyzer.calculate_statistics(&account, &[]);
1578
1579        let returns = analyzer.returns();
1580        assert!(returns.values().all(|value| value.is_finite()));
1581        assert!(approx_eq!(
1582            f64,
1583            *returns
1584                .get(&UnixNanos::from(1_706_659_200_000_000_000))
1585                .unwrap(),
1586            0.05,
1587            epsilon = 1e-9
1588        ));
1589    }
1590
1591    #[rstest]
1592    fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
1593        let mut analyzer = PortfolioAnalyzer::new();
1594        let currency = Currency::USD();
1595        let positions = vec![
1596            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1597            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1598        ];
1599
1600        let mut starting_balances = AHashMap::new();
1601        starting_balances.insert(currency, Money::new(1000.0, currency));
1602
1603        let mut current_balances = AHashMap::new();
1604        current_balances.insert(currency, Money::new(1100.0, currency));
1605
1606        let account = MockAccount {
1607            starting_balances,
1608            current_balances,
1609            events: vec![],
1610        };
1611
1612        analyzer.calculate_statistics(&account, &positions);
1613
1614        let returns = analyzer.returns();
1615        assert!(analyzer.portfolio_returns().is_empty());
1616        assert_eq!(returns, analyzer.position_returns());
1617        assert_eq!(returns.len(), 1);
1618        assert!(approx_eq!(
1619            f64,
1620            *returns.values().next().unwrap(),
1621            0.30000000000000004,
1622            epsilon = 1e-9
1623        ));
1624    }
1625
1626    #[rstest]
1627    fn test_get_performance_stats_returns_prefers_portfolio_returns() {
1628        let mut analyzer = PortfolioAnalyzer::new();
1629        let currency = Currency::USD();
1630        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1631            Arc::new(MockStatistic::new("test_stat"));
1632        analyzer.register_statistic(Arc::clone(&stat));
1633
1634        let positions = vec![
1635            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1636            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1637        ];
1638
1639        let mut starting_balances = AHashMap::new();
1640        starting_balances.insert(currency, Money::new(1000.0, currency));
1641
1642        let mut current_balances = AHashMap::new();
1643        current_balances.insert(currency, Money::new(1100.0, currency));
1644
1645        let account = MockAccount {
1646            starting_balances,
1647            current_balances,
1648            events: vec![
1649                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1650                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1651                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1652            ],
1653        };
1654
1655        analyzer.calculate_statistics(&account, &positions);
1656
1657        let position_stats = analyzer.get_performance_stats_position_returns();
1658        let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
1659        let returns_stats = analyzer.get_performance_stats_returns();
1660
1661        assert!(approx_eq!(
1662            f64,
1663            *position_stats.get("test_stat").unwrap(),
1664            0.30000000000000004,
1665            epsilon = 1e-9
1666        ));
1667        assert_eq!(returns_stats, portfolio_stats);
1668    }
1669
1670    #[rstest]
1671    fn test_from_accounts_aggregates_balances_and_positions() {
1672        let currency = Currency::USD();
1673        let positions = vec![
1674            create_mock_position("pos1", 100.0, 0.1, currency),
1675            create_mock_position("pos2", 200.0, 0.2, currency),
1676        ];
1677
1678        let analyzer = PortfolioAnalyzer::from_accounts(
1679            &[AccountAny::Cash(CashAccount::default())],
1680            &positions,
1681            &[],
1682            AHashMap::new(),
1683        );
1684
1685        assert_eq!(analyzer.positions.len(), positions.len());
1686        assert!(!analyzer.account_balances.is_empty());
1687    }
1688
1689    #[rstest]
1690    fn test_from_accounts_sums_balances_across_accounts() {
1691        let usd = Currency::USD();
1692        let one = PortfolioAnalyzer::from_accounts(
1693            &[AccountAny::Cash(CashAccount::default())],
1694            &[],
1695            &[],
1696            AHashMap::new(),
1697        );
1698        let two = PortfolioAnalyzer::from_accounts(
1699            &[
1700                AccountAny::Cash(CashAccount::default()),
1701                AccountAny::Cash(CashAccount::default()),
1702            ],
1703            &[],
1704            &[],
1705            AHashMap::new(),
1706        );
1707
1708        let single = one.account_balances.get(&usd).unwrap().as_decimal();
1709        let summed = two.account_balances.get(&usd).unwrap().as_decimal();
1710        let single_start = one
1711            .account_balances_starting
1712            .get(&usd)
1713            .unwrap()
1714            .as_decimal();
1715        let summed_start = two
1716            .account_balances_starting
1717            .get(&usd)
1718            .unwrap()
1719            .as_decimal();
1720
1721        assert_eq!(summed, single + single);
1722        assert_eq!(summed_start, single_start + single_start);
1723        assert_ne!(summed, single);
1724    }
1725
1726    #[rstest]
1727    fn test_statistics_snapshot_matches_getters() {
1728        let currency = Currency::USD();
1729        let positions = vec![
1730            create_mock_position("pos1", 100.0, 0.1, currency),
1731            create_mock_position("pos2", 200.0, 0.2, currency),
1732        ];
1733
1734        let analyzer = PortfolioAnalyzer::from_accounts(
1735            &[AccountAny::Cash(CashAccount::default())],
1736            &positions,
1737            &[],
1738            AHashMap::new(),
1739        );
1740
1741        let snapshot = analyzer.statistics();
1742        assert!(maps_equal_nan_aware(
1743            &snapshot.returns,
1744            &analyzer.get_performance_stats_returns()
1745        ));
1746        assert!(maps_equal_nan_aware(
1747            &snapshot.general,
1748            &analyzer.get_performance_stats_general()
1749        ));
1750
1751        for currency in analyzer.currencies() {
1752            let expected = analyzer
1753                .get_performance_stats_pnls(Some(currency), None)
1754                .unwrap();
1755            let actual = snapshot.pnls.get(&currency.code.to_string()).unwrap();
1756            assert!(maps_equal_nan_aware(actual, &expected));
1757        }
1758    }
1759
1760    fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
1761        if a.len() != b.len() {
1762            return false;
1763        }
1764        a.iter().all(|(k, v)| {
1765            b.get(k)
1766                .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
1767        })
1768    }
1769
1770    #[rstest]
1771    fn test_get_performance_stats_returns_vs_benchmark() {
1772        let mut analyzer = PortfolioAnalyzer::new();
1773        analyzer.register_statistic(Arc::new(BetaRatio::new()));
1774        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
1775
1776        let one_day = 86_400_000_000_000_u64;
1777        let start = 1_600_000_000_000_000_000_u64;
1778        for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
1779            analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
1780        }
1781
1782        let mut benchmark: Returns = BTreeMap::new();
1783        for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
1784            benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
1785        }
1786
1787        let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
1788
1789        // r = [0.03, -0.01, 0.02, 0.04], b = [0.01, 0.005, 0.005, 0.01]:
1790        //   mean_r = 0.02, mean_b = 0.0075
1791        //   Cov = 1.5e-4 / 3 = 5e-5, Var(b) = 2.5e-5 / 3 -> beta = 6.0
1792        // Only the benchmark-relative statistic contributes; SharpeRatio
1793        // returns None from the default and is skipped.
1794        assert_eq!(stats.len(), 1);
1795        assert!(approx_eq!(
1796            f64,
1797            *stats.get("Beta").unwrap(),
1798            6.0,
1799            epsilon = 1e-9
1800        ));
1801        assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
1802    }
1803}