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