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    pub fn total_pnl(
198        &self,
199        currency: Option<&Currency>,
200        unrealized_pnl: Option<&Money>,
201    ) -> Result<f64, &'static str> {
202        if self.account_balances.is_empty() {
203            return Ok(0.0);
204        }
205
206        let currency = currency
207            .or_else(|| self.account_balances.keys().next())
208            .ok_or("Currency not specified for multi-currency portfolio")?;
209
210        if let Some(unrealized_pnl) = unrealized_pnl {
211            if unrealized_pnl.currency != *currency {
212                return Err("Unrealized PnL currency does not match specified currency");
213            }
214        }
215
216        let account_balance = self
217            .account_balances
218            .get(currency)
219            .ok_or("Specified currency not found in account balances")?;
220
221        let default_money = &Money::new(0.0, *currency);
222        let account_balance_starting = self
223            .account_balances_starting
224            .get(currency)
225            .unwrap_or(default_money);
226
227        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
228        Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
229    }
230
231    /// Calculates total `PnL` as a percentage of starting balance.
232    pub fn total_pnl_percentage(
233        &self,
234        currency: Option<&Currency>,
235        unrealized_pnl: Option<&Money>,
236    ) -> Result<f64, &'static str> {
237        if self.account_balances.is_empty() {
238            return Ok(0.0);
239        }
240
241        let currency = currency
242            .or_else(|| self.account_balances.keys().next())
243            .ok_or("Currency not specified for multi-currency portfolio")?;
244
245        if let Some(unrealized_pnl) = unrealized_pnl {
246            if unrealized_pnl.currency != *currency {
247                return Err("Unrealized PnL currency does not match specified currency");
248            }
249        }
250
251        let account_balance = self
252            .account_balances
253            .get(currency)
254            .ok_or("Specified currency not found in account balances")?;
255
256        let default_money = &Money::new(0.0, *currency);
257        let account_balance_starting = self
258            .account_balances_starting
259            .get(currency)
260            .unwrap_or(default_money);
261
262        if account_balance_starting.as_decimal() == Decimal::ZERO {
263            return Ok(0.0);
264        }
265
266        let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
267        let current = account_balance.as_f64() + unrealized_pnl_f64;
268        let starting = account_balance_starting.as_f64();
269        let difference = current - starting;
270
271        Ok((difference / starting) * 100.0)
272    }
273
274    /// Gets all PnL-related performance statistics.
275    pub fn get_performance_stats_pnls(
276        &self,
277        currency: Option<&Currency>,
278        unrealized_pnl: Option<&Money>,
279    ) -> Result<HashMap<String, f64>, &'static str> {
280        let mut output = HashMap::new();
281
282        output.insert(
283            "PnL (total)".to_string(),
284            self.total_pnl(currency, unrealized_pnl)?,
285        );
286        output.insert(
287            "PnL% (total)".to_string(),
288            self.total_pnl_percentage(currency, unrealized_pnl)?,
289        );
290
291        if let Some(realized_pnls) = self.realized_pnls(currency) {
292            for (name, stat) in &self.statistics {
293                if let Some(value) = stat.calculate_from_realized_pnls(
294                    &realized_pnls
295                        .iter()
296                        .map(|(_, pnl)| *pnl)
297                        .collect::<Vec<f64>>(),
298                ) {
299                    output.insert(name.clone(), value);
300                }
301            }
302        }
303
304        Ok(output)
305    }
306
307    /// Gets all return-based performance statistics.
308    #[must_use]
309    pub fn get_performance_stats_returns(&self) -> HashMap<String, f64> {
310        let mut output = HashMap::new();
311
312        for (name, stat) in &self.statistics {
313            if let Some(value) = stat.calculate_from_returns(&self.returns) {
314                output.insert(name.clone(), value);
315            }
316        }
317
318        output
319    }
320
321    /// Gets general portfolio statistics.
322    #[must_use]
323    pub fn get_performance_stats_general(&self) -> HashMap<String, f64> {
324        let mut output = HashMap::new();
325
326        for (name, stat) in &self.statistics {
327            if let Some(value) = stat.calculate_from_positions(&self.positions) {
328                output.insert(name.clone(), value);
329            }
330        }
331
332        output
333    }
334
335    /// Calculates the maximum length of statistic names for formatting.
336    fn get_max_length_name(&self) -> usize {
337        self.statistics.keys().map(String::len).max().unwrap_or(0)
338    }
339
340    /// Gets formatted `PnL` statistics as strings.
341    pub fn get_stats_pnls_formatted(
342        &self,
343        currency: Option<&Currency>,
344        unrealized_pnl: Option<&Money>,
345    ) -> Result<Vec<String>, String> {
346        let max_length = self.get_max_length_name();
347        let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
348
349        let mut output = Vec::new();
350        for (k, v) in stats {
351            let padding = if max_length > k.len() {
352                max_length - k.len() + 1
353            } else {
354                1
355            };
356            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
357        }
358
359        Ok(output)
360    }
361
362    /// Gets formatted return statistics as strings.
363    #[must_use]
364    pub fn get_stats_returns_formatted(&self) -> Vec<String> {
365        let max_length = self.get_max_length_name();
366        let stats = self.get_performance_stats_returns();
367
368        let mut output = Vec::new();
369        for (k, v) in stats {
370            let padding = max_length - k.len() + 1;
371            output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
372        }
373
374        output
375    }
376
377    /// Gets formatted general statistics as strings.
378    #[must_use]
379    pub fn get_stats_general_formatted(&self) -> Vec<String> {
380        let max_length = self.get_max_length_name();
381        let stats = self.get_performance_stats_general();
382
383        let mut output = Vec::new();
384        for (k, v) in stats {
385            let padding = max_length - k.len() + 1;
386            output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
387        }
388
389        output
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::sync::Arc;
396
397    use nautilus_model::{
398        enums::{AccountType, LiquiditySide, OrderSide},
399        events::{AccountState, OrderFilled},
400        identifiers::{
401            AccountId, ClientOrderId,
402            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
403        },
404        instruments::InstrumentAny,
405        types::{AccountBalance, Money, Price, Quantity},
406    };
407
408    use super::*;
409
410    /// Mock implementation of `PortfolioStatistic` for testing.
411    #[derive(Debug)]
412    struct MockStatistic {
413        name: String,
414    }
415
416    impl MockStatistic {
417        fn new(name: &str) -> Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> {
418            Arc::new(Self {
419                name: name.to_string(),
420            })
421        }
422    }
423
424    impl PortfolioStatistic for MockStatistic {
425        type Item = f64;
426
427        fn name(&self) -> String {
428            self.name.clone()
429        }
430
431        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
432            Some(pnls.iter().sum())
433        }
434
435        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
436            Some(returns.values().sum())
437        }
438
439        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
440            Some(positions.len() as f64)
441        }
442    }
443
444    fn create_mock_position(
445        id: String,
446        realized_pnl: f64,
447        realized_return: f64,
448        currency: Currency,
449    ) -> Position {
450        Position {
451            events: Vec::new(),
452            trader_id: trader_id(),
453            strategy_id: strategy_id_ema_cross(),
454            instrument_id: instrument_id_aud_usd_sim(),
455            id: PositionId::new(&id),
456            account_id: AccountId::new("test-account"),
457            opening_order_id: ClientOrderId::default(),
458            closing_order_id: None,
459            entry: OrderSide::NoOrderSide,
460            side: nautilus_model::enums::PositionSide::NoPositionSide,
461            signed_qty: 0.0,
462            quantity: Quantity::default(),
463            peak_qty: Quantity::default(),
464            price_precision: 2,
465            size_precision: 2,
466            multiplier: Quantity::default(),
467            is_inverse: false,
468            base_currency: None,
469            quote_currency: Currency::USD(),
470            settlement_currency: Currency::USD(),
471            ts_init: UnixNanos::default(),
472            ts_opened: UnixNanos::default(),
473            ts_last: UnixNanos::default(),
474            ts_closed: None,
475            duration_ns: 2,
476            avg_px_open: 0.0,
477            avg_px_close: None,
478            realized_return,
479            realized_pnl: Some(Money::new(realized_pnl, currency)),
480            trade_ids: Vec::new(),
481            buy_qty: Quantity::default(),
482            sell_qty: Quantity::default(),
483            commissions: HashMap::new(),
484        }
485    }
486
487    struct MockAccount {
488        starting_balances: HashMap<Currency, Money>,
489        current_balances: HashMap<Currency, Money>,
490    }
491
492    impl Account for MockAccount {
493        fn starting_balances(&self) -> HashMap<Currency, Money> {
494            self.starting_balances.clone()
495        }
496        fn balances_total(&self) -> HashMap<Currency, Money> {
497            self.current_balances.clone()
498        }
499        fn id(&self) -> AccountId {
500            todo!()
501        }
502        fn account_type(&self) -> AccountType {
503            todo!()
504        }
505        fn base_currency(&self) -> Option<Currency> {
506            todo!()
507        }
508        fn is_cash_account(&self) -> bool {
509            todo!()
510        }
511        fn is_margin_account(&self) -> bool {
512            todo!()
513        }
514        fn calculated_account_state(&self) -> bool {
515            todo!()
516        }
517        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
518            todo!()
519        }
520        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
521            todo!()
522        }
523        fn balances_free(&self) -> HashMap<Currency, Money> {
524            todo!()
525        }
526        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
527            todo!()
528        }
529        fn balances_locked(&self) -> HashMap<Currency, Money> {
530            todo!()
531        }
532        fn last_event(&self) -> Option<AccountState> {
533            todo!()
534        }
535        fn events(&self) -> Vec<AccountState> {
536            todo!()
537        }
538        fn event_count(&self) -> usize {
539            todo!()
540        }
541        fn currencies(&self) -> Vec<Currency> {
542            todo!()
543        }
544        fn balances(&self) -> HashMap<Currency, AccountBalance> {
545            todo!()
546        }
547        fn apply(&mut self, _: AccountState) {
548            todo!()
549        }
550        fn calculate_balance_locked(
551            &mut self,
552            _: InstrumentAny,
553            _: OrderSide,
554            _: Quantity,
555            _: Price,
556            _: Option<bool>,
557        ) -> Result<Money, anyhow::Error> {
558            todo!()
559        }
560        fn calculate_pnls(
561            &self,
562            _: InstrumentAny,
563            _: OrderFilled,
564            _: Option<Position>,
565        ) -> Result<Vec<Money>, anyhow::Error> {
566            todo!()
567        }
568        fn calculate_commission(
569            &self,
570            _: InstrumentAny,
571            _: Quantity,
572            _: Price,
573            _: LiquiditySide,
574            _: Option<bool>,
575        ) -> Result<Money, anyhow::Error> {
576            todo!()
577        }
578
579        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
580            todo!()
581        }
582    }
583
584    #[test]
585    fn test_register_and_deregister_statistics() {
586        let mut analyzer = PortfolioAnalyzer::new();
587        let stat = Arc::new(MockStatistic::new("test_stat"));
588
589        // Test registration
590        analyzer.register_statistic(Arc::clone(&stat));
591        assert!(analyzer.statistic("test_stat").is_some());
592
593        // Test deregistration
594        analyzer.deregister_statistic(Arc::clone(&stat));
595        assert!(analyzer.statistic("test_stat").is_none());
596
597        // Test deregister all
598        let stat1 = Arc::new(MockStatistic::new("stat1"));
599        let stat2 = Arc::new(MockStatistic::new("stat2"));
600        analyzer.register_statistic(Arc::clone(&stat1));
601        analyzer.register_statistic(Arc::clone(&stat2));
602        analyzer.deregister_statistics();
603        assert!(analyzer.statistics.is_empty());
604    }
605
606    #[test]
607    fn test_calculate_total_pnl() {
608        let mut analyzer = PortfolioAnalyzer::new();
609        let currency = Currency::USD();
610
611        // Set up mock account data
612        let mut starting_balances = HashMap::new();
613        starting_balances.insert(currency, Money::new(1000.0, currency));
614
615        let mut current_balances = HashMap::new();
616        current_balances.insert(currency, Money::new(1500.0, currency));
617
618        let account = MockAccount {
619            starting_balances,
620            current_balances,
621        };
622
623        analyzer.calculate_statistics(&account, &[]);
624
625        // Test total PnL calculation
626        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
627        assert_eq!(result, 500.0);
628
629        // Test with unrealized PnL
630        let unrealized_pnl = Money::new(100.0, currency);
631        let result = analyzer
632            .total_pnl(Some(&currency), Some(&unrealized_pnl))
633            .unwrap();
634        assert_eq!(result, 600.0);
635    }
636
637    #[test]
638    fn test_calculate_total_pnl_percentage() {
639        let mut analyzer = PortfolioAnalyzer::new();
640        let currency = Currency::USD();
641
642        // Set up mock account data
643        let mut starting_balances = HashMap::new();
644        starting_balances.insert(currency, Money::new(1000.0, currency));
645
646        let mut current_balances = HashMap::new();
647        current_balances.insert(currency, Money::new(1500.0, currency));
648
649        let account = MockAccount {
650            starting_balances,
651            current_balances,
652        };
653
654        analyzer.calculate_statistics(&account, &[]);
655
656        // Test percentage calculation
657        let result = analyzer
658            .total_pnl_percentage(Some(&currency), None)
659            .unwrap();
660        assert_eq!(result, 50.0); // (1500 - 1000) / 1000 * 100
661
662        // Test with unrealized PnL
663        let unrealized_pnl = Money::new(500.0, currency);
664        let result = analyzer
665            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
666            .unwrap();
667        assert_eq!(result, 100.0); // (2000 - 1000) / 1000 * 100
668    }
669
670    #[test]
671    fn test_add_positions_and_returns() {
672        let mut analyzer = PortfolioAnalyzer::new();
673        let currency = Currency::USD();
674
675        let positions = vec![
676            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
677            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
678        ];
679
680        analyzer.add_positions(&positions);
681
682        // Verify realized PnLs were recorded
683        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
684        assert_eq!(pnls.len(), 2);
685        assert_eq!(pnls[0].1, 100.0);
686        assert_eq!(pnls[1].1, 200.0);
687
688        // Verify returns were recorded
689        let returns = analyzer.returns();
690        assert_eq!(returns.len(), 1);
691        assert_eq!(*returns.values().next().unwrap(), 0.30000000000000004);
692    }
693
694    #[test]
695    fn test_performance_stats_calculation() {
696        let mut analyzer = PortfolioAnalyzer::new();
697        let currency = Currency::USD();
698        let stat = Arc::new(MockStatistic::new("test_stat"));
699        analyzer.register_statistic(Arc::clone(&stat));
700
701        // Add some positions
702        let positions = vec![
703            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
704            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
705        ];
706
707        let mut starting_balances = HashMap::new();
708        starting_balances.insert(currency, Money::new(1000.0, currency));
709
710        let mut current_balances = HashMap::new();
711        current_balances.insert(currency, Money::new(1500.0, currency));
712
713        let account = MockAccount {
714            starting_balances,
715            current_balances,
716        };
717
718        analyzer.calculate_statistics(&account, &positions);
719
720        // Test PnL stats
721        let pnl_stats = analyzer
722            .get_performance_stats_pnls(Some(&currency), None)
723            .unwrap();
724        assert!(pnl_stats.contains_key("PnL (total)"));
725        assert!(pnl_stats.contains_key("PnL% (total)"));
726        assert!(pnl_stats.contains_key("test_stat"));
727
728        // Test returns stats
729        let return_stats = analyzer.get_performance_stats_returns();
730        assert!(return_stats.contains_key("test_stat"));
731
732        // Test general stats
733        let general_stats = analyzer.get_performance_stats_general();
734        assert!(general_stats.contains_key("test_stat"));
735    }
736
737    #[test]
738    fn test_formatted_output() {
739        let mut analyzer = PortfolioAnalyzer::new();
740        let currency = Currency::USD();
741        let stat = Arc::new(MockStatistic::new("test_stat"));
742        analyzer.register_statistic(Arc::clone(&stat));
743
744        let positions = vec![
745            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
746            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
747        ];
748
749        let mut starting_balances = HashMap::new();
750        starting_balances.insert(currency, Money::new(1000.0, currency));
751
752        let mut current_balances = HashMap::new();
753        current_balances.insert(currency, Money::new(1500.0, currency));
754
755        let account = MockAccount {
756            starting_balances,
757            current_balances,
758        };
759
760        analyzer.calculate_statistics(&account, &positions);
761
762        // Test formatted outputs
763        let pnl_formatted = analyzer
764            .get_stats_pnls_formatted(Some(&currency), None)
765            .unwrap();
766        assert!(!pnl_formatted.is_empty());
767        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
768
769        let returns_formatted = analyzer.get_stats_returns_formatted();
770        assert!(!returns_formatted.is_empty());
771        assert!(returns_formatted.iter().all(|s| s.contains(':')));
772
773        let general_formatted = analyzer.get_stats_general_formatted();
774        assert!(!general_formatted.is_empty());
775        assert!(general_formatted.iter().all(|s| s.contains(':')));
776    }
777
778    #[test]
779    fn test_reset() {
780        let mut analyzer = PortfolioAnalyzer::new();
781        let currency = Currency::USD();
782
783        let positions = vec![create_mock_position(
784            "AUD/USD".to_owned(),
785            100.0,
786            0.1,
787            currency,
788        )];
789        let mut starting_balances = HashMap::new();
790        starting_balances.insert(currency, Money::new(1000.0, currency));
791        let mut current_balances = HashMap::new();
792        current_balances.insert(currency, Money::new(1500.0, currency));
793
794        let account = MockAccount {
795            starting_balances,
796            current_balances,
797        };
798
799        analyzer.calculate_statistics(&account, &positions);
800
801        analyzer.reset();
802
803        assert!(analyzer.account_balances_starting.is_empty());
804        assert!(analyzer.account_balances.is_empty());
805        assert!(analyzer.realized_pnls.is_empty());
806        assert!(analyzer.returns.is_empty());
807    }
808}