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