Skip to main content

backtest_server/
convert.rs

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