Skip to main content

pine_lang/
run.rs

1//! The result of replaying a script over a whole series of bars.
2
3use crate::Backtest;
4use pine_core::{
5    AlertCondition, AlertConditionOutput, Indicator, Input, InputOutput, LogEntry, LogOutput,
6    MetadataOutput, PineOutput, Plot, PlotOutput,
7};
8use std::collections::BTreeMap;
9
10/// What a full replay produced. Owns its data, so the `Script` is dropped once
11/// [`Script::run`] returns.
12pub struct Run<O: PineOutput> {
13    /// What each bar produced; [`RunResult::collect`] turns these into columns.
14    pub outputs: Vec<O>,
15    /// The backtest, or `None` if the script declared no `strategy`.
16    pub backtest: Option<Backtest>,
17}
18
19/// A run's per-bar outputs turned into columns.
20///
21/// Drawings are missing because the output traits expose labels, lines and
22/// boxes only by id, so there is no way to enumerate what a bar created.
23#[derive(Debug, Clone, Default)]
24pub struct RunResult {
25    pub bars: usize,
26    /// Plotted values by title, one slot per bar; `None` where the plot was na.
27    pub plots: BTreeMap<String, Vec<Option<f64>>>,
28    pub logs: Vec<LogEntry>,
29    pub alerts: Vec<AlertCondition>,
30    pub indicator: Option<Indicator>,
31    pub inputs: Vec<Input>,
32}
33
34impl RunResult {
35    /// Transpose the per-bar outputs [`crate::Script::run`] returns.
36    pub fn collect<O>(outputs: &[O]) -> Self
37    where
38        O: PlotOutput + LogOutput + AlertConditionOutput + MetadataOutput + InputOutput,
39    {
40        let mut result = Self::default();
41
42        for output in outputs {
43            result.push_bar(output.plots());
44            result.logs.extend(output.get_logs().iter().cloned());
45        }
46
47        // These describe the script, not a bar, so the last word wins.
48        if let Some(last) = outputs.last() {
49            result.alerts = last.alertconditions().to_vec();
50            result.inputs = last.inputs().to_vec();
51            result.indicator = last.indicator().cloned();
52        }
53
54        result
55    }
56
57    /// Append one bar, padding every column so titles stay aligned whether a
58    /// plot starts late or stops early.
59    fn push_bar(&mut self, plots: &[Plot]) {
60        for plot in plots {
61            let column = self.plots.entry(plot.title.clone()).or_default();
62            column.resize(self.bars, None);
63            column.push((!plot.series.is_nan()).then_some(plot.series));
64        }
65
66        self.bars += 1;
67
68        for column in self.plots.values_mut() {
69            column.resize(self.bars, None);
70        }
71    }
72
73    /// The values plotted under `title`, or `None` if nothing plotted it.
74    pub fn plot(&self, title: &str) -> Option<&[Option<f64>]> {
75        self.plots.get(title).map(Vec::as_slice)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn plot(title: &str, series: f64) -> Plot {
84        Plot {
85            series,
86            title: title.to_string(),
87            ..Default::default()
88        }
89    }
90
91    #[test]
92    fn columns_line_up_with_bars() {
93        let mut run = RunResult::default();
94        run.push_bar(&[plot("a", 1.0)]);
95        run.push_bar(&[plot("a", 2.0)]);
96
97        assert_eq!(run.bars, 2);
98        assert_eq!(run.plot("a"), Some([Some(1.0), Some(2.0)].as_slice()));
99    }
100
101    #[test]
102    fn na_becomes_a_gap() {
103        let mut run = RunResult::default();
104        run.push_bar(&[plot("a", f64::NAN)]);
105        run.push_bar(&[plot("a", 2.0)]);
106
107        assert_eq!(run.plot("a"), Some([None, Some(2.0)].as_slice()));
108    }
109
110    #[test]
111    fn a_plot_appearing_late_is_padded_at_the_front() {
112        let mut run = RunResult::default();
113        run.push_bar(&[plot("a", 1.0)]);
114        run.push_bar(&[plot("a", 2.0), plot("b", 9.0)]);
115
116        assert_eq!(run.plot("a"), Some([Some(1.0), Some(2.0)].as_slice()));
117        assert_eq!(run.plot("b"), Some([None, Some(9.0)].as_slice()));
118    }
119
120    #[test]
121    fn a_plot_that_stops_is_padded_at_the_end() {
122        let mut run = RunResult::default();
123        run.push_bar(&[plot("a", 1.0)]);
124        run.push_bar(&[]);
125
126        assert_eq!(run.plot("a"), Some([Some(1.0), None].as_slice()));
127        assert_eq!(run.bars, 2);
128    }
129
130    #[test]
131    fn an_unplotted_title_is_absent() {
132        let run = RunResult::default();
133        assert!(run.plot("nope").is_none());
134    }
135
136    #[test]
137    fn with_broker_swaps_the_broker_factory() {
138        use crate::broker::{Broker, BrokerConfig, BrokerFactory, DefaultBrokerFactory};
139        use crate::core::DefaultPineOutput;
140        use crate::ScriptBuilder;
141        use std::sync::atomic::{AtomicUsize, Ordering};
142        use std::sync::Arc;
143
144        // A factory that records how often it is asked to build, then defers to
145        // the built-in one so the strategy still runs.
146        struct CountingFactory(Arc<AtomicUsize>);
147        impl BrokerFactory for CountingFactory {
148            fn build(&self, config: &BrokerConfig) -> Box<dyn Broker> {
149                self.0.fetch_add(1, Ordering::SeqCst);
150                DefaultBrokerFactory.build(config)
151            }
152        }
153
154        let source = r#"
155//@version=5
156strategy("t", initial_capital = 10000)
157if bar_index == 1
158    strategy.entry("Long", strategy.long)
159"#;
160        let calls = Arc::new(AtomicUsize::new(0));
161        let run = ScriptBuilder::<DefaultPineOutput>::with_code(source)
162            .with_data(crate::data::synthetic(5))
163            .with_broker(Box::new(CountingFactory(Arc::clone(&calls))))
164            .compile()
165            .expect("compile")
166            .run()
167            .expect("run");
168
169        // The strategy traded against our broker, built once and lazily.
170        assert!(run.backtest.is_some());
171        assert_eq!(calls.load(Ordering::SeqCst), 1);
172    }
173
174    #[test]
175    fn backtest_reports_the_halt_bar() {
176        use crate::core::DefaultPineOutput;
177        use crate::ScriptBuilder;
178
179        // A short into a rising market draws down fast; a tight max_drawdown
180        // halts the run, and the Backtest records the bar it died on.
181        let halting = r#"
182//@version=5
183strategy("t", initial_capital = 10000)
184strategy.risk.max_drawdown(50, strategy.cash)
185if bar_index == 1
186    strategy.entry("S", strategy.short, qty = 100)
187"#;
188        let run = ScriptBuilder::<DefaultPineOutput>::with_code(halting)
189            .with_data(crate::data::synthetic(10))
190            .compile()
191            .expect("compile")
192            .run()
193            .expect("run");
194        assert!(run.backtest.expect("strategy").halted.is_some());
195
196        // A strategy that simply stops trading is not halted.
197        let quiet = r#"
198//@version=5
199strategy("t", initial_capital = 10000)
200if bar_index == 1
201    strategy.entry("L", strategy.long, qty = 1)
202"#;
203        let run = ScriptBuilder::<DefaultPineOutput>::with_code(quiet)
204            .with_data(crate::data::synthetic(10))
205            .compile()
206            .expect("compile")
207            .run()
208            .expect("run");
209        assert_eq!(run.backtest.expect("strategy").halted, None);
210    }
211}