Skip to main content

nautilus_analysis/
analyzer.rs

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