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