1use super::{Bar, OrderSide, Timeframe};
9#[cfg(feature = "csv")]
10use chrono::SecondsFormat;
11use chrono::{DateTime, TimeDelta, Utc};
12use serde::{Deserialize, Deserializer, Serialize};
13
14const YEAR_MS: f64 = 31_557_600_000.0;
16
17#[cfg(feature = "csv")]
19fn csv_text(value: &str) -> String {
20 if value.starts_with(['=', '+', '-', '@', '\t', '\r']) {
21 format!("'{value}")
22 } else {
23 value.to_owned()
24 }
25}
26
27fn null_as_nan<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
30 Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
31}
32
33fn curve_null_as_nan<'de, D: Deserializer<'de>>(
35 deserializer: D,
36) -> Result<Vec<(DateTime<Utc>, f64)>, D::Error> {
37 let curve = Vec::<(DateTime<Utc>, Option<f64>)>::deserialize(deserializer)?;
38 Ok(curve
39 .into_iter()
40 .map(|(t, v)| (t, v.unwrap_or(f64::NAN)))
41 .collect())
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct StrategyReport {
78 pub name: String,
80 pub symbol: String,
82 pub timeframe: Timeframe,
84 #[serde(deserialize_with = "null_as_nan")]
87 pub initial_capital: f64,
88 #[serde(default)]
90 pub currency: Option<String>,
91 #[serde(default, deserialize_with = "null_as_nan")]
94 pub risk_free_rate: f64,
95 #[serde(default)]
98 pub properties: Vec<(String, String)>,
99 #[serde(default)]
101 pub trades: Vec<StrategyTrade>,
102 #[serde(default, deserialize_with = "curve_null_as_nan")]
105 pub equity: Vec<(DateTime<Utc>, f64)>,
106}
107
108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[non_exhaustive]
117pub struct StrategyTrade {
118 pub side: OrderSide,
120 #[serde(deserialize_with = "null_as_nan")]
122 pub quantity: f64,
123 pub entry_time: DateTime<Utc>,
125 #[serde(deserialize_with = "null_as_nan")]
127 pub entry_price: f64,
128 #[serde(default)]
130 pub exit: Option<StrategyExit>,
131 #[serde(default, deserialize_with = "null_as_nan")]
134 pub fees: f64,
135 #[serde(default)]
137 pub label: Option<String>,
138}
139
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct StrategyExit {
144 pub time: DateTime<Utc>,
146 #[serde(deserialize_with = "null_as_nan")]
148 pub price: f64,
149 #[serde(deserialize_with = "null_as_nan")]
152 pub profit: f64,
153}
154
155#[derive(Clone, Debug, Default, PartialEq)]
166#[non_exhaustive]
167pub struct StrategyPerformance {
168 pub closed_trades: usize,
170 pub open_trades: usize,
172 pub winning_trades: usize,
174 pub losing_trades: usize,
176 pub net_profit: f64,
178 pub net_profit_fraction: Option<f64>,
180 pub gross_profit: f64,
182 pub gross_loss: f64,
184 pub total_fees: f64,
186 pub profit_factor: Option<f64>,
188 pub win_rate: Option<f64>,
191 pub average_trade: Option<f64>,
193 pub largest_winning_trade: Option<f64>,
195 pub largest_losing_trade: Option<f64>,
197 pub max_consecutive_wins: usize,
200 pub max_consecutive_losses: usize,
202 pub max_drawdown: Option<f64>,
205 pub max_drawdown_fraction: Option<f64>,
209 pub sharpe_ratio: Option<f64>,
217 pub sortino_ratio: Option<f64>,
221 pub periods_per_year: Option<f64>,
233 pub skipped_trades: usize,
235 pub skipped_equity_points: usize,
238}
239
240#[derive(Clone, Debug, Default, PartialEq)]
242#[non_exhaustive]
243pub struct StrategyComparison {
244 pub buy_and_hold_return: Option<f64>,
246 pub buy_and_hold_equity: Vec<(DateTime<Utc>, f64)>,
249 pub excursions: Vec<Option<StrategyExcursion>>,
252 pub skipped_bars: usize,
254}
255
256#[derive(Clone, Debug, PartialEq)]
266#[non_exhaustive]
267pub struct StrategyExcursion {
268 pub adverse: f64,
270 pub favourable: f64,
272}
273
274impl StrategyReport {
275 pub fn signals(&self) -> Vec<super::Signal> {
280 use super::{Direction, Signal, SignalId, SignalShape};
281 let mut signals = Vec::new();
282 for (index, trade) in self
283 .trades
284 .iter()
285 .enumerate()
286 .filter(|(_, trade)| trade.is_valid())
287 {
288 let entry = match trade.side {
289 OrderSide::Buy => Direction::Up,
290 OrderSide::Sell => Direction::Down,
291 };
292 let exit = match trade.side {
293 OrderSide::Buy => Direction::Down,
294 OrderSide::Sell => Direction::Up,
295 };
296 for (fill, time, price, direction) in
297 std::iter::once(("Entry", trade.entry_time, trade.entry_price, entry)).chain(
298 trade
299 .exit
300 .as_ref()
301 .map(|value| ("Exit", value.time, value.price, exit)),
302 )
303 {
304 let label = format!("{fill} {}", index + 1);
305 signals.push(Signal {
306 id: SignalId(format!(
307 "strategy:{}:{}:{}:{index}:{fill}",
308 self.symbol, self.timeframe, self.name
309 )),
310 shape: SignalShape::Marker {
311 time,
312 price: Some(price),
313 direction,
314 },
315 category: "Strategy trades".into(),
316 label: Some(label.clone()),
317 tooltip: Some(format!(
318 "{} · {label} · {} units @ {price}{}",
319 self.name,
320 trade.quantity,
321 trade
322 .label
323 .as_ref()
324 .map_or_else(String::new, |label| format!(" · {label}"))
325 )),
326 });
327 }
328 }
329 signals
330 }
331
332 pub fn new(
334 name: impl Into<String>,
335 symbol: impl Into<String>,
336 timeframe: Timeframe,
337 initial_capital: f64,
338 ) -> Self {
339 Self {
340 name: name.into(),
341 symbol: symbol.into(),
342 timeframe,
343 initial_capital,
344 currency: None,
345 risk_free_rate: 0.0,
346 properties: Vec::new(),
347 trades: Vec::new(),
348 equity: Vec::new(),
349 }
350 }
351
352 pub fn performance(&self) -> StrategyPerformance {
355 let finite = |x: f64| Some(x).filter(|x| x.is_finite());
356 let mut p = StrategyPerformance::default();
357 for trade in &self.trades {
358 if !trade.is_valid() {
359 p.skipped_trades += 1;
360 continue;
361 }
362 let Some(exit) = &trade.exit else {
363 p.open_trades += 1;
364 continue;
365 };
366 p.closed_trades += 1;
367 p.net_profit += exit.profit;
368 p.total_fees += trade.fees;
369 if exit.profit > 0.0 {
370 p.winning_trades += 1;
371 p.gross_profit += exit.profit;
372 p.largest_winning_trade =
373 Some(p.largest_winning_trade.unwrap_or(0.0).max(exit.profit));
374 } else if exit.profit < 0.0 {
375 p.losing_trades += 1;
376 p.gross_loss -= exit.profit;
377 p.largest_losing_trade =
378 Some(p.largest_losing_trade.unwrap_or(0.0).min(exit.profit));
379 }
380 }
381 let mut exits: Vec<_> = self
382 .trades
383 .iter()
384 .filter(|t| t.is_valid())
385 .filter_map(|t| t.exit.as_ref())
386 .collect();
387 exits.sort_by_key(|e| e.time);
388 let (mut wins, mut losses) = (0, 0);
389 for exit in exits {
390 wins = if exit.profit > 0.0 { wins + 1 } else { 0 };
391 losses = if exit.profit < 0.0 { losses + 1 } else { 0 };
392 p.max_consecutive_wins = p.max_consecutive_wins.max(wins);
393 p.max_consecutive_losses = p.max_consecutive_losses.max(losses);
394 }
395 let closed = p.closed_trades as f64;
396 let capital = self.initial_capital;
397 p.net_profit_fraction = (capital.is_finite() && capital > 0.0)
398 .then(|| p.net_profit / capital)
399 .and_then(finite);
400 p.profit_factor = finite(p.gross_profit / p.gross_loss);
401 p.win_rate = finite(p.winning_trades as f64 / closed);
402 p.average_trade = finite(p.net_profit / closed);
403
404 let curve = self.equity_curve();
405 p.skipped_equity_points = self.equity.len() - curve.len();
406 let (Some(&(start, first)), Some(&(end, _))) = (curve.first(), curve.last()) else {
407 return p;
408 };
409 let (mut peak, mut drawdown, mut fraction) = (first, 0.0_f64, 0.0_f64);
410 for &(_, e) in &curve {
411 peak = peak.max(e);
412 drawdown = drawdown.max(peak - e);
413 fraction = fraction.max((peak - e) / peak);
414 }
415 p.max_drawdown = finite(drawdown);
416 p.max_drawdown_fraction = (first > 0.0).then_some(fraction).and_then(finite);
417
418 let steps = curve.len() - 1;
419 if end - start < TimeDelta::weeks(13) {
420 return p;
421 }
422 let years = (end - start).num_milliseconds() as f64 / YEAR_MS;
423 let periods_per_year = steps as f64 / years;
424 p.periods_per_year = finite(periods_per_year);
425 let rate = self.risk_free_rate;
428 if steps < 2 || rate <= -1.0 || curve[..steps].iter().any(|&(_, e)| e <= 0.0) {
429 return p;
430 }
431 let returns: Vec<f64> = curve.windows(2).map(|w| w[1].1 / w[0].1 - 1.0).collect();
432 let n = steps as f64;
433 let mean = returns.iter().sum::<f64>() / n;
434 let deviation =
435 (returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt();
436 let rate_per_step = (1.0 + rate).powf(1.0 / periods_per_year) - 1.0;
437 let downside = (returns
438 .iter()
439 .map(|r| (r - rate_per_step).min(0.0).powi(2))
440 .sum::<f64>()
441 / n)
442 .sqrt();
443 if deviation > 16.0 * f64::EPSILON {
445 let excess = (mean - rate_per_step) * periods_per_year.sqrt();
446 p.sharpe_ratio = finite(excess / deviation);
447 p.sortino_ratio = finite(excess / downside);
448 }
449 p
450 }
451
452 pub fn equity_curve(&self) -> Vec<(DateTime<Utc>, f64)> {
455 let curve: Vec<_> = self
456 .equity
457 .iter()
458 .copied()
459 .filter(|(_, e)| e.is_finite())
460 .collect();
461 if curve.windows(2).any(|w| w[1].0 <= w[0].0) {
462 Vec::new()
463 } else {
464 curve
465 }
466 }
467
468 pub fn comparison(&self, bars: &[Bar]) -> StrategyComparison {
478 let mut result = StrategyComparison {
479 excursions: vec![None; self.trades.len()],
480 ..Default::default()
481 };
482 if bars.windows(2).any(|w| w[1].time <= w[0].time) {
483 result.skipped_bars = bars.len();
484 return result;
485 }
486 let valid = |b: &Bar| {
487 [b.open, b.high, b.low, b.close]
488 .iter()
489 .all(|p| p.is_finite())
490 && b.low <= b.open.min(b.close)
491 && b.high >= b.open.max(b.close)
492 };
493 let source: Vec<_> = bars.iter().filter(|b| valid(b)).collect();
494 result.skipped_bars = bars.len() - source.len();
495 if let (Some(first), Some(last)) = (source.first(), source.last()) {
496 let fraction = last.close / first.open - 1.0;
497 if first.open > 0.0 && fraction.is_finite() {
498 result.buy_and_hold_return = Some(fraction);
499 if self.initial_capital.is_finite() && self.initial_capital > 0.0 {
500 result.buy_and_hold_equity = source
501 .iter()
502 .map(|b| (b.time, self.initial_capital * (b.close / first.open)))
503 .collect();
504 if result
505 .buy_and_hold_equity
506 .iter()
507 .any(|(_, e)| !e.is_finite())
508 {
509 result.buy_and_hold_equity.clear();
510 }
511 }
512 }
513 }
514 for (i, trade) in self.trades.iter().enumerate().filter(|(_, t)| t.is_valid()) {
515 let mut start = bars.partition_point(|b| b.time < trade.entry_time);
516 if let Some(previous) = start.checked_sub(1).and_then(|i| bars.get(i)) {
517 let close = match self.timeframe {
518 Timeframe::Month1 => previous.time.checked_add_months(chrono::Months::new(1)),
519 Timeframe::Months(count) => previous
520 .time
521 .checked_add_months(chrono::Months::new(count.get())),
522 tf if tf.is_trade_based() => bars.get(start).map(|b| b.time),
523 tf => previous.time.checked_add_signed(tf.duration()),
524 };
525 if close.is_none_or(|close| trade.entry_time < close) {
526 start -= 1;
527 }
528 }
529 let end = trade
530 .exit
531 .as_ref()
532 .map_or(bars.len(), |e| bars.partition_point(|b| b.time < e.time));
533 let held = &bars[start..end];
534 if held.is_empty() || held.iter().any(|b| !valid(b)) {
535 continue;
536 }
537 let mut low = trade.entry_price;
538 let mut high = trade.entry_price;
539 for bar in held {
540 low = low.min(bar.low);
541 high = high.max(bar.high);
542 }
543 if let Some(exit) = &trade.exit {
544 low = low.min(exit.price);
545 high = high.max(exit.price);
546 }
547 let (adverse, favourable) = match trade.side {
548 OrderSide::Buy => (trade.entry_price - low, high - trade.entry_price),
549 OrderSide::Sell => (high - trade.entry_price, trade.entry_price - low),
550 };
551 let (adverse, favourable) = (adverse * trade.quantity, favourable * trade.quantity);
552 if adverse.is_finite() && favourable.is_finite() {
553 result.excursions[i] = Some(StrategyExcursion {
554 adverse,
555 favourable,
556 });
557 }
558 }
559 result
560 }
561
562 #[cfg(feature = "csv")]
572 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
573 pub fn trades_csv(&self) -> String {
574 const IN_MEMORY: &str = "writing CSV to memory cannot fail";
575 let mut trades: Vec<&StrategyTrade> = self.trades.iter().filter(|t| t.is_valid()).collect();
576 trades.sort_by_key(|t| t.entry_time);
577 let time = |t: DateTime<Utc>| csv_text(&t.to_rfc3339_opts(SecondsFormat::AutoSi, true));
578 let mut csv = csv::Writer::from_writer(Vec::new());
579 csv.write_record([
580 "number",
581 "side",
582 "quantity",
583 "entry_time",
584 "entry_price",
585 "exit_time",
586 "exit_price",
587 "profit",
588 "fees",
589 "label",
590 ])
591 .expect(IN_MEMORY);
592 for (i, trade) in trades.into_iter().enumerate() {
593 let (exit_time, exit_price, profit) = match &trade.exit {
594 Some(exit) => (
595 time(exit.time),
596 exit.price.to_string(),
597 exit.profit.to_string(),
598 ),
599 None => Default::default(),
600 };
601 let side = match trade.side {
602 OrderSide::Buy => "long",
603 OrderSide::Sell => "short",
604 };
605 csv.write_record([
606 (i + 1).to_string(),
607 side.to_string(),
608 trade.quantity.to_string(),
609 time(trade.entry_time),
610 trade.entry_price.to_string(),
611 exit_time,
612 exit_price,
613 profit,
614 trade.fees.to_string(),
615 csv_text(trade.label.as_deref().unwrap_or_default()),
616 ])
617 .expect(IN_MEMORY);
618 }
619 String::from_utf8(csv.into_inner().expect(IN_MEMORY)).expect("every field is a String")
620 }
621
622 #[cfg(feature = "csv")]
639 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
640 pub fn summary_csv(
641 &self,
642 performance: &StrategyPerformance,
643 comparison: &StrategyComparison,
644 ) -> String {
645 const IN_MEMORY: &str = "writing CSV to memory cannot fail";
646 let currency = csv_text(self.currency.as_deref().unwrap_or_default());
647 let mut csv = csv::Writer::from_writer(Vec::new());
648 csv.write_record(["section", "name", "value", "unit", "currency"])
649 .expect(IN_MEMORY);
650 let mut row = |section: &str, name: &str, value: String, unit: &str| {
651 let name = csv_text(name);
652 let value = if unit == "text" {
653 csv_text(&value)
654 } else {
655 value
656 };
657 let currency = if unit == "money" { ¤cy } else { "" };
658 csv.write_record([section, &name, &value, unit, currency])
659 .expect(IN_MEMORY);
660 };
661 let number = |value: Option<f64>, percent: bool| {
662 value
663 .map(|v| if percent { v * 100.0 } else { v })
664 .filter(|v| v.is_finite())
665 .map_or_else(String::new, |v| {
666 let v = if percent {
667 let rounded = format!("{v:.14e}")
668 .parse::<f64>()
669 .expect("a formatted f64 parses as f64");
670 if rounded.is_finite() { rounded } else { v }
671 } else {
672 v
673 };
674 if v == 0.0 { "0".into() } else { v.to_string() }
675 })
676 };
677 let p = performance;
678 for (name, value, unit) in [
679 ("Net profit", Some(p.net_profit), "money"),
680 ("Net return", p.net_profit_fraction, "percent"),
681 ("Gross profit", Some(p.gross_profit), "money"),
682 ("Gross loss", Some(p.gross_loss), "money"),
683 ("Profit factor", p.profit_factor, "ratio"),
684 ("Win rate", p.win_rate, "percent"),
685 ("Average trade", p.average_trade, "money"),
686 ("Fees", Some(p.total_fees), "money"),
687 ("Maximum drawdown", p.max_drawdown, "money"),
688 ("Maximum drawdown %", p.max_drawdown_fraction, "percent"),
689 ("Sharpe ratio", p.sharpe_ratio, "ratio"),
690 ("Sortino ratio", p.sortino_ratio, "ratio"),
691 (
692 "Buy-and-hold return",
693 comparison.buy_and_hold_return,
694 "percent",
695 ),
696 ("Largest winning trade", p.largest_winning_trade, "money"),
697 ("Largest losing trade", p.largest_losing_trade, "money"),
698 ] {
699 row("summary", name, number(value, unit == "percent"), unit);
700 }
701 for (name, count) in [
702 ("Consecutive wins", p.max_consecutive_wins),
703 ("Consecutive losses", p.max_consecutive_losses),
704 ("Closed trades", p.closed_trades),
705 ("Open trades", p.open_trades),
706 ("Skipped trades", p.skipped_trades),
707 ("Skipped equity points", p.skipped_equity_points),
708 ("Skipped source bars", comparison.skipped_bars),
709 ] {
710 row("summary", name, count.to_string(), "count");
711 }
712 row("properties", "Name", self.name.clone(), "text");
713 row("properties", "Symbol", self.symbol.clone(), "text");
714 row("properties", "Interval", self.timeframe.to_string(), "text");
715 row(
716 "properties",
717 "Initial capital",
718 number(Some(self.initial_capital), false),
719 "money",
720 );
721 row(
722 "properties",
723 "Currency",
724 self.currency.clone().unwrap_or_default(),
725 "text",
726 );
727 row(
728 "properties",
729 "Risk-free rate",
730 number(Some(self.risk_free_rate), true),
731 "percent",
732 );
733 for (name, value) in &self.properties {
734 row("properties", name, value.clone(), "text");
735 }
736 String::from_utf8(csv.into_inner().expect(IN_MEMORY)).expect("every field is a String")
737 }
738}
739
740impl StrategyTrade {
741 pub fn new(
743 side: OrderSide,
744 quantity: f64,
745 entry_time: DateTime<Utc>,
746 entry_price: f64,
747 ) -> Self {
748 Self {
749 side,
750 quantity,
751 entry_time,
752 entry_price,
753 exit: None,
754 fees: 0.0,
755 label: None,
756 }
757 }
758
759 pub fn is_valid(&self) -> bool {
761 self.quantity.is_finite()
762 && self.quantity > 0.0
763 && self.entry_price.is_finite()
764 && self.fees.is_finite()
765 && self.exit.as_ref().is_none_or(|exit| {
766 exit.price.is_finite() && exit.profit.is_finite() && exit.time >= self.entry_time
767 })
768 }
769}
770
771impl StrategyExit {
772 pub fn new(time: DateTime<Utc>, price: f64, profit: f64) -> Self {
774 Self {
775 time,
776 price,
777 profit,
778 }
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use chrono::{Datelike, TimeDelta, TimeZone, Weekday};
786
787 #[test]
788 fn report_signals_preserve_valid_fill_times_prices_indices_and_sides() {
789 use crate::model::{Direction, SignalShape};
790 let start = DateTime::UNIX_EPOCH + chrono::TimeDelta::nanoseconds(123);
791 let mut report = StrategyReport::new("Fills", "TEST", Timeframe::Min1, 1000.0);
792 for (side, exit) in [
793 (OrderSide::Buy, true),
794 (OrderSide::Sell, true),
795 (OrderSide::Sell, false),
796 ] {
797 let mut trade = StrategyTrade::new(side, 2.0, start, -0.00001234);
798 trade.label = Some("Rule / close".into());
799 if exit {
800 trade.exit = Some(StrategyExit::new(
801 start + chrono::TimeDelta::milliseconds(250),
802 0.25,
803 0.0,
804 ));
805 }
806 report.trades.push(trade);
807 }
808 let mut invalid = report.trades[0].clone();
809 invalid.quantity = 0.0;
810 report.trades.insert(1, invalid);
811 let signals = report.signals();
812 assert_eq!(signals.len(), 5);
813 for (index, (signal, direction)) in signals
814 .iter()
815 .zip([
816 Direction::Up,
817 Direction::Down,
818 Direction::Down,
819 Direction::Up,
820 Direction::Down,
821 ])
822 .enumerate()
823 {
824 let entry = index == 0 || index == 2 || index == 4;
825 assert_eq!(
826 signal.shape,
827 SignalShape::Marker {
828 time: if entry {
829 start
830 } else {
831 start + chrono::TimeDelta::milliseconds(250)
832 },
833 price: Some(if entry { -0.00001234 } else { 0.25 }),
834 direction,
835 }
836 );
837 assert!(signal.tooltip.as_ref().unwrap().contains("Rule / close"));
838 }
839 assert_eq!(signals.last().unwrap().label.as_deref(), Some("Entry 4"));
840 assert_eq!(signals, report.signals(), "stable marker ids");
841 }
842
843 fn t0() -> DateTime<Utc> {
847 Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap()
849 }
850
851 fn report(capital: f64) -> StrategyReport {
852 StrategyReport::new("Test", "TEST", Timeframe::Hour1, capital)
853 }
854
855 fn curve(values: &[f64], step: TimeDelta) -> Vec<(DateTime<Utc>, f64)> {
856 let at = |i: usize| t0() + step * i as i32;
857 values
858 .iter()
859 .enumerate()
860 .map(|(i, &v)| (at(i), v))
861 .collect()
862 }
863
864 fn closed(profit: f64, fees: f64) -> StrategyTrade {
865 let mut trade = StrategyTrade::new(OrderSide::Buy, 1.0, t0(), 100.0);
866 trade.fees = fees;
867 trade.exit = Some(StrategyExit::new(t0() + TimeDelta::hours(1), 101.0, profit));
868 trade
869 }
870
871 fn fixture_a() -> StrategyReport {
873 let mut r = report(10_000.0);
874 let profits = [48.0, -11.0, 0.0, -21.0, 28.5];
875 let fees = [1.0, 0.5, 0.0, 2.0, 1.5];
876 r.trades = profits
877 .iter()
878 .zip(fees)
879 .map(|(&p, f)| closed(p, f))
880 .collect();
881 let mut open = StrategyTrade::new(OrderSide::Sell, 2.0, t0(), 100.0);
882 open.fees = 3.0;
883 r.trades.push(open);
884 r.equity = curve(
885 &[
886 10000.0, 10050.0, 10020.0, 10080.0, 9990.0, 10010.0, 10100.0, 10060.0, 10120.0,
887 10090.0,
888 ],
889 TimeDelta::hours(1),
890 );
891 r
892 }
893
894 fn expected_a() -> StrategyPerformance {
895 StrategyPerformance {
896 closed_trades: 5,
897 open_trades: 1,
898 winning_trades: 2,
899 losing_trades: 2,
900 net_profit: 44.5,
901 net_profit_fraction: Some(0.00445),
902 gross_profit: 76.5,
903 gross_loss: 32.0,
904 total_fees: 5.0,
905 profit_factor: Some(2.390625),
906 win_rate: Some(0.4),
907 average_trade: Some(8.9),
908 largest_winning_trade: Some(48.0),
909 largest_losing_trade: Some(-21.0),
910 max_consecutive_wins: 1,
911 max_consecutive_losses: 1,
912 max_drawdown: Some(90.0),
913 max_drawdown_fraction: Some(0.008928571428571428),
914 ..Default::default()
915 }
916 }
917
918 fn fixture_c(times: impl Iterator<Item = DateTime<Utc>>, rate: f64) -> StrategyReport {
920 let mut r = report(10_000.0);
921 r.risk_free_rate = rate;
922 r.equity = times
923 .enumerate()
924 .map(|(i, t)| (t, (10_000 + 3 * i + (37 * i) % 101) as f64))
925 .collect();
926 r
927 }
928
929 fn hourly_13_weeks() -> impl Iterator<Item = DateTime<Utc>> {
931 (0..2185).map(|h| t0() + TimeDelta::hours(h))
932 }
933
934 fn assert_matches(case: &str, actual: &StrategyPerformance, expected: &StrategyPerformance) {
935 let close = |a: f64, b: f64| a == b || (a - b).abs() <= 1e-12 * a.abs().max(b.abs());
936 let counts = |p: &StrategyPerformance| {
937 [
938 p.closed_trades,
939 p.open_trades,
940 p.winning_trades,
941 p.losing_trades,
942 p.skipped_trades,
943 p.skipped_equity_points,
944 p.max_consecutive_wins,
945 p.max_consecutive_losses,
946 ]
947 };
948 let sums =
949 |p: &StrategyPerformance| [p.net_profit, p.gross_profit, p.gross_loss, p.total_fees];
950 let options = |p: &StrategyPerformance| {
951 [
952 p.net_profit_fraction,
953 p.profit_factor,
954 p.win_rate,
955 p.average_trade,
956 p.max_drawdown,
957 p.max_drawdown_fraction,
958 p.sharpe_ratio,
959 p.sortino_ratio,
960 p.periods_per_year,
961 p.largest_winning_trade,
962 p.largest_losing_trade,
963 ]
964 };
965 let same = counts(actual) == counts(expected)
966 && sums(actual)
967 .iter()
968 .zip(sums(expected))
969 .all(|(&a, b)| close(a, b))
970 && options(actual)
971 .iter()
972 .zip(options(expected))
973 .all(|pair| match pair {
974 (Some(a), Some(b)) => close(*a, b),
975 (a, b) => a.is_none() && b.is_none(),
976 });
977 assert!(
978 same,
979 "{case}:\n actual {actual:?}\n expected {expected:?}"
980 );
981 }
982
983 #[test]
984 fn performance_matches_reference() {
985 let mut invalid = fixture_a();
986 let mut nan_profit = closed(5.0, 0.0);
987 nan_profit.exit.as_mut().unwrap().profit = f64::NAN;
988 let mut no_quantity = closed(5.0, 0.0);
989 no_quantity.quantity = 0.0;
990 let mut exit_before_entry = closed(5.0, 0.0);
991 exit_before_entry.entry_time = t0() + TimeDelta::hours(2);
992 invalid
993 .trades
994 .extend([nan_profit, no_quantity, exit_before_entry]);
995 invalid
996 .equity
997 .insert(5, (t0() + TimeDelta::minutes(270), f64::INFINITY));
998
999 let mut repeated_time = fixture_a();
1000 repeated_time.equity[5].0 = repeated_time.equity[4].0;
1001
1002 let mut fixture_b = report(10_000.0);
1003 fixture_b.equity = curve(&[100.0, 50.0, 1000.0, 900.0], TimeDelta::hours(1));
1004
1005 let weekdays = (0..)
1006 .map(|d| t0() + TimeDelta::days(d))
1007 .filter(|t| !matches!(t.weekday(), Weekday::Sat | Weekday::Sun))
1008 .take_while(|t| *t <= Utc.with_ymd_and_hms(2025, 1, 3, 0, 0, 0).unwrap());
1009 let mut weekday_report = fixture_c(weekdays, 0.0);
1010 weekday_report.timeframe = Timeframe::Day1;
1011
1012 let mut negative_equity = report(10_000.0);
1013 negative_equity.equity = curve(&[100.0, -50.0, 25.0, 30.0], TimeDelta::days(122));
1014
1015 let mut zero_start = report(10_000.0);
1016 zero_start.equity = curve(&[0.0, 10.0, 5.0], TimeDelta::hours(1));
1017
1018 let mut negative_capital = report(-1_000.0);
1019 negative_capital.trades.push(closed(5.0, 0.0));
1020
1021 let mut constant_growth = report(10_000.0);
1022 constant_growth.risk_free_rate = 0.02;
1023 let growth: Vec<f64> = (0..10).map(|i| 100.0 * 1.0001_f64.powf(i as f64)).collect();
1024 constant_growth.equity = curve(&growth, TimeDelta::days(41));
1025
1026 let mut non_finite = report(f64::NAN);
1028 non_finite.risk_free_rate = f64::NAN;
1029 non_finite.equity = vec![(t0(), f64::NAN)];
1030 let mut trade = closed(5.0, f64::NEG_INFINITY);
1031 trade.quantity = f64::INFINITY;
1032 trade.entry_price = f64::NAN;
1033 trade.exit.as_mut().unwrap().price = f64::NAN;
1034 non_finite.trades.push(trade);
1035
1036 let mut streaks = report(10_000.0);
1039 let chronological: Vec<_> = [5.0, 3.0, -1.0, -7.0, -2.0, 0.0, 4.0, 4.0, 0.0, 2.0]
1040 .into_iter()
1041 .enumerate()
1042 .map(|(i, p)| {
1043 let mut t = closed(p, 0.0);
1044 t.exit.as_mut().unwrap().time += TimeDelta::hours(i as i64);
1045 t
1046 })
1047 .collect();
1048 streaks.trades = [9, 1, 6, 3, 8, 0, 5, 2, 7, 4]
1049 .map(|i| chronological[i].clone())
1050 .to_vec();
1051 streaks
1052 .trades
1053 .push(StrategyTrade::new(OrderSide::Buy, 1.0, t0(), 100.0));
1054 let mut invalid_streak = closed(99.0, 0.0);
1055 invalid_streak.quantity = 0.0;
1056 streaks.trades.push(invalid_streak);
1057
1058 let no_curve = StrategyPerformance {
1059 max_drawdown: None,
1060 max_drawdown_fraction: None,
1061 ..expected_a()
1062 };
1063 let rows = [
1064 (
1065 "streaks ordered by exit, with breakevens, open and invalid trades",
1066 streaks,
1067 StrategyPerformance {
1068 closed_trades: 10,
1069 open_trades: 1,
1070 winning_trades: 5,
1071 losing_trades: 3,
1072 net_profit: 8.0,
1073 net_profit_fraction: Some(0.0008),
1074 gross_profit: 18.0,
1075 gross_loss: 10.0,
1076 profit_factor: Some(1.8),
1077 win_rate: Some(0.5),
1078 average_trade: Some(0.8),
1079 largest_winning_trade: Some(5.0),
1080 largest_losing_trade: Some(-7.0),
1081 max_consecutive_wins: 2,
1082 max_consecutive_losses: 3,
1083 skipped_trades: 1,
1084 ..Default::default()
1085 },
1086 ),
1087 ("A", fixture_a(), expected_a()),
1088 (
1089 "A with invalid trades and an infinite equity value",
1090 invalid,
1091 StrategyPerformance {
1092 skipped_trades: 3,
1093 skipped_equity_points: 1,
1094 ..expected_a()
1095 },
1096 ),
1097 (
1098 "A with a repeated timestamp",
1099 repeated_time,
1100 StrategyPerformance {
1101 skipped_equity_points: 10,
1102 ..no_curve
1103 },
1104 ),
1105 (
1106 "B",
1107 fixture_b,
1108 StrategyPerformance {
1109 net_profit_fraction: Some(0.0),
1110 max_drawdown: Some(100.0),
1111 max_drawdown_fraction: Some(0.5),
1112 ..Default::default()
1113 },
1114 ),
1115 (
1116 "C hourly over 13 weeks, rate 0.02",
1117 fixture_c(hourly_13_weeks(), 0.02),
1118 StrategyPerformance {
1119 net_profit_fraction: Some(0.0),
1120 max_drawdown: Some(82.0),
1121 max_drawdown_fraction: Some(0.008104368452263293),
1122 periods_per_year: Some(8766.0),
1123 sharpe_ratio: Some(5.864663773071857),
1124 sortino_ratio: Some(7.742250169261054),
1125 ..Default::default()
1126 },
1127 ),
1128 (
1129 "C weekdays over 367 days, rate 0",
1130 weekday_report,
1131 StrategyPerformance {
1132 net_profit_fraction: Some(0.0),
1133 max_drawdown: Some(82.0),
1134 max_drawdown_fraction: Some(0.008104368452263293),
1135 periods_per_year: Some(261.74591280653954),
1136 sharpe_ratio: Some(1.08244877567277),
1137 sortino_ratio: Some(1.433886945862868),
1138 ..Default::default()
1139 },
1140 ),
1141 (
1142 "C hourly, rate -1",
1143 fixture_c(hourly_13_weeks(), -1.0),
1144 StrategyPerformance {
1145 net_profit_fraction: Some(0.0),
1146 max_drawdown: Some(82.0),
1147 max_drawdown_fraction: Some(0.008104368452263293),
1148 periods_per_year: Some(8766.0),
1149 ..Default::default()
1150 },
1151 ),
1152 (
1153 "equity below zero",
1154 negative_equity,
1155 StrategyPerformance {
1156 net_profit_fraction: Some(0.0),
1157 max_drawdown: Some(150.0),
1158 max_drawdown_fraction: Some(1.5),
1159 periods_per_year: Some(2.9938524590163933),
1160 ..Default::default()
1161 },
1162 ),
1163 (
1164 "equity starting at zero",
1165 zero_start,
1166 StrategyPerformance {
1167 net_profit_fraction: Some(0.0),
1168 max_drawdown: Some(5.0),
1169 ..Default::default()
1170 },
1171 ),
1172 (
1173 "one winning trade, negative capital",
1174 negative_capital,
1175 StrategyPerformance {
1176 closed_trades: 1,
1177 winning_trades: 1,
1178 net_profit: 5.0,
1179 gross_profit: 5.0,
1180 win_rate: Some(1.0),
1181 average_trade: Some(5.0),
1182 largest_winning_trade: Some(5.0),
1183 max_consecutive_wins: 1,
1184 ..Default::default()
1185 },
1186 ),
1187 (
1188 "constant growth, rate 0.02",
1189 constant_growth,
1190 StrategyPerformance {
1191 net_profit_fraction: Some(0.0),
1192 max_drawdown: Some(0.0),
1193 max_drawdown_fraction: Some(0.0),
1194 periods_per_year: Some(8.908536585365853),
1195 ..Default::default()
1196 },
1197 ),
1198 (
1199 "non-finite capital, rate, trade fields and equity",
1200 non_finite,
1201 StrategyPerformance {
1202 skipped_trades: 1,
1203 skipped_equity_points: 1,
1204 ..Default::default()
1205 },
1206 ),
1207 (
1208 "empty",
1209 report(10_000.0),
1210 StrategyPerformance {
1211 net_profit_fraction: Some(0.0),
1212 ..Default::default()
1213 },
1214 ),
1215 ];
1216 for (case, report, expected) in rows {
1217 assert_matches(case, &report.performance(), &expected);
1218 let loaded: StrategyReport =
1219 serde_json::from_str(&serde_json::to_string(&report).unwrap())
1220 .unwrap_or_else(|e| panic!("{case}: {e}"));
1221 assert_matches(
1222 &format!("{case}, saved and loaded"),
1223 &loaded.performance(),
1224 &expected,
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn comparison_matches_source_bar_reference() {
1231 let bars = [
1233 (100.0, 115.0, 95.0, 110.0),
1234 (110.0, 112.0, 85.0, 90.0),
1235 (90.0, 125.0, 88.0, 120.0),
1236 ]
1237 .into_iter()
1238 .enumerate()
1239 .map(|(i, (o, h, l, c))| {
1240 super::super::Bar::new(t0() + TimeDelta::hours(i as i64), o, h, l, c, 1.0)
1241 })
1242 .collect::<Vec<_>>();
1243 let mut r = report(1_000.0);
1244 let mut long = StrategyTrade::new(OrderSide::Buy, 2.0, t0(), 100.0);
1245 long.exit = Some(StrategyExit::new(t0() + TimeDelta::hours(2), 90.0, -20.0));
1246 let mut short = long.clone();
1247 short.side = OrderSide::Sell;
1248 short.entry_price = 110.0;
1249 short.quantity = 3.0;
1250 let open = StrategyTrade::new(OrderSide::Buy, 1.0, t0() + TimeDelta::hours(2), 90.0);
1251 let mut invalid = long.clone();
1252 invalid.quantity = 0.0;
1253 r.trades = vec![long, short, open, invalid];
1254 let mut sessions = bars.clone();
1255 sessions[2].time += TimeDelta::days(3);
1256 for (case, source, skipped, expected_return, expected_excursions) in [
1257 (
1258 "complete",
1259 bars.clone(),
1260 0,
1261 Some(0.2),
1262 vec![
1263 Some((30.0, 30.0)),
1264 Some((15.0, 75.0)),
1265 Some((2.0, 35.0)),
1266 None,
1267 ],
1268 ),
1269 (
1270 "session gap does not change price-based results",
1271 sessions,
1272 0,
1273 Some(0.2),
1274 vec![
1275 Some((30.0, 30.0)),
1276 Some((15.0, 75.0)),
1277 Some((2.0, 35.0)),
1278 None,
1279 ],
1280 ),
1281 ("empty", vec![], 0, None, vec![None; 4]),
1282 (
1283 "unordered",
1284 vec![bars[1].clone(), bars[0].clone()],
1285 2,
1286 None,
1287 vec![None; 4],
1288 ),
1289 (
1290 "duplicate",
1291 vec![bars[0].clone(), bars[0].clone()],
1292 2,
1293 None,
1294 vec![None; 4],
1295 ),
1296 ] {
1297 let c = r.comparison(&source);
1298 assert_eq!(c.skipped_bars, skipped, "{case}");
1299 assert!(
1300 match (c.buy_and_hold_return, expected_return) {
1301 (Some(a), Some(b)) => (a - b).abs() < 1e-12,
1302 (a, b) => a == b,
1303 },
1304 "{case}"
1305 );
1306 assert_eq!(
1307 c.excursions
1308 .iter()
1309 .map(|e| e.as_ref().map(|e| (e.adverse, e.favourable)))
1310 .collect::<Vec<_>>(),
1311 expected_excursions,
1312 "{case}"
1313 );
1314 if expected_return.is_some() {
1315 assert_eq!(
1316 c.buy_and_hold_equity
1317 .iter()
1318 .map(|p| p.1)
1319 .collect::<Vec<_>>(),
1320 [1100.0, 900.0, 1200.0]
1321 );
1322 } else {
1323 assert!(c.buy_and_hold_equity.is_empty());
1324 }
1325 }
1326 let mut invalid_bars = bars.clone();
1327 invalid_bars[1].high = f64::NAN;
1328 let c = r.comparison(&invalid_bars);
1329 assert_eq!(c.skipped_bars, 1);
1330 assert_eq!(c.excursions[0], None); for capital in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1332 r.initial_capital = capital;
1333 assert!(r.comparison(&bars).buy_and_hold_equity.is_empty());
1334 }
1335 let mut zero = bars.clone();
1336 zero[0].open = 0.0;
1337 zero[0].low = 0.0;
1338 assert_eq!(r.comparison(&zero).buy_and_hold_return, None);
1339
1340 for (case, entry_minutes, exit_minutes, expected) in [
1343 ("intrabar round trip", 30, 45, Some((10.0, 30.0))),
1344 ("intrabar entry across bars", 30, 120, Some((30.0, 30.0))),
1345 ("exit at the next open", 30, 60, Some((10.0, 30.0))),
1346 ("entry at the next open", 60, 120, Some((30.0, 24.0))),
1347 ("entry after the source ends", 240, 300, None),
1348 ] {
1349 let mut r = report(1_000.0);
1350 let mut t = StrategyTrade::new(
1351 OrderSide::Buy,
1352 2.0,
1353 t0() + TimeDelta::minutes(entry_minutes),
1354 100.0,
1355 );
1356 t.exit = Some(StrategyExit::new(
1357 t0() + TimeDelta::minutes(exit_minutes),
1358 100.0,
1359 0.0,
1360 ));
1361 r.trades.push(t);
1362 assert_eq!(
1363 r.comparison(&bars).excursions[0]
1364 .as_ref()
1365 .map(|e| (e.adverse, e.favourable)),
1366 expected,
1367 "{case}"
1368 );
1369 }
1370 for (timeframe, entry) in [
1371 (Timeframe::Ms500, TimeDelta::milliseconds(250)),
1372 (Timeframe::Month1, TimeDelta::days(30)),
1373 ("3month".parse().unwrap(), TimeDelta::days(90)),
1374 (
1375 Timeframe::Ticks(std::num::NonZeroU64::new(10).unwrap()),
1376 TimeDelta::minutes(30),
1377 ),
1378 ] {
1379 let mut r = report(1_000.0);
1380 r.timeframe = timeframe;
1381 let mut t = StrategyTrade::new(OrderSide::Buy, 2.0, t0() + entry, 100.0);
1382 t.exit = Some(StrategyExit::new(
1383 t0() + entry + TimeDelta::milliseconds(1),
1384 100.0,
1385 0.0,
1386 ));
1387 r.trades.push(t);
1388 assert_eq!(
1389 r.comparison(&bars[..1]).excursions[0],
1390 Some(StrategyExcursion {
1391 adverse: 10.0,
1392 favourable: 30.0,
1393 }),
1394 "{timeframe} entry bar"
1395 );
1396 }
1397 }
1398
1399 #[test]
1400 #[cfg(feature = "csv")]
1401 fn trades_csv_writes_valid_trades_in_entry_order() {
1402 let mut r = report(10_000.0);
1403 let mut late = StrategyTrade::new(OrderSide::Sell, 2.0, t0() + TimeDelta::hours(5), 101.5);
1404 late.label = Some("Breakout, short".into());
1405 let mut first = closed(-1.25, 0.25);
1406 first.entry_time = t0() + TimeDelta::hours(1);
1407 first.exit.as_mut().unwrap().time = t0() + TimeDelta::hours(2);
1408 let mut second = closed(3.0, 0.5);
1409 second.entry_time = first.entry_time;
1410 second.exit.as_mut().unwrap().time = t0() + TimeDelta::hours(3);
1411 let mut invalid = closed(1.0, 0.0);
1412 invalid.quantity = -1.0;
1413 r.trades = vec![late, invalid, first, second];
1414 assert_eq!(
1415 r.trades_csv(),
1416 "number,side,quantity,entry_time,entry_price,exit_time,exit_price,profit,fees,label\n\
1417 1,long,1,2024-01-02T01:00:00Z,100,2024-01-02T02:00:00Z,101,-1.25,0.25,\n\
1418 2,long,1,2024-01-02T01:00:00Z,100,2024-01-02T03:00:00Z,101,3,0.5,\n\
1419 3,short,2,2024-01-02T05:00:00Z,101.5,,,,0,\"Breakout, short\"\n"
1420 );
1421 for (label, expected) in [
1422 ("=1+1", "'=1+1"),
1423 ("+SUM(1,2)", "'+SUM(1,2)"),
1424 ("-1+2", "'-1+2"),
1425 ("@SUM(A1)", "'@SUM(A1)"),
1426 ("\t=1", "'\t=1"),
1427 ("\r=1", "'\r=1"),
1428 ("ordinary, \"quoted\"\nline", "ordinary, \"quoted\"\nline"),
1429 ("", ""),
1430 ("λ=1", "λ=1"),
1431 ("'=already text", "'=already text"),
1432 ] {
1433 let mut trade = closed(-1.25, -0.25);
1434 trade.entry_price = -100.0;
1435 trade.exit.as_mut().unwrap().price = -101.0;
1436 trade.label = Some(label.into());
1437 r.trades = vec![trade];
1438 let csv = r.trades_csv();
1439 let row = csv::Reader::from_reader(csv.as_bytes())
1440 .records()
1441 .next()
1442 .unwrap()
1443 .unwrap();
1444 assert_eq!(&row[9], expected, "{label:?}");
1445 for (column, expected) in [(4, "-100"), (6, "-101"), (7, "-1.25"), (8, "-0.25")] {
1446 assert_eq!(&row[column], expected, "numeric column {column}");
1447 }
1448 }
1449 for (year, expected) in [
1450 (-1, "'-0001-01-02T00:00:00Z"),
1451 (10_000, "'+10000-01-02T00:00:00Z"),
1452 ] {
1453 let time = Utc.with_ymd_and_hms(year, 1, 2, 0, 0, 0).unwrap();
1454 let mut trade = StrategyTrade::new(OrderSide::Buy, 1.0, time, 100.0);
1455 trade.exit = Some(StrategyExit::new(time, 100.0, 0.0));
1456 r.trades = vec![trade];
1457 let csv = r.trades_csv();
1458 let row = csv::Reader::from_reader(csv.as_bytes())
1459 .records()
1460 .next()
1461 .unwrap()
1462 .unwrap();
1463 assert_eq!(&row[3], expected);
1464 assert_eq!(&row[5], expected);
1465 }
1466 }
1467
1468 #[test]
1469 #[cfg(feature = "csv")]
1470 fn summary_csv_writes_metrics_and_properties_with_unavailable_values() {
1471 let mut r = fixture_a();
1472 r.currency = Some("USD".into());
1473 r.properties
1474 .push(("Fast, length".into(), "say \"hi\"".into()));
1475 assert_eq!(
1476 r.summary_csv(&r.performance(), &r.comparison(&[])),
1477 "section,name,value,unit,currency\n\
1478 summary,Net profit,44.5,money,USD\n\
1479 summary,Net return,0.445,percent,\n\
1480 summary,Gross profit,76.5,money,USD\n\
1481 summary,Gross loss,32,money,USD\n\
1482 summary,Profit factor,2.390625,ratio,\n\
1483 summary,Win rate,40,percent,\n\
1484 summary,Average trade,8.9,money,USD\n\
1485 summary,Fees,5,money,USD\n\
1486 summary,Maximum drawdown,90,money,USD\n\
1487 summary,Maximum drawdown %,0.892857142857143,percent,\n\
1488 summary,Sharpe ratio,,ratio,\n\
1489 summary,Sortino ratio,,ratio,\n\
1490 summary,Buy-and-hold return,,percent,\n\
1491 summary,Largest winning trade,48,money,USD\n\
1492 summary,Largest losing trade,-21,money,USD\n\
1493 summary,Consecutive wins,1,count,\n\
1494 summary,Consecutive losses,1,count,\n\
1495 summary,Closed trades,5,count,\n\
1496 summary,Open trades,1,count,\n\
1497 summary,Skipped trades,0,count,\n\
1498 summary,Skipped equity points,0,count,\n\
1499 summary,Skipped source bars,0,count,\n\
1500 properties,Name,Test,text,\n\
1501 properties,Symbol,TEST,text,\n\
1502 properties,Interval,1h,text,\n\
1503 properties,Initial capital,10000,money,USD\n\
1504 properties,Currency,USD,text,\n\
1505 properties,Risk-free rate,0,percent,\n\
1506 properties,\"Fast, length\",\"say \"\"hi\"\"\",text,\n"
1507 );
1508 for (text, expected) in [
1509 ("=1+1", "'=1+1"),
1510 ("+SUM(1,2)", "'+SUM(1,2)"),
1511 ("-1+2", "'-1+2"),
1512 ("@SUM(A1)", "'@SUM(A1)"),
1513 ("\t=1", "'\t=1"),
1514 ("\r=1", "'\r=1"),
1515 ("ordinary, \"quoted\"\nline", "ordinary, \"quoted\"\nline"),
1516 ("", ""),
1517 ("λ=1", "λ=1"),
1518 ("'=already text", "'=already text"),
1519 ] {
1520 let mut r = report(1000.0);
1521 r.name = text.into();
1522 r.symbol = text.into();
1523 r.currency = Some(text.into());
1524 r.properties.push((text.into(), text.into()));
1525 r.trades.push(closed(-1.25, -0.25));
1526 let csv = r.summary_csv(&r.performance(), &r.comparison(&[]));
1527 let rows: Vec<_> = csv::Reader::from_reader(csv.as_bytes())
1528 .records()
1529 .map(Result::unwrap)
1530 .collect();
1531 for name in ["Name", "Symbol", "Currency"] {
1532 let row = rows.iter().find(|row| &row[1] == name).unwrap();
1533 assert_eq!(&row[2], expected, "{name}: {text:?}");
1534 }
1535 let property = rows.last().unwrap();
1536 assert_eq!(&property[1], expected, "property name: {text:?}");
1537 assert_eq!(&property[2], expected, "property value: {text:?}");
1538 for row in rows.iter().filter(|row| &row[3] == "money") {
1539 assert_eq!(&row[4], expected, "currency: {text:?}");
1540 }
1541 let loss = rows.iter().find(|row| &row[1] == "Net profit").unwrap();
1542 assert_eq!(&loss[2], "-1.25");
1543 let fees = rows.iter().find(|row| &row[1] == "Fees").unwrap();
1544 assert_eq!(&fees[2], "-0.25");
1545 }
1546 for (fraction, expected) in [
1548 (0.07, "7"),
1549 (0.0725, "7.25"),
1550 (-0.0, "0"),
1551 (-0.0725, "-7.25"),
1552 (1e-20, "0.000000000000000001"),
1553 (1.0 / 3.0, "33.3333333333333"),
1554 (f64::NAN, ""),
1555 (f64::INFINITY, ""),
1556 (f64::MAX, ""),
1557 ] {
1558 r.risk_free_rate = fraction;
1559 let mut p = r.performance();
1560 p.net_profit_fraction = Some(fraction);
1561 p.win_rate = Some(fraction);
1562 p.max_drawdown_fraction = Some(fraction);
1563 let mut c = r.comparison(&[]);
1564 c.buy_and_hold_return = Some(fraction);
1565 let csv = r.summary_csv(&p, &c);
1566 let rows: Vec<_> = csv::Reader::from_reader(csv.as_bytes())
1567 .records()
1568 .map(Result::unwrap)
1569 .collect();
1570 for row in rows.iter().filter(|row| &row[3] == "percent") {
1571 assert_eq!(&row[2], expected, "{}: {fraction}", &row[1]);
1572 }
1573 }
1574 r.risk_free_rate = f64::MAX / 100.0;
1576 let csv = r.summary_csv(&r.performance(), &r.comparison(&[]));
1577 let rate = csv::Reader::from_reader(csv.as_bytes())
1578 .records()
1579 .map(Result::unwrap)
1580 .find(|row| &row[1] == "Risk-free rate")
1581 .unwrap();
1582 assert_eq!(rate[2].parse::<f64>().unwrap(), f64::MAX);
1583 r.initial_capital = -0.0;
1584 let p = StrategyPerformance {
1585 net_profit: -0.0,
1586 gross_profit: -0.0,
1587 gross_loss: -0.0,
1588 total_fees: -0.0,
1589 profit_factor: Some(-0.0),
1590 average_trade: Some(-0.0),
1591 largest_winning_trade: Some(-0.0),
1592 largest_losing_trade: Some(-0.0),
1593 max_drawdown: Some(-0.0),
1594 sharpe_ratio: Some(-0.0),
1595 sortino_ratio: Some(-0.0),
1596 ..Default::default()
1597 };
1598 let csv = r.summary_csv(&p, &StrategyComparison::default());
1599 for row in csv::Reader::from_reader(csv.as_bytes()).records() {
1600 let row = row.unwrap();
1601 if matches!(&row[3], "money" | "ratio") {
1602 assert_eq!(&row[2], "0", "{}", &row[1]);
1603 }
1604 }
1605 let empty = report(0.0);
1607 let csv = empty.summary_csv(&empty.performance(), &empty.comparison(&[]));
1608 assert!(csv.contains("summary,Win rate,,percent,\n"), "{csv}");
1609 assert!(csv.contains("summary,Net profit,0,money,\n"), "{csv}");
1610 assert!(csv.contains("properties,Currency,,text,\n"), "{csv}");
1611 }
1612}