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