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