nautilus_analysis/
analyzer.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::{
17    collections::{BTreeMap, HashMap},
18    fmt::Debug,
19    sync::Arc,
20};
21
22use nautilus_core::UnixNanos;
23use nautilus_model::{
24    accounts::Account,
25    identifiers::PositionId,
26    position::Position,
27    types::{Currency, Money},
28};
29use rust_decimal::Decimal;
30
31use crate::{
32    Returns,
33    statistic::PortfolioStatistic,
34    statistics::{
35        expectancy::Expectancy, long_ratio::LongRatio, loser_max::MaxLoser, loser_min::MinLoser,
36        profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
37        returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
38        returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
39        sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
40        winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
41    },
42};
43
44pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
45
46/// Analyzes portfolio performance and calculates various statistics.
47///
48/// The `PortfolioAnalyzer` tracks account balances, positions, and realized PnLs
49/// to provide comprehensive portfolio analysis including returns, PnL calculations,
50/// and customizable statistics.
51#[repr(C)]
52#[derive(Debug)]
53#[cfg_attr(
54    feature = "python",
55    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
56)]
57pub struct PortfolioAnalyzer {
58    statistics: HashMap<String, Statistic>,
59    account_balances_starting: HashMap<Currency, Money>,
60    account_balances: HashMap<Currency, Money>,
61    positions: Vec<Position>,
62    realized_pnls: HashMap<Currency, Vec<(PositionId, f64)>>,
63    returns: Returns,
64}
65
66impl Default for PortfolioAnalyzer {
67    /// Creates a new default [`PortfolioAnalyzer`] instance.
68    fn default() -> Self {
69        let mut analyzer = Self::new();
70        analyzer.register_statistic(Arc::new(MaxWinner {}));
71        analyzer.register_statistic(Arc::new(AvgWinner {}));
72        analyzer.register_statistic(Arc::new(MinWinner {}));
73        analyzer.register_statistic(Arc::new(MinLoser {}));
74        analyzer.register_statistic(Arc::new(MaxLoser {}));
75        analyzer.register_statistic(Arc::new(Expectancy {}));
76        analyzer.register_statistic(Arc::new(WinRate {}));
77        analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
78        analyzer.register_statistic(Arc::new(ReturnsAverage {}));
79        analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
80        analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
81        analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
82        analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
83        analyzer.register_statistic(Arc::new(ProfitFactor {}));
84        analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
85        analyzer.register_statistic(Arc::new(LongRatio::new(None)));
86        analyzer
87    }
88}
89
90impl PortfolioAnalyzer {
91    /// Creates a new [`PortfolioAnalyzer`] instance.
92    ///
93    /// Starts with empty state.
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            statistics: HashMap::new(),
98            account_balances_starting: HashMap::new(),
99            account_balances: HashMap::new(),
100            positions: Vec::new(),
101            realized_pnls: HashMap::new(),
102            returns: BTreeMap::new(),
103        }
104    }
105
106    /// Registers a new portfolio statistic for calculation.
107    pub fn register_statistic(&mut self, statistic: Statistic) {
108        self.statistics.insert(statistic.name(), statistic);
109    }
110
111    /// Removes a specific statistic from calculation.
112    pub fn deregister_statistic(&mut self, statistic: Statistic) {
113        self.statistics.remove(&statistic.name());
114    }
115
116    /// Removes all registered statistics.
117    pub fn deregister_statistics(&mut self) {
118        self.statistics.clear();
119    }
120
121    /// Resets all analysis data to initial state.
122    pub fn reset(&mut self) {
123        self.account_balances_starting.clear();
124        self.account_balances.clear();
125        self.realized_pnls.clear();
126        self.returns.clear();
127    }
128
129    /// Returns all tracked currencies.
130    #[must_use]
131    pub fn currencies(&self) -> Vec<&Currency> {
132        self.account_balances.keys().collect()
133    }
134
135    /// Retrieves a specific statistic by name.
136    #[must_use]
137    pub fn statistic(&self, name: &str) -> Option<&Statistic> {
138        self.statistics.get(name)
139    }
140
141    /// Returns all calculated returns.
142    #[must_use]
143    pub const fn returns(&self) -> &Returns {
144        &self.returns
145    }
146
147    /// Calculates statistics based on account and position data.
148    pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
149        self.account_balances_starting = account.starting_balances();
150        self.account_balances = account.balances_total();
151        self.realized_pnls.clear();
152        self.returns.clear();
153
154        self.add_positions(positions);
155    }
156
157    /// Adds new positions for analysis.
158    pub fn add_positions(&mut self, positions: &[Position]) {
159        self.positions.extend_from_slice(positions);
160        for position in positions {
161            if let Some(ref pnl) = position.realized_pnl {
162                self.add_trade(&position.id, pnl);
163            }
164            self.add_return(
165                position.ts_closed.unwrap_or(UnixNanos::default()),
166                position.realized_return,
167            );
168        }
169    }
170
171    /// Records a trade's PnL.
172    pub fn add_trade(&mut self, position_id: &PositionId, pnl: &Money) {
173        let currency = pnl.currency;
174        let entry = self.realized_pnls.entry(currency).or_default();
175        entry.push((*position_id, pnl.as_f64()));
176    }
177
178    /// Records a return at a specific timestamp.
179    pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
180        self.returns
181            .entry(timestamp)
182            .and_modify(|existing_value| *existing_value += value)
183            .or_insert(value);
184    }
185
186    /// Retrieves realized PnLs for a specific currency.
187    #[must_use]
188    pub fn realized_pnls(&self, currency: Option<&Currency>) -> Option<Vec<(PositionId, f64)>> {
189        if self.realized_pnls.is_empty() {
190            return None;
191        }
192        let currency = currency.or_else(|| self.account_balances.keys().next())?;
193        self.realized_pnls.get(currency).cloned()
194    }
195
196    /// Calculates total PnL including unrealized PnL if provided.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if:
201    /// - No currency is specified in a multi-currency portfolio.
202    /// - The specified currency is not found in account balances.
203    /// - The unrealized PnL currency does not match the specified currency.
204    pub fn total_pnl(
205        &self,
206        currency: Option<&Currency>,
207        unrealized_pnl: Option<&Money>,
208    ) -> Result<f64, &'static str> {
209        if self.account_balances.is_empty() {
210            return Ok(0.0);
211        }
212
213        let currency = currency
214            .or_else(|| self.account_balances.keys().next())
215            .ok_or("Currency not specified for multi-currency portfolio")?;
216
217        if let Some(unrealized_pnl) = unrealized_pnl
218            && unrealized_pnl.currency != *currency
219        {
220            return Err("Unrealized PnL currency does not match specified currency");
221        }
222
223        let account_balance = self
224            .account_balances
225            .get(currency)
226            .ok_or("Specified currency not found in account balances")?;
227
228        let default_money = &Money::new(0.0, *currency);
229        let account_balance_starting = self
230            .account_balances_starting
231            .get(currency)
232            .unwrap_or(default_money);
233
234        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
235        Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
236    }
237
238    /// Calculates total PnL as a percentage of starting balance.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if:
243    /// - No currency is specified in a multi-currency portfolio.
244    /// - The specified currency is not found in account balances.
245    /// - The unrealized PnL currency does not match the specified currency.
246    pub fn total_pnl_percentage(
247        &self,
248        currency: Option<&Currency>,
249        unrealized_pnl: Option<&Money>,
250    ) -> Result<f64, &'static str> {
251        if self.account_balances.is_empty() {
252            return Ok(0.0);
253        }
254
255        let currency = currency
256            .or_else(|| self.account_balances.keys().next())
257            .ok_or("Currency not specified for multi-currency portfolio")?;
258
259        if let Some(unrealized_pnl) = unrealized_pnl
260            && unrealized_pnl.currency != *currency
261        {
262            return Err("Unrealized PnL currency does not match specified currency");
263        }
264
265        let account_balance = self
266            .account_balances
267            .get(currency)
268            .ok_or("Specified currency not found in account balances")?;
269
270        let default_money = &Money::new(0.0, *currency);
271        let account_balance_starting = self
272            .account_balances_starting
273            .get(currency)
274            .unwrap_or(default_money);
275
276        if account_balance_starting.as_decimal() == Decimal::ZERO {
277            return Ok(0.0);
278        }
279
280        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
281        let current = account_balance.as_f64() + unrealized_pnl_f64;
282        let starting = account_balance_starting.as_f64();
283        let difference = current - starting;
284
285        Ok((difference / starting) * 100.0)
286    }
287
288    /// Gets all PnL-related performance statistics.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if PnL calculations fail, for example due to:
293    ///
294    /// - No currency specified for a multi-currency portfolio.
295    /// - Unrealized PnL currency not matching the specified currency.
296    /// - Specified currency not found in account balances.
297    pub fn get_performance_stats_pnls(
298        &self,
299        currency: Option<&Currency>,
300        unrealized_pnl: Option<&Money>,
301    ) -> Result<HashMap<String, f64>, &'static str> {
302        let mut output = HashMap::new();
303
304        output.insert(
305            "PnL (total)".to_string(),
306            self.total_pnl(currency, unrealized_pnl)?,
307        );
308        output.insert(
309            "PnL% (total)".to_string(),
310            self.total_pnl_percentage(currency, unrealized_pnl)?,
311        );
312
313        if let Some(realized_pnls) = self.realized_pnls(currency) {
314            for (name, stat) in &self.statistics {
315                if let Some(value) = stat.calculate_from_realized_pnls(
316                    &realized_pnls
317                        .iter()
318                        .map(|(_, pnl)| *pnl)
319                        .collect::<Vec<f64>>(),
320                ) {
321                    output.insert(name.clone(), value);
322                }
323            }
324        }
325
326        Ok(output)
327    }
328
329    /// Gets all return-based performance statistics.
330    #[must_use]
331    pub fn get_performance_stats_returns(&self) -> HashMap<String, f64> {
332        let mut output = HashMap::new();
333
334        for (name, stat) in &self.statistics {
335            if let Some(value) = stat.calculate_from_returns(&self.returns) {
336                output.insert(name.clone(), value);
337            }
338        }
339
340        output
341    }
342
343    /// Gets general portfolio statistics.
344    #[must_use]
345    pub fn get_performance_stats_general(&self) -> HashMap<String, f64> {
346        let mut output = HashMap::new();
347
348        for (name, stat) in &self.statistics {
349            if let Some(value) = stat.calculate_from_positions(&self.positions) {
350                output.insert(name.clone(), value);
351            }
352        }
353
354        output
355    }
356
357    /// Calculates the maximum length of statistic names for formatting.
358    fn get_max_length_name(&self) -> usize {
359        self.statistics.keys().map(String::len).max().unwrap_or(0)
360    }
361
362    /// Gets formatted PnL statistics as strings.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if PnL statistics calculation fails.
367    pub fn get_stats_pnls_formatted(
368        &self,
369        currency: Option<&Currency>,
370        unrealized_pnl: Option<&Money>,
371    ) -> Result<Vec<String>, String> {
372        let max_length = self.get_max_length_name();
373        let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
374
375        let mut output = Vec::new();
376        for (k, v) in stats {
377            let padding = if max_length > k.len() {
378                max_length - k.len() + 1
379            } else {
380                1
381            };
382            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
383        }
384
385        Ok(output)
386    }
387
388    /// Gets formatted return statistics as strings.
389    #[must_use]
390    pub fn get_stats_returns_formatted(&self) -> Vec<String> {
391        let max_length = self.get_max_length_name();
392        let stats = self.get_performance_stats_returns();
393
394        let mut output = Vec::new();
395        for (k, v) in stats {
396            let padding = max_length - k.len() + 1;
397            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
398        }
399
400        output
401    }
402
403    /// Gets formatted general statistics as strings.
404    #[must_use]
405    pub fn get_stats_general_formatted(&self) -> Vec<String> {
406        let max_length = self.get_max_length_name();
407        let stats = self.get_performance_stats_general();
408
409        let mut output = Vec::new();
410        for (k, v) in stats {
411            let padding = max_length - k.len() + 1;
412            output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
413        }
414
415        output
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use std::sync::Arc;
422
423    use nautilus_model::{
424        enums::{AccountType, LiquiditySide, OrderSide},
425        events::{AccountState, OrderFilled},
426        identifiers::{
427            AccountId, ClientOrderId,
428            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
429        },
430        instruments::InstrumentAny,
431        types::{AccountBalance, Money, Price, Quantity},
432    };
433    use rstest::rstest;
434
435    use super::*;
436
437    /// Mock implementation of `PortfolioStatistic` for testing.
438    #[derive(Debug)]
439    struct MockStatistic {
440        name: String,
441    }
442
443    impl MockStatistic {
444        fn new(name: &str) -> Self {
445            Self {
446                name: name.to_string(),
447            }
448        }
449    }
450
451    impl PortfolioStatistic for MockStatistic {
452        type Item = f64;
453
454        fn name(&self) -> String {
455            self.name.clone()
456        }
457
458        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
459            Some(pnls.iter().sum())
460        }
461
462        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
463            Some(returns.values().sum())
464        }
465
466        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
467            Some(positions.len() as f64)
468        }
469    }
470
471    fn create_mock_position(
472        id: String,
473        realized_pnl: f64,
474        realized_return: f64,
475        currency: Currency,
476    ) -> Position {
477        Position {
478            events: Vec::new(),
479            trader_id: trader_id(),
480            strategy_id: strategy_id_ema_cross(),
481            instrument_id: instrument_id_aud_usd_sim(),
482            id: PositionId::new(&id),
483            account_id: AccountId::new("test-account"),
484            opening_order_id: ClientOrderId::default(),
485            closing_order_id: None,
486            entry: OrderSide::NoOrderSide,
487            side: nautilus_model::enums::PositionSide::NoPositionSide,
488            signed_qty: 0.0,
489            quantity: Quantity::default(),
490            peak_qty: Quantity::default(),
491            price_precision: 2,
492            size_precision: 2,
493            multiplier: Quantity::default(),
494            is_inverse: false,
495            base_currency: None,
496            quote_currency: Currency::USD(),
497            settlement_currency: Currency::USD(),
498            ts_init: UnixNanos::default(),
499            ts_opened: UnixNanos::default(),
500            ts_last: UnixNanos::default(),
501            ts_closed: None,
502            duration_ns: 2,
503            avg_px_open: 0.0,
504            avg_px_close: None,
505            realized_return,
506            realized_pnl: Some(Money::new(realized_pnl, currency)),
507            trade_ids: Vec::new(),
508            buy_qty: Quantity::default(),
509            sell_qty: Quantity::default(),
510            commissions: HashMap::new(),
511        }
512    }
513
514    struct MockAccount {
515        starting_balances: HashMap<Currency, Money>,
516        current_balances: HashMap<Currency, Money>,
517    }
518
519    impl Account for MockAccount {
520        fn starting_balances(&self) -> HashMap<Currency, Money> {
521            self.starting_balances.clone()
522        }
523        fn balances_total(&self) -> HashMap<Currency, Money> {
524            self.current_balances.clone()
525        }
526        fn id(&self) -> AccountId {
527            todo!()
528        }
529        fn account_type(&self) -> AccountType {
530            todo!()
531        }
532        fn base_currency(&self) -> Option<Currency> {
533            todo!()
534        }
535        fn is_cash_account(&self) -> bool {
536            todo!()
537        }
538        fn is_margin_account(&self) -> bool {
539            todo!()
540        }
541        fn calculated_account_state(&self) -> bool {
542            todo!()
543        }
544        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
545            todo!()
546        }
547        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
548            todo!()
549        }
550        fn balances_free(&self) -> HashMap<Currency, Money> {
551            todo!()
552        }
553        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
554            todo!()
555        }
556        fn balances_locked(&self) -> HashMap<Currency, Money> {
557            todo!()
558        }
559        fn last_event(&self) -> Option<AccountState> {
560            todo!()
561        }
562        fn events(&self) -> Vec<AccountState> {
563            todo!()
564        }
565        fn event_count(&self) -> usize {
566            todo!()
567        }
568        fn currencies(&self) -> Vec<Currency> {
569            todo!()
570        }
571        fn balances(&self) -> HashMap<Currency, AccountBalance> {
572            todo!()
573        }
574        fn apply(&mut self, _: AccountState) {
575            todo!()
576        }
577        fn calculate_balance_locked(
578            &mut self,
579            _: InstrumentAny,
580            _: OrderSide,
581            _: Quantity,
582            _: Price,
583            _: Option<bool>,
584        ) -> Result<Money, anyhow::Error> {
585            todo!()
586        }
587        fn calculate_pnls(
588            &self,
589            _: InstrumentAny,
590            _: OrderFilled,
591            _: Option<Position>,
592        ) -> Result<Vec<Money>, anyhow::Error> {
593            todo!()
594        }
595        fn calculate_commission(
596            &self,
597            _: InstrumentAny,
598            _: Quantity,
599            _: Price,
600            _: LiquiditySide,
601            _: Option<bool>,
602        ) -> Result<Money, anyhow::Error> {
603            todo!()
604        }
605
606        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
607            todo!()
608        }
609
610        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
611            // MockAccount doesn't need purging
612        }
613    }
614
615    #[rstest]
616    fn test_register_and_deregister_statistics() {
617        let mut analyzer = PortfolioAnalyzer::new();
618        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
619            Arc::new(MockStatistic::new("test_stat"));
620
621        // Test registration
622        analyzer.register_statistic(Arc::clone(&stat));
623        assert!(analyzer.statistic("test_stat").is_some());
624
625        // Test deregistration
626        analyzer.deregister_statistic(Arc::clone(&stat));
627        assert!(analyzer.statistic("test_stat").is_none());
628
629        // Test deregister all
630        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
631            Arc::new(MockStatistic::new("stat1"));
632        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
633            Arc::new(MockStatistic::new("stat2"));
634        analyzer.register_statistic(Arc::clone(&stat1));
635        analyzer.register_statistic(Arc::clone(&stat2));
636        analyzer.deregister_statistics();
637        assert!(analyzer.statistics.is_empty());
638    }
639
640    #[rstest]
641    fn test_calculate_total_pnl() {
642        let mut analyzer = PortfolioAnalyzer::new();
643        let currency = Currency::USD();
644
645        // Set up mock account data
646        let mut starting_balances = HashMap::new();
647        starting_balances.insert(currency, Money::new(1000.0, currency));
648
649        let mut current_balances = HashMap::new();
650        current_balances.insert(currency, Money::new(1500.0, currency));
651
652        let account = MockAccount {
653            starting_balances,
654            current_balances,
655        };
656
657        analyzer.calculate_statistics(&account, &[]);
658
659        // Test total PnL calculation
660        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
661        assert_eq!(result, 500.0);
662
663        // Test with unrealized PnL
664        let unrealized_pnl = Money::new(100.0, currency);
665        let result = analyzer
666            .total_pnl(Some(&currency), Some(&unrealized_pnl))
667            .unwrap();
668        assert_eq!(result, 600.0);
669    }
670
671    #[rstest]
672    fn test_calculate_total_pnl_percentage() {
673        let mut analyzer = PortfolioAnalyzer::new();
674        let currency = Currency::USD();
675
676        // Set up mock account data
677        let mut starting_balances = HashMap::new();
678        starting_balances.insert(currency, Money::new(1000.0, currency));
679
680        let mut current_balances = HashMap::new();
681        current_balances.insert(currency, Money::new(1500.0, currency));
682
683        let account = MockAccount {
684            starting_balances,
685            current_balances,
686        };
687
688        analyzer.calculate_statistics(&account, &[]);
689
690        // Test percentage calculation
691        let result = analyzer
692            .total_pnl_percentage(Some(&currency), None)
693            .unwrap();
694        assert_eq!(result, 50.0); // (1500 - 1000) / 1000 * 100
695
696        // Test with unrealized PnL
697        let unrealized_pnl = Money::new(500.0, currency);
698        let result = analyzer
699            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
700            .unwrap();
701        assert_eq!(result, 100.0); // (2000 - 1000) / 1000 * 100
702    }
703
704    #[rstest]
705    fn test_add_positions_and_returns() {
706        let mut analyzer = PortfolioAnalyzer::new();
707        let currency = Currency::USD();
708
709        let positions = vec![
710            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
711            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
712        ];
713
714        analyzer.add_positions(&positions);
715
716        // Verify realized PnLs were recorded
717        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
718        assert_eq!(pnls.len(), 2);
719        assert_eq!(pnls[0].1, 100.0);
720        assert_eq!(pnls[1].1, 200.0);
721
722        // Verify returns were recorded
723        let returns = analyzer.returns();
724        assert_eq!(returns.len(), 1);
725        assert_eq!(*returns.values().next().unwrap(), 0.30000000000000004);
726    }
727
728    #[rstest]
729    fn test_performance_stats_calculation() {
730        let mut analyzer = PortfolioAnalyzer::new();
731        let currency = Currency::USD();
732        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
733            Arc::new(MockStatistic::new("test_stat"));
734        analyzer.register_statistic(Arc::clone(&stat));
735
736        // Add some positions
737        let positions = vec![
738            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
739            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
740        ];
741
742        let mut starting_balances = HashMap::new();
743        starting_balances.insert(currency, Money::new(1000.0, currency));
744
745        let mut current_balances = HashMap::new();
746        current_balances.insert(currency, Money::new(1500.0, currency));
747
748        let account = MockAccount {
749            starting_balances,
750            current_balances,
751        };
752
753        analyzer.calculate_statistics(&account, &positions);
754
755        // Test PnL stats
756        let pnl_stats = analyzer
757            .get_performance_stats_pnls(Some(&currency), None)
758            .unwrap();
759        assert!(pnl_stats.contains_key("PnL (total)"));
760        assert!(pnl_stats.contains_key("PnL% (total)"));
761        assert!(pnl_stats.contains_key("test_stat"));
762
763        // Test returns stats
764        let return_stats = analyzer.get_performance_stats_returns();
765        assert!(return_stats.contains_key("test_stat"));
766
767        // Test general stats
768        let general_stats = analyzer.get_performance_stats_general();
769        assert!(general_stats.contains_key("test_stat"));
770    }
771
772    #[rstest]
773    fn test_formatted_output() {
774        let mut analyzer = PortfolioAnalyzer::new();
775        let currency = Currency::USD();
776        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
777            Arc::new(MockStatistic::new("test_stat"));
778        analyzer.register_statistic(Arc::clone(&stat));
779
780        let positions = vec![
781            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
782            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
783        ];
784
785        let mut starting_balances = HashMap::new();
786        starting_balances.insert(currency, Money::new(1000.0, currency));
787
788        let mut current_balances = HashMap::new();
789        current_balances.insert(currency, Money::new(1500.0, currency));
790
791        let account = MockAccount {
792            starting_balances,
793            current_balances,
794        };
795
796        analyzer.calculate_statistics(&account, &positions);
797
798        // Test formatted outputs
799        let pnl_formatted = analyzer
800            .get_stats_pnls_formatted(Some(&currency), None)
801            .unwrap();
802        assert!(!pnl_formatted.is_empty());
803        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
804
805        let returns_formatted = analyzer.get_stats_returns_formatted();
806        assert!(!returns_formatted.is_empty());
807        assert!(returns_formatted.iter().all(|s| s.contains(':')));
808
809        let general_formatted = analyzer.get_stats_general_formatted();
810        assert!(!general_formatted.is_empty());
811        assert!(general_formatted.iter().all(|s| s.contains(':')));
812    }
813
814    #[rstest]
815    fn test_reset() {
816        let mut analyzer = PortfolioAnalyzer::new();
817        let currency = Currency::USD();
818
819        let positions = vec![create_mock_position(
820            "AUD/USD".to_owned(),
821            100.0,
822            0.1,
823            currency,
824        )];
825        let mut starting_balances = HashMap::new();
826        starting_balances.insert(currency, Money::new(1000.0, currency));
827        let mut current_balances = HashMap::new();
828        current_balances.insert(currency, Money::new(1500.0, currency));
829
830        let account = MockAccount {
831            starting_balances,
832            current_balances,
833        };
834
835        analyzer.calculate_statistics(&account, &positions);
836
837        analyzer.reset();
838
839        assert!(analyzer.account_balances_starting.is_empty());
840        assert!(analyzer.account_balances.is_empty());
841        assert!(analyzer.realized_pnls.is_empty());
842        assert!(analyzer.returns.is_empty());
843    }
844}