nautilus_analysis/
analyzer.rs

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