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