Skip to main content

qs_backtest_api/
rpc_types.rs

1//! Logical request, response, and event types for the backtest service.
2//!
3//! All structs use serde and remain independent of the selected transport/provider codec.
4//! Enums are represented as strings at the service boundary for readability where retained compatibility requires it.
5
6use std::collections::{BTreeMap, HashMap};
7
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9
10fn default_true() -> bool {
11    true
12}
13
14/// Current format version for complete result JSON artifacts.
15pub const RESULT_FORMAT_VERSION: u32 = 1;
16
17// ── Ping ────────────────────────────────────────────────────────────────────
18
19/// Health check response with server status.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PingResponse {
22    pub status: String,
23    pub uptime_secs: u64,
24    pub data_dir: String,
25}
26
27// ── List Profiles ───────────────────────────────────────────────────────────
28
29/// Summary of a management profile available on the server.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProfileInfo {
32    pub name: String,
33    pub use_targets: Vec<usize>,
34    pub close_ratios: Vec<f64>,
35    pub stoploss_mode: String,
36    pub rules_count: usize,
37    pub let_remainder_run: bool,
38}
39
40/// Response listing all loaded management profiles.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ListProfilesResponse {
43    pub profiles: Vec<ProfileInfo>,
44}
45
46// ── List Symbols ────────────────────────────────────────────────────────────
47
48/// Request to list available data, optionally filtered.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ListSymbolsRequest {
51    pub exchange: Option<String>,
52    pub data_type: Option<String>,
53}
54
55/// One row of data availability info.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SymbolAvailability {
58    pub exchange: String,
59    pub symbol: String,
60    pub data_type: String,
61    pub timeframe: Option<String>,
62    pub row_count: u64,
63    pub earliest: String,
64    pub latest: String,
65}
66
67/// Response listing available market data in the store.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ListSymbolsResponse {
70    pub symbols: Vec<SymbolAvailability>,
71}
72
73// ── Backtest Config ─────────────────────────────────────────────────────────
74
75/// Serializable backtest configuration sent by the client.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct BacktestConfigMsg {
78    /// Starting account balance. Default: 10000.0
79    pub initial_balance: Option<f64>,
80    /// Whether to force-close open positions at end of data. Default: true
81    pub close_on_finish: Option<bool>,
82    /// Fill model: "BidAsk", "AskOnly", or "MidPrice". Default: "BidAsk"
83    pub fill_model: Option<String>,
84    /// Account sizing policy. Required when the request contains an Entry signal.
85    #[serde(default)]
86    pub sizing: Option<SizingPolicyMsg>,
87}
88
89/// Wire-safe in-place account sizing policy.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(tag = "type", deny_unknown_fields)]
92pub enum SizingPolicyMsg {
93    /// Use a fixed lot quantity scaled by the entry risk multiplier.
94    FixedLot { lots: f64 },
95    /// Risk a fixed amount in account currency.
96    FixedRiskAmount { amount: f64 },
97    /// Risk a percentage of the realized balance before the entry.
98    BalanceRiskPercent { percent: f64 },
99}
100
101// ── Async Job API (Issue 2) ─────────────────────────────────────────────────
102
103/// Response from submitting a backtest job.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct SubmitBacktestResponse {
106    pub success: bool,
107    pub job_id: Option<String>,
108    pub error: Option<String>,
109}
110
111/// Request the current status of a backtest job.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct GetBacktestStatusRequest {
114    pub job_id: String,
115}
116
117/// Subscribe to status snapshots for a retained backtest job.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct WatchBacktestRequest {
120    pub job_id: String,
121}
122
123/// Structured progress for an asynchronous backtest job.
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(default)]
126pub struct BacktestProgress {
127    pub stage: String,
128    pub processed_events: u64,
129    pub total_events: u64,
130    pub processed_signals: u64,
131    pub total_signals: u64,
132    pub processed_symbols: u64,
133    pub total_symbols: u64,
134}
135
136/// Status of a backtest job.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct BacktestStatusResponse {
139    pub success: bool,
140    pub job_id: String,
141    pub status: String,
142    pub error: Option<String>,
143    pub elapsed_ms: Option<u64>,
144    /// Missing in older responses; defaults to an empty progress snapshot.
145    #[serde(default)]
146    pub progress: BacktestProgress,
147}
148
149impl BacktestStatusResponse {
150    /// Whether this snapshot represents a terminal retained-job state.
151    pub fn is_terminal(&self) -> bool {
152        matches!(self.status.as_str(), "Completed" | "Failed" | "Cancelled")
153    }
154}
155
156/// Server-streamed retained-job status and liveness events.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(tag = "type", rename_all = "snake_case")]
159pub enum BacktestEvent {
160    Snapshot { status: BacktestStatusResponse },
161    Heartbeat { job_id: String, elapsed_ms: u64 },
162}
163
164/// Request the result of a completed backtest job.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct GetBacktestResultRequest {
167    pub job_id: String,
168}
169
170/// Response containing the result of a completed backtest job.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct GetBacktestResultResponse {
173    pub success: bool,
174    pub job_id: String,
175    pub result: Option<BacktestResultMsg>,
176    pub error: Option<String>,
177    #[serde(default)]
178    pub artifact: Option<ResultArtifactRefMsg>,
179    #[serde(default = "default_true")]
180    pub inline_complete: bool,
181    /// True when an async result artifact was deleted after delivery.
182    #[serde(default)]
183    pub artifact_consumed: bool,
184}
185
186/// Request cancellation of a backtest job.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct CancelBacktestRequest {
189    pub job_id: String,
190}
191
192/// Response from cancelling a backtest job.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CancelBacktestResponse {
195    pub success: bool,
196    pub job_id: String,
197    pub error: Option<String>,
198}
199
200// ── FutureQuote execution ───────────────────────────────────────────────────
201
202/// Requested delivery mode for a complete result JSON payload.
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ResultDeliveryMsg {
206    #[default]
207    Auto,
208    Inline,
209    Artifact,
210}
211
212/// Reference to a complete result JSON artifact.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct ResultArtifactRefMsg {
215    pub format_version: u32,
216    pub artifact_id: String,
217    pub byte_len: u64,
218    pub sha256: String,
219    pub chunk_size: u64,
220}
221
222/// Request a raw byte chunk from a result artifact.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct GetResultArtifactChunkRequest {
226    pub artifact_id: String,
227    pub offset: u64,
228}
229
230/// Base64-encoded raw artifact bytes at the requested offset.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct GetResultArtifactChunkResponse {
233    pub success: bool,
234    pub artifact_id: String,
235    pub offset: u64,
236    pub data_base64: String,
237    pub eof: bool,
238    pub error: Option<String>,
239}
240
241/// Delete a result artifact after a successful download.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(deny_unknown_fields)]
244pub struct DeleteResultArtifactRequest {
245    pub artifact_id: String,
246}
247
248/// Response from deleting a result artifact.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DeleteResultArtifactResponse {
251    pub success: bool,
252    pub artifact_id: String,
253    pub error: Option<String>,
254}
255
256/// Wire-safe mark-to-market output retention policy.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case", deny_unknown_fields)]
259pub enum MtmOutputPolicyMsg {
260    None,
261    Bounded { max_points: usize },
262    Full,
263}
264
265impl Default for MtmOutputPolicyMsg {
266    fn default() -> Self {
267        Self::Bounded { max_points: 4_096 }
268    }
269}
270
271/// FutureQuoteV1 execution settings used by the backtest RPC methods.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(default, deny_unknown_fields)]
274pub struct FutureQuoteConfigMsg {
275    pub signal_latency_ms: i64,
276    pub slippage_pips: f64,
277    pub stale_quote_after_ms: Option<i64>,
278    pub pnl_epsilon: f64,
279    pub account_currency: String,
280    pub conversion_stale_after_ms: i64,
281    pub mtm_output: MtmOutputPolicyMsg,
282}
283
284impl Default for FutureQuoteConfigMsg {
285    fn default() -> Self {
286        Self {
287            signal_latency_ms: 0,
288            slippage_pips: 0.0,
289            stale_quote_after_ms: None,
290            pnl_epsilon: 1.0e-9,
291            account_currency: String::new(),
292            conversion_stale_after_ms: 300_000,
293            mtm_output: MtmOutputPolicyMsg::default(),
294        }
295    }
296}
297
298/// Provider/source identifiers carried into the evaluation result.
299#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(default, deny_unknown_fields)]
301pub struct EvaluationContextMsg {
302    pub provider_id: Option<String>,
303    pub source_id: Option<String>,
304}
305
306/// Selectable provider-evaluation report sections.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "snake_case")]
309pub enum EvaluationSectionMsg {
310    Coverage,
311    PositionPerformance,
312    RMetrics,
313    Excursions,
314    Execution,
315    Robustness,
316    Breakdowns,
317}
318
319impl EvaluationSectionMsg {
320    pub const ALL: [Self; 7] = [
321        Self::Coverage,
322        Self::PositionPerformance,
323        Self::RMetrics,
324        Self::Excursions,
325        Self::Execution,
326        Self::Robustness,
327        Self::Breakdowns,
328    ];
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum EvaluationPositionSideMsg {
334    Long,
335    Short,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum EvaluationGroupFilterMsg {
341    Named(String),
342    Ungrouped,
343}
344
345/// Typed position filters. Values within a field are ORed; populated fields and
346/// individual tag keys are ANDed.
347#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(default, deny_unknown_fields)]
349pub struct PositionFilterMsg {
350    pub symbols: Vec<String>,
351    pub sides: Vec<EvaluationPositionSideMsg>,
352    pub groups: Vec<EvaluationGroupFilterMsg>,
353    pub close_reasons: Vec<String>,
354    pub tags: BTreeMap<String, Vec<String>>,
355}
356
357/// Typed deterministic breakdown selector.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum BreakdownDimensionMsg {
361    Symbol,
362    Side,
363    Group,
364    CloseReason,
365    Tag(String),
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
369#[serde(default, deny_unknown_fields)]
370pub struct BootstrapConfigMsg {
371    pub samples: usize,
372    pub confidence_level: f64,
373    pub seed: u64,
374    pub minimum_sample_size: usize,
375}
376
377impl Default for BootstrapConfigMsg {
378    fn default() -> Self {
379        Self {
380            samples: 2_000,
381            confidence_level: 0.95,
382            seed: 0xA076_1D64_78BD_642F,
383            minimum_sample_size: 5,
384        }
385    }
386}
387
388/// Typed parser/source coverage supplied by clients that retain parse outcomes.
389#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(default, deny_unknown_fields)]
391pub struct SourceCoverageCountsMsg {
392    pub raw_messages: u64,
393    pub parsed_messages: u64,
394    pub skipped_messages: u64,
395    pub failed_messages: u64,
396    pub emitted_signals: u64,
397    pub emitted_entry_signals: u64,
398}
399
400/// Typed provider-evaluation selection for canonical requests.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402#[serde(default, deny_unknown_fields)]
403pub struct ProviderEvaluationOptionsMsg {
404    pub context: EvaluationContextMsg,
405    pub source_coverage: Option<SourceCoverageCountsMsg>,
406    /// Missing selectors request all sections; an explicit empty list requests none.
407    pub sections: Vec<EvaluationSectionMsg>,
408    pub filter: PositionFilterMsg,
409    pub breakdowns: Vec<BreakdownDimensionMsg>,
410    pub bootstrap: BootstrapConfigMsg,
411    pub rolling_window: usize,
412    pub minimum_breakdown_bucket_count: usize,
413    pub maximum_breakdown_rows: Option<usize>,
414    pub include_positions: bool,
415    pub maximum_position_rows: Option<usize>,
416}
417
418impl Default for ProviderEvaluationOptionsMsg {
419    fn default() -> Self {
420        Self {
421            context: EvaluationContextMsg::default(),
422            source_coverage: None,
423            sections: EvaluationSectionMsg::ALL.to_vec(),
424            filter: PositionFilterMsg::default(),
425            breakdowns: Vec::new(),
426            bootstrap: BootstrapConfigMsg::default(),
427            rolling_window: 20,
428            minimum_breakdown_bucket_count: 1,
429            maximum_breakdown_rows: None,
430            include_positions: false,
431            maximum_position_rows: None,
432        }
433    }
434}
435
436// ── Run Backtest ────────────────────────────────────────────────────────────
437
438/// Execution scope and inputs for one backtest run.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct BacktestRunSpec {
441    pub symbol: String,
442    #[serde(default)]
443    pub symbols: Vec<String>,
444    #[serde(default)]
445    pub all_symbols: bool,
446    pub exchange: String,
447    pub data_type: String,
448    pub timeframe: Option<String>,
449    pub from: Option<String>,
450    pub to: Option<String>,
451    /// Full signal stream (entry + management).
452    #[serde(default)]
453    pub raw_signals: Vec<RawSignalMsg>,
454    pub profile: Option<String>,
455    #[serde(default)]
456    pub profile_def: Option<ManagementProfileMsg>,
457    pub config: BacktestConfigMsg,
458}
459
460/// Response from a single backtest run.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct RunBacktestResponse {
463    pub success: bool,
464    pub error: Option<String>,
465    /// Complete inline result or compact summary when `inline_complete` is false.
466    pub result: Option<BacktestResultMsg>,
467    pub elapsed_ms: u64,
468    #[serde(default)]
469    pub artifact: Option<ResultArtifactRefMsg>,
470    #[serde(default = "default_true")]
471    pub inline_complete: bool,
472}
473
474/// Strict FutureQuoteV1 request accepted by `run_backtest`.
475#[derive(Debug, Clone, Serialize)]
476pub struct RunBacktestRequest {
477    pub request: BacktestRunSpec,
478    #[serde(default)]
479    pub future: FutureQuoteConfigMsg,
480    #[serde(default)]
481    pub evaluation: ProviderEvaluationOptionsMsg,
482    #[serde(default)]
483    pub result_delivery: ResultDeliveryMsg,
484}
485
486/// Asynchronous FutureQuoteV1 submission.
487#[derive(Debug, Clone, Serialize)]
488pub struct SubmitBacktestRequest {
489    pub request: RunBacktestRequest,
490}
491
492// ── Run Backtest Multi ──────────────────────────────────────────────────────
493
494/// Execution scope and inputs for a multi-profile comparison.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct BacktestMultiRunSpec {
497    pub symbol: String,
498    #[serde(default)]
499    pub symbols: Vec<String>,
500    #[serde(default)]
501    pub all_symbols: bool,
502    pub exchange: String,
503    pub data_type: String,
504    pub timeframe: Option<String>,
505    pub from: Option<String>,
506    pub to: Option<String>,
507    /// Full signal stream (entry + management).
508    #[serde(default)]
509    pub raw_signals: Vec<RawSignalMsg>,
510    pub profiles: Vec<ProfileRef>,
511    pub config: BacktestConfigMsg,
512}
513
514/// Result for one profile in a multi-profile comparison.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct ProfileResult {
517    pub profile: String,
518    pub success: bool,
519    pub error: Option<String>,
520    pub result: Option<BacktestResultMsg>,
521}
522
523/// Response from a multi-profile comparison run.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct RunBacktestMultiResponse {
526    /// Defaults to true when decoding responses from older servers.
527    #[serde(default = "default_true")]
528    pub success: bool,
529    #[serde(default)]
530    pub error: Option<String>,
531    /// Complete inline results or compact summaries when `inline_complete` is false.
532    pub results: Vec<ProfileResult>,
533    pub elapsed_ms: u64,
534    #[serde(default)]
535    pub artifact: Option<ResultArtifactRefMsg>,
536    #[serde(default = "default_true")]
537    pub inline_complete: bool,
538}
539
540#[derive(Debug, Clone, Serialize)]
541pub struct RunBacktestMultiRequest {
542    pub request: BacktestMultiRunSpec,
543    #[serde(default)]
544    pub future: FutureQuoteConfigMsg,
545    #[serde(default)]
546    pub evaluation: ProviderEvaluationOptionsMsg,
547    #[serde(default)]
548    pub result_delivery: ResultDeliveryMsg,
549}
550
551// ── Backtest Result Message ─────────────────────────────────────────────────
552
553/// Serializable mirror of `BacktestResult` for wire transport.
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct BacktestResultMsg {
556    pub initial_balance: f64,
557    pub final_balance: f64,
558    pub total_pnl: f64,
559    pub total_trades: usize,
560    pub winning_trades: usize,
561    pub losing_trades: usize,
562    pub win_rate: f64,
563    pub profit_factor: f64,
564    pub max_drawdown: f64,
565    pub max_drawdown_pct: f64,
566
567    // F08 enhanced fields
568    pub summary: SubsetStatsMsg,
569    pub per_symbol: HashMap<String, SubsetStatsMsg>,
570    pub per_group: HashMap<String, SubsetStatsMsg>,
571    pub long_stats: SubsetStatsMsg,
572    pub short_stats: SubsetStatsMsg,
573    pub per_close_reason: Vec<CloseReasonStatsMsg>,
574    pub streaks: StreakStatsMsg,
575    pub risk_metrics: RiskMetricsMsg,
576    pub duration_stats: Option<DurationStatsMsg>,
577    pub monthly_returns: Vec<MonthlyReturnMsg>,
578
579    pub equity_curve: Vec<EquityPoint>,
580    pub trade_log: Vec<TradeResultMsg>,
581
582    // Position-level aggregation
583    pub positions: Vec<PositionSummaryMsg>,
584    pub total_positions: usize,
585    pub winning_positions: usize,
586    pub losing_positions: usize,
587    pub position_win_rate: f64,
588
589    /// Additive FutureQuoteV1 artifacts.
590    #[serde(default)]
591    pub future: Option<FutureBacktestResultMsg>,
592}
593
594/// Wire-safe mark-to-market output counts.
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
596#[serde(default)]
597pub struct MtmOutputSummaryMsg {
598    pub policy: MtmOutputPolicyMsg,
599    pub observed_points: u64,
600    pub retained_points: u64,
601    pub omitted_points: u64,
602}
603
604impl Default for MtmOutputSummaryMsg {
605    fn default() -> Self {
606        Self {
607            policy: MtmOutputPolicyMsg::Full,
608            observed_points: 0,
609            retained_points: 0,
610            omitted_points: 0,
611        }
612    }
613}
614
615/// Format-versioned execution/accounting payload. Complex internal records remain
616/// JSON values so new additive fields do not require synchronized positional
617/// wire changes; the transport itself uses JsonCodec.
618#[derive(Debug, Clone, Serialize, Deserialize)]
619pub struct FutureBacktestResultMsg {
620    pub format_version: u32,
621    pub execution_metadata: serde_json::Value,
622    pub recorded_fills: serde_json::Value,
623    pub action_dispositions: serde_json::Value,
624    pub close_events: serde_json::Value,
625    pub completed_positions: serde_json::Value,
626    pub open_positions: serde_json::Value,
627    pub pending_orders: serde_json::Value,
628    /// Typed FutureQuote pending-order transition stream.
629    #[serde(default)]
630    pub pending_order_lifecycle: Vec<PendingOrderLifecycleEventMsg>,
631    pub mtm_equity_curve: serde_json::Value,
632    #[serde(default)]
633    pub mtm_output_summary: MtmOutputSummaryMsg,
634    pub mtm_max_drawdown: Option<f64>,
635    pub mtm_max_drawdown_pct: Option<f64>,
636    pub provider_evaluation: serde_json::Value,
637}
638
639/// Wire-safe state for one pending-order lifecycle transition.
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
641#[serde(rename_all = "snake_case")]
642pub enum PendingOrderLifecycleStateMsg {
643    #[default]
644    Placed,
645    Filled,
646    Cancelled,
647    UnfilledAtEnd,
648}
649
650/// Typed wire mirror of a FutureQuote pending-order lifecycle event.
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
652#[serde(default)]
653pub struct PendingOrderLifecycleEventMsg {
654    pub id: String,
655    pub sequence: u64,
656    pub position_id: String,
657    pub placement_action_id: Option<String>,
658    pub terminal_action_id: Option<String>,
659    pub state: PendingOrderLifecycleStateMsg,
660    pub symbol: String,
661    pub side: String,
662    pub order_type: String,
663    pub requested_size: f64,
664    pub filled_size: Option<f64>,
665    pub requested_price: Option<f64>,
666    pub fill_price: Option<f64>,
667    pub signal_ts: Option<String>,
668    pub placed_ts: Option<String>,
669    pub effective_ts: Option<String>,
670    pub terminal_ts: Option<String>,
671    pub wait_latency_ms: Option<i64>,
672    pub fill_ratio: Option<f64>,
673}
674
675// ── Sub-message types ───────────────────────────────────────────────────────
676
677/// Wire-safe mirror of `SubsetStats`.
678#[derive(Debug, Clone, Serialize, Deserialize)]
679pub struct SubsetStatsMsg {
680    pub total_trades: usize,
681    pub winning_trades: usize,
682    pub losing_trades: usize,
683    pub breakeven_trades: usize,
684    pub total_pnl: f64,
685    pub gross_profit: f64,
686    pub gross_loss: f64,
687    pub win_rate: f64,
688    pub profit_factor: f64,
689    pub avg_win: f64,
690    pub avg_loss: f64,
691    pub win_loss_ratio: f64,
692    pub expectancy: f64,
693    pub largest_win: f64,
694    pub largest_loss: f64,
695}
696
697/// Wire-safe mirror of `StreakStats`.
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct StreakStatsMsg {
700    pub max_consecutive_wins: u32,
701    pub max_consecutive_losses: u32,
702    pub current_streak: i32,
703}
704
705/// Wire-safe mirror of `RiskMetrics`.
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct RiskMetricsMsg {
708    pub sharpe_ratio: Option<f64>,
709    pub sortino_ratio: Option<f64>,
710    pub calmar_ratio: Option<f64>,
711    pub return_on_max_drawdown: Option<f64>,
712    pub max_drawdown: f64,
713    pub max_drawdown_pct: f64,
714    pub max_drawdown_duration_secs: Option<i64>,
715}
716
717/// Wire-safe mirror of `DurationStats`.
718#[derive(Debug, Clone, Serialize, Deserialize)]
719pub struct DurationStatsMsg {
720    pub avg_duration_secs: i64,
721    pub min_duration_secs: i64,
722    pub max_duration_secs: i64,
723    pub avg_winner_duration_secs: i64,
724    pub avg_loser_duration_secs: i64,
725}
726
727/// Wire-safe mirror of `MonthlyReturn`.
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct MonthlyReturnMsg {
730    pub year: i32,
731    pub month: u32,
732    pub pnl: f64,
733    pub trade_count: usize,
734    pub ending_balance: f64,
735}
736
737/// Wire-safe mirror of `CloseReasonStats`.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct CloseReasonStatsMsg {
740    pub reason: String,
741    pub count: usize,
742    pub total_pnl: f64,
743    pub avg_pnl: f64,
744    pub percentage: f64,
745}
746
747/// Wire-safe mirror of `PositionSummary`.
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct PositionSummaryMsg {
750    pub position_id: String,
751    pub symbol: String,
752    pub side: String,
753    pub group: Option<String>,
754    pub entry_price: f64,
755    pub avg_exit_price: f64,
756    pub original_size: f64,
757    pub close_count: usize,
758    pub net_pnl: f64,
759    pub close_reasons: Vec<String>,
760    pub open_ts: String,
761    pub final_close_ts: Option<String>,
762    pub duration_seconds: i64,
763}
764
765/// A single point on the equity curve.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct EquityPoint {
768    pub ts: String,
769    pub balance: f64,
770}
771
772/// Wire-safe mirror of `TradeResult`.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct TradeResultMsg {
775    pub position_id: String,
776    pub symbol: String,
777    pub side: String,
778    pub entry_price: f64,
779    pub exit_price: f64,
780    pub size: f64,
781    pub pnl: f64,
782    pub open_ts: String,
783    pub close_ts: String,
784    pub close_reason: String,
785    pub group: Option<String>,
786}
787
788// ── Dynamic Profiles (F13) ──────────────────────────────────────────────────
789
790/// Wire-safe strict target selection.
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
792pub enum TargetSelectionMsg {
793    /// Use every target supplied by the entry signal, in signal order.
794    All,
795    /// Do not attach any targets.
796    None,
797    /// Use the listed 1-based signal target indices, in the listed order.
798    Selected(Vec<usize>),
799}
800
801/// Wire-safe management profile definition sent inline with a request.
802#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct ManagementProfileMsg {
804    pub name: String,
805    /// Explicit strict selection. This wins over `use_targets` when present;
806    /// omission preserves the legacy `use_targets`-derived default.
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub target_selection: Option<TargetSelectionMsg>,
809    /// Compatibility selection retained for older serialized profiles.
810    pub use_targets: Vec<usize>,
811    pub close_ratios: Vec<f64>,
812    #[serde(default)]
813    pub stoploss_mode: Option<StoplossModeMsg>,
814    #[serde(default)]
815    pub rules: Vec<RuleConfigDefMsg>,
816    #[serde(default)]
817    pub group_override: Option<String>,
818    #[serde(default)]
819    pub let_remainder_run: bool,
820}
821
822/// Wire-safe stoploss mode enum.
823#[derive(Debug, Clone, Serialize, Deserialize)]
824#[serde(tag = "type")]
825pub enum StoplossModeMsg {
826    FromSignal,
827    None,
828    FixedDistance { distance: f64 },
829    FixedPrice { price: f64 },
830}
831
832/// Wire-safe rule configuration definition.
833#[derive(Debug, Clone, Serialize, Deserialize)]
834#[serde(tag = "type")]
835pub enum RuleConfigDefMsg {
836    FixedStoploss { price: f64 },
837    TrailingStop { distance: f64 },
838    TakeProfit { price: f64, close_ratio: f64 },
839    BreakevenWhen { trigger_price: f64 },
840    BreakevenWhenOffset { trigger_price_offset: f64 },
841    BreakevenAfterTargets { after_n: u32 },
842    TimeExit { max_seconds: u64 },
843}
844
845/// A profile reference: either by name or inline definition.
846#[derive(Debug, Clone, Serialize, Deserialize)]
847#[serde(untagged)]
848pub enum ProfileRef {
849    Named(String),
850    Inline(ManagementProfileMsg),
851}
852
853// ── Full Signal Actions (F14) ───────────────────────────────────────────────
854
855/// Wire-safe signal that can represent any action, not just entries.
856///
857/// Uses `#[serde(tag = "action")]` so each variant is distinguished by an
858/// `"action"` field in the JSON/Bincode representation.
859#[derive(Debug, Clone, Serialize, Deserialize)]
860#[serde(tag = "action", deny_unknown_fields)]
861pub enum RawSignalMsg {
862    /// Open a new position (same semantics as `RawSignalEntryMsg`).
863    Entry {
864        ts: String,
865        symbol: String,
866        side: String,
867        order_type: String,
868        price: Option<f64>,
869        risk: f64,
870        stoploss: Option<f64>,
871        #[serde(default)]
872        targets: Vec<f64>,
873        #[serde(default)]
874        group: Option<String>,
875        /// Application-defined trade id. Required for `ByTradeId` resolution.
876        #[serde(default)]
877        trade_id: Option<String>,
878    },
879    /// Close position(s) at market.
880    Close {
881        ts: String,
882        position: PositionRefMsg,
883    },
884    /// Close a fraction of position(s).
885    ClosePartial {
886        ts: String,
887        position: PositionRefMsg,
888        ratio: f64,
889    },
890    /// Set or replace the stoploss price.
891    ModifyStoploss {
892        ts: String,
893        position: PositionRefMsg,
894        price: f64,
895    },
896    /// Move stoploss to the average entry price.
897    MoveStoplossToEntry {
898        ts: String,
899        position: PositionRefMsg,
900    },
901    /// Add a take-profit level.
902    AddTarget {
903        ts: String,
904        position: PositionRefMsg,
905        price: f64,
906        close_ratio: f64,
907    },
908    /// Remove a take-profit level at a specific price.
909    RemoveTarget {
910        ts: String,
911        position: PositionRefMsg,
912        price: f64,
913    },
914    /// Atomically change an existing take-profit price while retaining its ratio.
915    ModifyTarget {
916        ts: String,
917        position: PositionRefMsg,
918        old_price: f64,
919        new_price: f64,
920    },
921    /// Attach a management rule.
922    AddRule {
923        ts: String,
924        position: PositionRefMsg,
925        rule: RuleConfigDefMsg,
926    },
927    /// Remove a management rule by name.
928    RemoveRule {
929        ts: String,
930        position: PositionRefMsg,
931        rule_name: String,
932    },
933    /// Scale into an existing position.
934    ScaleIn {
935        ts: String,
936        position: PositionRefMsg,
937        price: Option<f64>,
938        size: f64,
939    },
940    /// Cancel a pending order.
941    CancelPending {
942        ts: String,
943        position: PositionRefMsg,
944    },
945    /// Close all open positions on a symbol.
946    CloseAllOf { ts: String, symbol: String },
947    /// Close all open positions.
948    CloseAll { ts: String },
949    /// Cancel all pending orders.
950    CancelAllPending { ts: String },
951    /// Modify stoploss for all open positions on a symbol.
952    ModifyAllStoploss {
953        ts: String,
954        symbol: String,
955        price: f64,
956    },
957    /// Close all open positions in a group.
958    CloseAllInGroup { ts: String, group_id: String },
959    /// Modify stoploss for all open positions in a group.
960    ModifyAllStoplossInGroup {
961        ts: String,
962        group_id: String,
963        price: f64,
964    },
965}
966
967impl RawSignalMsg {
968    /// Extract the timestamp string from any variant.
969    pub fn ts(&self) -> &str {
970        match self {
971            RawSignalMsg::Entry { ts, .. }
972            | RawSignalMsg::Close { ts, .. }
973            | RawSignalMsg::ClosePartial { ts, .. }
974            | RawSignalMsg::ModifyStoploss { ts, .. }
975            | RawSignalMsg::MoveStoplossToEntry { ts, .. }
976            | RawSignalMsg::AddTarget { ts, .. }
977            | RawSignalMsg::RemoveTarget { ts, .. }
978            | RawSignalMsg::ModifyTarget { ts, .. }
979            | RawSignalMsg::AddRule { ts, .. }
980            | RawSignalMsg::RemoveRule { ts, .. }
981            | RawSignalMsg::ScaleIn { ts, .. }
982            | RawSignalMsg::CancelPending { ts, .. }
983            | RawSignalMsg::CloseAllOf { ts, .. }
984            | RawSignalMsg::CloseAll { ts }
985            | RawSignalMsg::CancelAllPending { ts }
986            | RawSignalMsg::ModifyAllStoploss { ts, .. }
987            | RawSignalMsg::CloseAllInGroup { ts, .. }
988            | RawSignalMsg::ModifyAllStoplossInGroup { ts, .. } => ts,
989        }
990    }
991}
992
993/// Wire-safe position reference.
994#[derive(Debug, Clone, Serialize, Deserialize)]
995#[serde(tag = "type")]
996pub enum PositionRefMsg {
997    /// Target the position with the given application-defined trade id.
998    ByTradeId { trade_id: String },
999    /// All open positions on this symbol.
1000    AllOnSymbol { symbol: String },
1001    /// All open positions in this group.
1002    AllInGroup { group_id: String },
1003}
1004
1005// ── Strict request decoding ──────────────────────────────────────────────────
1006
1007#[derive(Serialize, Deserialize)]
1008#[serde(tag = "type", deny_unknown_fields)]
1009enum StrictSizingPolicyMsg {
1010    FixedLot { lots: f64 },
1011    FixedRiskAmount { amount: f64 },
1012    BalanceRiskPercent { percent: f64 },
1013}
1014
1015#[derive(Serialize, Deserialize)]
1016#[serde(deny_unknown_fields)]
1017struct StrictBacktestConfigMsg {
1018    initial_balance: Option<f64>,
1019    close_on_finish: Option<bool>,
1020    fill_model: Option<String>,
1021    #[serde(default)]
1022    sizing: Option<StrictSizingPolicyMsg>,
1023}
1024
1025#[derive(Serialize, Deserialize)]
1026#[serde(tag = "type", deny_unknown_fields)]
1027enum StrictStoplossModeMsg {
1028    FromSignal,
1029    None,
1030    FixedDistance { distance: f64 },
1031    FixedPrice { price: f64 },
1032}
1033
1034#[derive(Serialize, Deserialize)]
1035#[serde(tag = "type", deny_unknown_fields)]
1036enum StrictRuleConfigDefMsg {
1037    FixedStoploss { price: f64 },
1038    TrailingStop { distance: f64 },
1039    TakeProfit { price: f64, close_ratio: f64 },
1040    BreakevenWhen { trigger_price: f64 },
1041    BreakevenWhenOffset { trigger_price_offset: f64 },
1042    BreakevenAfterTargets { after_n: u32 },
1043    TimeExit { max_seconds: u64 },
1044}
1045
1046#[derive(Serialize, Deserialize)]
1047#[serde(deny_unknown_fields)]
1048struct StrictManagementProfileMsg {
1049    name: String,
1050    #[serde(default)]
1051    target_selection: Option<TargetSelectionMsg>,
1052    use_targets: Vec<usize>,
1053    close_ratios: Vec<f64>,
1054    #[serde(default)]
1055    stoploss_mode: Option<StrictStoplossModeMsg>,
1056    #[serde(default)]
1057    rules: Vec<StrictRuleConfigDefMsg>,
1058    #[serde(default)]
1059    group_override: Option<String>,
1060    #[serde(default)]
1061    let_remainder_run: bool,
1062}
1063
1064#[derive(Serialize, Deserialize)]
1065#[serde(tag = "type", deny_unknown_fields)]
1066enum StrictPositionRefMsg {
1067    ByTradeId { trade_id: String },
1068    AllOnSymbol { symbol: String },
1069    AllInGroup { group_id: String },
1070}
1071
1072#[derive(Serialize, Deserialize)]
1073#[serde(tag = "action", deny_unknown_fields)]
1074enum StrictRawSignalMsg {
1075    Entry {
1076        ts: String,
1077        symbol: String,
1078        side: String,
1079        order_type: String,
1080        price: Option<f64>,
1081        risk: f64,
1082        stoploss: Option<f64>,
1083        #[serde(default)]
1084        targets: Vec<f64>,
1085        #[serde(default)]
1086        group: Option<String>,
1087        #[serde(default)]
1088        trade_id: Option<String>,
1089    },
1090    Close {
1091        ts: String,
1092        position: StrictPositionRefMsg,
1093    },
1094    ClosePartial {
1095        ts: String,
1096        position: StrictPositionRefMsg,
1097        ratio: f64,
1098    },
1099    ModifyStoploss {
1100        ts: String,
1101        position: StrictPositionRefMsg,
1102        price: f64,
1103    },
1104    MoveStoplossToEntry {
1105        ts: String,
1106        position: StrictPositionRefMsg,
1107    },
1108    AddTarget {
1109        ts: String,
1110        position: StrictPositionRefMsg,
1111        price: f64,
1112        close_ratio: f64,
1113    },
1114    RemoveTarget {
1115        ts: String,
1116        position: StrictPositionRefMsg,
1117        price: f64,
1118    },
1119    ModifyTarget {
1120        ts: String,
1121        position: StrictPositionRefMsg,
1122        old_price: f64,
1123        new_price: f64,
1124    },
1125    AddRule {
1126        ts: String,
1127        position: StrictPositionRefMsg,
1128        rule: StrictRuleConfigDefMsg,
1129    },
1130    RemoveRule {
1131        ts: String,
1132        position: StrictPositionRefMsg,
1133        rule_name: String,
1134    },
1135    ScaleIn {
1136        ts: String,
1137        position: StrictPositionRefMsg,
1138        price: Option<f64>,
1139        size: f64,
1140    },
1141    CancelPending {
1142        ts: String,
1143        position: StrictPositionRefMsg,
1144    },
1145    CloseAllOf {
1146        ts: String,
1147        symbol: String,
1148    },
1149    CloseAll {
1150        ts: String,
1151    },
1152    CancelAllPending {
1153        ts: String,
1154    },
1155    ModifyAllStoploss {
1156        ts: String,
1157        symbol: String,
1158        price: f64,
1159    },
1160    CloseAllInGroup {
1161        ts: String,
1162        group_id: String,
1163    },
1164    ModifyAllStoplossInGroup {
1165        ts: String,
1166        group_id: String,
1167        price: f64,
1168    },
1169}
1170
1171#[derive(Serialize, Deserialize)]
1172#[serde(deny_unknown_fields)]
1173struct StrictBacktestRunSpec {
1174    symbol: String,
1175    #[serde(default)]
1176    symbols: Vec<String>,
1177    #[serde(default)]
1178    all_symbols: bool,
1179    exchange: String,
1180    data_type: String,
1181    timeframe: Option<String>,
1182    from: Option<String>,
1183    to: Option<String>,
1184    #[serde(default)]
1185    raw_signals: Vec<StrictRawSignalMsg>,
1186    profile: Option<String>,
1187    #[serde(default)]
1188    profile_def: Option<StrictManagementProfileMsg>,
1189    config: StrictBacktestConfigMsg,
1190}
1191
1192#[derive(Serialize, Deserialize)]
1193#[serde(untagged)]
1194enum StrictProfileRef {
1195    Named(String),
1196    Inline(StrictManagementProfileMsg),
1197}
1198
1199#[derive(Serialize, Deserialize)]
1200#[serde(deny_unknown_fields)]
1201struct StrictBacktestMultiRunSpec {
1202    symbol: String,
1203    #[serde(default)]
1204    symbols: Vec<String>,
1205    #[serde(default)]
1206    all_symbols: bool,
1207    exchange: String,
1208    data_type: String,
1209    timeframe: Option<String>,
1210    from: Option<String>,
1211    to: Option<String>,
1212    #[serde(default)]
1213    raw_signals: Vec<StrictRawSignalMsg>,
1214    profiles: Vec<StrictProfileRef>,
1215    config: StrictBacktestConfigMsg,
1216}
1217
1218#[derive(Deserialize)]
1219#[serde(deny_unknown_fields)]
1220struct StrictRunBacktestRequest {
1221    request: StrictBacktestRunSpec,
1222    #[serde(default)]
1223    future: FutureQuoteConfigMsg,
1224    #[serde(default)]
1225    evaluation: ProviderEvaluationOptionsMsg,
1226    #[serde(default)]
1227    result_delivery: ResultDeliveryMsg,
1228}
1229
1230#[derive(Deserialize)]
1231#[serde(deny_unknown_fields)]
1232struct StrictSubmitBacktestRequest {
1233    request: RunBacktestRequest,
1234}
1235
1236#[derive(Deserialize)]
1237#[serde(deny_unknown_fields)]
1238struct StrictRunBacktestMultiRequest {
1239    request: StrictBacktestMultiRunSpec,
1240    #[serde(default)]
1241    future: FutureQuoteConfigMsg,
1242    #[serde(default)]
1243    evaluation: ProviderEvaluationOptionsMsg,
1244    #[serde(default)]
1245    result_delivery: ResultDeliveryMsg,
1246}
1247
1248fn strict_into_wire<T>(value: impl Serialize) -> Result<T, serde_json::Error>
1249where
1250    T: DeserializeOwned,
1251{
1252    serde_json::from_value(serde_json::to_value(value)?)
1253}
1254
1255impl<'de> Deserialize<'de> for RunBacktestRequest {
1256    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1257    where
1258        D: serde::Deserializer<'de>,
1259    {
1260        let strict = StrictRunBacktestRequest::deserialize(deserializer)?;
1261        Ok(Self {
1262            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
1263            future: strict.future,
1264            evaluation: strict.evaluation,
1265            result_delivery: strict.result_delivery,
1266        })
1267    }
1268}
1269
1270impl<'de> Deserialize<'de> for SubmitBacktestRequest {
1271    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1272    where
1273        D: serde::Deserializer<'de>,
1274    {
1275        let strict = StrictSubmitBacktestRequest::deserialize(deserializer)?;
1276        Ok(Self {
1277            request: strict.request,
1278        })
1279    }
1280}
1281
1282impl<'de> Deserialize<'de> for RunBacktestMultiRequest {
1283    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1284    where
1285        D: serde::Deserializer<'de>,
1286    {
1287        let strict = StrictRunBacktestMultiRequest::deserialize(deserializer)?;
1288        Ok(Self {
1289            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
1290            future: strict.future,
1291            evaluation: strict.evaluation,
1292            result_delivery: strict.result_delivery,
1293        })
1294    }
1295}
1296
1297// ── Phase 2: Profile Management ─────────────────────────────────────────────
1298
1299/// Request to add (or overwrite) a management profile at runtime.
1300#[derive(Debug, Clone, Serialize, Deserialize)]
1301pub struct AddProfileRequest {
1302    pub profile: ManagementProfileMsg,
1303    #[serde(default)]
1304    pub overwrite: bool,
1305}
1306
1307/// Response from adding a profile.
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1309pub struct AddProfileResponse {
1310    pub success: bool,
1311    pub error: Option<String>,
1312    pub profile_count: usize,
1313}
1314
1315/// Request to remove a management profile by name.
1316#[derive(Debug, Clone, Serialize, Deserialize)]
1317pub struct RemoveProfileRequest {
1318    pub name: String,
1319}
1320
1321/// Response from removing a profile.
1322#[derive(Debug, Clone, Serialize, Deserialize)]
1323pub struct RemoveProfileResponse {
1324    pub success: bool,
1325    pub error: Option<String>,
1326    pub profile_count: usize,
1327}
1328
1329/// Response from reloading profiles from disk.
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct ReloadProfilesResponse {
1332    pub success: bool,
1333    pub error: Option<String>,
1334    pub profile_count: usize,
1335    pub loaded_from: String,
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use super::*;
1341    use serde_json::json;
1342
1343    fn entry_fields() -> serde_json::Value {
1344        json!({
1345            "action": "Entry",
1346            "ts": "2026-01-02T10:00:00",
1347            "symbol": "eurusd",
1348            "side": "Buy",
1349            "order_type": "Market",
1350            "price": null,
1351            "risk": 1.5,
1352            "stoploss": 1.08
1353        })
1354    }
1355
1356    #[test]
1357    fn entry_requires_risk_and_rejects_size() {
1358        let entry: RawSignalMsg = serde_json::from_value(entry_fields()).unwrap();
1359        assert!(matches!(entry, RawSignalMsg::Entry { risk, .. } if risk == 1.5));
1360
1361        let mut with_size = entry_fields();
1362        with_size.as_object_mut().unwrap().remove("risk");
1363        with_size["size"] = json!(0.1);
1364        let error = serde_json::from_value::<RawSignalMsg>(with_size).unwrap_err();
1365        assert!(error.to_string().contains("unknown field `size`"));
1366
1367        let mut missing_risk = entry_fields();
1368        missing_risk.as_object_mut().unwrap().remove("risk");
1369        let error = serde_json::from_value::<RawSignalMsg>(missing_risk).unwrap_err();
1370        assert!(error.to_string().contains("missing field `risk`"));
1371    }
1372
1373    #[test]
1374    fn scale_in_keeps_size_field() {
1375        let signal: RawSignalMsg = serde_json::from_value(json!({
1376            "action": "ScaleIn",
1377            "ts": "2026-01-02T10:01:00",
1378            "position": { "type": "ByTradeId", "trade_id": "trade-1" },
1379            "price": null,
1380            "size": 0.25
1381        }))
1382        .unwrap();
1383        assert!(matches!(signal, RawSignalMsg::ScaleIn { size, .. } if size == 0.25));
1384    }
1385
1386    #[test]
1387    fn current_sizing_and_future_fields_are_strict() {
1388        let sizing: SizingPolicyMsg = serde_json::from_value(json!({
1389            "type": "FixedRiskAmount",
1390            "amount": 100.0
1391        }))
1392        .unwrap();
1393        assert!(matches!(
1394            sizing,
1395            SizingPolicyMsg::FixedRiskAmount { amount } if amount == 100.0
1396        ));
1397        assert!(
1398            serde_json::from_value::<SizingPolicyMsg>(json!({
1399                "type": "FixedLot",
1400                "qty": "all=0.01"
1401            }))
1402            .is_err()
1403        );
1404
1405        let future: FutureQuoteConfigMsg = serde_json::from_value(json!({
1406            "account_currency": "USD",
1407            "conversion_stale_after_ms": 15_000
1408        }))
1409        .unwrap();
1410        assert_eq!(future.account_currency, "USD");
1411        assert_eq!(future.conversion_stale_after_ms, 15_000);
1412        assert_eq!(
1413            future.mtm_output,
1414            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1415        );
1416        assert!(
1417            serde_json::from_value::<FutureQuoteConfigMsg>(json!({
1418                "account_currency": "USD",
1419                "conversion_rates": {}
1420            }))
1421            .is_err()
1422        );
1423    }
1424
1425    #[test]
1426    fn mtm_output_policy_uses_strict_snake_case_wire_values() {
1427        for (value, expected) in [
1428            (json!("none"), MtmOutputPolicyMsg::None),
1429            (
1430                json!({ "bounded": { "max_points": 512 } }),
1431                MtmOutputPolicyMsg::Bounded { max_points: 512 },
1432            ),
1433            (json!("full"), MtmOutputPolicyMsg::Full),
1434        ] {
1435            let policy: MtmOutputPolicyMsg = serde_json::from_value(value.clone()).unwrap();
1436            assert_eq!(policy, expected);
1437            assert_eq!(serde_json::to_value(policy).unwrap(), value);
1438        }
1439
1440        assert!(
1441            serde_json::from_value::<MtmOutputPolicyMsg>(json!({
1442                "bounded": { "max_points": 512, "unexpected": true }
1443            }))
1444            .is_err()
1445        );
1446    }
1447
1448    #[test]
1449    fn future_result_defaults_missing_mtm_output_summary() {
1450        let result: FutureBacktestResultMsg = serde_json::from_value(json!({
1451            "format_version": 1,
1452            "execution_metadata": null,
1453            "recorded_fills": null,
1454            "action_dispositions": null,
1455            "close_events": null,
1456            "completed_positions": null,
1457            "open_positions": null,
1458            "pending_orders": null,
1459            "pending_order_lifecycle": [],
1460            "mtm_equity_curve": null,
1461            "mtm_max_drawdown": null,
1462            "mtm_max_drawdown_pct": null,
1463            "provider_evaluation": null
1464        }))
1465        .unwrap();
1466
1467        assert_eq!(result.mtm_output_summary, MtmOutputSummaryMsg::default());
1468        assert_eq!(result.mtm_output_summary.policy, MtmOutputPolicyMsg::Full);
1469
1470        let summary: MtmOutputSummaryMsg = serde_json::from_value(json!({
1471            "policy": "full",
1472            "observed_points": 4,
1473            "retained_points": 4,
1474            "omitted_points": 0,
1475            "future_addition": true
1476        }))
1477        .unwrap();
1478        assert_eq!(summary.policy, MtmOutputPolicyMsg::Full);
1479    }
1480
1481    fn minimal_request_json() -> serde_json::Value {
1482        json!({
1483            "request": {
1484                "symbol": "EURUSD",
1485                "exchange": "fixture",
1486                "data_type": "tick",
1487                "timeframe": null,
1488                "from": null,
1489                "to": null,
1490                "raw_signals": [],
1491                "profile": null,
1492                "config": {
1493                    "initial_balance": null,
1494                    "close_on_finish": null,
1495                    "fill_model": null
1496                }
1497            },
1498            "future": {
1499                "account_currency": "USD"
1500            }
1501        })
1502    }
1503
1504    #[test]
1505    fn omitted_delivery_and_mtm_fields_use_current_defaults() {
1506        let request: RunBacktestRequest = serde_json::from_value(minimal_request_json()).unwrap();
1507        assert_eq!(request.result_delivery, ResultDeliveryMsg::Auto);
1508        assert_eq!(
1509            request.future.mtm_output,
1510            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1511        );
1512
1513        let submitted: SubmitBacktestRequest = serde_json::from_value(json!({
1514            "request": minimal_request_json()
1515        }))
1516        .unwrap();
1517        assert_eq!(submitted.request.result_delivery, ResultDeliveryMsg::Auto);
1518        assert_eq!(
1519            submitted.request.future.mtm_output,
1520            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1521        );
1522
1523        let mut explicit = minimal_request_json();
1524        explicit["result_delivery"] = json!("auto");
1525        explicit["future"]["mtm_output"] = json!({
1526            "bounded": { "max_points": 4_096 }
1527        });
1528        let explicit: RunBacktestRequest = serde_json::from_value(explicit).unwrap();
1529        assert_eq!(explicit.result_delivery, ResultDeliveryMsg::Auto);
1530        assert_eq!(
1531            explicit.future.mtm_output,
1532            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1533        );
1534
1535        let mut multi_request = minimal_request_json()["request"].clone();
1536        let fields = multi_request.as_object_mut().unwrap();
1537        fields.remove("profile");
1538        fields.insert("profiles".into(), json!([]));
1539        let multi: RunBacktestMultiRequest = serde_json::from_value(json!({
1540            "request": multi_request,
1541            "future": { "account_currency": "USD" }
1542        }))
1543        .unwrap();
1544        assert_eq!(multi.result_delivery, ResultDeliveryMsg::Auto);
1545        assert_eq!(
1546            multi.future.mtm_output,
1547            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1548        );
1549    }
1550
1551    #[test]
1552    fn older_async_result_responses_default_artifact_consumption_state() {
1553        let response: GetBacktestResultResponse = serde_json::from_value(json!({
1554            "success": true,
1555            "job_id": "job-old",
1556            "result": null,
1557            "error": null,
1558            "inline_complete": true
1559        }))
1560        .unwrap();
1561        assert!(!response.artifact_consumed);
1562    }
1563}