1use std::collections::BTreeSet;
4
5use chrono::NaiveDateTime;
6use qs_backtest::artifacts::{
7 FUTURE_ARTIFACT_FORMAT_VERSION, PendingOrderLifecycleEvent, PendingOrderLifecycleState,
8};
9use qs_backtest::currency::RunCurrencyPlan;
10use qs_backtest::economic_support::resolve_legacy_economics;
11use qs_backtest::evaluation::{
12 BootstrapConfig, BreakdownDimension, EvaluationContext, EvaluationOptions, EvaluationSection,
13 GroupFilter, PositionFilter, PositionSide, SourceCoverageCounts,
14};
15use qs_backtest::profile::{
16 ManagementProfile, PositionRef, RawSignal, RuleConfigDef, StoplossMode, TargetSelection,
17};
18use qs_backtest::report::{
19 BacktestResult, CloseReasonStats, DurationStats, MonthlyReturn, PositionSummary, RiskMetrics,
20 StreakStats, SubsetStats, TradeResult,
21};
22use qs_backtest::runner::{BacktestConfig, FutureQuoteConfig};
23use qs_backtest::{MtmOutputPolicy, MtmOutputSummary};
24use qs_core::types::{FillModel, OrderType, Side};
25use qs_symbols::{SymbolRegistry, normalize_currency_code};
26
27use crate::error::BacktestServerError;
28use crate::rpc_types::{
29 BacktestConfigMsg, BacktestResultMsg, BreakdownDimensionMsg, CloseReasonStatsMsg,
30 DurationStatsMsg, EquityPoint, EvaluationGroupFilterMsg, EvaluationPositionSideMsg,
31 EvaluationSectionMsg, FutureBacktestResultMsg, FutureQuoteConfigMsg, ManagementProfileMsg,
32 MonthlyReturnMsg, MtmOutputPolicyMsg, MtmOutputSummaryMsg, PendingOrderLifecycleEventMsg,
33 PendingOrderLifecycleStateMsg, PositionRefMsg, PositionSummaryMsg,
34 ProviderEvaluationOptionsMsg, RawSignalMsg, RiskMetricsMsg, RuleConfigDefMsg, SizingPolicyMsg,
35 StoplossModeMsg, StreakStatsMsg, SubsetStatsMsg, TargetSelectionMsg, TradeResultMsg,
36};
37
38const TS_FMT: &str = "%Y-%m-%dT%H:%M:%S%.f";
41
42fn ndt_to_string(ts: NaiveDateTime) -> String {
43 ts.format(TS_FMT).to_string()
44}
45
46pub fn config_from_msg(
53 msg: &BacktestConfigMsg,
54 registry: &SymbolRegistry,
55 symbols: &[String],
56) -> crate::error::Result<BacktestConfig> {
57 let initial_balance = msg.initial_balance.unwrap_or(10_000.0);
58 if !initial_balance.is_finite() || initial_balance <= 0.0 {
59 return Err(BacktestServerError::InvalidRequest(format!(
60 "initial balance must be finite and positive, got {initial_balance}"
61 )));
62 }
63
64 let mut contract_sizes = std::collections::HashMap::new();
65 let mut symbol_specs = std::collections::HashMap::new();
66 for symbol in symbols {
67 let spec = registry
68 .spec(symbol)
69 .ok_or_else(|| BacktestServerError::SymbolNotFound(symbol.clone()))?;
70 let economics = resolve_legacy_economics(spec)
71 .map_err(|error| BacktestServerError::InvalidRequest(error.to_string()))?;
72 contract_sizes.insert(symbol.clone(), economics.contract_multiplier);
73 symbol_specs.insert(symbol.clone(), spec.clone());
74 }
75 let sizing = msg.sizing.as_ref().map(sizing_from_msg).transpose()?;
76 Ok(BacktestConfig {
77 initial_balance,
78 close_on_finish: msg.close_on_finish.unwrap_or(true),
79 fill_model: parse_fill_model(msg.fill_model.as_deref()),
80 contract_sizes,
81 sizing,
82 symbol_specs,
83 instrument_manifest: None,
84 })
85}
86
87pub fn account_currency_from_msg(msg: &FutureQuoteConfigMsg) -> crate::error::Result<String> {
88 normalize_currency_code(&msg.account_currency).ok_or_else(|| {
89 BacktestServerError::InvalidRequest(format!(
90 "account_currency must be 3 ASCII letters, got '{}'",
91 msg.account_currency
92 ))
93 })
94}
95
96fn mtm_output_policy_from_msg(msg: &MtmOutputPolicyMsg) -> crate::error::Result<MtmOutputPolicy> {
97 let policy = match *msg {
98 MtmOutputPolicyMsg::None => MtmOutputPolicy::None,
99 MtmOutputPolicyMsg::Bounded { max_points } => MtmOutputPolicy::Bounded { max_points },
100 MtmOutputPolicyMsg::Full => MtmOutputPolicy::Full,
101 };
102 policy.validate().map_err(|error| {
103 BacktestServerError::InvalidRequest(format!("invalid mtm_output: {error}"))
104 })?;
105 Ok(policy)
106}
107
108fn mtm_output_policy_to_msg(policy: MtmOutputPolicy) -> MtmOutputPolicyMsg {
109 match policy {
110 MtmOutputPolicy::None => MtmOutputPolicyMsg::None,
111 MtmOutputPolicy::Bounded { max_points } => MtmOutputPolicyMsg::Bounded { max_points },
112 MtmOutputPolicy::Full => MtmOutputPolicyMsg::Full,
113 }
114}
115
116fn mtm_output_summary_to_msg(summary: &MtmOutputSummary) -> MtmOutputSummaryMsg {
117 MtmOutputSummaryMsg {
118 policy: mtm_output_policy_to_msg(summary.policy),
119 observed_points: summary.observed_points,
120 retained_points: summary.retained_points,
121 omitted_points: summary.omitted_points,
122 }
123}
124
125pub fn validate_future_quote_scalars(msg: &FutureQuoteConfigMsg) -> crate::error::Result<()> {
127 account_currency_from_msg(msg)?;
128 if msg.signal_latency_ms < 0 {
129 return Err(BacktestServerError::InvalidRequest(format!(
130 "signal_latency_ms must be non-negative, got {}",
131 msg.signal_latency_ms
132 )));
133 }
134 if !msg.slippage_pips.is_finite() {
135 return Err(BacktestServerError::InvalidRequest(format!(
136 "slippage_pips must be finite, got {}",
137 msg.slippage_pips
138 )));
139 }
140 if msg.stale_quote_after_ms.is_some_and(|value| value < 0) {
141 return Err(BacktestServerError::InvalidRequest(
142 "stale_quote_after_ms must be non-negative".into(),
143 ));
144 }
145 if !msg.pnl_epsilon.is_finite() || msg.pnl_epsilon < 0.0 {
146 return Err(BacktestServerError::InvalidRequest(format!(
147 "pnl_epsilon must be finite and non-negative, got {}",
148 msg.pnl_epsilon
149 )));
150 }
151 if msg.conversion_stale_after_ms < 0 {
152 return Err(BacktestServerError::InvalidRequest(format!(
153 "conversion_stale_after_ms must be non-negative, got {}",
154 msg.conversion_stale_after_ms
155 )));
156 }
157 mtm_output_policy_from_msg(&msg.mtm_output)?;
158 Ok(())
159}
160
161pub fn future_config_from_msg(
163 msg: &FutureQuoteConfigMsg,
164 currency_plan: RunCurrencyPlan,
165) -> crate::error::Result<FutureQuoteConfig> {
166 validate_future_quote_scalars(msg)?;
167 let account_currency = account_currency_from_msg(msg)?;
168 if account_currency != currency_plan.account_currency() {
169 return Err(BacktestServerError::InvalidRequest(format!(
170 "account_currency {account_currency} does not match currency plan {}",
171 currency_plan.account_currency()
172 )));
173 }
174 let mtm_output = mtm_output_policy_from_msg(&msg.mtm_output)?;
175
176 Ok(FutureQuoteConfig {
177 signal_latency_ms: msg.signal_latency_ms,
178 slippage_pips: msg.slippage_pips,
179 stale_quote_after_ms: msg.stale_quote_after_ms,
180 pnl_epsilon: msg.pnl_epsilon,
181 currency_plan: Some(currency_plan),
182 conversion_stale_after_ms: msg.conversion_stale_after_ms,
183 mtm_output,
184 })
185}
186
187pub fn evaluation_options_from_msg(
189 msg: &ProviderEvaluationOptionsMsg,
190 registry: &SymbolRegistry,
191) -> crate::error::Result<EvaluationOptions> {
192 evaluation_options_from_msg_for_symbols(msg, registry, &[])
193}
194
195pub fn evaluation_options_from_msg_for_symbols(
200 msg: &ProviderEvaluationOptionsMsg,
201 registry: &SymbolRegistry,
202 request_symbols: &[String],
203) -> crate::error::Result<EvaluationOptions> {
204 let invalid = |message: String| BacktestServerError::InvalidRequest(message);
205 for (name, value) in [
206 ("provider_id", msg.context.provider_id.as_deref()),
207 ("source_id", msg.context.source_id.as_deref()),
208 ] {
209 if value.is_some_and(|value| value.trim().is_empty()) {
210 return Err(invalid(format!("evaluation {name} must not be empty")));
211 }
212 }
213 if msg.bootstrap.samples == 0 {
214 return Err(invalid(
215 "evaluation bootstrap.samples must be positive".into(),
216 ));
217 }
218 if !msg.bootstrap.confidence_level.is_finite()
219 || !(0.0..1.0).contains(&msg.bootstrap.confidence_level)
220 || msg.bootstrap.confidence_level == 0.0
221 {
222 return Err(invalid(
223 "evaluation bootstrap.confidence_level must be finite and between 0 and 1".into(),
224 ));
225 }
226 if msg.bootstrap.minimum_sample_size == 0 {
227 return Err(invalid(
228 "evaluation bootstrap.minimum_sample_size must be positive".into(),
229 ));
230 }
231 if msg.rolling_window == 0 {
232 return Err(invalid("evaluation rolling_window must be positive".into()));
233 }
234 if msg.minimum_breakdown_bucket_count == 0 {
235 return Err(invalid(
236 "evaluation minimum_breakdown_bucket_count must be positive".into(),
237 ));
238 }
239 if msg.maximum_position_rows.is_some() && !msg.include_positions {
240 return Err(invalid(
241 "evaluation maximum_position_rows requires include_positions=true".into(),
242 ));
243 }
244 if !msg.filter.tags.is_empty() {
245 return Err(invalid(
246 "unsupported evaluation selector: tag filters are not supported by integrated backtests because completed positions have no tags".into(),
247 ));
248 }
249 if msg
250 .breakdowns
251 .iter()
252 .any(|dimension| matches!(dimension, BreakdownDimensionMsg::Tag(_)))
253 {
254 return Err(invalid(
255 "unsupported evaluation selector: tag breakdowns are not supported by integrated backtests because completed positions have no tags".into(),
256 ));
257 }
258
259 let source_coverage = msg.source_coverage.map(|coverage| SourceCoverageCounts {
260 raw_messages: coverage.raw_messages,
261 parsed_messages: coverage.parsed_messages,
262 skipped_messages: coverage.skipped_messages,
263 failed_messages: coverage.failed_messages,
264 emitted_signals: coverage.emitted_signals,
265 emitted_entry_signals: coverage.emitted_entry_signals,
266 });
267 if let Some(error) = source_coverage.and_then(SourceCoverageCounts::validation_error) {
268 return Err(invalid(format!(
269 "invalid evaluation source_coverage: {error}"
270 )));
271 }
272
273 let symbols = msg
274 .filter
275 .symbols
276 .iter()
277 .map(|symbol| normalize_evaluation_symbol(registry, request_symbols, symbol))
278 .collect::<crate::error::Result<Vec<_>>>()?;
279 let sections: BTreeSet<_> = msg.sections.iter().copied().map(section_from_msg).collect();
280 if !msg.breakdowns.is_empty() && !sections.contains(&EvaluationSection::Breakdowns) {
281 return Err(invalid(
282 "evaluation breakdowns require the breakdowns report section".into(),
283 ));
284 }
285
286 Ok(EvaluationOptions {
287 context: EvaluationContext {
288 provider_id: msg.context.provider_id.clone(),
289 source_id: msg.context.source_id.clone(),
290 },
291 source_coverage,
292 sections,
293 filter: PositionFilter {
294 symbols,
295 sides: msg
296 .filter
297 .sides
298 .iter()
299 .copied()
300 .map(position_side_from_msg)
301 .collect(),
302 groups: msg
303 .filter
304 .groups
305 .iter()
306 .cloned()
307 .map(group_filter_from_msg)
308 .collect(),
309 close_reasons: msg.filter.close_reasons.clone(),
310 tags: msg.filter.tags.clone(),
311 },
312 breakdowns: msg
313 .breakdowns
314 .iter()
315 .cloned()
316 .map(breakdown_from_msg)
317 .collect(),
318 bootstrap: BootstrapConfig {
319 samples: msg.bootstrap.samples,
320 confidence_level: msg.bootstrap.confidence_level,
321 seed: msg.bootstrap.seed,
322 minimum_sample_size: msg.bootstrap.minimum_sample_size,
323 },
324 rolling_window: msg.rolling_window,
325 minimum_breakdown_bucket_count: msg.minimum_breakdown_bucket_count,
326 maximum_breakdown_rows: msg.maximum_breakdown_rows,
327 include_position_rows: msg.include_positions,
328 maximum_position_rows: msg.maximum_position_rows,
329 })
330}
331
332fn normalize_evaluation_symbol(
333 registry: &SymbolRegistry,
334 request_symbols: &[String],
335 raw: &str,
336) -> crate::error::Result<String> {
337 let raw = raw.trim();
338 if raw.is_empty() {
339 return Err(BacktestServerError::InvalidRequest(
340 "evaluation symbol filters must not be empty".into(),
341 ));
342 }
343 let normalized = registry.normalize_or_passthrough(raw);
344 if registry.is_known(raw)
345 || request_symbols
346 .iter()
347 .any(|request_symbol| request_symbol == &normalized)
348 {
349 return Ok(normalized);
350 }
351
352 let suggestions = registry.suggest(raw, 3, 3);
353 let suggestion = if suggestions.is_empty() {
354 String::new()
355 } else {
356 format!(
357 "; did you mean {}?",
358 suggestions
359 .iter()
360 .map(|(symbol, _)| format!("`{symbol}`"))
361 .collect::<Vec<_>>()
362 .join(", ")
363 )
364 };
365 Err(BacktestServerError::InvalidRequest(format!(
366 "unknown evaluation symbol `{raw}`{suggestion}"
367 )))
368}
369
370fn section_from_msg(section: EvaluationSectionMsg) -> EvaluationSection {
371 match section {
372 EvaluationSectionMsg::Coverage => EvaluationSection::Coverage,
373 EvaluationSectionMsg::PositionPerformance => EvaluationSection::PositionPerformance,
374 EvaluationSectionMsg::RMetrics => EvaluationSection::RMetrics,
375 EvaluationSectionMsg::Excursions => EvaluationSection::Excursions,
376 EvaluationSectionMsg::Execution => EvaluationSection::Execution,
377 EvaluationSectionMsg::Robustness => EvaluationSection::Robustness,
378 EvaluationSectionMsg::Breakdowns => EvaluationSection::Breakdowns,
379 }
380}
381
382fn position_side_from_msg(side: EvaluationPositionSideMsg) -> PositionSide {
383 match side {
384 EvaluationPositionSideMsg::Long => PositionSide::Long,
385 EvaluationPositionSideMsg::Short => PositionSide::Short,
386 }
387}
388
389fn group_filter_from_msg(group: EvaluationGroupFilterMsg) -> GroupFilter {
390 match group {
391 EvaluationGroupFilterMsg::Named(name) => GroupFilter::Named(name),
392 EvaluationGroupFilterMsg::Ungrouped => GroupFilter::Ungrouped,
393 }
394}
395
396fn breakdown_from_msg(dimension: BreakdownDimensionMsg) -> BreakdownDimension {
397 match dimension {
398 BreakdownDimensionMsg::Symbol => BreakdownDimension::Symbol,
399 BreakdownDimensionMsg::Side => BreakdownDimension::Side,
400 BreakdownDimensionMsg::Group => BreakdownDimension::Group,
401 BreakdownDimensionMsg::CloseReason => BreakdownDimension::CloseReason,
402 BreakdownDimensionMsg::Tag(key) => BreakdownDimension::Tag(key),
403 }
404}
405
406pub fn sizing_from_msg(
407 msg: &SizingPolicyMsg,
408) -> crate::error::Result<qs_backtest::sizing::SizingPolicy> {
409 use qs_backtest::sizing::SizingPolicy;
410 let (name, value, policy) = match msg {
411 SizingPolicyMsg::FixedLot { lots } => {
412 ("fixed lots", *lots, SizingPolicy::FixedLot { lots: *lots })
413 }
414 SizingPolicyMsg::FixedRiskAmount { amount } => (
415 "fixed risk amount",
416 *amount,
417 SizingPolicy::FixedRiskAmount { amount: *amount },
418 ),
419 SizingPolicyMsg::BalanceRiskPercent { percent } => (
420 "balance risk percent",
421 *percent,
422 SizingPolicy::BalanceRiskPercent { percent: *percent },
423 ),
424 };
425 if !value.is_finite() || value <= 0.0 {
426 return Err(BacktestServerError::InvalidRequest(format!(
427 "{name} must be finite and positive, got {value}"
428 )));
429 }
430 Ok(policy)
431}
432
433pub fn parse_fill_model(s: Option<&str>) -> FillModel {
435 match s {
436 Some("AskOnly") => FillModel::AskOnly,
437 Some("MidPrice") => FillModel::MidPrice,
438 _ => FillModel::BidAsk,
439 }
440}
441
442fn target_selection_from_msg(msg: &TargetSelectionMsg) -> TargetSelection {
445 match msg {
446 TargetSelectionMsg::All => TargetSelection::All,
447 TargetSelectionMsg::None => TargetSelection::None,
448 TargetSelectionMsg::Selected(indices) => TargetSelection::Selected(indices.clone()),
449 }
450}
451
452fn target_selection_to_msg(selection: &TargetSelection) -> TargetSelectionMsg {
453 match selection {
454 TargetSelection::All => TargetSelectionMsg::All,
455 TargetSelection::None => TargetSelectionMsg::None,
456 TargetSelection::Selected(indices) => TargetSelectionMsg::Selected(indices.clone()),
457 }
458}
459
460pub fn profile_from_msg(msg: &ManagementProfileMsg) -> crate::error::Result<ManagementProfile> {
466 let stoploss_mode = match &msg.stoploss_mode {
467 Some(StoplossModeMsg::FromSignal) | None => StoplossMode::FromSignal,
468 Some(StoplossModeMsg::None) => StoplossMode::None,
469 Some(StoplossModeMsg::FixedDistance { distance }) => StoplossMode::FixedDistance {
470 distance: *distance,
471 },
472 Some(StoplossModeMsg::FixedPrice { price }) => StoplossMode::FixedPrice { price: *price },
473 };
474
475 let rules: Vec<RuleConfigDef> = msg
476 .rules
477 .iter()
478 .map(|r| match r {
479 RuleConfigDefMsg::FixedStoploss { price } => {
480 RuleConfigDef::FixedStoploss { price: *price }
481 }
482 RuleConfigDefMsg::TrailingStop { distance } => RuleConfigDef::TrailingStop {
483 distance: *distance,
484 },
485 RuleConfigDefMsg::TakeProfit { price, close_ratio } => RuleConfigDef::TakeProfit {
486 price: *price,
487 close_ratio: *close_ratio,
488 },
489 RuleConfigDefMsg::BreakevenWhen { trigger_price } => RuleConfigDef::BreakevenWhen {
490 trigger_price: *trigger_price,
491 },
492 RuleConfigDefMsg::BreakevenWhenOffset {
493 trigger_price_offset,
494 } => RuleConfigDef::BreakevenWhenOffset {
495 trigger_price_offset: *trigger_price_offset,
496 },
497 RuleConfigDefMsg::BreakevenAfterTargets { after_n } => {
498 RuleConfigDef::BreakevenAfterTargets { after_n: *after_n }
499 }
500 RuleConfigDefMsg::TimeExit { max_seconds } => RuleConfigDef::TimeExit {
501 max_seconds: *max_seconds,
502 },
503 })
504 .collect();
505
506 Ok(ManagementProfile {
507 name: msg.name.clone(),
508 target_selection: msg.target_selection.as_ref().map(target_selection_from_msg),
509 use_targets: msg.use_targets.clone(),
510 close_ratios: msg.close_ratios.clone(),
511 stoploss_mode,
512 rules,
513 group_override: msg.group_override.clone(),
514 let_remainder_run: msg.let_remainder_run,
515 })
516}
517
518pub fn profile_to_msg(p: &ManagementProfile) -> ManagementProfileMsg {
520 let stoploss_mode = Some(match &p.stoploss_mode {
521 StoplossMode::FromSignal => StoplossModeMsg::FromSignal,
522 StoplossMode::None => StoplossModeMsg::None,
523 StoplossMode::FixedDistance { distance } => StoplossModeMsg::FixedDistance {
524 distance: *distance,
525 },
526 StoplossMode::FixedPrice { price } => StoplossModeMsg::FixedPrice { price: *price },
527 });
528
529 let rules = p
530 .rules
531 .iter()
532 .map(|r| match r {
533 RuleConfigDef::FixedStoploss { price } => {
534 RuleConfigDefMsg::FixedStoploss { price: *price }
535 }
536 RuleConfigDef::TrailingStop { distance } => RuleConfigDefMsg::TrailingStop {
537 distance: *distance,
538 },
539 RuleConfigDef::TakeProfit { price, close_ratio } => RuleConfigDefMsg::TakeProfit {
540 price: *price,
541 close_ratio: *close_ratio,
542 },
543 RuleConfigDef::BreakevenWhen { trigger_price } => RuleConfigDefMsg::BreakevenWhen {
544 trigger_price: *trigger_price,
545 },
546 RuleConfigDef::BreakevenWhenOffset {
547 trigger_price_offset,
548 } => RuleConfigDefMsg::BreakevenWhenOffset {
549 trigger_price_offset: *trigger_price_offset,
550 },
551 RuleConfigDef::BreakevenAfterTargets { after_n } => {
552 RuleConfigDefMsg::BreakevenAfterTargets { after_n: *after_n }
553 }
554 RuleConfigDef::TimeExit { max_seconds } => RuleConfigDefMsg::TimeExit {
555 max_seconds: *max_seconds,
556 },
557 })
558 .collect();
559
560 ManagementProfileMsg {
561 name: p.name.clone(),
562 target_selection: p.target_selection.as_ref().map(target_selection_to_msg),
563 use_targets: p.use_targets.clone(),
564 close_ratios: p.close_ratios.clone(),
565 stoploss_mode,
566 rules,
567 group_override: p.group_override.clone(),
568 let_remainder_run: p.let_remainder_run,
569 }
570}
571
572pub fn result_to_msg(r: &BacktestResult) -> BacktestResultMsg {
576 BacktestResultMsg {
577 initial_balance: r.initial_balance,
578 final_balance: r.final_balance,
579 total_pnl: r.total_pnl,
580 total_trades: r.total_trades,
581 winning_trades: r.winning_trades,
582 losing_trades: r.losing_trades,
583 win_rate: r.win_rate,
584 profit_factor: sanitize_f64(r.profit_factor),
585 max_drawdown: r.max_drawdown,
586 max_drawdown_pct: r.max_drawdown_pct,
587 summary: subset_stats_to_msg(&r.summary),
588 per_symbol: r
589 .per_symbol
590 .iter()
591 .map(|(k, v)| (k.clone(), subset_stats_to_msg(v)))
592 .collect(),
593 per_group: r
594 .per_group
595 .iter()
596 .map(|(k, v)| (k.clone(), subset_stats_to_msg(v)))
597 .collect(),
598 long_stats: subset_stats_to_msg(&r.long_stats),
599 short_stats: subset_stats_to_msg(&r.short_stats),
600 per_close_reason: r
601 .per_close_reason
602 .iter()
603 .map(close_reason_stats_to_msg)
604 .collect(),
605 streaks: streak_stats_to_msg(&r.streaks),
606 risk_metrics: risk_metrics_to_msg(&r.risk_metrics),
607 duration_stats: r.duration_stats.as_ref().map(duration_stats_to_msg),
608 monthly_returns: r
609 .monthly_returns
610 .iter()
611 .map(monthly_return_to_msg)
612 .collect(),
613 equity_curve: r
614 .equity_curve
615 .iter()
616 .map(|(ts, bal)| EquityPoint {
617 ts: ndt_to_string(*ts),
618 balance: *bal,
619 })
620 .collect(),
621 trade_log: r.trade_log.iter().map(trade_result_to_msg).collect(),
622 positions: r.positions.iter().map(position_summary_to_msg).collect(),
623 total_positions: r.total_positions,
624 winning_positions: r.winning_positions,
625 losing_positions: r.losing_positions,
626 position_win_rate: r.position_win_rate,
627 future: r
628 .execution_metadata
629 .as_ref()
630 .map(|metadata| FutureBacktestResultMsg {
631 format_version: r
632 .future_format_version
633 .unwrap_or(FUTURE_ARTIFACT_FORMAT_VERSION),
634 execution_metadata: serde_json::to_value(metadata)
635 .unwrap_or(serde_json::Value::Null),
636 recorded_fills: serde_json::to_value(&r.recorded_fills)
637 .unwrap_or(serde_json::Value::Null),
638 action_dispositions: serde_json::to_value(&r.action_dispositions)
639 .unwrap_or(serde_json::Value::Null),
640 close_events: serde_json::to_value(&r.close_events)
641 .unwrap_or(serde_json::Value::Null),
642 completed_positions: serde_json::to_value(&r.completed_positions)
643 .unwrap_or(serde_json::Value::Null),
644 open_positions: serde_json::to_value(&r.open_position_snapshots)
645 .unwrap_or(serde_json::Value::Null),
646 pending_orders: serde_json::to_value(&r.pending_order_snapshots)
647 .unwrap_or(serde_json::Value::Null),
648 pending_order_lifecycle: r
649 .pending_order_lifecycle
650 .iter()
651 .map(pending_order_lifecycle_to_msg)
652 .collect(),
653 mtm_equity_curve: serde_json::to_value(&r.mtm_equity_curve)
654 .unwrap_or(serde_json::Value::Null),
655 mtm_output_summary: mtm_output_summary_to_msg(&r.mtm_output_summary),
656 mtm_max_drawdown: r.mtm_max_drawdown,
657 mtm_max_drawdown_pct: r.mtm_max_drawdown_pct,
658 provider_evaluation: serde_json::to_value(&r.provider_evaluation)
659 .unwrap_or(serde_json::Value::Null),
660 }),
661 }
662}
663
664fn pending_order_lifecycle_to_msg(
667 event: &PendingOrderLifecycleEvent,
668) -> PendingOrderLifecycleEventMsg {
669 let state = match event.state {
670 PendingOrderLifecycleState::Placed => PendingOrderLifecycleStateMsg::Placed,
671 PendingOrderLifecycleState::Filled => PendingOrderLifecycleStateMsg::Filled,
672 PendingOrderLifecycleState::Cancelled => PendingOrderLifecycleStateMsg::Cancelled,
673 PendingOrderLifecycleState::UnfilledAtEnd => PendingOrderLifecycleStateMsg::UnfilledAtEnd,
674 };
675 PendingOrderLifecycleEventMsg {
676 id: event.id.clone(),
677 sequence: event.sequence,
678 position_id: event.position_id.clone(),
679 placement_action_id: event.placement_action_id.clone(),
680 terminal_action_id: event.terminal_action_id.clone(),
681 state,
682 symbol: event.symbol.clone(),
683 side: format!("{:?}", event.side),
684 order_type: format!("{:?}", event.order_type),
685 requested_size: event.requested_size,
686 filled_size: event.filled_size,
687 requested_price: event.requested_price,
688 fill_price: event.fill_price,
689 signal_ts: event.signal_ts.map(ndt_to_string),
690 placed_ts: event.placed_ts.map(ndt_to_string),
691 effective_ts: event.effective_ts.map(ndt_to_string),
692 terminal_ts: event.terminal_ts.map(ndt_to_string),
693 wait_latency_ms: event.wait_latency_ms,
694 fill_ratio: event.fill_ratio,
695 }
696}
697
698fn subset_stats_to_msg(s: &SubsetStats) -> SubsetStatsMsg {
699 SubsetStatsMsg {
700 total_trades: s.total_trades,
701 winning_trades: s.winning_trades,
702 losing_trades: s.losing_trades,
703 breakeven_trades: s.breakeven_trades,
704 total_pnl: s.total_pnl,
705 gross_profit: s.gross_profit,
706 gross_loss: s.gross_loss,
707 win_rate: s.win_rate,
708 profit_factor: sanitize_f64(s.profit_factor),
709 avg_win: s.avg_win,
710 avg_loss: s.avg_loss,
711 win_loss_ratio: sanitize_f64(s.win_loss_ratio),
712 expectancy: s.expectancy,
713 largest_win: s.largest_win,
714 largest_loss: s.largest_loss,
715 }
716}
717
718fn streak_stats_to_msg(s: &StreakStats) -> StreakStatsMsg {
719 StreakStatsMsg {
720 max_consecutive_wins: s.max_consecutive_wins,
721 max_consecutive_losses: s.max_consecutive_losses,
722 current_streak: s.current_streak,
723 }
724}
725
726fn risk_metrics_to_msg(r: &RiskMetrics) -> RiskMetricsMsg {
727 RiskMetricsMsg {
728 sharpe_ratio: r.sharpe_ratio,
729 sortino_ratio: r.sortino_ratio,
730 calmar_ratio: r.calmar_ratio,
731 return_on_max_drawdown: r.return_on_max_drawdown,
732 max_drawdown: r.max_drawdown,
733 max_drawdown_pct: r.max_drawdown_pct,
734 max_drawdown_duration_secs: r.max_drawdown_duration_secs,
735 }
736}
737
738fn duration_stats_to_msg(d: &DurationStats) -> DurationStatsMsg {
739 DurationStatsMsg {
740 avg_duration_secs: d.avg_duration_secs,
741 min_duration_secs: d.min_duration_secs,
742 max_duration_secs: d.max_duration_secs,
743 avg_winner_duration_secs: d.avg_winner_duration_secs,
744 avg_loser_duration_secs: d.avg_loser_duration_secs,
745 }
746}
747
748fn monthly_return_to_msg(m: &MonthlyReturn) -> MonthlyReturnMsg {
749 MonthlyReturnMsg {
750 year: m.year,
751 month: m.month,
752 pnl: m.pnl,
753 trade_count: m.trade_count,
754 ending_balance: m.ending_balance,
755 }
756}
757
758fn close_reason_stats_to_msg(c: &CloseReasonStats) -> CloseReasonStatsMsg {
759 CloseReasonStatsMsg {
760 reason: format!("{:?}", c.reason),
761 count: c.count,
762 total_pnl: c.total_pnl,
763 avg_pnl: c.avg_pnl,
764 percentage: c.percentage,
765 }
766}
767
768fn trade_result_to_msg(t: &TradeResult) -> TradeResultMsg {
769 TradeResultMsg {
770 position_id: t.position_id.clone(),
771 symbol: t.symbol.clone(),
772 side: format!("{:?}", t.side),
773 entry_price: t.entry_price,
774 exit_price: t.exit_price,
775 size: t.size,
776 pnl: t.pnl,
777 open_ts: ndt_to_string(t.open_ts),
778 close_ts: ndt_to_string(t.close_ts),
779 close_reason: format!("{:?}", t.close_reason),
780 group: t.group.clone(),
781 }
782}
783
784fn position_summary_to_msg(p: &PositionSummary) -> PositionSummaryMsg {
785 PositionSummaryMsg {
786 position_id: p.position_id.clone(),
787 symbol: p.symbol.clone(),
788 side: format!("{:?}", p.side),
789 group: p.group.clone(),
790 entry_price: p.entry_price,
791 avg_exit_price: p.avg_exit_price,
792 original_size: p.original_size,
793 close_count: p.close_count,
794 net_pnl: p.net_pnl,
795 close_reasons: p.close_reasons.iter().map(|r| format!("{:?}", r)).collect(),
796 open_ts: ndt_to_string(p.open_ts),
797 final_close_ts: Some(ndt_to_string(p.final_close_ts)),
798 duration_seconds: p.duration_seconds,
799 }
800}
801
802fn sanitize_f64(v: f64) -> f64 {
806 if v.is_finite() { v } else { 0.0 }
807}
808
809pub fn position_ref_from_msg(msg: &PositionRefMsg, registry: &SymbolRegistry) -> PositionRef {
813 match msg {
814 PositionRefMsg::ByTradeId { trade_id } => PositionRef::ByTradeId {
815 trade_id: trade_id.clone(),
816 },
817 PositionRefMsg::AllOnSymbol { symbol } => PositionRef::AllOnSymbol {
818 symbol: registry.normalize_or_passthrough(symbol),
819 },
820 PositionRefMsg::AllInGroup { group_id } => PositionRef::AllInGroup {
821 group_id: group_id.clone(),
822 },
823 }
824}
825
826pub fn raw_signal_from_msg(
840 msg: &RawSignalMsg,
841 default_symbol: &str,
842 registry: &SymbolRegistry,
843) -> crate::error::Result<RawSignal> {
844 let signal = decode_raw_signal_msg(msg, default_symbol, registry)?;
845 qs_core::validate_raw_signal(&signal)
846 .map_err(|error| BacktestServerError::InvalidRequest(error.to_string()))?;
847 Ok(signal)
848}
849
850fn decode_raw_signal_msg(
852 msg: &RawSignalMsg,
853 default_symbol: &str,
854 registry: &SymbolRegistry,
855) -> crate::error::Result<RawSignal> {
856 match msg {
857 RawSignalMsg::Entry {
858 ts,
859 symbol,
860 side,
861 order_type,
862 price,
863 risk,
864 stoploss,
865 targets,
866 group,
867 trade_id,
868 } => {
869 let parsed_ts = parse_datetime_internal(ts)?;
870 let parsed_symbol = if symbol.is_empty() {
871 default_symbol.to_string()
872 } else {
873 registry.normalize_or_passthrough(symbol)
874 };
875 let parsed_side = parse_side_internal(side)?;
876 let parsed_order_type = parse_order_type_internal(order_type)?;
877 Ok(RawSignal::Entry {
878 ts: parsed_ts,
879 symbol: parsed_symbol,
880 side: parsed_side,
881 order_type: parsed_order_type,
882 price: *price,
883 risk_multiplier: *risk,
884 stoploss: *stoploss,
885 targets: targets.clone(),
886 group: group.clone(),
887 trade_id: trade_id.clone(),
888 })
889 }
890 RawSignalMsg::Close { ts, position } => Ok(RawSignal::Close {
891 ts: parse_datetime_internal(ts)?,
892 position: position_ref_from_msg(position, registry),
893 }),
894 RawSignalMsg::ClosePartial {
895 ts,
896 position,
897 ratio,
898 } => Ok(RawSignal::ClosePartial {
899 ts: parse_datetime_internal(ts)?,
900 position: position_ref_from_msg(position, registry),
901 ratio: *ratio,
902 }),
903 RawSignalMsg::ModifyStoploss {
904 ts,
905 position,
906 price,
907 } => Ok(RawSignal::ModifyStoploss {
908 ts: parse_datetime_internal(ts)?,
909 position: position_ref_from_msg(position, registry),
910 price: *price,
911 }),
912 RawSignalMsg::MoveStoplossToEntry { ts, position } => Ok(RawSignal::MoveStoplossToEntry {
913 ts: parse_datetime_internal(ts)?,
914 position: position_ref_from_msg(position, registry),
915 }),
916 RawSignalMsg::AddTarget {
917 ts,
918 position,
919 price,
920 close_ratio,
921 } => Ok(RawSignal::AddTarget {
922 ts: parse_datetime_internal(ts)?,
923 position: position_ref_from_msg(position, registry),
924 price: *price,
925 close_ratio: *close_ratio,
926 }),
927 RawSignalMsg::RemoveTarget {
928 ts,
929 position,
930 price,
931 } => Ok(RawSignal::RemoveTarget {
932 ts: parse_datetime_internal(ts)?,
933 position: position_ref_from_msg(position, registry),
934 price: *price,
935 }),
936 RawSignalMsg::ModifyTarget {
937 ts,
938 position,
939 old_price,
940 new_price,
941 } => Ok(RawSignal::ModifyTarget {
942 ts: parse_datetime_internal(ts)?,
943 position: position_ref_from_msg(position, registry),
944 old_price: *old_price,
945 new_price: *new_price,
946 }),
947 RawSignalMsg::AddRule { ts, position, rule } => {
948 let rule_def = rule_config_def_from_msg(rule);
949 Ok(RawSignal::AddRule {
950 ts: parse_datetime_internal(ts)?,
951 position: position_ref_from_msg(position, registry),
952 rule: rule_def,
953 })
954 }
955 RawSignalMsg::RemoveRule {
956 ts,
957 position,
958 rule_name,
959 } => Ok(RawSignal::RemoveRule {
960 ts: parse_datetime_internal(ts)?,
961 position: position_ref_from_msg(position, registry),
962 rule_name: rule_name.clone(),
963 }),
964 RawSignalMsg::ScaleIn {
965 ts,
966 position,
967 price,
968 size,
969 } => Ok(RawSignal::ScaleIn {
970 ts: parse_datetime_internal(ts)?,
971 position: position_ref_from_msg(position, registry),
972 price: *price,
973 size: *size,
974 }),
975 RawSignalMsg::CancelPending { ts, position } => Ok(RawSignal::CancelPending {
976 ts: parse_datetime_internal(ts)?,
977 position: position_ref_from_msg(position, registry),
978 }),
979 RawSignalMsg::CloseAllOf { ts, symbol } => Ok(RawSignal::CloseAllOf {
980 ts: parse_datetime_internal(ts)?,
981 symbol: registry.normalize_or_passthrough(symbol),
982 }),
983 RawSignalMsg::CloseAll { ts } => Ok(RawSignal::CloseAll {
984 ts: parse_datetime_internal(ts)?,
985 }),
986 RawSignalMsg::CancelAllPending { ts } => Ok(RawSignal::CancelAllPending {
987 ts: parse_datetime_internal(ts)?,
988 }),
989 RawSignalMsg::ModifyAllStoploss { ts, symbol, price } => Ok(RawSignal::ModifyAllStoploss {
990 ts: parse_datetime_internal(ts)?,
991 symbol: registry.normalize_or_passthrough(symbol),
992 price: *price,
993 }),
994 RawSignalMsg::CloseAllInGroup { ts, group_id } => Ok(RawSignal::CloseAllInGroup {
995 ts: parse_datetime_internal(ts)?,
996 group_id: group_id.clone(),
997 }),
998 RawSignalMsg::ModifyAllStoplossInGroup {
999 ts,
1000 group_id,
1001 price,
1002 } => Ok(RawSignal::ModifyAllStoplossInGroup {
1003 ts: parse_datetime_internal(ts)?,
1004 group_id: group_id.clone(),
1005 price: *price,
1006 }),
1007 }
1008}
1009
1010fn rule_config_def_from_msg(msg: &RuleConfigDefMsg) -> RuleConfigDef {
1012 match msg {
1013 RuleConfigDefMsg::FixedStoploss { price } => RuleConfigDef::FixedStoploss { price: *price },
1014 RuleConfigDefMsg::TrailingStop { distance } => RuleConfigDef::TrailingStop {
1015 distance: *distance,
1016 },
1017 RuleConfigDefMsg::TakeProfit { price, close_ratio } => RuleConfigDef::TakeProfit {
1018 price: *price,
1019 close_ratio: *close_ratio,
1020 },
1021 RuleConfigDefMsg::BreakevenWhen { trigger_price } => RuleConfigDef::BreakevenWhen {
1022 trigger_price: *trigger_price,
1023 },
1024 RuleConfigDefMsg::BreakevenWhenOffset {
1025 trigger_price_offset,
1026 } => RuleConfigDef::BreakevenWhenOffset {
1027 trigger_price_offset: *trigger_price_offset,
1028 },
1029 RuleConfigDefMsg::BreakevenAfterTargets { after_n } => {
1030 RuleConfigDef::BreakevenAfterTargets { after_n: *after_n }
1031 }
1032 RuleConfigDefMsg::TimeExit { max_seconds } => RuleConfigDef::TimeExit {
1033 max_seconds: *max_seconds,
1034 },
1035 }
1036}
1037
1038fn parse_datetime_internal(s: &str) -> crate::error::Result<NaiveDateTime> {
1041 let formats = [
1042 "%Y-%m-%dT%H:%M:%S%.f",
1043 "%Y-%m-%dT%H:%M:%S",
1044 "%Y-%m-%d %H:%M:%S%.f",
1045 "%Y-%m-%d %H:%M:%S",
1046 "%Y-%m-%d",
1047 ];
1048 for fmt in &formats {
1049 if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
1050 return Ok(dt);
1051 }
1052 }
1053 if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
1054 return Ok(date.and_hms_opt(0, 0, 0).unwrap());
1055 }
1056 Err(BacktestServerError::InvalidRequest(format!(
1057 "Cannot parse datetime: '{s}'."
1058 )))
1059}
1060
1061fn parse_side_internal(s: &str) -> crate::error::Result<Side> {
1062 match s {
1063 "Buy" | "buy" | "BUY" | "Long" | "long" => Ok(Side::Buy),
1064 "Sell" | "sell" | "SELL" | "Short" | "short" => Ok(Side::Sell),
1065 other => Err(BacktestServerError::InvalidRequest(format!(
1066 "Invalid side: '{other}'."
1067 ))),
1068 }
1069}
1070
1071fn parse_order_type_internal(s: &str) -> crate::error::Result<OrderType> {
1072 match s {
1073 "Market" | "market" | "MARKET" => Ok(OrderType::Market),
1074 "Limit" | "limit" | "LIMIT" => Ok(OrderType::Limit),
1075 "Stop" | "stop" | "STOP" => Ok(OrderType::Stop),
1076 other => Err(BacktestServerError::InvalidRequest(format!(
1077 "Invalid order_type: '{other}'."
1078 ))),
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use chrono::NaiveDate;
1086 use qs_backtest::profile::{ManagementProfile, TargetSelection};
1087 use qs_core::types::{CloseReason, OrderType, Side};
1088
1089 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
1090 NaiveDate::from_ymd_opt(2026, 1, 1)
1091 .unwrap()
1092 .and_hms_opt(h, m, s)
1093 .unwrap()
1094 }
1095
1096 #[test]
1097 fn config_defaults() {
1098 let msg = BacktestConfigMsg {
1099 initial_balance: None,
1100 close_on_finish: None,
1101 fill_model: None,
1102 sizing: None,
1103 };
1104 let registry = qs_symbols::SymbolRegistry::empty();
1105 let symbols: Vec<String> = vec![];
1106 let cfg = config_from_msg(&msg, ®istry, &symbols).unwrap();
1107 assert!((cfg.initial_balance - 10_000.0).abs() < f64::EPSILON);
1108 assert!(cfg.close_on_finish);
1109 assert_eq!(cfg.fill_model, FillModel::BidAsk);
1110 }
1111
1112 #[test]
1113 fn config_overrides() {
1114 let msg = BacktestConfigMsg {
1115 initial_balance: Some(50_000.0),
1116 close_on_finish: Some(false),
1117 fill_model: Some("MidPrice".into()),
1118 sizing: None,
1119 };
1120 let registry = qs_symbols::SymbolRegistry::empty();
1121 let symbols: Vec<String> = vec![];
1122 let cfg = config_from_msg(&msg, ®istry, &symbols).unwrap();
1123 assert!((cfg.initial_balance - 50_000.0).abs() < f64::EPSILON);
1124 assert!(!cfg.close_on_finish);
1125 assert_eq!(cfg.fill_model, FillModel::MidPrice);
1126 }
1127
1128 #[test]
1129 fn config_rejects_invalid_sizing_value() {
1130 let msg = BacktestConfigMsg {
1131 initial_balance: None,
1132 close_on_finish: None,
1133 fill_model: None,
1134 sizing: Some(SizingPolicyMsg::BalanceRiskPercent { percent: 0.0 }),
1135 };
1136 let error = config_from_msg(&msg, &SymbolRegistry::empty(), &[]).unwrap_err();
1137 assert!(error.to_string().contains("balance risk percent"));
1138 }
1139
1140 #[test]
1141 fn future_config_validates_and_embeds_currency_plan() {
1142 use qs_backtest::currency::ConversionRoute;
1143 use std::collections::{BTreeMap, BTreeSet};
1144
1145 let primary_symbols = BTreeSet::from(["eurusd".to_owned()]);
1146 let pnl = BTreeMap::from([("eurusd".to_owned(), "USD".to_owned())]);
1147 let routes = BTreeMap::from([(
1148 "USD".to_owned(),
1149 ConversionRoute::Identity {
1150 currency: "USD".to_owned(),
1151 },
1152 )]);
1153 let plan = RunCurrencyPlan::new(
1154 "USD",
1155 primary_symbols,
1156 BTreeSet::new(),
1157 pnl,
1158 routes,
1159 Vec::new(),
1160 )
1161 .unwrap();
1162 let msg = FutureQuoteConfigMsg {
1163 account_currency: " usd ".into(),
1164 conversion_stale_after_ms: 42_000,
1165 ..FutureQuoteConfigMsg::default()
1166 };
1167 let config = future_config_from_msg(&msg, plan).unwrap();
1168 assert_eq!(config.conversion_stale_after_ms, 42_000);
1169 assert_eq!(
1170 config.mtm_output,
1171 MtmOutputPolicy::Bounded { max_points: 4_096 }
1172 );
1173 assert_eq!(config.currency_plan.unwrap().account_currency(), "USD");
1174 }
1175
1176 #[test]
1177 fn mtm_output_policy_maps_and_validates_internal_bounds() {
1178 for (message, expected) in [
1179 (MtmOutputPolicyMsg::None, MtmOutputPolicy::None),
1180 (
1181 MtmOutputPolicyMsg::Bounded { max_points: 512 },
1182 MtmOutputPolicy::Bounded { max_points: 512 },
1183 ),
1184 (MtmOutputPolicyMsg::Full, MtmOutputPolicy::Full),
1185 ] {
1186 assert_eq!(mtm_output_policy_from_msg(&message).unwrap(), expected);
1187 }
1188
1189 for max_points in [7, 16_385] {
1190 let error = mtm_output_policy_from_msg(&MtmOutputPolicyMsg::Bounded { max_points })
1191 .unwrap_err();
1192 assert!(error.to_string().contains("invalid mtm_output"));
1193 assert!(error.to_string().contains(&max_points.to_string()));
1194 }
1195 }
1196
1197 #[test]
1198 fn fill_model_parsing() {
1199 assert_eq!(parse_fill_model(Some("BidAsk")), FillModel::BidAsk);
1200 assert_eq!(parse_fill_model(Some("AskOnly")), FillModel::AskOnly);
1201 assert_eq!(parse_fill_model(Some("MidPrice")), FillModel::MidPrice);
1202 assert_eq!(parse_fill_model(Some("unknown")), FillModel::BidAsk);
1203 assert_eq!(parse_fill_model(None), FillModel::BidAsk);
1204 }
1205
1206 #[test]
1207 fn trade_result_converts() {
1208 let tr = TradeResult {
1209 position_id: "p1".into(),
1210 symbol: "eurusd".into(),
1211 side: Side::Buy,
1212 entry_price: 1.0850,
1213 exit_price: 1.0900,
1214 size: 1.0,
1215 pnl: 50.0,
1216 open_ts: ts(10, 0, 0),
1217 close_ts: ts(11, 0, 0),
1218 close_reason: CloseReason::Target,
1219 group: Some("g1".into()),
1220 };
1221 let msg = trade_result_to_msg(&tr);
1222 assert_eq!(msg.position_id, "p1");
1223 assert_eq!(msg.side, "Buy");
1224 assert_eq!(msg.close_reason, "Target");
1225 assert_eq!(msg.group, Some("g1".into()));
1226 assert!(msg.open_ts.contains("2026-01-01"));
1227 }
1228
1229 #[test]
1230 fn subset_stats_sanitizes_infinity() {
1231 let s = SubsetStats {
1232 total_trades: 2,
1233 winning_trades: 2,
1234 losing_trades: 0,
1235 breakeven_trades: 0,
1236 total_pnl: 100.0,
1237 gross_profit: 100.0,
1238 gross_loss: 0.0,
1239 win_rate: 1.0,
1240 profit_factor: f64::INFINITY,
1241 avg_win: 50.0,
1242 avg_loss: 0.0,
1243 win_loss_ratio: f64::INFINITY,
1244 expectancy: 50.0,
1245 largest_win: 60.0,
1246 largest_loss: 0.0,
1247 };
1248 let msg = subset_stats_to_msg(&s);
1249 assert!((msg.profit_factor - 0.0).abs() < f64::EPSILON);
1250 assert!((msg.win_loss_ratio - 0.0).abs() < f64::EPSILON);
1251 }
1252
1253 #[test]
1254 fn equity_point_timestamp_format() {
1255 let ts_val = ts(14, 30, 15);
1256 let s = ndt_to_string(ts_val);
1257 assert!(s.starts_with("2026-01-01T14:30:15"));
1258 }
1259
1260 #[test]
1261 fn empty_result_converts_without_panic() {
1262 let result = BacktestResult::from_trade_log(10_000.0, Vec::new());
1263 let msg = result_to_msg(&result);
1264 assert_eq!(msg.total_trades, 0);
1265 assert!(msg.trade_log.is_empty());
1266 assert!(msg.equity_curve.is_empty());
1267 assert!(msg.positions.is_empty());
1268 }
1269
1270 #[test]
1271 fn future_result_converts_pending_order_lifecycle_to_typed_message() {
1272 let mut result = BacktestResult::from_trade_log(10_000.0, Vec::new());
1273 result.execution_metadata = Some(qs_backtest::ExecutionMetadata::default());
1274 result.mtm_output_summary = MtmOutputSummary {
1275 policy: MtmOutputPolicy::Full,
1276 observed_points: 12,
1277 retained_points: 12,
1278 omitted_points: 0,
1279 };
1280 result.pending_order_lifecycle = vec![PendingOrderLifecycleEvent {
1281 id: "position-1:pending_filled:00000001".into(),
1282 sequence: 1,
1283 position_id: "position-1".into(),
1284 placement_action_id: Some("signal:00000000".into()),
1285 state: PendingOrderLifecycleState::Filled,
1286 symbol: "EURUSD".into(),
1287 side: Side::Buy,
1288 order_type: OrderType::Limit,
1289 requested_size: 2.0,
1290 filled_size: Some(2.0),
1291 requested_price: Some(1.1),
1292 fill_price: Some(1.09),
1293 signal_ts: Some(ts(10, 0, 0)),
1294 placed_ts: Some(ts(10, 0, 1)),
1295 effective_ts: Some(ts(10, 0, 0)),
1296 terminal_ts: Some(ts(10, 0, 3)),
1297 wait_latency_ms: Some(2_000),
1298 fill_ratio: Some(1.0),
1299 ..PendingOrderLifecycleEvent::default()
1300 }];
1301
1302 let future = result_to_msg(&result).future.expect("FutureQuote payload");
1303 assert_eq!(
1304 future.mtm_output_summary,
1305 MtmOutputSummaryMsg {
1306 policy: MtmOutputPolicyMsg::Full,
1307 observed_points: 12,
1308 retained_points: 12,
1309 omitted_points: 0,
1310 }
1311 );
1312 assert_eq!(future.pending_order_lifecycle.len(), 1);
1313 let event = &future.pending_order_lifecycle[0];
1314 assert_eq!(event.state, PendingOrderLifecycleStateMsg::Filled);
1315 assert_eq!(event.order_type, "Limit");
1316 assert_eq!(event.requested_size, 2.0);
1317 assert_eq!(event.filled_size, Some(2.0));
1318 assert_eq!(event.wait_latency_ms, Some(2_000));
1319 assert_eq!(event.fill_ratio, Some(1.0));
1320 assert_eq!(event.terminal_ts.as_deref(), Some("2026-01-01T10:00:03"));
1321 }
1322
1323 #[test]
1326 fn profile_from_msg_basic() {
1327 let msg = ManagementProfileMsg {
1328 name: "test".into(),
1329 target_selection: None,
1330 use_targets: vec![1, 2],
1331 close_ratios: vec![0.5, 0.5],
1332 stoploss_mode: Some(StoplossModeMsg::FromSignal),
1333 rules: vec![RuleConfigDefMsg::TrailingStop { distance: 10.0 }],
1334 group_override: Some("grp".into()),
1335 let_remainder_run: true,
1336 };
1337 let p = profile_from_msg(&msg).unwrap();
1338 assert_eq!(p.name, "test");
1339 assert_eq!(p.use_targets, vec![1, 2]);
1340 assert_eq!(p.close_ratios, vec![0.5, 0.5]);
1341 assert!(matches!(p.stoploss_mode, StoplossMode::FromSignal));
1342 assert_eq!(p.rules.len(), 1);
1343 assert_eq!(p.group_override, Some("grp".into()));
1344 assert!(p.let_remainder_run);
1345 }
1346
1347 #[test]
1348 fn profile_from_msg_defaults() {
1349 let msg = ManagementProfileMsg {
1350 name: "minimal".into(),
1351 target_selection: None,
1352 use_targets: vec![1],
1353 close_ratios: vec![1.0],
1354 stoploss_mode: None,
1355 rules: vec![],
1356 group_override: None,
1357 let_remainder_run: false,
1358 };
1359 let p = profile_from_msg(&msg).unwrap();
1360 assert!(matches!(p.stoploss_mode, StoplossMode::FromSignal));
1361 assert!(p.rules.is_empty());
1362 assert!(p.group_override.is_none());
1363 assert!(!p.let_remainder_run);
1364 }
1365
1366 #[test]
1367 fn profile_from_msg_all_stoploss_modes() {
1368 let msg = ManagementProfileMsg {
1370 name: "a".into(),
1371 target_selection: None,
1372 use_targets: vec![1],
1373 close_ratios: vec![1.0],
1374 stoploss_mode: Some(StoplossModeMsg::FromSignal),
1375 rules: vec![],
1376 group_override: None,
1377 let_remainder_run: false,
1378 };
1379 let p = profile_from_msg(&msg).unwrap();
1380 assert!(matches!(p.stoploss_mode, StoplossMode::FromSignal));
1381
1382 let msg2 = ManagementProfileMsg {
1384 stoploss_mode: Some(StoplossModeMsg::None),
1385 ..msg.clone()
1386 };
1387 let p2 = profile_from_msg(&msg2).unwrap();
1388 assert!(matches!(p2.stoploss_mode, StoplossMode::None));
1389
1390 let msg3 = ManagementProfileMsg {
1392 stoploss_mode: Some(StoplossModeMsg::FixedDistance { distance: 50.0 }),
1393 ..msg.clone()
1394 };
1395 let p3 = profile_from_msg(&msg3).unwrap();
1396 assert!(matches!(
1397 p3.stoploss_mode,
1398 StoplossMode::FixedDistance { distance } if (distance - 50.0).abs() < f64::EPSILON
1399 ));
1400
1401 let msg4 = ManagementProfileMsg {
1403 stoploss_mode: Some(StoplossModeMsg::FixedPrice { price: 1.0800 }),
1404 ..msg.clone()
1405 };
1406 let p4 = profile_from_msg(&msg4).unwrap();
1407 assert!(matches!(
1408 p4.stoploss_mode,
1409 StoplossMode::FixedPrice { price } if (price - 1.0800).abs() < f64::EPSILON
1410 ));
1411 }
1412
1413 #[test]
1414 fn profile_from_msg_all_rule_types() {
1415 let rules = vec![
1416 RuleConfigDefMsg::FixedStoploss { price: 1.0 },
1417 RuleConfigDefMsg::TrailingStop { distance: 10.0 },
1418 RuleConfigDefMsg::TakeProfit {
1419 price: 2.0,
1420 close_ratio: 0.5,
1421 },
1422 RuleConfigDefMsg::BreakevenWhen { trigger_price: 1.5 },
1423 RuleConfigDefMsg::BreakevenWhenOffset {
1424 trigger_price_offset: 0.5,
1425 },
1426 RuleConfigDefMsg::BreakevenAfterTargets { after_n: 2 },
1427 RuleConfigDefMsg::TimeExit { max_seconds: 3600 },
1428 ];
1429 let msg = ManagementProfileMsg {
1430 name: "allrules".into(),
1431 target_selection: None,
1432 use_targets: vec![1],
1433 close_ratios: vec![1.0],
1434 stoploss_mode: None,
1435 rules,
1436 group_override: None,
1437 let_remainder_run: false,
1438 };
1439 let p = profile_from_msg(&msg).unwrap();
1440 assert_eq!(p.rules.len(), 7);
1441 assert!(matches!(p.rules[0], RuleConfigDef::FixedStoploss { .. }));
1442 assert!(matches!(p.rules[1], RuleConfigDef::TrailingStop { .. }));
1443 assert!(matches!(p.rules[2], RuleConfigDef::TakeProfit { .. }));
1444 assert!(matches!(p.rules[3], RuleConfigDef::BreakevenWhen { .. }));
1445 assert!(matches!(
1446 p.rules[4],
1447 RuleConfigDef::BreakevenWhenOffset { .. }
1448 ));
1449 assert!(matches!(
1450 p.rules[5],
1451 RuleConfigDef::BreakevenAfterTargets { .. }
1452 ));
1453 assert!(matches!(p.rules[6], RuleConfigDef::TimeExit { .. }));
1454 }
1455
1456 #[test]
1457 fn profile_target_selection_serde_and_conversion_roundtrip() {
1458 let selections = [
1459 TargetSelectionMsg::All,
1460 TargetSelectionMsg::None,
1461 TargetSelectionMsg::Selected(vec![2, 1]),
1462 ];
1463
1464 for selection in selections {
1465 let msg = ManagementProfileMsg {
1466 name: "selection".into(),
1467 target_selection: Some(selection.clone()),
1468 use_targets: vec![1],
1469 close_ratios: vec![],
1470 stoploss_mode: None,
1471 rules: vec![],
1472 group_override: None,
1473 let_remainder_run: false,
1474 };
1475 let json = serde_json::to_value(&msg).unwrap();
1476 assert!(json.get("target_selection").is_some());
1477
1478 let decoded: ManagementProfileMsg = serde_json::from_value(json).unwrap();
1479 assert_eq!(decoded.target_selection, Some(selection.clone()));
1480
1481 let profile = profile_from_msg(&decoded).unwrap();
1482 let roundtrip = profile_to_msg(&profile);
1483 assert_eq!(roundtrip.target_selection, Some(selection));
1484 assert!(roundtrip.close_ratios.is_empty());
1485 }
1486 }
1487
1488 #[test]
1489 fn legacy_profile_msg_omission_uses_legacy_selection_default() {
1490 let json = serde_json::json!({
1491 "name": "legacy",
1492 "use_targets": [2],
1493 "close_ratios": [1.0]
1494 });
1495 let msg: ManagementProfileMsg = serde_json::from_value(json).unwrap();
1496 assert_eq!(msg.target_selection, None);
1497
1498 let profile = profile_from_msg(&msg).unwrap();
1499 assert_eq!(profile.target_selection, None);
1500 assert_eq!(
1501 profile.effective_target_selection(),
1502 TargetSelection::Selected(vec![2])
1503 );
1504 assert_eq!(profile_to_msg(&profile).target_selection, None);
1505 }
1506
1507 #[test]
1508 fn profile_to_msg_roundtrip() {
1509 let original = ManagementProfile {
1510 name: "rt".into(),
1511 target_selection: Some(TargetSelection::Selected(vec![2, 1])),
1512 use_targets: vec![1, 2],
1513 close_ratios: vec![0.6, 0.4],
1514 stoploss_mode: StoplossMode::FixedDistance { distance: 25.0 },
1515 rules: vec![
1516 RuleConfigDef::TrailingStop { distance: 15.0 },
1517 RuleConfigDef::TimeExit { max_seconds: 7200 },
1518 ],
1519 group_override: Some("mygroup".into()),
1520 let_remainder_run: true,
1521 };
1522 let msg = profile_to_msg(&original);
1523 let back = profile_from_msg(&msg).unwrap();
1524
1525 assert_eq!(back.name, original.name);
1526 assert_eq!(back.target_selection, original.target_selection);
1527 assert_eq!(back.use_targets, original.use_targets);
1528 assert_eq!(back.close_ratios, original.close_ratios);
1529 assert!(matches!(
1530 back.stoploss_mode,
1531 StoplossMode::FixedDistance { distance } if (distance - 25.0).abs() < f64::EPSILON
1532 ));
1533 assert_eq!(back.rules.len(), 2);
1534 assert_eq!(back.group_override, original.group_override);
1535 assert_eq!(back.let_remainder_run, original.let_remainder_run);
1536 }
1537
1538 #[test]
1541 fn position_ref_from_msg_id() {
1542 let reg = qs_symbols::SymbolRegistry::empty();
1543 let msg = PositionRefMsg::ByTradeId {
1544 trade_id: "pos_123".into(),
1545 };
1546 let result = position_ref_from_msg(&msg, ®);
1547 assert!(matches!(result, PositionRef::ByTradeId { trade_id } if trade_id == "pos_123"));
1548 }
1549
1550 #[test]
1551 fn position_ref_from_msg_all_on_symbol_normalizes() {
1552 let reg = qs_symbols::SymbolRegistry::empty();
1553 let msg = PositionRefMsg::AllOnSymbol {
1554 symbol: "EUR/USD".into(),
1555 };
1556 let result = position_ref_from_msg(&msg, ®);
1557 assert!(matches!(result, PositionRef::AllOnSymbol { symbol } if symbol == "eurusd"));
1559 }
1560
1561 #[test]
1562 fn position_ref_from_msg_all_in_group() {
1563 let reg = qs_symbols::SymbolRegistry::empty();
1564 let msg = PositionRefMsg::AllInGroup {
1565 group_id: "scalp".into(),
1566 };
1567 let result = position_ref_from_msg(&msg, ®);
1568 assert!(matches!(result, PositionRef::AllInGroup { group_id } if group_id == "scalp"));
1569 }
1570
1571 #[test]
1572 fn raw_signal_from_msg_entry_basic() {
1573 let reg = qs_symbols::SymbolRegistry::empty();
1574 let msg = RawSignalMsg::Entry {
1575 ts: "2026-01-15T10:00:00".into(),
1576 symbol: "eurusd".into(),
1577 side: "Buy".into(),
1578 order_type: "Market".into(),
1579 price: None,
1580 risk: 0.02,
1581 stoploss: Some(1.0800),
1582 targets: vec![1.0900],
1583 group: Some("grp".into()),
1584 trade_id: Some("t1".into()),
1585 };
1586 let result = raw_signal_from_msg(&msg, "default", ®).unwrap();
1587 assert!(result.is_entry());
1588 match &result {
1589 RawSignal::Entry {
1590 symbol,
1591 side,
1592 order_type,
1593 risk_multiplier,
1594 stoploss,
1595 targets,
1596 group,
1597 trade_id,
1598 ..
1599 } => {
1600 assert_eq!(symbol, "eurusd");
1601 assert_eq!(*side, Side::Buy);
1602 assert_eq!(*order_type, OrderType::Market);
1603 assert_eq!(*risk_multiplier, 0.02);
1604 assert_eq!(*stoploss, Some(1.0800));
1605 assert_eq!(*targets, vec![1.0900]);
1606 assert_eq!(*group, Some("grp".into()));
1607 assert_eq!(trade_id.as_deref(), Some("t1"));
1608 }
1609 _ => panic!("Expected Entry"),
1610 }
1611 }
1612
1613 #[test]
1614 fn raw_signal_from_msg_close_partial() {
1615 let reg = qs_symbols::SymbolRegistry::empty();
1616 let msg = RawSignalMsg::ClosePartial {
1617 ts: "2026-01-15T10:30:00".into(),
1618 position: PositionRefMsg::ByTradeId {
1619 trade_id: "t1".into(),
1620 },
1621 ratio: 0.5,
1622 };
1623 let result = raw_signal_from_msg(&msg, "eurusd", ®).unwrap();
1624 match result {
1625 RawSignal::ClosePartial {
1626 ratio, position, ..
1627 } => {
1628 assert!((ratio - 0.5).abs() < f64::EPSILON);
1629 assert!(
1630 matches!(position, PositionRef::ByTradeId { trade_id } if trade_id == "t1")
1631 );
1632 }
1633 _ => panic!("Expected ClosePartial"),
1634 }
1635 }
1636
1637 #[test]
1638 fn raw_signal_from_msg_modify_target() {
1639 let reg = qs_symbols::SymbolRegistry::empty();
1640 let msg = RawSignalMsg::ModifyTarget {
1641 ts: "2026-01-15T10:25:00".into(),
1642 position: PositionRefMsg::ByTradeId {
1643 trade_id: "targeted".into(),
1644 },
1645 old_price: 1.0900,
1646 new_price: 1.0950,
1647 };
1648
1649 let result = raw_signal_from_msg(&msg, "eurusd", ®).unwrap();
1650
1651 assert!(matches!(
1652 result,
1653 RawSignal::ModifyTarget {
1654 position: PositionRef::ByTradeId { trade_id },
1655 old_price,
1656 new_price,
1657 ..
1658 } if trade_id == "targeted"
1659 && (old_price - 1.0900).abs() < f64::EPSILON
1660 && (new_price - 1.0950).abs() < f64::EPSILON
1661 ));
1662 }
1663
1664 #[test]
1665 fn raw_signal_from_msg_add_rule_trailing() {
1666 let reg = qs_symbols::SymbolRegistry::empty();
1667 let msg = RawSignalMsg::AddRule {
1668 ts: "2026-01-15T10:30:00".into(),
1669 position: PositionRefMsg::ByTradeId {
1670 trade_id: "p1".into(),
1671 },
1672 rule: RuleConfigDefMsg::TrailingStop { distance: 0.0020 },
1673 };
1674 let result = raw_signal_from_msg(&msg, "eurusd", ®).unwrap();
1675 match result {
1676 RawSignal::AddRule { rule, .. } => {
1677 assert!(
1678 matches!(rule, RuleConfigDef::TrailingStop { distance } if (distance - 0.0020).abs() < f64::EPSILON)
1679 );
1680 }
1681 _ => panic!("Expected AddRule"),
1682 }
1683 }
1684
1685 #[test]
1686 fn raw_signal_from_msg_scale_in() {
1687 let reg = qs_symbols::SymbolRegistry::empty();
1688 let msg = RawSignalMsg::ScaleIn {
1689 ts: "2026-01-15T10:30:00".into(),
1690 position: PositionRefMsg::ByTradeId {
1691 trade_id: "g1-trade-1".into(),
1692 },
1693 price: Some(1.0850),
1694 size: 0.01,
1695 };
1696 let result = raw_signal_from_msg(&msg, "eurusd", ®).unwrap();
1697 match result {
1698 RawSignal::ScaleIn {
1699 price,
1700 size,
1701 position,
1702 ..
1703 } => {
1704 assert_eq!(price, Some(1.0850));
1705 assert_eq!(size, 0.01);
1706 assert!(
1707 matches!(position, PositionRef::ByTradeId { trade_id } if trade_id == "g1-trade-1")
1708 );
1709 }
1710 _ => panic!("Expected ScaleIn"),
1711 }
1712 }
1713
1714 #[test]
1715 fn raw_signal_from_msg_bulk_close_all_in_group() {
1716 let reg = qs_symbols::SymbolRegistry::empty();
1717 let msg = RawSignalMsg::CloseAllInGroup {
1718 ts: "2026-01-15T11:00:00".into(),
1719 group_id: "momentum".into(),
1720 };
1721 let result = raw_signal_from_msg(&msg, "eurusd", ®).unwrap();
1722 match result {
1723 RawSignal::CloseAllInGroup { group_id, .. } => {
1724 assert_eq!(group_id, "momentum");
1725 }
1726 _ => panic!("Expected CloseAllInGroup"),
1727 }
1728 }
1729
1730 #[test]
1731 fn raw_signal_from_msg_invalid_side_errors() {
1732 let reg = qs_symbols::SymbolRegistry::empty();
1733 let msg = RawSignalMsg::Entry {
1734 ts: "2026-01-15T10:00:00".into(),
1735 symbol: "eurusd".into(),
1736 side: "WRONG".into(),
1737 order_type: "Market".into(),
1738 price: None,
1739 trade_id: None,
1740 risk: 0.01,
1741 stoploss: None,
1742 targets: vec![],
1743 group: None,
1744 };
1745 assert!(raw_signal_from_msg(&msg, "eurusd", ®).is_err());
1746 }
1747
1748 #[test]
1749 fn raw_signal_from_msg_invalid_ts_errors() {
1750 let reg = qs_symbols::SymbolRegistry::empty();
1751 let msg = RawSignalMsg::CloseAll {
1752 ts: "bad-date".into(),
1753 };
1754 assert!(raw_signal_from_msg(&msg, "eurusd", ®).is_err());
1755 }
1756
1757 #[test]
1758 fn raw_signal_from_msg_empty_symbol_uses_default() {
1759 let reg = qs_symbols::SymbolRegistry::empty();
1760 let msg = RawSignalMsg::Entry {
1761 ts: "2026-01-15T10:00:00".into(),
1762 symbol: "".into(),
1763 side: "Sell".into(),
1764 order_type: "Limit".into(),
1765 price: Some(1.0900),
1766 risk: 0.01,
1767 stoploss: None,
1768 targets: vec![],
1769 trade_id: None,
1770 group: None,
1771 };
1772 let result = raw_signal_from_msg(&msg, "xauusd", ®).unwrap();
1773 assert!(result.is_entry());
1774 match &result {
1775 RawSignal::Entry { symbol, .. } => assert_eq!(symbol, "xauusd"),
1776 _ => panic!("expected Entry"),
1777 }
1778 }
1779
1780 #[test]
1781 fn rule_config_def_from_msg_all_variants() {
1782 let cases: Vec<(RuleConfigDefMsg, &str)> = vec![
1783 (
1784 RuleConfigDefMsg::FixedStoploss { price: 1.08 },
1785 "FixedStoploss",
1786 ),
1787 (
1788 RuleConfigDefMsg::TrailingStop { distance: 0.002 },
1789 "TrailingStop",
1790 ),
1791 (
1792 RuleConfigDefMsg::TakeProfit {
1793 price: 1.10,
1794 close_ratio: 0.5,
1795 },
1796 "TakeProfit",
1797 ),
1798 (
1799 RuleConfigDefMsg::BreakevenWhen {
1800 trigger_price: 1.09,
1801 },
1802 "BreakevenWhen",
1803 ),
1804 (
1805 RuleConfigDefMsg::BreakevenWhenOffset {
1806 trigger_price_offset: 0.005,
1807 },
1808 "BreakevenWhenOffset",
1809 ),
1810 (
1811 RuleConfigDefMsg::BreakevenAfterTargets { after_n: 2 },
1812 "BreakevenAfterTargets",
1813 ),
1814 (RuleConfigDefMsg::TimeExit { max_seconds: 3600 }, "TimeExit"),
1815 ];
1816 for (msg, expected_name) in cases {
1817 let result = rule_config_def_from_msg(&msg);
1818 let debug_str = format!("{:?}", result);
1819 assert!(
1820 debug_str.contains(expected_name),
1821 "Expected {} in {:?}",
1822 expected_name,
1823 debug_str
1824 );
1825 }
1826 }
1827}