1use std::collections::BTreeMap;
8
9use chrono::NaiveDateTime;
10use qs_core::{
11 CloseReason, EffectiveStop, ExecutionFill, ExecutionModel, OrderType, PriceQuote, Side,
12};
13use serde::{Deserialize, Serialize};
14
15use crate::currency::{ConversionResult, RunCurrencyPlan};
16use crate::ledger::LifecycleLedger;
17use crate::mtm::MtmOutputSummary;
18use crate::portfolio::EquityPoint;
19
20pub const FUTURE_ARTIFACT_FORMAT_VERSION: u32 = 1;
22
23pub const DEFAULT_PNL_EPSILON: f64 = 1.0e-9;
25
26fn default_format_version() -> u32 {
27 FUTURE_ARTIFACT_FORMAT_VERSION
28}
29
30fn default_pnl_epsilon() -> f64 {
31 DEFAULT_PNL_EPSILON
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ExecutionMetadata {
38 pub run_id: Option<String>,
39 pub execution_model: ExecutionModel,
40 pub initial_balance: f64,
41 pub account_currency: Option<String>,
42 pub currency_plan: Option<RunCurrencyPlan>,
44 pub contract_sizes: BTreeMap<String, f64>,
46 pub stale_quote_after_millis: Option<i64>,
48 #[serde(default = "default_pnl_epsilon")]
49 pub pnl_epsilon: f64,
50 pub tags: BTreeMap<String, String>,
52}
53
54impl Default for ExecutionMetadata {
55 fn default() -> Self {
56 Self {
57 run_id: None,
58 execution_model: ExecutionModel::default(),
59 initial_balance: 0.0,
60 account_currency: None,
61 currency_plan: None,
62 contract_sizes: BTreeMap::new(),
63 stale_quote_after_millis: None,
64 pnl_epsilon: DEFAULT_PNL_EPSILON,
65 tags: BTreeMap::new(),
66 }
67 }
68}
69
70pub fn deterministic_event_id(scope: &str, kind: &str, sequence: u64) -> String {
75 format!("{scope}:{kind}:{sequence:08}")
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct RecordedFill {
82 pub id: String,
83 #[serde(default)]
84 pub action_id: Option<String>,
85 pub position_id: String,
86 pub symbol: String,
87 #[serde(default)]
89 pub signal_ts: Option<NaiveDateTime>,
90 pub effective_ts: NaiveDateTime,
92 #[serde(default)]
94 pub execution_ts: Option<NaiveDateTime>,
95 pub quote_ts: NaiveDateTime,
97 #[serde(default)]
99 pub quote_age_millis: Option<i64>,
100 pub size: f64,
101 pub bid: f64,
102 pub ask: f64,
103 pub fill: ExecutionFill,
105}
106
107impl RecordedFill {
108 #[allow(clippy::too_many_arguments)]
109 pub fn from_quote(
110 position_id: impl Into<String>,
111 action_id: Option<String>,
112 sequence: u64,
113 signal_ts: Option<NaiveDateTime>,
114 effective_ts: NaiveDateTime,
115 size: f64,
116 quote: &PriceQuote,
117 fill: ExecutionFill,
118 ) -> Self {
119 Self::from_quote_at(
120 position_id,
121 action_id,
122 sequence,
123 signal_ts,
124 effective_ts,
125 quote.ts,
126 size,
127 quote,
128 fill,
129 )
130 }
131
132 #[allow(clippy::too_many_arguments)]
133 pub fn from_quote_at(
134 position_id: impl Into<String>,
135 action_id: Option<String>,
136 sequence: u64,
137 signal_ts: Option<NaiveDateTime>,
138 effective_ts: NaiveDateTime,
139 execution_ts: NaiveDateTime,
140 size: f64,
141 quote: &PriceQuote,
142 fill: ExecutionFill,
143 ) -> Self {
144 let position_id = position_id.into();
145 Self {
146 id: deterministic_event_id(&position_id, "fill", sequence),
147 action_id,
148 position_id,
149 symbol: quote.symbol.clone(),
150 signal_ts,
151 effective_ts,
152 execution_ts: Some(execution_ts),
153 quote_ts: quote.ts,
154 quote_age_millis: Some(
155 execution_ts
156 .signed_duration_since(quote.ts)
157 .num_milliseconds(),
158 ),
159 size,
160 bid: quote.bid,
161 ask: quote.ask,
162 fill,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(default)]
170pub struct CloseEvent {
171 pub id: String,
172 pub action_id: Option<String>,
173 pub fill_id: Option<String>,
174 pub position_id: String,
175 pub symbol: String,
176 pub side: Side,
177 pub ts: NaiveDateTime,
178 pub size: f64,
179 pub price: f64,
180 #[serde(default)]
182 pub entry_price: Option<f64>,
183 pub pnl: f64,
184 #[serde(default)]
185 pub native_pnl: Option<f64>,
186 #[serde(default)]
187 pub native_currency: Option<String>,
188 #[serde(default)]
189 pub pnl_conversion: Option<ConversionResult>,
190 pub reason: CloseReason,
191 pub remaining_size: Option<f64>,
193}
194
195impl Default for CloseEvent {
196 fn default() -> Self {
197 Self {
198 id: String::new(),
199 action_id: None,
200 fill_id: None,
201 position_id: String::new(),
202 symbol: String::new(),
203 side: Side::Buy,
204 ts: NaiveDateTime::default(),
205 size: 0.0,
206 price: 0.0,
207 entry_price: None,
208 pnl: 0.0,
209 native_pnl: None,
210 native_currency: None,
211 pnl_conversion: None,
212 reason: CloseReason::Manual,
213 remaining_size: None,
214 }
215 }
216}
217
218impl CloseEvent {
219 #[allow(clippy::too_many_arguments)]
220 pub fn new(
221 position_id: impl Into<String>,
222 sequence: u64,
223 symbol: impl Into<String>,
224 side: Side,
225 ts: NaiveDateTime,
226 size: f64,
227 price: f64,
228 pnl: f64,
229 reason: CloseReason,
230 ) -> Self {
231 let position_id = position_id.into();
232 Self {
233 id: deterministic_event_id(&position_id, "close", sequence),
234 position_id,
235 symbol: symbol.into(),
236 side,
237 ts,
238 size,
239 price,
240 pnl,
241 native_pnl: Some(pnl),
242 reason,
243 ..Self::default()
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
250#[serde(rename_all = "snake_case")]
251pub enum RiskBasisStatus {
252 Available,
253 Partial,
255 #[default]
256 MissingStop,
257 InvalidInput,
258 NonProtectiveStop,
259 ZeroRisk,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264#[serde(default)]
265pub struct RiskTranche {
266 pub fill_id: Option<String>,
267 pub size: f64,
268 pub entry_price: f64,
269 pub initial_stop: Option<f64>,
270 pub contract_size: f64,
271 pub risk_per_unit: Option<f64>,
272 pub risk_amount: Option<f64>,
273 #[serde(default)]
274 pub native_risk_amount: Option<f64>,
275 #[serde(default)]
276 pub native_currency: Option<String>,
277 #[serde(default)]
278 pub risk_conversion: Option<ConversionResult>,
279 pub status: RiskBasisStatus,
280}
281
282impl Default for RiskTranche {
283 fn default() -> Self {
284 Self {
285 fill_id: None,
286 size: 0.0,
287 entry_price: 0.0,
288 initial_stop: None,
289 contract_size: 1.0,
290 risk_per_unit: None,
291 risk_amount: None,
292 native_risk_amount: None,
293 native_currency: None,
294 risk_conversion: None,
295 status: RiskBasisStatus::MissingStop,
296 }
297 }
298}
299
300impl RiskTranche {
301 pub fn calculate(
302 fill_id: Option<String>,
303 side: Side,
304 size: f64,
305 entry_price: f64,
306 initial_stop: Option<f64>,
307 contract_size: f64,
308 epsilon: f64,
309 ) -> Self {
310 let mut tranche = Self {
311 fill_id,
312 size,
313 entry_price,
314 initial_stop,
315 contract_size,
316 ..Self::default()
317 };
318 let epsilon = normalized_epsilon(epsilon);
319
320 if !size.is_finite()
321 || size <= 0.0
322 || !entry_price.is_finite()
323 || !contract_size.is_finite()
324 || contract_size <= 0.0
325 {
326 tranche.status = RiskBasisStatus::InvalidInput;
327 return tranche;
328 }
329
330 let Some(stop) = initial_stop else {
331 return tranche;
332 };
333 if !stop.is_finite() {
334 tranche.status = RiskBasisStatus::InvalidInput;
335 return tranche;
336 }
337
338 let signed_distance = match side {
339 Side::Buy => entry_price - stop,
340 Side::Sell => stop - entry_price,
341 };
342 if signed_distance < -epsilon {
343 tranche.status = RiskBasisStatus::NonProtectiveStop;
344 return tranche;
345 }
346 if signed_distance.abs() <= epsilon {
347 tranche.status = RiskBasisStatus::ZeroRisk;
348 tranche.risk_per_unit = Some(0.0);
349 tranche.risk_amount = Some(0.0);
350 tranche.native_risk_amount = Some(0.0);
351 return tranche;
352 }
353
354 tranche.status = RiskBasisStatus::Available;
355 tranche.risk_per_unit = Some(signed_distance);
356 let native_risk = signed_distance * size * contract_size;
357 tranche.risk_amount = Some(native_risk);
358 tranche.native_risk_amount = Some(native_risk);
359 tranche
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
365#[serde(rename_all = "snake_case")]
366pub enum NetPnlOutcome {
367 Win,
368 Loss,
369 #[default]
370 Breakeven,
371}
372
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375#[serde(default)]
376pub struct CompletedPosition {
377 pub position_id: String,
378 pub symbol: String,
379 pub side: Side,
380 pub group: Option<String>,
381 pub trade_id: Option<String>,
382 pub open_ts: NaiveDateTime,
383 pub close_ts: NaiveDateTime,
384 pub entry_size: f64,
385 pub average_entry_price: f64,
386 pub net_pnl: f64,
387 #[serde(default)]
388 pub native_net_pnl: Option<f64>,
389 #[serde(default)]
390 pub native_currency: Option<String>,
391 pub outcome: NetPnlOutcome,
392 #[serde(default = "default_pnl_epsilon")]
393 pub pnl_epsilon: f64,
394 pub initial_stop: Option<f64>,
395 pub effective_stop: Option<EffectiveStop>,
396 pub risk_basis_status: RiskBasisStatus,
397 pub risk_tranches: Vec<RiskTranche>,
398 pub realized_r: Option<f64>,
400 pub mae: Option<f64>,
402 pub mfe: Option<f64>,
404 pub close_reasons: Vec<CloseReason>,
406 pub close_events: Vec<CloseEvent>,
407}
408
409impl Default for CompletedPosition {
410 fn default() -> Self {
411 Self {
412 position_id: String::new(),
413 symbol: String::new(),
414 side: Side::Buy,
415 group: None,
416 trade_id: None,
417 open_ts: NaiveDateTime::default(),
418 close_ts: NaiveDateTime::default(),
419 entry_size: 0.0,
420 average_entry_price: 0.0,
421 net_pnl: 0.0,
422 native_net_pnl: None,
423 native_currency: None,
424 outcome: NetPnlOutcome::Breakeven,
425 pnl_epsilon: DEFAULT_PNL_EPSILON,
426 initial_stop: None,
427 effective_stop: None,
428 risk_basis_status: RiskBasisStatus::MissingStop,
429 risk_tranches: Vec::new(),
430 realized_r: None,
431 mae: None,
432 mfe: None,
433 close_reasons: Vec::new(),
434 close_events: Vec::new(),
435 }
436 }
437}
438
439impl CompletedPosition {
440 #[allow(clippy::too_many_arguments)]
441 pub fn from_close_events(
442 position_id: impl Into<String>,
443 symbol: impl Into<String>,
444 side: Side,
445 open_ts: NaiveDateTime,
446 close_ts: NaiveDateTime,
447 entry_size: f64,
448 average_entry_price: f64,
449 initial_stop: Option<f64>,
450 effective_stop: Option<EffectiveStop>,
451 risk_tranches: Vec<RiskTranche>,
452 close_events: Vec<CloseEvent>,
453 mae: Option<f64>,
454 mfe: Option<f64>,
455 epsilon: f64,
456 ) -> Self {
457 let epsilon = normalized_epsilon(epsilon);
458 let net_pnl = close_events.iter().map(|event| event.pnl).sum();
459 let native_net_pnl = close_events.iter().try_fold(0.0, |total, event| {
460 event.native_pnl.map(|native_pnl| total + native_pnl)
461 });
462 let native_currency = close_events
463 .first()
464 .and_then(|event| event.native_currency.clone())
465 .filter(|currency| {
466 close_events
467 .iter()
468 .all(|event| event.native_currency.as_ref() == Some(currency))
469 });
470 let close_reasons = distinct_close_reasons(&close_events);
471 let (risk_basis_status, initial_risk) = summarize_risk(&risk_tranches, epsilon);
472 let realized_r = initial_risk
473 .filter(|risk| *risk > epsilon)
474 .map(|risk| net_pnl / risk);
475
476 Self {
477 position_id: position_id.into(),
478 symbol: symbol.into(),
479 side,
480 open_ts,
481 close_ts,
482 entry_size,
483 average_entry_price,
484 net_pnl,
485 native_net_pnl,
486 native_currency,
487 outcome: Self::classify(net_pnl, epsilon),
488 pnl_epsilon: epsilon,
489 initial_stop,
490 effective_stop,
491 risk_basis_status,
492 risk_tranches,
493 realized_r,
494 mae,
495 mfe,
496 close_reasons,
497 close_events,
498 ..Self::default()
499 }
500 }
501
502 pub fn classify(net_pnl: f64, epsilon: f64) -> NetPnlOutcome {
503 let epsilon = normalized_epsilon(epsilon);
504 if net_pnl > epsilon {
505 NetPnlOutcome::Win
506 } else if net_pnl < -epsilon {
507 NetPnlOutcome::Loss
508 } else {
509 NetPnlOutcome::Breakeven
510 }
511 }
512
513 pub fn initial_risk(&self) -> Option<f64> {
514 summarize_risk(&self.risk_tranches, self.pnl_epsilon).1
515 }
516}
517
518fn normalized_epsilon(epsilon: f64) -> f64 {
519 if epsilon.is_finite() {
520 epsilon.abs()
521 } else {
522 DEFAULT_PNL_EPSILON
523 }
524}
525
526fn distinct_close_reasons(events: &[CloseEvent]) -> Vec<CloseReason> {
527 let mut reasons = Vec::new();
528 for event in events {
529 if !reasons.contains(&event.reason) {
530 reasons.push(event.reason);
531 }
532 }
533 reasons
534}
535
536fn summarize_risk(tranches: &[RiskTranche], epsilon: f64) -> (RiskBasisStatus, Option<f64>) {
537 if tranches.is_empty() {
538 return (RiskBasisStatus::MissingStop, None);
539 }
540
541 let available = tranches
542 .iter()
543 .filter(|tranche| tranche.status == RiskBasisStatus::Available)
544 .count();
545 if available == tranches.len() {
546 let total: f64 = tranches
547 .iter()
548 .filter_map(|tranche| tranche.risk_amount)
549 .sum();
550 if !total.is_finite() {
551 return (RiskBasisStatus::InvalidInput, None);
552 }
553 if total <= epsilon {
554 return (RiskBasisStatus::ZeroRisk, None);
555 }
556 return (RiskBasisStatus::Available, Some(total));
557 }
558 if available > 0 {
559 return (RiskBasisStatus::Partial, None);
560 }
561
562 let status = tranches
563 .iter()
564 .map(|tranche| tranche.status)
565 .find(|status| *status != RiskBasisStatus::MissingStop)
566 .unwrap_or(RiskBasisStatus::MissingStop);
567 (status, None)
568}
569
570#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
575#[serde(default)]
576pub struct OpenPositionSnapshot {
577 pub position_id: String,
578 pub symbol: String,
579 pub side: Side,
580 pub group: Option<String>,
581 pub trade_id: Option<String>,
582 pub open_ts: Option<NaiveDateTime>,
583 pub average_entry_price: f64,
584 pub remaining_size: f64,
585 pub initial_stop: Option<f64>,
586 pub effective_stop: Option<EffectiveStop>,
587 pub realized_pnl: f64,
589 #[serde(default)]
590 pub native_realized_pnl: Option<f64>,
591 #[serde(default)]
592 pub native_currency: Option<String>,
593 #[serde(default)]
594 pub account_currency: Option<String>,
595 pub quote_ts: Option<NaiveDateTime>,
596 pub mark_price: Option<f64>,
597 pub unrealized_pnl: Option<f64>,
598 #[serde(default)]
599 pub native_unrealized_pnl: Option<f64>,
600 #[serde(default)]
601 pub unrealized_pnl_conversion: Option<ConversionResult>,
602 pub gross_exposure: Option<f64>,
603 #[serde(default)]
604 pub native_signed_exposure: Option<f64>,
605 #[serde(default)]
606 pub gross_exposure_conversion: Option<ConversionResult>,
607 pub open_risk: Option<f64>,
608 #[serde(default)]
609 pub native_open_risk: Option<f64>,
610 #[serde(default)]
611 pub open_risk_conversion: Option<ConversionResult>,
612 pub campaign_mae: Option<f64>,
613 pub campaign_mfe: Option<f64>,
614}
615
616impl Default for OpenPositionSnapshot {
617 fn default() -> Self {
618 Self {
619 position_id: String::new(),
620 symbol: String::new(),
621 side: Side::Buy,
622 group: None,
623 trade_id: None,
624 open_ts: None,
625 average_entry_price: 0.0,
626 remaining_size: 0.0,
627 initial_stop: None,
628 effective_stop: None,
629 realized_pnl: 0.0,
630 native_realized_pnl: None,
631 native_currency: None,
632 account_currency: None,
633 quote_ts: None,
634 mark_price: None,
635 unrealized_pnl: None,
636 native_unrealized_pnl: None,
637 unrealized_pnl_conversion: None,
638 gross_exposure: None,
639 native_signed_exposure: None,
640 gross_exposure_conversion: None,
641 open_risk: None,
642 native_open_risk: None,
643 open_risk_conversion: None,
644 campaign_mae: None,
645 campaign_mfe: None,
646 }
647 }
648}
649
650impl OpenPositionSnapshot {
651 pub fn new(
652 position_id: impl Into<String>,
653 symbol: impl Into<String>,
654 side: Side,
655 average_entry_price: f64,
656 remaining_size: f64,
657 ) -> Self {
658 Self {
659 position_id: position_id.into(),
660 symbol: symbol.into(),
661 side,
662 average_entry_price,
663 remaining_size,
664 ..Self::default()
665 }
666 }
667
668 pub(crate) fn clear_mark(&mut self) {
669 self.quote_ts = None;
670 self.mark_price = None;
671 self.unrealized_pnl = None;
672 self.native_unrealized_pnl = None;
673 self.unrealized_pnl_conversion = None;
674 self.gross_exposure = None;
675 self.native_signed_exposure = None;
676 self.gross_exposure_conversion = None;
677 self.open_risk = None;
678 self.native_open_risk = None;
679 self.open_risk_conversion = None;
680 self.campaign_mae = None;
681 self.campaign_mfe = None;
682 }
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
687#[serde(rename_all = "snake_case")]
688pub enum PendingOrderLifecycleState {
689 #[default]
690 Placed,
691 Filled,
692 Cancelled,
693 UnfilledAtEnd,
694}
695
696impl PendingOrderLifecycleState {
697 pub fn is_terminal(self) -> bool {
699 !matches!(self, Self::Placed)
700 }
701}
702
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
709#[serde(default)]
710pub struct PendingOrderLifecycleEvent {
711 pub id: String,
712 pub sequence: u64,
713 pub position_id: String,
714 pub placement_action_id: Option<String>,
715 pub terminal_action_id: Option<String>,
716 pub state: PendingOrderLifecycleState,
717 pub symbol: String,
718 pub side: Side,
719 pub order_type: OrderType,
720 pub requested_size: f64,
721 pub filled_size: Option<f64>,
722 pub requested_price: Option<f64>,
723 pub fill_price: Option<f64>,
724 pub signal_ts: Option<NaiveDateTime>,
725 pub placed_ts: Option<NaiveDateTime>,
726 pub effective_ts: Option<NaiveDateTime>,
727 pub terminal_ts: Option<NaiveDateTime>,
728 pub wait_latency_ms: Option<i64>,
729 pub fill_ratio: Option<f64>,
730}
731
732impl Default for PendingOrderLifecycleEvent {
733 fn default() -> Self {
734 Self {
735 id: String::new(),
736 sequence: 0,
737 position_id: String::new(),
738 placement_action_id: None,
739 terminal_action_id: None,
740 state: PendingOrderLifecycleState::Placed,
741 symbol: String::new(),
742 side: Side::Buy,
743 order_type: OrderType::Limit,
744 requested_size: 0.0,
745 filled_size: None,
746 requested_price: None,
747 fill_price: None,
748 signal_ts: None,
749 placed_ts: None,
750 effective_ts: None,
751 terminal_ts: None,
752 wait_latency_ms: None,
753 fill_ratio: None,
754 }
755 }
756}
757
758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760#[serde(default)]
761pub struct PendingOrderSnapshot {
762 pub position_id: String,
763 pub action_id: Option<String>,
764 pub symbol: String,
765 pub side: Side,
766 pub order_type: OrderType,
767 pub requested_price: Option<f64>,
768 pub size: f64,
769 pub signal_ts: Option<NaiveDateTime>,
770 pub effective_ts: Option<NaiveDateTime>,
771 pub initial_stop: Option<f64>,
772 pub group: Option<String>,
773 pub trade_id: Option<String>,
774}
775
776impl Default for PendingOrderSnapshot {
777 fn default() -> Self {
778 Self {
779 position_id: String::new(),
780 action_id: None,
781 symbol: String::new(),
782 side: Side::Buy,
783 order_type: OrderType::Limit,
784 requested_price: None,
785 size: 0.0,
786 signal_ts: None,
787 effective_ts: None,
788 initial_stop: None,
789 group: None,
790 trade_id: None,
791 }
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797#[serde(default)]
798pub struct FutureBacktestArtifacts {
799 #[serde(default = "default_format_version")]
800 pub format_version: u32,
801 pub execution: ExecutionMetadata,
802 pub fills: Vec<RecordedFill>,
803 pub close_events: Vec<CloseEvent>,
804 pub completed_positions: Vec<CompletedPosition>,
805 pub open_positions: Vec<OpenPositionSnapshot>,
806 pub pending_orders: Vec<PendingOrderSnapshot>,
807 pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
808 pub lifecycle: LifecycleLedger,
809 pub equity_curve: Vec<EquityPoint>,
810 pub mtm_output_summary: MtmOutputSummary,
811 pub max_drawdown: Option<f64>,
812 pub max_drawdown_pct: Option<f64>,
813}
814
815impl Default for FutureBacktestArtifacts {
816 fn default() -> Self {
817 Self {
818 format_version: FUTURE_ARTIFACT_FORMAT_VERSION,
819 execution: ExecutionMetadata::default(),
820 fills: Vec::new(),
821 close_events: Vec::new(),
822 completed_positions: Vec::new(),
823 open_positions: Vec::new(),
824 pending_orders: Vec::new(),
825 pending_order_lifecycle: Vec::new(),
826 lifecycle: LifecycleLedger::default(),
827 equity_curve: Vec::new(),
828 mtm_output_summary: MtmOutputSummary::default(),
829 max_drawdown: None,
830 max_drawdown_pct: None,
831 }
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use chrono::NaiveDate;
839 use qs_core::{ExecutionConvention, FillModel, FillPurpose, SlippageModel, StopOrigin};
840
841 fn ts(second: u32) -> NaiveDateTime {
842 NaiveDate::from_ymd_opt(2026, 1, 2)
843 .unwrap()
844 .and_hms_opt(3, 4, second)
845 .unwrap()
846 }
847
848 fn execution_fill(side: Side, price: f64) -> ExecutionFill {
849 ExecutionFill {
850 purpose: FillPurpose::MarketEntry,
851 side,
852 price,
853 quote_price: price,
854 requested_price: None,
855 slippage_pips: 0.0,
856 }
857 }
858
859 #[test]
860 fn execution_metadata_is_serializable_and_defaults_new_fields() {
861 let decoded: ExecutionMetadata = serde_json::from_str("{}").unwrap();
862 assert_eq!(decoded.pnl_epsilon, DEFAULT_PNL_EPSILON);
863 assert_eq!(decoded.execution_model, ExecutionModel::default());
864
865 let metadata = ExecutionMetadata {
866 execution_model: ExecutionModel::new(
867 ExecutionConvention::FutureQuoteV1,
868 FillModel::BidAsk,
869 SlippageModel::adverse(0.2),
870 ),
871 initial_balance: 50_000.0,
872 account_currency: Some("USD".into()),
873 ..ExecutionMetadata::default()
874 };
875 let roundtrip: ExecutionMetadata =
876 serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
877 assert_eq!(roundtrip, metadata);
878 }
879
880 #[test]
881 fn recorded_fill_has_stable_id_and_quote_context() {
882 let quote = PriceQuote {
883 symbol: "EURUSD".into(),
884 ts: ts(2),
885 bid: 1.0998,
886 ask: 1.1000,
887 };
888 let first = RecordedFill::from_quote(
889 "position-7",
890 Some("action-3".into()),
891 4,
892 Some(ts(0)),
893 ts(1),
894 0.5,
895 "e,
896 execution_fill(Side::Buy, 1.1000),
897 );
898 let second = RecordedFill::from_quote(
899 "position-7",
900 Some("action-3".into()),
901 4,
902 Some(ts(0)),
903 ts(1),
904 0.5,
905 "e,
906 execution_fill(Side::Buy, 1.1000),
907 );
908
909 assert_eq!(first.id, "position-7:fill:00000004");
910 assert_eq!(first, second);
911 assert_eq!(first.symbol, "EURUSD");
912 assert_eq!(first.quote_ts, ts(2));
913 assert_eq!((first.ask - 1.1000).abs(), 0.0);
914 }
915
916 #[test]
917 fn risk_tranches_validate_direction_and_calculate_money_risk() {
918 let long = RiskTranche::calculate(
919 Some("fill-1".into()),
920 Side::Buy,
921 2.0,
922 100.0,
923 Some(95.0),
924 10.0,
925 DEFAULT_PNL_EPSILON,
926 );
927 assert_eq!(long.status, RiskBasisStatus::Available);
928 assert_eq!(long.risk_per_unit, Some(5.0));
929 assert_eq!(long.risk_amount, Some(100.0));
930
931 let short = RiskTranche::calculate(
932 None,
933 Side::Sell,
934 1.0,
935 100.0,
936 Some(105.0),
937 10.0,
938 DEFAULT_PNL_EPSILON,
939 );
940 assert_eq!(short.risk_amount, Some(50.0));
941
942 let non_protective = RiskTranche::calculate(
943 None,
944 Side::Buy,
945 1.0,
946 100.0,
947 Some(101.0),
948 1.0,
949 DEFAULT_PNL_EPSILON,
950 );
951 assert_eq!(non_protective.status, RiskBasisStatus::NonProtectiveStop);
952 assert_eq!(non_protective.risk_amount, None);
953 }
954
955 #[test]
956 fn completed_position_sums_closes_classifies_and_realizes_r() {
957 let closes = vec![
958 CloseEvent::new(
959 "p1",
960 0,
961 "XAUUSD",
962 Side::Buy,
963 ts(3),
964 0.5,
965 101.0,
966 50.0,
967 CloseReason::Target,
968 ),
969 CloseEvent::new(
970 "p1",
971 1,
972 "XAUUSD",
973 Side::Buy,
974 ts(4),
975 0.5,
976 99.0,
977 -20.0,
978 CloseReason::Manual,
979 ),
980 CloseEvent::new(
981 "p1",
982 2,
983 "XAUUSD",
984 Side::Buy,
985 ts(5),
986 0.1,
987 99.0,
988 0.0,
989 CloseReason::Manual,
990 ),
991 ];
992 let risk = RiskTranche::calculate(
993 Some("entry".into()),
994 Side::Buy,
995 1.0,
996 100.0,
997 Some(99.0),
998 100.0,
999 DEFAULT_PNL_EPSILON,
1000 );
1001 let completed = CompletedPosition::from_close_events(
1002 "p1",
1003 "XAUUSD",
1004 Side::Buy,
1005 ts(0),
1006 ts(5),
1007 1.0,
1008 100.0,
1009 Some(99.0),
1010 Some(EffectiveStop::new(100.0, StopOrigin::Breakeven)),
1011 vec![risk],
1012 closes,
1013 Some(-40.0),
1014 Some(70.0),
1015 DEFAULT_PNL_EPSILON,
1016 );
1017
1018 assert_eq!(completed.net_pnl, 30.0);
1019 assert_eq!(completed.outcome, NetPnlOutcome::Win);
1020 assert_eq!(completed.initial_risk(), Some(100.0));
1021 assert_eq!(completed.realized_r, Some(0.3));
1022 assert_eq!(
1023 completed.close_reasons,
1024 vec![CloseReason::Target, CloseReason::Manual]
1025 );
1026 assert_eq!(completed.mae, Some(-40.0));
1027 assert_eq!(completed.mfe, Some(70.0));
1028 }
1029
1030 #[test]
1031 fn net_pnl_outcome_uses_absolute_epsilon() {
1032 assert_eq!(
1033 CompletedPosition::classify(0.0005, 0.001),
1034 NetPnlOutcome::Breakeven
1035 );
1036 assert_eq!(
1037 CompletedPosition::classify(-0.002, -0.001),
1038 NetPnlOutcome::Loss
1039 );
1040 assert_eq!(
1041 CompletedPosition::classify(0.002, 0.001),
1042 NetPnlOutcome::Win
1043 );
1044 }
1045
1046 #[test]
1047 fn partial_risk_basis_does_not_report_misleading_r() {
1048 let valid = RiskTranche::calculate(
1049 None,
1050 Side::Buy,
1051 1.0,
1052 10.0,
1053 Some(9.0),
1054 1.0,
1055 DEFAULT_PNL_EPSILON,
1056 );
1057 let missing =
1058 RiskTranche::calculate(None, Side::Buy, 1.0, 10.0, None, 1.0, DEFAULT_PNL_EPSILON);
1059 let completed = CompletedPosition::from_close_events(
1060 "p",
1061 "S",
1062 Side::Buy,
1063 ts(0),
1064 ts(1),
1065 2.0,
1066 10.0,
1067 Some(9.0),
1068 None,
1069 vec![valid, missing],
1070 vec![CloseEvent::new(
1071 "p",
1072 0,
1073 "S",
1074 Side::Buy,
1075 ts(1),
1076 2.0,
1077 11.0,
1078 2.0,
1079 CloseReason::Manual,
1080 )],
1081 None,
1082 None,
1083 DEFAULT_PNL_EPSILON,
1084 );
1085 assert_eq!(completed.risk_basis_status, RiskBasisStatus::Partial);
1086 assert_eq!(completed.realized_r, None);
1087 }
1088
1089 #[test]
1090 fn aggregate_deserializes_additive_fields_from_empty_object() {
1091 let artifacts: FutureBacktestArtifacts = serde_json::from_str("{}").unwrap();
1092 assert_eq!(artifacts.format_version, FUTURE_ARTIFACT_FORMAT_VERSION);
1093 assert!(artifacts.fills.is_empty());
1094 assert!(artifacts.completed_positions.is_empty());
1095 assert!(artifacts.equity_curve.is_empty());
1096 assert_eq!(artifacts.mtm_output_summary, MtmOutputSummary::default());
1097 assert_eq!(artifacts.max_drawdown, None);
1098 }
1099
1100 #[test]
1101 fn snapshots_preserve_defaults_for_forward_compatible_fields() {
1102 let open: OpenPositionSnapshot = serde_json::from_str(
1103 r#"{"position_id":"p","symbol":"EURUSD","side":"Buy","average_entry_price":1.1,"remaining_size":1.0}"#,
1104 )
1105 .unwrap();
1106 assert_eq!(open.realized_pnl, 0.0);
1107 assert_eq!(open.mark_price, None);
1108 assert_eq!(open.campaign_mae, None);
1109
1110 let pending: PendingOrderSnapshot = serde_json::from_str("{}").unwrap();
1111 assert_eq!(pending.order_type, OrderType::Limit);
1112 assert_eq!(pending.initial_stop, None);
1113
1114 let lifecycle: PendingOrderLifecycleEvent = serde_json::from_str("{}").unwrap();
1115 assert_eq!(lifecycle.state, PendingOrderLifecycleState::Placed);
1116 assert_eq!(lifecycle.filled_size, None);
1117 assert_eq!(lifecycle.terminal_ts, None);
1118 }
1119}