Skip to main content

wickra_backtest_core/
request.rs

1//! A single JSON request bundling candles, the strategy spec and optional feeds
2//! — the uniform entry point the language bindings call, so every binding can
3//! run any feed combination by passing one JSON document.
4
5use serde::Deserialize;
6
7use crate::data::{Candle, CrossSection, DerivativesTick, OrderBook, TradePrint};
8use crate::engine::{Feeds, StreamingBacktest, DEFAULT_CAPITAL};
9use crate::error::{BacktestError, Result};
10use crate::report::BacktestReport;
11use crate::spec::StrategySpec;
12
13fn default_capital() -> f64 {
14    DEFAULT_CAPITAL
15}
16
17/// A complete backtest request: the strategy, the candle stream, the starting
18/// capital and any optional per-bar feeds. Each present feed must be the same
19/// length as `candles`.
20#[derive(Debug, Clone, Deserialize)]
21pub struct RunRequest {
22    /// The strategy spec.
23    pub spec: StrategySpec,
24    /// The OHLCV candle stream.
25    pub candles: Vec<Candle>,
26    /// Starting capital (defaults to [`DEFAULT_CAPITAL`]).
27    #[serde(default = "default_capital")]
28    pub capital: f64,
29    /// Reference candle series for pairwise indicators (its per-bar close).
30    #[serde(default)]
31    pub reference: Option<Vec<Candle>>,
32    /// Per-bar derivatives ticks for derivatives indicators / funding.
33    #[serde(default)]
34    pub derivs: Option<Vec<DerivativesTick>>,
35    /// Per-bar order-book snapshots for order-book / spread indicators.
36    #[serde(default)]
37    pub books: Option<Vec<OrderBook>>,
38    /// Per-bar trade lists for trade-flow / trade-quote indicators.
39    #[serde(default)]
40    pub trades: Option<Vec<Vec<TradePrint>>>,
41    /// Per-bar market cross-sections for breadth indicators.
42    #[serde(default)]
43    pub sections: Option<Vec<CrossSection>>,
44}
45
46impl RunRequest {
47    /// Run the backtest, threading any present feeds bar by bar.
48    pub fn run(&self) -> Result<BacktestReport> {
49        self.spec.validate()?;
50        // This request states its feeds up front, so a spec that prices the run
51        // against a feed it does not carry is caught here rather than producing a
52        // report of a cheaper strategy than the one described.
53        crate::engine::require_feeds(&self.spec, self.books.is_some(), self.derivs.is_some())?;
54        let n = self.candles.len();
55        if n == 0 {
56            return Err(BacktestError::InvalidData("no candles".into()));
57        }
58        let check = |name: &str, len: Option<usize>| -> Result<()> {
59            match len {
60                Some(l) if l != n => Err(BacktestError::InvalidData(format!(
61                    "{name} feed length {l} does not match {n} candles"
62                ))),
63                _ => Ok(()),
64            }
65        };
66        check("reference", self.reference.as_ref().map(Vec::len))?;
67        check("derivs", self.derivs.as_ref().map(Vec::len))?;
68        check("books", self.books.as_ref().map(Vec::len))?;
69        check("trades", self.trades.as_ref().map(Vec::len))?;
70        check("sections", self.sections.as_ref().map(Vec::len))?;
71
72        let mut bt = StreamingBacktest::new(&self.spec, self.capital)?;
73        for (i, candle) in self.candles.iter().enumerate() {
74            let feeds = Feeds {
75                reference: self.reference.as_ref().map(|r| r[i].close),
76                deriv: self.derivs.as_ref().map(|d| &d[i]),
77                orderbook: self.books.as_ref().map(|b| &b[i]),
78                trades: self.trades.as_ref().map(|t| t[i].as_slice()),
79                cross_section: self.sections.as_ref().map(|s| &s[i]),
80            };
81            bt.step_with_feeds(candle, &feeds)?;
82        }
83        Ok(bt.finish())
84    }
85}
86
87/// Run a backtest from a single JSON [`RunRequest`], returning the report JSON.
88/// This is the uniform entry point every language binding wraps.
89pub fn run_json(request_json: &str) -> Result<String> {
90    let req: RunRequest = serde_json::from_str(request_json)
91        .map_err(|e| BacktestError::InvalidSpec(e.to_string()))?;
92    let report = req.run()?;
93    serde_json::to_string(&report).map_err(|e| BacktestError::InvalidData(e.to_string()))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn run_json_matches_plain_run() {
102        let request = r#"{
103            "capital": 1000.0,
104            "spec": {"symbol":"x","timeframe":"1h","indicators":{},
105                "entry":{"gt":[{"price":"close"},100]},
106                "exit":{"lt":[{"price":"close"},100]},
107                "sizing":{"type":"fixed_qty","qty":1}},
108            "candles": [
109                {"time":0,"open":100,"high":101,"low":100,"close":101},
110                {"time":1,"open":102,"high":103,"low":102,"close":103},
111                {"time":2,"open":104,"high":104,"low":99,"close":99},
112                {"time":3,"open":98,"high":98,"low":97,"close":97}
113            ]
114        }"#;
115        let json = run_json(request).unwrap();
116        assert!(json.contains("\"num_trades\":1"));
117        assert!(json.contains("\"entry_price\":102.0"));
118        assert!(json.contains("\"exit_price\":98.0"));
119    }
120
121    #[test]
122    fn run_json_threads_a_derivatives_feed() {
123        let request = r#"{
124            "spec": {"symbol":"x","timeframe":"1h",
125                "indicators":{"f":{"type":"FundingRate","params":[]}},
126                "entry":{"gt":["f",0.0]},"exit":{"lt":["f",-1.0]},
127                "sizing":{"type":"fixed_qty","qty":1}},
128            "candles": [
129                {"time":0,"open":100,"high":100,"low":100,"close":100},
130                {"time":1,"open":100,"high":100,"low":100,"close":100},
131                {"time":2,"open":100,"high":100,"low":100,"close":100}
132            ],
133            "derivs": [
134                {"funding_rate":0.01,"mark_price":100,"index_price":100,"futures_price":100,"open_interest":1000,"long_size":600,"short_size":400,"taker_buy_volume":50,"taker_sell_volume":40,"long_liquidation":0,"short_liquidation":0},
135                {"funding_rate":0.01,"mark_price":100,"index_price":100,"futures_price":100,"open_interest":1000,"long_size":600,"short_size":400,"taker_buy_volume":50,"taker_sell_volume":40,"long_liquidation":0,"short_liquidation":0},
136                {"funding_rate":0.01,"mark_price":100,"index_price":100,"futures_price":100,"open_interest":1000,"long_size":600,"short_size":400,"taker_buy_volume":50,"taker_sell_volume":40,"long_liquidation":0,"short_liquidation":0}
137            ]
138        }"#;
139        let report = run_json(request).unwrap();
140        assert!(report.contains("\"num_trades\":1"));
141    }
142
143    #[test]
144    fn run_json_rejects_feed_length_mismatch() {
145        let request = r#"{
146            "spec": {"symbol":"x","timeframe":"1h","indicators":{},
147                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
148                "sizing":{"type":"fixed_qty","qty":1}},
149            "candles": [{"time":0,"open":1,"high":1,"low":1,"close":1}],
150            "trades": []
151        }"#;
152        assert!(run_json(request).is_err());
153    }
154}
155
156/// One bar's optional side feeds, as a JSON document.
157///
158/// [`RunRequest`] carries the whole run's feeds as parallel arrays, which only
159/// works when every bar is known up front. A streaming caller has one bar at a
160/// time, so it needs the same information in per-bar shape: this is that shape,
161/// and it deserialises from the same field names a `RunRequest` element uses.
162/// Every field is optional — an absent feed is simply not supplied to the bar.
163#[derive(Debug, Clone, Default, Deserialize)]
164pub struct StepFeeds {
165    /// Reference-series close for pairwise indicators.
166    #[serde(default)]
167    pub reference: Option<f64>,
168    /// Derivatives tick for derivatives indicators and funding.
169    #[serde(default)]
170    pub deriv: Option<DerivativesTick>,
171    /// Order-book snapshot for order-book and spread indicators.
172    #[serde(default)]
173    pub orderbook: Option<OrderBook>,
174    /// Trades that printed within this bar, for trade-flow indicators.
175    #[serde(default)]
176    pub trades: Option<Vec<TradePrint>>,
177    /// Market cross-section for this bar, for breadth indicators.
178    #[serde(default)]
179    pub cross_section: Option<CrossSection>,
180}
181
182impl StepFeeds {
183    /// Borrow this document as the engine's per-bar [`Feeds`].
184    #[must_use]
185    pub fn as_feeds(&self) -> Feeds<'_> {
186        Feeds {
187            reference: self.reference,
188            deriv: self.deriv.as_ref(),
189            orderbook: self.orderbook.as_ref(),
190            trades: self.trades.as_deref(),
191            cross_section: self.cross_section.as_ref(),
192        }
193    }
194}
195
196/// One streaming step as a JSON document: the bar, plus that bar's feeds.
197///
198/// This is the streaming counterpart to [`RunRequest`] — the uniform shape a
199/// binding hands to the engine for a single bar, so that every language drives
200/// the streaming path through one document instead of a per-language argument
201/// list that grows with each new feed.
202#[derive(Debug, Clone, Deserialize)]
203pub struct StepRequest {
204    /// The bar to advance by.
205    pub candle: Candle,
206    /// That bar's optional side feeds.
207    #[serde(default)]
208    pub feeds: StepFeeds,
209}