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