1use std::{collections::BTreeMap, fmt::Debug, sync::Arc};
17
18use ahash::AHashMap;
19use indexmap::IndexMap;
20use nautilus_core::{UnixNanos, datetime::NANOSECONDS_IN_DAY};
21use nautilus_model::{
22 accounts::Account,
23 identifiers::PositionId,
24 position::Position,
25 types::{Currency, Money},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30 Returns,
31 statistic::PortfolioStatistic,
32 statistics::{
33 expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
34 loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
35 returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
36 returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
37 sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
38 winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
39 },
40};
41
42pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
43
44#[repr(C)]
50#[derive(Debug)]
51#[cfg_attr(
52 feature = "python",
53 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
54)]
55#[cfg_attr(
56 feature = "python",
57 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
58)]
59pub struct PortfolioAnalyzer {
60 pub statistics: AHashMap<String, Statistic>,
61 pub account_balances_starting: IndexMap<Currency, Money>,
62 pub account_balances: IndexMap<Currency, Money>,
63 pub positions: Vec<Position>,
64 pub realized_pnls: AHashMap<Currency, Vec<(PositionId, f64)>>,
65 pub position_returns: Returns,
66 pub portfolio_returns: Returns,
67 pub returns: Returns,
72}
73
74impl Default for PortfolioAnalyzer {
75 fn default() -> Self {
77 let mut analyzer = Self::new();
78 analyzer.register_statistic(Arc::new(MaxWinner {}));
79 analyzer.register_statistic(Arc::new(AvgWinner {}));
80 analyzer.register_statistic(Arc::new(MinWinner {}));
81 analyzer.register_statistic(Arc::new(MinLoser {}));
82 analyzer.register_statistic(Arc::new(AvgLoser {}));
83 analyzer.register_statistic(Arc::new(MaxLoser {}));
84 analyzer.register_statistic(Arc::new(Expectancy {}));
85 analyzer.register_statistic(Arc::new(WinRate {}));
86 analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
87 analyzer.register_statistic(Arc::new(ReturnsAverage {}));
88 analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
89 analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
90 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
91 analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
92 analyzer.register_statistic(Arc::new(ProfitFactor {}));
93 analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
94 analyzer.register_statistic(Arc::new(LongRatio::new(None)));
95 analyzer
96 }
97}
98
99impl PortfolioAnalyzer {
100 #[must_use]
104 pub fn new() -> Self {
105 Self {
106 statistics: AHashMap::new(),
107 account_balances_starting: IndexMap::new(),
108 account_balances: IndexMap::new(),
109 positions: Vec::new(),
110 realized_pnls: AHashMap::new(),
111 position_returns: BTreeMap::new(),
112 portfolio_returns: BTreeMap::new(),
113 returns: BTreeMap::new(),
114 }
115 }
116
117 pub fn register_statistic(&mut self, statistic: Statistic) {
119 self.statistics.insert(statistic.name(), statistic);
120 }
121
122 pub fn deregister_statistic(&mut self, statistic: &Statistic) {
124 self.statistics.remove(&statistic.name());
125 }
126
127 pub fn deregister_statistics(&mut self) {
129 self.statistics.clear();
130 }
131
132 pub fn reset(&mut self) {
134 self.account_balances_starting.clear();
135 self.account_balances.clear();
136 self.positions.clear();
137 self.realized_pnls.clear();
138 self.position_returns.clear();
139 self.portfolio_returns.clear();
140 self.returns.clear();
141 }
142
143 #[must_use]
145 pub fn currencies(&self) -> Vec<&Currency> {
146 self.account_balances.keys().collect()
147 }
148
149 #[must_use]
151 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
152 self.statistics.get(name)
153 }
154
155 #[must_use]
160 pub const fn returns(&self) -> &Returns {
161 &self.returns
162 }
163
164 #[must_use]
166 pub const fn position_returns(&self) -> &Returns {
167 &self.position_returns
168 }
169
170 #[must_use]
172 pub const fn portfolio_returns(&self) -> &Returns {
173 &self.portfolio_returns
174 }
175
176 pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
181 self.account_balances_starting = account.starting_balances().into_iter().collect();
182 self.account_balances = account.balances_total().into_iter().collect();
183 self.positions.clear();
184 self.realized_pnls.clear();
185 self.position_returns.clear();
186 self.portfolio_returns.clear();
187 self.returns.clear();
188
189 self.add_positions(positions);
190
191 if let Some(account_returns) = Self::calculate_account_returns(account) {
192 self.portfolio_returns = account_returns;
193 self.sync_returns_alias();
194 }
195 }
196
197 pub fn add_positions(&mut self, positions: &[Position]) {
199 self.positions.extend_from_slice(positions);
200 for position in positions {
201 if let Some(ref pnl) = position.realized_pnl {
202 self.add_trade(&position.id, pnl);
203 }
204
205 if let Some(ts_closed) = position.ts_closed
206 && ts_closed.as_u64() > 0
207 && position.realized_pnl.is_some()
208 {
209 self.add_position_return(ts_closed, position.realized_return);
210 }
211 }
212 }
213
214 pub fn add_trade(&mut self, position_id: &PositionId, pnl: &Money) {
216 let currency = pnl.currency;
217 let entry = self.realized_pnls.entry(currency).or_default();
218 entry.push((*position_id, pnl.as_f64()));
219 }
220
221 pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
223 self.position_returns
224 .entry(timestamp)
225 .and_modify(|existing_value| *existing_value += value)
226 .or_insert(value);
227
228 if self.portfolio_returns.is_empty() {
232 self.returns
233 .entry(timestamp)
234 .and_modify(|existing_value| *existing_value += value)
235 .or_insert(value);
236 }
237 }
238
239 pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
243 self.add_position_return(timestamp, value);
244 }
245
246 fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
257 let mut events = account.events();
258 if events.len() < 2 {
259 return None;
260 }
261
262 events.sort_by_key(|event| event.ts_event);
263
264 let mut currency = None;
265 let mut daily_balances = BTreeMap::new();
266
267 for event in events {
268 if event.balances.len() != 1 {
269 return None;
270 }
271
272 let balance = event.balances[0];
273
274 if let Some(existing_currency) = currency {
275 if existing_currency != balance.currency {
276 return None;
277 }
278 } else {
279 currency = Some(balance.currency);
280 }
281
282 let day_start = UnixNanos::from(
283 event.ts_event.as_u64() - (event.ts_event.as_u64() % NANOSECONDS_IN_DAY),
284 );
285 daily_balances.insert(day_start, balance.total.as_f64());
286 }
287
288 if daily_balances.len() < 2 {
289 return None;
290 }
291
292 let mut returns = Returns::new();
293 let mut current_day = *daily_balances.keys().next()?;
294 let last_day = *daily_balances.keys().next_back()?;
295 let mut current_balance: Option<f64> = None;
296 let mut previous_balance: Option<f64> = None;
297
298 loop {
299 if let Some(balance) = daily_balances.get(¤t_day) {
300 current_balance = Some(*balance);
301 }
302
303 let balance = current_balance?;
304
305 if let Some(previous) = previous_balance
306 && previous != 0.0
307 {
308 let value: f64 = (balance / previous) - 1.0;
309 if value.is_finite() {
310 returns.insert(current_day, value);
311 }
312 }
313
314 previous_balance = Some(balance);
315
316 if current_day >= last_day {
317 break;
318 }
319
320 current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
321 }
322
323 (!returns.is_empty()).then_some(returns)
324 }
325
326 #[must_use]
331 pub fn realized_pnls(&self, currency: Option<&Currency>) -> Option<Vec<(PositionId, f64)>> {
332 if self.realized_pnls.is_empty() {
333 return None;
334 }
335
336 let currency = match currency {
338 Some(c) => c,
339 None if self.account_balances.len() == 1 => self.account_balances.keys().next()?,
340 None => return None,
341 };
342
343 self.realized_pnls.get(currency).cloned()
344 }
345
346 #[expect(clippy::missing_panics_doc)] pub fn total_pnl(
356 &self,
357 currency: Option<&Currency>,
358 unrealized_pnl: Option<&Money>,
359 ) -> Result<f64, &'static str> {
360 if self.account_balances.is_empty() {
361 return Ok(0.0);
362 }
363
364 let currency = match currency {
366 Some(c) => c,
367 None if self.account_balances.len() == 1 => {
368 self.account_balances.keys().next().expect("len is 1")
369 }
370 None => return Err("Currency must be specified for multi-currency portfolio"),
371 };
372
373 if let Some(unrealized_pnl) = unrealized_pnl
374 && unrealized_pnl.currency != *currency
375 {
376 return Err("Unrealized PnL currency does not match specified currency");
377 }
378
379 let account_balance = self
380 .account_balances
381 .get(currency)
382 .ok_or("Specified currency not found in account balances")?;
383
384 let default_money = &Money::new(0.0, *currency);
385 let account_balance_starting = self
386 .account_balances_starting
387 .get(currency)
388 .unwrap_or(default_money);
389
390 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
391 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
392 }
393
394 #[expect(clippy::missing_panics_doc)] pub fn total_pnl_percentage(
404 &self,
405 currency: Option<&Currency>,
406 unrealized_pnl: Option<&Money>,
407 ) -> Result<f64, &'static str> {
408 if self.account_balances.is_empty() {
409 return Ok(0.0);
410 }
411
412 let currency = match currency {
414 Some(c) => c,
415 None if self.account_balances.len() == 1 => {
416 self.account_balances.keys().next().expect("len is 1")
417 }
418 None => return Err("Currency must be specified for multi-currency portfolio"),
419 };
420
421 if let Some(unrealized_pnl) = unrealized_pnl
422 && unrealized_pnl.currency != *currency
423 {
424 return Err("Unrealized PnL currency does not match specified currency");
425 }
426
427 let account_balance = self
428 .account_balances
429 .get(currency)
430 .ok_or("Specified currency not found in account balances")?;
431
432 let default_money = &Money::new(0.0, *currency);
433 let account_balance_starting = self
434 .account_balances_starting
435 .get(currency)
436 .unwrap_or(default_money);
437
438 if account_balance_starting.as_decimal() == Decimal::ZERO {
439 return Ok(0.0);
440 }
441
442 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
443 let current = account_balance.as_f64() + unrealized_pnl_f64;
444 let starting = account_balance_starting.as_f64();
445 let difference = current - starting;
446
447 Ok((difference / starting) * 100.0)
448 }
449
450 pub fn get_performance_stats_pnls(
460 &self,
461 currency: Option<&Currency>,
462 unrealized_pnl: Option<&Money>,
463 ) -> Result<AHashMap<String, f64>, &'static str> {
464 let mut output = AHashMap::new();
465
466 output.insert(
467 "PnL (total)".to_string(),
468 self.total_pnl(currency, unrealized_pnl)?,
469 );
470 output.insert(
471 "PnL% (total)".to_string(),
472 self.total_pnl_percentage(currency, unrealized_pnl)?,
473 );
474
475 if let Some(realized_pnls) = self.realized_pnls(currency) {
476 for (name, stat) in &self.statistics {
477 if let Some(value) = stat.calculate_from_realized_pnls(
478 &realized_pnls
479 .iter()
480 .map(|(_, pnl)| *pnl)
481 .collect::<Vec<f64>>(),
482 ) {
483 output.insert(name.clone(), value);
484 }
485 }
486 }
487
488 Ok(output)
489 }
490
491 #[must_use]
493 pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
494 self.calculate_returns_stats(self.returns())
495 }
496
497 #[must_use]
499 pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
500 self.calculate_returns_stats(self.position_returns())
501 }
502
503 #[must_use]
505 pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
506 self.calculate_returns_stats(self.portfolio_returns())
507 }
508
509 #[must_use]
511 pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
512 let mut output = AHashMap::new();
513
514 for (name, stat) in &self.statistics {
515 if let Some(value) = stat.calculate_from_positions(&self.positions) {
516 output.insert(name.clone(), value);
517 }
518 }
519
520 output
521 }
522
523 fn get_max_length_name(&self) -> usize {
525 self.statistics.keys().map(String::len).max().unwrap_or(0)
526 }
527
528 fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
529 let mut output = AHashMap::new();
530
531 for (name, stat) in &self.statistics {
532 if let Some(value) = stat.calculate_from_returns(returns) {
533 output.insert(name.clone(), value);
534 }
535 }
536
537 output
538 }
539
540 fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
541 let max_length = self.get_max_length_name();
542 let mut entries: Vec<_> = stats.into_iter().collect();
543 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
544
545 let mut output = Vec::new();
546
547 for (k, v) in entries {
548 let padding = max_length.saturating_sub(k.len()) + 1;
549 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
550 }
551
552 output
553 }
554
555 fn sync_returns_alias(&mut self) {
556 if self.portfolio_returns.is_empty() {
557 self.returns = self.position_returns.clone();
558 return;
559 }
560
561 self.returns = self.portfolio_returns.clone();
562 }
563
564 pub fn get_stats_pnls_formatted(
570 &self,
571 currency: Option<&Currency>,
572 unrealized_pnl: Option<&Money>,
573 ) -> Result<Vec<String>, String> {
574 let max_length = self.get_max_length_name();
575 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
576
577 let mut entries: Vec<_> = stats.into_iter().collect();
578 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
579
580 let mut output = Vec::new();
581
582 for (k, v) in entries {
583 let padding = if max_length > k.len() {
584 max_length - k.len() + 1
585 } else {
586 1
587 };
588 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
589 }
590
591 Ok(output)
592 }
593
594 #[must_use]
596 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
597 self.format_returns_stats(self.get_performance_stats_returns())
598 }
599
600 #[must_use]
602 pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
603 self.format_returns_stats(self.get_performance_stats_position_returns())
604 }
605
606 #[must_use]
608 pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
609 self.format_returns_stats(self.get_performance_stats_portfolio_returns())
610 }
611
612 #[must_use]
614 pub fn get_stats_general_formatted(&self) -> Vec<String> {
615 let max_length = self.get_max_length_name();
616 let stats = self.get_performance_stats_general();
617
618 let mut entries: Vec<_> = stats.into_iter().collect();
619 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
620
621 let mut output = Vec::new();
622
623 for (k, v) in entries {
624 let padding = max_length - k.len() + 1;
625 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
626 }
627
628 output
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use std::sync::Arc;
635
636 use ahash::{AHashMap, AHashSet};
637 use indexmap::IndexMap;
638 use nautilus_core::{UUID4, approx_eq};
639 use nautilus_model::{
640 enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
641 events::{AccountState, OrderFilled},
642 identifiers::{
643 AccountId, ClientOrderId,
644 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
645 },
646 instruments::InstrumentAny,
647 stubs::TestDefault,
648 types::{AccountBalance, Money, Price, Quantity},
649 };
650 use rstest::rstest;
651
652 use super::*;
653
654 #[derive(Debug)]
656 struct MockStatistic {
657 name: String,
658 }
659
660 impl MockStatistic {
661 fn new(name: &str) -> Self {
662 Self {
663 name: name.to_string(),
664 }
665 }
666 }
667
668 impl PortfolioStatistic for MockStatistic {
669 type Item = f64;
670
671 fn name(&self) -> String {
672 self.name.clone()
673 }
674
675 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
676 Some(pnls.iter().sum())
677 }
678
679 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
680 Some(returns.values().sum())
681 }
682
683 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
684 Some(positions.len() as f64)
685 }
686 }
687
688 fn create_mock_position(
689 id: &str,
690 realized_pnl: f64,
691 realized_return: f64,
692 currency: Currency,
693 ) -> Position {
694 Position {
695 events: Vec::new(),
696 adjustments: Vec::new(),
697 trader_id: trader_id(),
698 strategy_id: strategy_id_ema_cross(),
699 instrument_id: instrument_id_aud_usd_sim(),
700 id: PositionId::new(id),
701 account_id: AccountId::new("test-account"),
702 opening_order_id: ClientOrderId::test_default(),
703 closing_order_id: None,
704 entry: OrderSide::NoOrderSide,
705 side: PositionSide::NoPositionSide,
706 signed_qty: 0.0,
707 quantity: Quantity::default(),
708 peak_qty: Quantity::default(),
709 price_precision: 2,
710 size_precision: 2,
711 multiplier: Quantity::default(),
712 is_inverse: false,
713 is_currency_pair: true,
714 instrument_class: InstrumentClass::Spot,
715 base_currency: None,
716 quote_currency: Currency::USD(),
717 settlement_currency: Currency::USD(),
718 ts_init: UnixNanos::default(),
719 ts_opened: UnixNanos::default(),
720 ts_last: UnixNanos::default(),
721 ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
722 duration_ns: 2,
723 avg_px_open: 0.0,
724 avg_px_close: None,
725 realized_return,
726 realized_pnl: Some(Money::new(realized_pnl, currency)),
727 trade_ids: AHashSet::new(),
728 buy_qty: Quantity::default(),
729 sell_qty: Quantity::default(),
730 commissions: IndexMap::new(),
731 }
732 }
733
734 struct MockAccount {
735 starting_balances: AHashMap<Currency, Money>,
736 current_balances: AHashMap<Currency, Money>,
737 events: Vec<AccountState>,
738 }
739
740 impl Account for MockAccount {
741 fn starting_balances(&self) -> IndexMap<Currency, Money> {
742 self.starting_balances.clone().into_iter().collect()
743 }
744 fn balances_total(&self) -> IndexMap<Currency, Money> {
745 self.current_balances.clone().into_iter().collect()
746 }
747 fn id(&self) -> AccountId {
748 todo!()
749 }
750 fn account_type(&self) -> AccountType {
751 todo!()
752 }
753 fn base_currency(&self) -> Option<Currency> {
754 todo!()
755 }
756 fn is_cash_account(&self) -> bool {
757 todo!()
758 }
759 fn is_margin_account(&self) -> bool {
760 todo!()
761 }
762 fn calculated_account_state(&self) -> bool {
763 todo!()
764 }
765 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
766 todo!()
767 }
768 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
769 todo!()
770 }
771 fn balances_free(&self) -> IndexMap<Currency, Money> {
772 todo!()
773 }
774 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
775 todo!()
776 }
777 fn balances_locked(&self) -> IndexMap<Currency, Money> {
778 todo!()
779 }
780 fn last_event(&self) -> Option<AccountState> {
781 self.events.last().cloned()
782 }
783 fn events(&self) -> Vec<AccountState> {
784 self.events.clone()
785 }
786 fn event_count(&self) -> usize {
787 self.events.len()
788 }
789 fn currencies(&self) -> Vec<Currency> {
790 self.current_balances.keys().copied().collect()
791 }
792 fn balances(&self) -> IndexMap<Currency, AccountBalance> {
793 todo!()
794 }
795 fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
796 todo!()
797 }
798 fn calculate_balance_locked(
799 &mut self,
800 _: &InstrumentAny,
801 _: OrderSide,
802 _: Quantity,
803 _: Price,
804 _: Option<bool>,
805 ) -> Result<Money, anyhow::Error> {
806 todo!()
807 }
808 fn calculate_pnls(
809 &self,
810 _: &InstrumentAny,
811 _: &OrderFilled,
812 _: Option<Position>,
813 ) -> Result<Vec<Money>, anyhow::Error> {
814 todo!()
815 }
816 fn calculate_commission(
817 &self,
818 _: &InstrumentAny,
819 _: Quantity,
820 _: Price,
821 _: LiquiditySide,
822 _: Option<bool>,
823 ) -> Result<Money, anyhow::Error> {
824 todo!()
825 }
826
827 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
828 todo!()
829 }
830
831 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
832 }
834 }
835
836 fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
837 AccountState::new(
838 AccountId::new("test-account"),
839 AccountType::Cash,
840 vec![AccountBalance::new(
841 Money::new(total, currency),
842 Money::new(0.0, currency),
843 Money::new(total, currency),
844 )],
845 vec![],
846 true,
847 UUID4::new(),
848 UnixNanos::from(ts_event),
849 UnixNanos::from(ts_event),
850 Some(currency),
851 )
852 }
853
854 #[rstest]
855 fn test_register_and_deregister_statistics() {
856 let mut analyzer = PortfolioAnalyzer::new();
857 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
858 Arc::new(MockStatistic::new("test_stat"));
859
860 analyzer.register_statistic(Arc::clone(&stat));
862 assert!(analyzer.statistic("test_stat").is_some());
863
864 analyzer.deregister_statistic(&stat);
866 assert!(analyzer.statistic("test_stat").is_none());
867
868 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
870 Arc::new(MockStatistic::new("stat1"));
871 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
872 Arc::new(MockStatistic::new("stat2"));
873 analyzer.register_statistic(Arc::clone(&stat1));
874 analyzer.register_statistic(Arc::clone(&stat2));
875 analyzer.deregister_statistics();
876 assert!(analyzer.statistics.is_empty());
877 }
878
879 #[rstest]
880 fn test_calculate_total_pnl() {
881 let mut analyzer = PortfolioAnalyzer::new();
882 let currency = Currency::USD();
883
884 let mut starting_balances = AHashMap::new();
886 starting_balances.insert(currency, Money::new(1000.0, currency));
887
888 let mut current_balances = AHashMap::new();
889 current_balances.insert(currency, Money::new(1500.0, currency));
890
891 let account = MockAccount {
892 starting_balances,
893 current_balances,
894 events: vec![],
895 };
896
897 analyzer.calculate_statistics(&account, &[]);
898
899 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
901 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
902
903 let unrealized_pnl = Money::new(100.0, currency);
905 let result = analyzer
906 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
907 .unwrap();
908 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
909 }
910
911 #[rstest]
912 fn test_calculate_total_pnl_percentage() {
913 let mut analyzer = PortfolioAnalyzer::new();
914 let currency = Currency::USD();
915
916 let mut starting_balances = AHashMap::new();
918 starting_balances.insert(currency, Money::new(1000.0, currency));
919
920 let mut current_balances = AHashMap::new();
921 current_balances.insert(currency, Money::new(1500.0, currency));
922
923 let account = MockAccount {
924 starting_balances,
925 current_balances,
926 events: vec![],
927 };
928
929 analyzer.calculate_statistics(&account, &[]);
930
931 let result = analyzer
933 .total_pnl_percentage(Some(¤cy), None)
934 .unwrap();
935 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
939 let result = analyzer
940 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
941 .unwrap();
942 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
944
945 #[rstest]
946 fn test_add_positions_and_returns() {
947 let mut analyzer = PortfolioAnalyzer::new();
948 let currency = Currency::USD();
949
950 let positions = vec![
951 create_mock_position("AUD/USD", 100.0, 0.1, currency),
952 create_mock_position("AUD/USD", 200.0, 0.2, currency),
953 ];
954
955 analyzer.add_positions(&positions);
956
957 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
959 assert_eq!(pnls.len(), 2);
960 assert!(approx_eq!(f64, pnls[0].1, 100.0, epsilon = 1e-9));
961 assert!(approx_eq!(f64, pnls[1].1, 200.0, epsilon = 1e-9));
962
963 let returns = analyzer.returns();
965 let position_returns = analyzer.position_returns();
966 assert_eq!(returns.len(), 1);
967 assert_eq!(position_returns.len(), 1);
968 assert!(analyzer.portfolio_returns().is_empty());
969 assert!(approx_eq!(
970 f64,
971 *returns.values().next().unwrap(),
972 0.30000000000000004,
973 epsilon = 1e-9
974 ));
975 assert!(approx_eq!(
976 f64,
977 *position_returns.values().next().unwrap(),
978 0.30000000000000004,
979 epsilon = 1e-9
980 ));
981 }
982
983 #[rstest]
984 fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
985 let mut analyzer = PortfolioAnalyzer::new();
986 let currency = Currency::USD();
987 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
988 position.ts_closed = Some(UnixNanos::default());
989
990 analyzer.add_positions(&[position]);
991
992 assert!(analyzer.position_returns().is_empty());
993 assert!(analyzer.returns().is_empty());
994 }
995
996 #[rstest]
997 fn test_performance_stats_calculation() {
998 let mut analyzer = PortfolioAnalyzer::new();
999 let currency = Currency::USD();
1000 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1001 Arc::new(MockStatistic::new("test_stat"));
1002 analyzer.register_statistic(Arc::clone(&stat));
1003
1004 let positions = vec![
1006 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1007 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1008 ];
1009
1010 let mut starting_balances = AHashMap::new();
1011 starting_balances.insert(currency, Money::new(1000.0, currency));
1012
1013 let mut current_balances = AHashMap::new();
1014 current_balances.insert(currency, Money::new(1500.0, currency));
1015
1016 let account = MockAccount {
1017 starting_balances,
1018 current_balances,
1019 events: vec![],
1020 };
1021
1022 analyzer.calculate_statistics(&account, &positions);
1023
1024 let pnl_stats = analyzer
1026 .get_performance_stats_pnls(Some(¤cy), None)
1027 .unwrap();
1028 assert!(pnl_stats.contains_key("PnL (total)"));
1029 assert!(pnl_stats.contains_key("PnL% (total)"));
1030 assert!(pnl_stats.contains_key("test_stat"));
1031
1032 let return_stats = analyzer.get_performance_stats_returns();
1034 assert!(return_stats.contains_key("test_stat"));
1035
1036 let general_stats = analyzer.get_performance_stats_general();
1038 assert!(general_stats.contains_key("test_stat"));
1039 }
1040
1041 #[rstest]
1042 fn test_formatted_output() {
1043 let mut analyzer = PortfolioAnalyzer::new();
1044 let currency = Currency::USD();
1045 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1046 Arc::new(MockStatistic::new("test_stat"));
1047 analyzer.register_statistic(Arc::clone(&stat));
1048
1049 let positions = vec![
1050 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1051 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1052 ];
1053
1054 let mut starting_balances = AHashMap::new();
1055 starting_balances.insert(currency, Money::new(1000.0, currency));
1056
1057 let mut current_balances = AHashMap::new();
1058 current_balances.insert(currency, Money::new(1500.0, currency));
1059
1060 let account = MockAccount {
1061 starting_balances,
1062 current_balances,
1063 events: vec![],
1064 };
1065
1066 analyzer.calculate_statistics(&account, &positions);
1067
1068 let pnl_formatted = analyzer
1070 .get_stats_pnls_formatted(Some(¤cy), None)
1071 .unwrap();
1072 assert!(!pnl_formatted.is_empty());
1073 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
1074
1075 let returns_formatted = analyzer.get_stats_returns_formatted();
1076 assert!(!returns_formatted.is_empty());
1077 assert!(returns_formatted.iter().all(|s| s.contains(':')));
1078
1079 let general_formatted = analyzer.get_stats_general_formatted();
1080 assert!(!general_formatted.is_empty());
1081 assert!(general_formatted.iter().all(|s| s.contains(':')));
1082 }
1083
1084 #[rstest]
1085 fn test_reset() {
1086 let mut analyzer = PortfolioAnalyzer::new();
1087 let currency = Currency::USD();
1088
1089 let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
1090 let mut starting_balances = AHashMap::new();
1091 starting_balances.insert(currency, Money::new(1000.0, currency));
1092 let mut current_balances = AHashMap::new();
1093 current_balances.insert(currency, Money::new(1500.0, currency));
1094
1095 let account = MockAccount {
1096 starting_balances,
1097 current_balances,
1098 events: vec![],
1099 };
1100
1101 analyzer.calculate_statistics(&account, &positions);
1102
1103 analyzer.reset();
1104
1105 assert!(analyzer.account_balances_starting.is_empty());
1106 assert!(analyzer.account_balances.is_empty());
1107 assert!(analyzer.positions.is_empty());
1108 assert!(analyzer.realized_pnls.is_empty());
1109 assert!(analyzer.position_returns.is_empty());
1110 assert!(analyzer.portfolio_returns.is_empty());
1111 assert!(analyzer.returns.is_empty());
1112 }
1113
1114 #[rstest]
1115 fn test_currencies_preserve_account_balance_order() {
1116 let mut analyzer = PortfolioAnalyzer::new();
1121 let inserts = [
1122 (Currency::BTC(), Money::new(1.0, Currency::BTC())),
1123 (Currency::USD(), Money::new(2.0, Currency::USD())),
1124 (Currency::ETH(), Money::new(3.0, Currency::ETH())),
1125 ];
1126
1127 for (currency, money) in inserts {
1128 analyzer.account_balances.insert(currency, money);
1129 }
1130
1131 let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
1132 assert_eq!(
1133 returned,
1134 vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
1135 );
1136 }
1137
1138 #[rstest]
1139 fn test_calculate_statistics_clears_previous_positions() {
1140 let mut analyzer = PortfolioAnalyzer::new();
1141 let currency = Currency::USD();
1142
1143 let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
1144 let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
1145
1146 let mut starting_balances = AHashMap::new();
1147 starting_balances.insert(currency, Money::new(1000.0, currency));
1148 let mut current_balances = AHashMap::new();
1149 current_balances.insert(currency, Money::new(1500.0, currency));
1150
1151 let account = MockAccount {
1152 starting_balances,
1153 current_balances,
1154 events: vec![],
1155 };
1156
1157 analyzer.calculate_statistics(&account, &positions1);
1159 assert_eq!(analyzer.positions.len(), 1);
1160
1161 analyzer.calculate_statistics(&account, &positions2);
1163 assert_eq!(analyzer.positions.len(), 1);
1164 }
1165
1166 #[rstest]
1167 fn test_calculate_statistics_uses_account_state_returns_when_available() {
1168 let mut analyzer = PortfolioAnalyzer::new();
1169 let currency = Currency::USD();
1170 let positions = vec![
1171 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1172 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1173 ];
1174
1175 let mut starting_balances = AHashMap::new();
1176 starting_balances.insert(currency, Money::new(1000.0, currency));
1177
1178 let mut current_balances = AHashMap::new();
1179 current_balances.insert(currency, Money::new(1100.0, currency));
1180
1181 let account = MockAccount {
1182 starting_balances,
1183 current_balances,
1184 events: vec![
1185 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1186 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1187 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1188 ],
1189 };
1190
1191 analyzer.calculate_statistics(&account, &positions);
1192
1193 let position_returns = analyzer.position_returns();
1194 let portfolio_returns = analyzer.portfolio_returns();
1195 let returns = analyzer.returns();
1196 assert_eq!(position_returns.len(), 1);
1197 assert_eq!(portfolio_returns.len(), 30);
1198 assert_eq!(returns, portfolio_returns);
1199 assert!(approx_eq!(
1200 f64,
1201 *portfolio_returns
1202 .get(&UnixNanos::from(1_704_153_600_000_000_000))
1203 .unwrap(),
1204 0.0,
1205 epsilon = 1e-9
1206 ));
1207 assert!(approx_eq!(
1208 f64,
1209 *portfolio_returns
1210 .get(&UnixNanos::from(1_704_844_800_000_000_000))
1211 .unwrap(),
1212 0.05,
1213 epsilon = 1e-9
1214 ));
1215 assert!(approx_eq!(
1216 f64,
1217 *portfolio_returns
1218 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1219 .unwrap(),
1220 (1100.0 / 1050.0) - 1.0,
1221 epsilon = 1e-9
1222 ));
1223 assert!(approx_eq!(
1224 f64,
1225 *position_returns.values().next().unwrap(),
1226 0.30000000000000004,
1227 epsilon = 1e-9
1228 ));
1229 }
1230
1231 #[rstest]
1232 fn test_calculate_statistics_skips_non_finite_account_returns() {
1233 let mut analyzer = PortfolioAnalyzer::new();
1234 let currency = Currency::USD();
1235
1236 let mut starting_balances = AHashMap::new();
1237 starting_balances.insert(currency, Money::new(0.0, currency));
1238
1239 let mut current_balances = AHashMap::new();
1240 current_balances.insert(currency, Money::new(1050.0, currency));
1241
1242 let account = MockAccount {
1243 starting_balances,
1244 current_balances,
1245 events: vec![
1246 create_account_state(0.0, currency, 1_704_067_200_000_000_000),
1247 create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
1248 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1249 ],
1250 };
1251
1252 analyzer.calculate_statistics(&account, &[]);
1253
1254 let returns = analyzer.returns();
1255 assert!(returns.values().all(|value| value.is_finite()));
1256 assert!(approx_eq!(
1257 f64,
1258 *returns
1259 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1260 .unwrap(),
1261 0.05,
1262 epsilon = 1e-9
1263 ));
1264 }
1265
1266 #[rstest]
1267 fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
1268 let mut analyzer = PortfolioAnalyzer::new();
1269 let currency = Currency::USD();
1270 let positions = vec![
1271 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1272 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1273 ];
1274
1275 let mut starting_balances = AHashMap::new();
1276 starting_balances.insert(currency, Money::new(1000.0, currency));
1277
1278 let mut current_balances = AHashMap::new();
1279 current_balances.insert(currency, Money::new(1100.0, currency));
1280
1281 let account = MockAccount {
1282 starting_balances,
1283 current_balances,
1284 events: vec![],
1285 };
1286
1287 analyzer.calculate_statistics(&account, &positions);
1288
1289 let returns = analyzer.returns();
1290 assert!(analyzer.portfolio_returns().is_empty());
1291 assert_eq!(returns, analyzer.position_returns());
1292 assert_eq!(returns.len(), 1);
1293 assert!(approx_eq!(
1294 f64,
1295 *returns.values().next().unwrap(),
1296 0.30000000000000004,
1297 epsilon = 1e-9
1298 ));
1299 }
1300
1301 #[rstest]
1302 fn test_get_performance_stats_returns_prefers_portfolio_returns() {
1303 let mut analyzer = PortfolioAnalyzer::new();
1304 let currency = Currency::USD();
1305 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1306 Arc::new(MockStatistic::new("test_stat"));
1307 analyzer.register_statistic(Arc::clone(&stat));
1308
1309 let positions = vec![
1310 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1311 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1312 ];
1313
1314 let mut starting_balances = AHashMap::new();
1315 starting_balances.insert(currency, Money::new(1000.0, currency));
1316
1317 let mut current_balances = AHashMap::new();
1318 current_balances.insert(currency, Money::new(1100.0, currency));
1319
1320 let account = MockAccount {
1321 starting_balances,
1322 current_balances,
1323 events: vec![
1324 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1325 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1326 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1327 ],
1328 };
1329
1330 analyzer.calculate_statistics(&account, &positions);
1331
1332 let position_stats = analyzer.get_performance_stats_position_returns();
1333 let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
1334 let returns_stats = analyzer.get_performance_stats_returns();
1335
1336 assert!(approx_eq!(
1337 f64,
1338 *position_stats.get("test_stat").unwrap(),
1339 0.30000000000000004,
1340 epsilon = 1e-9
1341 ));
1342 assert_eq!(returns_stats, portfolio_stats);
1343 }
1344}