1use std::collections::{BTreeMap, HashMap};
7
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9
10fn default_true() -> bool {
11 true
12}
13
14pub const RESULT_FORMAT_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PingResponse {
22 pub status: String,
23 pub uptime_secs: u64,
24 pub data_dir: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProfileInfo {
32 pub name: String,
33 pub use_targets: Vec<usize>,
34 pub close_ratios: Vec<f64>,
35 pub stoploss_mode: String,
36 pub rules_count: usize,
37 pub let_remainder_run: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ListProfilesResponse {
43 pub profiles: Vec<ProfileInfo>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ListSymbolsRequest {
51 pub exchange: Option<String>,
52 pub data_type: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SymbolAvailability {
58 pub exchange: String,
59 pub symbol: String,
60 pub data_type: String,
61 pub timeframe: Option<String>,
62 pub row_count: u64,
63 pub earliest: String,
64 pub latest: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ListSymbolsResponse {
70 pub symbols: Vec<SymbolAvailability>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct BacktestConfigMsg {
78 pub initial_balance: Option<f64>,
80 pub close_on_finish: Option<bool>,
82 pub fill_model: Option<String>,
84 #[serde(default)]
86 pub sizing: Option<SizingPolicyMsg>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(tag = "type", deny_unknown_fields)]
92pub enum SizingPolicyMsg {
93 FixedLot { lots: f64 },
95 FixedRiskAmount { amount: f64 },
97 BalanceRiskPercent { percent: f64 },
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct SubmitBacktestResponse {
106 pub success: bool,
107 pub job_id: Option<String>,
108 pub error: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct GetBacktestStatusRequest {
114 pub job_id: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct WatchBacktestRequest {
120 pub job_id: String,
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(default)]
126pub struct BacktestProgress {
127 pub stage: String,
128 pub processed_events: u64,
129 pub total_events: u64,
130 pub processed_signals: u64,
131 pub total_signals: u64,
132 pub processed_symbols: u64,
133 pub total_symbols: u64,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct BacktestStatusResponse {
139 pub success: bool,
140 pub job_id: String,
141 pub status: String,
142 pub error: Option<String>,
143 pub elapsed_ms: Option<u64>,
144 #[serde(default)]
146 pub progress: BacktestProgress,
147}
148
149impl BacktestStatusResponse {
150 pub fn is_terminal(&self) -> bool {
152 matches!(self.status.as_str(), "Completed" | "Failed" | "Cancelled")
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(tag = "type", rename_all = "snake_case")]
159pub enum BacktestEvent {
160 Snapshot { status: BacktestStatusResponse },
161 Heartbeat { job_id: String, elapsed_ms: u64 },
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct GetBacktestResultRequest {
167 pub job_id: String,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct GetBacktestResultResponse {
173 pub success: bool,
174 pub job_id: String,
175 pub result: Option<BacktestResultMsg>,
176 pub error: Option<String>,
177 #[serde(default)]
178 pub artifact: Option<ResultArtifactRefMsg>,
179 #[serde(default = "default_true")]
180 pub inline_complete: bool,
181 #[serde(default)]
183 pub artifact_consumed: bool,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct CancelBacktestRequest {
189 pub job_id: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CancelBacktestResponse {
195 pub success: bool,
196 pub job_id: String,
197 pub error: Option<String>,
198}
199
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ResultDeliveryMsg {
206 #[default]
207 Auto,
208 Inline,
209 Artifact,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct ResultArtifactRefMsg {
215 pub format_version: u32,
216 pub artifact_id: String,
217 pub byte_len: u64,
218 pub sha256: String,
219 pub chunk_size: u64,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct GetResultArtifactChunkRequest {
226 pub artifact_id: String,
227 pub offset: u64,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct GetResultArtifactChunkResponse {
233 pub success: bool,
234 pub artifact_id: String,
235 pub offset: u64,
236 pub data_base64: String,
237 pub eof: bool,
238 pub error: Option<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(deny_unknown_fields)]
244pub struct DeleteResultArtifactRequest {
245 pub artifact_id: String,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DeleteResultArtifactResponse {
251 pub success: bool,
252 pub artifact_id: String,
253 pub error: Option<String>,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case", deny_unknown_fields)]
259pub enum MtmOutputPolicyMsg {
260 None,
261 Bounded { max_points: usize },
262 Full,
263}
264
265impl Default for MtmOutputPolicyMsg {
266 fn default() -> Self {
267 Self::Bounded { max_points: 4_096 }
268 }
269}
270
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case", deny_unknown_fields)]
274pub enum MarketEntrySizingBasisMsg {
275 #[default]
276 FillPrice,
277 SignalEntryPrice,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(default, deny_unknown_fields)]
283pub struct FutureQuoteConfigMsg {
284 pub signal_latency_ms: i64,
285 pub slippage_pips: f64,
286 pub stale_quote_after_ms: Option<i64>,
287 pub pnl_epsilon: f64,
288 pub account_currency: String,
289 pub conversion_stale_after_ms: i64,
290 pub mtm_output: MtmOutputPolicyMsg,
291 pub market_entry_sizing_basis: MarketEntrySizingBasisMsg,
292}
293
294impl Default for FutureQuoteConfigMsg {
295 fn default() -> Self {
296 Self {
297 signal_latency_ms: 0,
298 slippage_pips: 0.0,
299 stale_quote_after_ms: None,
300 pnl_epsilon: 1.0e-9,
301 account_currency: String::new(),
302 conversion_stale_after_ms: 300_000,
303 mtm_output: MtmOutputPolicyMsg::default(),
304 market_entry_sizing_basis: MarketEntrySizingBasisMsg::default(),
305 }
306 }
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(default, deny_unknown_fields)]
312pub struct EvaluationContextMsg {
313 pub provider_id: Option<String>,
314 pub source_id: Option<String>,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum EvaluationSectionMsg {
321 Coverage,
322 PositionPerformance,
323 RMetrics,
324 Excursions,
325 Execution,
326 Robustness,
327 Breakdowns,
328}
329
330impl EvaluationSectionMsg {
331 pub const ALL: [Self; 7] = [
332 Self::Coverage,
333 Self::PositionPerformance,
334 Self::RMetrics,
335 Self::Excursions,
336 Self::Execution,
337 Self::Robustness,
338 Self::Breakdowns,
339 ];
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(rename_all = "snake_case")]
344pub enum EvaluationPositionSideMsg {
345 Long,
346 Short,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum EvaluationGroupFilterMsg {
352 Named(String),
353 Ungrouped,
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(default, deny_unknown_fields)]
360pub struct PositionFilterMsg {
361 pub symbols: Vec<String>,
362 pub sides: Vec<EvaluationPositionSideMsg>,
363 pub groups: Vec<EvaluationGroupFilterMsg>,
364 pub close_reasons: Vec<String>,
365 pub tags: BTreeMap<String, Vec<String>>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum BreakdownDimensionMsg {
372 Symbol,
373 Side,
374 Group,
375 CloseReason,
376 Tag(String),
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
380#[serde(default, deny_unknown_fields)]
381pub struct BootstrapConfigMsg {
382 pub samples: usize,
383 pub confidence_level: f64,
384 pub seed: u64,
385 pub minimum_sample_size: usize,
386}
387
388impl Default for BootstrapConfigMsg {
389 fn default() -> Self {
390 Self {
391 samples: 2_000,
392 confidence_level: 0.95,
393 seed: 0xA076_1D64_78BD_642F,
394 minimum_sample_size: 5,
395 }
396 }
397}
398
399#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(default, deny_unknown_fields)]
402pub struct SourceCoverageCountsMsg {
403 pub raw_messages: u64,
404 pub parsed_messages: u64,
405 pub skipped_messages: u64,
406 pub failed_messages: u64,
407 pub emitted_signals: u64,
408 pub emitted_entry_signals: u64,
409}
410
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413#[serde(default, deny_unknown_fields)]
414pub struct ProviderEvaluationOptionsMsg {
415 pub context: EvaluationContextMsg,
416 pub source_coverage: Option<SourceCoverageCountsMsg>,
417 pub sections: Vec<EvaluationSectionMsg>,
419 pub filter: PositionFilterMsg,
420 pub breakdowns: Vec<BreakdownDimensionMsg>,
421 pub bootstrap: BootstrapConfigMsg,
422 pub rolling_window: usize,
423 pub minimum_breakdown_bucket_count: usize,
424 pub maximum_breakdown_rows: Option<usize>,
425 pub include_positions: bool,
426 pub maximum_position_rows: Option<usize>,
427}
428
429impl Default for ProviderEvaluationOptionsMsg {
430 fn default() -> Self {
431 Self {
432 context: EvaluationContextMsg::default(),
433 source_coverage: None,
434 sections: EvaluationSectionMsg::ALL.to_vec(),
435 filter: PositionFilterMsg::default(),
436 breakdowns: Vec::new(),
437 bootstrap: BootstrapConfigMsg::default(),
438 rolling_window: 20,
439 minimum_breakdown_bucket_count: 1,
440 maximum_breakdown_rows: None,
441 include_positions: false,
442 maximum_position_rows: None,
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct BacktestRunSpec {
452 pub symbol: String,
453 #[serde(default)]
454 pub symbols: Vec<String>,
455 #[serde(default)]
456 pub all_symbols: bool,
457 pub exchange: String,
458 pub data_type: String,
459 pub timeframe: Option<String>,
460 pub from: Option<String>,
461 pub to: Option<String>,
462 #[serde(default)]
464 pub raw_signals: Vec<RawSignalMsg>,
465 pub profile: Option<String>,
466 #[serde(default)]
467 pub profile_def: Option<ManagementProfileMsg>,
468 pub config: BacktestConfigMsg,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct RunBacktestResponse {
474 pub success: bool,
475 pub error: Option<String>,
476 pub result: Option<BacktestResultMsg>,
478 pub elapsed_ms: u64,
479 #[serde(default)]
480 pub artifact: Option<ResultArtifactRefMsg>,
481 #[serde(default = "default_true")]
482 pub inline_complete: bool,
483}
484
485#[derive(Debug, Clone, Serialize)]
487pub struct RunBacktestRequest {
488 pub request: BacktestRunSpec,
489 #[serde(default)]
490 pub future: FutureQuoteConfigMsg,
491 #[serde(default)]
492 pub evaluation: ProviderEvaluationOptionsMsg,
493 #[serde(default)]
494 pub result_delivery: ResultDeliveryMsg,
495}
496
497#[derive(Debug, Clone, Serialize)]
499pub struct SubmitBacktestRequest {
500 pub request: RunBacktestRequest,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct BacktestMultiRunSpec {
508 pub symbol: String,
509 #[serde(default)]
510 pub symbols: Vec<String>,
511 #[serde(default)]
512 pub all_symbols: bool,
513 pub exchange: String,
514 pub data_type: String,
515 pub timeframe: Option<String>,
516 pub from: Option<String>,
517 pub to: Option<String>,
518 #[serde(default)]
520 pub raw_signals: Vec<RawSignalMsg>,
521 pub profiles: Vec<ProfileRef>,
522 pub config: BacktestConfigMsg,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct ProfileResult {
528 pub profile: String,
529 pub success: bool,
530 pub error: Option<String>,
531 pub result: Option<BacktestResultMsg>,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct RunBacktestMultiResponse {
537 #[serde(default = "default_true")]
539 pub success: bool,
540 #[serde(default)]
541 pub error: Option<String>,
542 pub results: Vec<ProfileResult>,
544 pub elapsed_ms: u64,
545 #[serde(default)]
546 pub artifact: Option<ResultArtifactRefMsg>,
547 #[serde(default = "default_true")]
548 pub inline_complete: bool,
549}
550
551#[derive(Debug, Clone, Serialize)]
552pub struct RunBacktestMultiRequest {
553 pub request: BacktestMultiRunSpec,
554 #[serde(default)]
555 pub future: FutureQuoteConfigMsg,
556 #[serde(default)]
557 pub evaluation: ProviderEvaluationOptionsMsg,
558 #[serde(default)]
559 pub result_delivery: ResultDeliveryMsg,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct BacktestResultMsg {
567 pub initial_balance: f64,
568 pub final_balance: f64,
569 pub total_pnl: f64,
570 pub total_trades: usize,
571 pub winning_trades: usize,
572 pub losing_trades: usize,
573 pub win_rate: f64,
574 pub profit_factor: f64,
575 pub max_drawdown: f64,
576 pub max_drawdown_pct: f64,
577
578 pub summary: SubsetStatsMsg,
580 pub per_symbol: HashMap<String, SubsetStatsMsg>,
581 pub per_group: HashMap<String, SubsetStatsMsg>,
582 pub long_stats: SubsetStatsMsg,
583 pub short_stats: SubsetStatsMsg,
584 pub per_close_reason: Vec<CloseReasonStatsMsg>,
585 pub streaks: StreakStatsMsg,
586 pub risk_metrics: RiskMetricsMsg,
587 pub duration_stats: Option<DurationStatsMsg>,
588 pub monthly_returns: Vec<MonthlyReturnMsg>,
589
590 pub equity_curve: Vec<EquityPoint>,
591 pub trade_log: Vec<TradeResultMsg>,
592
593 pub positions: Vec<PositionSummaryMsg>,
595 pub total_positions: usize,
596 pub winning_positions: usize,
597 pub losing_positions: usize,
598 pub position_win_rate: f64,
599
600 #[serde(default)]
602 pub future: Option<FutureBacktestResultMsg>,
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(default)]
608pub struct MtmOutputSummaryMsg {
609 pub policy: MtmOutputPolicyMsg,
610 pub observed_points: u64,
611 pub retained_points: u64,
612 pub omitted_points: u64,
613}
614
615impl Default for MtmOutputSummaryMsg {
616 fn default() -> Self {
617 Self {
618 policy: MtmOutputPolicyMsg::Full,
619 observed_points: 0,
620 retained_points: 0,
621 omitted_points: 0,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct FutureBacktestResultMsg {
631 pub format_version: u32,
632 pub execution_metadata: serde_json::Value,
633 pub recorded_fills: serde_json::Value,
634 pub action_dispositions: serde_json::Value,
635 pub close_events: serde_json::Value,
636 pub completed_positions: serde_json::Value,
637 pub open_positions: serde_json::Value,
638 pub pending_orders: serde_json::Value,
639 #[serde(default)]
641 pub pending_order_lifecycle: Vec<PendingOrderLifecycleEventMsg>,
642 pub mtm_equity_curve: serde_json::Value,
643 #[serde(default)]
644 pub mtm_output_summary: MtmOutputSummaryMsg,
645 pub mtm_max_drawdown: Option<f64>,
646 pub mtm_max_drawdown_pct: Option<f64>,
647 pub provider_evaluation: serde_json::Value,
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
652#[serde(rename_all = "snake_case")]
653pub enum PendingOrderLifecycleStateMsg {
654 #[default]
655 Placed,
656 Filled,
657 Cancelled,
658 UnfilledAtEnd,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
663#[serde(default)]
664pub struct PendingOrderLifecycleEventMsg {
665 pub id: String,
666 pub sequence: u64,
667 pub position_id: String,
668 pub placement_action_id: Option<String>,
669 pub terminal_action_id: Option<String>,
670 pub state: PendingOrderLifecycleStateMsg,
671 pub symbol: String,
672 pub side: String,
673 pub order_type: String,
674 pub requested_size: f64,
675 pub filled_size: Option<f64>,
676 pub requested_price: Option<f64>,
677 pub fill_price: Option<f64>,
678 pub signal_ts: Option<String>,
679 pub placed_ts: Option<String>,
680 pub effective_ts: Option<String>,
681 pub terminal_ts: Option<String>,
682 pub wait_latency_ms: Option<i64>,
683 pub fill_ratio: Option<f64>,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
690pub struct SubsetStatsMsg {
691 pub total_trades: usize,
692 pub winning_trades: usize,
693 pub losing_trades: usize,
694 pub breakeven_trades: usize,
695 pub total_pnl: f64,
696 pub gross_profit: f64,
697 pub gross_loss: f64,
698 pub win_rate: f64,
699 pub profit_factor: f64,
700 pub avg_win: f64,
701 pub avg_loss: f64,
702 pub win_loss_ratio: f64,
703 pub expectancy: f64,
704 pub largest_win: f64,
705 pub largest_loss: f64,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct StreakStatsMsg {
711 pub max_consecutive_wins: u32,
712 pub max_consecutive_losses: u32,
713 pub current_streak: i32,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
718pub struct RiskMetricsMsg {
719 pub sharpe_ratio: Option<f64>,
720 pub sortino_ratio: Option<f64>,
721 pub calmar_ratio: Option<f64>,
722 pub return_on_max_drawdown: Option<f64>,
723 pub max_drawdown: f64,
724 pub max_drawdown_pct: f64,
725 pub max_drawdown_duration_secs: Option<i64>,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct DurationStatsMsg {
731 pub avg_duration_secs: i64,
732 pub min_duration_secs: i64,
733 pub max_duration_secs: i64,
734 pub avg_winner_duration_secs: i64,
735 pub avg_loser_duration_secs: i64,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
740pub struct MonthlyReturnMsg {
741 pub year: i32,
742 pub month: u32,
743 pub pnl: f64,
744 pub trade_count: usize,
745 pub ending_balance: f64,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct CloseReasonStatsMsg {
751 pub reason: String,
752 pub count: usize,
753 pub total_pnl: f64,
754 pub avg_pnl: f64,
755 pub percentage: f64,
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct PositionSummaryMsg {
761 pub position_id: String,
762 pub symbol: String,
763 pub side: String,
764 pub group: Option<String>,
765 pub entry_price: f64,
766 pub avg_exit_price: f64,
767 pub original_size: f64,
768 pub close_count: usize,
769 pub net_pnl: f64,
770 pub close_reasons: Vec<String>,
771 pub open_ts: String,
772 pub final_close_ts: Option<String>,
773 pub duration_seconds: i64,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct EquityPoint {
779 pub ts: String,
780 pub balance: f64,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
785pub struct TradeResultMsg {
786 pub position_id: String,
787 pub symbol: String,
788 pub side: String,
789 pub entry_price: f64,
790 pub exit_price: f64,
791 pub size: f64,
792 pub pnl: f64,
793 pub open_ts: String,
794 pub close_ts: String,
795 pub close_reason: String,
796 pub group: Option<String>,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
803pub enum TargetSelectionMsg {
804 All,
806 None,
808 Selected(Vec<usize>),
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct ManagementProfileMsg {
815 pub name: String,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub target_selection: Option<TargetSelectionMsg>,
820 pub use_targets: Vec<usize>,
822 pub close_ratios: Vec<f64>,
823 #[serde(default)]
824 pub stoploss_mode: Option<StoplossModeMsg>,
825 #[serde(default)]
826 pub rules: Vec<RuleConfigDefMsg>,
827 #[serde(default)]
828 pub group_override: Option<String>,
829 #[serde(default)]
830 pub let_remainder_run: bool,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize)]
835#[serde(tag = "type")]
836pub enum StoplossModeMsg {
837 FromSignal,
838 None,
839 FixedDistance { distance: f64 },
840 FixedPrice { price: f64 },
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize)]
845#[serde(tag = "type")]
846pub enum RuleConfigDefMsg {
847 FixedStoploss { price: f64 },
848 TrailingStop { distance: f64 },
849 TakeProfit { price: f64, close_ratio: f64 },
850 BreakevenWhen { trigger_price: f64 },
851 BreakevenWhenOffset { trigger_price_offset: f64 },
852 BreakevenAfterTargets { after_n: u32 },
853 TimeExit { max_seconds: u64 },
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize)]
858#[serde(untagged)]
859pub enum ProfileRef {
860 Named(String),
861 Inline(ManagementProfileMsg),
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize)]
871#[serde(tag = "action", deny_unknown_fields)]
872pub enum RawSignalMsg {
873 Entry {
875 ts: String,
876 symbol: String,
877 side: String,
878 order_type: String,
879 price: Option<f64>,
880 risk: f64,
881 stoploss: Option<f64>,
882 #[serde(default)]
883 targets: Vec<f64>,
884 #[serde(default)]
885 group: Option<String>,
886 #[serde(default)]
888 trade_id: Option<String>,
889 },
890 Close {
892 ts: String,
893 position: PositionRefMsg,
894 },
895 ClosePartial {
897 ts: String,
898 position: PositionRefMsg,
899 ratio: f64,
900 },
901 ModifyStoploss {
903 ts: String,
904 position: PositionRefMsg,
905 price: f64,
906 },
907 MoveStoplossToEntry {
909 ts: String,
910 position: PositionRefMsg,
911 },
912 AddTarget {
914 ts: String,
915 position: PositionRefMsg,
916 price: f64,
917 close_ratio: f64,
918 },
919 RemoveTarget {
921 ts: String,
922 position: PositionRefMsg,
923 price: f64,
924 },
925 ModifyTarget {
927 ts: String,
928 position: PositionRefMsg,
929 old_price: f64,
930 new_price: f64,
931 },
932 AddRule {
934 ts: String,
935 position: PositionRefMsg,
936 rule: RuleConfigDefMsg,
937 },
938 RemoveRule {
940 ts: String,
941 position: PositionRefMsg,
942 rule_name: String,
943 },
944 ScaleIn {
946 ts: String,
947 position: PositionRefMsg,
948 price: Option<f64>,
949 size: f64,
950 },
951 CancelPending {
953 ts: String,
954 position: PositionRefMsg,
955 },
956 CloseAllOf { ts: String, symbol: String },
958 CloseAll { ts: String },
960 CancelAllPending { ts: String },
962 ModifyAllStoploss {
964 ts: String,
965 symbol: String,
966 price: f64,
967 },
968 CloseAllInGroup { ts: String, group_id: String },
970 ModifyAllStoplossInGroup {
972 ts: String,
973 group_id: String,
974 price: f64,
975 },
976}
977
978impl RawSignalMsg {
979 pub fn ts(&self) -> &str {
981 match self {
982 RawSignalMsg::Entry { ts, .. }
983 | RawSignalMsg::Close { ts, .. }
984 | RawSignalMsg::ClosePartial { ts, .. }
985 | RawSignalMsg::ModifyStoploss { ts, .. }
986 | RawSignalMsg::MoveStoplossToEntry { ts, .. }
987 | RawSignalMsg::AddTarget { ts, .. }
988 | RawSignalMsg::RemoveTarget { ts, .. }
989 | RawSignalMsg::ModifyTarget { ts, .. }
990 | RawSignalMsg::AddRule { ts, .. }
991 | RawSignalMsg::RemoveRule { ts, .. }
992 | RawSignalMsg::ScaleIn { ts, .. }
993 | RawSignalMsg::CancelPending { ts, .. }
994 | RawSignalMsg::CloseAllOf { ts, .. }
995 | RawSignalMsg::CloseAll { ts }
996 | RawSignalMsg::CancelAllPending { ts }
997 | RawSignalMsg::ModifyAllStoploss { ts, .. }
998 | RawSignalMsg::CloseAllInGroup { ts, .. }
999 | RawSignalMsg::ModifyAllStoplossInGroup { ts, .. } => ts,
1000 }
1001 }
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1006#[serde(tag = "type")]
1007pub enum PositionRefMsg {
1008 ByTradeId { trade_id: String },
1010 AllOnSymbol { symbol: String },
1012 AllInGroup { group_id: String },
1014}
1015
1016#[derive(Serialize, Deserialize)]
1019#[serde(tag = "type", deny_unknown_fields)]
1020enum StrictSizingPolicyMsg {
1021 FixedLot { lots: f64 },
1022 FixedRiskAmount { amount: f64 },
1023 BalanceRiskPercent { percent: f64 },
1024}
1025
1026#[derive(Serialize, Deserialize)]
1027#[serde(deny_unknown_fields)]
1028struct StrictBacktestConfigMsg {
1029 initial_balance: Option<f64>,
1030 close_on_finish: Option<bool>,
1031 fill_model: Option<String>,
1032 #[serde(default)]
1033 sizing: Option<StrictSizingPolicyMsg>,
1034}
1035
1036#[derive(Serialize, Deserialize)]
1037#[serde(tag = "type", deny_unknown_fields)]
1038enum StrictStoplossModeMsg {
1039 FromSignal,
1040 None,
1041 FixedDistance { distance: f64 },
1042 FixedPrice { price: f64 },
1043}
1044
1045#[derive(Serialize, Deserialize)]
1046#[serde(tag = "type", deny_unknown_fields)]
1047enum StrictRuleConfigDefMsg {
1048 FixedStoploss { price: f64 },
1049 TrailingStop { distance: f64 },
1050 TakeProfit { price: f64, close_ratio: f64 },
1051 BreakevenWhen { trigger_price: f64 },
1052 BreakevenWhenOffset { trigger_price_offset: f64 },
1053 BreakevenAfterTargets { after_n: u32 },
1054 TimeExit { max_seconds: u64 },
1055}
1056
1057#[derive(Serialize, Deserialize)]
1058#[serde(deny_unknown_fields)]
1059struct StrictManagementProfileMsg {
1060 name: String,
1061 #[serde(default)]
1062 target_selection: Option<TargetSelectionMsg>,
1063 use_targets: Vec<usize>,
1064 close_ratios: Vec<f64>,
1065 #[serde(default)]
1066 stoploss_mode: Option<StrictStoplossModeMsg>,
1067 #[serde(default)]
1068 rules: Vec<StrictRuleConfigDefMsg>,
1069 #[serde(default)]
1070 group_override: Option<String>,
1071 #[serde(default)]
1072 let_remainder_run: bool,
1073}
1074
1075#[derive(Serialize, Deserialize)]
1076#[serde(tag = "type", deny_unknown_fields)]
1077enum StrictPositionRefMsg {
1078 ByTradeId { trade_id: String },
1079 AllOnSymbol { symbol: String },
1080 AllInGroup { group_id: String },
1081}
1082
1083#[derive(Serialize, Deserialize)]
1084#[serde(tag = "action", deny_unknown_fields)]
1085enum StrictRawSignalMsg {
1086 Entry {
1087 ts: String,
1088 symbol: String,
1089 side: String,
1090 order_type: String,
1091 price: Option<f64>,
1092 risk: f64,
1093 stoploss: Option<f64>,
1094 #[serde(default)]
1095 targets: Vec<f64>,
1096 #[serde(default)]
1097 group: Option<String>,
1098 #[serde(default)]
1099 trade_id: Option<String>,
1100 },
1101 Close {
1102 ts: String,
1103 position: StrictPositionRefMsg,
1104 },
1105 ClosePartial {
1106 ts: String,
1107 position: StrictPositionRefMsg,
1108 ratio: f64,
1109 },
1110 ModifyStoploss {
1111 ts: String,
1112 position: StrictPositionRefMsg,
1113 price: f64,
1114 },
1115 MoveStoplossToEntry {
1116 ts: String,
1117 position: StrictPositionRefMsg,
1118 },
1119 AddTarget {
1120 ts: String,
1121 position: StrictPositionRefMsg,
1122 price: f64,
1123 close_ratio: f64,
1124 },
1125 RemoveTarget {
1126 ts: String,
1127 position: StrictPositionRefMsg,
1128 price: f64,
1129 },
1130 ModifyTarget {
1131 ts: String,
1132 position: StrictPositionRefMsg,
1133 old_price: f64,
1134 new_price: f64,
1135 },
1136 AddRule {
1137 ts: String,
1138 position: StrictPositionRefMsg,
1139 rule: StrictRuleConfigDefMsg,
1140 },
1141 RemoveRule {
1142 ts: String,
1143 position: StrictPositionRefMsg,
1144 rule_name: String,
1145 },
1146 ScaleIn {
1147 ts: String,
1148 position: StrictPositionRefMsg,
1149 price: Option<f64>,
1150 size: f64,
1151 },
1152 CancelPending {
1153 ts: String,
1154 position: StrictPositionRefMsg,
1155 },
1156 CloseAllOf {
1157 ts: String,
1158 symbol: String,
1159 },
1160 CloseAll {
1161 ts: String,
1162 },
1163 CancelAllPending {
1164 ts: String,
1165 },
1166 ModifyAllStoploss {
1167 ts: String,
1168 symbol: String,
1169 price: f64,
1170 },
1171 CloseAllInGroup {
1172 ts: String,
1173 group_id: String,
1174 },
1175 ModifyAllStoplossInGroup {
1176 ts: String,
1177 group_id: String,
1178 price: f64,
1179 },
1180}
1181
1182#[derive(Serialize, Deserialize)]
1183#[serde(deny_unknown_fields)]
1184struct StrictBacktestRunSpec {
1185 symbol: String,
1186 #[serde(default)]
1187 symbols: Vec<String>,
1188 #[serde(default)]
1189 all_symbols: bool,
1190 exchange: String,
1191 data_type: String,
1192 timeframe: Option<String>,
1193 from: Option<String>,
1194 to: Option<String>,
1195 #[serde(default)]
1196 raw_signals: Vec<StrictRawSignalMsg>,
1197 profile: Option<String>,
1198 #[serde(default)]
1199 profile_def: Option<StrictManagementProfileMsg>,
1200 config: StrictBacktestConfigMsg,
1201}
1202
1203#[derive(Serialize, Deserialize)]
1204#[serde(untagged)]
1205enum StrictProfileRef {
1206 Named(String),
1207 Inline(StrictManagementProfileMsg),
1208}
1209
1210#[derive(Serialize, Deserialize)]
1211#[serde(deny_unknown_fields)]
1212struct StrictBacktestMultiRunSpec {
1213 symbol: String,
1214 #[serde(default)]
1215 symbols: Vec<String>,
1216 #[serde(default)]
1217 all_symbols: bool,
1218 exchange: String,
1219 data_type: String,
1220 timeframe: Option<String>,
1221 from: Option<String>,
1222 to: Option<String>,
1223 #[serde(default)]
1224 raw_signals: Vec<StrictRawSignalMsg>,
1225 profiles: Vec<StrictProfileRef>,
1226 config: StrictBacktestConfigMsg,
1227}
1228
1229#[derive(Deserialize)]
1230#[serde(deny_unknown_fields)]
1231struct StrictRunBacktestRequest {
1232 request: StrictBacktestRunSpec,
1233 #[serde(default)]
1234 future: FutureQuoteConfigMsg,
1235 #[serde(default)]
1236 evaluation: ProviderEvaluationOptionsMsg,
1237 #[serde(default)]
1238 result_delivery: ResultDeliveryMsg,
1239}
1240
1241#[derive(Deserialize)]
1242#[serde(deny_unknown_fields)]
1243struct StrictSubmitBacktestRequest {
1244 request: RunBacktestRequest,
1245}
1246
1247#[derive(Deserialize)]
1248#[serde(deny_unknown_fields)]
1249struct StrictRunBacktestMultiRequest {
1250 request: StrictBacktestMultiRunSpec,
1251 #[serde(default)]
1252 future: FutureQuoteConfigMsg,
1253 #[serde(default)]
1254 evaluation: ProviderEvaluationOptionsMsg,
1255 #[serde(default)]
1256 result_delivery: ResultDeliveryMsg,
1257}
1258
1259fn strict_into_wire<T>(value: impl Serialize) -> Result<T, serde_json::Error>
1260where
1261 T: DeserializeOwned,
1262{
1263 serde_json::from_value(serde_json::to_value(value)?)
1264}
1265
1266impl<'de> Deserialize<'de> for RunBacktestRequest {
1267 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1268 where
1269 D: serde::Deserializer<'de>,
1270 {
1271 let strict = StrictRunBacktestRequest::deserialize(deserializer)?;
1272 Ok(Self {
1273 request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
1274 future: strict.future,
1275 evaluation: strict.evaluation,
1276 result_delivery: strict.result_delivery,
1277 })
1278 }
1279}
1280
1281impl<'de> Deserialize<'de> for SubmitBacktestRequest {
1282 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1283 where
1284 D: serde::Deserializer<'de>,
1285 {
1286 let strict = StrictSubmitBacktestRequest::deserialize(deserializer)?;
1287 Ok(Self {
1288 request: strict.request,
1289 })
1290 }
1291}
1292
1293impl<'de> Deserialize<'de> for RunBacktestMultiRequest {
1294 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1295 where
1296 D: serde::Deserializer<'de>,
1297 {
1298 let strict = StrictRunBacktestMultiRequest::deserialize(deserializer)?;
1299 Ok(Self {
1300 request: strict_into_wire(strict.request).map_err(serde::de::Error::custom)?,
1301 future: strict.future,
1302 evaluation: strict.evaluation,
1303 result_delivery: strict.result_delivery,
1304 })
1305 }
1306}
1307
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1312pub struct AddProfileRequest {
1313 pub profile: ManagementProfileMsg,
1314 #[serde(default)]
1315 pub overwrite: bool,
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize)]
1320pub struct AddProfileResponse {
1321 pub success: bool,
1322 pub error: Option<String>,
1323 pub profile_count: usize,
1324}
1325
1326#[derive(Debug, Clone, Serialize, Deserialize)]
1328pub struct RemoveProfileRequest {
1329 pub name: String,
1330}
1331
1332#[derive(Debug, Clone, Serialize, Deserialize)]
1334pub struct RemoveProfileResponse {
1335 pub success: bool,
1336 pub error: Option<String>,
1337 pub profile_count: usize,
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize)]
1342pub struct ReloadProfilesResponse {
1343 pub success: bool,
1344 pub error: Option<String>,
1345 pub profile_count: usize,
1346 pub loaded_from: String,
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use serde_json::json;
1353
1354 fn entry_fields() -> serde_json::Value {
1355 json!({
1356 "action": "Entry",
1357 "ts": "2026-01-02T10:00:00",
1358 "symbol": "eurusd",
1359 "side": "Buy",
1360 "order_type": "Market",
1361 "price": null,
1362 "risk": 1.5,
1363 "stoploss": 1.08
1364 })
1365 }
1366
1367 #[test]
1368 fn entry_requires_risk_and_rejects_size() {
1369 let entry: RawSignalMsg = serde_json::from_value(entry_fields()).unwrap();
1370 assert!(matches!(entry, RawSignalMsg::Entry { risk, .. } if risk == 1.5));
1371
1372 let mut with_size = entry_fields();
1373 with_size.as_object_mut().unwrap().remove("risk");
1374 with_size["size"] = json!(0.1);
1375 let error = serde_json::from_value::<RawSignalMsg>(with_size).unwrap_err();
1376 assert!(error.to_string().contains("unknown field `size`"));
1377
1378 let mut missing_risk = entry_fields();
1379 missing_risk.as_object_mut().unwrap().remove("risk");
1380 let error = serde_json::from_value::<RawSignalMsg>(missing_risk).unwrap_err();
1381 assert!(error.to_string().contains("missing field `risk`"));
1382 }
1383
1384 #[test]
1385 fn scale_in_keeps_size_field() {
1386 let signal: RawSignalMsg = serde_json::from_value(json!({
1387 "action": "ScaleIn",
1388 "ts": "2026-01-02T10:01:00",
1389 "position": { "type": "ByTradeId", "trade_id": "trade-1" },
1390 "price": null,
1391 "size": 0.25
1392 }))
1393 .unwrap();
1394 assert!(matches!(signal, RawSignalMsg::ScaleIn { size, .. } if size == 0.25));
1395 }
1396
1397 #[test]
1398 fn current_sizing_and_future_fields_are_strict() {
1399 let sizing: SizingPolicyMsg = serde_json::from_value(json!({
1400 "type": "FixedRiskAmount",
1401 "amount": 100.0
1402 }))
1403 .unwrap();
1404 assert!(matches!(
1405 sizing,
1406 SizingPolicyMsg::FixedRiskAmount { amount } if amount == 100.0
1407 ));
1408 assert!(
1409 serde_json::from_value::<SizingPolicyMsg>(json!({
1410 "type": "FixedLot",
1411 "qty": "all=0.01"
1412 }))
1413 .is_err()
1414 );
1415
1416 let future: FutureQuoteConfigMsg = serde_json::from_value(json!({
1417 "account_currency": "USD",
1418 "conversion_stale_after_ms": 15_000
1419 }))
1420 .unwrap();
1421 assert_eq!(future.account_currency, "USD");
1422 assert_eq!(future.conversion_stale_after_ms, 15_000);
1423 assert_eq!(
1424 future.mtm_output,
1425 MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1426 );
1427 assert_eq!(
1428 future.market_entry_sizing_basis,
1429 MarketEntrySizingBasisMsg::FillPrice
1430 );
1431 assert!(
1432 serde_json::from_value::<FutureQuoteConfigMsg>(json!({
1433 "account_currency": "USD",
1434 "conversion_rates": {}
1435 }))
1436 .is_err()
1437 );
1438 }
1439
1440 #[test]
1441 fn market_entry_sizing_basis_uses_strict_snake_case_wire_values() {
1442 for (value, expected) in [
1443 (json!("fill_price"), MarketEntrySizingBasisMsg::FillPrice),
1444 (
1445 json!("signal_entry_price"),
1446 MarketEntrySizingBasisMsg::SignalEntryPrice,
1447 ),
1448 ] {
1449 let basis: MarketEntrySizingBasisMsg = serde_json::from_value(value.clone()).unwrap();
1450 assert_eq!(basis, expected);
1451 assert_eq!(serde_json::to_value(basis).unwrap(), value);
1452 }
1453
1454 assert!(serde_json::from_value::<MarketEntrySizingBasisMsg>(json!("mark_price")).is_err());
1455 assert!(
1456 serde_json::from_value::<FutureQuoteConfigMsg>(json!({
1457 "market_entry_sizing_basis": "mark_price"
1458 }))
1459 .is_err()
1460 );
1461 }
1462
1463 #[test]
1464 fn mtm_output_policy_uses_strict_snake_case_wire_values() {
1465 for (value, expected) in [
1466 (json!("none"), MtmOutputPolicyMsg::None),
1467 (
1468 json!({ "bounded": { "max_points": 512 } }),
1469 MtmOutputPolicyMsg::Bounded { max_points: 512 },
1470 ),
1471 (json!("full"), MtmOutputPolicyMsg::Full),
1472 ] {
1473 let policy: MtmOutputPolicyMsg = serde_json::from_value(value.clone()).unwrap();
1474 assert_eq!(policy, expected);
1475 assert_eq!(serde_json::to_value(policy).unwrap(), value);
1476 }
1477
1478 assert!(
1479 serde_json::from_value::<MtmOutputPolicyMsg>(json!({
1480 "bounded": { "max_points": 512, "unexpected": true }
1481 }))
1482 .is_err()
1483 );
1484 }
1485
1486 #[test]
1487 fn future_result_defaults_missing_mtm_output_summary() {
1488 let result: FutureBacktestResultMsg = serde_json::from_value(json!({
1489 "format_version": 1,
1490 "execution_metadata": null,
1491 "recorded_fills": null,
1492 "action_dispositions": null,
1493 "close_events": null,
1494 "completed_positions": null,
1495 "open_positions": null,
1496 "pending_orders": null,
1497 "pending_order_lifecycle": [],
1498 "mtm_equity_curve": null,
1499 "mtm_max_drawdown": null,
1500 "mtm_max_drawdown_pct": null,
1501 "provider_evaluation": null
1502 }))
1503 .unwrap();
1504
1505 assert_eq!(result.mtm_output_summary, MtmOutputSummaryMsg::default());
1506 assert_eq!(result.mtm_output_summary.policy, MtmOutputPolicyMsg::Full);
1507
1508 let summary: MtmOutputSummaryMsg = serde_json::from_value(json!({
1509 "policy": "full",
1510 "observed_points": 4,
1511 "retained_points": 4,
1512 "omitted_points": 0,
1513 "future_addition": true
1514 }))
1515 .unwrap();
1516 assert_eq!(summary.policy, MtmOutputPolicyMsg::Full);
1517 }
1518
1519 fn minimal_request_json() -> serde_json::Value {
1520 json!({
1521 "request": {
1522 "symbol": "EURUSD",
1523 "exchange": "fixture",
1524 "data_type": "tick",
1525 "timeframe": null,
1526 "from": null,
1527 "to": null,
1528 "raw_signals": [],
1529 "profile": null,
1530 "config": {
1531 "initial_balance": null,
1532 "close_on_finish": null,
1533 "fill_model": null
1534 }
1535 },
1536 "future": {
1537 "account_currency": "USD"
1538 }
1539 })
1540 }
1541
1542 #[test]
1543 fn omitted_delivery_and_mtm_fields_use_current_defaults() {
1544 let request: RunBacktestRequest = serde_json::from_value(minimal_request_json()).unwrap();
1545 assert_eq!(request.result_delivery, ResultDeliveryMsg::Auto);
1546 assert_eq!(
1547 request.future.mtm_output,
1548 MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1549 );
1550 assert_eq!(
1551 request.future.market_entry_sizing_basis,
1552 MarketEntrySizingBasisMsg::FillPrice
1553 );
1554
1555 let submitted: SubmitBacktestRequest = serde_json::from_value(json!({
1556 "request": minimal_request_json()
1557 }))
1558 .unwrap();
1559 assert_eq!(submitted.request.result_delivery, ResultDeliveryMsg::Auto);
1560 assert_eq!(
1561 submitted.request.future.mtm_output,
1562 MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1563 );
1564
1565 let mut explicit = minimal_request_json();
1566 explicit["result_delivery"] = json!("auto");
1567 explicit["future"]["mtm_output"] = json!({
1568 "bounded": { "max_points": 4_096 }
1569 });
1570 let explicit: RunBacktestRequest = serde_json::from_value(explicit).unwrap();
1571 assert_eq!(explicit.result_delivery, ResultDeliveryMsg::Auto);
1572 assert_eq!(
1573 explicit.future.mtm_output,
1574 MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1575 );
1576
1577 let mut multi_request = minimal_request_json()["request"].clone();
1578 let fields = multi_request.as_object_mut().unwrap();
1579 fields.remove("profile");
1580 fields.insert("profiles".into(), json!([]));
1581 let multi: RunBacktestMultiRequest = serde_json::from_value(json!({
1582 "request": multi_request,
1583 "future": { "account_currency": "USD" }
1584 }))
1585 .unwrap();
1586 assert_eq!(multi.result_delivery, ResultDeliveryMsg::Auto);
1587 assert_eq!(
1588 multi.future.mtm_output,
1589 MtmOutputPolicyMsg::Bounded { max_points: 4_096 }
1590 );
1591 }
1592
1593 #[test]
1594 fn older_async_result_responses_default_artifact_consumption_state() {
1595 let response: GetBacktestResultResponse = serde_json::from_value(json!({
1596 "success": true,
1597 "job_id": "job-old",
1598 "result": null,
1599 "error": null,
1600 "inline_complete": true
1601 }))
1602 .unwrap();
1603 assert!(!response.artifact_consumed);
1604 }
1605}