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    /// Per-symbol commission and swap, keyed by canonical symbol. Empty charges nothing and reproduces runs made before costs existed.
94    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95    pub costs: BTreeMap<String, InstrumentCostsMsg>,
96}
97
98/// Wire-safe commission and swap specification for one instrument.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct InstrumentCostsMsg {
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub commission: Option<CommissionModelMsg>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub swap: Option<SwapScheduleMsg>,
106}
107
108/// Wire-safe commission model.
109///
110/// The tag also accepts the snake_case spelling that `qs-core` writes into run metadata, so a stored specification round-trips back into a request.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "type", deny_unknown_fields)]
113pub enum CommissionModelMsg {
114    /// Fixed amount per lot per side, in the run's account currency.
115    #[serde(alias = "per_lot_per_side")]
116    PerLotPerSide { amount: f64, currency: String },
117    /// Fraction of traded notional per side, with separate buy and sell rates.
118    #[serde(alias = "notional_rate_per_side")]
119    NotionalRatePerSide { buy_rate: f64, sell_rate: f64 },
120}
121
122/// Wire-safe nightly swap magnitude, where a negative value charges and a positive value credits.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(tag = "unit", deny_unknown_fields)]
125pub enum SwapAmountMsg {
126    /// Price points per lot per night, in the instrument's native currency.
127    #[serde(alias = "points")]
128    Points { long: f64, short: f64 },
129    /// Amount per lot per night, in the run's account currency.
130    #[serde(alias = "currency")]
131    Currency {
132        long: f64,
133        short: f64,
134        currency: String,
135    },
136}
137
138/// Wire-safe nightly swap magnitude plus the rollover calendar that charges it.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct SwapScheduleMsg {
142    pub amount: SwapAmountMsg,
143    /// Rollover time of day in the replay's timestamp zone, as `HH:MM:SS`.
144    pub rollover: String,
145    /// Weekday whose rollover charges three nights, as `Mon` through `Sun`.
146    pub triple_weekday: String,
147    /// Weekdays whose rollover charges nothing. Defaults to `Sat` and `Sun` when omitted.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub skipped_weekdays: Vec<String>,
150}
151
152/// Wire-safe in-place account sizing policy.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(tag = "type", deny_unknown_fields)]
155pub enum SizingPolicyMsg {
156    /// Use a fixed lot quantity scaled by the entry risk multiplier.
157    FixedLot { lots: f64 },
158    /// Risk a fixed amount in account currency.
159    FixedRiskAmount { amount: f64 },
160    /// Risk a percentage of the realized balance before the entry.
161    BalanceRiskPercent { percent: f64 },
162}
163
164// ── Async Job API (Issue 2) ─────────────────────────────────────────────────
165
166/// Response from submitting a backtest job.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SubmitBacktestResponse {
169    pub success: bool,
170    pub job_id: Option<String>,
171    pub error: Option<String>,
172}
173
174/// Request the current status of a backtest job.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct GetBacktestStatusRequest {
177    pub job_id: String,
178}
179
180/// Subscribe to status snapshots for a retained backtest job.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct WatchBacktestRequest {
183    pub job_id: String,
184}
185
186/// Structured progress for an asynchronous backtest job.
187#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(default)]
189pub struct BacktestProgress {
190    pub stage: String,
191    pub processed_events: u64,
192    pub total_events: u64,
193    pub processed_signals: u64,
194    pub total_signals: u64,
195    pub processed_symbols: u64,
196    pub total_symbols: u64,
197}
198
199/// Status of a backtest job.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct BacktestStatusResponse {
202    pub success: bool,
203    pub job_id: String,
204    pub status: String,
205    pub error: Option<String>,
206    pub elapsed_ms: Option<u64>,
207    /// Missing in older responses; defaults to an empty progress snapshot.
208    #[serde(default)]
209    pub progress: BacktestProgress,
210}
211
212impl BacktestStatusResponse {
213    /// Whether this snapshot represents a terminal retained-job state.
214    pub fn is_terminal(&self) -> bool {
215        matches!(self.status.as_str(), "Completed" | "Failed" | "Cancelled")
216    }
217}
218
219/// Server-streamed retained-job status and liveness events.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(tag = "type", rename_all = "snake_case")]
222pub enum BacktestEvent {
223    Snapshot { status: BacktestStatusResponse },
224    Heartbeat { job_id: String, elapsed_ms: u64 },
225}
226
227/// Request the result of a completed backtest job.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct GetBacktestResultRequest {
230    pub job_id: String,
231}
232
233/// Response containing the result of a completed backtest job.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct GetBacktestResultResponse {
236    pub success: bool,
237    pub job_id: String,
238    pub result: Option<BacktestResultMsg>,
239    pub error: Option<String>,
240    #[serde(default)]
241    pub artifact: Option<ResultArtifactRefMsg>,
242    #[serde(default = "default_true")]
243    pub inline_complete: bool,
244    /// True when an async result artifact was deleted after delivery.
245    #[serde(default)]
246    pub artifact_consumed: bool,
247}
248
249/// Request cancellation of a backtest job.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct CancelBacktestRequest {
252    pub job_id: String,
253}
254
255/// Response from cancelling a backtest job.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CancelBacktestResponse {
258    pub success: bool,
259    pub job_id: String,
260    pub error: Option<String>,
261}
262
263// ── FutureQuote execution ───────────────────────────────────────────────────
264
265/// Requested delivery mode for a complete result JSON payload.
266#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum ResultDeliveryMsg {
269    #[default]
270    Auto,
271    Inline,
272    Artifact,
273}
274
275/// Reference to a complete result JSON artifact.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct ResultArtifactRefMsg {
278    pub format_version: u32,
279    pub artifact_id: String,
280    pub byte_len: u64,
281    pub sha256: String,
282    pub chunk_size: u64,
283}
284
285/// Request a raw byte chunk from a result artifact.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct GetResultArtifactChunkRequest {
289    pub artifact_id: String,
290    pub offset: u64,
291}
292
293/// Base64-encoded raw artifact bytes at the requested offset.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct GetResultArtifactChunkResponse {
296    pub success: bool,
297    pub artifact_id: String,
298    pub offset: u64,
299    pub data_base64: String,
300    pub eof: bool,
301    pub error: Option<String>,
302}
303
304/// Delete a result artifact after a successful download.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct DeleteResultArtifactRequest {
308    pub artifact_id: String,
309}
310
311/// Response from deleting a result artifact.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct DeleteResultArtifactResponse {
314    pub success: bool,
315    pub artifact_id: String,
316    pub error: Option<String>,
317}
318
319/// Wire-safe mark-to-market output retention policy.
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case", deny_unknown_fields)]
322pub enum MtmOutputPolicyMsg {
323    None,
324    Bounded { max_points: usize },
325    Full,
326}
327
328impl Default for MtmOutputPolicyMsg {
329    fn default() -> Self {
330        Self::Bounded { max_points: 4_096 }
331    }
332}
333
334/// Price basis used to size market entries.
335#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case", deny_unknown_fields)]
337pub enum MarketEntrySizingBasisMsg {
338    #[default]
339    FillPrice,
340    SignalEntryPrice,
341}
342
343/// FutureQuoteV1 execution settings used by the backtest RPC methods.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(default, deny_unknown_fields)]
346pub struct FutureQuoteConfigMsg {
347    pub signal_latency_ms: i64,
348    pub slippage_pips: f64,
349    pub stale_quote_after_ms: Option<i64>,
350    pub pnl_epsilon: f64,
351    pub account_currency: String,
352    pub conversion_stale_after_ms: i64,
353    pub mtm_output: MtmOutputPolicyMsg,
354    pub market_entry_sizing_basis: MarketEntrySizingBasisMsg,
355}
356
357impl Default for FutureQuoteConfigMsg {
358    fn default() -> Self {
359        Self {
360            signal_latency_ms: 0,
361            slippage_pips: 0.0,
362            stale_quote_after_ms: None,
363            pnl_epsilon: 1.0e-9,
364            account_currency: String::new(),
365            conversion_stale_after_ms: 300_000,
366            mtm_output: MtmOutputPolicyMsg::default(),
367            market_entry_sizing_basis: MarketEntrySizingBasisMsg::default(),
368        }
369    }
370}
371
372/// Provider/source identifiers carried into the evaluation result.
373#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct EvaluationContextMsg {
376    pub provider_id: Option<String>,
377    pub source_id: Option<String>,
378}
379
380/// Selectable provider-evaluation report sections.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum EvaluationSectionMsg {
384    Coverage,
385    PositionPerformance,
386    RMetrics,
387    Excursions,
388    Execution,
389    Costs,
390    Robustness,
391    Breakdowns,
392}
393
394impl EvaluationSectionMsg {
395    pub const ALL: [Self; 8] = [
396        Self::Coverage,
397        Self::PositionPerformance,
398        Self::RMetrics,
399        Self::Excursions,
400        Self::Execution,
401        Self::Costs,
402        Self::Robustness,
403        Self::Breakdowns,
404    ];
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum EvaluationPositionSideMsg {
410    Long,
411    Short,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum EvaluationGroupFilterMsg {
417    Named(String),
418    Ungrouped,
419}
420
421/// Typed position filters. Values within a field are ORed; populated fields and
422/// individual tag keys are ANDed.
423#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(default, deny_unknown_fields)]
425pub struct PositionFilterMsg {
426    pub symbols: Vec<String>,
427    pub sides: Vec<EvaluationPositionSideMsg>,
428    pub groups: Vec<EvaluationGroupFilterMsg>,
429    pub close_reasons: Vec<String>,
430    pub tags: BTreeMap<String, Vec<String>>,
431}
432
433/// Typed deterministic breakdown selector.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum BreakdownDimensionMsg {
437    Symbol,
438    Side,
439    Group,
440    CloseReason,
441    Tag(String),
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
445#[serde(default, deny_unknown_fields)]
446pub struct BootstrapConfigMsg {
447    pub samples: usize,
448    pub confidence_level: f64,
449    pub seed: u64,
450    pub minimum_sample_size: usize,
451}
452
453impl Default for BootstrapConfigMsg {
454    fn default() -> Self {
455        Self {
456            samples: 2_000,
457            confidence_level: 0.95,
458            seed: 0xA076_1D64_78BD_642F,
459            minimum_sample_size: 5,
460        }
461    }
462}
463
464/// Typed parser/source coverage supplied by clients that retain parse outcomes.
465#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(default, deny_unknown_fields)]
467pub struct SourceCoverageCountsMsg {
468    pub raw_messages: u64,
469    pub parsed_messages: u64,
470    pub skipped_messages: u64,
471    pub failed_messages: u64,
472    pub emitted_signals: u64,
473    pub emitted_entry_signals: u64,
474}
475
476/// Typed provider-evaluation selection for canonical requests.
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
478#[serde(default, deny_unknown_fields)]
479pub struct ProviderEvaluationOptionsMsg {
480    pub context: EvaluationContextMsg,
481    pub source_coverage: Option<SourceCoverageCountsMsg>,
482    /// Missing selectors request all sections; an explicit empty list requests none.
483    pub sections: Vec<EvaluationSectionMsg>,
484    pub filter: PositionFilterMsg,
485    pub breakdowns: Vec<BreakdownDimensionMsg>,
486    pub bootstrap: BootstrapConfigMsg,
487    pub rolling_window: usize,
488    pub minimum_breakdown_bucket_count: usize,
489    pub maximum_breakdown_rows: Option<usize>,
490    pub include_positions: bool,
491    pub maximum_position_rows: Option<usize>,
492}
493
494impl Default for ProviderEvaluationOptionsMsg {
495    fn default() -> Self {
496        Self {
497            context: EvaluationContextMsg::default(),
498            source_coverage: None,
499            sections: EvaluationSectionMsg::ALL.to_vec(),
500            filter: PositionFilterMsg::default(),
501            breakdowns: Vec::new(),
502            bootstrap: BootstrapConfigMsg::default(),
503            rolling_window: 20,
504            minimum_breakdown_bucket_count: 1,
505            maximum_breakdown_rows: None,
506            include_positions: false,
507            maximum_position_rows: None,
508        }
509    }
510}
511
512// ── Run Backtest ────────────────────────────────────────────────────────────
513
514/// Handling of selected instruments that cannot be prepared for replay.
515#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517pub enum UnavailableInstrumentPolicyMsg {
518    #[default]
519    Skip,
520    Error,
521}
522
523/// Why an instrument's signals were excluded before replay.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(rename_all = "snake_case")]
526pub enum InstrumentExclusionReasonMsg {
527    NotSelected,
528    UnknownInstrument,
529    UnsupportedEconomics,
530    NoMarketData,
531    NoConversionData,
532    AmbiguousMapping,
533}
534
535/// Original input coordinates for one excluded signal.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(deny_unknown_fields)]
538pub struct ExcludedSignalRefMsg {
539    pub input_index: usize,
540    pub ts: String,
541    pub action: String,
542    pub trade_id: Option<String>,
543}
544
545/// Bounded evidence for an instrument excluded during preparation.
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(deny_unknown_fields)]
548pub struct ExcludedInstrumentMsg {
549    pub symbol: String,
550    pub reason: InstrumentExclusionReasonMsg,
551    pub details: String,
552    pub skipped_entries: usize,
553    pub skipped_management: usize,
554    pub signal_references: Vec<ExcludedSignalRefMsg>,
555    pub omitted_signal_references: usize,
556}
557
558/// Preparation decisions, separate from parser outcomes and execution dispositions.
559#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(deny_unknown_fields)]
561pub struct ReplayAdmissionReportMsg {
562    #[serde(default)]
563    pub omitted_instruments: usize,
564    #[serde(default)]
565    pub omitted_entries: usize,
566    #[serde(default)]
567    pub omitted_management: usize,
568    pub policy: UnavailableInstrumentPolicyMsg,
569    pub input_signals: usize,
570    pub retained_signals: usize,
571    pub excluded_instruments: Vec<ExcludedInstrumentMsg>,
572}
573
574/// Execution scope and inputs for one backtest run.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct BacktestRunSpec {
577    #[serde(default)]
578    pub on_unavailable: UnavailableInstrumentPolicyMsg,
579    pub symbol: String,
580    #[serde(default)]
581    pub symbols: Vec<String>,
582    #[serde(default)]
583    pub all_symbols: bool,
584    pub exchange: String,
585    pub data_type: String,
586    pub timeframe: Option<String>,
587    pub from: Option<String>,
588    pub to: Option<String>,
589    /// Full signal stream (entry + management).
590    #[serde(default)]
591    pub raw_signals: Vec<RawSignalMsg>,
592    pub profile: Option<String>,
593    #[serde(default)]
594    pub profile_def: Option<ManagementProfileMsg>,
595    #[serde(default, skip_serializing_if = "Vec::is_empty")]
596    pub entry_profile_routes: Vec<EntryProfileRouteMsg>,
597    pub config: BacktestConfigMsg,
598}
599
600/// Response from a single backtest run.
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct RunBacktestResponse {
603    pub success: bool,
604    pub error: Option<String>,
605    /// Complete inline result or compact summary when `inline_complete` is false.
606    pub result: Option<BacktestResultMsg>,
607    pub elapsed_ms: u64,
608    #[serde(default)]
609    pub artifact: Option<ResultArtifactRefMsg>,
610    #[serde(default = "default_true")]
611    pub inline_complete: bool,
612}
613
614/// Strict FutureQuoteV1 request accepted by `run_backtest`.
615#[derive(Debug, Clone, Serialize)]
616pub struct RunBacktestRequest {
617    pub request: BacktestRunSpec,
618    #[serde(default)]
619    pub future: FutureQuoteConfigMsg,
620    #[serde(default)]
621    pub evaluation: ProviderEvaluationOptionsMsg,
622    #[serde(default)]
623    pub result_delivery: ResultDeliveryMsg,
624}
625
626/// Asynchronous FutureQuoteV1 submission.
627#[derive(Debug, Clone, Serialize)]
628pub struct SubmitBacktestRequest {
629    pub request: RunBacktestRequest,
630}
631
632// ── Run Backtest Multi ──────────────────────────────────────────────────────
633
634/// Execution scope and inputs for a multi-profile comparison.
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct BacktestMultiRunSpec {
637    #[serde(default)]
638    pub on_unavailable: UnavailableInstrumentPolicyMsg,
639    pub symbol: String,
640    #[serde(default)]
641    pub symbols: Vec<String>,
642    #[serde(default)]
643    pub all_symbols: bool,
644    pub exchange: String,
645    pub data_type: String,
646    pub timeframe: Option<String>,
647    pub from: Option<String>,
648    pub to: Option<String>,
649    /// Full signal stream (entry + management).
650    #[serde(default)]
651    pub raw_signals: Vec<RawSignalMsg>,
652    pub profiles: Vec<ProfileRef>,
653    #[serde(default, skip_serializing_if = "Vec::is_empty")]
654    pub entry_profile_routes: Vec<EntryProfileRouteMsg>,
655    pub config: BacktestConfigMsg,
656}
657
658/// Result for one profile in a multi-profile comparison.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct ProfileResult {
661    pub profile: String,
662    pub success: bool,
663    pub error: Option<String>,
664    pub result: Option<BacktestResultMsg>,
665}
666
667/// Response from a multi-profile comparison run.
668#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct RunBacktestMultiResponse {
670    /// Defaults to true when decoding responses from older servers.
671    #[serde(default = "default_true")]
672    pub success: bool,
673    #[serde(default)]
674    pub error: Option<String>,
675    /// Complete inline results or compact summaries when `inline_complete` is false.
676    pub results: Vec<ProfileResult>,
677    pub elapsed_ms: u64,
678    #[serde(default)]
679    pub artifact: Option<ResultArtifactRefMsg>,
680    #[serde(default = "default_true")]
681    pub inline_complete: bool,
682}
683
684#[derive(Debug, Clone, Serialize)]
685pub struct RunBacktestMultiRequest {
686    pub request: BacktestMultiRunSpec,
687    #[serde(default)]
688    pub future: FutureQuoteConfigMsg,
689    #[serde(default)]
690    pub evaluation: ProviderEvaluationOptionsMsg,
691    #[serde(default)]
692    pub result_delivery: ResultDeliveryMsg,
693}
694
695// ── Configured Strategy Runs ────────────────────────────────────────────────
696
697/// Price basis the bars of a configured logical source are built on.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum PriceBasisMsg {
701    Bid,
702    Ask,
703    Mid,
704}
705
706/// Historical series geometry for one configured logical source; the source always reads the request symbol.
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(deny_unknown_fields)]
709pub struct SourceBindingMsg {
710    pub source: String,
711    pub timeframe_seconds: u32,
712    pub price_basis: PriceBasisMsg,
713    #[serde(default)]
714    pub alignment_offset_seconds: i32,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
718#[serde(deny_unknown_fields)]
719pub struct HistoricalInputsMsg {
720    pub calendars: BTreeMap<String, TradingCalendarMsg>,
721    pub inputs: Vec<CalendarInputMsg>,
722    #[serde(default)]
723    pub limits: Option<CalendarLimitsMsg>,
724}
725
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(deny_unknown_fields)]
728pub struct TradingCalendarMsg {
729    pub id: String,
730    pub timezone: String,
731    #[serde(default = "default_day_boundary")]
732    pub day_boundary: String,
733    #[serde(default)]
734    pub sessions: SessionScheduleMsg,
735    #[serde(default)]
736    pub market: MarketScheduleMsg,
737}
738
739fn default_day_boundary() -> String {
740    "00:00:00".into()
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
744#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
745pub enum SessionScheduleMsg {
746    #[default]
747    FullDay,
748    Custom {
749        items: Vec<NamedSessionMsg>,
750    },
751}
752
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(deny_unknown_fields)]
755pub struct NamedSessionMsg {
756    pub id: String,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub timezone: Option<String>,
759    pub span: SessionSpanMsg,
760    #[serde(default)]
761    pub weekdays: Vec<u8>,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
766pub enum SessionSpanMsg {
767    FullDay,
768    Timed {
769        start: String,
770        end: String,
771        end_day_offset: u8,
772    },
773}
774
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
776#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
777pub enum MarketScheduleMsg {
778    #[default]
779    Unspecified,
780    Continuous,
781    Weekly {
782        intervals: Vec<WeeklyMarketIntervalMsg>,
783        #[serde(default)]
784        exceptions: BTreeMap<String, Vec<LocalMarketIntervalMsg>>,
785    },
786}
787
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(deny_unknown_fields)]
790pub struct WeeklyMarketIntervalMsg {
791    pub weekday: u8,
792    pub start: String,
793    pub end: String,
794    pub end_day_offset: u8,
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798#[serde(deny_unknown_fields)]
799pub struct LocalMarketIntervalMsg {
800    pub start: String,
801    pub end: String,
802    pub end_day_offset: u8,
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
806#[serde(deny_unknown_fields)]
807pub struct CalendarInputMsg {
808    pub name: String,
809    pub source: String,
810    pub calendar: String,
811    pub feature: CalendarFeatureMsg,
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub session: Option<String>,
814    #[serde(default)]
815    pub time_basis: CalendarTimeBasisMsg,
816    #[serde(default = "default_opening_range_minutes")]
817    pub opening_range_minutes: u32,
818    pub child_seconds: u64,
819    #[serde(default)]
820    pub alignment_offset_seconds: i64,
821    pub maximum_history: usize,
822}
823
824fn default_opening_range_minutes() -> u32 {
825    5
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(rename_all = "snake_case")]
830pub enum CalendarFeatureMsg {
831    LocalSecondOfDay,
832    SessionElapsedSeconds,
833    SessionMembership,
834    PreviousSessionHigh,
835    PreviousSessionLow,
836    PreviousDayHigh,
837    PreviousDayLow,
838    PreviousWeekHigh,
839    PreviousWeekLow,
840    OpeningRangeHighSoFar,
841    OpeningRangeLowSoFar,
842    OpeningRangeHighFinal,
843    OpeningRangeLowFinal,
844    PastSameSlotRangeRatio,
845    PastSameSlotCount,
846}
847
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
849#[serde(rename_all = "snake_case")]
850pub enum CalendarTimeBasisMsg {
851    #[default]
852    SourceOpen,
853    DecisionTime,
854}
855
856#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(deny_unknown_fields)]
858pub struct CalendarLimitsMsg {
859    pub max_sessions: usize,
860    pub max_market_intervals: usize,
861    pub max_exceptions: usize,
862    pub max_history_occurrences: usize,
863    pub max_resolved_children: usize,
864    pub max_owned_bytes: usize,
865}
866
867/// One fully bound configured strategy document and the series backing its sources.
868#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
869#[serde(deny_unknown_fields)]
870pub struct ConfiguredStrategyRunMsg {
871    /// Canonical strategy document as a JSON value; the server decodes it strictly and rejects unknown fields.
872    pub document: serde_json::Value,
873    pub sources: Vec<SourceBindingMsg>,
874    /// Instance identifier used in generated campaign, trade, and command IDs; the server uses `instance` when omitted.
875    #[serde(default, skip_serializing_if = "Option::is_none")]
876    pub instance_id: Option<String>,
877    /// Decision latency in milliseconds applied to generated signals.
878    #[serde(default)]
879    pub decision_latency_ms: u64,
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub historical_inputs: Option<HistoricalInputsMsg>,
882}
883
884/// Execution scope for one configured strategy run over one symbol.
885#[derive(Debug, Clone, Serialize, Deserialize)]
886#[serde(deny_unknown_fields)]
887pub struct ConfiguredStrategyRunSpec {
888    pub symbol: String,
889    pub exchange: String,
890    /// `tick`, or `bar` together with `timeframe`.
891    pub data_type: String,
892    #[serde(default)]
893    pub timeframe: Option<String>,
894    #[serde(default)]
895    pub from: Option<String>,
896    #[serde(default)]
897    pub to: Option<String>,
898    pub strategy: ConfiguredStrategyRunMsg,
899    #[serde(default)]
900    pub profile: Option<String>,
901    #[serde(default)]
902    pub profile_def: Option<ManagementProfileMsg>,
903    #[serde(default, skip_serializing_if = "Vec::is_empty")]
904    pub entry_profile_routes: Vec<EntryProfileRouteMsg>,
905    pub config: BacktestConfigMsg,
906}
907
908/// Synchronous configured strategy run.
909#[derive(Debug, Clone, Serialize)]
910pub struct RunConfiguredStrategyRequest {
911    pub request: ConfiguredStrategyRunSpec,
912    #[serde(default)]
913    pub future: FutureQuoteConfigMsg,
914    #[serde(default)]
915    pub evaluation: ProviderEvaluationOptionsMsg,
916    #[serde(default)]
917    pub result_delivery: ResultDeliveryMsg,
918}
919
920/// Asynchronous configured strategy submission retained as a job.
921#[derive(Debug, Clone, Serialize)]
922pub struct SubmitConfiguredStrategyRequest {
923    pub request: RunConfiguredStrategyRequest,
924}
925
926/// Strategy output returned with a configured run's economic result.
927#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
928pub struct ConfiguredStrategyOutputMsg {
929    /// The document the run compiled, as decoded.
930    pub document: serde_json::Value,
931    pub sources: Vec<SourceBindingMsg>,
932    /// Compiled requirements the run was bound with: per-source completed-bar lookback, trade slots, Entry slots and classes, and stop-managed slots.
933    pub requirements: serde_json::Value,
934    /// `ticks` or `bars`; a bar run fills waiting orders at each bar's open and settles stops, targets, and pending orders against its range, meeting the adverse extreme first.
935    pub data_mode: String,
936    /// Retained configured decisions with the signals each emitted.
937    pub decisions: serde_json::Value,
938    /// Configured notes and research-only output.
939    pub research: serde_json::Value,
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub historical_inputs: Option<serde_json::Value>,
942}
943
944// ── Portfolio Runs ──────────────────────────────────────────────────────────
945
946/// One configured strategy instance of a portfolio run, trading one symbol against the shared account.
947#[derive(Debug, Clone, Serialize, Deserialize)]
948#[serde(deny_unknown_fields)]
949pub struct PortfolioInstanceMsg {
950    pub symbol: String,
951    /// Strategy document and source bindings. `instance_id` is required here and must be unique in the run, because it labels the instance's positions, reviews, and output.
952    pub strategy: ConfiguredStrategyRunMsg,
953    #[serde(default)]
954    pub profile: Option<String>,
955    #[serde(default)]
956    pub profile_def: Option<ManagementProfileMsg>,
957    #[serde(default, skip_serializing_if = "Vec::is_empty")]
958    pub entry_profile_routes: Vec<EntryProfileRouteMsg>,
959}
960
961/// Execution scope of a portfolio run: several configured instances, one account, and optional portfolio policies.
962#[derive(Debug, Clone, Serialize, Deserialize)]
963#[serde(deny_unknown_fields)]
964pub struct PortfolioRunSpec {
965    pub instances: Vec<PortfolioInstanceMsg>,
966    pub exchange: String,
967    /// `tick`, or `bar` together with `timeframe`; every instance source declares the same bar duration.
968    pub data_type: String,
969    #[serde(default)]
970    pub timeframe: Option<String>,
971    #[serde(default)]
972    pub from: Option<String>,
973    #[serde(default)]
974    pub to: Option<String>,
975    pub config: BacktestConfigMsg,
976    /// Portfolio policies as a JSON array, decoded strictly by the server; omitted or empty means the run has no supervisor.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub policies: Option<serde_json::Value>,
979    /// Correlation groups the policies refer to, as a JSON array.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub groups: Option<serde_json::Value>,
982}
983
984/// Synchronous portfolio run.
985#[derive(Debug, Clone, Serialize)]
986pub struct RunPortfolioRequest {
987    pub request: PortfolioRunSpec,
988    #[serde(default)]
989    pub future: FutureQuoteConfigMsg,
990    #[serde(default)]
991    pub evaluation: ProviderEvaluationOptionsMsg,
992    #[serde(default)]
993    pub result_delivery: ResultDeliveryMsg,
994}
995
996/// Asynchronous portfolio submission retained as a job.
997#[derive(Debug, Clone, Serialize)]
998pub struct SubmitPortfolioRequest {
999    pub request: RunPortfolioRequest,
1000}
1001
1002/// What one portfolio instance compiled, decided, and recorded.
1003#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1004pub struct PortfolioInstanceOutputMsg {
1005    pub instance_id: String,
1006    pub symbol: String,
1007    pub strategy: ConfiguredStrategyOutputMsg,
1008}
1009
1010/// Instance and supervisor output returned with a portfolio run's shared economic result.
1011#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1012pub struct PortfolioOutputMsg {
1013    pub instances: Vec<PortfolioInstanceOutputMsg>,
1014    /// The decoded policies and groups the run was supervised with, when it had a supervisor.
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub policies: Option<serde_json::Value>,
1017    /// Every review, halt action, and halt interval, when the run had a supervisor.
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub supervisor: Option<serde_json::Value>,
1020}
1021
1022// ── Parameter Search ────────────────────────────────────────────────────────
1023
1024/// One labelled half-open evaluation window.
1025#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1026#[serde(deny_unknown_fields)]
1027pub struct SearchWindowMsg {
1028    pub label: String,
1029    pub from: String,
1030    pub to: String,
1031}
1032
1033/// Evaluation windows of a parameter search.
1034#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
1036pub enum SearchWindowsMsg {
1037    Fixed {
1038        in_sample: SearchWindowMsg,
1039        out_of_sample: SearchWindowMsg,
1040    },
1041    RollingWalkForward {
1042        start: String,
1043        end: String,
1044        train_seconds: i64,
1045        test_seconds: i64,
1046        step_seconds: i64,
1047    },
1048}
1049
1050#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1051#[serde(rename_all = "snake_case")]
1052pub enum SearchEvaluationRoleMsg {
1053    Search,
1054    Validation,
1055    Final,
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1059#[serde(deny_unknown_fields)]
1060pub struct SearchSelectedRerunMsg {
1061    pub experiment_recipe: serde_json::Value,
1062    pub candidate_recipe: serde_json::Value,
1063    pub run_recipe: serde_json::Value,
1064    pub role: SearchEvaluationRoleMsg,
1065    pub caller_revision: String,
1066    #[serde(default)]
1067    pub release_final: bool,
1068    #[serde(default)]
1069    pub future_horizon_millis: Option<u64>,
1070    #[serde(default)]
1071    pub embargo_millis: Option<u64>,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1075#[serde(deny_unknown_fields)]
1076pub struct SearchDirectPortfolioInstanceMsg {
1077    pub instance_id: String,
1078    pub symbol: String,
1079    pub factory: SearchDirectFactoryMsg,
1080    #[serde(default)]
1081    pub profile: Option<String>,
1082}
1083
1084#[derive(Debug, Clone, Serialize, Deserialize)]
1085#[serde(deny_unknown_fields)]
1086pub struct SearchPortfolioCandidateMsg {
1087    pub id: String,
1088    #[serde(default)]
1089    pub instances: Vec<PortfolioInstanceMsg>,
1090    #[serde(default)]
1091    pub direct_instances: Vec<SearchDirectPortfolioInstanceMsg>,
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub policies: Option<serde_json::Value>,
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub groups: Option<serde_json::Value>,
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099#[serde(tag = "type", content = "value", rename_all = "snake_case")]
1100pub enum SearchFactoryParameterMsg {
1101    Integer(i64),
1102    Number(f64),
1103    Choice(String),
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[serde(deny_unknown_fields)]
1108pub struct SearchDirectFactoryPointMsg {
1109    pub parameters: BTreeMap<String, SearchFactoryParameterMsg>,
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113#[serde(deny_unknown_fields)]
1114pub struct SearchDirectFactoryMsg {
1115    pub name: String,
1116    pub revision: String,
1117    pub points: Vec<SearchDirectFactoryPointMsg>,
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121#[serde(deny_unknown_fields)]
1122pub struct SearchExecutionVariantMsg {
1123    pub id: String,
1124    pub config: BacktestConfigMsg,
1125    #[serde(default)]
1126    pub future: FutureQuoteConfigMsg,
1127    #[serde(default)]
1128    pub profile: Option<String>,
1129}
1130
1131/// Execution scope of a server-side parameter search over a strategy template and a space document.
1132#[derive(Debug, Clone, Serialize, Deserialize)]
1133#[serde(deny_unknown_fields)]
1134pub struct SearchRunSpec {
1135    /// Strategy template document as a JSON value.
1136    pub template: serde_json::Value,
1137    /// Space document as a JSON value.
1138    pub space: serde_json::Value,
1139    pub symbols: Vec<String>,
1140    pub exchange: String,
1141    /// `tick`, or `bar` together with `timeframe`.
1142    pub data_type: String,
1143    #[serde(default)]
1144    pub timeframe: Option<String>,
1145    pub windows: SearchWindowsMsg,
1146    pub config: BacktestConfigMsg,
1147    /// Registered run default profile; inline profiles are not accepted for a search.
1148    #[serde(default)]
1149    pub profile: Option<String>,
1150    /// Entry-class routes to registered profiles only.
1151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1152    pub entry_profile_routes: Vec<EntryProfileRouteMsg>,
1153    /// Requested worker threads, capped by the server's configured maximum.
1154    #[serde(default)]
1155    pub workers: Option<usize>,
1156    #[serde(default)]
1157    pub decision_latency_ms: u64,
1158    #[serde(default)]
1159    pub structural: Option<serde_json::Value>,
1160    #[serde(default)]
1161    pub resource_limits: Option<serde_json::Value>,
1162    #[serde(default)]
1163    pub variants: Vec<SearchExecutionVariantMsg>,
1164    /// Select a server-registered immutable direct Rust factory; arbitrary code is never accepted.
1165    #[serde(default)]
1166    pub direct_factory: Option<SearchDirectFactoryMsg>,
1167    /// A prior compatible checkpoint whose completed runs are retained and skipped.
1168    #[serde(default)]
1169    pub resume_checkpoint: Option<serde_json::Value>,
1170    /// Execute one downloaded frozen recipe instead of the declared candidate batch.
1171    #[serde(default)]
1172    pub selected_rerun: Option<SearchSelectedRerunMsg>,
1173    /// Explicit configured portfolio candidates evaluated under the request's windows and economics.
1174    #[serde(default)]
1175    pub portfolio_candidates: Vec<SearchPortfolioCandidateMsg>,
1176    /// Verified enhanced series descriptors for `price_bar` inputs.
1177    #[serde(default)]
1178    pub series_descriptors: Vec<serde_json::Value>,
1179}
1180
1181/// Asynchronous search submission retained as a job.
1182#[derive(Debug, Clone, Serialize)]
1183pub struct SubmitSearchRequest {
1184    pub request: SearchRunSpec,
1185    #[serde(default)]
1186    pub future: FutureQuoteConfigMsg,
1187    #[serde(default)]
1188    pub evaluation: ProviderEvaluationOptionsMsg,
1189}
1190
1191/// Request for the output of a completed search job.
1192#[derive(Debug, Clone, Serialize, Deserialize)]
1193#[serde(deny_unknown_fields)]
1194pub struct GetSearchResultRequest {
1195    pub job_id: String,
1196}
1197
1198/// Counts that describe a completed search without its full output.
1199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1200pub struct SearchSummaryMsg {
1201    pub points_total: usize,
1202    pub rows: usize,
1203    pub completed_rows: usize,
1204    pub failed_rows: usize,
1205    /// `ticks` or `bars`.
1206    pub data_mode: String,
1207}
1208
1209/// Complete search output, always delivered as the job's result artifact.
1210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1211pub struct SearchResultMsg {
1212    pub summary: SearchSummaryMsg,
1213    /// Comparison table in the research CSV format, one row per run, ordered by parameter rather than result.
1214    pub table_csv: String,
1215    /// Pooled provider evaluation over every run's completed positions.
1216    pub evaluation: serde_json::Value,
1217    /// Bound strategy document of every configured point, in point order.
1218    pub bound_documents: Vec<serde_json::Value>,
1219    #[serde(default)]
1220    pub experiment_recipe: Option<serde_json::Value>,
1221    #[serde(default)]
1222    pub candidate_recipes: Vec<serde_json::Value>,
1223    #[serde(default)]
1224    pub run_recipes: Vec<serde_json::Value>,
1225    #[serde(default)]
1226    pub generation_dispositions: Option<serde_json::Value>,
1227    #[serde(default)]
1228    pub checkpoint: Option<serde_json::Value>,
1229    #[serde(default)]
1230    pub selected_evidence: Option<serde_json::Value>,
1231    #[serde(default)]
1232    pub trace: Option<serde_json::Value>,
1233}
1234
1235/// Response carrying a search summary and the artifact holding its complete output.
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1237pub struct GetSearchResultResponse {
1238    pub success: bool,
1239    pub job_id: String,
1240    #[serde(default)]
1241    pub error: Option<String>,
1242    #[serde(default)]
1243    pub summary: Option<SearchSummaryMsg>,
1244    #[serde(default)]
1245    pub artifact: Option<ResultArtifactRefMsg>,
1246    /// Separately labeled resumable checkpoint produced by a cancelled retained search.
1247    #[serde(default)]
1248    pub checkpoint_artifact: Option<ResultArtifactRefMsg>,
1249}
1250
1251// ── Backtest Result Message ─────────────────────────────────────────────────
1252
1253/// Serializable mirror of `BacktestResult` for wire transport.
1254#[derive(Debug, Clone, Serialize, Deserialize)]
1255pub struct BacktestResultMsg {
1256    #[serde(default, skip_serializing_if = "Option::is_none")]
1257    pub admission_report: Option<ReplayAdmissionReportMsg>,
1258    /// Strategy document, sources, decisions, and notes when the run executed a configured strategy.
1259    #[serde(default, skip_serializing_if = "Option::is_none")]
1260    pub strategy: Option<ConfiguredStrategyOutputMsg>,
1261    /// Instance and supervisor output when the run executed a portfolio of configured strategies.
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub portfolio: Option<PortfolioOutputMsg>,
1264    pub initial_balance: f64,
1265    pub final_balance: f64,
1266    pub total_pnl: f64,
1267    pub total_trades: usize,
1268    pub winning_trades: usize,
1269    pub losing_trades: usize,
1270    pub win_rate: f64,
1271    pub profit_factor: f64,
1272    pub max_drawdown: f64,
1273    pub max_drawdown_pct: f64,
1274
1275    // Extended result breakdowns.
1276    pub summary: SubsetStatsMsg,
1277    pub per_symbol: HashMap<String, SubsetStatsMsg>,
1278    pub per_group: HashMap<String, SubsetStatsMsg>,
1279    pub long_stats: SubsetStatsMsg,
1280    pub short_stats: SubsetStatsMsg,
1281    pub per_close_reason: Vec<CloseReasonStatsMsg>,
1282    pub streaks: StreakStatsMsg,
1283    pub risk_metrics: RiskMetricsMsg,
1284    pub duration_stats: Option<DurationStatsMsg>,
1285    pub monthly_returns: Vec<MonthlyReturnMsg>,
1286
1287    pub equity_curve: Vec<EquityPoint>,
1288    pub trade_log: Vec<TradeResultMsg>,
1289
1290    // Position-level aggregation
1291    pub positions: Vec<PositionSummaryMsg>,
1292    pub total_positions: usize,
1293    pub winning_positions: usize,
1294    pub losing_positions: usize,
1295    pub position_win_rate: f64,
1296
1297    /// Additive FutureQuoteV1 artifacts.
1298    #[serde(default)]
1299    pub future: Option<FutureBacktestResultMsg>,
1300
1301    /// Total account-currency commission charged across every entry and exit fill.
1302    #[serde(default)]
1303    pub total_commission: f64,
1304    /// Total account-currency swap charged across every rollover.
1305    #[serde(default)]
1306    pub total_swap: f64,
1307    /// Realized profit and loss before commission and swap, present only when a cost was charged.
1308    #[serde(default)]
1309    pub gross_pnl: Option<f64>,
1310    /// Commission and swap charges in application order.
1311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1312    pub cost_events: Vec<CostEventMsg>,
1313}
1314
1315/// Wire-safe mirror of one commission or swap charge.
1316#[derive(Debug, Clone, Serialize, Deserialize)]
1317pub struct CostEventMsg {
1318    pub id: String,
1319    pub position_id: String,
1320    pub symbol: String,
1321    pub side: String,
1322    pub ts: String,
1323    /// `entry_commission`, `exit_commission`, or `swap`.
1324    pub kind: String,
1325    /// Signed account-currency charge, where positive reduced the balance.
1326    pub amount: f64,
1327    #[serde(default)]
1328    pub native_amount: Option<f64>,
1329    #[serde(default)]
1330    pub native_currency: Option<String>,
1331    pub size: f64,
1332    #[serde(default)]
1333    pub nights: Option<u32>,
1334}
1335
1336/// Wire-safe mark-to-market output counts.
1337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1338#[serde(default)]
1339pub struct MtmOutputSummaryMsg {
1340    pub policy: MtmOutputPolicyMsg,
1341    pub observed_points: u64,
1342    pub retained_points: u64,
1343    pub omitted_points: u64,
1344}
1345
1346impl Default for MtmOutputSummaryMsg {
1347    fn default() -> Self {
1348        Self {
1349            policy: MtmOutputPolicyMsg::Full,
1350            observed_points: 0,
1351            retained_points: 0,
1352            omitted_points: 0,
1353        }
1354    }
1355}
1356
1357/// Format-versioned execution/accounting payload. Complex internal records remain
1358/// JSON values so new additive fields do not require synchronized positional
1359/// wire changes; the transport itself uses JsonCodec.
1360#[derive(Debug, Clone, Serialize, Deserialize)]
1361pub struct FutureBacktestResultMsg {
1362    pub format_version: u32,
1363    pub execution_metadata: serde_json::Value,
1364    pub recorded_fills: serde_json::Value,
1365    pub action_dispositions: serde_json::Value,
1366    pub close_events: serde_json::Value,
1367    pub completed_positions: serde_json::Value,
1368    pub open_positions: serde_json::Value,
1369    pub pending_orders: serde_json::Value,
1370    /// Typed FutureQuote pending-order transition stream.
1371    #[serde(default)]
1372    pub pending_order_lifecycle: Vec<PendingOrderLifecycleEventMsg>,
1373    pub mtm_equity_curve: serde_json::Value,
1374    #[serde(default)]
1375    pub mtm_output_summary: MtmOutputSummaryMsg,
1376    pub mtm_max_drawdown: Option<f64>,
1377    pub mtm_max_drawdown_pct: Option<f64>,
1378    pub provider_evaluation: serde_json::Value,
1379}
1380
1381/// Wire-safe state for one pending-order lifecycle transition.
1382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1383#[serde(rename_all = "snake_case")]
1384pub enum PendingOrderLifecycleStateMsg {
1385    #[default]
1386    Placed,
1387    Filled,
1388    Cancelled,
1389    UnfilledAtEnd,
1390}
1391
1392/// Typed wire mirror of a FutureQuote pending-order lifecycle event.
1393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1394#[serde(default)]
1395pub struct PendingOrderLifecycleEventMsg {
1396    pub id: String,
1397    pub sequence: u64,
1398    pub position_id: String,
1399    pub placement_action_id: Option<String>,
1400    pub terminal_action_id: Option<String>,
1401    pub state: PendingOrderLifecycleStateMsg,
1402    pub symbol: String,
1403    pub side: String,
1404    pub order_type: String,
1405    pub requested_size: f64,
1406    pub filled_size: Option<f64>,
1407    pub requested_price: Option<f64>,
1408    pub fill_price: Option<f64>,
1409    pub signal_ts: Option<String>,
1410    pub placed_ts: Option<String>,
1411    pub effective_ts: Option<String>,
1412    pub terminal_ts: Option<String>,
1413    pub wait_latency_ms: Option<i64>,
1414    pub fill_ratio: Option<f64>,
1415}
1416
1417// ── Sub-message types ───────────────────────────────────────────────────────
1418
1419/// Wire-safe mirror of `SubsetStats`.
1420#[derive(Debug, Clone, Serialize, Deserialize)]
1421pub struct SubsetStatsMsg {
1422    pub total_trades: usize,
1423    pub winning_trades: usize,
1424    pub losing_trades: usize,
1425    pub breakeven_trades: usize,
1426    pub total_pnl: f64,
1427    pub gross_profit: f64,
1428    pub gross_loss: f64,
1429    pub win_rate: f64,
1430    pub profit_factor: f64,
1431    pub avg_win: f64,
1432    pub avg_loss: f64,
1433    pub win_loss_ratio: f64,
1434    pub expectancy: f64,
1435    pub largest_win: f64,
1436    pub largest_loss: f64,
1437    /// Commission settled on this subset's rows.
1438    #[serde(default)]
1439    pub commission: f64,
1440    /// Swap settled on this subset's rows.
1441    #[serde(default)]
1442    pub swap: f64,
1443    /// Subset profit and loss before `commission` and `swap`, present only when either was settled.
1444    #[serde(default)]
1445    pub gross_pnl: Option<f64>,
1446}
1447
1448/// Wire-safe mirror of `StreakStats`.
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1450pub struct StreakStatsMsg {
1451    pub max_consecutive_wins: u32,
1452    pub max_consecutive_losses: u32,
1453    pub current_streak: i32,
1454}
1455
1456/// Wire-safe mirror of `RiskMetrics`.
1457#[derive(Debug, Clone, Serialize, Deserialize)]
1458pub struct RiskMetricsMsg {
1459    pub sharpe_ratio: Option<f64>,
1460    pub sortino_ratio: Option<f64>,
1461    pub calmar_ratio: Option<f64>,
1462    pub return_on_max_drawdown: Option<f64>,
1463    pub max_drawdown: f64,
1464    pub max_drawdown_pct: f64,
1465    pub max_drawdown_duration_secs: Option<i64>,
1466}
1467
1468/// Wire-safe mirror of `DurationStats`.
1469#[derive(Debug, Clone, Serialize, Deserialize)]
1470pub struct DurationStatsMsg {
1471    pub avg_duration_secs: i64,
1472    pub min_duration_secs: i64,
1473    pub max_duration_secs: i64,
1474    pub avg_winner_duration_secs: i64,
1475    pub avg_loser_duration_secs: i64,
1476}
1477
1478/// Wire-safe mirror of `MonthlyReturn`.
1479#[derive(Debug, Clone, Serialize, Deserialize)]
1480pub struct MonthlyReturnMsg {
1481    pub year: i32,
1482    pub month: u32,
1483    pub pnl: f64,
1484    pub trade_count: usize,
1485    pub ending_balance: f64,
1486}
1487
1488/// Wire-safe mirror of `CloseReasonStats`.
1489#[derive(Debug, Clone, Serialize, Deserialize)]
1490pub struct CloseReasonStatsMsg {
1491    pub reason: String,
1492    pub count: usize,
1493    pub total_pnl: f64,
1494    pub avg_pnl: f64,
1495    pub percentage: f64,
1496}
1497
1498/// Wire-safe mirror of `PositionSummary`.
1499#[derive(Debug, Clone, Serialize, Deserialize)]
1500pub struct PositionSummaryMsg {
1501    pub position_id: String,
1502    pub symbol: String,
1503    pub side: String,
1504    pub group: Option<String>,
1505    pub entry_price: f64,
1506    pub avg_exit_price: f64,
1507    pub original_size: f64,
1508    pub close_count: usize,
1509    pub net_pnl: f64,
1510    pub close_reasons: Vec<String>,
1511    pub open_ts: String,
1512    pub final_close_ts: Option<String>,
1513    pub duration_seconds: i64,
1514}
1515
1516/// A single point on the equity curve.
1517#[derive(Debug, Clone, Serialize, Deserialize)]
1518pub struct EquityPoint {
1519    pub ts: String,
1520    pub balance: f64,
1521}
1522
1523/// Wire-safe mirror of `TradeResult`.
1524#[derive(Debug, Clone, Serialize, Deserialize)]
1525pub struct TradeResultMsg {
1526    pub position_id: String,
1527    pub symbol: String,
1528    pub side: String,
1529    pub entry_price: f64,
1530    pub exit_price: f64,
1531    pub size: f64,
1532    /// Realized profit and loss for this close, already net of `commission` and `swap`.
1533    pub pnl: f64,
1534    /// Account-currency commission settled on this row and already subtracted from `pnl`.
1535    ///
1536    /// The row that fully closes a position also settles that position's entry commission, because entry commission belongs to the position rather than to any one close.
1537    #[serde(default)]
1538    pub commission: f64,
1539    /// Account-currency swap settled on this row, which is non-zero only on the row that fully closes a position.
1540    #[serde(default)]
1541    pub swap: f64,
1542    /// Profit and loss before `commission` and `swap`, present only when either was settled.
1543    #[serde(default)]
1544    pub gross_pnl: Option<f64>,
1545    pub open_ts: String,
1546    pub close_ts: String,
1547    pub close_reason: String,
1548    pub group: Option<String>,
1549}
1550
1551// Dynamic management profile messages.
1552
1553/// Wire-safe strict target selection.
1554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1555pub enum TargetSelectionMsg {
1556    /// Use every target supplied by the entry signal, in signal order.
1557    All,
1558    /// Do not attach any targets.
1559    None,
1560    /// Use the listed 1-based signal target indices, in the listed order.
1561    Selected(Vec<usize>),
1562}
1563
1564/// Wire-safe source used to select or generate targets.
1565#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1566#[serde(tag = "type", deny_unknown_fields)]
1567pub enum TargetSourceMsg {
1568    #[default]
1569    FromSignal,
1570    StopDistanceMultiples {
1571        multiples: Vec<f64>,
1572    },
1573}
1574
1575/// Wire-safe entry geometry policy.
1576#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1577#[serde(rename_all = "lowercase")]
1578pub enum EntryGeometryPolicyMsg {
1579    /// Reject entry resolution when a signal level sits on the wrong side
1580    /// of the entry price.
1581    #[default]
1582    Strict,
1583    /// Attach signal levels unchanged; already-crossed levels close at
1584    /// market on the first tick evaluation.
1585    Permissive,
1586}
1587
1588/// Wire-safe management profile definition sent inline with a request.
1589#[derive(Debug, Clone, Serialize, Deserialize)]
1590pub struct ManagementProfileMsg {
1591    pub name: String,
1592    /// Explicit strict selection. This wins over `use_targets` when present;
1593    /// omission preserves the legacy `use_targets`-derived default.
1594    #[serde(default, skip_serializing_if = "Option::is_none")]
1595    pub target_selection: Option<TargetSelectionMsg>,
1596    /// Compatibility selection retained for older serialized profiles.
1597    pub use_targets: Vec<usize>,
1598    pub close_ratios: Vec<f64>,
1599    #[serde(default, skip_serializing_if = "Option::is_none")]
1600    pub target_source: Option<TargetSourceMsg>,
1601    #[serde(default)]
1602    pub stoploss_mode: Option<StoplossModeMsg>,
1603    #[serde(default)]
1604    pub rules: Vec<RuleConfigDefMsg>,
1605    #[serde(default)]
1606    pub group_override: Option<String>,
1607    #[serde(default)]
1608    pub let_remainder_run: bool,
1609    /// Directional geometry policy for signal stoploss/targets vs the
1610    /// execution price. Omission preserves the Strict default.
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub entry_geometry: Option<EntryGeometryPolicyMsg>,
1613}
1614
1615/// Wire-safe stoploss mode enum.
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617#[serde(tag = "type")]
1618pub enum StoplossModeMsg {
1619    FromSignal,
1620    None,
1621    FixedDistance { distance: f64 },
1622    FixedPrice { price: f64 },
1623    FromSignalDistance { multiplier: f64 },
1624}
1625
1626/// Wire-safe rule configuration definition.
1627#[derive(Debug, Clone, Serialize, Deserialize)]
1628#[serde(tag = "type")]
1629pub enum RuleConfigDefMsg {
1630    FixedStoploss { price: f64 },
1631    TrailingStop { distance: f64 },
1632    TakeProfit { price: f64, close_ratio: f64 },
1633    BreakevenWhen { trigger_price: f64 },
1634    BreakevenWhenOffset { trigger_price_offset: f64 },
1635    BreakevenAfterTargets { after_n: u32 },
1636    TimeExit { max_seconds: u64 },
1637}
1638
1639/// A profile reference: either by name or inline definition.
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1641#[serde(untagged)]
1642pub enum ProfileRef {
1643    Named(String),
1644    Inline(ManagementProfileMsg),
1645}
1646
1647/// One exact entry-class route to a named or inline management profile.
1648#[derive(Debug, Clone, Serialize, Deserialize)]
1649#[serde(deny_unknown_fields)]
1650pub struct EntryProfileRouteMsg {
1651    pub entry_class: String,
1652    pub profile: ProfileRef,
1653}
1654
1655// Full raw-signal action messages.
1656
1657/// Wire-safe signal that can represent any action, not just entries.
1658///
1659/// Uses `#[serde(tag = "action")]` so each variant is distinguished by an
1660/// `"action"` field in the JSON/Bincode representation.
1661#[derive(Debug, Clone, Serialize, Deserialize)]
1662#[serde(tag = "action", deny_unknown_fields)]
1663pub enum RawSignalMsg {
1664    /// Open a new position (same semantics as `RawSignalEntryMsg`).
1665    Entry {
1666        ts: String,
1667        symbol: String,
1668        side: String,
1669        order_type: String,
1670        price: Option<f64>,
1671        risk: f64,
1672        stoploss: Option<f64>,
1673        #[serde(default)]
1674        targets: Vec<f64>,
1675        #[serde(default)]
1676        group: Option<String>,
1677        /// Application-defined trade id. Required for `ByTradeId` resolution.
1678        #[serde(default)]
1679        trade_id: Option<String>,
1680        /// Optional semantic class used for per-entry profile routing.
1681        #[serde(default, skip_serializing_if = "Option::is_none")]
1682        entry_class: Option<String>,
1683    },
1684    /// Close position(s) at market.
1685    Close {
1686        ts: String,
1687        position: PositionRefMsg,
1688    },
1689    /// Close a fraction of position(s).
1690    ClosePartial {
1691        ts: String,
1692        position: PositionRefMsg,
1693        ratio: f64,
1694    },
1695    /// Set or replace the stoploss price.
1696    ModifyStoploss {
1697        ts: String,
1698        position: PositionRefMsg,
1699        price: f64,
1700    },
1701    /// Move stoploss to the average entry price.
1702    MoveStoplossToEntry {
1703        ts: String,
1704        position: PositionRefMsg,
1705    },
1706    /// Add a take-profit level.
1707    AddTarget {
1708        ts: String,
1709        position: PositionRefMsg,
1710        price: f64,
1711        close_ratio: f64,
1712    },
1713    /// Remove a take-profit level at a specific price.
1714    RemoveTarget {
1715        ts: String,
1716        position: PositionRefMsg,
1717        price: f64,
1718    },
1719    /// Atomically change an existing take-profit price while retaining its ratio.
1720    ModifyTarget {
1721        ts: String,
1722        position: PositionRefMsg,
1723        old_price: f64,
1724        new_price: f64,
1725    },
1726    /// Attach a management rule.
1727    AddRule {
1728        ts: String,
1729        position: PositionRefMsg,
1730        rule: RuleConfigDefMsg,
1731    },
1732    /// Remove a management rule by name.
1733    RemoveRule {
1734        ts: String,
1735        position: PositionRefMsg,
1736        rule_name: String,
1737    },
1738    /// Scale into an existing position.
1739    ScaleIn {
1740        ts: String,
1741        position: PositionRefMsg,
1742        price: Option<f64>,
1743        size: f64,
1744    },
1745    /// Cancel a pending order.
1746    CancelPending {
1747        ts: String,
1748        position: PositionRefMsg,
1749    },
1750    /// Close all open positions on a symbol.
1751    CloseAllOf { ts: String, symbol: String },
1752    /// Close all open positions.
1753    CloseAll { ts: String },
1754    /// Cancel all pending orders.
1755    CancelAllPending { ts: String },
1756    /// Modify stoploss for all open positions on a symbol.
1757    ModifyAllStoploss {
1758        ts: String,
1759        symbol: String,
1760        price: f64,
1761    },
1762    /// Close all open positions in a group.
1763    CloseAllInGroup { ts: String, group_id: String },
1764    /// Modify stoploss for all open positions in a group.
1765    ModifyAllStoplossInGroup {
1766        ts: String,
1767        group_id: String,
1768        price: f64,
1769    },
1770}
1771
1772impl RawSignalMsg {
1773    /// Extract the timestamp string from any variant.
1774    pub fn ts(&self) -> &str {
1775        match self {
1776            RawSignalMsg::Entry { ts, .. }
1777            | RawSignalMsg::Close { ts, .. }
1778            | RawSignalMsg::ClosePartial { ts, .. }
1779            | RawSignalMsg::ModifyStoploss { ts, .. }
1780            | RawSignalMsg::MoveStoplossToEntry { ts, .. }
1781            | RawSignalMsg::AddTarget { ts, .. }
1782            | RawSignalMsg::RemoveTarget { ts, .. }
1783            | RawSignalMsg::ModifyTarget { ts, .. }
1784            | RawSignalMsg::AddRule { ts, .. }
1785            | RawSignalMsg::RemoveRule { ts, .. }
1786            | RawSignalMsg::ScaleIn { ts, .. }
1787            | RawSignalMsg::CancelPending { ts, .. }
1788            | RawSignalMsg::CloseAllOf { ts, .. }
1789            | RawSignalMsg::CloseAll { ts }
1790            | RawSignalMsg::CancelAllPending { ts }
1791            | RawSignalMsg::ModifyAllStoploss { ts, .. }
1792            | RawSignalMsg::CloseAllInGroup { ts, .. }
1793            | RawSignalMsg::ModifyAllStoplossInGroup { ts, .. } => ts,
1794        }
1795    }
1796}
1797
1798/// Wire-safe position reference.
1799#[derive(Debug, Clone, Serialize, Deserialize)]
1800#[serde(tag = "type")]
1801pub enum PositionRefMsg {
1802    /// Target the position with the given application-defined trade id.
1803    ByTradeId { trade_id: String },
1804    /// All open positions on this symbol.
1805    AllOnSymbol { symbol: String },
1806    /// All open positions in this group.
1807    AllInGroup { group_id: String },
1808}
1809
1810// ── Strict request decoding ──────────────────────────────────────────────────
1811
1812#[derive(Serialize, Deserialize)]
1813#[serde(tag = "type", deny_unknown_fields)]
1814enum StrictSizingPolicyMsg {
1815    FixedLot { lots: f64 },
1816    FixedRiskAmount { amount: f64 },
1817    BalanceRiskPercent { percent: f64 },
1818}
1819
1820#[derive(Serialize, Deserialize)]
1821#[serde(tag = "type", deny_unknown_fields)]
1822enum StrictCommissionModelMsg {
1823    #[serde(alias = "per_lot_per_side")]
1824    PerLotPerSide { amount: f64, currency: String },
1825    #[serde(alias = "notional_rate_per_side")]
1826    NotionalRatePerSide { buy_rate: f64, sell_rate: f64 },
1827}
1828
1829#[derive(Serialize, Deserialize)]
1830#[serde(tag = "unit", deny_unknown_fields)]
1831enum StrictSwapAmountMsg {
1832    #[serde(alias = "points")]
1833    Points { long: f64, short: f64 },
1834    #[serde(alias = "currency")]
1835    Currency {
1836        long: f64,
1837        short: f64,
1838        currency: String,
1839    },
1840}
1841
1842#[derive(Serialize, Deserialize)]
1843#[serde(deny_unknown_fields)]
1844struct StrictSwapScheduleMsg {
1845    amount: StrictSwapAmountMsg,
1846    rollover: String,
1847    triple_weekday: String,
1848    #[serde(default)]
1849    skipped_weekdays: Vec<String>,
1850}
1851
1852#[derive(Serialize, Deserialize)]
1853#[serde(deny_unknown_fields)]
1854struct StrictInstrumentCostsMsg {
1855    #[serde(default)]
1856    commission: Option<StrictCommissionModelMsg>,
1857    #[serde(default)]
1858    swap: Option<StrictSwapScheduleMsg>,
1859}
1860
1861#[derive(Serialize, Deserialize)]
1862#[serde(deny_unknown_fields)]
1863struct StrictBacktestConfigMsg {
1864    initial_balance: Option<f64>,
1865    close_on_finish: Option<bool>,
1866    fill_model: Option<String>,
1867    #[serde(default)]
1868    sizing: Option<StrictSizingPolicyMsg>,
1869    #[serde(default)]
1870    costs: BTreeMap<String, StrictInstrumentCostsMsg>,
1871}
1872
1873#[derive(Serialize, Deserialize)]
1874#[serde(tag = "type", deny_unknown_fields)]
1875enum StrictStoplossModeMsg {
1876    FromSignal,
1877    None,
1878    FixedDistance { distance: f64 },
1879    FixedPrice { price: f64 },
1880    FromSignalDistance { multiplier: f64 },
1881}
1882
1883#[derive(Serialize, Deserialize)]
1884#[serde(tag = "type", deny_unknown_fields)]
1885enum StrictTargetSourceMsg {
1886    FromSignal,
1887    StopDistanceMultiples { multiples: Vec<f64> },
1888}
1889
1890#[derive(Serialize, Deserialize)]
1891#[serde(tag = "type", deny_unknown_fields)]
1892enum StrictRuleConfigDefMsg {
1893    FixedStoploss { price: f64 },
1894    TrailingStop { distance: f64 },
1895    TakeProfit { price: f64, close_ratio: f64 },
1896    BreakevenWhen { trigger_price: f64 },
1897    BreakevenWhenOffset { trigger_price_offset: f64 },
1898    BreakevenAfterTargets { after_n: u32 },
1899    TimeExit { max_seconds: u64 },
1900}
1901
1902#[derive(Serialize, Deserialize)]
1903#[serde(deny_unknown_fields)]
1904struct StrictManagementProfileMsg {
1905    name: String,
1906    #[serde(default)]
1907    target_selection: Option<TargetSelectionMsg>,
1908    use_targets: Vec<usize>,
1909    close_ratios: Vec<f64>,
1910    #[serde(default)]
1911    target_source: Option<StrictTargetSourceMsg>,
1912    #[serde(default)]
1913    stoploss_mode: Option<StrictStoplossModeMsg>,
1914    #[serde(default)]
1915    rules: Vec<StrictRuleConfigDefMsg>,
1916    #[serde(default)]
1917    group_override: Option<String>,
1918    #[serde(default)]
1919    let_remainder_run: bool,
1920    #[serde(default)]
1921    entry_geometry: Option<EntryGeometryPolicyMsg>,
1922}
1923
1924#[derive(Serialize, Deserialize)]
1925#[serde(tag = "type", deny_unknown_fields)]
1926enum StrictPositionRefMsg {
1927    ByTradeId { trade_id: String },
1928    AllOnSymbol { symbol: String },
1929    AllInGroup { group_id: String },
1930}
1931
1932#[derive(Serialize, Deserialize)]
1933#[serde(tag = "action", deny_unknown_fields)]
1934enum StrictRawSignalMsg {
1935    Entry {
1936        ts: String,
1937        symbol: String,
1938        side: String,
1939        order_type: String,
1940        price: Option<f64>,
1941        risk: f64,
1942        stoploss: Option<f64>,
1943        #[serde(default)]
1944        targets: Vec<f64>,
1945        #[serde(default)]
1946        group: Option<String>,
1947        #[serde(default)]
1948        trade_id: Option<String>,
1949        #[serde(default)]
1950        entry_class: Option<String>,
1951    },
1952    Close {
1953        ts: String,
1954        position: StrictPositionRefMsg,
1955    },
1956    ClosePartial {
1957        ts: String,
1958        position: StrictPositionRefMsg,
1959        ratio: f64,
1960    },
1961    ModifyStoploss {
1962        ts: String,
1963        position: StrictPositionRefMsg,
1964        price: f64,
1965    },
1966    MoveStoplossToEntry {
1967        ts: String,
1968        position: StrictPositionRefMsg,
1969    },
1970    AddTarget {
1971        ts: String,
1972        position: StrictPositionRefMsg,
1973        price: f64,
1974        close_ratio: f64,
1975    },
1976    RemoveTarget {
1977        ts: String,
1978        position: StrictPositionRefMsg,
1979        price: f64,
1980    },
1981    ModifyTarget {
1982        ts: String,
1983        position: StrictPositionRefMsg,
1984        old_price: f64,
1985        new_price: f64,
1986    },
1987    AddRule {
1988        ts: String,
1989        position: StrictPositionRefMsg,
1990        rule: StrictRuleConfigDefMsg,
1991    },
1992    RemoveRule {
1993        ts: String,
1994        position: StrictPositionRefMsg,
1995        rule_name: String,
1996    },
1997    ScaleIn {
1998        ts: String,
1999        position: StrictPositionRefMsg,
2000        price: Option<f64>,
2001        size: f64,
2002    },
2003    CancelPending {
2004        ts: String,
2005        position: StrictPositionRefMsg,
2006    },
2007    CloseAllOf {
2008        ts: String,
2009        symbol: String,
2010    },
2011    CloseAll {
2012        ts: String,
2013    },
2014    CancelAllPending {
2015        ts: String,
2016    },
2017    ModifyAllStoploss {
2018        ts: String,
2019        symbol: String,
2020        price: f64,
2021    },
2022    CloseAllInGroup {
2023        ts: String,
2024        group_id: String,
2025    },
2026    ModifyAllStoplossInGroup {
2027        ts: String,
2028        group_id: String,
2029        price: f64,
2030    },
2031}
2032
2033#[derive(Serialize, Deserialize)]
2034#[serde(deny_unknown_fields)]
2035struct StrictBacktestRunSpec {
2036    #[serde(default)]
2037    on_unavailable: UnavailableInstrumentPolicyMsg,
2038    symbol: String,
2039    #[serde(default)]
2040    symbols: Vec<String>,
2041    #[serde(default)]
2042    all_symbols: bool,
2043    exchange: String,
2044    data_type: String,
2045    timeframe: Option<String>,
2046    from: Option<String>,
2047    to: Option<String>,
2048    #[serde(default)]
2049    raw_signals: Vec<StrictRawSignalMsg>,
2050    profile: Option<String>,
2051    #[serde(default)]
2052    profile_def: Option<StrictManagementProfileMsg>,
2053    #[serde(default)]
2054    entry_profile_routes: Vec<StrictEntryProfileRouteMsg>,
2055    config: StrictBacktestConfigMsg,
2056}
2057
2058#[derive(Serialize, Deserialize)]
2059#[serde(untagged)]
2060enum StrictProfileRef {
2061    Named(String),
2062    Inline(StrictManagementProfileMsg),
2063}
2064
2065#[derive(Serialize, Deserialize)]
2066#[serde(deny_unknown_fields)]
2067struct StrictEntryProfileRouteMsg {
2068    entry_class: String,
2069    profile: StrictProfileRef,
2070}
2071
2072#[derive(Serialize, Deserialize)]
2073#[serde(deny_unknown_fields)]
2074struct StrictBacktestMultiRunSpec {
2075    #[serde(default)]
2076    on_unavailable: UnavailableInstrumentPolicyMsg,
2077    symbol: String,
2078    #[serde(default)]
2079    symbols: Vec<String>,
2080    #[serde(default)]
2081    all_symbols: bool,
2082    exchange: String,
2083    data_type: String,
2084    timeframe: Option<String>,
2085    from: Option<String>,
2086    to: Option<String>,
2087    #[serde(default)]
2088    raw_signals: Vec<StrictRawSignalMsg>,
2089    profiles: Vec<StrictProfileRef>,
2090    #[serde(default)]
2091    entry_profile_routes: Vec<StrictEntryProfileRouteMsg>,
2092    config: StrictBacktestConfigMsg,
2093}
2094
2095#[derive(Deserialize)]
2096#[serde(deny_unknown_fields)]
2097struct StrictRunBacktestRequest {
2098    request: StrictBacktestRunSpec,
2099    #[serde(default)]
2100    future: FutureQuoteConfigMsg,
2101    #[serde(default)]
2102    evaluation: ProviderEvaluationOptionsMsg,
2103    #[serde(default)]
2104    result_delivery: ResultDeliveryMsg,
2105}
2106
2107#[derive(Deserialize)]
2108#[serde(deny_unknown_fields)]
2109struct StrictSubmitBacktestRequest {
2110    request: RunBacktestRequest,
2111}
2112
2113#[derive(Deserialize)]
2114#[serde(deny_unknown_fields)]
2115struct StrictRunBacktestMultiRequest {
2116    request: StrictBacktestMultiRunSpec,
2117    #[serde(default)]
2118    future: FutureQuoteConfigMsg,
2119    #[serde(default)]
2120    evaluation: ProviderEvaluationOptionsMsg,
2121    #[serde(default)]
2122    result_delivery: ResultDeliveryMsg,
2123}
2124
2125fn strict_into_wire<T>(value: impl Serialize) -> Result<T, serde_json::Error>
2126where
2127    T: DeserializeOwned,
2128{
2129    serde_json::from_value(serde_json::to_value(value)?)
2130}
2131
2132impl<'de> Deserialize<'de> for RunBacktestRequest {
2133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2134    where
2135        D: serde::Deserializer<'de>,
2136    {
2137        let strict = StrictRunBacktestRequest::deserialize(deserializer)?;
2138        Ok(Self {
2139            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
2140            future: strict.future,
2141            evaluation: strict.evaluation,
2142            result_delivery: strict.result_delivery,
2143        })
2144    }
2145}
2146
2147impl<'de> Deserialize<'de> for SubmitBacktestRequest {
2148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2149    where
2150        D: serde::Deserializer<'de>,
2151    {
2152        let strict = StrictSubmitBacktestRequest::deserialize(deserializer)?;
2153        Ok(Self {
2154            request: strict.request,
2155        })
2156    }
2157}
2158
2159impl<'de> Deserialize<'de> for RunBacktestMultiRequest {
2160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2161    where
2162        D: serde::Deserializer<'de>,
2163    {
2164        let strict = StrictRunBacktestMultiRequest::deserialize(deserializer)?;
2165        Ok(Self {
2166            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
2167            future: strict.future,
2168            evaluation: strict.evaluation,
2169            result_delivery: strict.result_delivery,
2170        })
2171    }
2172}
2173
2174#[derive(Serialize, Deserialize)]
2175#[serde(deny_unknown_fields)]
2176struct StrictConfiguredStrategyRunSpec {
2177    symbol: String,
2178    exchange: String,
2179    data_type: String,
2180    #[serde(default)]
2181    timeframe: Option<String>,
2182    #[serde(default)]
2183    from: Option<String>,
2184    #[serde(default)]
2185    to: Option<String>,
2186    strategy: ConfiguredStrategyRunMsg,
2187    #[serde(default)]
2188    profile: Option<String>,
2189    #[serde(default)]
2190    profile_def: Option<StrictManagementProfileMsg>,
2191    #[serde(default)]
2192    entry_profile_routes: Vec<StrictEntryProfileRouteMsg>,
2193    config: StrictBacktestConfigMsg,
2194}
2195
2196#[derive(Deserialize)]
2197#[serde(deny_unknown_fields)]
2198struct StrictRunConfiguredStrategyRequest {
2199    request: StrictConfiguredStrategyRunSpec,
2200    #[serde(default)]
2201    future: FutureQuoteConfigMsg,
2202    #[serde(default)]
2203    evaluation: ProviderEvaluationOptionsMsg,
2204    #[serde(default)]
2205    result_delivery: ResultDeliveryMsg,
2206}
2207
2208#[derive(Deserialize)]
2209#[serde(deny_unknown_fields)]
2210struct StrictSubmitConfiguredStrategyRequest {
2211    request: RunConfiguredStrategyRequest,
2212}
2213
2214#[derive(Serialize, Deserialize)]
2215#[serde(deny_unknown_fields)]
2216struct StrictSearchExecutionVariantMsg {
2217    id: String,
2218    config: StrictBacktestConfigMsg,
2219    #[serde(default)]
2220    future: FutureQuoteConfigMsg,
2221    #[serde(default)]
2222    profile: Option<String>,
2223}
2224
2225#[derive(Serialize, Deserialize)]
2226#[serde(deny_unknown_fields)]
2227struct StrictSearchPortfolioCandidateMsg {
2228    id: String,
2229    #[serde(default)]
2230    instances: Vec<StrictPortfolioInstanceMsg>,
2231    #[serde(default)]
2232    direct_instances: Vec<SearchDirectPortfolioInstanceMsg>,
2233    #[serde(default)]
2234    policies: Option<serde_json::Value>,
2235    #[serde(default)]
2236    groups: Option<serde_json::Value>,
2237}
2238
2239#[derive(Serialize, Deserialize)]
2240#[serde(deny_unknown_fields)]
2241struct StrictSearchRunSpec {
2242    template: serde_json::Value,
2243    space: serde_json::Value,
2244    symbols: Vec<String>,
2245    exchange: String,
2246    data_type: String,
2247    #[serde(default)]
2248    timeframe: Option<String>,
2249    windows: SearchWindowsMsg,
2250    config: StrictBacktestConfigMsg,
2251    #[serde(default)]
2252    profile: Option<String>,
2253    #[serde(default)]
2254    entry_profile_routes: Vec<StrictEntryProfileRouteMsg>,
2255    #[serde(default)]
2256    workers: Option<usize>,
2257    #[serde(default)]
2258    decision_latency_ms: u64,
2259    #[serde(default)]
2260    structural: Option<serde_json::Value>,
2261    #[serde(default)]
2262    resource_limits: Option<serde_json::Value>,
2263    #[serde(default)]
2264    variants: Vec<StrictSearchExecutionVariantMsg>,
2265    #[serde(default)]
2266    direct_factory: Option<SearchDirectFactoryMsg>,
2267    #[serde(default)]
2268    resume_checkpoint: Option<serde_json::Value>,
2269    #[serde(default)]
2270    selected_rerun: Option<SearchSelectedRerunMsg>,
2271    #[serde(default)]
2272    portfolio_candidates: Vec<StrictSearchPortfolioCandidateMsg>,
2273    #[serde(default)]
2274    series_descriptors: Vec<serde_json::Value>,
2275}
2276
2277#[derive(Deserialize)]
2278#[serde(deny_unknown_fields)]
2279struct StrictSubmitSearchRequest {
2280    request: StrictSearchRunSpec,
2281    #[serde(default)]
2282    future: FutureQuoteConfigMsg,
2283    #[serde(default)]
2284    evaluation: ProviderEvaluationOptionsMsg,
2285}
2286
2287#[derive(Serialize, Deserialize)]
2288#[serde(deny_unknown_fields)]
2289struct StrictPortfolioInstanceMsg {
2290    symbol: String,
2291    strategy: ConfiguredStrategyRunMsg,
2292    #[serde(default)]
2293    profile: Option<String>,
2294    #[serde(default)]
2295    profile_def: Option<StrictManagementProfileMsg>,
2296    #[serde(default)]
2297    entry_profile_routes: Vec<StrictEntryProfileRouteMsg>,
2298}
2299
2300#[derive(Serialize, Deserialize)]
2301#[serde(deny_unknown_fields)]
2302struct StrictPortfolioRunSpec {
2303    instances: Vec<StrictPortfolioInstanceMsg>,
2304    exchange: String,
2305    data_type: String,
2306    #[serde(default)]
2307    timeframe: Option<String>,
2308    #[serde(default)]
2309    from: Option<String>,
2310    #[serde(default)]
2311    to: Option<String>,
2312    config: StrictBacktestConfigMsg,
2313    #[serde(default)]
2314    policies: Option<serde_json::Value>,
2315    #[serde(default)]
2316    groups: Option<serde_json::Value>,
2317}
2318
2319#[derive(Deserialize)]
2320#[serde(deny_unknown_fields)]
2321struct StrictRunPortfolioRequest {
2322    request: StrictPortfolioRunSpec,
2323    #[serde(default)]
2324    future: FutureQuoteConfigMsg,
2325    #[serde(default)]
2326    evaluation: ProviderEvaluationOptionsMsg,
2327    #[serde(default)]
2328    result_delivery: ResultDeliveryMsg,
2329}
2330
2331#[derive(Deserialize)]
2332#[serde(deny_unknown_fields)]
2333struct StrictSubmitPortfolioRequest {
2334    request: RunPortfolioRequest,
2335}
2336
2337impl<'de> Deserialize<'de> for RunPortfolioRequest {
2338    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2339    where
2340        D: serde::Deserializer<'de>,
2341    {
2342        let strict = StrictRunPortfolioRequest::deserialize(deserializer)?;
2343        Ok(Self {
2344            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
2345            future: strict.future,
2346            evaluation: strict.evaluation,
2347            result_delivery: strict.result_delivery,
2348        })
2349    }
2350}
2351
2352impl<'de> Deserialize<'de> for SubmitPortfolioRequest {
2353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2354    where
2355        D: serde::Deserializer<'de>,
2356    {
2357        let strict = StrictSubmitPortfolioRequest::deserialize(deserializer)?;
2358        Ok(Self {
2359            request: strict.request,
2360        })
2361    }
2362}
2363
2364impl<'de> Deserialize<'de> for RunConfiguredStrategyRequest {
2365    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2366    where
2367        D: serde::Deserializer<'de>,
2368    {
2369        let strict = StrictRunConfiguredStrategyRequest::deserialize(deserializer)?;
2370        Ok(Self {
2371            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
2372            future: strict.future,
2373            evaluation: strict.evaluation,
2374            result_delivery: strict.result_delivery,
2375        })
2376    }
2377}
2378
2379impl<'de> Deserialize<'de> for SubmitConfiguredStrategyRequest {
2380    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2381    where
2382        D: serde::Deserializer<'de>,
2383    {
2384        let strict = StrictSubmitConfiguredStrategyRequest::deserialize(deserializer)?;
2385        Ok(Self {
2386            request: strict.request,
2387        })
2388    }
2389}
2390
2391impl<'de> Deserialize<'de> for SubmitSearchRequest {
2392    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2393    where
2394        D: serde::Deserializer<'de>,
2395    {
2396        let strict = StrictSubmitSearchRequest::deserialize(deserializer)?;
2397        Ok(Self {
2398            request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
2399            future: strict.future,
2400            evaluation: strict.evaluation,
2401        })
2402    }
2403}
2404
2405// ── Phase 2: Profile Management ─────────────────────────────────────────────
2406
2407/// Request to add (or overwrite) a management profile at runtime.
2408#[derive(Debug, Clone, Serialize, Deserialize)]
2409pub struct AddProfileRequest {
2410    pub profile: ManagementProfileMsg,
2411    #[serde(default)]
2412    pub overwrite: bool,
2413}
2414
2415/// Response from adding a profile.
2416#[derive(Debug, Clone, Serialize, Deserialize)]
2417pub struct AddProfileResponse {
2418    pub success: bool,
2419    pub error: Option<String>,
2420    pub profile_count: usize,
2421}
2422
2423/// Request to remove a management profile by name.
2424#[derive(Debug, Clone, Serialize, Deserialize)]
2425pub struct RemoveProfileRequest {
2426    pub name: String,
2427}
2428
2429/// Response from removing a profile.
2430#[derive(Debug, Clone, Serialize, Deserialize)]
2431pub struct RemoveProfileResponse {
2432    pub success: bool,
2433    pub error: Option<String>,
2434    pub profile_count: usize,
2435}
2436
2437/// Response from reloading profiles from disk.
2438#[derive(Debug, Clone, Serialize, Deserialize)]
2439pub struct ReloadProfilesResponse {
2440    pub success: bool,
2441    pub error: Option<String>,
2442    pub profile_count: usize,
2443    pub loaded_from: String,
2444}
2445
2446#[cfg(test)]
2447mod tests {
2448    use super::*;
2449    use serde_json::json;
2450
2451    fn entry_fields() -> serde_json::Value {
2452        json!({
2453            "action": "Entry",
2454            "ts": "2026-01-02T10:00:00",
2455            "symbol": "eurusd",
2456            "side": "Buy",
2457            "order_type": "Market",
2458            "price": null,
2459            "risk": 1.5,
2460            "stoploss": 1.08
2461        })
2462    }
2463
2464    #[test]
2465    fn entry_requires_risk_and_rejects_size() {
2466        let entry: RawSignalMsg = serde_json::from_value(entry_fields()).unwrap();
2467        assert!(matches!(entry, RawSignalMsg::Entry { risk, .. } if risk == 1.5));
2468
2469        let mut with_size = entry_fields();
2470        with_size.as_object_mut().unwrap().remove("risk");
2471        with_size["size"] = json!(0.1);
2472        let error = serde_json::from_value::<RawSignalMsg>(with_size).unwrap_err();
2473        assert!(error.to_string().contains("unknown field `size`"));
2474
2475        let mut missing_risk = entry_fields();
2476        missing_risk.as_object_mut().unwrap().remove("risk");
2477        let error = serde_json::from_value::<RawSignalMsg>(missing_risk).unwrap_err();
2478        assert!(error.to_string().contains("missing field `risk`"));
2479    }
2480
2481    #[test]
2482    fn scale_in_keeps_size_field() {
2483        let signal: RawSignalMsg = serde_json::from_value(json!({
2484            "action": "ScaleIn",
2485            "ts": "2026-01-02T10:01:00",
2486            "position": { "type": "ByTradeId", "trade_id": "trade-1" },
2487            "price": null,
2488            "size": 0.25
2489        }))
2490        .unwrap();
2491        assert!(matches!(signal, RawSignalMsg::ScaleIn { size, .. } if size == 0.25));
2492    }
2493
2494    #[test]
2495    fn current_sizing_and_future_fields_are_strict() {
2496        let sizing: SizingPolicyMsg = serde_json::from_value(json!({
2497            "type": "FixedRiskAmount",
2498            "amount": 100.0
2499        }))
2500        .unwrap();
2501        assert!(matches!(
2502            sizing,
2503            SizingPolicyMsg::FixedRiskAmount { amount } if amount == 100.0
2504        ));
2505        assert!(
2506            serde_json::from_value::<SizingPolicyMsg>(json!({
2507                "type": "FixedLot",
2508                "qty": "all=0.01"
2509            }))
2510            .is_err()
2511        );
2512
2513        let future: FutureQuoteConfigMsg = serde_json::from_value(json!({
2514            "account_currency": "USD",
2515            "conversion_stale_after_ms": 15_000
2516        }))
2517        .unwrap();
2518        assert_eq!(future.account_currency, "USD");
2519        assert_eq!(future.conversion_stale_after_ms, 15_000);
2520        assert_eq!(
2521            future.mtm_output,
2522            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
2523        );
2524        assert_eq!(
2525            future.market_entry_sizing_basis,
2526            MarketEntrySizingBasisMsg::FillPrice
2527        );
2528        assert!(
2529            serde_json::from_value::<FutureQuoteConfigMsg>(json!({
2530                "account_currency": "USD",
2531                "conversion_rates": {}
2532            }))
2533            .is_err()
2534        );
2535    }
2536
2537    #[test]
2538    fn market_entry_sizing_basis_uses_strict_snake_case_wire_values() {
2539        for (value, expected) in [
2540            (json!("fill_price"), MarketEntrySizingBasisMsg::FillPrice),
2541            (
2542                json!("signal_entry_price"),
2543                MarketEntrySizingBasisMsg::SignalEntryPrice,
2544            ),
2545        ] {
2546            let basis: MarketEntrySizingBasisMsg = serde_json::from_value(value.clone()).unwrap();
2547            assert_eq!(basis, expected);
2548            assert_eq!(serde_json::to_value(basis).unwrap(), value);
2549        }
2550
2551        assert!(serde_json::from_value::<MarketEntrySizingBasisMsg>(json!("mark_price")).is_err());
2552        assert!(
2553            serde_json::from_value::<FutureQuoteConfigMsg>(json!({
2554                "market_entry_sizing_basis": "mark_price"
2555            }))
2556            .is_err()
2557        );
2558    }
2559
2560    #[test]
2561    fn mtm_output_policy_uses_strict_snake_case_wire_values() {
2562        for (value, expected) in [
2563            (json!("none"), MtmOutputPolicyMsg::None),
2564            (
2565                json!({ "bounded": { "max_points": 512 } }),
2566                MtmOutputPolicyMsg::Bounded { max_points: 512 },
2567            ),
2568            (json!("full"), MtmOutputPolicyMsg::Full),
2569        ] {
2570            let policy: MtmOutputPolicyMsg = serde_json::from_value(value.clone()).unwrap();
2571            assert_eq!(policy, expected);
2572            assert_eq!(serde_json::to_value(policy).unwrap(), value);
2573        }
2574
2575        assert!(
2576            serde_json::from_value::<MtmOutputPolicyMsg>(json!({
2577                "bounded": { "max_points": 512, "unexpected": true }
2578            }))
2579            .is_err()
2580        );
2581    }
2582
2583    #[test]
2584    fn future_result_defaults_missing_mtm_output_summary() {
2585        let result: FutureBacktestResultMsg = serde_json::from_value(json!({
2586            "format_version": 1,
2587            "execution_metadata": null,
2588            "recorded_fills": null,
2589            "action_dispositions": null,
2590            "close_events": null,
2591            "completed_positions": null,
2592            "open_positions": null,
2593            "pending_orders": null,
2594            "pending_order_lifecycle": [],
2595            "mtm_equity_curve": null,
2596            "mtm_max_drawdown": null,
2597            "mtm_max_drawdown_pct": null,
2598            "provider_evaluation": null
2599        }))
2600        .unwrap();
2601
2602        assert_eq!(result.mtm_output_summary, MtmOutputSummaryMsg::default());
2603        assert_eq!(result.mtm_output_summary.policy, MtmOutputPolicyMsg::Full);
2604
2605        let summary: MtmOutputSummaryMsg = serde_json::from_value(json!({
2606            "policy": "full",
2607            "observed_points": 4,
2608            "retained_points": 4,
2609            "omitted_points": 0,
2610            "future_addition": true
2611        }))
2612        .unwrap();
2613        assert_eq!(summary.policy, MtmOutputPolicyMsg::Full);
2614    }
2615
2616    fn minimal_request_json() -> serde_json::Value {
2617        json!({
2618            "request": {
2619                "symbol": "EURUSD",
2620                "exchange": "fixture",
2621                "data_type": "tick",
2622                "timeframe": null,
2623                "from": null,
2624                "to": null,
2625                "raw_signals": [],
2626                "profile": null,
2627                "config": {
2628                    "initial_balance": null,
2629                    "close_on_finish": null,
2630                    "fill_model": null
2631                }
2632            },
2633            "future": {
2634                "account_currency": "USD"
2635            }
2636        })
2637    }
2638
2639    #[test]
2640    fn omitted_delivery_and_mtm_fields_use_current_defaults() {
2641        let request: RunBacktestRequest = serde_json::from_value(minimal_request_json()).unwrap();
2642        assert_eq!(request.result_delivery, ResultDeliveryMsg::Auto);
2643        assert_eq!(
2644            request.future.mtm_output,
2645            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
2646        );
2647        assert_eq!(
2648            request.future.market_entry_sizing_basis,
2649            MarketEntrySizingBasisMsg::FillPrice
2650        );
2651
2652        let submitted: SubmitBacktestRequest = serde_json::from_value(json!({
2653            "request": minimal_request_json()
2654        }))
2655        .unwrap();
2656        assert_eq!(submitted.request.result_delivery, ResultDeliveryMsg::Auto);
2657        assert_eq!(
2658            submitted.request.future.mtm_output,
2659            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
2660        );
2661
2662        let mut explicit = minimal_request_json();
2663        explicit["result_delivery"] = json!("auto");
2664        explicit["future"]["mtm_output"] = json!({
2665            "bounded": { "max_points": 4_096 }
2666        });
2667        let explicit: RunBacktestRequest = serde_json::from_value(explicit).unwrap();
2668        assert_eq!(explicit.result_delivery, ResultDeliveryMsg::Auto);
2669        assert_eq!(
2670            explicit.future.mtm_output,
2671            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
2672        );
2673
2674        let mut multi_request = minimal_request_json()["request"].clone();
2675        let fields = multi_request.as_object_mut().unwrap();
2676        fields.remove("profile");
2677        fields.insert("profiles".into(), json!([]));
2678        let multi: RunBacktestMultiRequest = serde_json::from_value(json!({
2679            "request": multi_request,
2680            "future": { "account_currency": "USD" }
2681        }))
2682        .unwrap();
2683        assert_eq!(multi.result_delivery, ResultDeliveryMsg::Auto);
2684        assert_eq!(
2685            multi.future.mtm_output,
2686            MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
2687        );
2688    }
2689
2690    #[test]
2691    fn older_async_result_responses_default_artifact_consumption_state() {
2692        let response: GetBacktestResultResponse = serde_json::from_value(json!({
2693            "success": true,
2694            "job_id": "job-old",
2695            "result": null,
2696            "error": null,
2697            "inline_complete": true
2698        }))
2699        .unwrap();
2700        assert!(!response.artifact_consumed);
2701    }
2702}