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