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