Skip to main content

egui_charts/model/
strategy.rs

1//! The result of a strategy run, and the performance metrics computed from it.
2//!
3//! A host runs its strategy wherever it likes (a backtest against the headless engine, a forward
4//! test, its own analytics) and describes the result as a [`StrategyReport`]: the trades and the
5//! equity curve. The library computes every metric itself with [`StrategyReport::performance`], with
6//! one definition each, so reports from different sources compare.
7
8use super::{Bar, OrderSide, Timeframe};
9#[cfg(feature = "csv")]
10use chrono::SecondsFormat;
11use chrono::{DateTime, TimeDelta, Utc};
12use serde::{Deserialize, Deserializer, Serialize};
13
14/// A year of 365.25 days, in milliseconds.
15const YEAR_MS: f64 = 31_557_600_000.0;
16
17/// Keep spreadsheet formula prefixes literal; CSV quoting alone does not prevent evaluation.
18#[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
27/// Reads none as NaN: serde_json writes a non-finite number as `null`, which a plain `f64` refuses.
28/// [`StrategyReport::performance`] skips a NaN like any non-finite value.
29fn 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
33/// [`null_as_nan`] for the values of an equity curve.
34fn 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/// The result of one strategy run, from any source: a backtest the host runs against the headless
45/// engine, a forward test, or the host's own analytics. Build it with [`StrategyReport::new`] and set
46/// the remaining fields directly; [`StrategyReport::performance`] computes the metrics.
47///
48/// # Example
49///
50/// ```
51/// use chrono::{TimeZone, Utc};
52/// use egui_charts::model::{StrategyExit, StrategyReport, OrderSide, StrategyTrade, Timeframe};
53///
54/// let hour = |h| Utc.with_ymd_and_hms(2024, 1, 2, h, 0, 0).unwrap();
55/// let mut report = StrategyReport::new("SMA cross", "AAPL", Timeframe::Hour1, 10_000.0);
56/// report.currency = Some("USD".into());
57/// report.properties.push(("Fast length".into(), "10".into()));
58///
59/// // The host computes each trade's net profit: here (190 - 185) * 10 shares less 2 of fees.
60/// let mut trade = StrategyTrade::new(OrderSide::Buy, 10.0, hour(1), 185.0);
61/// trade.fees = 2.0;
62/// trade.exit = Some(StrategyExit::new(hour(3), 190.0, 48.0));
63/// report.trades.push(trade);
64/// report.equity = vec![(hour(0), 10_000.0), (hour(1), 9_998.0), (hour(2), 10_030.0), (hour(3), 10_048.0)];
65///
66/// let performance = report.performance();
67/// assert_eq!(performance.net_profit, 48.0);
68/// assert_eq!(performance.win_rate, Some(1.0));
69/// assert_eq!(performance.max_drawdown, Some(2.0));
70/// // Three hours of equity are too short to annualise.
71/// assert_eq!(performance.sharpe_ratio, None);
72/// # #[cfg(feature = "csv")]
73/// assert!(report.trades_csv().starts_with("number,side,quantity,"));
74/// ```
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct StrategyReport {
78    /// What the run is called, as the host names it.
79    pub name: String,
80    /// The instrument the trades are in.
81    pub symbol: String,
82    /// The bar interval the run used.
83    pub timeframe: Timeframe,
84    /// Account value at the start, in `currency`. Positive and finite, or
85    /// [`StrategyPerformance::net_profit_fraction`] is unavailable.
86    #[serde(deserialize_with = "null_as_nan")]
87    pub initial_capital: f64,
88    /// Currency of capital, profits, fees and equity, such as "USD". Display only.
89    #[serde(default)]
90    pub currency: Option<String>,
91    /// Annual risk-free rate as a fraction (0.02 for 2%), used by the Sharpe and Sortino ratios.
92    /// Finite and above -1, or the ratios are unavailable.
93    #[serde(default, deserialize_with = "null_as_nan")]
94    pub risk_free_rate: f64,
95    /// Settings of the run as the host wants them shown (parameters, commission, sizing), in order.
96    /// Display only.
97    #[serde(default)]
98    pub properties: Vec<(String, String)>,
99    /// Every trade, closed or open, in any order.
100    #[serde(default)]
101    pub trades: Vec<StrategyTrade>,
102    /// Account value marked to market at each bar close, oldest first, one point per bar, in
103    /// strictly increasing time order.
104    #[serde(default, deserialize_with = "curve_null_as_nan")]
105    pub equity: Vec<(DateTime<Utc>, f64)>,
106}
107
108/// One round trip. A host that scales out reports each partial exit as its own trade with its share
109/// of the entry.
110///
111/// A trade is valid when `quantity` is finite and positive, `entry_price` and `fees` are finite, and,
112/// if closed, the exit price and profit are finite and the exit is not before the entry. Prices may
113/// be zero or negative. Invalid trades are left out of the metrics and the CSV, and counted in
114/// [`StrategyPerformance::skipped_trades`].
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[non_exhaustive]
117pub struct StrategyTrade {
118    /// Long or short.
119    pub side: OrderSide,
120    /// Size of the position: positive and finite.
121    #[serde(deserialize_with = "null_as_nan")]
122    pub quantity: f64,
123    /// When the position was opened.
124    pub entry_time: DateTime<Utc>,
125    /// Average fill price of the entry.
126    #[serde(deserialize_with = "null_as_nan")]
127    pub entry_price: f64,
128    /// `None` while the trade is open.
129    #[serde(default)]
130    pub exit: Option<StrategyExit>,
131    /// Commission and other costs of entry and exit, in the report's currency. Already included in
132    /// the exit's profit.
133    #[serde(default, deserialize_with = "null_as_nan")]
134    pub fees: f64,
135    /// The host's name for the entry, such as the rule that opened it. Display only.
136    #[serde(default)]
137    pub label: Option<String>,
138}
139
140/// How and when a trade was closed.
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct StrategyExit {
144    /// When the position was closed.
145    pub time: DateTime<Utc>,
146    /// Average fill price of the exit.
147    #[serde(deserialize_with = "null_as_nan")]
148    pub price: f64,
149    /// Net profit of the trade after fees, in the report's currency, as the host computed it: the
150    /// library does not model point values, inverse contracts or currency conversion.
151    #[serde(deserialize_with = "null_as_nan")]
152    pub profit: f64,
153}
154
155/// Metrics of a [`StrategyReport`], from [`StrategyReport::performance`].
156///
157/// Fractions throughout: 0.4 is a 40% win rate. A metric that is undefined for the run is `None`,
158/// never 0 or infinity, and no `Option` metric is ever NaN or infinite. The sums add finite values and
159/// are finite unless they exceed the range of `f64`.
160///
161/// Trade metrics use the closed valid trades, with `p` the net profit of each. Curve metrics use the
162/// usable equity curve: the points with a finite value, provided they are in strictly increasing time
163/// order. A curve out of order is unusable as a whole, so the curve metrics are `None` rather than
164/// computed on part of it.
165#[derive(Clone, Debug, Default, PartialEq)]
166#[non_exhaustive]
167pub struct StrategyPerformance {
168    /// Valid trades with an exit.
169    pub closed_trades: usize,
170    /// Valid trades without an exit. They enter no trade metric; their value is in the equity curve.
171    pub open_trades: usize,
172    /// Closed trades with `p > 0`.
173    pub winning_trades: usize,
174    /// Closed trades with `p < 0`. A breakeven trade is neither a winner nor a loser.
175    pub losing_trades: usize,
176    /// Sum of `p`; 0 without closed trades.
177    pub net_profit: f64,
178    /// `net_profit / initial_capital`. `None` when the initial capital is not positive and finite.
179    pub net_profit_fraction: Option<f64>,
180    /// Sum of `p` over the winners.
181    pub gross_profit: f64,
182    /// Sum of `-p` over the losers, a positive number.
183    pub gross_loss: f64,
184    /// Sum of the fees of the closed trades, already included in `net_profit`.
185    pub total_fees: f64,
186    /// `gross_profit / gross_loss`. `None` without a losing trade.
187    pub profit_factor: Option<f64>,
188    /// `winning_trades / closed_trades`, breakeven trades included in the denominator. `None` without
189    /// closed trades.
190    pub win_rate: Option<f64>,
191    /// `net_profit / closed_trades`. `None` without closed trades.
192    pub average_trade: Option<f64>,
193    /// Largest positive net profit, or `None` without a winning trade.
194    pub largest_winning_trade: Option<f64>,
195    /// Most negative net profit, or `None` without a losing trade.
196    pub largest_losing_trade: Option<f64>,
197    /// Longest winning run in exit-time order; ties keep input order. Breakeven trades break runs;
198    /// open and invalid trades do not participate.
199    pub max_consecutive_wins: usize,
200    /// Longest losing run, with the same ordering and breaks as `max_consecutive_wins`.
201    pub max_consecutive_losses: usize,
202    /// The largest fall of equity from its highest earlier point, in money, over the curve from its
203    /// first point. `None` without a usable equity point.
204    pub max_drawdown: Option<f64>,
205    /// The largest fall of equity as a fraction of its highest earlier point, found separately from
206    /// `max_drawdown`, so the two can come from different points. Above 1 when equity falls below zero.
207    /// `None` without a usable equity point or when the first point is not positive.
208    pub max_drawdown_fraction: Option<f64>,
209    /// Annualised Sharpe ratio: the mean excess return per step of the curve over the sample standard
210    /// deviation of the returns, times the square root of `periods_per_year`. Returns are simple
211    /// returns between consecutive usable points, and the excess is over the risk-free rate per step,
212    /// `(1 + risk_free_rate)^(1 / periods_per_year) - 1`. `None` when `periods_per_year` is, with
213    /// fewer than 2 returns, when the returns do not vary beyond rounding (a deviation of at most 16
214    /// machine epsilons, as for a curve growing at a constant rate), when equity before the last
215    /// point is not positive, or when the risk-free rate is not finite or not above -1.
216    pub sharpe_ratio: Option<f64>,
217    /// Annualised Sortino ratio: as `sharpe_ratio`, over the downside deviation instead, the root mean
218    /// square of the shortfalls of the returns below the risk-free rate per step, taken over all
219    /// returns. `None` when `sharpe_ratio` is, or when no return falls short.
220    pub sortino_ratio: Option<f64>,
221    /// Steps of the usable curve per year: the number of returns over the span from the first to the
222    /// last point, in years of 365.25 days. Counting the bars a run actually contains follows the
223    /// interval and the market's sessions without configuration.
224    ///
225    /// `None` unless the curve spans at least 13 weeks. The span leaves out the closed time before
226    /// the first point and after the last, so a short run overstates the periods per year of a
227    /// market with sessions: two sessions of 1-minute bars of a stock count about 224,000 a year
228    /// instead of about 98,000, and the ratios come out about 1.5 times too high. Thirteen weeks
229    /// contain every weekly closed period (nights, weekends) many times over, so from 13 weeks on the
230    /// estimate is at most about 6% high, and the ratios about 3%, a small fraction of the sampling
231    /// error of a ratio measured over a quarter.
232    pub periods_per_year: Option<f64>,
233    /// Invalid trades, left out of every metric (see [`StrategyTrade`]).
234    pub skipped_trades: usize,
235    /// Equity points with a non-finite value, or every point when the curve is not in strictly
236    /// increasing time order.
237    pub skipped_equity_points: usize,
238}
239
240/// Source-bar comparison of a run, from [`StrategyReport::comparison`].
241#[derive(Clone, Debug, Default, PartialEq)]
242#[non_exhaustive]
243pub struct StrategyComparison {
244    /// Fee-free fractional return: last close / first open - 1. Requires a positive first open.
245    pub buy_and_hold_return: Option<f64>,
246    /// Initial capital invested at the first open, marked at each bar close. Times are the source
247    /// bar timestamps, as in a chart. Empty without positive finite capital or a defined return.
248    pub buy_and_hold_equity: Vec<(DateTime<Utc>, f64)>,
249    /// One result per input trade, in input order. `None` for an invalid trade, absent source bars,
250    /// or an invalid bar during the trade. See [`StrategyExcursion`] for the bar-resolution limits.
251    pub excursions: Vec<Option<StrategyExcursion>>,
252    /// Invalid OHLC bars, or all bars when timestamps are not strictly increasing.
253    pub skipped_bars: usize,
254}
255
256/// Largest unrealised loss and gain relative to a trade's entry, before fees.
257///
258/// Both are nonnegative price movements times quantity, for a linear contract with unit point
259/// value. They need conversion for other contracts or currencies. Entry and exit fills participate.
260/// Source highs and lows participate from the bar containing entry through bars opening strictly
261/// before exit; open trades use all remaining bars. At intrabar fills these are whole-bar estimates:
262/// OHLC cannot distinguish movement before entry or after exit. Time bars end after their interval
263/// (calendar months for monthly bars); execution bars end at the next source open, with the final
264/// bar assumed to cover the run's remaining fills. Use fills at bar boundaries for exact spans.
265#[derive(Clone, Debug, PartialEq)]
266#[non_exhaustive]
267pub struct StrategyExcursion {
268    /// Maximum adverse excursion, as a positive loss magnitude.
269    pub adverse: f64,
270    /// Maximum favourable excursion, as a positive gain magnitude.
271    pub favourable: f64,
272}
273
274impl StrategyReport {
275    /// Labelled entry and exit markers for the host's signals service. Invalid trades are skipped;
276    /// open trades have only an entry. Directions describe the fill side, including short exits.
277    /// Ids remain stable for this report's original trade indices. When replacing a run, remove
278    /// its old signals before publishing the replacement, including when it has no trades.
279    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    /// A report with no trades, no equity, no properties, no currency and a risk-free rate of 0.
333    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    /// Computes the metrics of the run. Sorts closed trades for streaks; call once per report,
353    /// not once per frame.
354    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        // Simple returns from a non-positive equity have no meaning, and a rate of -1 or below has no
426        // per-step equivalent.
427        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        // Returns that are equal up to rounding do not vary.
444        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    /// Finite equity points in strictly increasing time order, using the same validation as
453    /// [`Self::performance`]. Empty if the finite points are out of order.
454    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    /// Computes buy-and-hold and excursions from the complete source bars of this run's symbol and
469    /// interval, oldest first. The host supplies exactly the run's bars, including every held bar;
470    /// this method neither fetches data nor infers missing sessions. Bar times denote opens.
471    ///
472    /// Non-finite or inverted OHLC bars are skipped and counted. Unordered or duplicate timestamps
473    /// make the whole source unusable. Volume is unused. Buy-and-hold uses the first valid open and
474    /// last valid close, with fractional units, no fees or reinvested dividends. A non-finite
475    /// calculation is unavailable. Excursions require at least one held source bar; invalid held
476    /// bars make that trade's excursion unavailable rather than understating risk.
477    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    /// The valid trades as CSV, sorted by entry time (trades entered at the same time keep their
563    /// order), one header line and one line per trade, each ending with `\n`.
564    ///
565    /// Columns: `number` (from 1, in this order), `side` (`long` or `short`), `quantity`,
566    /// `entry_time`, `entry_price`, `exit_time`, `exit_price`, `profit`, `fees`, `label`. Times are
567    /// RFC 3339 in UTC (`2024-01-02T00:00:00Z`), numbers use the shortest form that reads back to the
568    /// same value, and an open trade leaves the three exit columns empty.
569    /// Text cells starting with `=`, `+`, `-`, `@`, tab or carriage return gain a leading apostrophe
570    /// so spreadsheets treat them as text. Numeric cells, including negative numbers, are unchanged.
571    #[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    /// The summary metrics and the properties of the run as CSV, one header line and one line per
623    /// metric or property, each ending with `\n`. `performance` and `comparison` are this report's
624    /// [`Self::performance`] and [`Self::comparison`], the figures the report panel shows.
625    ///
626    /// Columns: `section` (`summary` or `properties`), `name` (as the panel labels it), `value`,
627    /// `unit` and `currency`. The unit is `money` (in `currency`, empty when the report has none),
628    /// `percent` (100 is 100%), `ratio`, `count` or `text`. Percent values round to 15 significant
629    /// decimal digits to suppress binary multiplication noise; if rounding would overflow `f64`,
630    /// the unrounded finite value is kept. Numbers use the shortest form that reads back to the
631    /// resulting value, with a dot as the decimal separator and no thousands separator. Every
632    /// numeric zero is `0`, and an unavailable value is an empty cell, so numeric columns stay
633    /// numeric in a spreadsheet.
634    ///
635    /// Text cells starting with `=`, `+`, `-`, `@`, tab or carriage return gain a leading apostrophe
636    /// so spreadsheets treat them as text. This also applies to property names and currencies;
637    /// numeric cells, including negative numbers, are unchanged by this protection.
638    #[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" { &currency } 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    /// An open trade with no fees and no label.
742    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    /// Whether this trade meets the validity rules documented on [`StrategyTrade`].
760    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    /// An exit at `time` and `price` with the trade's net `profit` after fees.
773    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    // Reference values below are computed independently from the definitions documented on
844    // `StrategyPerformance` in double precision, not with this code.
845
846    fn t0() -> DateTime<Utc> {
847        // A Tuesday.
848        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    /// Fixture A: one breakeven trade, one open trade, ten hourly equity points.
872    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    /// Fixture C: integer equity `10000 + 3i + (37i mod 101)` at the given times.
919    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    /// Hourly points spanning exactly 13 weeks, the shortest span that is annualised.
930    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        // A non-finite number in every kind of field: one invalid trade, one skipped equity point.
1027        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        // In exit order: ++---0++0+. Sum 8, winners 18, losses 10; runs 2 and 3.
1037        // The input order differs, and open/invalid trades do not interrupt a run.
1038        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        // Investing 1,000 at 100 buys 10 units: closes 110, 90, 120 give 1,100, 900, 1,200.
1232        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); // A missing held bar must not understate risk.
1331        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 two units entered at 100, the first bar's [95, 115] gives MAE/MFE 10/30.
1341        // The second bar adds a low of 85, giving 30/30. A fill at its open excludes its range.
1342        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        // Decimal reference: multiplying a fraction by 100 shifts its decimal point twice.
1547        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        // Rounding the largest finite percent to 15 digits would overflow; retain its value.
1575        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        // Nothing to compute from: counts are zero, everything else is an empty cell.
1606        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}