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 types::{AccountBalance, Money, Price, Quantity},
469 };
470 use rstest::rstest;
471
472 use super::*;
473
474 #[derive(Debug)]
476 struct MockStatistic {
477 name: String,
478 }
479
480 impl MockStatistic {
481 fn new(name: &str) -> Self {
482 Self {
483 name: name.to_string(),
484 }
485 }
486 }
487
488 impl PortfolioStatistic for MockStatistic {
489 type Item = f64;
490
491 fn name(&self) -> String {
492 self.name.clone()
493 }
494
495 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
496 Some(pnls.iter().sum())
497 }
498
499 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
500 Some(returns.values().sum())
501 }
502
503 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
504 Some(positions.len() as f64)
505 }
506 }
507
508 fn create_mock_position(
509 id: String,
510 realized_pnl: f64,
511 realized_return: f64,
512 currency: Currency,
513 ) -> Position {
514 Position {
515 events: Vec::new(),
516 adjustments: Vec::new(),
517 trader_id: trader_id(),
518 strategy_id: strategy_id_ema_cross(),
519 instrument_id: instrument_id_aud_usd_sim(),
520 id: PositionId::new(&id),
521 account_id: AccountId::new("test-account"),
522 opening_order_id: ClientOrderId::default(),
523 closing_order_id: None,
524 entry: OrderSide::NoOrderSide,
525 side: PositionSide::NoPositionSide,
526 signed_qty: 0.0,
527 quantity: Quantity::default(),
528 peak_qty: Quantity::default(),
529 price_precision: 2,
530 size_precision: 2,
531 multiplier: Quantity::default(),
532 is_inverse: false,
533 is_currency_pair: true,
534 instrument_class: InstrumentClass::Spot,
535 base_currency: None,
536 quote_currency: Currency::USD(),
537 settlement_currency: Currency::USD(),
538 ts_init: UnixNanos::default(),
539 ts_opened: UnixNanos::default(),
540 ts_last: UnixNanos::default(),
541 ts_closed: None,
542 duration_ns: 2,
543 avg_px_open: 0.0,
544 avg_px_close: None,
545 realized_return,
546 realized_pnl: Some(Money::new(realized_pnl, currency)),
547 trade_ids: Vec::new(),
548 buy_qty: Quantity::default(),
549 sell_qty: Quantity::default(),
550 commissions: AHashMap::new(),
551 }
552 }
553
554 struct MockAccount {
555 starting_balances: AHashMap<Currency, Money>,
556 current_balances: AHashMap<Currency, Money>,
557 }
558
559 impl Account for MockAccount {
560 fn starting_balances(&self) -> AHashMap<Currency, Money> {
561 self.starting_balances.clone()
562 }
563 fn balances_total(&self) -> AHashMap<Currency, Money> {
564 self.current_balances.clone()
565 }
566 fn id(&self) -> AccountId {
567 todo!()
568 }
569 fn account_type(&self) -> AccountType {
570 todo!()
571 }
572 fn base_currency(&self) -> Option<Currency> {
573 todo!()
574 }
575 fn is_cash_account(&self) -> bool {
576 todo!()
577 }
578 fn is_margin_account(&self) -> bool {
579 todo!()
580 }
581 fn calculated_account_state(&self) -> bool {
582 todo!()
583 }
584 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
585 todo!()
586 }
587 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
588 todo!()
589 }
590 fn balances_free(&self) -> AHashMap<Currency, Money> {
591 todo!()
592 }
593 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
594 todo!()
595 }
596 fn balances_locked(&self) -> AHashMap<Currency, Money> {
597 todo!()
598 }
599 fn last_event(&self) -> Option<AccountState> {
600 todo!()
601 }
602 fn events(&self) -> Vec<AccountState> {
603 todo!()
604 }
605 fn event_count(&self) -> usize {
606 todo!()
607 }
608 fn currencies(&self) -> Vec<Currency> {
609 todo!()
610 }
611 fn balances(&self) -> AHashMap<Currency, AccountBalance> {
612 todo!()
613 }
614 fn apply(&mut self, _: AccountState) {
615 todo!()
616 }
617 fn calculate_balance_locked(
618 &mut self,
619 _: InstrumentAny,
620 _: OrderSide,
621 _: Quantity,
622 _: Price,
623 _: Option<bool>,
624 ) -> Result<Money, anyhow::Error> {
625 todo!()
626 }
627 fn calculate_pnls(
628 &self,
629 _: InstrumentAny,
630 _: OrderFilled,
631 _: Option<Position>,
632 ) -> Result<Vec<Money>, anyhow::Error> {
633 todo!()
634 }
635 fn calculate_commission(
636 &self,
637 _: InstrumentAny,
638 _: Quantity,
639 _: Price,
640 _: LiquiditySide,
641 _: Option<bool>,
642 ) -> Result<Money, anyhow::Error> {
643 todo!()
644 }
645
646 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
647 todo!()
648 }
649
650 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
651 }
653 }
654
655 #[rstest]
656 fn test_register_and_deregister_statistics() {
657 let mut analyzer = PortfolioAnalyzer::new();
658 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
659 Arc::new(MockStatistic::new("test_stat"));
660
661 analyzer.register_statistic(Arc::clone(&stat));
663 assert!(analyzer.statistic("test_stat").is_some());
664
665 analyzer.deregister_statistic(Arc::clone(&stat));
667 assert!(analyzer.statistic("test_stat").is_none());
668
669 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
671 Arc::new(MockStatistic::new("stat1"));
672 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
673 Arc::new(MockStatistic::new("stat2"));
674 analyzer.register_statistic(Arc::clone(&stat1));
675 analyzer.register_statistic(Arc::clone(&stat2));
676 analyzer.deregister_statistics();
677 assert!(analyzer.statistics.is_empty());
678 }
679
680 #[rstest]
681 fn test_calculate_total_pnl() {
682 let mut analyzer = PortfolioAnalyzer::new();
683 let currency = Currency::USD();
684
685 let mut starting_balances = AHashMap::new();
687 starting_balances.insert(currency, Money::new(1000.0, currency));
688
689 let mut current_balances = AHashMap::new();
690 current_balances.insert(currency, Money::new(1500.0, currency));
691
692 let account = MockAccount {
693 starting_balances,
694 current_balances,
695 };
696
697 analyzer.calculate_statistics(&account, &[]);
698
699 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
701 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
702
703 let unrealized_pnl = Money::new(100.0, currency);
705 let result = analyzer
706 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
707 .unwrap();
708 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
709 }
710
711 #[rstest]
712 fn test_calculate_total_pnl_percentage() {
713 let mut analyzer = PortfolioAnalyzer::new();
714 let currency = Currency::USD();
715
716 let mut starting_balances = AHashMap::new();
718 starting_balances.insert(currency, Money::new(1000.0, currency));
719
720 let mut current_balances = AHashMap::new();
721 current_balances.insert(currency, Money::new(1500.0, currency));
722
723 let account = MockAccount {
724 starting_balances,
725 current_balances,
726 };
727
728 analyzer.calculate_statistics(&account, &[]);
729
730 let result = analyzer
732 .total_pnl_percentage(Some(¤cy), None)
733 .unwrap();
734 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
738 let result = analyzer
739 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
740 .unwrap();
741 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
743
744 #[rstest]
745 fn test_add_positions_and_returns() {
746 let mut analyzer = PortfolioAnalyzer::new();
747 let currency = Currency::USD();
748
749 let positions = vec![
750 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
751 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
752 ];
753
754 analyzer.add_positions(&positions);
755
756 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
758 assert_eq!(pnls.len(), 2);
759 assert!(approx_eq!(f64, pnls[0].1, 100.0, epsilon = 1e-9));
760 assert!(approx_eq!(f64, pnls[1].1, 200.0, epsilon = 1e-9));
761
762 let returns = analyzer.returns();
764 assert_eq!(returns.len(), 1);
765 assert!(approx_eq!(
766 f64,
767 *returns.values().next().unwrap(),
768 0.30000000000000004,
769 epsilon = 1e-9
770 ));
771 }
772
773 #[rstest]
774 fn test_performance_stats_calculation() {
775 let mut analyzer = PortfolioAnalyzer::new();
776 let currency = Currency::USD();
777 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
778 Arc::new(MockStatistic::new("test_stat"));
779 analyzer.register_statistic(Arc::clone(&stat));
780
781 let positions = vec![
783 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
784 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
785 ];
786
787 let mut starting_balances = AHashMap::new();
788 starting_balances.insert(currency, Money::new(1000.0, currency));
789
790 let mut current_balances = AHashMap::new();
791 current_balances.insert(currency, Money::new(1500.0, currency));
792
793 let account = MockAccount {
794 starting_balances,
795 current_balances,
796 };
797
798 analyzer.calculate_statistics(&account, &positions);
799
800 let pnl_stats = analyzer
802 .get_performance_stats_pnls(Some(¤cy), None)
803 .unwrap();
804 assert!(pnl_stats.contains_key("PnL (total)"));
805 assert!(pnl_stats.contains_key("PnL% (total)"));
806 assert!(pnl_stats.contains_key("test_stat"));
807
808 let return_stats = analyzer.get_performance_stats_returns();
810 assert!(return_stats.contains_key("test_stat"));
811
812 let general_stats = analyzer.get_performance_stats_general();
814 assert!(general_stats.contains_key("test_stat"));
815 }
816
817 #[rstest]
818 fn test_formatted_output() {
819 let mut analyzer = PortfolioAnalyzer::new();
820 let currency = Currency::USD();
821 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
822 Arc::new(MockStatistic::new("test_stat"));
823 analyzer.register_statistic(Arc::clone(&stat));
824
825 let positions = vec![
826 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
827 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
828 ];
829
830 let mut starting_balances = AHashMap::new();
831 starting_balances.insert(currency, Money::new(1000.0, currency));
832
833 let mut current_balances = AHashMap::new();
834 current_balances.insert(currency, Money::new(1500.0, currency));
835
836 let account = MockAccount {
837 starting_balances,
838 current_balances,
839 };
840
841 analyzer.calculate_statistics(&account, &positions);
842
843 let pnl_formatted = analyzer
845 .get_stats_pnls_formatted(Some(¤cy), None)
846 .unwrap();
847 assert!(!pnl_formatted.is_empty());
848 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
849
850 let returns_formatted = analyzer.get_stats_returns_formatted();
851 assert!(!returns_formatted.is_empty());
852 assert!(returns_formatted.iter().all(|s| s.contains(':')));
853
854 let general_formatted = analyzer.get_stats_general_formatted();
855 assert!(!general_formatted.is_empty());
856 assert!(general_formatted.iter().all(|s| s.contains(':')));
857 }
858
859 #[rstest]
860 fn test_reset() {
861 let mut analyzer = PortfolioAnalyzer::new();
862 let currency = Currency::USD();
863
864 let positions = vec![create_mock_position(
865 "AUD/USD".to_owned(),
866 100.0,
867 0.1,
868 currency,
869 )];
870 let mut starting_balances = AHashMap::new();
871 starting_balances.insert(currency, Money::new(1000.0, currency));
872 let mut current_balances = AHashMap::new();
873 current_balances.insert(currency, Money::new(1500.0, currency));
874
875 let account = MockAccount {
876 starting_balances,
877 current_balances,
878 };
879
880 analyzer.calculate_statistics(&account, &positions);
881
882 analyzer.reset();
883
884 assert!(analyzer.account_balances_starting.is_empty());
885 assert!(analyzer.account_balances.is_empty());
886 assert!(analyzer.positions.is_empty());
887 assert!(analyzer.realized_pnls.is_empty());
888 assert!(analyzer.returns.is_empty());
889 }
890
891 #[rstest]
892 fn test_calculate_statistics_clears_previous_positions() {
893 let mut analyzer = PortfolioAnalyzer::new();
894 let currency = Currency::USD();
895
896 let positions1 = vec![create_mock_position(
897 "pos1".to_owned(),
898 100.0,
899 0.1,
900 currency,
901 )];
902 let positions2 = vec![create_mock_position(
903 "pos2".to_owned(),
904 200.0,
905 0.2,
906 currency,
907 )];
908
909 let mut starting_balances = AHashMap::new();
910 starting_balances.insert(currency, Money::new(1000.0, currency));
911 let mut current_balances = AHashMap::new();
912 current_balances.insert(currency, Money::new(1500.0, currency));
913
914 let account = MockAccount {
915 starting_balances,
916 current_balances,
917 };
918
919 analyzer.calculate_statistics(&account, &positions1);
921 assert_eq!(analyzer.positions.len(), 1);
922
923 analyzer.calculate_statistics(&account, &positions2);
925 assert_eq!(analyzer.positions.len(), 1);
926 }
927}