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