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