1use 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#[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 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 #[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 pub fn register_statistic(&mut self, statistic: Statistic) {
106 self.statistics.insert(statistic.name(), statistic);
107 }
108
109 pub fn deregister_statistic(&mut self, statistic: Statistic) {
111 self.statistics.remove(&statistic.name());
112 }
113
114 pub fn deregister_statistics(&mut self) {
116 self.statistics.clear();
117 }
118
119 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 #[must_use]
130 pub fn currencies(&self) -> Vec<&Currency> {
131 self.account_balances.keys().collect()
132 }
133
134 #[must_use]
136 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
137 self.statistics.get(name)
138 }
139
140 #[must_use]
142 pub const fn returns(&self) -> &Returns {
143 &self.returns
144 }
145
146 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 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 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 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 #[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 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 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 let currency = match currency {
233 Some(c) => c,
234 None if self.account_balances.len() == 1 => {
235 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 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 let currency = match currency {
286 Some(c) => c,
287 None if self.account_balances.len() == 1 => {
288 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 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 #[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 #[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 fn get_max_length_name(&self) -> usize {
394 self.statistics.keys().map(String::len).max().unwrap_or(0)
395 }
396
397 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 output = Vec::new();
411 for (k, v) in stats {
412 let padding = if max_length > k.len() {
413 max_length - k.len() + 1
414 } else {
415 1
416 };
417 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
418 }
419
420 Ok(output)
421 }
422
423 #[must_use]
425 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
426 let max_length = self.get_max_length_name();
427 let stats = self.get_performance_stats_returns();
428
429 let mut output = Vec::new();
430 for (k, v) in stats {
431 let padding = max_length - k.len() + 1;
432 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
433 }
434
435 output
436 }
437
438 #[must_use]
440 pub fn get_stats_general_formatted(&self) -> Vec<String> {
441 let max_length = self.get_max_length_name();
442 let stats = self.get_performance_stats_general();
443
444 let mut output = Vec::new();
445 for (k, v) in stats {
446 let padding = max_length - k.len() + 1;
447 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
448 }
449
450 output
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use std::sync::Arc;
457
458 use ahash::AHashMap;
459 use nautilus_core::approx_eq;
460 use nautilus_model::{
461 enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
462 events::{AccountState, OrderFilled},
463 identifiers::{
464 AccountId, ClientOrderId,
465 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
466 },
467 instruments::InstrumentAny,
468 stubs::TestDefault,
469 types::{AccountBalance, Money, Price, Quantity},
470 };
471 use rstest::rstest;
472
473 use super::*;
474
475 #[derive(Debug)]
477 struct MockStatistic {
478 name: String,
479 }
480
481 impl MockStatistic {
482 fn new(name: &str) -> Self {
483 Self {
484 name: name.to_string(),
485 }
486 }
487 }
488
489 impl PortfolioStatistic for MockStatistic {
490 type Item = f64;
491
492 fn name(&self) -> String {
493 self.name.clone()
494 }
495
496 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
497 Some(pnls.iter().sum())
498 }
499
500 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
501 Some(returns.values().sum())
502 }
503
504 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
505 Some(positions.len() as f64)
506 }
507 }
508
509 fn create_mock_position(
510 id: String,
511 realized_pnl: f64,
512 realized_return: f64,
513 currency: Currency,
514 ) -> Position {
515 Position {
516 events: Vec::new(),
517 adjustments: Vec::new(),
518 trader_id: trader_id(),
519 strategy_id: strategy_id_ema_cross(),
520 instrument_id: instrument_id_aud_usd_sim(),
521 id: PositionId::new(&id),
522 account_id: AccountId::new("test-account"),
523 opening_order_id: ClientOrderId::test_default(),
524 closing_order_id: None,
525 entry: OrderSide::NoOrderSide,
526 side: PositionSide::NoPositionSide,
527 signed_qty: 0.0,
528 quantity: Quantity::default(),
529 peak_qty: Quantity::default(),
530 price_precision: 2,
531 size_precision: 2,
532 multiplier: Quantity::default(),
533 is_inverse: false,
534 is_currency_pair: true,
535 instrument_class: InstrumentClass::Spot,
536 base_currency: None,
537 quote_currency: Currency::USD(),
538 settlement_currency: Currency::USD(),
539 ts_init: UnixNanos::default(),
540 ts_opened: UnixNanos::default(),
541 ts_last: UnixNanos::default(),
542 ts_closed: None,
543 duration_ns: 2,
544 avg_px_open: 0.0,
545 avg_px_close: None,
546 realized_return,
547 realized_pnl: Some(Money::new(realized_pnl, currency)),
548 trade_ids: Vec::new(),
549 buy_qty: Quantity::default(),
550 sell_qty: Quantity::default(),
551 commissions: AHashMap::new(),
552 }
553 }
554
555 struct MockAccount {
556 starting_balances: AHashMap<Currency, Money>,
557 current_balances: AHashMap<Currency, Money>,
558 }
559
560 impl Account for MockAccount {
561 fn starting_balances(&self) -> AHashMap<Currency, Money> {
562 self.starting_balances.clone()
563 }
564 fn balances_total(&self) -> AHashMap<Currency, Money> {
565 self.current_balances.clone()
566 }
567 fn id(&self) -> AccountId {
568 todo!()
569 }
570 fn account_type(&self) -> AccountType {
571 todo!()
572 }
573 fn base_currency(&self) -> Option<Currency> {
574 todo!()
575 }
576 fn is_cash_account(&self) -> bool {
577 todo!()
578 }
579 fn is_margin_account(&self) -> bool {
580 todo!()
581 }
582 fn calculated_account_state(&self) -> bool {
583 todo!()
584 }
585 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
586 todo!()
587 }
588 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
589 todo!()
590 }
591 fn balances_free(&self) -> AHashMap<Currency, Money> {
592 todo!()
593 }
594 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
595 todo!()
596 }
597 fn balances_locked(&self) -> AHashMap<Currency, Money> {
598 todo!()
599 }
600 fn last_event(&self) -> Option<AccountState> {
601 todo!()
602 }
603 fn events(&self) -> Vec<AccountState> {
604 todo!()
605 }
606 fn event_count(&self) -> usize {
607 todo!()
608 }
609 fn currencies(&self) -> Vec<Currency> {
610 todo!()
611 }
612 fn balances(&self) -> AHashMap<Currency, AccountBalance> {
613 todo!()
614 }
615 fn apply(&mut self, _: AccountState) {
616 todo!()
617 }
618 fn calculate_balance_locked(
619 &mut self,
620 _: InstrumentAny,
621 _: OrderSide,
622 _: Quantity,
623 _: Price,
624 _: Option<bool>,
625 ) -> Result<Money, anyhow::Error> {
626 todo!()
627 }
628 fn calculate_pnls(
629 &self,
630 _: InstrumentAny,
631 _: OrderFilled,
632 _: Option<Position>,
633 ) -> Result<Vec<Money>, anyhow::Error> {
634 todo!()
635 }
636 fn calculate_commission(
637 &self,
638 _: InstrumentAny,
639 _: Quantity,
640 _: Price,
641 _: LiquiditySide,
642 _: Option<bool>,
643 ) -> Result<Money, anyhow::Error> {
644 todo!()
645 }
646
647 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
648 todo!()
649 }
650
651 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
652 }
654 }
655
656 #[rstest]
657 fn test_register_and_deregister_statistics() {
658 let mut analyzer = PortfolioAnalyzer::new();
659 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
660 Arc::new(MockStatistic::new("test_stat"));
661
662 analyzer.register_statistic(Arc::clone(&stat));
664 assert!(analyzer.statistic("test_stat").is_some());
665
666 analyzer.deregister_statistic(Arc::clone(&stat));
668 assert!(analyzer.statistic("test_stat").is_none());
669
670 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
672 Arc::new(MockStatistic::new("stat1"));
673 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
674 Arc::new(MockStatistic::new("stat2"));
675 analyzer.register_statistic(Arc::clone(&stat1));
676 analyzer.register_statistic(Arc::clone(&stat2));
677 analyzer.deregister_statistics();
678 assert!(analyzer.statistics.is_empty());
679 }
680
681 #[rstest]
682 fn test_calculate_total_pnl() {
683 let mut analyzer = PortfolioAnalyzer::new();
684 let currency = Currency::USD();
685
686 let mut starting_balances = AHashMap::new();
688 starting_balances.insert(currency, Money::new(1000.0, currency));
689
690 let mut current_balances = AHashMap::new();
691 current_balances.insert(currency, Money::new(1500.0, currency));
692
693 let account = MockAccount {
694 starting_balances,
695 current_balances,
696 };
697
698 analyzer.calculate_statistics(&account, &[]);
699
700 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
702 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
703
704 let unrealized_pnl = Money::new(100.0, currency);
706 let result = analyzer
707 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
708 .unwrap();
709 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
710 }
711
712 #[rstest]
713 fn test_calculate_total_pnl_percentage() {
714 let mut analyzer = PortfolioAnalyzer::new();
715 let currency = Currency::USD();
716
717 let mut starting_balances = AHashMap::new();
719 starting_balances.insert(currency, Money::new(1000.0, currency));
720
721 let mut current_balances = AHashMap::new();
722 current_balances.insert(currency, Money::new(1500.0, currency));
723
724 let account = MockAccount {
725 starting_balances,
726 current_balances,
727 };
728
729 analyzer.calculate_statistics(&account, &[]);
730
731 let result = analyzer
733 .total_pnl_percentage(Some(¤cy), None)
734 .unwrap();
735 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
739 let result = analyzer
740 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
741 .unwrap();
742 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
744
745 #[rstest]
746 fn test_add_positions_and_returns() {
747 let mut analyzer = PortfolioAnalyzer::new();
748 let currency = Currency::USD();
749
750 let positions = vec![
751 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
752 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
753 ];
754
755 analyzer.add_positions(&positions);
756
757 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
759 assert_eq!(pnls.len(), 2);
760 assert!(approx_eq!(f64, pnls[0].1, 100.0, epsilon = 1e-9));
761 assert!(approx_eq!(f64, pnls[1].1, 200.0, epsilon = 1e-9));
762
763 let returns = analyzer.returns();
765 assert_eq!(returns.len(), 1);
766 assert!(approx_eq!(
767 f64,
768 *returns.values().next().unwrap(),
769 0.30000000000000004,
770 epsilon = 1e-9
771 ));
772 }
773
774 #[rstest]
775 fn test_performance_stats_calculation() {
776 let mut analyzer = PortfolioAnalyzer::new();
777 let currency = Currency::USD();
778 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
779 Arc::new(MockStatistic::new("test_stat"));
780 analyzer.register_statistic(Arc::clone(&stat));
781
782 let positions = vec![
784 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
785 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
786 ];
787
788 let mut starting_balances = AHashMap::new();
789 starting_balances.insert(currency, Money::new(1000.0, currency));
790
791 let mut current_balances = AHashMap::new();
792 current_balances.insert(currency, Money::new(1500.0, currency));
793
794 let account = MockAccount {
795 starting_balances,
796 current_balances,
797 };
798
799 analyzer.calculate_statistics(&account, &positions);
800
801 let pnl_stats = analyzer
803 .get_performance_stats_pnls(Some(¤cy), None)
804 .unwrap();
805 assert!(pnl_stats.contains_key("PnL (total)"));
806 assert!(pnl_stats.contains_key("PnL% (total)"));
807 assert!(pnl_stats.contains_key("test_stat"));
808
809 let return_stats = analyzer.get_performance_stats_returns();
811 assert!(return_stats.contains_key("test_stat"));
812
813 let general_stats = analyzer.get_performance_stats_general();
815 assert!(general_stats.contains_key("test_stat"));
816 }
817
818 #[rstest]
819 fn test_formatted_output() {
820 let mut analyzer = PortfolioAnalyzer::new();
821 let currency = Currency::USD();
822 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
823 Arc::new(MockStatistic::new("test_stat"));
824 analyzer.register_statistic(Arc::clone(&stat));
825
826 let positions = vec![
827 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
828 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
829 ];
830
831 let mut starting_balances = AHashMap::new();
832 starting_balances.insert(currency, Money::new(1000.0, currency));
833
834 let mut current_balances = AHashMap::new();
835 current_balances.insert(currency, Money::new(1500.0, currency));
836
837 let account = MockAccount {
838 starting_balances,
839 current_balances,
840 };
841
842 analyzer.calculate_statistics(&account, &positions);
843
844 let pnl_formatted = analyzer
846 .get_stats_pnls_formatted(Some(¤cy), None)
847 .unwrap();
848 assert!(!pnl_formatted.is_empty());
849 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
850
851 let returns_formatted = analyzer.get_stats_returns_formatted();
852 assert!(!returns_formatted.is_empty());
853 assert!(returns_formatted.iter().all(|s| s.contains(':')));
854
855 let general_formatted = analyzer.get_stats_general_formatted();
856 assert!(!general_formatted.is_empty());
857 assert!(general_formatted.iter().all(|s| s.contains(':')));
858 }
859
860 #[rstest]
861 fn test_reset() {
862 let mut analyzer = PortfolioAnalyzer::new();
863 let currency = Currency::USD();
864
865 let positions = vec![create_mock_position(
866 "AUD/USD".to_owned(),
867 100.0,
868 0.1,
869 currency,
870 )];
871 let mut starting_balances = AHashMap::new();
872 starting_balances.insert(currency, Money::new(1000.0, currency));
873 let mut current_balances = AHashMap::new();
874 current_balances.insert(currency, Money::new(1500.0, currency));
875
876 let account = MockAccount {
877 starting_balances,
878 current_balances,
879 };
880
881 analyzer.calculate_statistics(&account, &positions);
882
883 analyzer.reset();
884
885 assert!(analyzer.account_balances_starting.is_empty());
886 assert!(analyzer.account_balances.is_empty());
887 assert!(analyzer.positions.is_empty());
888 assert!(analyzer.realized_pnls.is_empty());
889 assert!(analyzer.returns.is_empty());
890 }
891
892 #[rstest]
893 fn test_calculate_statistics_clears_previous_positions() {
894 let mut analyzer = PortfolioAnalyzer::new();
895 let currency = Currency::USD();
896
897 let positions1 = vec![create_mock_position(
898 "pos1".to_owned(),
899 100.0,
900 0.1,
901 currency,
902 )];
903 let positions2 = vec![create_mock_position(
904 "pos2".to_owned(),
905 200.0,
906 0.2,
907 currency,
908 )];
909
910 let mut starting_balances = AHashMap::new();
911 starting_balances.insert(currency, Money::new(1000.0, currency));
912 let mut current_balances = AHashMap::new();
913 current_balances.insert(currency, Money::new(1500.0, currency));
914
915 let account = MockAccount {
916 starting_balances,
917 current_balances,
918 };
919
920 analyzer.calculate_statistics(&account, &positions1);
922 assert_eq!(analyzer.positions.len(), 1);
923
924 analyzer.calculate_statistics(&account, &positions2);
926 assert_eq!(analyzer.positions.len(), 1);
927 }
928}