Skip to main content

backtest_server/
strategy_client.rs

1//! TOML run files that describe a configured strategy run, a portfolio run, or a parameter search for the backtest service.
2//!
3//! A run file names a bound strategy document, a list of `[[instances]]` each naming one, or a strategy template and a space document, by path relative to the run file, together with the data scope, profiles, sizing, and series geometry. A portfolio run file may also carry `[[policies]]` and `[[groups]]` for its supervisor. Documents stay in their TOML authoring form on disk and are converted to JSON values only when the request is built, because the service transport is JSON and the service decodes documents and policies strictly.
4
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::rpc_types::{
10    BacktestConfigMsg, ConfiguredStrategyRunMsg, ConfiguredStrategyRunSpec, EntryProfileRouteMsg,
11    FutureQuoteConfigMsg, HistoricalInputsMsg, PortfolioInstanceMsg, PortfolioRunSpec, ProfileRef,
12    ProviderEvaluationOptionsMsg, ResultDeliveryMsg, RunConfiguredStrategyRequest,
13    RunPortfolioRequest, SearchRunSpec, SearchWindowsMsg, SourceBindingMsg,
14    SubmitConfiguredStrategyRequest, SubmitPortfolioRequest, SubmitSearchRequest,
15};
16
17/// A request built from a run file, ready to submit.
18#[derive(Debug, Clone)]
19pub enum StrategyClientRequest {
20    Run(Box<SubmitConfiguredStrategyRequest>),
21    Portfolio(Box<SubmitPortfolioRequest>),
22    Search(Box<SubmitSearchRequest>),
23}
24
25/// One `[[instances]]` entry of a portfolio run file.
26#[derive(Debug, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct InstanceFile {
29    strategy: PathBuf,
30    symbol: String,
31    instance_id: String,
32    #[serde(default)]
33    decision_latency_ms: u64,
34    #[serde(default)]
35    profile: Option<String>,
36    #[serde(default)]
37    entry_profile_routes: Vec<RouteFile>,
38    series: Vec<SourceBindingMsg>,
39    #[serde(default)]
40    historical_inputs: Option<HistoricalInputsMsg>,
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct RunFile {
46    #[serde(default)]
47    strategy: Option<PathBuf>,
48    #[serde(default)]
49    template: Option<PathBuf>,
50    #[serde(default)]
51    space: Option<PathBuf>,
52    exchange: String,
53    #[serde(default)]
54    symbol: Option<String>,
55    #[serde(default)]
56    symbols: Vec<String>,
57    data_type: String,
58    #[serde(default)]
59    timeframe: Option<String>,
60    #[serde(default)]
61    from: Option<String>,
62    #[serde(default)]
63    to: Option<String>,
64    account_currency: String,
65    #[serde(default)]
66    profile: Option<String>,
67    #[serde(default)]
68    entry_profile_routes: Vec<RouteFile>,
69    #[serde(default)]
70    series: Vec<SourceBindingMsg>,
71    #[serde(default)]
72    historical_inputs: Option<HistoricalInputsMsg>,
73    #[serde(default)]
74    windows: Option<SearchWindowsMsg>,
75    config: BacktestConfigMsg,
76    #[serde(default)]
77    instance_id: Option<String>,
78    #[serde(default)]
79    decision_latency_ms: u64,
80    #[serde(default)]
81    workers: Option<usize>,
82    #[serde(default)]
83    instances: Vec<InstanceFile>,
84    #[serde(default)]
85    policies: Vec<toml::Value>,
86    #[serde(default)]
87    groups: Vec<toml::Value>,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct RouteFile {
93    entry_class: String,
94    profile: String,
95}
96
97/// Read a run file and the documents it names, and build the request it describes.
98pub fn load_run_file(path: &Path) -> Result<StrategyClientRequest, String> {
99    let text = std::fs::read_to_string(path)
100        .map_err(|error| format!("cannot read run file {}: {error}", path.display()))?;
101    let base = path.parent().unwrap_or_else(|| Path::new("."));
102    build_request(&text, base)
103}
104
105/// Build a request from run-file text, resolving document paths against `base`.
106pub fn build_request(text: &str, base: &Path) -> Result<StrategyClientRequest, String> {
107    let file: RunFile =
108        toml::from_str(text).map_err(|error| format!("invalid run file: {error}"))?;
109    let future = FutureQuoteConfigMsg {
110        account_currency: file.account_currency.clone(),
111        ..FutureQuoteConfigMsg::default()
112    };
113    let named_routes = |routes: &[RouteFile]| {
114        routes
115            .iter()
116            .map(|route| EntryProfileRouteMsg {
117                entry_class: route.entry_class.clone(),
118                profile: ProfileRef::Named(route.profile.clone()),
119            })
120            .collect::<Vec<_>>()
121    };
122    let routes = named_routes(&file.entry_profile_routes);
123    if !file.instances.is_empty() {
124        if file.strategy.is_some() || file.template.is_some() || file.space.is_some() {
125            return Err("a portfolio run file names its documents under [[instances]] only".into());
126        }
127        if file.symbol.is_some()
128            || !file.symbols.is_empty()
129            || !file.series.is_empty()
130            || file.historical_inputs.is_some()
131            || file.instance_id.is_some()
132            || file.profile.is_some()
133            || !file.entry_profile_routes.is_empty()
134            || file.windows.is_some()
135            || file.workers.is_some()
136        {
137            return Err("symbol, series, instance_id, and profiles belong to each [[instances]] entry of a portfolio, and windows and workers apply only to a search".into());
138        }
139        let mut instances = Vec::with_capacity(file.instances.len());
140        for instance in &file.instances {
141            instances.push(PortfolioInstanceMsg {
142                symbol: instance.symbol.clone(),
143                strategy: ConfiguredStrategyRunMsg {
144                    document: document_value(&base.join(&instance.strategy))?,
145                    sources: instance.series.clone(),
146                    instance_id: Some(instance.instance_id.clone()),
147                    decision_latency_ms: instance.decision_latency_ms,
148                    historical_inputs: instance.historical_inputs.clone(),
149                },
150                profile: instance.profile.clone(),
151                profile_def: None,
152                entry_profile_routes: named_routes(&instance.entry_profile_routes),
153            });
154        }
155        let json = |values: &[toml::Value]| {
156            (!values.is_empty())
157                .then(|| serde_json::Value::Array(values.iter().map(json_value).collect()))
158        };
159        return Ok(StrategyClientRequest::Portfolio(Box::new(
160            SubmitPortfolioRequest {
161                request: RunPortfolioRequest {
162                    request: PortfolioRunSpec {
163                        instances,
164                        exchange: file.exchange,
165                        data_type: file.data_type,
166                        timeframe: file.timeframe,
167                        from: file.from,
168                        to: file.to,
169                        config: file.config,
170                        policies: json(&file.policies),
171                        groups: json(&file.groups),
172                    },
173                    future,
174                    evaluation: ProviderEvaluationOptionsMsg::default(),
175                    result_delivery: ResultDeliveryMsg::Auto,
176                },
177            },
178        )));
179    }
180    if !file.policies.is_empty() || !file.groups.is_empty() {
181        return Err(
182            "policies and groups apply only to a portfolio run file with [[instances]]".into(),
183        );
184    }
185    match (&file.strategy, &file.template, &file.space) {
186        (Some(strategy), None, None) => {
187            let symbol = file
188                .symbol
189                .clone()
190                .ok_or("a strategy run requires symbol")?;
191            if !file.symbols.is_empty() || file.windows.is_some() || file.workers.is_some() {
192                return Err("symbols, windows, and workers apply only to a search".into());
193            }
194            if file.series.is_empty() {
195                return Err("a strategy run requires at least one [[series]]".into());
196            }
197            Ok(StrategyClientRequest::Run(Box::new(
198                SubmitConfiguredStrategyRequest {
199                    request: RunConfiguredStrategyRequest {
200                        request: ConfiguredStrategyRunSpec {
201                            symbol,
202                            exchange: file.exchange,
203                            data_type: file.data_type,
204                            timeframe: file.timeframe,
205                            from: file.from,
206                            to: file.to,
207                            strategy: ConfiguredStrategyRunMsg {
208                                document: document_value(&base.join(strategy))?,
209                                sources: file.series,
210                                instance_id: file.instance_id,
211                                decision_latency_ms: file.decision_latency_ms,
212                                historical_inputs: file.historical_inputs,
213                            },
214                            profile: file.profile,
215                            profile_def: None,
216                            entry_profile_routes: routes,
217                            config: file.config,
218                        },
219                        future,
220                        evaluation: ProviderEvaluationOptionsMsg::default(),
221                        result_delivery: ResultDeliveryMsg::Auto,
222                    },
223                },
224            )))
225        }
226        (None, Some(template), Some(space)) => {
227            let mut symbols = file.symbols;
228            if let Some(symbol) = file.symbol {
229                symbols.push(symbol);
230            }
231            if symbols.is_empty() {
232                return Err("a search requires symbol or symbols".into());
233            }
234            if !file.series.is_empty()
235                || file.instance_id.is_some()
236                || file.historical_inputs.is_some()
237            {
238                return Err(
239                    "a search takes its series and historical inputs from the space document"
240                        .into(),
241                );
242            }
243            if file.from.is_some() || file.to.is_some() {
244                return Err("a search takes its range from [windows]".into());
245            }
246            let windows = file.windows.ok_or("a search requires [windows]")?;
247            Ok(StrategyClientRequest::Search(Box::new(
248                SubmitSearchRequest {
249                    request: SearchRunSpec {
250                        template: document_value(&base.join(template))?,
251                        space: document_value(&base.join(space))?,
252                        symbols,
253                        exchange: file.exchange,
254                        data_type: file.data_type,
255                        timeframe: file.timeframe,
256                        windows,
257                        config: file.config,
258                        profile: file.profile,
259                        entry_profile_routes: routes,
260                        workers: file.workers,
261                        decision_latency_ms: file.decision_latency_ms,
262                        structural: None,
263                        resource_limits: None,
264                        variants: vec![],
265                        direct_factory: None,
266                        resume_checkpoint: None,
267                        selected_rerun: None,
268                        portfolio_candidates: vec![],
269                        series_descriptors: vec![],
270                    },
271                    future,
272                    evaluation: ProviderEvaluationOptionsMsg::default(),
273                },
274            )))
275        }
276        _ => Err(
277            "a run file names either strategy, [[instances]], or both template and space".into(),
278        ),
279    }
280}
281
282/// Read a TOML document and convert it to the JSON value the service decodes.
283fn document_value(path: &Path) -> Result<serde_json::Value, String> {
284    let text = std::fs::read_to_string(path)
285        .map_err(|error| format!("cannot read document {}: {error}", path.display()))?;
286    let value: toml::Value = toml::from_str(&text)
287        .map_err(|error| format!("invalid TOML document {}: {error}", path.display()))?;
288    Ok(json_value(&value))
289}
290
291/// Convert TOML to the JSON the service decodes, writing a TOML date or time as the ISO text a strict decoder reads, so `reset_at_utc = 22:00:00` means the same as `reset_at_utc = "22:00:00"`.
292fn json_value(value: &toml::Value) -> serde_json::Value {
293    match value {
294        toml::Value::String(text) => serde_json::Value::String(text.clone()),
295        toml::Value::Integer(number) => serde_json::Value::from(*number),
296        toml::Value::Float(number) => serde_json::Number::from_f64(*number)
297            .map_or(serde_json::Value::Null, serde_json::Value::Number),
298        toml::Value::Boolean(flag) => serde_json::Value::Bool(*flag),
299        toml::Value::Datetime(datetime) => serde_json::Value::String(datetime.to_string()),
300        toml::Value::Array(items) => {
301            serde_json::Value::Array(items.iter().map(json_value).collect())
302        }
303        toml::Value::Table(table) => serde_json::Value::Object(
304            table
305                .iter()
306                .map(|(key, value)| (key.clone(), json_value(value)))
307                .collect(),
308        ),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn directory() -> PathBuf {
317        let path = std::env::temp_dir().join(format!(
318            "qs_strategy_client_{}_{:?}",
319            std::process::id(),
320            std::thread::current().id()
321        ));
322        std::fs::create_dir_all(&path).unwrap();
323        std::fs::write(
324            path.join("strategy.toml"),
325            "strategy_id = \"alpha\"\n[[states]]\nid = \"flat\"\n",
326        )
327        .unwrap();
328        std::fs::write(path.join("space.toml"), "family_id = \"alpha\"\n").unwrap();
329        path
330    }
331
332    const COMMON: &str = r#"
333exchange = "icmarkets"
334data_type = "tick"
335account_currency = "USD"
336
337[config]
338initial_balance = 10000.0
339sizing = { type = "FixedLot", lots = 0.1 }
340"#;
341
342    #[test]
343    fn a_run_file_builds_a_configured_request_with_its_document_as_json() {
344        let base = directory();
345        let text = format!(
346            "strategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\nfrom = \"2026-01-01\"\nprofile = \"trail\"\n{COMMON}\n[[entry_profile_routes]]\nentry_class = \"trend\"\nprofile = \"trail\"\n\n[[series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n"
347        );
348        let StrategyClientRequest::Run(request) = build_request(&text, &base).unwrap() else {
349            panic!("a strategy run file builds a run request");
350        };
351        let run = &request.request;
352        assert_eq!(run.future.account_currency, "USD");
353        assert_eq!(run.request.symbol, "EURUSD");
354        assert_eq!(run.request.from.as_deref(), Some("2026-01-01"));
355        assert_eq!(
356            run.request.strategy.document,
357            serde_json::json!({ "strategy_id": "alpha", "states": [{ "id": "flat" }] })
358        );
359        assert_eq!(run.request.strategy.sources[0].source, "primary");
360        assert!(matches!(
361            &run.request.entry_profile_routes[0].profile,
362            ProfileRef::Named(name) if name == "trail"
363        ));
364        std::fs::remove_dir_all(base).unwrap();
365    }
366
367    #[test]
368    fn a_run_file_carries_document_driven_full_day_calendar_inputs() {
369        let base = directory();
370        let text = format!(
371            "strategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\nfrom = \"2026-01-01T00:00:00\"\nto = \"2026-01-02T00:00:00\"\n{COMMON}\n[[series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n\n[historical_inputs.calendars.main]\nid = \"main\"\ntimezone = \"UTC\"\n\n[[historical_inputs.inputs]]\nname = \"in_full_day\"\nsource = \"primary\"\ncalendar = \"main\"\nfeature = \"session_membership\"\nchild_seconds = 60\nmaximum_history = 2\n"
372        );
373        let StrategyClientRequest::Run(request) = build_request(&text, &base).unwrap() else {
374            panic!("a strategy run file builds a run request");
375        };
376        let inputs = request
377            .request
378            .request
379            .strategy
380            .historical_inputs
381            .as_ref()
382            .unwrap();
383        assert!(matches!(
384            inputs.calendars["main"].sessions,
385            crate::rpc_types::SessionScheduleMsg::FullDay
386        ));
387        assert_eq!(inputs.inputs[0].name, "in_full_day");
388        std::fs::remove_dir_all(base).unwrap();
389    }
390
391    #[test]
392    fn a_search_file_builds_a_search_request_from_template_space_and_windows() {
393        let base = directory();
394        let text = format!(
395            "template = \"strategy.toml\"\nspace = \"space.toml\"\nsymbols = [\"EURUSD\"]\nsymbol = \"GBPUSD\"\nworkers = 4\n{COMMON}\n[windows]\ntype = \"fixed\"\nin_sample = {{ label = \"is\", from = \"2025-01-01\", to = \"2026-01-01\" }}\nout_of_sample = {{ label = \"oos\", from = \"2026-01-01\", to = \"2026-09-01\" }}\n"
396        );
397        let StrategyClientRequest::Search(request) = build_request(&text, &base).unwrap() else {
398            panic!("a template and space build a search request");
399        };
400        assert_eq!(request.request.symbols, vec!["EURUSD", "GBPUSD"]);
401        assert_eq!(request.request.workers, Some(4));
402        assert_eq!(
403            request.request.space,
404            serde_json::json!({ "family_id": "alpha" })
405        );
406        assert!(matches!(
407            request.request.windows,
408            SearchWindowsMsg::Fixed { .. }
409        ));
410        std::fs::remove_dir_all(base).unwrap();
411    }
412
413    #[test]
414    fn a_portfolio_file_builds_one_request_with_every_instance_and_its_policies() {
415        let base = directory();
416        let text = format!(
417            "{COMMON}\n[[instances]]\nstrategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\ninstance_id = \"eur\"\nprofile = \"trail\"\n[[instances.series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n\n[[instances]]\nstrategy = \"strategy.toml\"\nsymbol = \"GBPUSD\"\ninstance_id = \"gbp\"\n[[instances.series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n\n[[policies]]\ntype = \"max_open_positions\"\nlimit = 1\n\n[[policies]]\ntype = \"daily_loss_halt\"\nmax_loss = {{ amount = 100.0 }}\nreset_at_utc = 22:00:00\n\n[[groups]]\nid = \"usd\"\nsymbols = [\"EURUSD\", \"GBPUSD\"]\n"
418        );
419        let StrategyClientRequest::Portfolio(request) = build_request(&text, &base).unwrap() else {
420            panic!("[[instances]] build a portfolio request");
421        };
422        let spec = &request.request.request;
423        assert_eq!(spec.instances.len(), 2);
424        assert_eq!(
425            spec.instances[0].strategy.instance_id.as_deref(),
426            Some("eur")
427        );
428        assert_eq!(spec.instances[0].profile.as_deref(), Some("trail"));
429        assert_eq!(spec.instances[1].symbol, "GBPUSD");
430        assert_eq!(
431            spec.policies,
432            Some(serde_json::json!([
433                { "type": "max_open_positions", "limit": 1 },
434                { "type": "daily_loss_halt", "max_loss": { "amount": 100.0 }, "reset_at_utc": "22:00:00" }
435            ]))
436        );
437        assert_eq!(
438            spec.groups,
439            Some(serde_json::json!([{ "id": "usd", "symbols": ["EURUSD", "GBPUSD"] }]))
440        );
441        std::fs::remove_dir_all(base).unwrap();
442    }
443
444    #[test]
445    fn contradictory_or_incomplete_run_files_are_rejected() {
446        let base = directory();
447        let cases = [
448            (
449                "strategy = \"strategy.toml\"\ntemplate = \"strategy.toml\"\nspace = \"space.toml\"\nsymbol = \"EURUSD\"\n",
450                "either strategy",
451            ),
452            (
453                "strategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\n",
454                "[[series]]",
455            ),
456            (
457                "template = \"strategy.toml\"\nspace = \"space.toml\"\nsymbol = \"EURUSD\"\n",
458                "[windows]",
459            ),
460            (
461                "template = \"strategy.toml\"\nspace = \"space.toml\"\nsymbol = \"EURUSD\"\nfrom = \"2026-01-01\"\n",
462                "[windows]",
463            ),
464            (
465                "strategy = \"missing.toml\"\nsymbol = \"EURUSD\"\n[[series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n",
466                "cannot read document",
467            ),
468            (
469                "strategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\nunknown = 1\n",
470                "invalid run file",
471            ),
472            (
473                "strategy = \"strategy.toml\"\nsymbol = \"EURUSD\"\n[[series]]\nsource = \"primary\"\ntimeframe_seconds = 60\nprice_basis = \"mid\"\n[[policies]]\ntype = \"max_open_positions\"\nlimit = 1\n",
474                "only to a portfolio",
475            ),
476        ];
477        for (head, expected) in cases {
478            let (before, after) = head
479                .split_once("[[series]]")
480                .map_or((head, ""), |(before, after)| (before, after));
481            let text = if after.is_empty() {
482                format!("{before}{COMMON}")
483            } else {
484                format!("{before}{COMMON}\n[[series]]{after}")
485            };
486            let error = build_request(&text, &base).unwrap_err();
487            assert!(error.contains(expected), "{expected:?} not in {error:?}");
488        }
489        std::fs::remove_dir_all(base).unwrap();
490    }
491}