Skip to main content

pine_lang/
backtest.rs

1//! The outcome of replaying a `strategy`: its equity curve and trade log.
2
3use pine_broker::Trade;
4use pine_core::Timeframe;
5
6/// Milliseconds in a 365-day year, for annualising a per-bar figure.
7const MS_PER_YEAR: f64 = 365.0 * 24.0 * 60.0 * 60.0 * 1000.0;
8
9/// What a `strategy` produced over a run: the equity curve, the trade log, and
10/// the summary values Pine exposes as `strategy.*`. Field names follow Pine's.
11#[derive(Debug, Clone, Default)]
12pub struct Backtest {
13    pub initial_capital: f64,
14    /// Account value at each bar's close.
15    pub equity: Vec<f64>,
16    /// Every trade, closed ones (in the order they closed) before still-open
17    /// ones. `exit_price` is `None` while open; `profit(price)` values it.
18    pub trades: Vec<Trade>,
19    pub net_profit: f64,
20    pub open_profit: f64,
21    pub gross_profit: f64,
22    /// Total loss of the losing trades, as a positive magnitude.
23    pub gross_loss: f64,
24    pub max_drawdown: f64,
25    pub max_runup: f64,
26    pub win_trades: usize,
27    pub loss_trades: usize,
28    pub even_trades: usize,
29    /// Signed: positive long, negative short.
30    pub position_size: f64,
31    /// The last bar's close, at which open trades are valued.
32    pub mark_price: f64,
33    /// The bar the run halted on if a rest-of-run risk rule fired
34    /// (`strategy.risk.max_drawdown` / `max_cons_loss_days`)
35    pub halted: Option<u64>,
36    /// The chart timeframe, so per-bar figures can be annualised.
37    pub timeframe: Timeframe,
38}
39
40impl Backtest {
41    /// The final account value, or the initial capital if no bar ran.
42    pub fn final_equity(&self) -> f64 {
43        self.equity.last().copied().unwrap_or(self.initial_capital)
44    }
45
46    /// The trades already closed, in the order they closed.
47    pub fn closed_trades(&self) -> impl Iterator<Item = &Trade> {
48        self.trades.iter().filter(|t| !t.is_open())
49    }
50
51    /// The trades still open at the end of the run.
52    pub fn open_trades(&self) -> impl Iterator<Item = &Trade> {
53        self.trades.iter().filter(|t| t.is_open())
54    }
55
56    /// Standard summary metrics derived from the equity curve and trade log.
57    pub fn generate_metrics(&self) -> Metrics {
58        let trades = self.win_trades + self.loss_trades + self.even_trades;
59        let final_equity = self.final_equity();
60        let max_drawdown = max_drawdown_percent(&self.equity);
61
62        // Annualisation from the bar length: how many bars a year holds, and how
63        // many years this run's window spans.
64        let bars_per_year = bars_per_year(&self.timeframe);
65        let years = ratio(self.equity.len() as f64, bars_per_year);
66
67        // Compounding back out of the window. Meaningless over a sub-bar window
68        // or once equity has reached zero.
69        let annual_return = if years > 0.0 && final_equity > 0.0 && self.initial_capital > 0.0 {
70            (final_equity / self.initial_capital).powf(1.0 / years) - 1.0
71        } else {
72            0.0
73        };
74
75        let returns = bar_returns(&self.equity);
76        let (mean, deviation) = mean_and_deviation(&returns);
77        let annualise = bars_per_year.sqrt();
78
79        Metrics {
80            bars: self.equity.len(),
81            initial_capital: self.initial_capital,
82            final_equity,
83            net_profit: self.net_profit,
84            total_return: ratio(final_equity - self.initial_capital, self.initial_capital),
85            annual_return,
86            max_drawdown,
87            sharpe: ratio(mean * annualise, deviation),
88            sortino: ratio(mean * annualise, downside_deviation(&returns)),
89            calmar: ratio(annual_return, max_drawdown),
90            trades,
91            wins: self.win_trades,
92            losses: self.loss_trades,
93            win_rate: ratio(self.win_trades as f64, trades as f64),
94            profit_factor: ratio(self.gross_profit, self.gross_loss),
95            avg_trade: ratio(self.net_profit, trades as f64),
96            exposure: exposure(&self.trades, self.equity.len()),
97        }
98    }
99}
100
101/// Standard summary metrics of a run, from [`Backtest::generate_metrics`]. Every
102/// figure is reported with the context that makes it comparable — returns beside
103/// drawdown, wins beside profit factor.
104#[derive(Debug, Clone)]
105pub struct Metrics {
106    /// Bars the strategy ran over.
107    pub bars: usize,
108    pub initial_capital: f64,
109    pub final_equity: f64,
110    pub net_profit: f64,
111    /// Total return over the run, as a fraction of starting capital.
112    pub total_return: f64,
113    /// Return annualised from the window's length.
114    pub annual_return: f64,
115    /// Largest peak-to-trough fall in close equity, as a fraction of the peak.
116    pub max_drawdown: f64,
117    /// Annualised mean return over its standard deviation.
118    pub sharpe: f64,
119    /// As Sharpe, but penalising only downside deviation.
120    pub sortino: f64,
121    /// Annual return over max drawdown: profit per unit of worst loss.
122    pub calmar: f64,
123    /// Closed trades — winners, losers and breakevens.
124    pub trades: usize,
125    pub wins: usize,
126    pub losses: usize,
127    pub win_rate: f64,
128    /// Gross profit over gross loss. Below 1.0 loses money.
129    pub profit_factor: f64,
130    /// Average profit per closed trade.
131    pub avg_trade: f64,
132    /// Fraction of bars holding a position; can exceed 1.0 with pyramiding.
133    pub exposure: f64,
134}
135
136/// `numerator / denominator`, or 0 when the denominator can't divide. A summary
137/// of nothing is zero, not an infinity that later sorts to the top of a ranking.
138fn ratio(numerator: f64, denominator: f64) -> f64 {
139    if denominator > 0.0 && denominator.is_finite() {
140        numerator / denominator
141    } else {
142        0.0
143    }
144}
145
146/// Largest peak-to-trough fall in the equity curve, as a fraction of the peak it
147/// fell from — the drawdown percentage TradingView reports. Measured on
148/// bar-close equity; the cash `max_drawdown` field is the intrabar figure the
149/// risk engine enforces against.
150fn max_drawdown_percent(equity: &[f64]) -> f64 {
151    let mut peak = f64::NEG_INFINITY;
152    let mut worst = 0.0f64;
153    for &value in equity {
154        peak = peak.max(value);
155        if peak > 0.0 {
156            worst = worst.max((peak - value) / peak);
157        }
158    }
159    worst
160}
161
162/// Fraction of `bars` spent holding a position, summing each trade's span.
163fn exposure(trades: &[Trade], bars: usize) -> f64 {
164    if bars == 0 {
165        return 0.0;
166    }
167    let last = bars.saturating_sub(1) as u64;
168    let held: u64 = trades
169        .iter()
170        .map(|t| t.exit_bar.unwrap_or(last).saturating_sub(t.entry_bar))
171        .sum();
172    held as f64 / bars as f64
173}
174
175/// Bars a year holds at `tf`'s length, or 0 for a timeframe with no fixed
176/// duration — which zeroes the annualised figures rather than inventing one.
177fn bars_per_year(tf: &Timeframe) -> f64 {
178    match tf.to_millis() {
179        Some(ms) if ms > 0 => MS_PER_YEAR / ms as f64,
180        _ => 0.0,
181    }
182}
183
184/// Simple return from each bar's close to the next.
185fn bar_returns(equity: &[f64]) -> Vec<f64> {
186    equity
187        .windows(2)
188        .filter(|pair| pair[0] > 0.0)
189        .map(|pair| pair[1] / pair[0] - 1.0)
190        .collect()
191}
192
193/// Mean and (population) standard deviation of `returns`.
194fn mean_and_deviation(returns: &[f64]) -> (f64, f64) {
195    if returns.is_empty() {
196        return (0.0, 0.0);
197    }
198    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
199    let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
200    (mean, variance.sqrt())
201}
202
203/// Standard deviation of the losing bars only — upside volatility is not risk.
204fn downside_deviation(returns: &[f64]) -> f64 {
205    if returns.is_empty() {
206        return 0.0;
207    }
208    let sum: f64 = returns
209        .iter()
210        .filter(|r| **r < 0.0)
211        .map(|r| r.powi(2))
212        .sum();
213    (sum / returns.len() as f64).sqrt()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn ratios_over_nothing_are_zero() {
222        // A flat run would otherwise sort to the top on an infinite profit factor.
223        assert_eq!(ratio(1.0, 0.0), 0.0);
224        assert_eq!(ratio(0.0, 0.0), 0.0);
225    }
226
227    #[test]
228    fn max_drawdown_percent_is_measured_from_the_peak() {
229        // Up to 200, down to 100: half the peak, not half the start.
230        assert_eq!(max_drawdown_percent(&[100.0, 200.0, 100.0, 150.0]), 0.5);
231        // A curve that only rises never draws down.
232        assert_eq!(max_drawdown_percent(&[100.0, 110.0, 120.0]), 0.0);
233    }
234
235    #[test]
236    fn generate_metrics_derives_the_summary() {
237        let b = Backtest {
238            initial_capital: 1000.0,
239            equity: vec![1000.0, 1100.0, 1200.0],
240            gross_profit: 200.0,
241            gross_loss: 100.0,
242            net_profit: 100.0,
243            win_trades: 3,
244            loss_trades: 1,
245            even_trades: 0,
246            ..Default::default()
247        };
248        let m = b.generate_metrics();
249
250        assert_eq!(m.bars, 3);
251        assert!((m.total_return - 0.2).abs() < 1e-12); // 1200 / 1000 - 1
252        assert_eq!(m.profit_factor, 2.0);
253        assert_eq!(m.win_rate, 0.75); // 3 of 4 closed
254        assert_eq!(m.avg_trade, 25.0); // 100 over 4
255        assert_eq!(m.trades, 4);
256    }
257
258    #[test]
259    fn annualises_from_the_timeframe() {
260        // 365 daily bars (the default timeframe) doubling equity = one year, so
261        // roughly a 100% annual return, with a finite, positive Sharpe.
262        let equity: Vec<f64> = (0..365)
263            .map(|i| 1000.0 + 1000.0 * i as f64 / 364.0)
264            .collect();
265        let m = Backtest {
266            initial_capital: 1000.0,
267            equity,
268            ..Default::default()
269        }
270        .generate_metrics();
271
272        assert!((m.annual_return - 1.0).abs() < 1e-9);
273        assert!(m.sharpe.is_finite() && m.sharpe > 0.0);
274    }
275}