1use 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#[derive(Debug, Clone)]
19pub enum StrategyClientRequest {
20 Run(Box<SubmitConfiguredStrategyRequest>),
21 Portfolio(Box<SubmitPortfolioRequest>),
22 Search(Box<SubmitSearchRequest>),
23}
24
25#[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
97pub 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
105pub 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
282fn 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
291fn 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}