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