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_core::approx_eq;
424    use nautilus_model::{
425        enums::{AccountType, LiquiditySide, OrderSide},
426        events::{AccountState, OrderFilled},
427        identifiers::{
428            AccountId, ClientOrderId,
429            stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
430        },
431        instruments::InstrumentAny,
432        types::{AccountBalance, Money, Price, Quantity},
433    };
434    use rstest::rstest;
435
436    use super::*;
437
438    /// Mock implementation of `PortfolioStatistic` for testing.
439    #[derive(Debug)]
440    struct MockStatistic {
441        name: String,
442    }
443
444    impl MockStatistic {
445        fn new(name: &str) -> Self {
446            Self {
447                name: name.to_string(),
448            }
449        }
450    }
451
452    impl PortfolioStatistic for MockStatistic {
453        type Item = f64;
454
455        fn name(&self) -> String {
456            self.name.clone()
457        }
458
459        fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
460            Some(pnls.iter().sum())
461        }
462
463        fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
464            Some(returns.values().sum())
465        }
466
467        fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
468            Some(positions.len() as f64)
469        }
470    }
471
472    fn create_mock_position(
473        id: String,
474        realized_pnl: f64,
475        realized_return: f64,
476        currency: Currency,
477    ) -> Position {
478        Position {
479            events: Vec::new(),
480            trader_id: trader_id(),
481            strategy_id: strategy_id_ema_cross(),
482            instrument_id: instrument_id_aud_usd_sim(),
483            id: PositionId::new(&id),
484            account_id: AccountId::new("test-account"),
485            opening_order_id: ClientOrderId::default(),
486            closing_order_id: None,
487            entry: OrderSide::NoOrderSide,
488            side: nautilus_model::enums::PositionSide::NoPositionSide,
489            signed_qty: 0.0,
490            quantity: Quantity::default(),
491            peak_qty: Quantity::default(),
492            price_precision: 2,
493            size_precision: 2,
494            multiplier: Quantity::default(),
495            is_inverse: false,
496            base_currency: None,
497            quote_currency: Currency::USD(),
498            settlement_currency: Currency::USD(),
499            ts_init: UnixNanos::default(),
500            ts_opened: UnixNanos::default(),
501            ts_last: UnixNanos::default(),
502            ts_closed: None,
503            duration_ns: 2,
504            avg_px_open: 0.0,
505            avg_px_close: None,
506            realized_return,
507            realized_pnl: Some(Money::new(realized_pnl, currency)),
508            trade_ids: Vec::new(),
509            buy_qty: Quantity::default(),
510            sell_qty: Quantity::default(),
511            commissions: HashMap::new(),
512        }
513    }
514
515    struct MockAccount {
516        starting_balances: HashMap<Currency, Money>,
517        current_balances: HashMap<Currency, Money>,
518    }
519
520    impl Account for MockAccount {
521        fn starting_balances(&self) -> HashMap<Currency, Money> {
522            self.starting_balances.clone()
523        }
524        fn balances_total(&self) -> HashMap<Currency, Money> {
525            self.current_balances.clone()
526        }
527        fn id(&self) -> AccountId {
528            todo!()
529        }
530        fn account_type(&self) -> AccountType {
531            todo!()
532        }
533        fn base_currency(&self) -> Option<Currency> {
534            todo!()
535        }
536        fn is_cash_account(&self) -> bool {
537            todo!()
538        }
539        fn is_margin_account(&self) -> bool {
540            todo!()
541        }
542        fn calculated_account_state(&self) -> bool {
543            todo!()
544        }
545        fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
546            todo!()
547        }
548        fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
549            todo!()
550        }
551        fn balances_free(&self) -> HashMap<Currency, Money> {
552            todo!()
553        }
554        fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
555            todo!()
556        }
557        fn balances_locked(&self) -> HashMap<Currency, Money> {
558            todo!()
559        }
560        fn last_event(&self) -> Option<AccountState> {
561            todo!()
562        }
563        fn events(&self) -> Vec<AccountState> {
564            todo!()
565        }
566        fn event_count(&self) -> usize {
567            todo!()
568        }
569        fn currencies(&self) -> Vec<Currency> {
570            todo!()
571        }
572        fn balances(&self) -> HashMap<Currency, AccountBalance> {
573            todo!()
574        }
575        fn apply(&mut self, _: AccountState) {
576            todo!()
577        }
578        fn calculate_balance_locked(
579            &mut self,
580            _: InstrumentAny,
581            _: OrderSide,
582            _: Quantity,
583            _: Price,
584            _: Option<bool>,
585        ) -> Result<Money, anyhow::Error> {
586            todo!()
587        }
588        fn calculate_pnls(
589            &self,
590            _: InstrumentAny,
591            _: OrderFilled,
592            _: Option<Position>,
593        ) -> Result<Vec<Money>, anyhow::Error> {
594            todo!()
595        }
596        fn calculate_commission(
597            &self,
598            _: InstrumentAny,
599            _: Quantity,
600            _: Price,
601            _: LiquiditySide,
602            _: Option<bool>,
603        ) -> Result<Money, anyhow::Error> {
604            todo!()
605        }
606
607        fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
608            todo!()
609        }
610
611        fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
612            // MockAccount doesn't need purging
613        }
614    }
615
616    #[rstest]
617    fn test_register_and_deregister_statistics() {
618        let mut analyzer = PortfolioAnalyzer::new();
619        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
620            Arc::new(MockStatistic::new("test_stat"));
621
622        // Test registration
623        analyzer.register_statistic(Arc::clone(&stat));
624        assert!(analyzer.statistic("test_stat").is_some());
625
626        // Test deregistration
627        analyzer.deregister_statistic(Arc::clone(&stat));
628        assert!(analyzer.statistic("test_stat").is_none());
629
630        // Test deregister all
631        let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
632            Arc::new(MockStatistic::new("stat1"));
633        let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
634            Arc::new(MockStatistic::new("stat2"));
635        analyzer.register_statistic(Arc::clone(&stat1));
636        analyzer.register_statistic(Arc::clone(&stat2));
637        analyzer.deregister_statistics();
638        assert!(analyzer.statistics.is_empty());
639    }
640
641    #[rstest]
642    fn test_calculate_total_pnl() {
643        let mut analyzer = PortfolioAnalyzer::new();
644        let currency = Currency::USD();
645
646        // Set up mock account data
647        let mut starting_balances = HashMap::new();
648        starting_balances.insert(currency, Money::new(1000.0, currency));
649
650        let mut current_balances = HashMap::new();
651        current_balances.insert(currency, Money::new(1500.0, currency));
652
653        let account = MockAccount {
654            starting_balances,
655            current_balances,
656        };
657
658        analyzer.calculate_statistics(&account, &[]);
659
660        // Test total PnL calculation
661        let result = analyzer.total_pnl(Some(&currency), None).unwrap();
662        assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
663
664        // Test with unrealized PnL
665        let unrealized_pnl = Money::new(100.0, currency);
666        let result = analyzer
667            .total_pnl(Some(&currency), Some(&unrealized_pnl))
668            .unwrap();
669        assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
670    }
671
672    #[rstest]
673    fn test_calculate_total_pnl_percentage() {
674        let mut analyzer = PortfolioAnalyzer::new();
675        let currency = Currency::USD();
676
677        // Set up mock account data
678        let mut starting_balances = HashMap::new();
679        starting_balances.insert(currency, Money::new(1000.0, currency));
680
681        let mut current_balances = HashMap::new();
682        current_balances.insert(currency, Money::new(1500.0, currency));
683
684        let account = MockAccount {
685            starting_balances,
686            current_balances,
687        };
688
689        analyzer.calculate_statistics(&account, &[]);
690
691        // Test percentage calculation
692        let result = analyzer
693            .total_pnl_percentage(Some(&currency), None)
694            .unwrap();
695        assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); // (1500 - 1000) / 1000 * 100
696
697        // Test with unrealized PnL
698        let unrealized_pnl = Money::new(500.0, currency);
699        let result = analyzer
700            .total_pnl_percentage(Some(&currency), Some(&unrealized_pnl))
701            .unwrap();
702        assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); // (2000 - 1000) / 1000 * 100
703    }
704
705    #[rstest]
706    fn test_add_positions_and_returns() {
707        let mut analyzer = PortfolioAnalyzer::new();
708        let currency = Currency::USD();
709
710        let positions = vec![
711            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
712            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
713        ];
714
715        analyzer.add_positions(&positions);
716
717        // Verify realized PnLs were recorded
718        let pnls = analyzer.realized_pnls(Some(&currency)).unwrap();
719        assert_eq!(pnls.len(), 2);
720        assert!(approx_eq!(f64, pnls[0].1, 100.0, epsilon = 1e-9));
721        assert!(approx_eq!(f64, pnls[1].1, 200.0, epsilon = 1e-9));
722
723        // Verify returns were recorded
724        let returns = analyzer.returns();
725        assert_eq!(returns.len(), 1);
726        assert!(approx_eq!(
727            f64,
728            *returns.values().next().unwrap(),
729            0.30000000000000004,
730            epsilon = 1e-9
731        ));
732    }
733
734    #[rstest]
735    fn test_performance_stats_calculation() {
736        let mut analyzer = PortfolioAnalyzer::new();
737        let currency = Currency::USD();
738        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
739            Arc::new(MockStatistic::new("test_stat"));
740        analyzer.register_statistic(Arc::clone(&stat));
741
742        // Add some positions
743        let positions = vec![
744            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
745            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
746        ];
747
748        let mut starting_balances = HashMap::new();
749        starting_balances.insert(currency, Money::new(1000.0, currency));
750
751        let mut current_balances = HashMap::new();
752        current_balances.insert(currency, Money::new(1500.0, currency));
753
754        let account = MockAccount {
755            starting_balances,
756            current_balances,
757        };
758
759        analyzer.calculate_statistics(&account, &positions);
760
761        // Test PnL stats
762        let pnl_stats = analyzer
763            .get_performance_stats_pnls(Some(&currency), None)
764            .unwrap();
765        assert!(pnl_stats.contains_key("PnL (total)"));
766        assert!(pnl_stats.contains_key("PnL% (total)"));
767        assert!(pnl_stats.contains_key("test_stat"));
768
769        // Test returns stats
770        let return_stats = analyzer.get_performance_stats_returns();
771        assert!(return_stats.contains_key("test_stat"));
772
773        // Test general stats
774        let general_stats = analyzer.get_performance_stats_general();
775        assert!(general_stats.contains_key("test_stat"));
776    }
777
778    #[rstest]
779    fn test_formatted_output() {
780        let mut analyzer = PortfolioAnalyzer::new();
781        let currency = Currency::USD();
782        let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
783            Arc::new(MockStatistic::new("test_stat"));
784        analyzer.register_statistic(Arc::clone(&stat));
785
786        let positions = vec![
787            create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
788            create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
789        ];
790
791        let mut starting_balances = HashMap::new();
792        starting_balances.insert(currency, Money::new(1000.0, currency));
793
794        let mut current_balances = HashMap::new();
795        current_balances.insert(currency, Money::new(1500.0, currency));
796
797        let account = MockAccount {
798            starting_balances,
799            current_balances,
800        };
801
802        analyzer.calculate_statistics(&account, &positions);
803
804        // Test formatted outputs
805        let pnl_formatted = analyzer
806            .get_stats_pnls_formatted(Some(&currency), None)
807            .unwrap();
808        assert!(!pnl_formatted.is_empty());
809        assert!(pnl_formatted.iter().all(|s| s.contains(':')));
810
811        let returns_formatted = analyzer.get_stats_returns_formatted();
812        assert!(!returns_formatted.is_empty());
813        assert!(returns_formatted.iter().all(|s| s.contains(':')));
814
815        let general_formatted = analyzer.get_stats_general_formatted();
816        assert!(!general_formatted.is_empty());
817        assert!(general_formatted.iter().all(|s| s.contains(':')));
818    }
819
820    #[rstest]
821    fn test_reset() {
822        let mut analyzer = PortfolioAnalyzer::new();
823        let currency = Currency::USD();
824
825        let positions = vec![create_mock_position(
826            "AUD/USD".to_owned(),
827            100.0,
828            0.1,
829            currency,
830        )];
831        let mut starting_balances = HashMap::new();
832        starting_balances.insert(currency, Money::new(1000.0, currency));
833        let mut current_balances = HashMap::new();
834        current_balances.insert(currency, Money::new(1500.0, currency));
835
836        let account = MockAccount {
837            starting_balances,
838            current_balances,
839        };
840
841        analyzer.calculate_statistics(&account, &positions);
842
843        analyzer.reset();
844
845        assert!(analyzer.account_balances_starting.is_empty());
846        assert!(analyzer.account_balances.is_empty());
847        assert!(analyzer.realized_pnls.is_empty());
848        assert!(analyzer.returns.is_empty());
849    }
850}