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