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 identifiers::PositionId,
24 position::Position,
25 types::{Currency, Money},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30 Returns,
31 snapshot::PortfolioStatistics,
32 statistic::PortfolioStatistic,
33 statistics::{
34 expectancy::Expectancy, long_ratio::LongRatio, loser_avg::AvgLoser, loser_max::MaxLoser,
35 loser_min::MinLoser, profit_factor::ProfitFactor, returns_avg::ReturnsAverage,
36 returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin,
37 returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio,
38 sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate,
39 winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner,
40 },
41};
42
43pub type Statistic = Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync>;
44
45#[repr(C)]
51#[derive(Debug)]
52#[cfg_attr(
53 feature = "python",
54 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
55)]
56#[cfg_attr(
57 feature = "python",
58 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
59)]
60pub struct PortfolioAnalyzer {
61 pub statistics: AHashMap<String, Statistic>,
62 pub account_balances_starting: IndexMap<Currency, Money>,
63 pub account_balances: IndexMap<Currency, Money>,
64 pub positions: Vec<Position>,
65 pub realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
66 pub recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
67 pub position_returns: Returns,
68 pub portfolio_returns: Returns,
69 pub returns: Returns,
74}
75
76impl Default for PortfolioAnalyzer {
77 fn default() -> Self {
79 let mut analyzer = Self::new();
80 analyzer.register_statistic(Arc::new(MaxWinner {}));
81 analyzer.register_statistic(Arc::new(AvgWinner {}));
82 analyzer.register_statistic(Arc::new(MinWinner {}));
83 analyzer.register_statistic(Arc::new(MinLoser {}));
84 analyzer.register_statistic(Arc::new(AvgLoser {}));
85 analyzer.register_statistic(Arc::new(MaxLoser {}));
86 analyzer.register_statistic(Arc::new(Expectancy {}));
87 analyzer.register_statistic(Arc::new(WinRate {}));
88 analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None)));
89 analyzer.register_statistic(Arc::new(ReturnsAverage {}));
90 analyzer.register_statistic(Arc::new(ReturnsAverageLoss {}));
91 analyzer.register_statistic(Arc::new(ReturnsAverageWin {}));
92 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
93 analyzer.register_statistic(Arc::new(SortinoRatio::new(None)));
94 analyzer.register_statistic(Arc::new(ProfitFactor {}));
95 analyzer.register_statistic(Arc::new(RiskReturnRatio {}));
96 analyzer.register_statistic(Arc::new(LongRatio::new(None)));
97 analyzer
98 }
99}
100
101impl PortfolioAnalyzer {
102 #[must_use]
106 pub fn new() -> Self {
107 Self {
108 statistics: AHashMap::new(),
109 account_balances_starting: IndexMap::new(),
110 account_balances: IndexMap::new(),
111 positions: Vec::new(),
112 realized_pnls: AHashMap::new(),
113 recorded_realized_pnls: AHashMap::new(),
114 position_returns: BTreeMap::new(),
115 portfolio_returns: BTreeMap::new(),
116 returns: BTreeMap::new(),
117 }
118 }
119
120 pub fn register_statistic(&mut self, statistic: Statistic) {
122 self.statistics.insert(statistic.name(), statistic);
123 }
124
125 pub fn deregister_statistic(&mut self, statistic: &Statistic) {
127 self.statistics.remove(&statistic.name());
128 }
129
130 pub fn deregister_statistics(&mut self) {
132 self.statistics.clear();
133 }
134
135 pub fn reset(&mut self) {
137 self.account_balances_starting.clear();
138 self.account_balances.clear();
139 self.positions.clear();
140 self.realized_pnls.clear();
141 self.recorded_realized_pnls.clear();
142 self.position_returns.clear();
143 self.portfolio_returns.clear();
144 self.returns.clear();
145 }
146
147 #[must_use]
149 pub fn currencies(&self) -> Vec<&Currency> {
150 self.account_balances.keys().collect()
151 }
152
153 #[must_use]
155 pub fn statistic(&self, name: &str) -> Option<&Statistic> {
156 self.statistics.get(name)
157 }
158
159 #[must_use]
164 pub const fn returns(&self) -> &Returns {
165 &self.returns
166 }
167
168 #[must_use]
170 pub const fn position_returns(&self) -> &Returns {
171 &self.position_returns
172 }
173
174 #[must_use]
176 pub const fn portfolio_returns(&self) -> &Returns {
177 &self.portfolio_returns
178 }
179
180 pub fn calculate_statistics(&mut self, account: &dyn Account, positions: &[Position]) {
185 self.account_balances_starting = account.starting_balances().into_iter().collect();
186 self.account_balances = account.balances_total().into_iter().collect();
187 self.positions.clear();
188 self.realized_pnls.clear();
189 self.position_returns.clear();
190 self.portfolio_returns.clear();
191 self.returns.clear();
192
193 self.add_positions(positions);
194
195 if let Some(account_returns) = Self::calculate_account_returns(account) {
196 self.portfolio_returns = account_returns;
197 self.sync_returns_alias();
198 }
199 }
200
201 #[must_use]
206 pub fn from_accounts(
207 accounts: &[AccountAny],
208 positions: &[Position],
209 snapshots: &[Position],
210 recorded_realized_pnls: AHashMap<Currency, Vec<(PositionId, UnixNanos, f64)>>,
211 ) -> Self {
212 let mut analyzer = Self::default();
213
214 for account in accounts {
215 let account_ref: &dyn Account = match account {
216 AccountAny::Margin(margin) => margin,
217 AccountAny::Cash(cash) => cash,
218 AccountAny::Betting(betting) => betting,
219 };
220
221 for (currency, money) in account_ref.starting_balances() {
222 analyzer
223 .account_balances_starting
224 .entry(currency)
225 .and_modify(|existing| *existing = *existing + money)
226 .or_insert(money);
227 }
228
229 for (currency, money) in account_ref.balances_total() {
230 analyzer
231 .account_balances
232 .entry(currency)
233 .and_modify(|existing| *existing = *existing + money)
234 .or_insert(money);
235 }
236 }
237
238 analyzer.add_positions(positions);
239 analyzer.add_positions(snapshots);
240 analyzer.recorded_realized_pnls = recorded_realized_pnls;
241 analyzer
242 }
243
244 #[must_use]
246 pub fn statistics(&self) -> PortfolioStatistics {
247 let mut pnls = AHashMap::new();
248
249 for currency in self.currencies() {
250 if let Ok(stats) = self.get_performance_stats_pnls(Some(currency), None) {
251 pnls.insert(currency.code.to_string(), stats);
252 }
253 }
254 PortfolioStatistics {
255 pnls,
256 returns: self.get_performance_stats_returns(),
257 general: self.get_performance_stats_general(),
258 }
259 }
260
261 pub fn add_positions(&mut self, positions: &[Position]) {
263 self.positions.extend_from_slice(positions);
264 for position in positions {
265 if let Some(ref pnl) = position.realized_pnl {
266 self.add_trade(&position.id, position.ts_last, pnl);
267 }
268
269 if let Some(ts_closed) = position.ts_closed
270 && ts_closed.as_u64() > 0
271 && position.realized_pnl.is_some()
272 {
273 self.add_position_return(ts_closed, position.realized_return);
274 }
275 }
276 }
277
278 pub fn add_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
280 let currency = pnl.currency;
281 let entry = self.realized_pnls.entry(currency).or_default();
282 entry.push((*position_id, ts_event, pnl.as_f64()));
283 }
284
285 pub fn record_trade(&mut self, position_id: &PositionId, ts_event: UnixNanos, pnl: &Money) {
287 let currency = pnl.currency;
288 let entry = self.recorded_realized_pnls.entry(currency).or_default();
289 entry.push((*position_id, ts_event, pnl.as_f64()));
290 }
291
292 pub fn add_position_return(&mut self, timestamp: UnixNanos, value: f64) {
294 self.position_returns
295 .entry(timestamp)
296 .and_modify(|existing_value| *existing_value += value)
297 .or_insert(value);
298
299 if self.portfolio_returns.is_empty() {
303 self.returns
304 .entry(timestamp)
305 .and_modify(|existing_value| *existing_value += value)
306 .or_insert(value);
307 }
308 }
309
310 pub fn add_return(&mut self, timestamp: UnixNanos, value: f64) {
314 self.add_position_return(timestamp, value);
315 }
316
317 fn calculate_account_returns(account: &dyn Account) -> Option<Returns> {
328 let mut events = account.events();
329 if events.len() < 2 {
330 return None;
331 }
332
333 events.sort_by_key(|event| event.ts_event);
334
335 let mut currency = None;
336 let mut daily_balances = BTreeMap::new();
337
338 for event in events {
339 if event.balances.len() != 1 {
340 return None;
341 }
342
343 let balance = event.balances[0];
344
345 if let Some(existing_currency) = currency {
346 if existing_currency != balance.currency {
347 return None;
348 }
349 } else {
350 currency = Some(balance.currency);
351 }
352
353 let day_start = UnixNanos::from(
354 event.ts_event.as_u64() - (event.ts_event.as_u64() % NANOSECONDS_IN_DAY),
355 );
356 daily_balances.insert(day_start, balance.total.as_f64());
357 }
358
359 if daily_balances.len() < 2 {
360 return None;
361 }
362
363 let mut returns = Returns::new();
364 let mut current_day = *daily_balances.keys().next()?;
365 let last_day = *daily_balances.keys().next_back()?;
366 let mut current_balance: Option<f64> = None;
367 let mut previous_balance: Option<f64> = None;
368
369 loop {
370 if let Some(balance) = daily_balances.get(¤t_day) {
371 current_balance = Some(*balance);
372 }
373
374 let balance = current_balance?;
375
376 if let Some(previous) = previous_balance
377 && previous != 0.0
378 {
379 let value: f64 = (balance / previous) - 1.0;
380 if value.is_finite() {
381 returns.insert(current_day, value);
382 }
383 }
384
385 previous_balance = Some(balance);
386
387 if current_day >= last_day {
388 break;
389 }
390
391 current_day += UnixNanos::from(NANOSECONDS_IN_DAY);
392 }
393
394 (!returns.is_empty()).then_some(returns)
395 }
396
397 #[must_use]
412 pub fn trade_pnl_records(
413 &self,
414 currency: Option<&Currency>,
415 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
416 if self.realized_pnls.is_empty() && self.recorded_realized_pnls.is_empty() {
417 return None;
418 }
419
420 let currency = match currency {
422 Some(c) => *c,
423 None if self.account_balances.len() == 1 => *self.account_balances.keys().next()?,
424 None => {
425 let mut currencies: IndexSet<Currency> =
426 self.realized_pnls.keys().copied().collect();
427 currencies.extend(self.recorded_realized_pnls.keys().copied());
428 if currencies.len() != 1 {
429 return None;
430 }
431
432 *currencies.first()?
433 }
434 };
435
436 let realized_pnls = self.realized_pnls.get(¤cy);
437 let recorded_realized_pnls = self.recorded_realized_pnls.get(¤cy);
438
439 match (realized_pnls, recorded_realized_pnls) {
440 (None, None) => None,
441 (Some(realized_pnls), None) => Some(realized_pnls.clone()),
442 (None, Some(recorded_realized_pnls)) => Some(recorded_realized_pnls.clone()),
443 (Some(realized_pnls), Some(recorded_realized_pnls)) => {
444 let recorded_keys: IndexSet<(PositionId, UnixNanos)> = recorded_realized_pnls
445 .iter()
446 .map(|(position_id, ts_event, _)| {
447 (canonical_position_id(*position_id), *ts_event)
448 })
449 .collect();
450 let mut output: Vec<(PositionId, UnixNanos, f64)> = realized_pnls
451 .iter()
452 .copied()
453 .filter(|(position_id, ts_event, _)| {
454 let key = (canonical_position_id(*position_id), *ts_event);
455 !recorded_keys.contains(&key)
456 })
457 .collect();
458 output.extend(recorded_realized_pnls.iter().copied());
459
460 Some(output)
461 }
462 }
463 }
464
465 #[must_use]
470 pub fn realized_pnls(
471 &self,
472 currency: Option<&Currency>,
473 ) -> Option<Vec<(PositionId, UnixNanos, f64)>> {
474 self.trade_pnl_records(currency)
475 }
476
477 #[expect(clippy::missing_panics_doc)] pub fn total_pnl(
487 &self,
488 currency: Option<&Currency>,
489 unrealized_pnl: Option<&Money>,
490 ) -> Result<f64, &'static str> {
491 if self.account_balances.is_empty() {
492 return Ok(0.0);
493 }
494
495 let currency = match currency {
497 Some(c) => c,
498 None if self.account_balances.len() == 1 => {
499 self.account_balances.keys().next().expect("len is 1")
500 }
501 None => return Err("Currency must be specified for multi-currency portfolio"),
502 };
503
504 if let Some(unrealized_pnl) = unrealized_pnl
505 && unrealized_pnl.currency != *currency
506 {
507 return Err("Unrealized PnL currency does not match specified currency");
508 }
509
510 let account_balance = self
511 .account_balances
512 .get(currency)
513 .ok_or("Specified currency not found in account balances")?;
514
515 let default_money = &Money::zero(*currency);
516 let account_balance_starting = self
517 .account_balances_starting
518 .get(currency)
519 .unwrap_or(default_money);
520
521 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
522 Ok((account_balance.as_f64() - account_balance_starting.as_f64()) + unrealized_pnl_f64)
523 }
524
525 #[expect(clippy::missing_panics_doc)] pub fn total_pnl_percentage(
535 &self,
536 currency: Option<&Currency>,
537 unrealized_pnl: Option<&Money>,
538 ) -> Result<f64, &'static str> {
539 if self.account_balances.is_empty() {
540 return Ok(0.0);
541 }
542
543 let currency = match currency {
545 Some(c) => c,
546 None if self.account_balances.len() == 1 => {
547 self.account_balances.keys().next().expect("len is 1")
548 }
549 None => return Err("Currency must be specified for multi-currency portfolio"),
550 };
551
552 if let Some(unrealized_pnl) = unrealized_pnl
553 && unrealized_pnl.currency != *currency
554 {
555 return Err("Unrealized PnL currency does not match specified currency");
556 }
557
558 let account_balance = self
559 .account_balances
560 .get(currency)
561 .ok_or("Specified currency not found in account balances")?;
562
563 let default_money = &Money::zero(*currency);
564 let account_balance_starting = self
565 .account_balances_starting
566 .get(currency)
567 .unwrap_or(default_money);
568
569 if account_balance_starting.as_decimal() == Decimal::ZERO {
570 return Ok(0.0);
571 }
572
573 let unrealized_pnl_f64 = unrealized_pnl.map_or(0.0, Money::as_f64);
574 let current = account_balance.as_f64() + unrealized_pnl_f64;
575 let starting = account_balance_starting.as_f64();
576 let difference = current - starting;
577
578 Ok((difference / starting) * 100.0)
579 }
580
581 pub fn get_performance_stats_pnls(
591 &self,
592 currency: Option<&Currency>,
593 unrealized_pnl: Option<&Money>,
594 ) -> Result<AHashMap<String, f64>, &'static str> {
595 let mut output = AHashMap::new();
596
597 output.insert(
598 "PnL (total)".to_string(),
599 self.total_pnl(currency, unrealized_pnl)?,
600 );
601 output.insert(
602 "PnL% (total)".to_string(),
603 self.total_pnl_percentage(currency, unrealized_pnl)?,
604 );
605
606 if let Some(trade_pnl_records) = self.trade_pnl_records(currency) {
607 for (name, stat) in &self.statistics {
608 if let Some(value) = stat.calculate_from_realized_pnls(
609 &trade_pnl_records
610 .iter()
611 .map(|(_, _, pnl)| *pnl)
612 .collect::<Vec<f64>>(),
613 ) {
614 output.insert(name.clone(), value);
615 }
616 }
617 }
618
619 Ok(output)
620 }
621
622 #[must_use]
624 pub fn get_performance_stats_returns(&self) -> AHashMap<String, f64> {
625 self.calculate_returns_stats(self.returns())
626 }
627
628 #[must_use]
630 pub fn get_performance_stats_position_returns(&self) -> AHashMap<String, f64> {
631 self.calculate_returns_stats(self.position_returns())
632 }
633
634 #[must_use]
636 pub fn get_performance_stats_portfolio_returns(&self) -> AHashMap<String, f64> {
637 self.calculate_returns_stats(self.portfolio_returns())
638 }
639
640 #[must_use]
647 pub fn get_performance_stats_returns_vs_benchmark(
648 &self,
649 benchmark: &Returns,
650 ) -> AHashMap<String, f64> {
651 let mut output = AHashMap::new();
652
653 for (name, stat) in &self.statistics {
654 if let Some(value) =
655 stat.calculate_from_returns_with_benchmark(self.returns(), benchmark)
656 {
657 output.insert(name.clone(), value);
658 }
659 }
660
661 output
662 }
663
664 #[must_use]
666 pub fn get_performance_stats_general(&self) -> AHashMap<String, f64> {
667 let mut output = AHashMap::new();
668
669 for (name, stat) in &self.statistics {
670 if let Some(value) = stat.calculate_from_positions(&self.positions) {
671 output.insert(name.clone(), value);
672 }
673 }
674
675 output
676 }
677
678 fn get_max_length_name(&self) -> usize {
680 self.statistics.keys().map(String::len).max().unwrap_or(0)
681 }
682
683 fn calculate_returns_stats(&self, returns: &Returns) -> AHashMap<String, f64> {
684 let mut output = AHashMap::new();
685
686 for (name, stat) in &self.statistics {
687 if let Some(value) = stat.calculate_from_returns(returns) {
688 output.insert(name.clone(), value);
689 }
690 }
691
692 output
693 }
694
695 fn format_returns_stats(&self, stats: AHashMap<String, f64>) -> Vec<String> {
696 let max_length = self.get_max_length_name();
697 let mut entries: Vec<_> = stats.into_iter().collect();
698 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
699
700 let mut output = Vec::new();
701
702 for (k, v) in entries {
703 let padding = max_length.saturating_sub(k.len()) + 1;
704 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
705 }
706
707 output
708 }
709
710 fn sync_returns_alias(&mut self) {
711 if self.portfolio_returns.is_empty() {
712 self.returns = self.position_returns.clone();
713 return;
714 }
715
716 self.returns = self.portfolio_returns.clone();
717 }
718
719 pub fn get_stats_pnls_formatted(
725 &self,
726 currency: Option<&Currency>,
727 unrealized_pnl: Option<&Money>,
728 ) -> Result<Vec<String>, String> {
729 let max_length = self.get_max_length_name();
730 let stats = self.get_performance_stats_pnls(currency, unrealized_pnl)?;
731
732 let mut entries: Vec<_> = stats.into_iter().collect();
733 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
734
735 let mut output = Vec::new();
736
737 for (k, v) in entries {
738 let padding = if max_length > k.len() {
739 max_length - k.len() + 1
740 } else {
741 1
742 };
743 output.push(format!("{}: {}{:.2}", k, " ".repeat(padding), v));
744 }
745
746 Ok(output)
747 }
748
749 #[must_use]
751 pub fn get_stats_returns_formatted(&self) -> Vec<String> {
752 self.format_returns_stats(self.get_performance_stats_returns())
753 }
754
755 #[must_use]
757 pub fn get_stats_position_returns_formatted(&self) -> Vec<String> {
758 self.format_returns_stats(self.get_performance_stats_position_returns())
759 }
760
761 #[must_use]
763 pub fn get_stats_portfolio_returns_formatted(&self) -> Vec<String> {
764 self.format_returns_stats(self.get_performance_stats_portfolio_returns())
765 }
766
767 #[must_use]
769 pub fn get_stats_general_formatted(&self) -> Vec<String> {
770 let max_length = self.get_max_length_name();
771 let stats = self.get_performance_stats_general();
772
773 let mut entries: Vec<_> = stats.into_iter().collect();
774 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
775
776 let mut output = Vec::new();
777
778 for (k, v) in entries {
779 let padding = max_length - k.len() + 1;
780 output.push(format!("{}: {}{}", k, " ".repeat(padding), v));
781 }
782
783 output
784 }
785}
786
787fn canonical_position_id(position_id: PositionId) -> PositionId {
788 const UUID4_STRING_LEN: usize = 36;
789
790 let value = position_id.as_str();
791 let Some(separator_index) = value.len().checked_sub(UUID4_STRING_LEN + 1) else {
792 return position_id;
793 };
794
795 if separator_index == 0 || value.as_bytes()[separator_index] != b'-' {
796 return position_id;
797 }
798
799 let suffix = &value[separator_index + 1..];
800 if suffix.parse::<UUID4>().is_ok() {
801 PositionId::new(&value[..separator_index])
802 } else {
803 position_id
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use std::sync::Arc;
810
811 use ahash::{AHashMap, AHashSet};
812 use indexmap::IndexMap;
813 use nautilus_core::{UUID4, approx_eq};
814 use nautilus_model::{
815 accounts::{AccountAny, CashAccount},
816 enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide, PositionSide},
817 events::{AccountState, OrderFilled},
818 identifiers::{
819 AccountId, ClientOrderId,
820 stubs::{instrument_id_aud_usd_sim, strategy_id_ema_cross, trader_id},
821 },
822 instruments::InstrumentAny,
823 stubs::TestDefault,
824 types::{AccountBalance, Money, Price, Quantity},
825 };
826 use rstest::rstest;
827
828 use super::*;
829 use crate::statistics::beta_ratio::BetaRatio;
830
831 #[derive(Debug)]
833 struct MockStatistic {
834 name: String,
835 }
836
837 impl MockStatistic {
838 fn new(name: &str) -> Self {
839 Self {
840 name: name.to_string(),
841 }
842 }
843 }
844
845 impl PortfolioStatistic for MockStatistic {
846 type Item = f64;
847
848 fn name(&self) -> String {
849 self.name.clone()
850 }
851
852 fn calculate_from_realized_pnls(&self, pnls: &[f64]) -> Option<f64> {
853 Some(pnls.iter().sum())
854 }
855
856 fn calculate_from_returns(&self, returns: &Returns) -> Option<f64> {
857 Some(returns.values().sum())
858 }
859
860 fn calculate_from_positions(&self, positions: &[Position]) -> Option<f64> {
861 Some(positions.len() as f64)
862 }
863 }
864
865 fn create_mock_position(
866 id: &str,
867 realized_pnl: f64,
868 realized_return: f64,
869 currency: Currency,
870 ) -> Position {
871 Position {
872 events: Vec::new(),
873 adjustments: Vec::new(),
874 trader_id: trader_id(),
875 strategy_id: strategy_id_ema_cross(),
876 instrument_id: instrument_id_aud_usd_sim(),
877 id: PositionId::new(id),
878 account_id: AccountId::new("test-account"),
879 opening_order_id: ClientOrderId::test_default(),
880 closing_order_id: None,
881 entry: OrderSide::NoOrderSide,
882 side: PositionSide::NoPositionSide,
883 signed_qty: 0.0,
884 quantity: Quantity::default(),
885 peak_qty: Quantity::default(),
886 price_precision: 2,
887 size_precision: 2,
888 multiplier: Quantity::default(),
889 is_inverse: false,
890 is_currency_pair: true,
891 instrument_class: InstrumentClass::Spot,
892 base_currency: None,
893 quote_currency: Currency::USD(),
894 settlement_currency: Currency::USD(),
895 ts_init: UnixNanos::default(),
896 ts_opened: UnixNanos::default(),
897 ts_last: UnixNanos::default(),
898 ts_closed: Some(UnixNanos::from(1_706_659_200_000_000_000)),
899 duration_ns: 2,
900 avg_px_open: 0.0,
901 avg_px_close: None,
902 realized_return,
903 realized_pnl: Some(Money::new(realized_pnl, currency)),
904 trade_ids: AHashSet::new(),
905 buy_qty: Quantity::default(),
906 sell_qty: Quantity::default(),
907 commissions: IndexMap::new(),
908 }
909 }
910
911 struct MockAccount {
912 starting_balances: AHashMap<Currency, Money>,
913 current_balances: AHashMap<Currency, Money>,
914 events: Vec<AccountState>,
915 }
916
917 impl Account for MockAccount {
918 fn starting_balances(&self) -> IndexMap<Currency, Money> {
919 self.starting_balances.clone().into_iter().collect()
920 }
921 fn balances_total(&self) -> IndexMap<Currency, Money> {
922 self.current_balances.clone().into_iter().collect()
923 }
924 fn id(&self) -> AccountId {
925 todo!()
926 }
927 fn account_type(&self) -> AccountType {
928 todo!()
929 }
930 fn base_currency(&self) -> Option<Currency> {
931 todo!()
932 }
933 fn is_cash_account(&self) -> bool {
934 todo!()
935 }
936 fn is_margin_account(&self) -> bool {
937 todo!()
938 }
939 fn calculated_account_state(&self) -> bool {
940 todo!()
941 }
942 fn balance_total(&self, _: Option<Currency>) -> Option<Money> {
943 todo!()
944 }
945 fn balance_free(&self, _: Option<Currency>) -> Option<Money> {
946 todo!()
947 }
948 fn balances_free(&self) -> IndexMap<Currency, Money> {
949 todo!()
950 }
951 fn balance_locked(&self, _: Option<Currency>) -> Option<Money> {
952 todo!()
953 }
954 fn balances_locked(&self) -> IndexMap<Currency, Money> {
955 todo!()
956 }
957 fn last_event(&self) -> Option<AccountState> {
958 self.events.last().cloned()
959 }
960 fn events(&self) -> Vec<AccountState> {
961 self.events.clone()
962 }
963 fn event_count(&self) -> usize {
964 self.events.len()
965 }
966 fn currencies(&self) -> Vec<Currency> {
967 self.current_balances.keys().copied().collect()
968 }
969 fn balances(&self) -> IndexMap<Currency, AccountBalance> {
970 todo!()
971 }
972 fn apply(&mut self, _: AccountState) -> anyhow::Result<()> {
973 todo!()
974 }
975 fn calculate_balance_locked(
976 &mut self,
977 _: &InstrumentAny,
978 _: OrderSide,
979 _: Quantity,
980 _: Price,
981 _: Option<bool>,
982 ) -> Result<Money, anyhow::Error> {
983 todo!()
984 }
985 fn calculate_pnls(
986 &self,
987 _: &InstrumentAny,
988 _: &OrderFilled,
989 _: Option<Position>,
990 ) -> Result<Vec<Money>, anyhow::Error> {
991 todo!()
992 }
993 fn calculate_commission(
994 &self,
995 _: &InstrumentAny,
996 _: Quantity,
997 _: Price,
998 _: LiquiditySide,
999 _: Option<bool>,
1000 ) -> Result<Money, anyhow::Error> {
1001 todo!()
1002 }
1003
1004 fn balance(&self, _: Option<Currency>) -> Option<&AccountBalance> {
1005 todo!()
1006 }
1007
1008 fn purge_account_events(&mut self, _: UnixNanos, _: u64) {
1009 }
1011 }
1012
1013 fn create_account_state(total: f64, currency: Currency, ts_event: u64) -> AccountState {
1014 AccountState::new(
1015 AccountId::new("test-account"),
1016 AccountType::Cash,
1017 vec![AccountBalance::new(
1018 Money::new(total, currency),
1019 Money::new(0.0, currency),
1020 Money::new(total, currency),
1021 )],
1022 vec![],
1023 true,
1024 UUID4::new(),
1025 UnixNanos::from(ts_event),
1026 UnixNanos::from(ts_event),
1027 Some(currency),
1028 )
1029 }
1030
1031 #[rstest]
1032 fn test_register_and_deregister_statistics() {
1033 let mut analyzer = PortfolioAnalyzer::new();
1034 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1035 Arc::new(MockStatistic::new("test_stat"));
1036
1037 analyzer.register_statistic(Arc::clone(&stat));
1039 assert!(analyzer.statistic("test_stat").is_some());
1040
1041 analyzer.deregister_statistic(&stat);
1043 assert!(analyzer.statistic("test_stat").is_none());
1044
1045 let stat1: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1047 Arc::new(MockStatistic::new("stat1"));
1048 let stat2: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1049 Arc::new(MockStatistic::new("stat2"));
1050 analyzer.register_statistic(Arc::clone(&stat1));
1051 analyzer.register_statistic(Arc::clone(&stat2));
1052 analyzer.deregister_statistics();
1053 assert!(analyzer.statistics.is_empty());
1054 }
1055
1056 #[rstest]
1057 fn test_calculate_total_pnl() {
1058 let mut analyzer = PortfolioAnalyzer::new();
1059 let currency = Currency::USD();
1060
1061 let mut starting_balances = AHashMap::new();
1063 starting_balances.insert(currency, Money::new(1000.0, currency));
1064
1065 let mut current_balances = AHashMap::new();
1066 current_balances.insert(currency, Money::new(1500.0, currency));
1067
1068 let account = MockAccount {
1069 starting_balances,
1070 current_balances,
1071 events: vec![],
1072 };
1073
1074 analyzer.calculate_statistics(&account, &[]);
1075
1076 let result = analyzer.total_pnl(Some(¤cy), None).unwrap();
1078 assert!(approx_eq!(f64, result, 500.0, epsilon = 1e-9));
1079
1080 let unrealized_pnl = Money::new(100.0, currency);
1082 let result = analyzer
1083 .total_pnl(Some(¤cy), Some(&unrealized_pnl))
1084 .unwrap();
1085 assert!(approx_eq!(f64, result, 600.0, epsilon = 1e-9));
1086 }
1087
1088 #[rstest]
1089 fn test_calculate_total_pnl_percentage() {
1090 let mut analyzer = PortfolioAnalyzer::new();
1091 let currency = Currency::USD();
1092
1093 let mut starting_balances = AHashMap::new();
1095 starting_balances.insert(currency, Money::new(1000.0, currency));
1096
1097 let mut current_balances = AHashMap::new();
1098 current_balances.insert(currency, Money::new(1500.0, currency));
1099
1100 let account = MockAccount {
1101 starting_balances,
1102 current_balances,
1103 events: vec![],
1104 };
1105
1106 analyzer.calculate_statistics(&account, &[]);
1107
1108 let result = analyzer
1110 .total_pnl_percentage(Some(¤cy), None)
1111 .unwrap();
1112 assert!(approx_eq!(f64, result, 50.0, epsilon = 1e-9)); let unrealized_pnl = Money::new(500.0, currency);
1116 let result = analyzer
1117 .total_pnl_percentage(Some(¤cy), Some(&unrealized_pnl))
1118 .unwrap();
1119 assert!(approx_eq!(f64, result, 100.0, epsilon = 1e-9)); }
1121
1122 #[rstest]
1123 fn test_add_positions_and_returns() {
1124 let mut analyzer = PortfolioAnalyzer::new();
1125 let currency = Currency::USD();
1126
1127 let positions = vec![
1128 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1129 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1130 ];
1131
1132 analyzer.add_positions(&positions);
1133
1134 let pnls = analyzer.realized_pnls(Some(¤cy)).unwrap();
1136 assert_eq!(pnls.len(), 2);
1137 assert!(approx_eq!(f64, pnls[0].2, 100.0, epsilon = 1e-9));
1138 assert!(approx_eq!(f64, pnls[1].2, 200.0, epsilon = 1e-9));
1139
1140 let returns = analyzer.returns();
1142 let position_returns = analyzer.position_returns();
1143 assert_eq!(returns.len(), 1);
1144 assert_eq!(position_returns.len(), 1);
1145 assert!(analyzer.portfolio_returns().is_empty());
1146 assert!(approx_eq!(
1147 f64,
1148 *returns.values().next().unwrap(),
1149 0.30000000000000004,
1150 epsilon = 1e-9
1151 ));
1152 assert!(approx_eq!(
1153 f64,
1154 *position_returns.values().next().unwrap(),
1155 0.30000000000000004,
1156 epsilon = 1e-9
1157 ));
1158 }
1159
1160 #[rstest]
1161 fn test_add_positions_skips_position_returns_without_real_close_timestamp() {
1162 let mut analyzer = PortfolioAnalyzer::new();
1163 let currency = Currency::USD();
1164 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1165 position.ts_closed = Some(UnixNanos::default());
1166
1167 analyzer.add_positions(&[position]);
1168
1169 assert!(analyzer.position_returns().is_empty());
1170 assert!(analyzer.returns().is_empty());
1171 }
1172
1173 #[rstest]
1174 fn test_add_positions_records_open_position_realized_pnl() {
1175 let mut analyzer = PortfolioAnalyzer::new();
1176 let currency = Currency::USD();
1177 let mut position = create_mock_position("AUD/USD", 100.0, 0.1, currency);
1178 position.ts_closed = None;
1179 position.ts_last = UnixNanos::from(7);
1181 let position_id = position.id;
1182
1183 analyzer.add_positions(&[position]);
1184
1185 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1186 assert_eq!(records.len(), 1);
1187 assert_eq!(records[0], (position_id, UnixNanos::from(7), 100.0));
1188 assert!(analyzer.position_returns().is_empty());
1189 }
1190
1191 #[rstest]
1192 fn test_trade_pnl_records_keeps_unrecorded_native_cycle() {
1193 let mut analyzer = PortfolioAnalyzer::new();
1196 let currency = Currency::USD();
1197 let position_id = PositionId::new("pos1");
1198
1199 analyzer.add_trade(
1200 &position_id,
1201 UnixNanos::from(1),
1202 &Money::new(10.0, currency),
1203 );
1204 analyzer.add_trade(
1205 &position_id,
1206 UnixNanos::from(2),
1207 &Money::new(20.0, currency),
1208 );
1209 analyzer.record_trade(
1210 &position_id,
1211 UnixNanos::from(2),
1212 &Money::new(25.0, currency),
1213 );
1214
1215 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1216
1217 assert_eq!(
1218 records,
1219 vec![
1220 (position_id, UnixNanos::from(1), 10.0),
1221 (position_id, UnixNanos::from(2), 25.0),
1222 ]
1223 );
1224 }
1225
1226 #[rstest]
1227 fn test_trade_pnl_records_drops_recorded_snapshot_alias() {
1228 let mut analyzer = PortfolioAnalyzer::new();
1229 let currency = Currency::USD();
1230 let position_id = PositionId::new("pos1");
1231 let snapshot_id = PositionId::new(format!("{}-{}", position_id.as_str(), UUID4::new()));
1232 let ts_event = UnixNanos::from(1);
1233
1234 analyzer.add_trade(&snapshot_id, ts_event, &Money::new(10.0, currency));
1235 analyzer.record_trade(&position_id, ts_event, &Money::new(10.0, currency));
1236
1237 let records = analyzer.trade_pnl_records(Some(¤cy)).unwrap();
1238
1239 assert_eq!(records, vec![(position_id, ts_event, 10.0)]);
1240 }
1241
1242 #[rstest]
1243 fn test_performance_stats_calculation() {
1244 let mut analyzer = PortfolioAnalyzer::new();
1245 let currency = Currency::USD();
1246 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1247 Arc::new(MockStatistic::new("test_stat"));
1248 analyzer.register_statistic(Arc::clone(&stat));
1249
1250 let positions = vec![
1252 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1253 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1254 ];
1255
1256 let mut starting_balances = AHashMap::new();
1257 starting_balances.insert(currency, Money::new(1000.0, currency));
1258
1259 let mut current_balances = AHashMap::new();
1260 current_balances.insert(currency, Money::new(1500.0, currency));
1261
1262 let account = MockAccount {
1263 starting_balances,
1264 current_balances,
1265 events: vec![],
1266 };
1267
1268 analyzer.calculate_statistics(&account, &positions);
1269
1270 let pnl_stats = analyzer
1272 .get_performance_stats_pnls(Some(¤cy), None)
1273 .unwrap();
1274 assert!(pnl_stats.contains_key("PnL (total)"));
1275 assert!(pnl_stats.contains_key("PnL% (total)"));
1276 assert!(pnl_stats.contains_key("test_stat"));
1277
1278 let return_stats = analyzer.get_performance_stats_returns();
1280 assert!(return_stats.contains_key("test_stat"));
1281
1282 let general_stats = analyzer.get_performance_stats_general();
1284 assert!(general_stats.contains_key("test_stat"));
1285 }
1286
1287 #[rstest]
1288 fn test_calculate_statistics_preserves_recorded_realized_pnls() {
1289 let mut analyzer = PortfolioAnalyzer::new();
1290 let account_currency = Currency::EUR();
1291 let native_currency = Currency::USD();
1292 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1293 Arc::new(MockStatistic::new("test_stat"));
1294 analyzer.register_statistic(Arc::clone(&stat));
1295 analyzer.record_trade(
1296 &PositionId::new("pos1"),
1297 UnixNanos::from(1),
1298 &Money::new(90.0, account_currency),
1299 );
1300
1301 let positions = vec![create_mock_position("pos1", 100.0, 0.1, native_currency)];
1302
1303 let mut starting_balances = AHashMap::new();
1304 starting_balances.insert(account_currency, Money::new(1000.0, account_currency));
1305
1306 let mut current_balances = AHashMap::new();
1307 current_balances.insert(account_currency, Money::new(1100.0, account_currency));
1308
1309 let account = MockAccount {
1310 starting_balances,
1311 current_balances,
1312 events: vec![],
1313 };
1314
1315 analyzer.calculate_statistics(&account, &positions);
1316
1317 let native_pnls = analyzer.realized_pnls(Some(&native_currency)).unwrap();
1318 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1319 let pnl_stats = analyzer
1320 .get_performance_stats_pnls(Some(&account_currency), None)
1321 .unwrap();
1322
1323 assert_eq!(native_pnls[0].2, 100.0);
1324 assert_eq!(recorded_pnls[0].2, 90.0);
1325 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 90.0);
1326 }
1327
1328 #[rstest]
1329 fn test_record_trade_preserves_duplicate_position_ids() {
1330 let mut analyzer = PortfolioAnalyzer::new();
1331 let account_currency = Currency::EUR();
1332 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1333 Arc::new(MockStatistic::new("test_stat"));
1334 let position_id = PositionId::new("pos1");
1335
1336 analyzer.register_statistic(Arc::clone(&stat));
1337 analyzer.record_trade(
1338 &position_id,
1339 UnixNanos::from(1),
1340 &Money::new(90.0, account_currency),
1341 );
1342 analyzer.record_trade(
1343 &position_id,
1344 UnixNanos::from(2),
1345 &Money::new(-45.0, account_currency),
1346 );
1347
1348 let records = analyzer.trade_pnl_records(Some(&account_currency)).unwrap();
1349 let recorded_pnls = analyzer.realized_pnls(Some(&account_currency)).unwrap();
1350 let pnl_stats = analyzer
1351 .get_performance_stats_pnls(Some(&account_currency), None)
1352 .unwrap();
1353
1354 assert_eq!(records[0], (position_id, UnixNanos::from(1), 90.0));
1355 assert_eq!(records[1], (position_id, UnixNanos::from(2), -45.0));
1356 assert_eq!(
1357 recorded_pnls,
1358 vec![
1359 (position_id, UnixNanos::from(1), 90.0),
1360 (position_id, UnixNanos::from(2), -45.0),
1361 ]
1362 );
1363 assert_eq!(*pnl_stats.get("test_stat").unwrap(), 45.0);
1364 }
1365 #[rstest]
1366 fn test_formatted_output() {
1367 let mut analyzer = PortfolioAnalyzer::new();
1368 let currency = Currency::USD();
1369 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1370 Arc::new(MockStatistic::new("test_stat"));
1371 analyzer.register_statistic(Arc::clone(&stat));
1372
1373 let positions = vec![
1374 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1375 create_mock_position("AUD/USD", 200.0, 0.2, currency),
1376 ];
1377
1378 let mut starting_balances = AHashMap::new();
1379 starting_balances.insert(currency, Money::new(1000.0, currency));
1380
1381 let mut current_balances = AHashMap::new();
1382 current_balances.insert(currency, Money::new(1500.0, currency));
1383
1384 let account = MockAccount {
1385 starting_balances,
1386 current_balances,
1387 events: vec![],
1388 };
1389
1390 analyzer.calculate_statistics(&account, &positions);
1391
1392 let pnl_formatted = analyzer
1394 .get_stats_pnls_formatted(Some(¤cy), None)
1395 .unwrap();
1396 assert!(!pnl_formatted.is_empty());
1397 assert!(pnl_formatted.iter().all(|s| s.contains(':')));
1398
1399 let returns_formatted = analyzer.get_stats_returns_formatted();
1400 assert!(!returns_formatted.is_empty());
1401 assert!(returns_formatted.iter().all(|s| s.contains(':')));
1402
1403 let general_formatted = analyzer.get_stats_general_formatted();
1404 assert!(!general_formatted.is_empty());
1405 assert!(general_formatted.iter().all(|s| s.contains(':')));
1406 }
1407
1408 #[rstest]
1409 fn test_reset() {
1410 let mut analyzer = PortfolioAnalyzer::new();
1411 let currency = Currency::USD();
1412
1413 let positions = vec![create_mock_position("AUD/USD", 100.0, 0.1, currency)];
1414 let mut starting_balances = AHashMap::new();
1415 starting_balances.insert(currency, Money::new(1000.0, currency));
1416 let mut current_balances = AHashMap::new();
1417 current_balances.insert(currency, Money::new(1500.0, currency));
1418
1419 let account = MockAccount {
1420 starting_balances,
1421 current_balances,
1422 events: vec![],
1423 };
1424
1425 analyzer.calculate_statistics(&account, &positions);
1426
1427 analyzer.reset();
1428
1429 assert!(analyzer.account_balances_starting.is_empty());
1430 assert!(analyzer.account_balances.is_empty());
1431 assert!(analyzer.positions.is_empty());
1432 assert!(analyzer.realized_pnls.is_empty());
1433 assert!(analyzer.recorded_realized_pnls.is_empty());
1434 assert!(analyzer.position_returns.is_empty());
1435 assert!(analyzer.portfolio_returns.is_empty());
1436 assert!(analyzer.returns.is_empty());
1437 }
1438
1439 #[rstest]
1440 fn test_currencies_preserve_account_balance_order() {
1441 let mut analyzer = PortfolioAnalyzer::new();
1446 let inserts = [
1447 (Currency::BTC(), Money::new(1.0, Currency::BTC())),
1448 (Currency::USD(), Money::new(2.0, Currency::USD())),
1449 (Currency::ETH(), Money::new(3.0, Currency::ETH())),
1450 ];
1451
1452 for (currency, money) in inserts {
1453 analyzer.account_balances.insert(currency, money);
1454 }
1455
1456 let returned: Vec<Currency> = analyzer.currencies().into_iter().copied().collect();
1457 assert_eq!(
1458 returned,
1459 vec![Currency::BTC(), Currency::USD(), Currency::ETH()],
1460 );
1461 }
1462
1463 #[rstest]
1464 fn test_calculate_statistics_clears_previous_positions() {
1465 let mut analyzer = PortfolioAnalyzer::new();
1466 let currency = Currency::USD();
1467
1468 let positions1 = vec![create_mock_position("pos1", 100.0, 0.1, currency)];
1469 let positions2 = vec![create_mock_position("pos2", 200.0, 0.2, currency)];
1470
1471 let mut starting_balances = AHashMap::new();
1472 starting_balances.insert(currency, Money::new(1000.0, currency));
1473 let mut current_balances = AHashMap::new();
1474 current_balances.insert(currency, Money::new(1500.0, currency));
1475
1476 let account = MockAccount {
1477 starting_balances,
1478 current_balances,
1479 events: vec![],
1480 };
1481
1482 analyzer.calculate_statistics(&account, &positions1);
1484 assert_eq!(analyzer.positions.len(), 1);
1485
1486 analyzer.calculate_statistics(&account, &positions2);
1488 assert_eq!(analyzer.positions.len(), 1);
1489 }
1490
1491 #[rstest]
1492 fn test_calculate_statistics_uses_account_state_returns_when_available() {
1493 let mut analyzer = PortfolioAnalyzer::new();
1494 let currency = Currency::USD();
1495 let positions = vec![
1496 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1497 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1498 ];
1499
1500 let mut starting_balances = AHashMap::new();
1501 starting_balances.insert(currency, Money::new(1000.0, currency));
1502
1503 let mut current_balances = AHashMap::new();
1504 current_balances.insert(currency, Money::new(1100.0, currency));
1505
1506 let account = MockAccount {
1507 starting_balances,
1508 current_balances,
1509 events: vec![
1510 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1511 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1512 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1513 ],
1514 };
1515
1516 analyzer.calculate_statistics(&account, &positions);
1517
1518 let position_returns = analyzer.position_returns();
1519 let portfolio_returns = analyzer.portfolio_returns();
1520 let returns = analyzer.returns();
1521 assert_eq!(position_returns.len(), 1);
1522 assert_eq!(portfolio_returns.len(), 30);
1523 assert_eq!(returns, portfolio_returns);
1524 assert!(approx_eq!(
1525 f64,
1526 *portfolio_returns
1527 .get(&UnixNanos::from(1_704_153_600_000_000_000))
1528 .unwrap(),
1529 0.0,
1530 epsilon = 1e-9
1531 ));
1532 assert!(approx_eq!(
1533 f64,
1534 *portfolio_returns
1535 .get(&UnixNanos::from(1_704_844_800_000_000_000))
1536 .unwrap(),
1537 0.05,
1538 epsilon = 1e-9
1539 ));
1540 assert!(approx_eq!(
1541 f64,
1542 *portfolio_returns
1543 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1544 .unwrap(),
1545 (1100.0 / 1050.0) - 1.0,
1546 epsilon = 1e-9
1547 ));
1548 assert!(approx_eq!(
1549 f64,
1550 *position_returns.values().next().unwrap(),
1551 0.30000000000000004,
1552 epsilon = 1e-9
1553 ));
1554 }
1555
1556 #[rstest]
1557 fn test_calculate_statistics_skips_non_finite_account_returns() {
1558 let mut analyzer = PortfolioAnalyzer::new();
1559 let currency = Currency::USD();
1560
1561 let mut starting_balances = AHashMap::new();
1562 starting_balances.insert(currency, Money::new(0.0, currency));
1563
1564 let mut current_balances = AHashMap::new();
1565 current_balances.insert(currency, Money::new(1050.0, currency));
1566
1567 let account = MockAccount {
1568 starting_balances,
1569 current_balances,
1570 events: vec![
1571 create_account_state(0.0, currency, 1_704_067_200_000_000_000),
1572 create_account_state(1000.0, currency, 1_704_844_800_000_000_000),
1573 create_account_state(1050.0, currency, 1_706_659_200_000_000_000),
1574 ],
1575 };
1576
1577 analyzer.calculate_statistics(&account, &[]);
1578
1579 let returns = analyzer.returns();
1580 assert!(returns.values().all(|value| value.is_finite()));
1581 assert!(approx_eq!(
1582 f64,
1583 *returns
1584 .get(&UnixNanos::from(1_706_659_200_000_000_000))
1585 .unwrap(),
1586 0.05,
1587 epsilon = 1e-9
1588 ));
1589 }
1590
1591 #[rstest]
1592 fn test_calculate_statistics_falls_back_to_position_returns_without_account_events() {
1593 let mut analyzer = PortfolioAnalyzer::new();
1594 let currency = Currency::USD();
1595 let positions = vec![
1596 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1597 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1598 ];
1599
1600 let mut starting_balances = AHashMap::new();
1601 starting_balances.insert(currency, Money::new(1000.0, currency));
1602
1603 let mut current_balances = AHashMap::new();
1604 current_balances.insert(currency, Money::new(1100.0, currency));
1605
1606 let account = MockAccount {
1607 starting_balances,
1608 current_balances,
1609 events: vec![],
1610 };
1611
1612 analyzer.calculate_statistics(&account, &positions);
1613
1614 let returns = analyzer.returns();
1615 assert!(analyzer.portfolio_returns().is_empty());
1616 assert_eq!(returns, analyzer.position_returns());
1617 assert_eq!(returns.len(), 1);
1618 assert!(approx_eq!(
1619 f64,
1620 *returns.values().next().unwrap(),
1621 0.30000000000000004,
1622 epsilon = 1e-9
1623 ));
1624 }
1625
1626 #[rstest]
1627 fn test_get_performance_stats_returns_prefers_portfolio_returns() {
1628 let mut analyzer = PortfolioAnalyzer::new();
1629 let currency = Currency::USD();
1630 let stat: Arc<dyn PortfolioStatistic<Item = f64> + Send + Sync> =
1631 Arc::new(MockStatistic::new("test_stat"));
1632 analyzer.register_statistic(Arc::clone(&stat));
1633
1634 let positions = vec![
1635 create_mock_position("AUD/USD", 100.0, 0.1, currency),
1636 create_mock_position("EUR/USD", 200.0, 0.2, currency),
1637 ];
1638
1639 let mut starting_balances = AHashMap::new();
1640 starting_balances.insert(currency, Money::new(1000.0, currency));
1641
1642 let mut current_balances = AHashMap::new();
1643 current_balances.insert(currency, Money::new(1100.0, currency));
1644
1645 let account = MockAccount {
1646 starting_balances,
1647 current_balances,
1648 events: vec![
1649 create_account_state(1000.0, currency, 1_704_067_200_000_000_000),
1650 create_account_state(1050.0, currency, 1_704_844_800_000_000_000),
1651 create_account_state(1100.0, currency, 1_706_659_200_000_000_000),
1652 ],
1653 };
1654
1655 analyzer.calculate_statistics(&account, &positions);
1656
1657 let position_stats = analyzer.get_performance_stats_position_returns();
1658 let portfolio_stats = analyzer.get_performance_stats_portfolio_returns();
1659 let returns_stats = analyzer.get_performance_stats_returns();
1660
1661 assert!(approx_eq!(
1662 f64,
1663 *position_stats.get("test_stat").unwrap(),
1664 0.30000000000000004,
1665 epsilon = 1e-9
1666 ));
1667 assert_eq!(returns_stats, portfolio_stats);
1668 }
1669
1670 #[rstest]
1671 fn test_from_accounts_aggregates_balances_and_positions() {
1672 let currency = Currency::USD();
1673 let positions = vec![
1674 create_mock_position("pos1", 100.0, 0.1, currency),
1675 create_mock_position("pos2", 200.0, 0.2, currency),
1676 ];
1677
1678 let analyzer = PortfolioAnalyzer::from_accounts(
1679 &[AccountAny::Cash(CashAccount::default())],
1680 &positions,
1681 &[],
1682 AHashMap::new(),
1683 );
1684
1685 assert_eq!(analyzer.positions.len(), positions.len());
1686 assert!(!analyzer.account_balances.is_empty());
1687 }
1688
1689 #[rstest]
1690 fn test_from_accounts_sums_balances_across_accounts() {
1691 let usd = Currency::USD();
1692 let one = PortfolioAnalyzer::from_accounts(
1693 &[AccountAny::Cash(CashAccount::default())],
1694 &[],
1695 &[],
1696 AHashMap::new(),
1697 );
1698 let two = PortfolioAnalyzer::from_accounts(
1699 &[
1700 AccountAny::Cash(CashAccount::default()),
1701 AccountAny::Cash(CashAccount::default()),
1702 ],
1703 &[],
1704 &[],
1705 AHashMap::new(),
1706 );
1707
1708 let single = one.account_balances.get(&usd).unwrap().as_decimal();
1709 let summed = two.account_balances.get(&usd).unwrap().as_decimal();
1710 let single_start = one
1711 .account_balances_starting
1712 .get(&usd)
1713 .unwrap()
1714 .as_decimal();
1715 let summed_start = two
1716 .account_balances_starting
1717 .get(&usd)
1718 .unwrap()
1719 .as_decimal();
1720
1721 assert_eq!(summed, single + single);
1722 assert_eq!(summed_start, single_start + single_start);
1723 assert_ne!(summed, single);
1724 }
1725
1726 #[rstest]
1727 fn test_statistics_snapshot_matches_getters() {
1728 let currency = Currency::USD();
1729 let positions = vec![
1730 create_mock_position("pos1", 100.0, 0.1, currency),
1731 create_mock_position("pos2", 200.0, 0.2, currency),
1732 ];
1733
1734 let analyzer = PortfolioAnalyzer::from_accounts(
1735 &[AccountAny::Cash(CashAccount::default())],
1736 &positions,
1737 &[],
1738 AHashMap::new(),
1739 );
1740
1741 let snapshot = analyzer.statistics();
1742 assert!(maps_equal_nan_aware(
1743 &snapshot.returns,
1744 &analyzer.get_performance_stats_returns()
1745 ));
1746 assert!(maps_equal_nan_aware(
1747 &snapshot.general,
1748 &analyzer.get_performance_stats_general()
1749 ));
1750
1751 for currency in analyzer.currencies() {
1752 let expected = analyzer
1753 .get_performance_stats_pnls(Some(currency), None)
1754 .unwrap();
1755 let actual = snapshot.pnls.get(¤cy.code.to_string()).unwrap();
1756 assert!(maps_equal_nan_aware(actual, &expected));
1757 }
1758 }
1759
1760 fn maps_equal_nan_aware(a: &AHashMap<String, f64>, b: &AHashMap<String, f64>) -> bool {
1761 if a.len() != b.len() {
1762 return false;
1763 }
1764 a.iter().all(|(k, v)| {
1765 b.get(k)
1766 .is_some_and(|bv| (v.is_nan() && bv.is_nan()) || (v == bv))
1767 })
1768 }
1769
1770 #[rstest]
1771 fn test_get_performance_stats_returns_vs_benchmark() {
1772 let mut analyzer = PortfolioAnalyzer::new();
1773 analyzer.register_statistic(Arc::new(BetaRatio::new()));
1774 analyzer.register_statistic(Arc::new(SharpeRatio::new(None)));
1775
1776 let one_day = 86_400_000_000_000_u64;
1777 let start = 1_600_000_000_000_000_000_u64;
1778 for (i, value) in [0.03, -0.01, 0.02, 0.04].iter().enumerate() {
1779 analyzer.add_return(UnixNanos::from(start + i as u64 * one_day), *value);
1780 }
1781
1782 let mut benchmark: Returns = BTreeMap::new();
1783 for (i, value) in [0.01, 0.005, 0.005, 0.01].iter().enumerate() {
1784 benchmark.insert(UnixNanos::from(start + i as u64 * one_day), *value);
1785 }
1786
1787 let stats = analyzer.get_performance_stats_returns_vs_benchmark(&benchmark);
1788
1789 assert_eq!(stats.len(), 1);
1795 assert!(approx_eq!(
1796 f64,
1797 *stats.get("Beta").unwrap(),
1798 6.0,
1799 epsilon = 1e-9
1800 ));
1801 assert!(!stats.contains_key("Sharpe Ratio (252 days)"));
1802 }
1803}