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