1use 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#[repr(C)]
52#[derive(Debug)]
53#[cfg_attr(
54 feature = "python",
55 pyo3::pyclass(module = "nautilus_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 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 #[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 pub fn register_statistic(&mut self, statistic: Statistic) {
108 self.statistics.insert(statistic.name(), statistic);
109 }
110
111 pub fn deregister_statistic(&mut self, statistic: Statistic) {
113 self.statistics.remove(&statistic.name());
114 }
115
116 pub fn deregister_statistics(&mut self) {
118 self.statistics.clear();
119 }
120
121 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 #[must_use]
131 pub fn currencies(&self) -> Vec<&Currency> {
132 self.account_balances.keys().collect()
133 }
134
135 #[must_use]
137 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
138 self.statistics.get(name)
139 }
140
141 #[must_use]
143 pub const fn returns(&self) -> &Returns {
144 &self.returns
145 }
146
147 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 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 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 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 #[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 pub fn total_pnl(
198 &self,
199 currency: Option<&Currency>,
200 unrealized_pnl: Option<&Money>,
201 ) -> Result<f64, &'static str> {
202 if self.account_balances.is_empty() {
203 return Ok(0.0);
204 }
205
206 let currency = currency
207 .or_else(|| self.account_balances.keys().next())
208 .ok_or("Currency not specified for multi-currency portfolio")?;
209
210 if let Some(unrealized_pnl) = unrealized_pnl {
211 if unrealized_pnl.currency != *currency {
212 return Err("Unrealized PnL currency does not match specified currency");
213 }
214 }
215
216 let account_balance = self
217 .account_balances
218 .get(currency)
219 .ok_or("Specified currency not found in account balances")?;
220
221 let default_money = &Money::new(0.0, *currency);
222 let account_balance_starting = self
223 .account_balances_starting
224 .get(currency)
225 .unwrap_or(default_money);
226
227 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
228 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
229 }
230
231 pub fn total_pnl_percentage(
233 &self,
234 currency: Option<&Currency>,
235 unrealized_pnl: Option<&Money>,
236 ) -> Result<f64, &'static str> {
237 if self.account_balances.is_empty() {
238 return Ok(0.0);
239 }
240
241 let currency = currency
242 .or_else(|| self.account_balances.keys().next())
243 .ok_or("Currency not specified for multi-currency portfolio")?;
244
245 if let Some(unrealized_pnl) = unrealized_pnl {
246 if unrealized_pnl.currency != *currency {
247 return Err("Unrealized PnL currency does not match specified currency");
248 }
249 }
250
251 let account_balance = self
252 .account_balances
253 .get(currency)
254 .ok_or("Specified currency not found in account balances")?;
255
256 let default_money = &Money::new(0.0, *currency);
257 let account_balance_starting = self
258 .account_balances_starting
259 .get(currency)
260 .unwrap_or(default_money);
261
262 if account_balance_starting.as_decimal() == Decimal::ZERO {
263 return Ok(0.0);
264 }
265
266 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
267 let current = account_balance.as_f64() + unrealized_pnl_f64;
268 let starting = account_balance_starting.as_f64();
269 let difference = current - starting;
270
271 Ok((difference / starting) * 100.0)
272 }
273
274 pub fn get_performance_stats_pnls(
276 &self,
277 currency: Option<&Currency>,
278 unrealized_pnl: Option<&Money>,
279 ) -> Result<HashMap<String, f64>, &'static str> {
280 let mut output = HashMap::new();
281
282 output.insert(
283 "PnL (total)".to_string(),
284 self.total_pnl(currency, unrealized_pnl)?,
285 );
286 output.insert(
287 "PnL% (total)".to_string(),
288 self.total_pnl_percentage(currency, unrealized_pnl)?,
289 );
290
291 if let Some(realized_pnls) = self.realized_pnls(currency) {
292 for (name, stat) in &self.statistics {
293 if let Some(value) = stat.calculate_from_realized_pnls(
294 &realized_pnls
295 .iter()
296 .map(|(_, pnl)| *pnl)
297 .collect::<Vec<f64>>(),
298 ) {
299 output.insert(name.clone(), value);
300 }
301 }
302 }
303
304 Ok(output)
305 }
306
307 #[must_use]
309 pub fn get_performance_stats_returns(&self) -> HashMap<String, f64> {
310 let mut output = HashMap::new();
311
312 for (name, stat) in &self.statistics {
313 if let Some(value) = stat.calculate_from_returns(&self.returns) {
314 output.insert(name.clone(), value);
315 }
316 }
317
318 output
319 }
320
321 #[must_use]
323 pub fn get_performance_stats_general(&self) -> HashMap<String, f64> {
324 let mut output = HashMap::new();
325
326 for (name, stat) in &self.statistics {
327 if let Some(value) = stat.calculate_from_positions(&self.positions) {
328 output.insert(name.clone(), value);
329 }
330 }
331
332 output
333 }
334
335 fn get_max_length_name(&self) -> usize {
337 self.statistics.keys().map(String::len).max().unwrap_or(0)
338 }
339
340 pub fn get_stats_pnls_formatted(
342 &self,
343 currency: Option<&Currency>,
344 unrealized_pnl: Option<&Money>,
345 ) -> Result<Vec<String>, String> {
346 let max_length = self.get_max_length_name();
347 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
348
349 let mut output = Vec::new();
350 for (k, v) in stats {
351 let padding = if max_length > k.len() {
352 max_length - k.len() + 1
353 } else {
354 1
355 };
356 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
357 }
358
359 Ok(output)
360 }
361
362 #[must_use]
364 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
365 let max_length = self.get_max_length_name();
366 let stats = self.get_performance_stats_returns();
367
368 let mut output = Vec::new();
369 for (k, v) in stats {
370 let padding = max_length - k.len() + 1;
371 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
372 }
373
374 output
375 }
376
377 #[must_use]
379 pub fn get_stats_general_formatted(&self) -> Vec<String> {
380 let max_length = self.get_max_length_name();
381 let stats = self.get_performance_stats_general();
382
383 let mut output = Vec::new();
384 for (k, v) in stats {
385 let padding = max_length - k.len() + 1;
386 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
387 }
388
389 output
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use std::sync::Arc;
396
397 use nautilus_model::{
398 enums::{AccountType, LiquiditySide, OrderSide},
399 events::{AccountState, OrderFilled},
400 identifiers::{
401 AccountId, ClientOrderId,
402 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
403 },
404 instruments::InstrumentAny,
405 types::{AccountBalance, Money, Price, Quantity},
406 };
407
408 use super::*;
409
410 #[derive(Debug)]
412 struct MockStatistic {
413 name: String,
414 }
415
416 impl MockStatistic {
417 fn new(name: &str) -> Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> {
418 Arc::new(Self {
419 name: name.to_string(),
420 })
421 }
422 }
423
424 impl PortfolioStatistic for MockStatistic {
425 type Item = f64;
426
427 fn name(&self) -> String {
428 self.name.clone()
429 }
430
431 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
432 Some(pnls.iter().sum())
433 }
434
435 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
436 Some(returns.values().sum())
437 }
438
439 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
440 Some(positions.len() as f64)
441 }
442 }
443
444 fn create_mock_position(
445 id: String,
446 realized_pnl: f64,
447 realized_return: f64,
448 currency: Currency,
449 ) -> Position {
450 Position {
451 events: Vec::new(),
452 trader_id: trader_id(),
453 strategy_id: strategy_id_ema_cross(),
454 instrument_id: instrument_id_aud_usd_sim(),
455 id: PositionId::new(&id),
456 account_id: AccountId::new("test-account"),
457 opening_order_id: ClientOrderId::default(),
458 closing_order_id: None,
459 entry: OrderSide::NoOrderSide,
460 side: nautilus_model::enums::PositionSide::NoPositionSide,
461 signed_qty: 0.0,
462 quantity: Quantity::default(),
463 peak_qty: Quantity::default(),
464 price_precision: 2,
465 size_precision: 2,
466 multiplier: Quantity::default(),
467 is_inverse: false,
468 base_currency: None,
469 quote_currency: Currency::USD(),
470 settlement_currency: Currency::USD(),
471 ts_init: UnixNanos::default(),
472 ts_opened: UnixNanos::default(),
473 ts_last: UnixNanos::default(),
474 ts_closed: None,
475 duration_ns: 2,
476 avg_px_open: 0.0,
477 avg_px_close: None,
478 realized_return,
479 realized_pnl: Some(Money::new(realized_pnl, currency)),
480 trade_ids: Vec::new(),
481 buy_qty: Quantity::default(),
482 sell_qty: Quantity::default(),
483 commissions: HashMap::new(),
484 }
485 }
486
487 struct MockAccount {
488 starting_balances: HashMap<Currency, Money>,
489 current_balances: HashMap<Currency, Money>,
490 }
491
492 impl Account for MockAccount {
493 fn starting_balances(&self) -> HashMap<Currency, Money> {
494 self.starting_balances.clone()
495 }
496 fn balances_total(&self) -> HashMap<Currency, Money> {
497 self.current_balances.clone()
498 }
499 fn id(&self) -> AccountId {
500 todo!()
501 }
502 fn account_type(&self) -> AccountType {
503 todo!()
504 }
505 fn base_currency(&self) -> Option<Currency> {
506 todo!()
507 }
508 fn is_cash_account(&self) -> bool {
509 todo!()
510 }
511 fn is_margin_account(&self) -> bool {
512 todo!()
513 }
514 fn calculated_account_state(&self) -> bool {
515 todo!()
516 }
517 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
518 todo!()
519 }
520 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
521 todo!()
522 }
523 fn balances_free(&self) -> HashMap<Currency, Money> {
524 todo!()
525 }
526 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
527 todo!()
528 }
529 fn balances_locked(&self) -> HashMap<Currency, Money> {
530 todo!()
531 }
532 fn last_event(&self) -> Option<AccountState> {
533 todo!()
534 }
535 fn events(&self) -> Vec<AccountState> {
536 todo!()
537 }
538 fn event_count(&self) -> usize {
539 todo!()
540 }
541 fn currencies(&self) -> Vec<Currency> {
542 todo!()
543 }
544 fn balances(&self) -> HashMap<Currency, AccountBalance> {
545 todo!()
546 }
547 fn apply(&mut self, _: AccountState) {
548 todo!()
549 }
550 fn calculate_balance_locked(
551 &mut self,
552 _: InstrumentAny,
553 _: OrderSide,
554 _: Quantity,
555 _: Price,
556 _: Option<bool>,
557 ) -> Result<Money, anyhow::Error> {
558 todo!()
559 }
560 fn calculate_pnls(
561 &self,
562 _: InstrumentAny,
563 _: OrderFilled,
564 _: Option<Position>,
565 ) -> Result<Vec<Money>, anyhow::Error> {
566 todo!()
567 }
568 fn calculate_commission(
569 &self,
570 _: InstrumentAny,
571 _: Quantity,
572 _: Price,
573 _: LiquiditySide,
574 _: Option<bool>,
575 ) -> Result<Money, anyhow::Error> {
576 todo!()
577 }
578
579 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
580 todo!()
581 }
582 }
583
584 #[test]
585 fn test_register_and_deregister_statistics() {
586 let mut analyzer = PortfolioAnalyzer::new();
587 let stat = Arc::new(MockStatistic::new("test_stat"));
588
589 analyzer.register_statistic(Arc::clone(&stat));
591 assert!(analyzer.statistic("test_stat").is_some());
592
593 analyzer.deregister_statistic(Arc::clone(&stat));
595 assert!(analyzer.statistic("test_stat").is_none());
596
597 let stat1 = Arc::new(MockStatistic::new("stat1"));
599 let stat2 = Arc::new(MockStatistic::new("stat2"));
600 analyzer.register_statistic(Arc::clone(&stat1));
601 analyzer.register_statistic(Arc::clone(&stat2));
602 analyzer.deregister_statistics();
603 assert!(analyzer.statistics.is_empty());
604 }
605
606 #[test]
607 fn test_calculate_total_pnl() {
608 let mut analyzer = PortfolioAnalyzer::new();
609 let currency = Currency::USD();
610
611 let mut starting_balances = HashMap::new();
613 starting_balances.insert(currency, Money::new(1000.0, currency));
614
615 let mut current_balances = HashMap::new();
616 current_balances.insert(currency, Money::new(1500.0, currency));
617
618 let account = MockAccount {
619 starting_balances,
620 current_balances,
621 };
622
623 analyzer.calculate_statistics(&account, &[]);
624
625 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
627 assert_eq!(result, 500.0);
628
629 let unrealized_pnl = Money::new(100.0, currency);
631 let result = analyzer
632 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
633 .unwrap();
634 assert_eq!(result, 600.0);
635 }
636
637 #[test]
638 fn test_calculate_total_pnl_percentage() {
639 let mut analyzer = PortfolioAnalyzer::new();
640 let currency = Currency::USD();
641
642 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 let result = analyzer
658 .total_pnl_percentage(Some(¤cy), None)
659 .unwrap();
660 assert_eq!(result, 50.0); let unrealized_pnl = Money::new(500.0, currency);
664 let result = analyzer
665 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
666 .unwrap();
667 assert_eq!(result, 100.0); }
669
670 #[test]
671 fn test_add_positions_and_returns() {
672 let mut analyzer = PortfolioAnalyzer::new();
673 let currency = Currency::USD();
674
675 let positions = vec![
676 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
677 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
678 ];
679
680 analyzer.add_positions(&positions);
681
682 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
684 assert_eq!(pnls.len(), 2);
685 assert_eq!(pnls[0].1, 100.0);
686 assert_eq!(pnls[1].1, 200.0);
687
688 let returns = analyzer.returns();
690 assert_eq!(returns.len(), 1);
691 assert_eq!(*returns.values().next().unwrap(), 0.30000000000000004);
692 }
693
694 #[test]
695 fn test_performance_stats_calculation() {
696 let mut analyzer = PortfolioAnalyzer::new();
697 let currency = Currency::USD();
698 let stat = Arc::new(MockStatistic::new("test_stat"));
699 analyzer.register_statistic(Arc::clone(&stat));
700
701 let positions = vec![
703 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
704 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
705 ];
706
707 let mut starting_balances = HashMap::new();
708 starting_balances.insert(currency, Money::new(1000.0, currency));
709
710 let mut current_balances = HashMap::new();
711 current_balances.insert(currency, Money::new(1500.0, currency));
712
713 let account = MockAccount {
714 starting_balances,
715 current_balances,
716 };
717
718 analyzer.calculate_statistics(&account, &positions);
719
720 let pnl_stats = analyzer
722 .get_performance_stats_pnls(Some(¤cy), None)
723 .unwrap();
724 assert!(pnl_stats.contains_key("PnL (total)"));
725 assert!(pnl_stats.contains_key("PnL% (total)"));
726 assert!(pnl_stats.contains_key("test_stat"));
727
728 let return_stats = analyzer.get_performance_stats_returns();
730 assert!(return_stats.contains_key("test_stat"));
731
732 let general_stats = analyzer.get_performance_stats_general();
734 assert!(general_stats.contains_key("test_stat"));
735 }
736
737 #[test]
738 fn test_formatted_output() {
739 let mut analyzer = PortfolioAnalyzer::new();
740 let currency = Currency::USD();
741 let stat = Arc::new(MockStatistic::new("test_stat"));
742 analyzer.register_statistic(Arc::clone(&stat));
743
744 let positions = vec![
745 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
746 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
747 ];
748
749 let mut starting_balances = HashMap::new();
750 starting_balances.insert(currency, Money::new(1000.0, currency));
751
752 let mut current_balances = HashMap::new();
753 current_balances.insert(currency, Money::new(1500.0, currency));
754
755 let account = MockAccount {
756 starting_balances,
757 current_balances,
758 };
759
760 analyzer.calculate_statistics(&account, &positions);
761
762 let pnl_formatted = analyzer
764 .get_stats_pnls_formatted(Some(¤cy), None)
765 .unwrap();
766 assert!(!pnl_formatted.is_empty());
767 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
768
769 let returns_formatted = analyzer.get_stats_returns_formatted();
770 assert!(!returns_formatted.is_empty());
771 assert!(returns_formatted.iter().all(|s| s.contains(':')));
772
773 let general_formatted = analyzer.get_stats_general_formatted();
774 assert!(!general_formatted.is_empty());
775 assert!(general_formatted.iter().all(|s| s.contains(':')));
776 }
777
778 #[test]
779 fn test_reset() {
780 let mut analyzer = PortfolioAnalyzer::new();
781 let currency = Currency::USD();
782
783 let positions = vec![create_mock_position(
784 "AUD/USD".to_owned(),
785 100.0,
786 0.1,
787 currency,
788 )];
789 let mut starting_balances = HashMap::new();
790 starting_balances.insert(currency, Money::new(1000.0, currency));
791 let mut current_balances = HashMap::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 analyzer.reset();
802
803 assert!(analyzer.account_balances_starting.is_empty());
804 assert!(analyzer.account_balances.is_empty());
805 assert!(analyzer.realized_pnls.is_empty());
806 assert!(analyzer.returns.is_empty());
807 }
808}