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