1use std::{collections::BTreeMap, fmt::Debug, sync::Arc};
17
18use ahash::AHashMap;
19use indexmap::{IndexMap, IndexSet};
20use nautilus_core::{DurationNanos, UUID4, UnixNanos};
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 replace_statistics(&mut self, statistics: AHashMap<String, Statistic>) {
142 self.statistics = statistics;
143 }
144
145 pub fn reset(&mut self) {
149 self.account_balances_starting.clear();
150 self.account_balances.clear();
151 self.positions.clear();
152 self.realized_pnls.clear();
153 self.recorded_realized_pnls.clear();
154 self.position_returns.clear();
155 self.portfolio_returns.clear();
156 self.returns.clear();
157 }
158
159 #[must_use]
161 pub fn currencies(&self) -> Vec<&Currency> {
162 self.account_balances.keys().collect()
163 }
164
165 #[must_use]
167 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
168 self.statistics.get(name)
169 }
170
171 #[must_use]
176 pub const fn returns(&self) -> &Returns {
177 &self.returns
178 }
179
180 #[must_use]
182 pub const fn position_returns(&self) -> &Returns {
183 &self.position_returns
184 }
185
186 #[must_use]
188 pub const fn portfolio_returns(&self) -> &Returns {
189 &self.portfolio_returns
190 }
191
192 pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
197 self.account_balances_starting = account.starting_balances().into_iter().collect();
198 self.account_balances = account.balances_total().into_iter().collect();
199 self.positions.clear();
200 self.realized_pnls.clear();
201 self.position_returns.clear();
202 self.portfolio_returns.clear();
203 self.returns.clear();
204
205 self.add_positions(positions);
206
207 if let Some(account_returns) = Self::calculate_account_returns(account) {
208 self.portfolio_returns = account_returns;
209 self.sync_returns_alias();
210 }
211 }
212
213 #[must_use]
218 pub fn from_accounts(
219 accounts: &[AccountAny],
220 positions: &[Position],
221 snapshots: &[Position],
222 recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
223 ) -> Self {
224 Self::from_accounts_with_snapshots(
225 accounts,
226 positions,
227 snapshots,
228 &[],
229 recorded_realized_pnls,
230 )
231 }
232
233 #[must_use]
239 pub fn from_accounts_with_snapshots<'a>(
240 accounts: &[AccountAny],
241 positions: &[Position],
242 position_snapshots: &[Position],
243 portfolio_snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
244 recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
245 ) -> Self {
246 let mut analyzer = Self::default();
247 let mut account_ids = Vec::with_capacity(accounts.len());
248
249 for account in accounts {
250 let account_ref: &dyn Account = match account {
251 AccountAny::Margin(margin) => margin,
252 AccountAny::Cash(cash) => cash,
253 AccountAny::Betting(betting) => betting,
254 AccountAny::Wallet(wallet) => wallet,
255 };
256 account_ids.push(account_ref.id());
257
258 for (currency, money) in account_ref.starting_balances() {
259 analyzer
260 .account_balances_starting
261 .entry(currency)
262 .and_modify(|existing| *existing = *existing + money)
263 .or_insert(money);
264 }
265
266 for (currency, money) in account_ref.balances_total() {
267 analyzer
268 .account_balances
269 .entry(currency)
270 .and_modify(|existing| *existing = *existing + money)
271 .or_insert(money);
272 }
273 }
274
275 analyzer.add_positions(positions);
276 analyzer.add_positions(position_snapshots);
277 analyzer.recorded_realized_pnls = recorded_realized_pnls;
278 analyzer.set_portfolio_returns_from_snapshots(&account_ids, portfolio_snapshots);
279 analyzer
280 }
281
282 pub fn set_portfolio_returns_from_snapshots<'a>(
284 &mut self,
285 account_ids: &[AccountId],
286 snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
287 ) {
288 if let Some(returns) = Self::calculate_snapshot_returns(account_ids, snapshots) {
289 self.portfolio_returns = returns;
290 self.sync_returns_alias();
291 }
292 }
293
294 #[must_use]
296 pub fn statistics(&self) -> PortfolioStatistics {
297 let mut pnls = AHashMap::new();
298
299 for currency in self.currencies() {
300 if let Ok(stats) = self.get_performance_stats_pnls(Some(currency), None) {
301 pnls.insert(currency.code.to_string(), stats);
302 }
303 }
304 PortfolioStatistics {
305 pnls,
306 returns: self.get_performance_stats_returns(),
307 general: self.get_performance_stats_general(),
308 returns_series: self.returns.clone(),
309 }
310 }
311
312 pub fn add_positions(&mut self, positions: &[Position]) {
314 self.positions.extend_from_slice(positions);
315 for position in positions {
316 if let Some(ref pnl) = position.realized_pnl {
317 self.add_trade(&position.id, position.ts_last, pnl);
318 }
319
320 if let Some(ts_closed) = position.ts_closed
321 && ts_closed.as_u64() > 0
322 && position.realized_pnl.is_some()
323 {
324 self.add_position_return(ts_closed, position.realized_return);
325 }
326 }
327 }
328
329 pub fn add_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
331 let currency = pnl.currency;
332 let entry = self.realized_pnls.entry(currency).or_default();
333 entry.push((*position_id, ts_event, pnl.as_f64()));
334 }
335
336 pub fn record_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
338 let currency = pnl.currency;
339 let entry = self.recorded_realized_pnls.entry(currency).or_default();
340 entry.push((*position_id, ts_event, pnl.as_f64()));
341 }
342
343 pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
345 self.position_returns
346 .entry(timestamp)
347 .and_modify(|existing_value| *existing_value += value)
348 .or_insert(value);
349
350 if self.portfolio_returns.is_empty() {
354 self.returns
355 .entry(timestamp)
356 .and_modify(|existing_value| *existing_value += value)
357 .or_insert(value);
358 }
359 }
360
361 pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
365 self.add_position_return(timestamp, value);
366 }
367
368 fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
379 let mut events = account.events();
380 if events.len() < 2 {
381 return None;
382 }
383
384 events.sort_by_key(|event| event.ts_event);
385
386 let mut currency = None;
387 let mut daily_balances = BTreeMap::new();
388
389 for event in events {
390 if event.balances.is_empty() {
391 continue;
392 }
393
394 if event.balances.len() != 1 {
395 return None;
396 }
397
398 let balance = event.balances[0];
399
400 if let Some(existing_currency) = currency {
401 if existing_currency != balance.currency {
402 return None;
403 }
404 } else {
405 currency = Some(balance.currency);
406 }
407
408 let day_start = event.ts_event.floor(DurationNanos::from_days(1));
409 daily_balances.insert(day_start, balance.total.as_f64());
410 }
411
412 Self::calculate_daily_returns(&daily_balances)
413 }
414
415 fn calculate_snapshot_returns<'a>(
416 account_ids: &[AccountId],
417 snapshots: impl IntoIterator<Item = &'a PortfolioSnapshot>,
418 ) -> Option<Returns> {
419 let expected_accounts: IndexSet<AccountId> = account_ids.iter().copied().collect();
420 if expected_accounts.is_empty() {
421 return None;
422 }
423
424 let mut currency = None;
425 let mut equity_by_account: AHashMap<AccountId, BTreeMap<UnixNanos, f64>> = AHashMap::new();
426
427 for snapshot in snapshots {
428 if !expected_accounts.contains(&snapshot.account_id) {
429 continue;
430 }
431
432 if !snapshot.unpriced_instruments.is_empty() {
433 continue;
434 }
435
436 if snapshot.total_equity.len() != 1 {
437 return None;
438 }
439
440 let equity = snapshot
441 .base_currency_equity
442 .unwrap_or(snapshot.total_equity[0]);
443
444 if let Some(existing_currency) = currency {
445 if existing_currency != equity.currency {
446 return None;
447 }
448 } else {
449 currency = Some(equity.currency);
450 }
451
452 let is_registration = !equity_by_account.contains_key(&snapshot.account_id);
453 let day_start = Self::snapshot_day_start(snapshot.ts_event, is_registration);
454 equity_by_account
455 .entry(snapshot.account_id)
456 .or_default()
457 .insert(day_start, equity.as_f64());
458 }
459
460 if equity_by_account.len() != expected_accounts.len() {
461 return None;
462 }
463
464 let first_day = equity_by_account
465 .values()
466 .filter_map(|equity| equity.keys().next().copied())
467 .min()?;
468 let last_day = equity_by_account
469 .values()
470 .filter_map(|equity| equity.keys().next_back().copied())
471 .max()?;
472 let mut daily_equity = BTreeMap::new();
473 let mut current_equity = AHashMap::new();
474 let mut current_day = first_day;
475
476 loop {
477 for account_id in &expected_accounts {
478 if let Some(equity) = equity_by_account
479 .get(account_id)
480 .and_then(|values| values.get(¤t_day))
481 {
482 current_equity.insert(*account_id, *equity);
483 }
484 }
485
486 if current_equity.len() == expected_accounts.len() {
487 let total = expected_accounts
488 .iter()
489 .map(|account_id| current_equity[account_id])
490 .sum();
491 daily_equity.insert(current_day, total);
492 }
493
494 if current_day >= last_day {
495 break;
496 }
497
498 current_day += DurationNanos::from_days(1);
499 }
500
501 Self::calculate_daily_returns(&daily_equity)
502 }
503
504 fn snapshot_day_start(ts_event: UnixNanos, is_registration: bool) -> UnixNanos {
505 let day = DurationNanos::from_days(1);
506 let day_start = ts_event.floor(day);
507 if is_registration || (ts_event == day_start && !ts_event.is_zero()) {
508 day_start.saturating_sub(day)
509 } else {
510 day_start
511 }
512 }
513
514 fn calculate_daily_returns(daily_equity: &BTreeMap<UnixNanos, f64>) -> Option<Returns> {
515 if daily_equity.len() < 2 {
516 return None;
517 }
518
519 let mut returns = Returns::new();
520 let mut current_day = *daily_equity.keys().next()?;
521 let last_day = *daily_equity.keys().next_back()?;
522 let mut current_balance: Option<f64> = None;
523 let mut previous_balance: Option<f64> = None;
524
525 loop {
526 if let Some(balance) = daily_equity.get(¤t_day) {
527 current_balance = Some(*balance);
528 }
529
530 let balance = current_balance?;
531
532 if let Some(previous) = previous_balance
533 && previous != 0.0
534 {
535 let value: f64 = (balance / previous) - 1.0;
536 if value.is_finite() {
537 returns.insert(current_day, value);
538 }
539 }
540
541 previous_balance = Some(balance);
542
543 if current_day >= last_day {
544 break;
545 }
546
547 current_day += DurationNanos::from_days(1);
548 }
549
550 (!returns.is_empty()).then_some(returns)
551 }
552
553 #[must_use]
569 pub fn trade_pnl_records(
570 &self,
571 currency: Option<&Currency>,
572 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
573 if self.realized_pnls.is_empty() && self.recorded_realized_pnls.is_empty() {
574 return None;
575 }
576
577 let currency = self.resolve_pnl_currency(currency).ok()?;
579
580 let realized_pnls = self.realized_pnls.get(¤cy);
581 let recorded_realized_pnls = self.recorded_realized_pnls.get(¤cy);
582
583 let mut output = match (realized_pnls, recorded_realized_pnls) {
584 (None, None) => return None,
585 (Some(realized_pnls), None) => realized_pnls.clone(),
586 (None, Some(recorded_realized_pnls)) => recorded_realized_pnls.clone(),
587 (Some(realized_pnls), Some(recorded_realized_pnls)) => {
588 let recorded_keys: IndexSet<(PositionId, UnixNanos)> = recorded_realized_pnls
589 .iter()
590 .map(|(position_id, ts_event, _)| {
591 (canonical_position_id(*position_id), *ts_event)
592 })
593 .collect();
594 let mut merged: Vec<(PositionId, UnixNanos, f64)> = realized_pnls
595 .iter()
596 .copied()
597 .filter(|(position_id, ts_event, _)| {
598 let key = (canonical_position_id(*position_id), *ts_event);
599 !recorded_keys.contains(&key)
600 })
601 .collect();
602 merged.extend(recorded_realized_pnls.iter().copied());
603
604 merged
605 }
606 };
607
608 output.sort_by_key(|(_, ts_event, _)| *ts_event);
611
612 Some(output)
613 }
614
615 #[must_use]
621 pub fn realized_pnls(
622 &self,
623 currency: Option<&Currency>,
624 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
625 self.trade_pnl_records(currency)
626 }
627
628 #[expect(clippy::missing_panics_doc)] pub fn total_pnl(
638 &self,
639 currency: Option<&Currency>,
640 unrealized_pnl: Option<&Money>,
641 ) -> Result<f64, &'static str> {
642 if self.account_balances.is_empty() {
643 return Ok(0.0);
644 }
645
646 let currency = match currency {
648 Some(c) => c,
649 None if self.account_balances.len() == 1 => {
650 self.account_balances.keys().next().expect("len is 1")
651 }
652 None => return Err("Currency must be specified for multi-currency portfolio"),
653 };
654
655 if let Some(unrealized_pnl) = unrealized_pnl
656 && unrealized_pnl.currency != *currency
657 {
658 return Err("Unrealized PnL currency does not match specified currency");
659 }
660
661 let account_balance = self
662 .account_balances
663 .get(currency)
664 .ok_or("Specified currency not found in account balances")?;
665
666 let default_money = &Money::zero(*currency);
667 let account_balance_starting = self
668 .account_balances_starting
669 .get(currency)
670 .unwrap_or(default_money);
671
672 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
673 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
674 }
675
676 #[expect(clippy::missing_panics_doc)] pub fn total_pnl_percentage(
686 &self,
687 currency: Option<&Currency>,
688 unrealized_pnl: Option<&Money>,
689 ) -> Result<f64, &'static str> {
690 if self.account_balances.is_empty() {
691 return Ok(0.0);
692 }
693
694 let currency = match currency {
696 Some(c) => c,
697 None if self.account_balances.len() == 1 => {
698 self.account_balances.keys().next().expect("len is 1")
699 }
700 None => return Err("Currency must be specified for multi-currency portfolio"),
701 };
702
703 if let Some(unrealized_pnl) = unrealized_pnl
704 && unrealized_pnl.currency != *currency
705 {
706 return Err("Unrealized PnL currency does not match specified currency");
707 }
708
709 let account_balance = self
710 .account_balances
711 .get(currency)
712 .ok_or("Specified currency not found in account balances")?;
713
714 let default_money = &Money::zero(*currency);
715 let account_balance_starting = self
716 .account_balances_starting
717 .get(currency)
718 .unwrap_or(default_money);
719
720 if account_balance_starting.as_decimal() == Decimal::ZERO {
721 return Ok(0.0);
722 }
723
724 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
725 let current = account_balance.as_f64() + unrealized_pnl_f64;
726 let starting = account_balance_starting.as_f64();
727 let difference = current - starting;
728
729 Ok((difference / starting) * 100.0)
730 }
731
732 pub fn get_performance_stats_pnls(
742 &self,
743 currency: Option<&Currency>,
744 unrealized_pnl: Option<&Money>,
745 ) -> Result<AHashMap<String, f64>, &'static str> {
746 let mut output = AHashMap::new();
747
748 output.insert(
749 "PnL (total)".to_string(),
750 self.total_pnl(currency, unrealized_pnl)?,
751 );
752 output.insert(
753 "PnL% (total)".to_string(),
754 self.total_pnl_percentage(currency, unrealized_pnl)?,
755 );
756
757 let records = self.trade_pnl_records(currency);
758 let has_records = !self.realized_pnls.is_empty() || !self.recorded_realized_pnls.is_empty();
759
760 if records.is_none() && has_records {
764 self.resolve_pnl_currency(currency)?;
765 }
766
767 let realized_pnls: Vec<f64> = records
768 .unwrap_or_default()
769 .iter()
770 .map(|(_, _, pnl)| *pnl)
771 .collect();
772
773 for (name, stat) in &self.statistics {
774 if let Some(value) = stat.calculate_from_realized_pnls(&realized_pnls) {
775 output.insert(name.clone(), value);
776 }
777 }
778
779 Ok(output)
780 }
781
782 #[must_use]
784 pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
785 self.calculate_returns_stats(self.returns())
786 }
787
788 #[must_use]
790 pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
791 self.calculate_returns_stats(self.position_returns())
792 }
793
794 #[must_use]
796 pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
797 self.calculate_returns_stats(self.portfolio_returns())
798 }
799
800 #[must_use]
807 pub fn get_performance_stats_returns_vs_benchmark(
808 &self,
809 benchmark: &Returns,
810 ) -> AHashMap<String, f64> {
811 let mut output = AHashMap::new();
812
813 for (name, stat) in &self.statistics {
814 if let Some(value) =
815 stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
816 {
817 output.insert(name.clone(), value);
818 }
819 }
820
821 output
822 }
823
824 #[must_use]
826 pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
827 let mut output = AHashMap::new();
828
829 for (name, stat) in &self.statistics {
830 if let Some(value) = stat.calculate_from_positions(&self.positions) {
831 output.insert(name.clone(), value);
832 }
833 }
834
835 output
836 }
837
838 fn get_max_length_name(&self) -> usize {
840 self.statistics.keys().map(String::len).max().unwrap_or(0)
841 }
842
843 fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
844 let mut output = AHashMap::new();
845
846 for (name, stat) in &self.statistics {
847 if let Some(value) = stat.calculate_from_returns(returns) {
848 output.insert(name.clone(), value);
849 }
850 }
851
852 output
853 }
854
855 fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
856 let max_length = self.get_max_length_name();
857 let mut entries: Vec<_> = stats.into_iter().collect();
858 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
859
860 let mut output = Vec::new();
861
862 for (k, v) in entries {
863 let padding = max_length.saturating_sub(k.len()) + 1;
864 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
865 }
866
867 output
868 }
869
870 fn sync_returns_alias(&mut self) {
871 if self.portfolio_returns.is_empty() {
872 self.returns = self.position_returns.clone();
873 return;
874 }
875
876 self.returns = self.portfolio_returns.clone();
877 }
878
879 fn resolve_pnl_currency(&self, currency: Option<&Currency>) -> Result<Currency, &'static str> {
888 match currency {
889 Some(c) => Ok(*c),
890 None if self.account_balances.len() == 1 => {
891 Ok(*self.account_balances.keys().next().expect("len is 1"))
892 }
893 None => {
894 let mut currencies: IndexSet<Currency> =
895 self.realized_pnls.keys().copied().collect();
896 currencies.extend(self.recorded_realized_pnls.keys().copied());
897 if currencies.len() != 1 {
898 return Err("Currency must be specified for multi-currency portfolio");
899 }
900
901 Ok(*currencies.first().expect("len is 1"))
902 }
903 }
904 }
905
906 pub fn get_stats_pnls_formatted(
912 &self,
913 currency: Option<&Currency>,
914 unrealized_pnl: Option<&Money>,
915 ) -> Result<Vec<String>, String> {
916 let max_length = self.get_max_length_name();
917 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
918
919 let mut entries: Vec<_> = stats.into_iter().collect();
920 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
921
922 let mut output = Vec::new();
923
924 for (k, v) in entries {
925 let padding = if max_length > k.len() {
926 max_length - k.len() + 1
927 } else {
928 1
929 };
930 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
931 }
932
933 Ok(output)
934 }
935
936 #[must_use]
938 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
939 self.format_returns_stats(self.get_performance_stats_returns())
940 }
941
942 #[must_use]
944 pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
945 self.format_returns_stats(self.get_performance_stats_position_returns())
946 }
947
948 #[must_use]
950 pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
951 self.format_returns_stats(self.get_performance_stats_portfolio_returns())
952 }
953
954 #[must_use]
956 pub fn get_stats_general_formatted(&self) -> Vec<String> {
957 let max_length = self.get_max_length_name();
958 let stats = self.get_performance_stats_general();
959
960 let mut entries: Vec<_> = stats.into_iter().collect();
961 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
962
963 let mut output = Vec::new();
964
965 for (k, v) in entries {
966 let padding = max_length - k.len() + 1;
967 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
968 }
969
970 output
971 }
972}
973
974fn canonical_position_id(position_id: PositionId) -> PositionId {
975 const UUID4_STRING_LEN: usize = 36;
976
977 let value = position_id.as_str();
978 let Some(separator_index) = value.len().checked_sub(UUID4_STRING_LEN + 1) else {
979 return position_id;
980 };
981
982 if separator_index == 0 || value.as_bytes()[separator_index] != b'-' {
983 return position_id;
984 }
985
986 let suffix = &value[separator_index + 1..];
987 if suffix.parse::<UUID4>().is_ok() {
988 PositionId::new(&value[..separator_index])
989 } else {
990 position_id
991 }
992}
993
994#[cfg(test)]
995mod tests {
996 use std::sync::Arc;
997
998 use ahash::{AHashMap, AHashSet};
999 use indexmap::IndexMap;
1000 use nautilus_core::{DurationNanos, UUID4, approx_eq, datetime::NANOSECONDS_IN_DAY};
1001 use nautilus_model::{
1002 accounts::{AccountAny, CashAccount},
1003 enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
1004 events::{AccountState, OrderFilled, PortfolioSnapshot},
1005 fees::MakerTakerFeeRates,
1006 identifiers::{
1007 AccountId, ClientOrderId,
1008 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
1009 },
1010 instruments::InstrumentAny,
1011 stubs::TestDefault,
1012 types::{AccountBalance, Money, Price, Quantity},
1013 };
1014 use rstest::rstest;
1015
1016 use super::*;
1017 use crate::statistics::beta_ratio::BetaRatio;
1018
1019 #[derive(Debug)]
1021 struct MockStatistic {
1022 name: String,
1023 }
1024
1025 impl MockStatistic {
1026 fn new(name: &str) -> Self {
1027 Self {
1028 name: name.to_string(),
1029 }
1030 }
1031 }
1032
1033 impl PortfolioStatistic for MockStatistic {
1034 type Item = f64;
1035
1036 fn name(&self) -> String {
1037 self.name.clone()
1038 }
1039
1040 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
1041 Some(pnls.iter().sum())
1042 }
1043
1044 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
1045 Some(returns.values().sum())
1046 }
1047
1048 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
1049 Some(positions.len() as f64)
1050 }
1051 }
1052
1053 #[derive(Debug)]
1058 struct ConstantStatistic {
1059 name: String,
1060 value: f64,
1061 }
1062
1063 impl ConstantStatistic {
1064 fn new(name: &str, value: f64) -> Self {
1065 Self {
1066 name: name.to_string(),
1067 value,
1068 }
1069 }
1070 }
1071
1072 impl PortfolioStatistic for ConstantStatistic {
1073 type Item = f64;
1074
1075 fn name(&self) -> String {
1076 self.name.clone()
1077 }
1078
1079 fn calculate_from_realized_pnls(&self, _pnls: &[f64]) -> Option<f64> {
1080 Some(self.value)
1081 }
1082
1083 fn calculate_from_returns(&self, _returns: &Returns) -> Option<f64> {
1084 Some(self.value)
1085 }
1086
1087 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<f64> {
1088 Some(self.value)
1089 }
1090 }
1091
1092 fn create_mock_position(
1093 id: &str,
1094 realized_pnl: f64,
1095 realized_return: f64,
1096 currency: Currency,
1097 ) -> Position {
1098 Position {
1099 events: Vec::new(),
1100 adjustments: Vec::new(),
1101 replay_events: Vec::new(),
1102 fill_voids: Vec::new(),
1103 trader_id: trader_id(),
1104 strategy_id: strategy_id_ema_cross(),
1105 instrument_id: instrument_id_aud_usd_sim(),
1106 id: PositionId::new(id),
1107 account_id: AccountId::new("test-account"),
1108 opening_order_id: ClientOrderId::test_default(),
1109 closing_order_id: None,
1110 entry: OrderSide::Buy,
1111 side: PositionSide::Flat,
1112 signed_qty: 0.into(),
1113 quantity: Quantity::default(),
1114 peak_qty: Quantity::default(),
1115 price_precision: 2,
1116 size_precision: 2,
1117 multiplier: Quantity::default(),
1118 is_inverse: false,
1119 is_currency_pair: true,
1120 instrument_class: InstrumentClass::Spot,
1121 base_currency: None,
1122 quote_currency: Currency::USD(),
1123 settlement_currency: Currency::USD(),
1124 ts_init: UnixNanos::default(),
1125 ts_opened: UnixNanos::default(),
1126 ts_last: UnixNanos::default(),
1127 ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
1128 duration_ns: DurationNanos::new(2),
1129 avg_px_open: 0.into(),
1130 avg_px_close: None,
1131 realized_return,
1132 realized_pnl: Some(Money::new(realized_pnl, currency)),
1133 trade_ids: AHashSet::new(),
1134 buy_qty: Quantity::default(),
1135 sell_qty: Quantity::default(),
1136 commissions: IndexMap::new(),
1137 cost_open: 0.into(),
1138 cost_close: 0.into(),
1139 qty_closed: 0.into(),
1140 realized_pnl_remainder: 0.into(),
1141 }
1142 }
1143
1144 struct MockAccount {
1145 starting_balances: AHashMap<Currency, Money>,
1146 current_balances: AHashMap<Currency, Money>,
1147 events: Vec<AccountState>,
1148 }
1149
1150 impl Account for MockAccount {
1151 fn starting_balances(&self) -> IndexMap<Currency, Money> {
1152 self.starting_balances.clone().into_iter().collect()
1153 }
1154 fn balances_total(&self) -> IndexMap<Currency, Money> {
1155 self.current_balances.clone().into_iter().collect()
1156 }
1157 fn id(&self) -> AccountId {
1158 todo!()
1159 }
1160 fn account_type(&self) -> AccountType {
1161 todo!()
1162 }
1163 fn base_currency(&self) -> Option<Currency> {
1164 todo!()
1165 }
1166 fn is_cash_account(&self) -> bool {
1167 todo!()
1168 }
1169 fn is_margin_account(&self) -> bool {
1170 todo!()
1171 }
1172 fn calculated_account_state(&self) -> bool {
1173 todo!()
1174 }
1175 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
1176 todo!()
1177 }
1178 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
1179 todo!()
1180 }
1181 fn balances_free(&self) -> IndexMap<Currency, Money> {
1182 todo!()
1183 }
1184 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
1185 todo!()
1186 }
1187 fn balances_locked(&self) -> IndexMap<Currency, Money> {
1188 todo!()
1189 }
1190 fn last_event(&self) -> Option<AccountState> {
1191 self.events.last().cloned()
1192 }
1193 fn events(&self) -> Vec<AccountState> {
1194 self.events.clone()
1195 }
1196 fn event_count(&self) -> usize {
1197 self.events.len()
1198 }
1199 fn currencies(&self) -> Vec<Currency> {
1200 self.current_balances.keys().copied().collect()
1201 }
1202 fn balances(&self) -> IndexMap<Currency, AccountBalance> {
1203 todo!()
1204 }
1205 fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
1206 todo!()
1207 }
1208 fn calculate_balance_locked(
1209 &self,
1210 _: &InstrumentAny,
1211 _: OrderSide,
1212 _: Quantity,
1213 _: Price,
1214 _: Option<bool>,
1215 ) -> Result<Money, anyhow::Error> {
1216 todo!()
1217 }
1218 fn calculate_pnls(
1219 &self,
1220 _: &InstrumentAny,
1221 _: &OrderFilled,
1222 _: Option<Position>,
1223 ) -> Result<Vec<Money>, anyhow::Error> {
1224 todo!()
1225 }
1226 fn calculate_commission(
1227 &self,
1228 _: &InstrumentAny,
1229 _: Quantity,
1230 _: Price,
1231 _: LiquiditySide,
1232 _: MakerTakerFeeRates,
1233 _: Option<bool>,
1234 ) -> Result<Money, anyhow::Error> {
1235 todo!()
1236 }
1237
1238 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1239 todo!()
1240 }
1241
1242 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1243 }
1245 }
1246
1247 fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1248 AccountState::new(
1249 AccountId::new("test-account"),
1250 AccountType::Cash,
1251 vec![AccountBalance::new(
1252 Money::new(total, currency),
1253 Money::new(0.0, currency),
1254 Money::new(total, currency),
1255 )],
1256 vec![],
1257 true,
1258 UUID4::new(),
1259 UnixNanos::from(ts_event),
1260 UnixNanos::from(ts_event),
1261 Some(currency),
1262 )
1263 }
1264
1265 fn create_portfolio_snapshot(
1266 account_id: AccountId,
1267 equity: Decimal,
1268 currency: Currency,
1269 ts_event: u64,
1270 ) -> PortfolioSnapshot {
1271 let equity = Money::from_decimal(equity, currency).unwrap();
1272
1273 PortfolioSnapshot::new(
1274 account_id,
1275 AccountType::Cash,
1276 Some(currency),
1277 vec![],
1278 vec![],
1279 vec![],
1280 vec![],
1281 vec![equity],
1282 Some(equity),
1283 false,
1284 vec![],
1285 vec![],
1286 vec![],
1287 UUID4::new(),
1288 UnixNanos::from(ts_event),
1289 UnixNanos::from(ts_event),
1290 )
1291 }
1292
1293 #[rstest]
1294 fn test_calculate_snapshot_returns_tracks_daily_mark_to_market_equity() {
1295 let account_id = AccountId::new("SIM-001");
1296 let currency = Currency::USD();
1297 let snapshots = [
1298 create_portfolio_snapshot(
1299 account_id,
1300 Decimal::from(10_000),
1301 currency,
1302 NANOSECONDS_IN_DAY + NANOSECONDS_IN_DAY / 2,
1303 ),
1304 create_portfolio_snapshot(
1305 account_id,
1306 Decimal::from(10_500),
1307 currency,
1308 NANOSECONDS_IN_DAY + 3 * NANOSECONDS_IN_DAY / 4,
1309 ),
1310 create_portfolio_snapshot(
1311 account_id,
1312 Decimal::from(11_000),
1313 currency,
1314 2 * NANOSECONDS_IN_DAY,
1315 ),
1316 create_portfolio_snapshot(
1317 account_id,
1318 Decimal::from(12_100),
1319 currency,
1320 3 * NANOSECONDS_IN_DAY,
1321 ),
1322 ];
1323
1324 let returns =
1325 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1326 let values: Vec<f64> = returns.values().copied().collect();
1327 let dates: Vec<UnixNanos> = returns.keys().copied().collect();
1328
1329 assert_eq!(
1330 dates,
1331 vec![
1332 UnixNanos::from(NANOSECONDS_IN_DAY),
1333 UnixNanos::from(2 * NANOSECONDS_IN_DAY),
1334 ]
1335 );
1336 assert_eq!(values.len(), 2);
1337 assert!(approx_eq!(f64, values[0], 0.1, epsilon = 1e-12));
1338 assert!(approx_eq!(f64, values[1], 0.1, epsilon = 1e-12));
1339 }
1340
1341 #[rstest]
1342 fn test_calculate_snapshot_returns_aggregates_accounts_in_one_currency() {
1343 let account_a = AccountId::new("SIM-001");
1344 let account_b = AccountId::new("SIM-002");
1345 let currency = Currency::USD();
1346 let snapshots = [
1347 create_portfolio_snapshot(account_a, Decimal::from(100), currency, NANOSECONDS_IN_DAY),
1348 create_portfolio_snapshot(account_b, Decimal::from(50), currency, NANOSECONDS_IN_DAY),
1349 create_portfolio_snapshot(
1350 account_a,
1351 Decimal::from(110),
1352 currency,
1353 2 * NANOSECONDS_IN_DAY,
1354 ),
1355 ];
1356
1357 let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1358 &[account_a, account_b],
1359 snapshots.iter(),
1360 )
1361 .unwrap();
1362
1363 assert!(approx_eq!(
1364 f64,
1365 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1366 160.0 / 150.0 - 1.0,
1367 epsilon = 1e-12
1368 ));
1369 }
1370
1371 #[rstest]
1372 fn test_calculate_snapshot_returns_uses_single_total_without_base_currency() {
1373 let account_id = AccountId::new("SIM-001");
1374 let currency = Currency::USD();
1375 let mut first =
1376 create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1377 let mut second = create_portfolio_snapshot(
1378 account_id,
1379 Decimal::from(110),
1380 currency,
1381 2 * NANOSECONDS_IN_DAY,
1382 );
1383 first.base_currency_equity = None;
1384 second.base_currency_equity = None;
1385 let snapshots = [first, second];
1386
1387 let returns =
1388 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1389
1390 assert!(approx_eq!(
1391 f64,
1392 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1393 0.1,
1394 epsilon = 1e-12
1395 ));
1396 }
1397
1398 #[rstest]
1399 fn test_calculate_snapshot_returns_rejects_multi_currency_total_with_base_equity() {
1400 let account_id = AccountId::new("SIM-001");
1401 let mut first = create_portfolio_snapshot(
1402 account_id,
1403 Decimal::from(100),
1404 Currency::USD(),
1405 NANOSECONDS_IN_DAY,
1406 );
1407 let mut second = create_portfolio_snapshot(
1408 account_id,
1409 Decimal::from(110),
1410 Currency::USD(),
1411 2 * NANOSECONDS_IN_DAY,
1412 );
1413 first.total_equity.push(Money::new(50.0, Currency::AUD()));
1414 second.total_equity.push(Money::new(55.0, Currency::AUD()));
1415 let snapshots = [first, second];
1416
1417 let returns =
1418 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter());
1419
1420 assert!(returns.is_none());
1421 }
1422
1423 #[rstest]
1424 fn test_calculate_snapshot_returns_forward_fills_unpriced_dates() {
1425 let account_id = AccountId::new("SIM-001");
1426 let currency = Currency::USD();
1427 let first =
1428 create_portfolio_snapshot(account_id, Decimal::from(100), currency, NANOSECONDS_IN_DAY);
1429 let mut unpriced =
1430 create_portfolio_snapshot(account_id, Decimal::ZERO, currency, 2 * NANOSECONDS_IN_DAY);
1431 unpriced.unpriced_instruments = vec![instrument_id_aud_usd_sim()];
1432 let last = create_portfolio_snapshot(
1433 account_id,
1434 Decimal::from(110),
1435 currency,
1436 3 * NANOSECONDS_IN_DAY,
1437 );
1438 let snapshots = [first, unpriced, last];
1439
1440 let returns =
1441 PortfolioAnalyzer::calculate_snapshot_returns(&[account_id], snapshots.iter()).unwrap();
1442
1443 assert!(approx_eq!(
1444 f64,
1445 returns[&UnixNanos::from(NANOSECONDS_IN_DAY)],
1446 0.0,
1447 epsilon = 1e-12
1448 ));
1449 assert!(approx_eq!(
1450 f64,
1451 returns[&UnixNanos::from(2 * NANOSECONDS_IN_DAY)],
1452 0.1,
1453 epsilon = 1e-12
1454 ));
1455 }
1456
1457 #[rstest]
1458 fn test_calculate_snapshot_returns_rejects_mixed_account_currencies() {
1459 let account_a = AccountId::new("SIM-001");
1460 let account_b = AccountId::new("SIM-002");
1461 let snapshots = [
1462 create_portfolio_snapshot(
1463 account_a,
1464 Decimal::from(100),
1465 Currency::USD(),
1466 NANOSECONDS_IN_DAY,
1467 ),
1468 create_portfolio_snapshot(
1469 account_b,
1470 Decimal::from(100),
1471 Currency::AUD(),
1472 NANOSECONDS_IN_DAY,
1473 ),
1474 ];
1475
1476 let returns = PortfolioAnalyzer::calculate_snapshot_returns(
1477 &[account_a, account_b],
1478 snapshots.iter(),
1479 );
1480
1481 assert!(returns.is_none());
1482 }
1483
1484 #[rstest]
1485 fn test_register_and_deregister_statistics() {
1486 let mut analyzer = PortfolioAnalyzer::new();
1487 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1488 Arc::new(MockStatistic::new("test_stat"));
1489
1490 analyzer.register_statistic(Arc::clone(&stat));
1492 assert!(analyzer.statistic("test_stat").is_some());
1493
1494 analyzer.deregister_statistic(&stat);
1496 assert!(analyzer.statistic("test_stat").is_none());
1497
1498 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1500 Arc::new(MockStatistic::new("stat1"));
1501 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1502 Arc::new(MockStatistic::new("stat2"));
1503 analyzer.register_statistic(Arc::clone(&stat1));
1504 analyzer.register_statistic(Arc::clone(&stat2));
1505 analyzer.deregister_statistics();
1506 assert!(analyzer.statistics.is_empty());
1507 }
1508
1509 #[rstest]
1510 fn test_register_statistic_replaces_matching_name() {
1511 let mut analyzer = PortfolioAnalyzer::new();
1512 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1513 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 2.0)));
1514
1515 let stats = analyzer.get_performance_stats_general();
1516
1517 assert_eq!(analyzer.statistics.len(), 1);
1518 assert_eq!(stats["Custom"], 2.0);
1519 }
1520
1521 #[rstest]
1522 fn test_pnl_statistics_reject_unresolved_currency() {
1523 let mut analyzer = PortfolioAnalyzer::new();
1526 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 1.0)));
1527 analyzer.add_trade(
1528 &PositionId::new("P-USD"),
1529 UnixNanos::from(1),
1530 &Money::new(10.0, Currency::USD()),
1531 );
1532 analyzer.add_trade(
1533 &PositionId::new("P-EUR"),
1534 UnixNanos::from(2),
1535 &Money::new(5.0, Currency::EUR()),
1536 );
1537
1538 let result = analyzer.get_performance_stats_pnls(None, None);
1539
1540 assert_eq!(
1541 result,
1542 Err("Currency must be specified for multi-currency portfolio")
1543 );
1544 }
1545
1546 #[rstest]
1547 fn test_pnl_statistics_resolve_single_pnl_currency() {
1548 let mut analyzer = PortfolioAnalyzer::new();
1551 analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1552 let currency = Currency::USD();
1553 let position_id = PositionId::new("P-USD");
1554 analyzer.add_trade(
1555 &position_id,
1556 UnixNanos::from(1),
1557 &Money::new(10.0, currency),
1558 );
1559
1560 let records = analyzer.trade_pnl_records(None).unwrap();
1561 let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1562
1563 assert_eq!(records, vec![(position_id, UnixNanos::from(1), 10.0)]);
1564 assert_eq!(stats["test_stat"], 10.0);
1565 }
1566
1567 #[rstest]
1568 fn test_pnl_statistics_prefer_account_balance_currency() {
1569 let mut analyzer = PortfolioAnalyzer::new();
1573 analyzer.register_statistic(Arc::new(MockStatistic::new("test_stat")));
1574 let currency = Currency::USD();
1575 analyzer
1576 .account_balances
1577 .insert(currency, Money::new(1000.0, currency));
1578 analyzer.add_trade(
1579 &PositionId::new("P-EUR"),
1580 UnixNanos::from(1),
1581 &Money::new(5.0, Currency::EUR()),
1582 );
1583
1584 let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1585
1586 assert!(analyzer.trade_pnl_records(None).is_none());
1587 assert_eq!(stats["test_stat"], 0.0);
1588 }
1589
1590 #[rstest]
1591 fn test_trade_pnl_records_sorted_by_event_time() {
1592 let currency = Currency::USD();
1595 let mut analyzer = PortfolioAnalyzer::new();
1596 analyzer.add_trade(
1597 &PositionId::new("P-LATE"),
1598 UnixNanos::from(2),
1599 &Money::new(5.0, currency),
1600 );
1601 analyzer.record_trade(
1602 &PositionId::new("P-EARLY"),
1603 UnixNanos::from(1),
1604 &Money::new(7.0, currency),
1605 );
1606
1607 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1608
1609 assert_eq!(
1610 records,
1611 vec![
1612 (PositionId::new("P-EARLY"), UnixNanos::from(1), 7.0),
1613 (PositionId::new("P-LATE"), UnixNanos::from(2), 5.0),
1614 ]
1615 );
1616 }
1617
1618 #[rstest]
1619 fn test_pnl_statistics_run_without_any_trades() {
1620 let mut analyzer = PortfolioAnalyzer::new();
1623 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1624
1625 let stats = analyzer
1626 .get_performance_stats_pnls(Some(&Currency::USD()), None)
1627 .unwrap();
1628
1629 assert_eq!(stats["Custom"], 0.0);
1630 assert_eq!(stats["PnL (total)"], 0.0);
1631 }
1632
1633 #[rstest]
1634 fn test_pnl_statistics_run_without_any_trades_or_currency() {
1635 let mut analyzer = PortfolioAnalyzer::new();
1638 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 0.0)));
1639
1640 let stats = analyzer.get_performance_stats_pnls(None, None).unwrap();
1641
1642 assert_eq!(stats["Custom"], 0.0);
1643 assert_eq!(stats["PnL (total)"], 0.0);
1644 }
1645
1646 #[rstest]
1647 fn test_reset_retains_registered_statistics() {
1648 let currency = Currency::USD();
1649 let mut analyzer = PortfolioAnalyzer::new();
1650 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 7.5)));
1651 analyzer.add_positions(&[create_mock_position("AUD/USD", 100.0, 0.1, currency)]);
1652
1653 analyzer.reset();
1654
1655 assert!(analyzer.positions.is_empty());
1656 assert_eq!(analyzer.statistics.len(), 1);
1657 assert_eq!(analyzer.get_performance_stats_general()["Custom"], 7.5);
1658 }
1659
1660 #[rstest]
1661 fn test_formatted_general_stats_include_custom_statistic() {
1662 let mut analyzer = PortfolioAnalyzer::new();
1665 analyzer.register_statistic(Arc::new(ConstantStatistic::new("Custom", 12.5)));
1666
1667 let lines = analyzer.get_stats_general_formatted();
1668
1669 assert_eq!(lines, vec!["Custom: 12.5".to_string()]);
1670 }
1671
1672 #[rstest]
1673 fn test_replace_statistics_adopts_given_set() {
1674 let mut analyzer = PortfolioAnalyzer::default();
1675 let default_count = analyzer.statistics.len();
1676
1677 let mut replacement: AHashMap<String, Statistic> = AHashMap::new();
1678 replacement.insert(
1679 "Custom".to_string(),
1680 Arc::new(ConstantStatistic::new("Custom", 3.25)),
1681 );
1682 analyzer.replace_statistics(replacement);
1683
1684 let stats = analyzer.get_performance_stats_general();
1685
1686 assert!(default_count > 1);
1687 assert_eq!(analyzer.statistics.len(), 1);
1688 assert_eq!(stats.len(), 1);
1689 assert_eq!(stats["Custom"], 3.25);
1690 }
1691
1692 #[rstest]
1693 fn test_calculate_total_pnl() {
1694 let mut analyzer = PortfolioAnalyzer::new();
1695 let currency = Currency::USD();
1696
1697 let mut starting_balances = AHashMap::new();
1699 starting_balances.insert(currency, Money::new(1000.0, currency));
1700
1701 let mut current_balances = AHashMap::new();
1702 current_balances.insert(currency, Money::new(1500.0, currency));
1703
1704 let account = MockAccount {
1705 starting_balances,
1706 current_balances,
1707 events: vec![],
1708 };
1709
1710 analyzer.calculate_statistics(&account, &[]);
1711
1712 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
1714 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1715
1716 let unrealized_pnl = Money::new(100.0, currency);
1718 let result = analyzer
1719 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
1720 .unwrap();
1721 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1722 }
1723
1724 #[rstest]
1725 fn test_calculate_total_pnl_percentage() {
1726 let mut analyzer = PortfolioAnalyzer::new();
1727 let currency = Currency::USD();
1728
1729 let mut starting_balances = AHashMap::new();
1731 starting_balances.insert(currency, Money::new(1000.0, currency));
1732
1733 let mut current_balances = AHashMap::new();
1734 current_balances.insert(currency, Money::new(1500.0, currency));
1735
1736 let account = MockAccount {
1737 starting_balances,
1738 current_balances,
1739 events: vec![],
1740 };
1741
1742 analyzer.calculate_statistics(&account, &[]);
1743
1744 let result = analyzer
1746 .total_pnl_percentage(Some(¤cy), None)
1747 .unwrap();
1748 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
1752 let result = analyzer
1753 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
1754 .unwrap();
1755 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
1757
1758 #[rstest]
1759 fn test_add_positions_and_returns() {
1760 let mut analyzer = PortfolioAnalyzer::new();
1761 let currency = Currency::USD();
1762
1763 let positions = vec![
1764 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1765 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1766 ];
1767
1768 analyzer.add_positions(&positions);
1769
1770 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
1772 assert_eq!(pnls.len(), 2);
1773 assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1774 assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1775
1776 let returns = analyzer.returns();
1778 let position_returns = analyzer.position_returns();
1779 assert_eq!(returns.len(), 1);
1780 assert_eq!(position_returns.len(), 1);
1781 assert!(analyzer.portfolio_returns().is_empty());
1782 assert!(approx_eq!(
1783 f64,
1784 *returns.values().next().unwrap(),
1785 0.30000000000000004,
1786 epsilon = 1e-9
1787 ));
1788 assert!(approx_eq!(
1789 f64,
1790 *position_returns.values().next().unwrap(),
1791 0.30000000000000004,
1792 epsilon = 1e-9
1793 ));
1794 }
1795
1796 #[rstest]
1797 fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1798 let mut analyzer = PortfolioAnalyzer::new();
1799 let currency = Currency::USD();
1800 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1801 position.ts_closed = Some(UnixNanos::default());
1802
1803 analyzer.add_positions(&[position]);
1804
1805 assert!(analyzer.position_returns().is_empty());
1806 assert!(analyzer.returns().is_empty());
1807 }
1808
1809 #[rstest]
1810 fn test_add_positions_records_open_position_realized_pnl() {
1811 let mut analyzer = PortfolioAnalyzer::new();
1812 let currency = Currency::USD();
1813 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1814 position.ts_closed = None;
1815 position.ts_last = UnixNanos::from(7);
1817 let position_id = position.id;
1818
1819 analyzer.add_positions(&[position]);
1820
1821 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1822 assert_eq!(records.len(), 1);
1823 assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1824 assert!(analyzer.position_returns().is_empty());
1825 }
1826
1827 #[rstest]
1828 fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1829 let mut analyzer = PortfolioAnalyzer::new();
1832 let currency = Currency::USD();
1833 let position_id = PositionId::new("pos1");
1834
1835 analyzer.add_trade(
1836 &position_id,
1837 UnixNanos::from(1),
1838 &Money::new(10.0, currency),
1839 );
1840 analyzer.add_trade(
1841 &position_id,
1842 UnixNanos::from(2),
1843 &Money::new(20.0, currency),
1844 );
1845 analyzer.record_trade(
1846 &position_id,
1847 UnixNanos::from(2),
1848 &Money::new(25.0, currency),
1849 );
1850
1851 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1852
1853 assert_eq!(
1854 records,
1855 vec![
1856 (position_id, UnixNanos::from(1), 10.0),
1857 (position_id, UnixNanos::from(2), 25.0),
1858 ]
1859 );
1860 }
1861
1862 #[rstest]
1863 fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1864 let mut analyzer = PortfolioAnalyzer::new();
1865 let currency = Currency::USD();
1866 let position_id = PositionId::new("pos1");
1867 let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1868 let ts_event = UnixNanos::from(1);
1869
1870 analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1871 analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1872
1873 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1874
1875 assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1876 }
1877
1878 #[rstest]
1879 fn test_performance_stats_calculation() {
1880 let mut analyzer = PortfolioAnalyzer::new();
1881 let currency = Currency::USD();
1882 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1883 Arc::new(MockStatistic::new("test_stat"));
1884 analyzer.register_statistic(Arc::clone(&stat));
1885
1886 let positions = vec![
1888 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1889 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1890 ];
1891
1892 let mut starting_balances = AHashMap::new();
1893 starting_balances.insert(currency, Money::new(1000.0, currency));
1894
1895 let mut current_balances = AHashMap::new();
1896 current_balances.insert(currency, Money::new(1500.0, currency));
1897
1898 let account = MockAccount {
1899 starting_balances,
1900 current_balances,
1901 events: vec![],
1902 };
1903
1904 analyzer.calculate_statistics(&account, &positions);
1905
1906 let pnl_stats = analyzer
1908 .get_performance_stats_pnls(Some(¤cy), None)
1909 .unwrap();
1910 assert!(pnl_stats.contains_key("PnL (total)"));
1911 assert!(pnl_stats.contains_key("PnL% (total)"));
1912 assert!(pnl_stats.contains_key("test_stat"));
1913
1914 let return_stats = analyzer.get_performance_stats_returns();
1916 assert!(return_stats.contains_key("test_stat"));
1917
1918 let general_stats = analyzer.get_performance_stats_general();
1920 assert!(general_stats.contains_key("test_stat"));
1921 }
1922
1923 #[rstest]
1924 fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1925 let mut analyzer = PortfolioAnalyzer::new();
1926 let account_currency = Currency::EUR();
1927 let native_currency = Currency::USD();
1928 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1929 Arc::new(MockStatistic::new("test_stat"));
1930 analyzer.register_statistic(Arc::clone(&stat));
1931 analyzer.record_trade(
1932 &PositionId::new("pos1"),
1933 UnixNanos::from(1),
1934 &Money::new(90.0, account_currency),
1935 );
1936
1937 let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1938
1939 let mut starting_balances = AHashMap::new();
1940 starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1941
1942 let mut current_balances = AHashMap::new();
1943 current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1944
1945 let account = MockAccount {
1946 starting_balances,
1947 current_balances,
1948 events: vec![],
1949 };
1950
1951 analyzer.calculate_statistics(&account, &positions);
1952
1953 let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1954 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1955 let pnl_stats = analyzer
1956 .get_performance_stats_pnls(Some(&account_currency), None)
1957 .unwrap();
1958
1959 assert_eq!(native_pnls[0].2, 100.0);
1960 assert_eq!(recorded_pnls[0].2, 90.0);
1961 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1962 }
1963
1964 #[rstest]
1965 fn test_record_trade_preserves_duplicate_position_ids() {
1966 let mut analyzer = PortfolioAnalyzer::new();
1967 let account_currency = Currency::EUR();
1968 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1969 Arc::new(MockStatistic::new("test_stat"));
1970 let position_id = PositionId::new("pos1");
1971
1972 analyzer.register_statistic(Arc::clone(&stat));
1973 analyzer.record_trade(
1974 &position_id,
1975 UnixNanos::from(1),
1976 &Money::new(90.0, account_currency),
1977 );
1978 analyzer.record_trade(
1979 &position_id,
1980 UnixNanos::from(2),
1981 &Money::new(-45.0, account_currency),
1982 );
1983
1984 let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1985 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1986 let pnl_stats = analyzer
1987 .get_performance_stats_pnls(Some(&account_currency), None)
1988 .unwrap();
1989
1990 assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1991 assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1992 assert_eq!(
1993 recorded_pnls,
1994 vec![
1995 (position_id, UnixNanos::from(1), 90.0),
1996 (position_id, UnixNanos::from(2), -45.0),
1997 ]
1998 );
1999 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
2000 }
2001 #[rstest]
2002 fn test_formatted_output() {
2003 let mut analyzer = PortfolioAnalyzer::new();
2004 let currency = Currency::USD();
2005 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2006 Arc::new(MockStatistic::new("test_stat"));
2007 analyzer.register_statistic(Arc::clone(&stat));
2008
2009 let positions = vec![
2010 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2011 create_mock_position("AUD/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(1500.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 pnl_formatted = analyzer
2030 .get_stats_pnls_formatted(Some(¤cy), None)
2031 .unwrap();
2032 assert!(!pnl_formatted.is_empty());
2033 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
2034
2035 let returns_formatted = analyzer.get_stats_returns_formatted();
2036 assert!(!returns_formatted.is_empty());
2037 assert!(returns_formatted.iter().all(|s| s.contains(':')));
2038
2039 let general_formatted = analyzer.get_stats_general_formatted();
2040 assert!(!general_formatted.is_empty());
2041 assert!(general_formatted.iter().all(|s| s.contains(':')));
2042 }
2043
2044 #[rstest]
2045 fn test_reset() {
2046 let mut analyzer = PortfolioAnalyzer::new();
2047 let currency = Currency::USD();
2048
2049 let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
2050 let mut starting_balances = AHashMap::new();
2051 starting_balances.insert(currency, Money::new(1000.0, currency));
2052 let mut current_balances = AHashMap::new();
2053 current_balances.insert(currency, Money::new(1500.0, currency));
2054
2055 let account = MockAccount {
2056 starting_balances,
2057 current_balances,
2058 events: vec![],
2059 };
2060
2061 analyzer.calculate_statistics(&account, &positions);
2062
2063 analyzer.reset();
2064
2065 assert!(analyzer.account_balances_starting.is_empty());
2066 assert!(analyzer.account_balances.is_empty());
2067 assert!(analyzer.positions.is_empty());
2068 assert!(analyzer.realized_pnls.is_empty());
2069 assert!(analyzer.recorded_realized_pnls.is_empty());
2070 assert!(analyzer.position_returns.is_empty());
2071 assert!(analyzer.portfolio_returns.is_empty());
2072 assert!(analyzer.returns.is_empty());
2073 }
2074
2075 #[rstest]
2076 fn test_currencies_preserve_account_balance_order() {
2077 let mut analyzer = PortfolioAnalyzer::new();
2082 let inserts = [
2083 (Currency::BTC(), Money::new(1.0, Currency::BTC())),
2084 (Currency::USD(), Money::new(2.0, Currency::USD())),
2085 (Currency::ETH(), Money::new(3.0, Currency::ETH())),
2086 ];
2087
2088 for (currency, money) in inserts {
2089 analyzer.account_balances.insert(currency, money);
2090 }
2091
2092 let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
2093 assert_eq!(
2094 returned,
2095 vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
2096 );
2097 }
2098
2099 #[rstest]
2100 fn test_calculate_statistics_clears_previous_positions() {
2101 let mut analyzer = PortfolioAnalyzer::new();
2102 let currency = Currency::USD();
2103
2104 let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
2105 let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
2106
2107 let mut starting_balances = AHashMap::new();
2108 starting_balances.insert(currency, Money::new(1000.0, currency));
2109 let mut current_balances = AHashMap::new();
2110 current_balances.insert(currency, Money::new(1500.0, currency));
2111
2112 let account = MockAccount {
2113 starting_balances,
2114 current_balances,
2115 events: vec![],
2116 };
2117
2118 analyzer.calculate_statistics(&account, &positions1);
2120 assert_eq!(analyzer.positions.len(), 1);
2121
2122 analyzer.calculate_statistics(&account, &positions2);
2124 assert_eq!(analyzer.positions.len(), 1);
2125 }
2126
2127 #[rstest]
2128 fn test_calculate_statistics_uses_account_state_returns_when_available() {
2129 let mut analyzer = PortfolioAnalyzer::new();
2130 let currency = Currency::USD();
2131 let positions = vec![
2132 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2133 create_mock_position("EUR/USD", 200.0, 0.2, currency),
2134 ];
2135
2136 let mut starting_balances = AHashMap::new();
2137 starting_balances.insert(currency, Money::new(1000.0, currency));
2138
2139 let mut current_balances = AHashMap::new();
2140 current_balances.insert(currency, Money::new(1100.0, currency));
2141
2142 let account = MockAccount {
2143 starting_balances,
2144 current_balances,
2145 events: vec![
2146 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2147 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2148 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2149 ],
2150 };
2151
2152 analyzer.calculate_statistics(&account, &positions);
2153
2154 let position_returns = analyzer.position_returns();
2155 let portfolio_returns = analyzer.portfolio_returns();
2156 let returns = analyzer.returns();
2157 assert_eq!(position_returns.len(), 1);
2158 assert_eq!(portfolio_returns.len(), 30);
2159 assert_eq!(returns, portfolio_returns);
2160 assert!(approx_eq!(
2161 f64,
2162 *portfolio_returns
2163 .get(&UnixNanos::from(1_704_153_600_000_000_000))
2164 .unwrap(),
2165 0.0,
2166 epsilon = 1e-9
2167 ));
2168 assert!(approx_eq!(
2169 f64,
2170 *portfolio_returns
2171 .get(&UnixNanos::from(1_704_844_800_000_000_000))
2172 .unwrap(),
2173 0.05,
2174 epsilon = 1e-9
2175 ));
2176 assert!(approx_eq!(
2177 f64,
2178 *portfolio_returns
2179 .get(&UnixNanos::from(1_706_659_200_000_000_000))
2180 .unwrap(),
2181 (1100.0 / 1050.0) - 1.0,
2182 epsilon = 1e-9
2183 ));
2184 assert!(approx_eq!(
2185 f64,
2186 *position_returns.values().next().unwrap(),
2187 0.30000000000000004,
2188 epsilon = 1e-9
2189 ));
2190 }
2191
2192 #[rstest]
2193 fn test_calculate_statistics_skips_empty_balance_events() {
2194 let mut analyzer = PortfolioAnalyzer::new();
2195 let currency = Currency::USD();
2196 let mut starting_balances = AHashMap::new();
2197 starting_balances.insert(currency, Money::new(1000.0, currency));
2198 let mut current_balances = AHashMap::new();
2199 current_balances.insert(currency, Money::new(1050.0, currency));
2200 let empty_event = AccountState::new(
2201 AccountId::new("test-account"),
2202 AccountType::Cash,
2203 vec![],
2204 vec![],
2205 true,
2206 UUID4::new(),
2207 UnixNanos::from(1_705_276_800_000_000_000),
2208 UnixNanos::from(1_705_276_800_000_000_000),
2209 Some(currency),
2210 );
2211 let account = MockAccount {
2212 starting_balances,
2213 current_balances,
2214 events: vec![
2215 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2216 empty_event,
2217 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2218 ],
2219 };
2220
2221 analyzer.calculate_statistics(&account, &[]);
2222
2223 let portfolio_returns = analyzer.portfolio_returns();
2224 assert_eq!(portfolio_returns.len(), 30);
2225 assert_eq!(analyzer.returns(), portfolio_returns);
2226 assert!(approx_eq!(
2227 f64,
2228 *portfolio_returns
2229 .get(&UnixNanos::from(1_706_659_200_000_000_000))
2230 .unwrap(),
2231 0.05,
2232 epsilon = 1e-9
2233 ));
2234 }
2235
2236 #[rstest]
2237 fn test_calculate_statistics_skips_non_finite_account_returns() {
2238 let mut analyzer = PortfolioAnalyzer::new();
2239 let currency = Currency::USD();
2240
2241 let mut starting_balances = AHashMap::new();
2242 starting_balances.insert(currency, Money::new(0.0, currency));
2243
2244 let mut current_balances = AHashMap::new();
2245 current_balances.insert(currency, Money::new(1050.0, currency));
2246
2247 let account = MockAccount {
2248 starting_balances,
2249 current_balances,
2250 events: vec![
2251 create_account_state(0.0, currency, 1_704_067_200_000_000_000),
2252 create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
2253 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
2254 ],
2255 };
2256
2257 analyzer.calculate_statistics(&account, &[]);
2258
2259 let returns = analyzer.returns();
2260 assert!(returns.values().all(|value| value.is_finite()));
2261 assert!(approx_eq!(
2262 f64,
2263 *returns
2264 .get(&UnixNanos::from(1_706_659_200_000_000_000))
2265 .unwrap(),
2266 0.05,
2267 epsilon = 1e-9
2268 ));
2269 }
2270
2271 #[rstest]
2272 fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
2273 let mut analyzer = PortfolioAnalyzer::new();
2274 let currency = Currency::USD();
2275 let positions = vec![
2276 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2277 create_mock_position("EUR/USD", 200.0, 0.2, currency),
2278 ];
2279
2280 let mut starting_balances = AHashMap::new();
2281 starting_balances.insert(currency, Money::new(1000.0, currency));
2282
2283 let mut current_balances = AHashMap::new();
2284 current_balances.insert(currency, Money::new(1100.0, currency));
2285
2286 let account = MockAccount {
2287 starting_balances,
2288 current_balances,
2289 events: vec![],
2290 };
2291
2292 analyzer.calculate_statistics(&account, &positions);
2293
2294 let returns = analyzer.returns();
2295 assert!(analyzer.portfolio_returns().is_empty());
2296 assert_eq!(returns, analyzer.position_returns());
2297 assert_eq!(returns.len(), 1);
2298 assert!(approx_eq!(
2299 f64,
2300 *returns.values().next().unwrap(),
2301 0.30000000000000004,
2302 epsilon = 1e-9
2303 ));
2304 }
2305
2306 #[rstest]
2307 fn test_get_performance_stats_returns_prefers_portfolio_returns() {
2308 let mut analyzer = PortfolioAnalyzer::new();
2309 let currency = Currency::USD();
2310 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
2311 Arc::new(MockStatistic::new("test_stat"));
2312 analyzer.register_statistic(Arc::clone(&stat));
2313
2314 let positions = vec![
2315 create_mock_position("AUD/USD", 100.0, 0.1, currency),
2316 create_mock_position("EUR/USD", 200.0, 0.2, currency),
2317 ];
2318
2319 let mut starting_balances = AHashMap::new();
2320 starting_balances.insert(currency, Money::new(1000.0, currency));
2321
2322 let mut current_balances = AHashMap::new();
2323 current_balances.insert(currency, Money::new(1100.0, currency));
2324
2325 let account = MockAccount {
2326 starting_balances,
2327 current_balances,
2328 events: vec![
2329 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
2330 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
2331 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
2332 ],
2333 };
2334
2335 analyzer.calculate_statistics(&account, &positions);
2336
2337 let position_stats = analyzer.get_performance_stats_position_returns();
2338 let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
2339 let returns_stats = analyzer.get_performance_stats_returns();
2340
2341 assert!(approx_eq!(
2342 f64,
2343 *position_stats.get("test_stat").unwrap(),
2344 0.30000000000000004,
2345 epsilon = 1e-9
2346 ));
2347 assert_eq!(returns_stats, portfolio_stats);
2348 }
2349
2350 #[rstest]
2351 fn test_from_accounts_aggregates_balances_and_positions() {
2352 let currency = Currency::USD();
2353 let positions = vec![
2354 create_mock_position("pos1", 100.0, 0.1, currency),
2355 create_mock_position("pos2", 200.0, 0.2, currency),
2356 ];
2357
2358 let analyzer = PortfolioAnalyzer::from_accounts(
2359 &[AccountAny::Cash(CashAccount::default())],
2360 &positions,
2361 &[],
2362 AHashMap::new(),
2363 );
2364
2365 assert_eq!(analyzer.positions.len(), positions.len());
2366 assert!(!analyzer.account_balances.is_empty());
2367 }
2368
2369 #[rstest]
2370 fn test_from_accounts_sums_balances_across_accounts() {
2371 let usd = Currency::USD();
2372 let one = PortfolioAnalyzer::from_accounts(
2373 &[AccountAny::Cash(CashAccount::default())],
2374 &[],
2375 &[],
2376 AHashMap::new(),
2377 );
2378 let two = PortfolioAnalyzer::from_accounts(
2379 &[
2380 AccountAny::Cash(CashAccount::default()),
2381 AccountAny::Cash(CashAccount::default()),
2382 ],
2383 &[],
2384 &[],
2385 AHashMap::new(),
2386 );
2387
2388 let single = one.account_balances.get(&usd).unwrap().as_decimal();
2389 let summed = two.account_balances.get(&usd).unwrap().as_decimal();
2390 let single_start = one
2391 .account_balances_starting
2392 .get(&usd)
2393 .unwrap()
2394 .as_decimal();
2395 let summed_start = two
2396 .account_balances_starting
2397 .get(&usd)
2398 .unwrap()
2399 .as_decimal();
2400
2401 assert_eq!(summed, single + single);
2402 assert_eq!(summed_start, single_start + single_start);
2403 assert_ne!(summed, single);
2404 }
2405
2406 #[rstest]
2407 fn test_statistics_snapshot_matches_getters() {
2408 let currency = Currency::USD();
2409 let positions = vec![
2410 create_mock_position("pos1", 100.0, 0.1, currency),
2411 create_mock_position("pos2", 200.0, 0.2, currency),
2412 ];
2413
2414 let analyzer = PortfolioAnalyzer::from_accounts(
2415 &[AccountAny::Cash(CashAccount::default())],
2416 &positions,
2417 &[],
2418 AHashMap::new(),
2419 );
2420
2421 let snapshot = analyzer.statistics();
2422 assert!(maps_equal_nan_aware(
2423 &snapshot.returns,
2424 &analyzer.get_performance_stats_returns()
2425 ));
2426 assert!(maps_equal_nan_aware(
2427 &snapshot.general,
2428 &analyzer.get_performance_stats_general()
2429 ));
2430 assert_eq!(&snapshot.returns_series, analyzer.returns());
2431
2432 for currency in analyzer.currencies() {
2433 let expected = analyzer
2434 .get_performance_stats_pnls(Some(currency), None)
2435 .unwrap();
2436 let actual = snapshot.pnls.get(¤cy.code.to_string()).unwrap();
2437 assert!(maps_equal_nan_aware(actual, &expected));
2438 }
2439 }
2440
2441 fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
2442 if a.len() != b.len() {
2443 return false;
2444 }
2445 a.iter().all(|(k, v)| {
2446 b.get(k)
2447 .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
2448 })
2449 }
2450
2451 #[rstest]
2452 fn test_get_performance_stats_returns_vs_benchmark() {
2453 let mut analyzer = PortfolioAnalyzer::new();
2454 analyzer.register_statistic(Arc::new(BetaRatio::new()));
2455 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
2456
2457 let one_day = 86_400_000_000_000_u64;
2458 let start = 1_600_000_000_000_000_000_u64;
2459 for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
2460 analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
2461 }
2462
2463 let mut benchmark: Returns = BTreeMap::new();
2464 for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
2465 benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
2466 }
2467
2468 let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
2469
2470 assert_eq!(stats.len(), 1);
2476 assert!(approx_eq!(
2477 f64,
2478 *stats.get("Beta").unwrap(),
2479 6.0,
2480 epsilon = 1e-9
2481 ));
2482 assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
2483 }
2484}