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