1use std::{collections::BTreeMap, fmt::Debug, sync::Arc};
17
18use ahash::AHashMap;
19use indexmap::{IndexMap, IndexSet};
20use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_DAY};
21use nautilus_model::{
22 accounts::{Account, AccountAny},
23 events::PortfolioSnapshot,
24 identifiers::{AccountId, PositionId},
25 position::Position,
26 types::{Currency, Money},
27};
28use rust_decimal::Decimal;
29
30use crate::{
31 Returns,
32 snapshot::PortfolioStatistics,
33 statistic::PortfolioStatistic,
34 statistics::{
35 expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
36 loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
37 returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
38 returns_kurtosis::ReturnsKurtosis, returns_skewness::ReturnsSkewness,
39 returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
40 sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, tail_ratio::TailRatio,
41 win_rate::WinRate, winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
42 },
43};
44
45pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
46
47#[repr(C)]
53#[derive(Debug)]
54#[cfg_attr(feature = "python", pyo3::pyclass(module = "nautilus_trader.analysis"))]
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, UnixNanos, f64)>>,
65 pub recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
66 pub position_returns: Returns,
67 pub portfolio_returns: Returns,
68 pub returns: Returns,
73}
74
75impl Default for PortfolioAnalyzer {
76 fn default() -> Self {
78 let mut analyzer = Self::new();
79 analyzer.register_statistic(Arc::new(MaxWinner {}));
80 analyzer.register_statistic(Arc::new(AvgWinner {}));
81 analyzer.register_statistic(Arc::new(MinWinner {}));
82 analyzer.register_statistic(Arc::new(MinLoser {}));
83 analyzer.register_statistic(Arc::new(AvgLoser {}));
84 analyzer.register_statistic(Arc::new(MaxLoser {}));
85 analyzer.register_statistic(Arc::new(Expectancy {}));
86 analyzer.register_statistic(Arc::new(WinRate {}));
87 analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
88 analyzer.register_statistic(Arc::new(ReturnsSkewness::new()));
89 analyzer.register_statistic(Arc::new(ReturnsKurtosis::new()));
90 analyzer.register_statistic(Arc::new(ReturnsAverage {}));
91 analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
92 analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
93 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
94 analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
95 analyzer.register_statistic(Arc::new(TailRatio {}));
96 analyzer.register_statistic(Arc::new(ProfitFactor {}));
97 analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
98 analyzer.register_statistic(Arc::new(LongRatio::new(None)));
99 analyzer
100 }
101}
102
103impl PortfolioAnalyzer {
104 #[must_use]
108 pub fn new() -> Self {
109 Self {
110 statistics: AHashMap::new(),
111 account_balances_starting: IndexMap::new(),
112 account_balances: IndexMap::new(),
113 positions: Vec::new(),
114 realized_pnls: AHashMap::new(),
115 recorded_realized_pnls: AHashMap::new(),
116 position_returns: BTreeMap::new(),
117 portfolio_returns: BTreeMap::new(),
118 returns: BTreeMap::new(),
119 }
120 }
121
122 pub fn register_statistic(&mut self, statistic: Statistic) {
124 self.statistics.insert(statistic.name(), statistic);
125 }
126
127 pub fn deregister_statistic(&mut self, statistic: &Statistic) {
129 self.statistics.remove(&statistic.name());
130 }
131
132 pub fn deregister_statistics(&mut self) {
134 self.statistics.clear();
135 }
136
137 pub fn reset(&mut self) {
139 self.account_balances_starting.clear();
140 self.account_balances.clear();
141 self.positions.clear();
142 self.realized_pnls.clear();
143 self.recorded_realized_pnls.clear();
144 self.position_returns.clear();
145 self.portfolio_returns.clear();
146 self.returns.clear();
147 }
148
149 #[must_use]
151 pub fn currencies(&self) -> Vec<&Currency> {
152 self.account_balances.keys().collect()
153 }
154
155 #[must_use]
157 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
158 self.statistics.get(name)
159 }
160
161 #[must_use]
166 pub const fn returns(&self) -> &Returns {
167 &self.returns
168 }
169
170 #[must_use]
172 pub const fn position_returns(&self) -> &Returns {
173 &self.position_returns
174 }
175
176 #[must_use]
178 pub const fn portfolio_returns(&self) -> &Returns {
179 &self.portfolio_returns
180 }
181
182 pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
187 self.account_balances_starting = account.starting_balances().into_iter().collect();
188 self.account_balances = account.balances_total().into_iter().collect();
189 self.positions.clear();
190 self.realized_pnls.clear();
191 self.position_returns.clear();
192 self.portfolio_returns.clear();
193 self.returns.clear();
194
195 self.add_positions(positions);
196
197 if let Some(account_returns) = Self::calculate_account_returns(account) {
198 self.portfolio_returns = account_returns;
199 self.sync_returns_alias();
200 }
201 }
202
203 #[must_use]
208 pub fn from_accounts(
209 accounts: &[AccountAny],
210 positions: &[Position],
211 snapshots: &[Position],
212 recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
213 ) -> Self {
214 Self::from_accounts_with_snapshots(
215 accounts,
216 positions,
217 snapshots,
218 &[],
219 recorded_realized_pnls,
220 )
221 }
222
223 #[must_use]
229 pub fn from_accounts_with_snapshots<'a>(
230 accounts: &[AccountAny],
231 positions: &[Position],
232 position_snapshots: &[Position],
233 portfolio_snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
234 recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
235 ) -> Self {
236 let mut analyzer = Self::default();
237 let mut account_ids = Vec::with_capacity(accounts.len());
238
239 for account in accounts {
240 let account_ref: &dyn Account = match account {
241 AccountAny::Margin(margin) => margin,
242 AccountAny::Cash(cash) => cash,
243 AccountAny::Betting(betting) => betting,
244 AccountAny::Wallet(wallet) => wallet,
245 };
246 account_ids.push(account_ref.id());
247
248 for (currency, money) in account_ref.starting_balances() {
249 analyzer
250 .account_balances_starting
251 .entry(currency)
252 .and_modify(|existing| *existing = *existing + money)
253 .or_insert(money);
254 }
255
256 for (currency, money) in account_ref.balances_total() {
257 analyzer
258 .account_balances
259 .entry(currency)
260 .and_modify(|existing| *existing = *existing + money)
261 .or_insert(money);
262 }
263 }
264
265 analyzer.add_positions(positions);
266 analyzer.add_positions(position_snapshots);
267 analyzer.recorded_realized_pnls = recorded_realized_pnls;
268 analyzer.set_portfolio_returns_from_snapshots(&account_ids, portfolio_snapshots);
269 analyzer
270 }
271
272 pub fn set_portfolio_returns_from_snapshots<'a>(
274 &mut self,
275 account_ids: &[AccountId],
276 snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
277 ) {
278 if let Some(returns) = Self::calculate_snapshot_returns(account_ids, snapshots) {
279 self.portfolio_returns = returns;
280 self.sync_returns_alias();
281 }
282 }
283
284 #[must_use]
286 pub fn statistics(&self) -> PortfolioStatistics {
287 let mut pnls = AHashMap::new();
288
289 for currency in self.currencies() {
290 if let Ok(stats) = self.get_performance_stats_pnls(Some(currency), None) {
291 pnls.insert(currency.code.to_string(), stats);
292 }
293 }
294 PortfolioStatistics {
295 pnls,
296 returns: self.get_performance_stats_returns(),
297 general: self.get_performance_stats_general(),
298 returns_series: self.returns.clone(),
299 }
300 }
301
302 pub fn add_positions(&mut self, positions: &[Position]) {
304 self.positions.extend_from_slice(positions);
305 for position in positions {
306 if let Some(ref pnl) = position.realized_pnl {
307 self.add_trade(&position.id, position.ts_last, pnl);
308 }
309
310 if let Some(ts_closed) = position.ts_closed
311 && ts_closed.as_u64() > 0
312 && position.realized_pnl.is_some()
313 {
314 self.add_position_return(ts_closed, position.realized_return);
315 }
316 }
317 }
318
319 pub fn add_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
321 let currency = pnl.currency;
322 let entry = self.realized_pnls.entry(currency).or_default();
323 entry.push((*position_id, ts_event, pnl.as_f64()));
324 }
325
326 pub fn record_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
328 let currency = pnl.currency;
329 let entry = self.recorded_realized_pnls.entry(currency).or_default();
330 entry.push((*position_id, ts_event, pnl.as_f64()));
331 }
332
333 pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
335 self.position_returns
336 .entry(timestamp)
337 .and_modify(|existing_value| *existing_value += value)
338 .or_insert(value);
339
340 if self.portfolio_returns.is_empty() {
344 self.returns
345 .entry(timestamp)
346 .and_modify(|existing_value| *existing_value += value)
347 .or_insert(value);
348 }
349 }
350
351 pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
355 self.add_position_return(timestamp, value);
356 }
357
358 fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
369 let mut events = account.events();
370 if events.len() < 2 {
371 return None;
372 }
373
374 events.sort_by_key(|event| event.ts_event);
375
376 let mut currency = None;
377 let mut daily_balances = BTreeMap::new();
378
379 for event in events {
380 if event.balances.is_empty() {
381 continue;
382 }
383
384 if event.balances.len() != 1 {
385 return None;
386 }
387
388 let balance = event.balances[0];
389
390 if let Some(existing_currency) = currency {
391 if existing_currency != balance.currency {
392 return None;
393 }
394 } else {
395 currency = Some(balance.currency);
396 }
397
398 let day_start = UnixNanos::from(
399 event.ts_event.as_u64() - (event.ts_event.as_u64() % NANOSECONDS_IN_DAY),
400 );
401 daily_balances.insert(day_start, balance.total.as_f64());
402 }
403
404 Self::calculate_daily_returns(&daily_balances)
405 }
406
407 fn calculate_snapshot_returns<'a>(
408 account_ids: &[AccountId],
409 snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
410 ) -> Option<Returns> {
411 let expected_accounts: IndexSet<AccountId> = account_ids.iter().copied().collect();
412 if expected_accounts.is_empty() {
413 return None;
414 }
415
416 let mut currency = None;
417 let mut equity_by_account: AHashMap<AccountId, BTreeMap<UnixNanos, f64>> = AHashMap::new();
418
419 for snapshot in snapshots {
420 if !expected_accounts.contains(&snapshot.account_id) {
421 continue;
422 }
423
424 if !snapshot.unpriced_instruments.is_empty() {
425 continue;
426 }
427
428 if snapshot.total_equity.len() != 1 {
429 return None;
430 }
431
432 let equity = snapshot
433 .base_currency_equity
434 .unwrap_or(snapshot.total_equity[0]);
435
436 if let Some(existing_currency) = currency {
437 if existing_currency != equity.currency {
438 return None;
439 }
440 } else {
441 currency = Some(equity.currency);
442 }
443
444 let is_registration = !equity_by_account.contains_key(&snapshot.account_id);
445 let day_start = Self::snapshot_day_start(snapshot.ts_event, is_registration);
446 equity_by_account
447 .entry(snapshot.account_id)
448 .or_default()
449 .insert(day_start, equity.as_f64());
450 }
451
452 if equity_by_account.len() != expected_accounts.len() {
453 return None;
454 }
455
456 let first_day = equity_by_account
457 .values()
458 .filter_map(|equity| equity.keys().next().copied())
459 .min()?;
460 let last_day = equity_by_account
461 .values()
462 .filter_map(|equity| equity.keys().next_back().copied())
463 .max()?;
464 let mut daily_equity = BTreeMap::new();
465 let mut current_equity = AHashMap::new();
466 let mut current_day = first_day;
467
468 loop {
469 for account_id in &expected_accounts {
470 if let Some(equity) = equity_by_account
471 .get(account_id)
472 .and_then(|values| values.get(¤t_day))
473 {
474 current_equity.insert(*account_id, *equity);
475 }
476 }
477
478 if current_equity.len() == expected_accounts.len() {
479 let total = expected_accounts
480 .iter()
481 .map(|account_id| current_equity[account_id])
482 .sum();
483 daily_equity.insert(current_day, total);
484 }
485
486 if current_day >= last_day {
487 break;
488 }
489
490 current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
491 }
492
493 Self::calculate_daily_returns(&daily_equity)
494 }
495
496 fn snapshot_day_start(ts_event: UnixNanos, is_registration: bool) -> UnixNanos {
497 let timestamp = ts_event.as_u64();
498 let offset = timestamp % NANOSECONDS_IN_DAY;
499 let day_start = timestamp - offset;
500 if is_registration || (offset == 0 && timestamp > 0) {
501 UnixNanos::from(day_start.saturating_sub(NANOSECONDS_IN_DAY))
502 } else {
503 UnixNanos::from(day_start)
504 }
505 }
506
507 fn calculate_daily_returns(daily_equity: &BTreeMap<UnixNanos, f64>) -> Option<Returns> {
508 if daily_equity.len() < 2 {
509 return None;
510 }
511
512 let mut returns = Returns::new();
513 let mut current_day = *daily_equity.keys().next()?;
514 let last_day = *daily_equity.keys().next_back()?;
515 let mut current_balance: Option<f64> = None;
516 let mut previous_balance: Option<f64> = None;
517
518 loop {
519 if let Some(balance) = daily_equity.get(¤t_day) {
520 current_balance = Some(*balance);
521 }
522
523 let balance = current_balance?;
524
525 if let Some(previous) = previous_balance
526 && previous != 0.0
527 {
528 let value: f64 = (balance / previous) - 1.0;
529 if value.is_finite() {
530 returns.insert(current_day, value);
531 }
532 }
533
534 previous_balance = Some(balance);
535
536 if current_day >= last_day {
537 break;
538 }
539
540 current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
541 }
542
543 (!returns.is_empty()).then_some(returns)
544 }
545
546 #[must_use]
561 pub fn trade_pnl_records(
562 &self,
563 currency: Option<&Currency>,
564 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
565 if self.realized_pnls.is_empty() && self.recorded_realized_pnls.is_empty() {
566 return None;
567 }
568
569 let currency = match currency {
571 Some(c) => *c,
572 None if self.account_balances.len() == 1 => *self.account_balances.keys().next()?,
573 None => {
574 let mut currencies: IndexSet<Currency> =
575 self.realized_pnls.keys().copied().collect();
576 currencies.extend(self.recorded_realized_pnls.keys().copied());
577 if currencies.len() != 1 {
578 return None;
579 }
580
581 *currencies.first()?
582 }
583 };
584
585 let realized_pnls = self.realized_pnls.get(¤cy);
586 let recorded_realized_pnls = self.recorded_realized_pnls.get(¤cy);
587
588 match (realized_pnls, recorded_realized_pnls) {
589 (None, None) => None,
590 (Some(realized_pnls), None) => Some(realized_pnls.clone()),
591 (None, Some(recorded_realized_pnls)) => Some(recorded_realized_pnls.clone()),
592 (Some(realized_pnls), Some(recorded_realized_pnls)) => {
593 let recorded_keys: IndexSet<(PositionId, UnixNanos)> = recorded_realized_pnls
594 .iter()
595 .map(|(position_id, ts_event, _)| {
596 (canonical_position_id(*position_id), *ts_event)
597 })
598 .collect();
599 let mut output: Vec<(PositionId, UnixNanos, f64)> = realized_pnls
600 .iter()
601 .copied()
602 .filter(|(position_id, ts_event, _)| {
603 let key = (canonical_position_id(*position_id), *ts_event);
604 !recorded_keys.contains(&key)
605 })
606 .collect();
607 output.extend(recorded_realized_pnls.iter().copied());
608
609 Some(output)
610 }
611 }
612 }
613
614 #[must_use]
619 pub fn realized_pnls(
620 &self,
621 currency: Option<&Currency>,
622 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
623 self.trade_pnl_records(currency)
624 }
625
626 #[expect(clippy::missing_panics_doc)] pub fn total_pnl(
636 &self,
637 currency: Option<&Currency>,
638 unrealized_pnl: Option<&Money>,
639 ) -> Result<f64, &'static str> {
640 if self.account_balances.is_empty() {
641 return Ok(0.0);
642 }
643
644 let currency = match currency {
646 Some(c) => c,
647 None if self.account_balances.len() == 1 => {
648 self.account_balances.keys().next().expect("len is 1")
649 }
650 None => return Err("Currency must be specified for multi-currency portfolio"),
651 };
652
653 if let Some(unrealized_pnl) = unrealized_pnl
654 && unrealized_pnl.currency != *currency
655 {
656 return Err("Unrealized PnL currency does not match specified currency");
657 }
658
659 let account_balance = self
660 .account_balances
661 .get(currency)
662 .ok_or("Specified currency not found in account balances")?;
663
664 let default_money = &Money::zero(*currency);
665 let account_balance_starting = self
666 .account_balances_starting
667 .get(currency)
668 .unwrap_or(default_money);
669
670 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
671 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
672 }
673
674 #[expect(clippy::missing_panics_doc)] pub fn total_pnl_percentage(
684 &self,
685 currency: Option<&Currency>,
686 unrealized_pnl: Option<&Money>,
687 ) -> Result<f64, &'static str> {
688 if self.account_balances.is_empty() {
689 return Ok(0.0);
690 }
691
692 let currency = match currency {
694 Some(c) => c,
695 None if self.account_balances.len() == 1 => {
696 self.account_balances.keys().next().expect("len is 1")
697 }
698 None => return Err("Currency must be specified for multi-currency portfolio"),
699 };
700
701 if let Some(unrealized_pnl) = unrealized_pnl
702 && unrealized_pnl.currency != *currency
703 {
704 return Err("Unrealized PnL currency does not match specified currency");
705 }
706
707 let account_balance = self
708 .account_balances
709 .get(currency)
710 .ok_or("Specified currency not found in account balances")?;
711
712 let default_money = &Money::zero(*currency);
713 let account_balance_starting = self
714 .account_balances_starting
715 .get(currency)
716 .unwrap_or(default_money);
717
718 if account_balance_starting.as_decimal() == Decimal::ZERO {
719 return Ok(0.0);
720 }
721
722 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
723 let current = account_balance.as_f64() + unrealized_pnl_f64;
724 let starting = account_balance_starting.as_f64();
725 let difference = current - starting;
726
727 Ok((difference / starting) * 100.0)
728 }
729
730 pub fn get_performance_stats_pnls(
740 &self,
741 currency: Option<&Currency>,
742 unrealized_pnl: Option<&Money>,
743 ) -> Result<AHashMap<String, f64>, &'static str> {
744 let mut output = AHashMap::new();
745
746 output.insert(
747 "PnL (total)".to_string(),
748 self.total_pnl(currency, unrealized_pnl)?,
749 );
750 output.insert(
751 "PnL% (total)".to_string(),
752 self.total_pnl_percentage(currency, unrealized_pnl)?,
753 );
754
755 if let Some(trade_pnl_records) = self.trade_pnl_records(currency) {
756 for (name, stat) in &self.statistics {
757 if let Some(value) = stat.calculate_from_realized_pnls(
758 &trade_pnl_records
759 .iter()
760 .map(|(_, _, pnl)| *pnl)
761 .collect::<Vec<f64>>(),
762 ) {
763 output.insert(name.clone(), value);
764 }
765 }
766 }
767
768 Ok(output)
769 }
770
771 #[must_use]
773 pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
774 self.calculate_returns_stats(self.returns())
775 }
776
777 #[must_use]
779 pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
780 self.calculate_returns_stats(self.position_returns())
781 }
782
783 #[must_use]
785 pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
786 self.calculate_returns_stats(self.portfolio_returns())
787 }
788
789 #[must_use]
796 pub fn get_performance_stats_returns_vs_benchmark(
797 &self,
798 benchmark: &Returns,
799 ) -> AHashMap<String, f64> {
800 let mut output = AHashMap::new();
801
802 for (name, stat) in &self.statistics {
803 if let Some(value) =
804 stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
805 {
806 output.insert(name.clone(), value);
807 }
808 }
809
810 output
811 }
812
813 #[must_use]
815 pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
816 let mut output = AHashMap::new();
817
818 for (name, stat) in &self.statistics {
819 if let Some(value) = stat.calculate_from_positions(&self.positions) {
820 output.insert(name.clone(), value);
821 }
822 }
823
824 output
825 }
826
827 fn get_max_length_name(&self) -> usize {
829 self.statistics.keys().map(String::len).max().unwrap_or(0)
830 }
831
832 fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
833 let mut output = AHashMap::new();
834
835 for (name, stat) in &self.statistics {
836 if let Some(value) = stat.calculate_from_returns(returns) {
837 output.insert(name.clone(), value);
838 }
839 }
840
841 output
842 }
843
844 fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
845 let max_length = self.get_max_length_name();
846 let mut entries: Vec<_> = stats.into_iter().collect();
847 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
848
849 let mut output = Vec::new();
850
851 for (k, v) in entries {
852 let padding = max_length.saturating_sub(k.len()) + 1;
853 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
854 }
855
856 output
857 }
858
859 fn sync_returns_alias(&mut self) {
860 if self.portfolio_returns.is_empty() {
861 self.returns = self.position_returns.clone();
862 return;
863 }
864
865 self.returns = self.portfolio_returns.clone();
866 }
867
868 pub fn get_stats_pnls_formatted(
874 &self,
875 currency: Option<&Currency>,
876 unrealized_pnl: Option<&Money>,
877 ) -> Result<Vec<String>, String> {
878 let max_length = self.get_max_length_name();
879 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
880
881 let mut entries: Vec<_> = stats.into_iter().collect();
882 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
883
884 let mut output = Vec::new();
885
886 for (k, v) in entries {
887 let padding = if max_length > k.len() {
888 max_length - k.len() + 1
889 } else {
890 1
891 };
892 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
893 }
894
895 Ok(output)
896 }
897
898 #[must_use]
900 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
901 self.format_returns_stats(self.get_performance_stats_returns())
902 }
903
904 #[must_use]
906 pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
907 self.format_returns_stats(self.get_performance_stats_position_returns())
908 }
909
910 #[must_use]
912 pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
913 self.format_returns_stats(self.get_performance_stats_portfolio_returns())
914 }
915
916 #[must_use]
918 pub fn get_stats_general_formatted(&self) -> Vec<String> {
919 let max_length = self.get_max_length_name();
920 let stats = self.get_performance_stats_general();
921
922 let mut entries: Vec<_> = stats.into_iter().collect();
923 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
924
925 let mut output = Vec::new();
926
927 for (k, v) in entries {
928 let padding = max_length - k.len() + 1;
929 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
930 }
931
932 output
933 }
934}
935
936fn canonical_position_id(position_id: PositionId) -> PositionId {
937 const UUID4_STRING_LEN: usize = 36;
938
939 let value = position_id.as_str();
940 let Some(separator_index) = value.len().checked_sub(UUID4_STRING_LEN + 1) else {
941 return position_id;
942 };
943
944 if separator_index == 0 || value.as_bytes()[separator_index] != b'-' {
945 return position_id;
946 }
947
948 let suffix = &value[separator_index + 1..];
949 if suffix.parse::<UUID4>().is_ok() {
950 PositionId::new(&value[..separator_index])
951 } else {
952 position_id
953 }
954}
955
956#[cfg(test)]
957mod tests {
958 use std::sync::Arc;
959
960 use ahash::{AHashMap, AHashSet};
961 use indexmap::IndexMap;
962 use nautilus_core::{UUID4, approx_eq};
963 use nautilus_model::{
964 accounts::{AccountAny, CashAccount},
965 enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
966 events::{AccountState, OrderFilled, PortfolioSnapshot},
967 identifiers::{
968 AccountId, ClientOrderId,
969 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
970 },
971 instruments::InstrumentAny,
972 stubs::TestDefault,
973 types::{AccountBalance, Money, Price, Quantity},
974 };
975 use rstest::rstest;
976
977 use super::*;
978 use crate::statistics::beta_ratio::BetaRatio;
979
980 #[derive(Debug)]
982 struct MockStatistic {
983 name: String,
984 }
985
986 impl MockStatistic {
987 fn new(name: &str) -> Self {
988 Self {
989 name: name.to_string(),
990 }
991 }
992 }
993
994 impl PortfolioStatistic for MockStatistic {
995 type Item = f64;
996
997 fn name(&self) -> String {
998 self.name.clone()
999 }
1000
1001 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
1002 Some(pnls.iter().sum())
1003 }
1004
1005 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
1006 Some(returns.values().sum())
1007 }
1008
1009 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
1010 Some(positions.len() as f64)
1011 }
1012 }
1013
1014 fn create_mock_position(
1015 id: &str,
1016 realized_pnl: f64,
1017 realized_return: f64,
1018 currency: Currency,
1019 ) -> Position {
1020 Position {
1021 events: Vec::new(),
1022 adjustments: Vec::new(),
1023 replay_events: Vec::new(),
1024 fill_voids: Vec::new(),
1025 trader_id: trader_id(),
1026 strategy_id: strategy_id_ema_cross(),
1027 instrument_id: instrument_id_aud_usd_sim(),
1028 id: PositionId::new(id),
1029 account_id: AccountId::new("test-account"),
1030 opening_order_id: ClientOrderId::test_default(),
1031 closing_order_id: None,
1032 entry: OrderSide::NoOrderSide,
1033 side: PositionSide::NoPositionSide,
1034 signed_qty: 0.0,
1035 quantity: Quantity::default(),
1036 peak_qty: Quantity::default(),
1037 price_precision: 2,
1038 size_precision: 2,
1039 multiplier: Quantity::default(),
1040 is_inverse: false,
1041 is_currency_pair: true,
1042 instrument_class: InstrumentClass::Spot,
1043 base_currency: None,
1044 quote_currency: Currency::USD(),
1045 settlement_currency: Currency::USD(),
1046 ts_init: UnixNanos::default(),
1047 ts_opened: UnixNanos::default(),
1048 ts_last: UnixNanos::default(),
1049 ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
1050 duration_ns: 2,
1051 avg_px_open: 0.0,
1052 avg_px_close: None,
1053 realized_return,
1054 realized_pnl: Some(Money::new(realized_pnl, currency)),
1055 trade_ids: AHashSet::new(),
1056 buy_qty: Quantity::default(),
1057 sell_qty: Quantity::default(),
1058 commissions: IndexMap::new(),
1059 }
1060 }
1061
1062 struct MockAccount {
1063 starting_balances: AHashMap<Currency, Money>,
1064 current_balances: AHashMap<Currency, Money>,
1065 events: Vec<AccountState>,
1066 }
1067
1068 impl Account for MockAccount {
1069 fn starting_balances(&self) -> IndexMap<Currency, Money> {
1070 self.starting_balances.clone().into_iter().collect()
1071 }
1072 fn balances_total(&self) -> IndexMap<Currency, Money> {
1073 self.current_balances.clone().into_iter().collect()
1074 }
1075 fn id(&self) -> AccountId {
1076 todo!()
1077 }
1078 fn account_type(&self) -> AccountType {
1079 todo!()
1080 }
1081 fn base_currency(&self) -> Option<Currency> {
1082 todo!()
1083 }
1084 fn is_cash_account(&self) -> bool {
1085 todo!()
1086 }
1087 fn is_margin_account(&self) -> bool {
1088 todo!()
1089 }
1090 fn calculated_account_state(&self) -> bool {
1091 todo!()
1092 }
1093 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
1094 todo!()
1095 }
1096 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
1097 todo!()
1098 }
1099 fn balances_free(&self) -> IndexMap<Currency, Money> {
1100 todo!()
1101 }
1102 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
1103 todo!()
1104 }
1105 fn balances_locked(&self) -> IndexMap<Currency, Money> {
1106 todo!()
1107 }
1108 fn last_event(&self) -> Option<AccountState> {
1109 self.events.last().cloned()
1110 }
1111 fn events(&self) -> Vec<AccountState> {
1112 self.events.clone()
1113 }
1114 fn event_count(&self) -> usize {
1115 self.events.len()
1116 }
1117 fn currencies(&self) -> Vec<Currency> {
1118 self.current_balances.keys().copied().collect()
1119 }
1120 fn balances(&self) -> IndexMap<Currency, AccountBalance> {
1121 todo!()
1122 }
1123 fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
1124 todo!()
1125 }
1126 fn calculate_balance_locked(
1127 &mut self,
1128 _: &InstrumentAny,
1129 _: OrderSide,
1130 _: Quantity,
1131 _: Price,
1132 _: Option<bool>,
1133 ) -> Result<Money, anyhow::Error> {
1134 todo!()
1135 }
1136 fn calculate_pnls(
1137 &self,
1138 _: &InstrumentAny,
1139 _: &OrderFilled,
1140 _: Option<Position>,
1141 ) -> Result<Vec<Money>, anyhow::Error> {
1142 todo!()
1143 }
1144 fn calculate_commission(
1145 &self,
1146 _: &InstrumentAny,
1147 _: Quantity,
1148 _: Price,
1149 _: LiquiditySide,
1150 _: Option<bool>,
1151 ) -> Result<Money, anyhow::Error> {
1152 todo!()
1153 }
1154
1155 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1156 todo!()
1157 }
1158
1159 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1160 }
1162 }
1163
1164 fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1165 AccountState::new(
1166 AccountId::new("test-account"),
1167 AccountType::Cash,
1168 vec![AccountBalance::new(
1169 Money::new(total, currency),
1170 Money::new(0.0, currency),
1171 Money::new(total, currency),
1172 )],
1173 vec![],
1174 true,
1175 UUID4::new(),
1176 UnixNanos::from(ts_event),
1177 UnixNanos::from(ts_event),
1178 Some(currency),
1179 )
1180 }
1181
1182 fn create_portfolio_snapshot(
1183 account_id: AccountId,
1184 equity: Decimal,
1185 currency: Currency,
1186 ts_event: u64,
1187 ) -> PortfolioSnapshot {
1188 let equity = Money::from_decimal(equity, currency).unwrap();
1189
1190 PortfolioSnapshot::new(
1191 account_id,
1192 AccountType::Cash,
1193 Some(currency),
1194 vec![],
1195 vec![],
1196 vec![],
1197 vec![],
1198 vec![equity],
1199 Some(equity),
1200 false,
1201 vec![],
1202 vec![],
1203 vec![],
1204 UUID4::new(),
1205 UnixNanos::from(ts_event),
1206 UnixNanos::from(ts_event),
1207 )
1208 }
1209
1210 #[rstest]
1211 fn test_calculate_snapshot_returns_tracks_daily_mark_to_market_equity() {
1212 let account_id = AccountId::new("SIM-001");
1213 let currency = Currency::USD();
1214 let snapshots = [
1215 create_portfolio_snapshot(
1216 account_id,
1217 Decimal::from(10_000),
1218 currency,
1219 NANOSECONDS_IN_DAY + NANOSECONDS_IN_DAY / 2,
1220 ),
1221 create_portfolio_snapshot(
1222 account_id,
1223 Decimal::from(10_500),
1224 currency,
1225 NANOSECONDS_IN_DAY + 3 * NANOSECONDS_IN_DAY / 4,
1226 ),
1227 create_portfolio_snapshot(
1228 account_id,
1229 Decimal::from(11_000),
1230 currency,
1231 2 * NANOSECONDS_IN_DAY,
1232 ),
1233 create_portfolio_snapshot(
1234 account_id,
1235 Decimal::from(12_100),
1236 currency,
1237 3 * NANOSECONDS_IN_DAY,
1238 ),
1239 ];
1240
1241 let returns =
1242 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1243 let values: Vec<f64> = returns.values().copied().collect();
1244 let dates: Vec<UnixNanos> = returns.keys().copied().collect();
1245
1246 assert_eq!(
1247 dates,
1248 vec![
1249 UnixNanos::from(NANOSECONDS_IN_DAY),
1250 UnixNanos::from(2 * NANOSECONDS_IN_DAY),
1251 ]
1252 );
1253 assert_eq!(values.len(), 2);
1254 assert!(approx_eq!(f64, values[0], 0.1, epsilon = 1e-12));
1255 assert!(approx_eq!(f64, values[1], 0.1, epsilon = 1e-12));
1256 }
1257
1258 #[rstest]
1259 fn test_calculate_snapshot_returns_aggregates_accounts_in_one_currency() {
1260 let account_a = AccountId::new("SIM-001");
1261 let account_b = AccountId::new("SIM-002");
1262 let currency = Currency::USD();
1263 let snapshots = [
1264 create_portfolio_snapshot(account_a, Decimal::from(100), currency, NANOSECONDS_IN_DAY),
1265 create_portfolio_snapshot(account_b, Decimal::from(50), currency, NANOSECONDS_IN_DAY),
1266 create_portfolio_snapshot(
1267 account_a,
1268 Decimal::from(110),
1269 currency,
1270 2 * NANOSECONDS_IN_DAY,
1271 ),
1272 ];
1273
1274 let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1275 &[account_a, account_b],
1276 snapshots.iter(),
1277 )
1278 .unwrap();
1279
1280 assert!(approx_eq!(
1281 f64,
1282 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1283 160.0 / 150.0 - 1.0,
1284 epsilon = 1e-12
1285 ));
1286 }
1287
1288 #[rstest]
1289 fn test_calculate_snapshot_returns_uses_single_total_without_base_currency() {
1290 let account_id = AccountId::new("SIM-001");
1291 let currency = Currency::USD();
1292 let mut first =
1293 create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1294 let mut second = create_portfolio_snapshot(
1295 account_id,
1296 Decimal::from(110),
1297 currency,
1298 2 * NANOSECONDS_IN_DAY,
1299 );
1300 first.base_currency_equity = None;
1301 second.base_currency_equity = None;
1302 let snapshots = [first, second];
1303
1304 let returns =
1305 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1306
1307 assert!(approx_eq!(
1308 f64,
1309 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1310 0.1,
1311 epsilon = 1e-12
1312 ));
1313 }
1314
1315 #[rstest]
1316 fn test_calculate_snapshot_returns_rejects_multi_currency_total_with_base_equity() {
1317 let account_id = AccountId::new("SIM-001");
1318 let mut first = create_portfolio_snapshot(
1319 account_id,
1320 Decimal::from(100),
1321 Currency::USD(),
1322 NANOSECONDS_IN_DAY,
1323 );
1324 let mut second = create_portfolio_snapshot(
1325 account_id,
1326 Decimal::from(110),
1327 Currency::USD(),
1328 2 * NANOSECONDS_IN_DAY,
1329 );
1330 first.total_equity.push(Money::new(50.0, Currency::AUD()));
1331 second.total_equity.push(Money::new(55.0, Currency::AUD()));
1332 let snapshots = [first, second];
1333
1334 let returns =
1335 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter());
1336
1337 assert!(returns.is_none());
1338 }
1339
1340 #[rstest]
1341 fn test_calculate_snapshot_returns_forward_fills_unpriced_dates() {
1342 let account_id = AccountId::new("SIM-001");
1343 let currency = Currency::USD();
1344 let first =
1345 create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1346 let mut unpriced =
1347 create_portfolio_snapshot(account_id, Decimal::ZERO, currency, 2 * NANOSECONDS_IN_DAY);
1348 unpriced.unpriced_instruments = vec![instrument_id_aud_usd_sim()];
1349 let last = create_portfolio_snapshot(
1350 account_id,
1351 Decimal::from(110),
1352 currency,
1353 3 * NANOSECONDS_IN_DAY,
1354 );
1355 let snapshots = [first, unpriced, last];
1356
1357 let returns =
1358 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1359
1360 assert!(approx_eq!(
1361 f64,
1362 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1363 0.0,
1364 epsilon = 1e-12
1365 ));
1366 assert!(approx_eq!(
1367 f64,
1368 returns[&UnixNanos::from(2 * NANOSECONDS_IN_DAY)],
1369 0.1,
1370 epsilon = 1e-12
1371 ));
1372 }
1373
1374 #[rstest]
1375 fn test_calculate_snapshot_returns_rejects_mixed_account_currencies() {
1376 let account_a = AccountId::new("SIM-001");
1377 let account_b = AccountId::new("SIM-002");
1378 let snapshots = [
1379 create_portfolio_snapshot(
1380 account_a,
1381 Decimal::from(100),
1382 Currency::USD(),
1383 NANOSECONDS_IN_DAY,
1384 ),
1385 create_portfolio_snapshot(
1386 account_b,
1387 Decimal::from(100),
1388 Currency::AUD(),
1389 NANOSECONDS_IN_DAY,
1390 ),
1391 ];
1392
1393 let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1394 &[account_a, account_b],
1395 snapshots.iter(),
1396 );
1397
1398 assert!(returns.is_none());
1399 }
1400
1401 #[rstest]
1402 fn test_register_and_deregister_statistics() {
1403 let mut analyzer = PortfolioAnalyzer::new();
1404 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1405 Arc::new(MockStatistic::new("test_stat"));
1406
1407 analyzer.register_statistic(Arc::clone(&stat));
1409 assert!(analyzer.statistic("test_stat").is_some());
1410
1411 analyzer.deregister_statistic(&stat);
1413 assert!(analyzer.statistic("test_stat").is_none());
1414
1415 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1417 Arc::new(MockStatistic::new("stat1"));
1418 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1419 Arc::new(MockStatistic::new("stat2"));
1420 analyzer.register_statistic(Arc::clone(&stat1));
1421 analyzer.register_statistic(Arc::clone(&stat2));
1422 analyzer.deregister_statistics();
1423 assert!(analyzer.statistics.is_empty());
1424 }
1425
1426 #[rstest]
1427 fn test_calculate_total_pnl() {
1428 let mut analyzer = PortfolioAnalyzer::new();
1429 let currency = Currency::USD();
1430
1431 let mut starting_balances = AHashMap::new();
1433 starting_balances.insert(currency, Money::new(1000.0, currency));
1434
1435 let mut current_balances = AHashMap::new();
1436 current_balances.insert(currency, Money::new(1500.0, currency));
1437
1438 let account = MockAccount {
1439 starting_balances,
1440 current_balances,
1441 events: vec![],
1442 };
1443
1444 analyzer.calculate_statistics(&account, &[]);
1445
1446 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
1448 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1449
1450 let unrealized_pnl = Money::new(100.0, currency);
1452 let result = analyzer
1453 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
1454 .unwrap();
1455 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1456 }
1457
1458 #[rstest]
1459 fn test_calculate_total_pnl_percentage() {
1460 let mut analyzer = PortfolioAnalyzer::new();
1461 let currency = Currency::USD();
1462
1463 let mut starting_balances = AHashMap::new();
1465 starting_balances.insert(currency, Money::new(1000.0, currency));
1466
1467 let mut current_balances = AHashMap::new();
1468 current_balances.insert(currency, Money::new(1500.0, currency));
1469
1470 let account = MockAccount {
1471 starting_balances,
1472 current_balances,
1473 events: vec![],
1474 };
1475
1476 analyzer.calculate_statistics(&account, &[]);
1477
1478 let result = analyzer
1480 .total_pnl_percentage(Some(¤cy), None)
1481 .unwrap();
1482 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
1486 let result = analyzer
1487 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
1488 .unwrap();
1489 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
1491
1492 #[rstest]
1493 fn test_add_positions_and_returns() {
1494 let mut analyzer = PortfolioAnalyzer::new();
1495 let currency = Currency::USD();
1496
1497 let positions = vec![
1498 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1499 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1500 ];
1501
1502 analyzer.add_positions(&positions);
1503
1504 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
1506 assert_eq!(pnls.len(), 2);
1507 assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1508 assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1509
1510 let returns = analyzer.returns();
1512 let position_returns = analyzer.position_returns();
1513 assert_eq!(returns.len(), 1);
1514 assert_eq!(position_returns.len(), 1);
1515 assert!(analyzer.portfolio_returns().is_empty());
1516 assert!(approx_eq!(
1517 f64,
1518 *returns.values().next().unwrap(),
1519 0.30000000000000004,
1520 epsilon = 1e-9
1521 ));
1522 assert!(approx_eq!(
1523 f64,
1524 *position_returns.values().next().unwrap(),
1525 0.30000000000000004,
1526 epsilon = 1e-9
1527 ));
1528 }
1529
1530 #[rstest]
1531 fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1532 let mut analyzer = PortfolioAnalyzer::new();
1533 let currency = Currency::USD();
1534 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1535 position.ts_closed = Some(UnixNanos::default());
1536
1537 analyzer.add_positions(&[position]);
1538
1539 assert!(analyzer.position_returns().is_empty());
1540 assert!(analyzer.returns().is_empty());
1541 }
1542
1543 #[rstest]
1544 fn test_add_positions_records_open_position_realized_pnl() {
1545 let mut analyzer = PortfolioAnalyzer::new();
1546 let currency = Currency::USD();
1547 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1548 position.ts_closed = None;
1549 position.ts_last = UnixNanos::from(7);
1551 let position_id = position.id;
1552
1553 analyzer.add_positions(&[position]);
1554
1555 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1556 assert_eq!(records.len(), 1);
1557 assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1558 assert!(analyzer.position_returns().is_empty());
1559 }
1560
1561 #[rstest]
1562 fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1563 let mut analyzer = PortfolioAnalyzer::new();
1566 let currency = Currency::USD();
1567 let position_id = PositionId::new("pos1");
1568
1569 analyzer.add_trade(
1570 &position_id,
1571 UnixNanos::from(1),
1572 &Money::new(10.0, currency),
1573 );
1574 analyzer.add_trade(
1575 &position_id,
1576 UnixNanos::from(2),
1577 &Money::new(20.0, currency),
1578 );
1579 analyzer.record_trade(
1580 &position_id,
1581 UnixNanos::from(2),
1582 &Money::new(25.0, currency),
1583 );
1584
1585 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1586
1587 assert_eq!(
1588 records,
1589 vec![
1590 (position_id, UnixNanos::from(1), 10.0),
1591 (position_id, UnixNanos::from(2), 25.0),
1592 ]
1593 );
1594 }
1595
1596 #[rstest]
1597 fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1598 let mut analyzer = PortfolioAnalyzer::new();
1599 let currency = Currency::USD();
1600 let position_id = PositionId::new("pos1");
1601 let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1602 let ts_event = UnixNanos::from(1);
1603
1604 analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1605 analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1606
1607 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1608
1609 assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1610 }
1611
1612 #[rstest]
1613 fn test_performance_stats_calculation() {
1614 let mut analyzer = PortfolioAnalyzer::new();
1615 let currency = Currency::USD();
1616 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1617 Arc::new(MockStatistic::new("test_stat"));
1618 analyzer.register_statistic(Arc::clone(&stat));
1619
1620 let positions = vec![
1622 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1623 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1624 ];
1625
1626 let mut starting_balances = AHashMap::new();
1627 starting_balances.insert(currency, Money::new(1000.0, currency));
1628
1629 let mut current_balances = AHashMap::new();
1630 current_balances.insert(currency, Money::new(1500.0, currency));
1631
1632 let account = MockAccount {
1633 starting_balances,
1634 current_balances,
1635 events: vec![],
1636 };
1637
1638 analyzer.calculate_statistics(&account, &positions);
1639
1640 let pnl_stats = analyzer
1642 .get_performance_stats_pnls(Some(¤cy), None)
1643 .unwrap();
1644 assert!(pnl_stats.contains_key("PnL (total)"));
1645 assert!(pnl_stats.contains_key("PnL% (total)"));
1646 assert!(pnl_stats.contains_key("test_stat"));
1647
1648 let return_stats = analyzer.get_performance_stats_returns();
1650 assert!(return_stats.contains_key("test_stat"));
1651
1652 let general_stats = analyzer.get_performance_stats_general();
1654 assert!(general_stats.contains_key("test_stat"));
1655 }
1656
1657 #[rstest]
1658 fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1659 let mut analyzer = PortfolioAnalyzer::new();
1660 let account_currency = Currency::EUR();
1661 let native_currency = Currency::USD();
1662 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1663 Arc::new(MockStatistic::new("test_stat"));
1664 analyzer.register_statistic(Arc::clone(&stat));
1665 analyzer.record_trade(
1666 &PositionId::new("pos1"),
1667 UnixNanos::from(1),
1668 &Money::new(90.0, account_currency),
1669 );
1670
1671 let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1672
1673 let mut starting_balances = AHashMap::new();
1674 starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1675
1676 let mut current_balances = AHashMap::new();
1677 current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1678
1679 let account = MockAccount {
1680 starting_balances,
1681 current_balances,
1682 events: vec![],
1683 };
1684
1685 analyzer.calculate_statistics(&account, &positions);
1686
1687 let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1688 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1689 let pnl_stats = analyzer
1690 .get_performance_stats_pnls(Some(&account_currency), None)
1691 .unwrap();
1692
1693 assert_eq!(native_pnls[0].2, 100.0);
1694 assert_eq!(recorded_pnls[0].2, 90.0);
1695 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1696 }
1697
1698 #[rstest]
1699 fn test_record_trade_preserves_duplicate_position_ids() {
1700 let mut analyzer = PortfolioAnalyzer::new();
1701 let account_currency = Currency::EUR();
1702 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1703 Arc::new(MockStatistic::new("test_stat"));
1704 let position_id = PositionId::new("pos1");
1705
1706 analyzer.register_statistic(Arc::clone(&stat));
1707 analyzer.record_trade(
1708 &position_id,
1709 UnixNanos::from(1),
1710 &Money::new(90.0, account_currency),
1711 );
1712 analyzer.record_trade(
1713 &position_id,
1714 UnixNanos::from(2),
1715 &Money::new(-45.0, account_currency),
1716 );
1717
1718 let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1719 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1720 let pnl_stats = analyzer
1721 .get_performance_stats_pnls(Some(&account_currency), None)
1722 .unwrap();
1723
1724 assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1725 assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1726 assert_eq!(
1727 recorded_pnls,
1728 vec![
1729 (position_id, UnixNanos::from(1), 90.0),
1730 (position_id, UnixNanos::from(2), -45.0),
1731 ]
1732 );
1733 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
1734 }
1735 #[rstest]
1736 fn test_formatted_output() {
1737 let mut analyzer = PortfolioAnalyzer::new();
1738 let currency = Currency::USD();
1739 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1740 Arc::new(MockStatistic::new("test_stat"));
1741 analyzer.register_statistic(Arc::clone(&stat));
1742
1743 let positions = vec![
1744 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1745 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1746 ];
1747
1748 let mut starting_balances = AHashMap::new();
1749 starting_balances.insert(currency, Money::new(1000.0, currency));
1750
1751 let mut current_balances = AHashMap::new();
1752 current_balances.insert(currency, Money::new(1500.0, currency));
1753
1754 let account = MockAccount {
1755 starting_balances,
1756 current_balances,
1757 events: vec![],
1758 };
1759
1760 analyzer.calculate_statistics(&account, &positions);
1761
1762 let pnl_formatted = analyzer
1764 .get_stats_pnls_formatted(Some(¤cy), None)
1765 .unwrap();
1766 assert!(!pnl_formatted.is_empty());
1767 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
1768
1769 let returns_formatted = analyzer.get_stats_returns_formatted();
1770 assert!(!returns_formatted.is_empty());
1771 assert!(returns_formatted.iter().all(|s| s.contains(':')));
1772
1773 let general_formatted = analyzer.get_stats_general_formatted();
1774 assert!(!general_formatted.is_empty());
1775 assert!(general_formatted.iter().all(|s| s.contains(':')));
1776 }
1777
1778 #[rstest]
1779 fn test_reset() {
1780 let mut analyzer = PortfolioAnalyzer::new();
1781 let currency = Currency::USD();
1782
1783 let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
1784 let mut starting_balances = AHashMap::new();
1785 starting_balances.insert(currency, Money::new(1000.0, currency));
1786 let mut current_balances = AHashMap::new();
1787 current_balances.insert(currency, Money::new(1500.0, currency));
1788
1789 let account = MockAccount {
1790 starting_balances,
1791 current_balances,
1792 events: vec![],
1793 };
1794
1795 analyzer.calculate_statistics(&account, &positions);
1796
1797 analyzer.reset();
1798
1799 assert!(analyzer.account_balances_starting.is_empty());
1800 assert!(analyzer.account_balances.is_empty());
1801 assert!(analyzer.positions.is_empty());
1802 assert!(analyzer.realized_pnls.is_empty());
1803 assert!(analyzer.recorded_realized_pnls.is_empty());
1804 assert!(analyzer.position_returns.is_empty());
1805 assert!(analyzer.portfolio_returns.is_empty());
1806 assert!(analyzer.returns.is_empty());
1807 }
1808
1809 #[rstest]
1810 fn test_currencies_preserve_account_balance_order() {
1811 let mut analyzer = PortfolioAnalyzer::new();
1816 let inserts = [
1817 (Currency::BTC(), Money::new(1.0, Currency::BTC())),
1818 (Currency::USD(), Money::new(2.0, Currency::USD())),
1819 (Currency::ETH(), Money::new(3.0, Currency::ETH())),
1820 ];
1821
1822 for (currency, money) in inserts {
1823 analyzer.account_balances.insert(currency, money);
1824 }
1825
1826 let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
1827 assert_eq!(
1828 returned,
1829 vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
1830 );
1831 }
1832
1833 #[rstest]
1834 fn test_calculate_statistics_clears_previous_positions() {
1835 let mut analyzer = PortfolioAnalyzer::new();
1836 let currency = Currency::USD();
1837
1838 let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
1839 let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
1840
1841 let mut starting_balances = AHashMap::new();
1842 starting_balances.insert(currency, Money::new(1000.0, currency));
1843 let mut current_balances = AHashMap::new();
1844 current_balances.insert(currency, Money::new(1500.0, currency));
1845
1846 let account = MockAccount {
1847 starting_balances,
1848 current_balances,
1849 events: vec![],
1850 };
1851
1852 analyzer.calculate_statistics(&account, &positions1);
1854 assert_eq!(analyzer.positions.len(), 1);
1855
1856 analyzer.calculate_statistics(&account, &positions2);
1858 assert_eq!(analyzer.positions.len(), 1);
1859 }
1860
1861 #[rstest]
1862 fn test_calculate_statistics_uses_account_state_returns_when_available() {
1863 let mut analyzer = PortfolioAnalyzer::new();
1864 let currency = Currency::USD();
1865 let positions = vec![
1866 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1867 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1868 ];
1869
1870 let mut starting_balances = AHashMap::new();
1871 starting_balances.insert(currency, Money::new(1000.0, currency));
1872
1873 let mut current_balances = AHashMap::new();
1874 current_balances.insert(currency, Money::new(1100.0, currency));
1875
1876 let account = MockAccount {
1877 starting_balances,
1878 current_balances,
1879 events: vec![
1880 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1881 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1882 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1883 ],
1884 };
1885
1886 analyzer.calculate_statistics(&account, &positions);
1887
1888 let position_returns = analyzer.position_returns();
1889 let portfolio_returns = analyzer.portfolio_returns();
1890 let returns = analyzer.returns();
1891 assert_eq!(position_returns.len(), 1);
1892 assert_eq!(portfolio_returns.len(), 30);
1893 assert_eq!(returns, portfolio_returns);
1894 assert!(approx_eq!(
1895 f64,
1896 *portfolio_returns
1897 .get(&UnixNanos::from(1_704_153_600_000_000_000))
1898 .unwrap(),
1899 0.0,
1900 epsilon = 1e-9
1901 ));
1902 assert!(approx_eq!(
1903 f64,
1904 *portfolio_returns
1905 .get(&UnixNanos::from(1_704_844_800_000_000_000))
1906 .unwrap(),
1907 0.05,
1908 epsilon = 1e-9
1909 ));
1910 assert!(approx_eq!(
1911 f64,
1912 *portfolio_returns
1913 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1914 .unwrap(),
1915 (1100.0 / 1050.0) - 1.0,
1916 epsilon = 1e-9
1917 ));
1918 assert!(approx_eq!(
1919 f64,
1920 *position_returns.values().next().unwrap(),
1921 0.30000000000000004,
1922 epsilon = 1e-9
1923 ));
1924 }
1925
1926 #[rstest]
1927 fn test_calculate_statistics_skips_empty_balance_events() {
1928 let mut analyzer = PortfolioAnalyzer::new();
1929 let currency = Currency::USD();
1930 let mut starting_balances = AHashMap::new();
1931 starting_balances.insert(currency, Money::new(1000.0, currency));
1932 let mut current_balances = AHashMap::new();
1933 current_balances.insert(currency, Money::new(1050.0, currency));
1934 let empty_event = AccountState::new(
1935 AccountId::new("test-account"),
1936 AccountType::Cash,
1937 vec![],
1938 vec![],
1939 true,
1940 UUID4::new(),
1941 UnixNanos::from(1_705_276_800_000_000_000),
1942 UnixNanos::from(1_705_276_800_000_000_000),
1943 Some(currency),
1944 );
1945 let account = MockAccount {
1946 starting_balances,
1947 current_balances,
1948 events: vec![
1949 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1950 empty_event,
1951 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1952 ],
1953 };
1954
1955 analyzer.calculate_statistics(&account, &[]);
1956
1957 let portfolio_returns = analyzer.portfolio_returns();
1958 assert_eq!(portfolio_returns.len(), 30);
1959 assert_eq!(analyzer.returns(), portfolio_returns);
1960 assert!(approx_eq!(
1961 f64,
1962 *portfolio_returns
1963 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1964 .unwrap(),
1965 0.05,
1966 epsilon = 1e-9
1967 ));
1968 }
1969
1970 #[rstest]
1971 fn test_calculate_statistics_skips_non_finite_account_returns() {
1972 let mut analyzer = PortfolioAnalyzer::new();
1973 let currency = Currency::USD();
1974
1975 let mut starting_balances = AHashMap::new();
1976 starting_balances.insert(currency, Money::new(0.0, currency));
1977
1978 let mut current_balances = AHashMap::new();
1979 current_balances.insert(currency, Money::new(1050.0, currency));
1980
1981 let account = MockAccount {
1982 starting_balances,
1983 current_balances,
1984 events: vec![
1985 create_account_state(0.0, currency, 1_704_067_200_000_000_000),
1986 create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
1987 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1988 ],
1989 };
1990
1991 analyzer.calculate_statistics(&account, &[]);
1992
1993 let returns = analyzer.returns();
1994 assert!(returns.values().all(|value| value.is_finite()));
1995 assert!(approx_eq!(
1996 f64,
1997 *returns
1998 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1999 .unwrap(),
2000 0.05,
2001 epsilon = 1e-9
2002 ));
2003 }
2004
2005 #[rstest]
2006 fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
2007 let mut analyzer = PortfolioAnalyzer::new();
2008 let currency = Currency::USD();
2009 let positions = vec![
2010 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2011 create_mock_position("EUR/USD", 200.0, 0.2, currency),
2012 ];
2013
2014 let mut starting_balances = AHashMap::new();
2015 starting_balances.insert(currency, Money::new(1000.0, currency));
2016
2017 let mut current_balances = AHashMap::new();
2018 current_balances.insert(currency, Money::new(1100.0, currency));
2019
2020 let account = MockAccount {
2021 starting_balances,
2022 current_balances,
2023 events: vec![],
2024 };
2025
2026 analyzer.calculate_statistics(&account, &positions);
2027
2028 let returns = analyzer.returns();
2029 assert!(analyzer.portfolio_returns().is_empty());
2030 assert_eq!(returns, analyzer.position_returns());
2031 assert_eq!(returns.len(), 1);
2032 assert!(approx_eq!(
2033 f64,
2034 *returns.values().next().unwrap(),
2035 0.30000000000000004,
2036 epsilon = 1e-9
2037 ));
2038 }
2039
2040 #[rstest]
2041 fn test_get_performance_stats_returns_prefers_portfolio_returns() {
2042 let mut analyzer = PortfolioAnalyzer::new();
2043 let currency = Currency::USD();
2044 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2045 Arc::new(MockStatistic::new("test_stat"));
2046 analyzer.register_statistic(Arc::clone(&stat));
2047
2048 let positions = vec![
2049 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2050 create_mock_position("EUR/USD", 200.0, 0.2, currency),
2051 ];
2052
2053 let mut starting_balances = AHashMap::new();
2054 starting_balances.insert(currency, Money::new(1000.0, currency));
2055
2056 let mut current_balances = AHashMap::new();
2057 current_balances.insert(currency, Money::new(1100.0, currency));
2058
2059 let account = MockAccount {
2060 starting_balances,
2061 current_balances,
2062 events: vec![
2063 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2064 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2065 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2066 ],
2067 };
2068
2069 analyzer.calculate_statistics(&account, &positions);
2070
2071 let position_stats = analyzer.get_performance_stats_position_returns();
2072 let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
2073 let returns_stats = analyzer.get_performance_stats_returns();
2074
2075 assert!(approx_eq!(
2076 f64,
2077 *position_stats.get("test_stat").unwrap(),
2078 0.30000000000000004,
2079 epsilon = 1e-9
2080 ));
2081 assert_eq!(returns_stats, portfolio_stats);
2082 }
2083
2084 #[rstest]
2085 fn test_from_accounts_aggregates_balances_and_positions() {
2086 let currency = Currency::USD();
2087 let positions = vec![
2088 create_mock_position("pos1", 100.0, 0.1, currency),
2089 create_mock_position("pos2", 200.0, 0.2, currency),
2090 ];
2091
2092 let analyzer = PortfolioAnalyzer::from_accounts(
2093 &[AccountAny::Cash(CashAccount::default())],
2094 &positions,
2095 &[],
2096 AHashMap::new(),
2097 );
2098
2099 assert_eq!(analyzer.positions.len(), positions.len());
2100 assert!(!analyzer.account_balances.is_empty());
2101 }
2102
2103 #[rstest]
2104 fn test_from_accounts_sums_balances_across_accounts() {
2105 let usd = Currency::USD();
2106 let one = PortfolioAnalyzer::from_accounts(
2107 &[AccountAny::Cash(CashAccount::default())],
2108 &[],
2109 &[],
2110 AHashMap::new(),
2111 );
2112 let two = PortfolioAnalyzer::from_accounts(
2113 &[
2114 AccountAny::Cash(CashAccount::default()),
2115 AccountAny::Cash(CashAccount::default()),
2116 ],
2117 &[],
2118 &[],
2119 AHashMap::new(),
2120 );
2121
2122 let single = one.account_balances.get(&usd).unwrap().as_decimal();
2123 let summed = two.account_balances.get(&usd).unwrap().as_decimal();
2124 let single_start = one
2125 .account_balances_starting
2126 .get(&usd)
2127 .unwrap()
2128 .as_decimal();
2129 let summed_start = two
2130 .account_balances_starting
2131 .get(&usd)
2132 .unwrap()
2133 .as_decimal();
2134
2135 assert_eq!(summed, single + single);
2136 assert_eq!(summed_start, single_start + single_start);
2137 assert_ne!(summed, single);
2138 }
2139
2140 #[rstest]
2141 fn test_statistics_snapshot_matches_getters() {
2142 let currency = Currency::USD();
2143 let positions = vec![
2144 create_mock_position("pos1", 100.0, 0.1, currency),
2145 create_mock_position("pos2", 200.0, 0.2, currency),
2146 ];
2147
2148 let analyzer = PortfolioAnalyzer::from_accounts(
2149 &[AccountAny::Cash(CashAccount::default())],
2150 &positions,
2151 &[],
2152 AHashMap::new(),
2153 );
2154
2155 let snapshot = analyzer.statistics();
2156 assert!(maps_equal_nan_aware(
2157 &snapshot.returns,
2158 &analyzer.get_performance_stats_returns()
2159 ));
2160 assert!(maps_equal_nan_aware(
2161 &snapshot.general,
2162 &analyzer.get_performance_stats_general()
2163 ));
2164 assert_eq!(&snapshot.returns_series, analyzer.returns());
2165
2166 for currency in analyzer.currencies() {
2167 let expected = analyzer
2168 .get_performance_stats_pnls(Some(currency), None)
2169 .unwrap();
2170 let actual = snapshot.pnls.get(¤cy.code.to_string()).unwrap();
2171 assert!(maps_equal_nan_aware(actual, &expected));
2172 }
2173 }
2174
2175 fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
2176 if a.len() != b.len() {
2177 return false;
2178 }
2179 a.iter().all(|(k, v)| {
2180 b.get(k)
2181 .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
2182 })
2183 }
2184
2185 #[rstest]
2186 fn test_get_performance_stats_returns_vs_benchmark() {
2187 let mut analyzer = PortfolioAnalyzer::new();
2188 analyzer.register_statistic(Arc::new(BetaRatio::new()));
2189 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
2190
2191 let one_day = 86_400_000_000_000_u64;
2192 let start = 1_600_000_000_000_000_000_u64;
2193 for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
2194 analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
2195 }
2196
2197 let mut benchmark: Returns = BTreeMap::new();
2198 for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
2199 benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
2200 }
2201
2202 let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
2203
2204 assert_eq!(stats.len(), 1);
2210 assert!(approx_eq!(
2211 f64,
2212 *stats.get("Beta").unwrap(),
2213 6.0,
2214 epsilon = 1e-9
2215 ));
2216 assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
2217 }
2218}