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::{statistic::PortfolioStatistic, Returns};
32
33pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
34
35#[repr(C)]
41#[derive(Debug)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
45)]
46pub struct PortfolioAnalyzer {
47 statistics: HashMap<String, Statistic>,
48 account_balances_starting: HashMap<Currency, Money>,
49 account_balances: HashMap<Currency, Money>,
50 positions: Vec<Position>,
51 realized_pnls: HashMap<Currency, Vec<(PositionId, f64)>>,
52 returns: Returns,
53}
54
55impl Default for PortfolioAnalyzer {
56 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl PortfolioAnalyzer {
63 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 statistics: HashMap::new(),
70 account_balances_starting: HashMap::new(),
71 account_balances: HashMap::new(),
72 positions: Vec::new(),
73 realized_pnls: HashMap::new(),
74 returns: BTreeMap::new(),
75 }
76 }
77
78 pub fn register_statistic(&mut self, statistic: Statistic) {
80 self.statistics.insert(statistic.name(), statistic);
81 }
82
83 pub fn deregister_statistic(&mut self, statistic: Statistic) {
85 self.statistics.remove(&statistic.name());
86 }
87
88 pub fn deregister_statistics(&mut self) {
90 self.statistics.clear();
91 }
92
93 pub fn reset(&mut self) {
95 self.account_balances_starting.clear();
96 self.account_balances.clear();
97 self.realized_pnls.clear();
98 self.returns.clear();
99 }
100
101 #[must_use]
103 pub fn currencies(&self) -> Vec<&Currency> {
104 self.account_balances.keys().collect()
105 }
106
107 #[must_use]
109 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
110 self.statistics.get(name)
111 }
112
113 #[must_use]
115 pub const fn returns(&self) -> &Returns {
116 &self.returns
117 }
118
119 pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
121 self.account_balances_starting = account.starting_balances();
122 self.account_balances = account.balances_total();
123 self.realized_pnls.clear();
124 self.returns.clear();
125
126 self.add_positions(positions);
127 }
128
129 pub fn add_positions(&mut self, positions: &[Position]) {
131 self.positions.extend_from_slice(positions);
132 for position in positions {
133 if let Some(ref pnl) = position.realized_pnl {
134 self.add_trade(&position.id, pnl);
135 }
136 self.add_return(
137 position.ts_closed.unwrap_or(UnixNanos::default()),
138 position.realized_return,
139 );
140 }
141 }
142
143 pub fn add_trade(&mut self, position_id: &PositionId, pnl: &Money) {
145 let currency = pnl.currency;
146 let entry = self.realized_pnls.entry(currency).or_default();
147 entry.push((*position_id, pnl.as_f64()));
148 }
149
150 pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
152 self.returns
153 .entry(timestamp)
154 .and_modify(|existing_value| *existing_value += value)
155 .or_insert(value);
156 }
157
158 #[must_use]
160 pub fn realized_pnls(&self, currency: Option<&Currency>) -> Option<Vec<(PositionId, f64)>> {
161 if self.realized_pnls.is_empty() {
162 return None;
163 }
164 let currency = currency.or_else(|| self.account_balances.keys().next())?;
165 self.realized_pnls.get(currency).cloned()
166 }
167
168 pub fn total_pnl(
170 &self,
171 currency: Option<&Currency>,
172 unrealized_pnl: Option<&Money>,
173 ) -> Result<f64, &'static str> {
174 if self.account_balances.is_empty() {
175 return Ok(0.0);
176 }
177
178 let currency = currency
179 .or_else(|| self.account_balances.keys().next())
180 .ok_or("Currency not specified for multi-currency portfolio")?;
181
182 if let Some(unrealized_pnl) = unrealized_pnl {
183 if unrealized_pnl.currency != *currency {
184 return Err("Unrealized PnL currency does not match specified currency");
185 }
186 }
187
188 let account_balance = self
189 .account_balances
190 .get(currency)
191 .ok_or("Specified currency not found in account balances")?;
192
193 let default_money = &Money::new(0.0, *currency);
194 let account_balance_starting = self
195 .account_balances_starting
196 .get(currency)
197 .unwrap_or(default_money);
198
199 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
200 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
201 }
202
203 pub fn total_pnl_percentage(
205 &self,
206 currency: Option<&Currency>,
207 unrealized_pnl: Option<&Money>,
208 ) -> Result<f64, &'static str> {
209 if self.account_balances.is_empty() {
210 return Ok(0.0);
211 }
212
213 let currency = currency
214 .or_else(|| self.account_balances.keys().next())
215 .ok_or("Currency not specified for multi-currency portfolio")?;
216
217 if let Some(unrealized_pnl) = unrealized_pnl {
218 if unrealized_pnl.currency != *currency {
219 return Err("Unrealized PnL currency does not match specified currency");
220 }
221 }
222
223 let account_balance = self
224 .account_balances
225 .get(currency)
226 .ok_or("Specified currency not found in account balances")?;
227
228 let default_money = &Money::new(0.0, *currency);
229 let account_balance_starting = self
230 .account_balances_starting
231 .get(currency)
232 .unwrap_or(default_money);
233
234 if account_balance_starting.as_decimal() == Decimal::ZERO {
235 return Ok(0.0);
236 }
237
238 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
239 let current = account_balance.as_f64() + unrealized_pnl_f64;
240 let starting = account_balance_starting.as_f64();
241 let difference = current - starting;
242
243 Ok((difference / starting) * 100.0)
244 }
245
246 pub fn get_performance_stats_pnls(
248 &self,
249 currency: Option<&Currency>,
250 unrealized_pnl: Option<&Money>,
251 ) -> Result<HashMap<String, f64>, &'static str> {
252 let mut output = HashMap::new();
253
254 output.insert(
255 "PnL (total)".to_string(),
256 self.total_pnl(currency, unrealized_pnl)?,
257 );
258 output.insert(
259 "PnL% (total)".to_string(),
260 self.total_pnl_percentage(currency, unrealized_pnl)?,
261 );
262
263 if let Some(realized_pnls) = self.realized_pnls(currency) {
264 for (name, stat) in &self.statistics {
265 if let Some(value) = stat.calculate_from_realized_pnls(
266 &realized_pnls
267 .iter()
268 .map(|(_, pnl)| *pnl)
269 .collect::<Vec<f64>>(),
270 ) {
271 output.insert(name.clone(), value);
272 }
273 }
274 }
275
276 Ok(output)
277 }
278
279 #[must_use]
281 pub fn get_performance_stats_returns(&self) -> HashMap<String, f64> {
282 let mut output = HashMap::new();
283
284 for (name, stat) in &self.statistics {
285 if let Some(value) = stat.calculate_from_returns(&self.returns) {
286 output.insert(name.clone(), value);
287 }
288 }
289
290 output
291 }
292
293 #[must_use]
295 pub fn get_performance_stats_general(&self) -> HashMap<String, f64> {
296 let mut output = HashMap::new();
297
298 for (name, stat) in &self.statistics {
299 if let Some(value) = stat.calculate_from_positions(&self.positions) {
300 output.insert(name.clone(), value);
301 }
302 }
303
304 output
305 }
306
307 fn get_max_length_name(&self) -> usize {
309 self.statistics.keys().map(String::len).max().unwrap_or(0)
310 }
311
312 pub fn get_stats_pnls_formatted(
314 &self,
315 currency: Option<&Currency>,
316 unrealized_pnl: Option<&Money>,
317 ) -> Result<Vec<String>, String> {
318 let max_length = self.get_max_length_name();
319 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
320
321 let mut output = Vec::new();
322 for (k, v) in stats {
323 let padding = if max_length > k.len() {
324 max_length - k.len() + 1
325 } else {
326 1
327 };
328 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
329 }
330
331 Ok(output)
332 }
333
334 #[must_use]
336 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
337 let max_length = self.get_max_length_name();
338 let stats = self.get_performance_stats_returns();
339
340 let mut output = Vec::new();
341 for (k, v) in stats {
342 let padding = max_length - k.len() + 1;
343 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
344 }
345
346 output
347 }
348
349 #[must_use]
351 pub fn get_stats_general_formatted(&self) -> Vec<String> {
352 let max_length = self.get_max_length_name();
353 let stats = self.get_performance_stats_general();
354
355 let mut output = Vec::new();
356 for (k, v) in stats {
357 let padding = max_length - k.len() + 1;
358 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
359 }
360
361 output
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use std::sync::Arc;
368
369 use nautilus_model::{
370 enums::{AccountType, LiquiditySide, OrderSide},
371 events::{AccountState, OrderFilled},
372 identifiers::{
373 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
374 AccountId, ClientOrderId,
375 },
376 instruments::InstrumentAny,
377 types::{AccountBalance, Money, Price, Quantity},
378 };
379
380 use super::*;
381
382 #[derive(Debug)]
384 struct MockStatistic {
385 name: String,
386 }
387
388 impl MockStatistic {
389 fn new(name: &str) -> Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> {
390 Arc::new(Self {
391 name: name.to_string(),
392 })
393 }
394 }
395
396 impl PortfolioStatistic for MockStatistic {
397 type Item = f64;
398
399 fn name(&self) -> String {
400 self.name.clone()
401 }
402
403 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
404 Some(pnls.iter().sum())
405 }
406
407 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
408 Some(returns.values().sum())
409 }
410
411 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
412 Some(positions.len() as f64)
413 }
414 }
415
416 fn create_mock_position(
417 id: String,
418 realized_pnl: f64,
419 realized_return: f64,
420 currency: Currency,
421 ) -> Position {
422 Position {
423 events: Vec::new(),
424 trader_id: trader_id(),
425 strategy_id: strategy_id_ema_cross(),
426 instrument_id: instrument_id_aud_usd_sim(),
427 id: PositionId::new(&id),
428 account_id: AccountId::new("test-account"),
429 opening_order_id: ClientOrderId::default(),
430 closing_order_id: None,
431 entry: OrderSide::NoOrderSide,
432 side: nautilus_model::enums::PositionSide::NoPositionSide,
433 signed_qty: 0.0,
434 quantity: Quantity::default(),
435 peak_qty: Quantity::default(),
436 price_precision: 2,
437 size_precision: 2,
438 multiplier: Quantity::default(),
439 is_inverse: false,
440 base_currency: None,
441 quote_currency: Currency::USD(),
442 settlement_currency: Currency::USD(),
443 ts_init: UnixNanos::default(),
444 ts_opened: UnixNanos::default(),
445 ts_last: UnixNanos::default(),
446 ts_closed: None,
447 duration_ns: 2,
448 avg_px_open: 0.0,
449 avg_px_close: None,
450 realized_return,
451 realized_pnl: Some(Money::new(realized_pnl, currency)),
452 trade_ids: Vec::new(),
453 buy_qty: Quantity::default(),
454 sell_qty: Quantity::default(),
455 commissions: HashMap::new(),
456 }
457 }
458
459 struct MockAccount {
460 starting_balances: HashMap<Currency, Money>,
461 current_balances: HashMap<Currency, Money>,
462 }
463
464 impl Account for MockAccount {
465 fn starting_balances(&self) -> HashMap<Currency, Money> {
466 self.starting_balances.clone()
467 }
468 fn balances_total(&self) -> HashMap<Currency, Money> {
469 self.current_balances.clone()
470 }
471 fn id(&self) -> AccountId {
472 todo!()
473 }
474 fn account_type(&self) -> AccountType {
475 todo!()
476 }
477 fn base_currency(&self) -> Option<Currency> {
478 todo!()
479 }
480 fn is_cash_account(&self) -> bool {
481 todo!()
482 }
483 fn is_margin_account(&self) -> bool {
484 todo!()
485 }
486 fn calculated_account_state(&self) -> bool {
487 todo!()
488 }
489 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
490 todo!()
491 }
492 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
493 todo!()
494 }
495 fn balances_free(&self) -> HashMap<Currency, Money> {
496 todo!()
497 }
498 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
499 todo!()
500 }
501 fn balances_locked(&self) -> HashMap<Currency, Money> {
502 todo!()
503 }
504 fn last_event(&self) -> Option<AccountState> {
505 todo!()
506 }
507 fn events(&self) -> Vec<AccountState> {
508 todo!()
509 }
510 fn event_count(&self) -> usize {
511 todo!()
512 }
513 fn currencies(&self) -> Vec<Currency> {
514 todo!()
515 }
516 fn balances(&self) -> HashMap<Currency, AccountBalance> {
517 todo!()
518 }
519 fn apply(&mut self, _: AccountState) {
520 todo!()
521 }
522 fn calculate_balance_locked(
523 &mut self,
524 _: InstrumentAny,
525 _: OrderSide,
526 _: Quantity,
527 _: Price,
528 _: Option<bool>,
529 ) -> Result<Money, anyhow::Error> {
530 todo!()
531 }
532 fn calculate_pnls(
533 &self,
534 _: InstrumentAny,
535 _: OrderFilled,
536 _: Option<Position>,
537 ) -> Result<Vec<Money>, anyhow::Error> {
538 todo!()
539 }
540 fn calculate_commission(
541 &self,
542 _: InstrumentAny,
543 _: Quantity,
544 _: Price,
545 _: LiquiditySide,
546 _: Option<bool>,
547 ) -> Result<Money, anyhow::Error> {
548 todo!()
549 }
550
551 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
552 todo!()
553 }
554 }
555
556 #[test]
557 fn test_register_and_deregister_statistics() {
558 let mut analyzer = PortfolioAnalyzer::new();
559 let stat = Arc::new(MockStatistic::new("test_stat"));
560
561 analyzer.register_statistic(Arc::clone(&stat));
563 assert!(analyzer.statistic("test_stat").is_some());
564
565 analyzer.deregister_statistic(Arc::clone(&stat));
567 assert!(analyzer.statistic("test_stat").is_none());
568
569 let stat1 = Arc::new(MockStatistic::new("stat1"));
571 let stat2 = Arc::new(MockStatistic::new("stat2"));
572 analyzer.register_statistic(Arc::clone(&stat1));
573 analyzer.register_statistic(Arc::clone(&stat2));
574 analyzer.deregister_statistics();
575 assert!(analyzer.statistics.is_empty());
576 }
577
578 #[test]
579 fn test_calculate_total_pnl() {
580 let mut analyzer = PortfolioAnalyzer::new();
581 let currency = Currency::USD();
582
583 let mut starting_balances = HashMap::new();
585 starting_balances.insert(currency, Money::new(1000.0, currency));
586
587 let mut current_balances = HashMap::new();
588 current_balances.insert(currency, Money::new(1500.0, currency));
589
590 let account = MockAccount {
591 starting_balances,
592 current_balances,
593 };
594
595 analyzer.calculate_statistics(&account, &[]);
596
597 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
599 assert_eq!(result, 500.0);
600
601 let unrealized_pnl = Money::new(100.0, currency);
603 let result = analyzer
604 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
605 .unwrap();
606 assert_eq!(result, 600.0);
607 }
608
609 #[test]
610 fn test_calculate_total_pnl_percentage() {
611 let mut analyzer = PortfolioAnalyzer::new();
612 let currency = Currency::USD();
613
614 let mut starting_balances = HashMap::new();
616 starting_balances.insert(currency, Money::new(1000.0, currency));
617
618 let mut current_balances = HashMap::new();
619 current_balances.insert(currency, Money::new(1500.0, currency));
620
621 let account = MockAccount {
622 starting_balances,
623 current_balances,
624 };
625
626 analyzer.calculate_statistics(&account, &[]);
627
628 let result = analyzer
630 .total_pnl_percentage(Some(¤cy), None)
631 .unwrap();
632 assert_eq!(result, 50.0); let unrealized_pnl = Money::new(500.0, currency);
636 let result = analyzer
637 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
638 .unwrap();
639 assert_eq!(result, 100.0); }
641
642 #[test]
643 fn test_add_positions_and_returns() {
644 let mut analyzer = PortfolioAnalyzer::new();
645 let currency = Currency::USD();
646
647 let positions = vec![
648 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
649 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
650 ];
651
652 analyzer.add_positions(&positions);
653
654 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
656 assert_eq!(pnls.len(), 2);
657 assert_eq!(pnls[0].1, 100.0);
658 assert_eq!(pnls[1].1, 200.0);
659
660 let returns = analyzer.returns();
662 assert_eq!(returns.len(), 1);
663 assert_eq!(*returns.values().next().unwrap(), 0.30000000000000004);
664 }
665
666 #[test]
667 fn test_performance_stats_calculation() {
668 let mut analyzer = PortfolioAnalyzer::new();
669 let currency = Currency::USD();
670 let stat = Arc::new(MockStatistic::new("test_stat"));
671 analyzer.register_statistic(Arc::clone(&stat));
672
673 let positions = vec![
675 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
676 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
677 ];
678
679 let mut starting_balances = HashMap::new();
680 starting_balances.insert(currency, Money::new(1000.0, currency));
681
682 let mut current_balances = HashMap::new();
683 current_balances.insert(currency, Money::new(1500.0, currency));
684
685 let account = MockAccount {
686 starting_balances,
687 current_balances,
688 };
689
690 analyzer.calculate_statistics(&account, &positions);
691
692 let pnl_stats = analyzer
694 .get_performance_stats_pnls(Some(¤cy), None)
695 .unwrap();
696 assert!(pnl_stats.contains_key("PnL (total)"));
697 assert!(pnl_stats.contains_key("PnL% (total)"));
698 assert!(pnl_stats.contains_key("test_stat"));
699
700 let return_stats = analyzer.get_performance_stats_returns();
702 assert!(return_stats.contains_key("test_stat"));
703
704 let general_stats = analyzer.get_performance_stats_general();
706 assert!(general_stats.contains_key("test_stat"));
707 }
708
709 #[test]
710 fn test_formatted_output() {
711 let mut analyzer = PortfolioAnalyzer::new();
712 let currency = Currency::USD();
713 let stat = Arc::new(MockStatistic::new("test_stat"));
714 analyzer.register_statistic(Arc::clone(&stat));
715
716 let positions = vec![
717 create_mock_position("AUD/USD".to_owned(), 100.0, 0.1, currency),
718 create_mock_position("AUD/USD".to_owned(), 200.0, 0.2, currency),
719 ];
720
721 let mut starting_balances = HashMap::new();
722 starting_balances.insert(currency, Money::new(1000.0, currency));
723
724 let mut current_balances = HashMap::new();
725 current_balances.insert(currency, Money::new(1500.0, currency));
726
727 let account = MockAccount {
728 starting_balances,
729 current_balances,
730 };
731
732 analyzer.calculate_statistics(&account, &positions);
733
734 let pnl_formatted = analyzer
736 .get_stats_pnls_formatted(Some(¤cy), None)
737 .unwrap();
738 assert!(!pnl_formatted.is_empty());
739 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
740
741 let returns_formatted = analyzer.get_stats_returns_formatted();
742 assert!(!returns_formatted.is_empty());
743 assert!(returns_formatted.iter().all(|s| s.contains(':')));
744
745 let general_formatted = analyzer.get_stats_general_formatted();
746 assert!(!general_formatted.is_empty());
747 assert!(general_formatted.iter().all(|s| s.contains(':')));
748 }
749
750 #[test]
751 fn test_reset() {
752 let mut analyzer = PortfolioAnalyzer::new();
753 let currency = Currency::USD();
754
755 let positions = vec![create_mock_position(
756 "AUD/USD".to_owned(),
757 100.0,
758 0.1,
759 currency,
760 )];
761 let mut starting_balances = HashMap::new();
762 starting_balances.insert(currency, Money::new(1000.0, currency));
763 let mut current_balances = HashMap::new();
764 current_balances.insert(currency, Money::new(1500.0, currency));
765
766 let account = MockAccount {
767 starting_balances,
768 current_balances,
769 };
770
771 analyzer.calculate_statistics(&account, &positions);
772
773 analyzer.reset();
774
775 assert!(analyzer.account_balances_starting.is_empty());
776 assert!(analyzer.account_balances.is_empty());
777 assert!(analyzer.realized_pnls.is_empty());
778 assert!(analyzer.returns.is_empty());
779 }
780}