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