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;
20use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_DAY};
21use nautilus_model::{
22    accounts::Account,
23    identifiers::PositionId,
24    position::Position,
25    types::{Currency, Money},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30    Returns,
31    statistic::PortfolioStatistic,
32    statistics::{
33        expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
34        loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
35        returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
36        returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
37        sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
38        winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
39    },
40};
41
42pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
43
44/// Analyzes portfolio performance and calculates various statistics.
45///
46/// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
47/// to provide portfolio analysis including returns, PnL calculations,
48/// and customizable statistics.
49#[repr(C)]
50#[derive(Debug)]
51#[cfg_attr(
52    feature = "python",
53    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
54)]
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, f64)>>,
65    pub position_returns: Returns,
66    pub portfolio_returns: Returns,
67    /// Alias for the primary returns source.
68    ///
69    /// Contains portfolio returns when available, otherwise position returns.
70    /// Kept as a public field for API stability; prefer the `returns()` accessor.
71    pub returns: Returns,
72}
73
74impl Default for PortfolioAnalyzer {
75    /// Creates a new default [`PortfolioAnalyzer`] instance.
76    fn default() -> Self {
77        let mut analyzer = Self::new();
78        analyzer.register_statistic(Arc::new(MaxWinner {}));
79        analyzer.register_statistic(Arc::new(AvgWinner {}));
80        analyzer.register_statistic(Arc::new(MinWinner {}));
81        analyzer.register_statistic(Arc::new(MinLoser {}));
82        analyzer.register_statistic(Arc::new(AvgLoser {}));
83        analyzer.register_statistic(Arc::new(MaxLoser {}));
84        analyzer.register_statistic(Arc::new(Expectancy {}));
85        analyzer.register_statistic(Arc::new(WinRate {}));
86        analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
87        analyzer.register_statistic(Arc::new(ReturnsAverage {}));
88        analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
89        analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
90        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
91        analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
92        analyzer.register_statistic(Arc::new(ProfitFactor {}));
93        analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
94        analyzer.register_statistic(Arc::new(LongRatio::new(None)));
95        analyzer
96    }
97}
98
99impl PortfolioAnalyzer {
100    /// Creates a new [`PortfolioAnalyzer`] instance.
101    ///
102    /// Starts with empty state.
103    #[must_use]
104    pub fn new() -> Self {
105        Self {
106            statistics: AHashMap::new(),
107            account_balances_starting: IndexMap::new(),
108            account_balances: IndexMap::new(),
109            positions: Vec::new(),
110            realized_pnls: AHashMap::new(),
111            position_returns: BTreeMap::new(),
112            portfolio_returns: BTreeMap::new(),
113            returns: BTreeMap::new(),
114        }
115    }
116
117    /// Registers a new portfolio statistic for calculation.
118    pub fn register_statistic(&mut self, statistic: Statistic) {
119        self.statistics.insert(statistic.name(), statistic);
120    }
121
122    /// Removes a specific statistic from calculation.
123    pub fn deregister_statistic(&mut self, statistic: &Statistic) {
124        self.statistics.remove(&statistic.name());
125    }
126
127    /// Removes all registered statistics.
128    pub fn deregister_statistics(&mut self) {
129        self.statistics.clear();
130    }
131
132    /// Resets all analysis data to initial state.
133    pub fn reset(&mut self) {
134        self.account_balances_starting.clear();
135        self.account_balances.clear();
136        self.positions.clear();
137        self.realized_pnls.clear();
138        self.position_returns.clear();
139        self.portfolio_returns.clear();
140        self.returns.clear();
141    }
142
143    /// Returns all tracked currencies.
144    #[must_use]
145    pub fn currencies(&self) -> Vec<&Currency> {
146        self.account_balances.keys().collect()
147    }
148
149    /// Retrieves a specific statistic by name.
150    #[must_use]
151    pub fn statistic(&self, name: &str) -> Option<&Statistic> {
152        self.statistics.get(name)
153    }
154
155    /// Returns the primary calculated returns.
156    ///
157    /// This returns portfolio returns when available, otherwise it falls back
158    /// to position returns for backward compatibility.
159    #[must_use]
160    pub const fn returns(&self) -> &Returns {
161        &self.returns
162    }
163
164    /// Returns the per-position calculated returns.
165    #[must_use]
166    pub const fn position_returns(&self) -> &Returns {
167        &self.position_returns
168    }
169
170    /// Returns the portfolio calculated returns.
171    #[must_use]
172    pub const fn portfolio_returns(&self) -> &Returns {
173        &self.portfolio_returns
174    }
175
176    /// Calculates statistics based on account and position data.
177    ///
178    /// This clears all previous state before calculating, so can be called
179    /// multiple times without accumulating stale data.
180    pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
181        self.account_balances_starting = account.starting_balances().into_iter().collect();
182        self.account_balances = account.balances_total().into_iter().collect();
183        self.positions.clear();
184        self.realized_pnls.clear();
185        self.position_returns.clear();
186        self.portfolio_returns.clear();
187        self.returns.clear();
188
189        self.add_positions(positions);
190
191        if let Some(account_returns) = Self::calculate_account_returns(account) {
192            self.portfolio_returns = account_returns;
193            self.sync_returns_alias();
194        }
195    }
196
197    /// Adds new positions for analysis.
198    pub fn add_positions(&mut self, positions: &[Position]) {
199        self.positions.extend_from_slice(positions);
200        for position in positions {
201            if let Some(ref pnl) = position.realized_pnl {
202                self.add_trade(&position.id, pnl);
203            }
204
205            if let Some(ts_closed) = position.ts_closed
206                && ts_closed.as_u64() > 0
207                && position.realized_pnl.is_some()
208            {
209                self.add_position_return(ts_closed, position.realized_return);
210            }
211        }
212    }
213
214    /// Records a trade's PnL.
215    pub fn add_trade(&mut self, position_id: &PositionId, pnl: &Money) {
216        let currency = pnl.currency;
217        let entry = self.realized_pnls.entry(currency).or_default();
218        entry.push((*position_id, pnl.as_f64()));
219    }
220
221    /// Records a position return at a specific timestamp.
222    pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
223        self.position_returns
224            .entry(timestamp)
225            .and_modify(|existing_value| *existing_value += value)
226            .or_insert(value);
227
228        // Mirror writes into the `returns` alias when no portfolio returns exist.
229        // This avoids calling `sync_returns_alias` (which clones the full map)
230        // on every insert.
231        if self.portfolio_returns.is_empty() {
232            self.returns
233                .entry(timestamp)
234                .and_modify(|existing_value| *existing_value += value)
235                .or_insert(value);
236        }
237    }
238
239    /// Records a return at a specific timestamp.
240    ///
241    /// This is a backward-compatible alias for [`Self::add_position_return`].
242    pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
243        self.add_position_return(timestamp, value);
244    }
245
246    /// Computes daily portfolio returns from account balance snapshots.
247    ///
248    /// Returns `None` (falling back to per-position returns) when:
249    /// - Fewer than two account state events exist.
250    /// - Any event carries multiple balance currencies.
251    /// - The balance currency changes between events.
252    /// - Fewer than two distinct calendar days have balance data.
253    ///
254    /// Multi-currency accounts are not yet supported; the caller silently
255    /// receives per-position returns in that case.
256    fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
257        let mut events = account.events();
258        if events.len() < 2 {
259            return None;
260        }
261
262        events.sort_by_key(|event| event.ts_event);
263
264        let mut currency = None;
265        let mut daily_balances = BTreeMap::new();
266
267        for event in events {
268            if event.balances.len() != 1 {
269                return None;
270            }
271
272            let balance = event.balances[0];
273
274            if let Some(existing_currency) = currency {
275                if existing_currency != balance.currency {
276                    return None;
277                }
278            } else {
279                currency = Some(balance.currency);
280            }
281
282            let day_start = UnixNanos::from(
283                event.ts_event.as_u64() - (event.ts_event.as_u64() % NANOSECONDS_IN_DAY),
284            );
285            daily_balances.insert(day_start, balance.total.as_f64());
286        }
287
288        if daily_balances.len() < 2 {
289            return None;
290        }
291
292        let mut returns = Returns::new();
293        let mut current_day = *daily_balances.keys().next()?;
294        let last_day = *daily_balances.keys().next_back()?;
295        let mut current_balance: Option<f64> = None;
296        let mut previous_balance: Option<f64> = None;
297
298        loop {
299            if let Some(balance) = daily_balances.get(&current_day) {
300                current_balance = Some(*balance);
301            }
302
303            let balance = current_balance?;
304
305            if let Some(previous) = previous_balance
306                && previous != 0.0
307            {
308                let value: f64 = (balance / previous) - 1.0;
309                if value.is_finite() {
310                    returns.insert(current_day, value);
311                }
312            }
313
314            previous_balance = Some(balance);
315
316            if current_day >= last_day {
317                break;
318            }
319
320            current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
321        }
322
323        (!returns.is_empty()).then_some(returns)
324    }
325
326    /// Retrieves realized PnLs for a specific currency.
327    ///
328    /// Returns `None` if no PnLs exist, or if multiple currencies exist
329    /// without an explicit currency specified.
330    #[must_use]
331    pub fn realized_pnls(&self, currency: Option<&Currency>) -> Option<Vec<(PositionId, f64)>> {
332        if self.realized_pnls.is_empty() {
333            return None;
334        }
335
336        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
337        let currency = match currency {
338            Some(c) => c,
339            None if self.account_balances.len() == 1 => self.account_balances.keys().next()?,
340            None => return None,
341        };
342
343        self.realized_pnls.get(currency).cloned()
344    }
345
346    /// Calculates total PnL including unrealized PnL if provided.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if:
351    /// - No currency is specified in a multi-currency portfolio.
352    /// - The specified currency is not found in account balances.
353    /// - The unrealized PnL currency does not match the specified currency.
354    #[expect(clippy::missing_panics_doc)] // Guarded by length check
355    pub fn total_pnl(
356        &self,
357        currency: Option<&Currency>,
358        unrealized_pnl: Option<&Money>,
359    ) -> Result<f64, &'static str> {
360        if self.account_balances.is_empty() {
361            return Ok(0.0);
362        }
363
364        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
365        let currency = match currency {
366            Some(c) => c,
367            None if self.account_balances.len() == 1 => {
368                self.account_balances.keys().next().expect("len is 1")
369            }
370            None => return Err("Currency must be specified for multi-currency portfolio"),
371        };
372
373        if let Some(unrealized_pnl) = unrealized_pnl
374            && unrealized_pnl.currency != *currency
375        {
376            return Err("Unrealized PnL currency does not match specified currency");
377        }
378
379        let account_balance = self
380            .account_balances
381            .get(currency)
382            .ok_or("Specified currency not found in account balances")?;
383
384        let default_money = &Money::new(0.0, *currency);
385        let account_balance_starting = self
386            .account_balances_starting
387            .get(currency)
388            .unwrap_or(default_money);
389
390        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
391        Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
392    }
393
394    /// Calculates total PnL as a percentage of starting balance.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error if:
399    /// - No currency is specified in a multi-currency portfolio.
400    /// - The specified currency is not found in account balances.
401    /// - The unrealized PnL currency does not match the specified currency.
402    #[expect(clippy::missing_panics_doc)] // Guarded by length check
403    pub fn total_pnl_percentage(
404        &self,
405        currency: Option<&Currency>,
406        unrealized_pnl: Option<&Money>,
407    ) -> Result<f64, &'static str> {
408        if self.account_balances.is_empty() {
409            return Ok(0.0);
410        }
411
412        // Require explicit currency for multi-currency portfolios to avoid nondeterminism
413        let currency = match currency {
414            Some(c) => c,
415            None if self.account_balances.len() == 1 => {
416                self.account_balances.keys().next().expect("len is 1")
417            }
418            None => return Err("Currency must be specified for multi-currency portfolio"),
419        };
420
421        if let Some(unrealized_pnl) = unrealized_pnl
422            && unrealized_pnl.currency != *currency
423        {
424            return Err("Unrealized PnL currency does not match specified currency");
425        }
426
427        let account_balance = self
428            .account_balances
429            .get(currency)
430            .ok_or("Specified currency not found in account balances")?;
431
432        let default_money = &Money::new(0.0, *currency);
433        let account_balance_starting = self
434            .account_balances_starting
435            .get(currency)
436            .unwrap_or(default_money);
437
438        if account_balance_starting.as_decimal() == Decimal::ZERO {
439            return Ok(0.0);
440        }
441
442        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
443        let current = account_balance.as_f64() + unrealized_pnl_f64;
444        let starting = account_balance_starting.as_f64();
445        let difference = current - starting;
446
447        Ok((difference / starting) * 100.0)
448    }
449
450    /// Gets all PnL-related performance statistics.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if PnL calculations fail, for example due to:
455    ///
456    /// - No currency specified for a multi-currency portfolio.
457    /// - Unrealized PnL currency not matching the specified currency.
458    /// - Specified currency not found in account balances.
459    pub fn get_performance_stats_pnls(
460        &self,
461        currency: Option<&Currency>,
462        unrealized_pnl: Option<&Money>,
463    ) -> Result<AHashMap<String, f64>, &'static str> {
464        let mut output = AHashMap::new();
465
466        output.insert(
467            "PnL (total)".to_string(),
468            self.total_pnl(currency, unrealized_pnl)?,
469        );
470        output.insert(
471            "PnL% (total)".to_string(),
472            self.total_pnl_percentage(currency, unrealized_pnl)?,
473        );
474
475        if let Some(realized_pnls) = self.realized_pnls(currency) {
476            for (name, stat) in &self.statistics {
477                if let Some(value) = stat.calculate_from_realized_pnls(
478                    &realized_pnls
479                        .iter()
480                        .map(|(_, pnl)| *pnl)
481                        .collect::<Vec<f64>>(),
482                ) {
483                    output.insert(name.clone(), value);
484                }
485            }
486        }
487
488        Ok(output)
489    }
490
491    /// Gets all return-based performance statistics.
492    #[must_use]
493    pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
494        self.calculate_returns_stats(self.returns())
495    }
496
497    /// Gets all position-return-based performance statistics.
498    #[must_use]
499    pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
500        self.calculate_returns_stats(self.position_returns())
501    }
502
503    /// Gets all portfolio-return-based performance statistics.
504    #[must_use]
505    pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
506        self.calculate_returns_stats(self.portfolio_returns())
507    }
508
509    /// Gets general portfolio statistics.
510    #[must_use]
511    pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
512        let mut output = AHashMap::new();
513
514        for (name, stat) in &self.statistics {
515            if let Some(value) = stat.calculate_from_positions(&self.positions) {
516                output.insert(name.clone(), value);
517            }
518        }
519
520        output
521    }
522
523    /// Calculates the maximum length of statistic names for formatting.
524    fn get_max_length_name(&self) -> usize {
525        self.statistics.keys().map(String::len).max().unwrap_or(0)
526    }
527
528    fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
529        let mut output = AHashMap::new();
530
531        for (name, stat) in &self.statistics {
532            if let Some(value) = stat.calculate_from_returns(returns) {
533                output.insert(name.clone(), value);
534            }
535        }
536
537        output
538    }
539
540    fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
541        let max_length = self.get_max_length_name();
542        let mut entries: Vec<_> = stats.into_iter().collect();
543        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
544
545        let mut output = Vec::new();
546
547        for (k, v) in entries {
548            let padding = max_length.saturating_sub(k.len()) + 1;
549            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
550        }
551
552        output
553    }
554
555    fn sync_returns_alias(&mut self) {
556        if self.portfolio_returns.is_empty() {
557            self.returns = self.position_returns.clone();
558            return;
559        }
560
561        self.returns = self.portfolio_returns.clone();
562    }
563
564    /// Gets formatted PnL statistics as strings.
565    ///
566    /// # Errors
567    ///
568    /// Returns an error if PnL statistics calculation fails.
569    pub fn get_stats_pnls_formatted(
570        &self,
571        currency: Option<&Currency>,
572        unrealized_pnl: Option<&Money>,
573    ) -> Result<Vec<String>, String> {
574        let max_length = self.get_max_length_name();
575        let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
576
577        let mut entries: Vec<_> = stats.into_iter().collect();
578        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
579
580        let mut output = Vec::new();
581
582        for (k, v) in entries {
583            let padding = if max_length > k.len() {
584                max_length - k.len() + 1
585            } else {
586                1
587            };
588            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
589        }
590
591        Ok(output)
592    }
593
594    /// Gets formatted return statistics as strings.
595    #[must_use]
596    pub fn get_stats_returns_formatted(&self) -> Vec<String> {
597        self.format_returns_stats(self.get_performance_stats_returns())
598    }
599
600    /// Gets formatted position-return statistics as strings.
601    #[must_use]
602    pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
603        self.format_returns_stats(self.get_performance_stats_position_returns())
604    }
605
606    /// Gets formatted portfolio-return statistics as strings.
607    #[must_use]
608    pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
609        self.format_returns_stats(self.get_performance_stats_portfolio_returns())
610    }
611
612    /// Gets formatted general statistics as strings.
613    #[must_use]
614    pub fn get_stats_general_formatted(&self) -> Vec<String> {
615        let max_length = self.get_max_length_name();
616        let stats = self.get_performance_stats_general();
617
618        let mut entries: Vec<_> = stats.into_iter().collect();
619        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
620
621        let mut output = Vec::new();
622
623        for (k, v) in entries {
624            let padding = max_length - k.len() + 1;
625            output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
626        }
627
628        output
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use std::sync::Arc;
635
636    use ahash::{AHashMap, AHashSet};
637    use indexmap::IndexMap;
638    use nautilus_core::{UUID4, approx_eq};
639    use nautilus_model::{
640        enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
641        events::{AccountState, OrderFilled},
642        identifiers::{
643            AccountId, ClientOrderId,
644            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
645        },
646        instruments::InstrumentAny,
647        stubs::TestDefault,
648        types::{AccountBalance, Money, Price, Quantity},
649    };
650    use rstest::rstest;
651
652    use super::*;
653
654    /// Mock implementation of `PortfolioStatistic` for testing.
655    #[derive(Debug)]
656    struct MockStatistic {
657        name: String,
658    }
659
660    impl MockStatistic {
661        fn new(name: &str) -> Self {
662            Self {
663                name: name.to_string(),
664            }
665        }
666    }
667
668    impl PortfolioStatistic for MockStatistic {
669        type Item = f64;
670
671        fn name(&self) -> String {
672            self.name.clone()
673        }
674
675        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
676            Some(pnls.iter().sum())
677        }
678
679        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
680            Some(returns.values().sum())
681        }
682
683        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
684            Some(positions.len() as f64)
685        }
686    }
687
688    fn create_mock_position(
689        id: &str,
690        realized_pnl: f64,
691        realized_return: f64,
692        currency: Currency,
693    ) -> Position {
694        Position {
695            events: Vec::new(),
696            adjustments: Vec::new(),
697            trader_id: trader_id(),
698            strategy_id: strategy_id_ema_cross(),
699            instrument_id: instrument_id_aud_usd_sim(),
700            id: PositionId::new(id),
701            account_id: AccountId::new("test-account"),
702            opening_order_id: ClientOrderId::test_default(),
703            closing_order_id: None,
704            entry: OrderSide::NoOrderSide,
705            side: PositionSide::NoPositionSide,
706            signed_qty: 0.0,
707            quantity: Quantity::default(),
708            peak_qty: Quantity::default(),
709            price_precision: 2,
710            size_precision: 2,
711            multiplier: Quantity::default(),
712            is_inverse: false,
713            is_currency_pair: true,
714            instrument_class: InstrumentClass::Spot,
715            base_currency: None,
716            quote_currency: Currency::USD(),
717            settlement_currency: Currency::USD(),
718            ts_init: UnixNanos::default(),
719            ts_opened: UnixNanos::default(),
720            ts_last: UnixNanos::default(),
721            ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
722            duration_ns: 2,
723            avg_px_open: 0.0,
724            avg_px_close: None,
725            realized_return,
726            realized_pnl: Some(Money::new(realized_pnl, currency)),
727            trade_ids: AHashSet::new(),
728            buy_qty: Quantity::default(),
729            sell_qty: Quantity::default(),
730            commissions: IndexMap::new(),
731        }
732    }
733
734    struct MockAccount {
735        starting_balances: AHashMap<Currency, Money>,
736        current_balances: AHashMap<Currency, Money>,
737        events: Vec<AccountState>,
738    }
739
740    impl Account for MockAccount {
741        fn starting_balances(&self) -> IndexMap<Currency, Money> {
742            self.starting_balances.clone().into_iter().collect()
743        }
744        fn balances_total(&self) -> IndexMap<Currency, Money> {
745            self.current_balances.clone().into_iter().collect()
746        }
747        fn id(&self) -> AccountId {
748            todo!()
749        }
750        fn account_type(&self) -> AccountType {
751            todo!()
752        }
753        fn base_currency(&self) -> Option<Currency> {
754            todo!()
755        }
756        fn is_cash_account(&self) -> bool {
757            todo!()
758        }
759        fn is_margin_account(&self) -> bool {
760            todo!()
761        }
762        fn calculated_account_state(&self) -> bool {
763            todo!()
764        }
765        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
766            todo!()
767        }
768        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
769            todo!()
770        }
771        fn balances_free(&self) -> IndexMap<Currency, Money> {
772            todo!()
773        }
774        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
775            todo!()
776        }
777        fn balances_locked(&self) -> IndexMap<Currency, Money> {
778            todo!()
779        }
780        fn last_event(&self) -> Option<AccountState> {
781            self.events.last().cloned()
782        }
783        fn events(&self) -> Vec<AccountState> {
784            self.events.clone()
785        }
786        fn event_count(&self) -> usize {
787            self.events.len()
788        }
789        fn currencies(&self) -> Vec<Currency> {
790            self.current_balances.keys().copied().collect()
791        }
792        fn balances(&self) -> IndexMap<Currency, AccountBalance> {
793            todo!()
794        }
795        fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
796            todo!()
797        }
798        fn calculate_balance_locked(
799            &mut self,
800            _: &InstrumentAny,
801            _: OrderSide,
802            _: Quantity,
803            _: Price,
804            _: Option<bool>,
805        ) -> Result<Money, anyhow::Error> {
806            todo!()
807        }
808        fn calculate_pnls(
809            &self,
810            _: &InstrumentAny,
811            _: &OrderFilled,
812            _: Option<Position>,
813        ) -> Result<Vec<Money>, anyhow::Error> {
814            todo!()
815        }
816        fn calculate_commission(
817            &self,
818            _: &InstrumentAny,
819            _: Quantity,
820            _: Price,
821            _: LiquiditySide,
822            _: Option<bool>,
823        ) -> Result<Money, anyhow::Error> {
824            todo!()
825        }
826
827        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
828            todo!()
829        }
830
831        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
832            // MockAccount doesn't need purging
833        }
834    }
835
836    fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
837        AccountState::new(
838            AccountId::new("test-account"),
839            AccountType::Cash,
840            vec![AccountBalance::new(
841                Money::new(total, currency),
842                Money::new(0.0, currency),
843                Money::new(total, currency),
844            )],
845            vec![],
846            true,
847            UUID4::new(),
848            UnixNanos::from(ts_event),
849            UnixNanos::from(ts_event),
850            Some(currency),
851        )
852    }
853
854    #[rstest]
855    fn test_register_and_deregister_statistics() {
856        let mut analyzer = PortfolioAnalyzer::new();
857        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
858            Arc::new(MockStatistic::new("test_stat"));
859
860        // Test registration
861        analyzer.register_statistic(Arc::clone(&stat));
862        assert!(analyzer.statistic("test_stat").is_some());
863
864        // Test deregistration
865        analyzer.deregister_statistic(&stat);
866        assert!(analyzer.statistic("test_stat").is_none());
867
868        // Test deregister all
869        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
870            Arc::new(MockStatistic::new("stat1"));
871        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
872            Arc::new(MockStatistic::new("stat2"));
873        analyzer.register_statistic(Arc::clone(&stat1));
874        analyzer.register_statistic(Arc::clone(&stat2));
875        analyzer.deregister_statistics();
876        assert!(analyzer.statistics.is_empty());
877    }
878
879    #[rstest]
880    fn test_calculate_total_pnl() {
881        let mut analyzer = PortfolioAnalyzer::new();
882        let currency = Currency::USD();
883
884        // Set up mock account data
885        let mut starting_balances = AHashMap::new();
886        starting_balances.insert(currency, Money::new(1000.0, currency));
887
888        let mut current_balances = AHashMap::new();
889        current_balances.insert(currency, Money::new(1500.0, currency));
890
891        let account = MockAccount {
892            starting_balances,
893            current_balances,
894            events: vec![],
895        };
896
897        analyzer.calculate_statistics(&account, &[]);
898
899        // Test total PnL calculation
900        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
901        assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
902
903        // Test with unrealized PnL
904        let unrealized_pnl = Money::new(100.0, currency);
905        let result = analyzer
906            .total_pnl(Some(&currency), Some(&unrealized_pnl))
907            .unwrap();
908        assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
909    }
910
911    #[rstest]
912    fn test_calculate_total_pnl_percentage() {
913        let mut analyzer = PortfolioAnalyzer::new();
914        let currency = Currency::USD();
915
916        // Set up mock account data
917        let mut starting_balances = AHashMap::new();
918        starting_balances.insert(currency, Money::new(1000.0, currency));
919
920        let mut current_balances = AHashMap::new();
921        current_balances.insert(currency, Money::new(1500.0, currency));
922
923        let account = MockAccount {
924            starting_balances,
925            current_balances,
926            events: vec![],
927        };
928
929        analyzer.calculate_statistics(&account, &[]);
930
931        // Test percentage calculation
932        let result = analyzer
933            .total_pnl_percentage(Some(&currency), None)
934            .unwrap();
935        assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); // (1500 - 1000) / 1000 * 100
936
937        // Test with unrealized PnL
938        let unrealized_pnl = Money::new(500.0, currency);
939        let result = analyzer
940            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
941            .unwrap();
942        assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); // (2000 - 1000) / 1000 * 100
943    }
944
945    #[rstest]
946    fn test_add_positions_and_returns() {
947        let mut analyzer = PortfolioAnalyzer::new();
948        let currency = Currency::USD();
949
950        let positions = vec![
951            create_mock_position("AUD/USD", 100.0, 0.1, currency),
952            create_mock_position("AUD/USD", 200.0, 0.2, currency),
953        ];
954
955        analyzer.add_positions(&positions);
956
957        // Verify realized PnLs were recorded
958        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
959        assert_eq!(pnls.len(), 2);
960        assert!(approx_eq!(f64, pnls[0].1, 100.0, epsilon = 1e-9));
961        assert!(approx_eq!(f64, pnls[1].1, 200.0, epsilon = 1e-9));
962
963        // Verify returns were recorded
964        let returns = analyzer.returns();
965        let position_returns = analyzer.position_returns();
966        assert_eq!(returns.len(), 1);
967        assert_eq!(position_returns.len(), 1);
968        assert!(analyzer.portfolio_returns().is_empty());
969        assert!(approx_eq!(
970            f64,
971            *returns.values().next().unwrap(),
972            0.30000000000000004,
973            epsilon = 1e-9
974        ));
975        assert!(approx_eq!(
976            f64,
977            *position_returns.values().next().unwrap(),
978            0.30000000000000004,
979            epsilon = 1e-9
980        ));
981    }
982
983    #[rstest]
984    fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
985        let mut analyzer = PortfolioAnalyzer::new();
986        let currency = Currency::USD();
987        let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
988        position.ts_closed = Some(UnixNanos::default());
989
990        analyzer.add_positions(&[position]);
991
992        assert!(analyzer.position_returns().is_empty());
993        assert!(analyzer.returns().is_empty());
994    }
995
996    #[rstest]
997    fn test_performance_stats_calculation() {
998        let mut analyzer = PortfolioAnalyzer::new();
999        let currency = Currency::USD();
1000        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1001            Arc::new(MockStatistic::new("test_stat"));
1002        analyzer.register_statistic(Arc::clone(&stat));
1003
1004        // Add some positions
1005        let positions = vec![
1006            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1007            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1008        ];
1009
1010        let mut starting_balances = AHashMap::new();
1011        starting_balances.insert(currency, Money::new(1000.0, currency));
1012
1013        let mut current_balances = AHashMap::new();
1014        current_balances.insert(currency, Money::new(1500.0, currency));
1015
1016        let account = MockAccount {
1017            starting_balances,
1018            current_balances,
1019            events: vec![],
1020        };
1021
1022        analyzer.calculate_statistics(&account, &positions);
1023
1024        // Test PnL stats
1025        let pnl_stats = analyzer
1026            .get_performance_stats_pnls(Some(&currency), None)
1027            .unwrap();
1028        assert!(pnl_stats.contains_key("PnL (total)"));
1029        assert!(pnl_stats.contains_key("PnL% (total)"));
1030        assert!(pnl_stats.contains_key("test_stat"));
1031
1032        // Test returns stats
1033        let return_stats = analyzer.get_performance_stats_returns();
1034        assert!(return_stats.contains_key("test_stat"));
1035
1036        // Test general stats
1037        let general_stats = analyzer.get_performance_stats_general();
1038        assert!(general_stats.contains_key("test_stat"));
1039    }
1040
1041    #[rstest]
1042    fn test_formatted_output() {
1043        let mut analyzer = PortfolioAnalyzer::new();
1044        let currency = Currency::USD();
1045        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1046            Arc::new(MockStatistic::new("test_stat"));
1047        analyzer.register_statistic(Arc::clone(&stat));
1048
1049        let positions = vec![
1050            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1051            create_mock_position("AUD/USD", 200.0, 0.2, currency),
1052        ];
1053
1054        let mut starting_balances = AHashMap::new();
1055        starting_balances.insert(currency, Money::new(1000.0, currency));
1056
1057        let mut current_balances = AHashMap::new();
1058        current_balances.insert(currency, Money::new(1500.0, currency));
1059
1060        let account = MockAccount {
1061            starting_balances,
1062            current_balances,
1063            events: vec![],
1064        };
1065
1066        analyzer.calculate_statistics(&account, &positions);
1067
1068        // Test formatted outputs
1069        let pnl_formatted = analyzer
1070            .get_stats_pnls_formatted(Some(&currency), None)
1071            .unwrap();
1072        assert!(!pnl_formatted.is_empty());
1073        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
1074
1075        let returns_formatted = analyzer.get_stats_returns_formatted();
1076        assert!(!returns_formatted.is_empty());
1077        assert!(returns_formatted.iter().all(|s| s.contains(':')));
1078
1079        let general_formatted = analyzer.get_stats_general_formatted();
1080        assert!(!general_formatted.is_empty());
1081        assert!(general_formatted.iter().all(|s| s.contains(':')));
1082    }
1083
1084    #[rstest]
1085    fn test_reset() {
1086        let mut analyzer = PortfolioAnalyzer::new();
1087        let currency = Currency::USD();
1088
1089        let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
1090        let mut starting_balances = AHashMap::new();
1091        starting_balances.insert(currency, Money::new(1000.0, currency));
1092        let mut current_balances = AHashMap::new();
1093        current_balances.insert(currency, Money::new(1500.0, currency));
1094
1095        let account = MockAccount {
1096            starting_balances,
1097            current_balances,
1098            events: vec![],
1099        };
1100
1101        analyzer.calculate_statistics(&account, &positions);
1102
1103        analyzer.reset();
1104
1105        assert!(analyzer.account_balances_starting.is_empty());
1106        assert!(analyzer.account_balances.is_empty());
1107        assert!(analyzer.positions.is_empty());
1108        assert!(analyzer.realized_pnls.is_empty());
1109        assert!(analyzer.position_returns.is_empty());
1110        assert!(analyzer.portfolio_returns.is_empty());
1111        assert!(analyzer.returns.is_empty());
1112    }
1113
1114    #[rstest]
1115    fn test_currencies_preserve_account_balance_order() {
1116        // Pin IndexMap iteration on PortfolioAnalyzer::account_balances:
1117        // currencies() drives the per-currency stat computation in
1118        // BacktestEngine::run, so the returned Vec must reflect the
1119        // upstream account balance order across runs.
1120        let mut analyzer = PortfolioAnalyzer::new();
1121        let inserts = [
1122            (Currency::BTC(), Money::new(1.0, Currency::BTC())),
1123            (Currency::USD(), Money::new(2.0, Currency::USD())),
1124            (Currency::ETH(), Money::new(3.0, Currency::ETH())),
1125        ];
1126
1127        for (currency, money) in inserts {
1128            analyzer.account_balances.insert(currency, money);
1129        }
1130
1131        let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
1132        assert_eq!(
1133            returned,
1134            vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
1135        );
1136    }
1137
1138    #[rstest]
1139    fn test_calculate_statistics_clears_previous_positions() {
1140        let mut analyzer = PortfolioAnalyzer::new();
1141        let currency = Currency::USD();
1142
1143        let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
1144        let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
1145
1146        let mut starting_balances = AHashMap::new();
1147        starting_balances.insert(currency, Money::new(1000.0, currency));
1148        let mut current_balances = AHashMap::new();
1149        current_balances.insert(currency, Money::new(1500.0, currency));
1150
1151        let account = MockAccount {
1152            starting_balances,
1153            current_balances,
1154            events: vec![],
1155        };
1156
1157        // First calculation
1158        analyzer.calculate_statistics(&account, &positions1);
1159        assert_eq!(analyzer.positions.len(), 1);
1160
1161        // Second calculation should NOT accumulate
1162        analyzer.calculate_statistics(&account, &positions2);
1163        assert_eq!(analyzer.positions.len(), 1);
1164    }
1165
1166    #[rstest]
1167    fn test_calculate_statistics_uses_account_state_returns_when_available() {
1168        let mut analyzer = PortfolioAnalyzer::new();
1169        let currency = Currency::USD();
1170        let positions = vec![
1171            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1172            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1173        ];
1174
1175        let mut starting_balances = AHashMap::new();
1176        starting_balances.insert(currency, Money::new(1000.0, currency));
1177
1178        let mut current_balances = AHashMap::new();
1179        current_balances.insert(currency, Money::new(1100.0, currency));
1180
1181        let account = MockAccount {
1182            starting_balances,
1183            current_balances,
1184            events: vec![
1185                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1186                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1187                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1188            ],
1189        };
1190
1191        analyzer.calculate_statistics(&account, &positions);
1192
1193        let position_returns = analyzer.position_returns();
1194        let portfolio_returns = analyzer.portfolio_returns();
1195        let returns = analyzer.returns();
1196        assert_eq!(position_returns.len(), 1);
1197        assert_eq!(portfolio_returns.len(), 30);
1198        assert_eq!(returns, portfolio_returns);
1199        assert!(approx_eq!(
1200            f64,
1201            *portfolio_returns
1202                .get(&UnixNanos::from(1_704_153_600_000_000_000))
1203                .unwrap(),
1204            0.0,
1205            epsilon = 1e-9
1206        ));
1207        assert!(approx_eq!(
1208            f64,
1209            *portfolio_returns
1210                .get(&UnixNanos::from(1_704_844_800_000_000_000))
1211                .unwrap(),
1212            0.05,
1213            epsilon = 1e-9
1214        ));
1215        assert!(approx_eq!(
1216            f64,
1217            *portfolio_returns
1218                .get(&UnixNanos::from(1_706_659_200_000_000_000))
1219                .unwrap(),
1220            (1100.0 / 1050.0) - 1.0,
1221            epsilon = 1e-9
1222        ));
1223        assert!(approx_eq!(
1224            f64,
1225            *position_returns.values().next().unwrap(),
1226            0.30000000000000004,
1227            epsilon = 1e-9
1228        ));
1229    }
1230
1231    #[rstest]
1232    fn test_calculate_statistics_skips_non_finite_account_returns() {
1233        let mut analyzer = PortfolioAnalyzer::new();
1234        let currency = Currency::USD();
1235
1236        let mut starting_balances = AHashMap::new();
1237        starting_balances.insert(currency, Money::new(0.0, currency));
1238
1239        let mut current_balances = AHashMap::new();
1240        current_balances.insert(currency, Money::new(1050.0, currency));
1241
1242        let account = MockAccount {
1243            starting_balances,
1244            current_balances,
1245            events: vec![
1246                create_account_state(0.0, currency, 1_704_067_200_000_000_000),
1247                create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
1248                create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1249            ],
1250        };
1251
1252        analyzer.calculate_statistics(&account, &[]);
1253
1254        let returns = analyzer.returns();
1255        assert!(returns.values().all(|value| value.is_finite()));
1256        assert!(approx_eq!(
1257            f64,
1258            *returns
1259                .get(&UnixNanos::from(1_706_659_200_000_000_000))
1260                .unwrap(),
1261            0.05,
1262            epsilon = 1e-9
1263        ));
1264    }
1265
1266    #[rstest]
1267    fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
1268        let mut analyzer = PortfolioAnalyzer::new();
1269        let currency = Currency::USD();
1270        let positions = vec![
1271            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1272            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1273        ];
1274
1275        let mut starting_balances = AHashMap::new();
1276        starting_balances.insert(currency, Money::new(1000.0, currency));
1277
1278        let mut current_balances = AHashMap::new();
1279        current_balances.insert(currency, Money::new(1100.0, currency));
1280
1281        let account = MockAccount {
1282            starting_balances,
1283            current_balances,
1284            events: vec![],
1285        };
1286
1287        analyzer.calculate_statistics(&account, &positions);
1288
1289        let returns = analyzer.returns();
1290        assert!(analyzer.portfolio_returns().is_empty());
1291        assert_eq!(returns, analyzer.position_returns());
1292        assert_eq!(returns.len(), 1);
1293        assert!(approx_eq!(
1294            f64,
1295            *returns.values().next().unwrap(),
1296            0.30000000000000004,
1297            epsilon = 1e-9
1298        ));
1299    }
1300
1301    #[rstest]
1302    fn test_get_performance_stats_returns_prefers_portfolio_returns() {
1303        let mut analyzer = PortfolioAnalyzer::new();
1304        let currency = Currency::USD();
1305        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1306            Arc::new(MockStatistic::new("test_stat"));
1307        analyzer.register_statistic(Arc::clone(&stat));
1308
1309        let positions = vec![
1310            create_mock_position("AUD/USD", 100.0, 0.1, currency),
1311            create_mock_position("EUR/USD", 200.0, 0.2, currency),
1312        ];
1313
1314        let mut starting_balances = AHashMap::new();
1315        starting_balances.insert(currency, Money::new(1000.0, currency));
1316
1317        let mut current_balances = AHashMap::new();
1318        current_balances.insert(currency, Money::new(1100.0, currency));
1319
1320        let account = MockAccount {
1321            starting_balances,
1322            current_balances,
1323            events: vec![
1324                create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1325                create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1326                create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1327            ],
1328        };
1329
1330        analyzer.calculate_statistics(&account, &positions);
1331
1332        let position_stats = analyzer.get_performance_stats_position_returns();
1333        let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
1334        let returns_stats = analyzer.get_performance_stats_returns();
1335
1336        assert!(approx_eq!(
1337            f64,
1338            *position_stats.get("test_stat").unwrap(),
1339            0.30000000000000004,
1340            epsilon = 1e-9
1341        ));
1342        assert_eq!(returns_stats, portfolio_stats);
1343    }
1344}