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