1use std::collections::{BTreeMap, BTreeSet};
19
20use ahash::AHashMap;
21use nautilus_analysis::PortfolioStatistics;
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24 accounts::{AccountAny, margin_model::MarginModel},
25 events::{OrderEventAny, PortfolioSnapshot, PositionAdjusted},
26 identifiers::InstrumentId,
27 orders::{Order, OrderAny},
28 position::{Position, PositionReplayEvent},
29 types::{Currency, Money},
30};
31use serde::Serialize;
32use serde_json::{Map, Value, json};
33
34const CANONICAL_SCHEMA: &str = "nautilus-backtest-result/v1";
35const METADATA_KEYS: &[&str] = &["exec_algorithm_params", "info"];
36const UNORDERED_ARRAY_KEYS: &[&str] = &[
37 "accounts",
38 "actor_ids",
39 "balances",
40 "diagnostics",
41 "exec_algorithm_ids",
42 "fills",
43 "linked_order_ids",
44 "margins",
45 "orders",
46 "portfolio_snapshots",
47 "position_snapshots",
48 "positions",
49 "realized_pnls",
50 "stale_currencies",
51 "stale_instruments",
52 "strategy_ids",
53 "tags",
54 "total_equity",
55 "trade_ids",
56 "unpriced_instruments",
57 "unrealized_pnls",
58 "venue_order_ids",
59];
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
62enum IdentityClass {
63 ClientOrder,
64 Event,
65 OrderList,
66 Position,
67 Trade,
68 VenueOrder,
69}
70
71impl IdentityClass {
72 const fn prefix(self) -> &'static str {
73 match self {
74 Self::ClientOrder => "client-order",
75 Self::Event => "event",
76 Self::OrderList => "order-list",
77 Self::Position => "position",
78 Self::Trade => "trade",
79 Self::VenueOrder => "venue-order",
80 }
81 }
82}
83
84#[derive(Debug, Serialize)]
86#[cfg_attr(
87 feature = "python",
88 pyo3::pyclass(module = "nautilus_trader.backtest", skip_from_py_object)
89)]
90#[cfg_attr(
91 feature = "python",
92 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
93)]
94pub struct BacktestResult {
95 pub trader_id: String,
96 pub machine_id: String,
97 pub instance_id: UUID4,
98 pub run_config_id: Option<String>,
99 pub run_id: Option<UUID4>,
100 pub run_started: Option<UnixNanos>,
101 pub run_finished: Option<UnixNanos>,
102 pub backtest_start: Option<UnixNanos>,
103 pub backtest_end: Option<UnixNanos>,
104 pub elapsed_time_secs: f64,
105 pub iterations: usize,
106 pub total_events: usize,
107 pub total_orders: usize,
108 pub total_positions: usize,
109 pub summary: AHashMap<String, String>,
110 pub stats_pnls: AHashMap<String, AHashMap<String, f64>>,
111 pub stats_returns: AHashMap<String, f64>,
112 pub stats_general: AHashMap<String, f64>,
113 pub returns_series: BTreeMap<UnixNanos, f64>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct CanonicalBacktestResult {
119 document: Value,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct CanonicalResultDivergence {
125 pub path: String,
127 pub expected: Option<Value>,
129 pub actual: Option<Value>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134pub(crate) struct CanonicalDiagnostic {
135 pub code: CanonicalDiagnosticCode,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub(crate) enum CanonicalDiagnosticCode {
141 FundingSettlementFailed,
142 AccountBalanceRejected,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
146#[serde(rename_all = "lowercase")]
147pub(crate) enum CanonicalRunOutcome {
148 Completed,
149 Failed,
150 Incomplete,
151 Stopped,
152}
153
154pub(crate) struct CanonicalBacktestState {
155 pub trader_id: String,
156 pub run_config_id: Option<String>,
157 pub backtest_start: Option<UnixNanos>,
158 pub backtest_end: Option<UnixNanos>,
159 pub iterations: usize,
160 pub total_events: usize,
161 pub total_orders: usize,
162 pub total_positions: usize,
163 pub outcome: CanonicalRunOutcome,
164 pub diagnostics: Vec<CanonicalDiagnostic>,
165 pub trader_state: String,
166 pub actor_ids: Vec<String>,
167 pub strategy_ids: Vec<String>,
168 pub exec_algorithm_ids: Vec<String>,
169 pub summary: BTreeMap<String, String>,
170 pub orders: Vec<OrderAny>,
171 pub positions: Vec<Position>,
172 pub position_snapshots: Vec<Position>,
173 pub accounts: Vec<AccountAny>,
174 pub portfolio_snapshots: Vec<PortfolioSnapshot>,
175 pub statistics: PortfolioStatistics,
176}
177
178impl CanonicalBacktestResult {
179 pub fn from_slice(bytes: &[u8]) -> anyhow::Result<Self> {
189 let document: Value = serde_json::from_slice(bytes)
190 .map_err(|e| anyhow::anyhow!("invalid canonical backtest result JSON: {e}"))?;
191 validate_document(&document)?;
192 let mut normalized = document.clone();
193 canonicalize_document(&mut normalized)?;
194 anyhow::ensure!(
195 normalized == document,
196 "canonical backtest result violates the version 1 encoding rules"
197 );
198 let canonical = serde_json::to_vec(&normalized)?;
199 anyhow::ensure!(
200 canonical == bytes,
201 "canonical backtest result bytes do not use the canonical encoding"
202 );
203 Ok(Self { document })
204 }
205
206 pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
212 Ok(serde_json::to_vec(&self.document)?)
213 }
214
215 pub fn digest(&self) -> anyhow::Result<String> {
221 let bytes = self.to_bytes()?;
222 Ok(format!("blake3:{}", blake3::hash(&bytes).to_hex()))
223 }
224
225 #[must_use]
227 pub const fn as_value(&self) -> &Value {
228 &self.document
229 }
230
231 #[must_use]
233 pub fn first_divergence(&self, actual: &Self) -> Option<CanonicalResultDivergence> {
234 first_divergence(&self.document, &actual.document, String::new())
235 }
236
237 pub(crate) fn from_state(mut state: CanonicalBacktestState) -> anyhow::Result<Self> {
238 state.actor_ids.sort();
239 state.strategy_ids.sort();
240 state.exec_algorithm_ids.sort();
241
242 let orders = state
243 .orders
244 .iter()
245 .map(canonical_order)
246 .collect::<anyhow::Result<Vec<_>>>()?;
247 let fills = canonical_fills(&state.orders)?;
248 let positions = state
249 .positions
250 .iter()
251 .map(canonical_position)
252 .collect::<anyhow::Result<Vec<_>>>()?;
253 let position_snapshots = state
254 .position_snapshots
255 .iter()
256 .map(canonical_position)
257 .collect::<anyhow::Result<Vec<_>>>()?;
258 let accounts = state
259 .accounts
260 .iter()
261 .map(canonical_account)
262 .collect::<anyhow::Result<Vec<_>>>()?;
263 let portfolio_snapshots = state
264 .portfolio_snapshots
265 .iter()
266 .map(canonical_value)
267 .collect::<anyhow::Result<Vec<_>>>()?;
268
269 let mut document = json!({
270 "accounts": accounts,
271 "components": {
272 "actor_ids": state.actor_ids,
273 "exec_algorithm_ids": state.exec_algorithm_ids,
274 "strategy_ids": state.strategy_ids,
275 "trader_state": state.trader_state,
276 },
277 "diagnostics": state.diagnostics,
278 "fills": fills,
279 "orders": orders,
280 "portfolio_snapshots": portfolio_snapshots,
281 "position_snapshots": position_snapshots,
282 "positions": positions,
283 "run": {
284 "backtest_end_ns": optional_nanos(state.backtest_end),
285 "backtest_start_ns": optional_nanos(state.backtest_start),
286 "iterations": state.iterations.to_string(),
287 "outcome": state.outcome,
288 "run_config_id": state.run_config_id,
289 "total_events": state.total_events.to_string(),
290 "total_orders": state.total_orders.to_string(),
291 "total_positions": state.total_positions.to_string(),
292 "trader_id": state.trader_id,
293 },
294 "schema": CANONICAL_SCHEMA,
295 "statistics": canonical_statistics(state.statistics),
296 "summary": state.summary,
297 });
298
299 canonicalize_document(&mut document)?;
300 validate_document(&document)?;
301 Ok(Self { document })
302 }
303}
304
305fn validate_document(document: &Value) -> anyhow::Result<()> {
306 let object = document
307 .as_object()
308 .ok_or_else(|| anyhow::anyhow!("canonical backtest result must be a JSON object"))?;
309 anyhow::ensure!(
310 object.get("schema").and_then(Value::as_str) == Some(CANONICAL_SCHEMA),
311 "unsupported canonical backtest result schema"
312 );
313 let fields = [
314 "accounts",
315 "components",
316 "diagnostics",
317 "fills",
318 "orders",
319 "portfolio_snapshots",
320 "position_snapshots",
321 "positions",
322 "run",
323 "schema",
324 "statistics",
325 "summary",
326 ];
327 validate_fields(object, &fields, "canonical result")?;
328
329 for key in [
330 "accounts",
331 "diagnostics",
332 "fills",
333 "orders",
334 "portfolio_snapshots",
335 "position_snapshots",
336 "positions",
337 ] {
338 anyhow::ensure!(
339 object.get(key).is_some_and(Value::is_array),
340 "canonical result field '{key}' must be an array"
341 );
342 }
343 validate_components(object.get("components").expect("validated field"))?;
344 validate_diagnostics(object.get("diagnostics").expect("validated field"))?;
345 validate_run(object.get("run").expect("validated field"))?;
346 validate_statistics(object.get("statistics").expect("validated field"))?;
347 let summary = object
348 .get("summary")
349 .and_then(Value::as_object)
350 .ok_or_else(|| anyhow::anyhow!("canonical result summary must be an object"))?;
351 anyhow::ensure!(
352 summary.values().all(Value::is_string),
353 "canonical result summary values must be strings"
354 );
355 Ok(())
356}
357
358fn validate_fields(
359 object: &Map<String, Value>,
360 expected: &[&str],
361 context: &str,
362) -> anyhow::Result<()> {
363 let actual = object.keys().map(String::as_str).collect::<BTreeSet<_>>();
364 let expected = expected.iter().copied().collect::<BTreeSet<_>>();
365 anyhow::ensure!(
366 actual == expected,
367 "{context} fields do not match the version 1 schema"
368 );
369 Ok(())
370}
371
372fn validate_components(value: &Value) -> anyhow::Result<()> {
373 let object = value
374 .as_object()
375 .ok_or_else(|| anyhow::anyhow!("canonical result components must be an object"))?;
376 validate_fields(
377 object,
378 &[
379 "actor_ids",
380 "exec_algorithm_ids",
381 "strategy_ids",
382 "trader_state",
383 ],
384 "canonical result components",
385 )?;
386
387 for key in ["actor_ids", "exec_algorithm_ids", "strategy_ids"] {
388 let values = object
389 .get(key)
390 .and_then(Value::as_array)
391 .ok_or_else(|| anyhow::anyhow!("canonical component field '{key}' must be an array"))?;
392 anyhow::ensure!(
393 values.iter().all(Value::is_string),
394 "canonical component field '{key}' must contain strings"
395 );
396 }
397 anyhow::ensure!(
398 object.get("trader_state").is_some_and(Value::is_string),
399 "canonical trader state must be a string"
400 );
401 Ok(())
402}
403
404fn validate_diagnostics(value: &Value) -> anyhow::Result<()> {
405 let diagnostics = value
406 .as_array()
407 .ok_or_else(|| anyhow::anyhow!("canonical diagnostics must be an array"))?;
408 for diagnostic in diagnostics {
409 let object = diagnostic
410 .as_object()
411 .ok_or_else(|| anyhow::anyhow!("canonical diagnostic must be an object"))?;
412 validate_fields(object, &["code"], "canonical diagnostic")?;
413 anyhow::ensure!(
414 matches!(
415 object.get("code").and_then(Value::as_str),
416 Some("funding-settlement-failed" | "account-balance-rejected")
417 ),
418 "unsupported canonical diagnostic code"
419 );
420 }
421 Ok(())
422}
423
424fn validate_run(value: &Value) -> anyhow::Result<()> {
425 let object = value
426 .as_object()
427 .ok_or_else(|| anyhow::anyhow!("canonical result run must be an object"))?;
428 validate_fields(
429 object,
430 &[
431 "backtest_end_ns",
432 "backtest_start_ns",
433 "iterations",
434 "outcome",
435 "run_config_id",
436 "total_events",
437 "total_orders",
438 "total_positions",
439 "trader_id",
440 ],
441 "canonical result run",
442 )?;
443
444 for key in [
445 "iterations",
446 "total_events",
447 "total_orders",
448 "total_positions",
449 ] {
450 validate_unsigned_decimal(object.get(key).expect("validated field"), key, false)?;
451 }
452
453 for key in ["backtest_start_ns", "backtest_end_ns"] {
454 validate_unsigned_decimal(object.get(key).expect("validated field"), key, true)?;
455 }
456 anyhow::ensure!(
457 object
458 .get("run_config_id")
459 .is_some_and(|value| value.is_null() || value.is_string()),
460 "canonical run configuration ID must be a string or null"
461 );
462 anyhow::ensure!(
463 object.get("trader_id").is_some_and(Value::is_string),
464 "canonical trader ID must be a string"
465 );
466 anyhow::ensure!(
467 matches!(
468 object.get("outcome").and_then(Value::as_str),
469 Some("completed" | "failed" | "incomplete" | "stopped")
470 ),
471 "unsupported canonical run outcome"
472 );
473 Ok(())
474}
475
476fn validate_unsigned_decimal(value: &Value, field: &str, nullable: bool) -> anyhow::Result<()> {
477 if nullable && value.is_null() {
478 return Ok(());
479 }
480 let value = value
481 .as_str()
482 .ok_or_else(|| anyhow::anyhow!("canonical run field '{field}' must be a decimal string"))?;
483 anyhow::ensure!(
484 value == "0"
485 || (!value.is_empty()
486 && !value.starts_with('0')
487 && value.bytes().all(|byte| byte.is_ascii_digit())),
488 "canonical run field '{field}' is not a canonical unsigned decimal"
489 );
490 Ok(())
491}
492
493fn validate_statistics(value: &Value) -> anyhow::Result<()> {
494 let object = value
495 .as_object()
496 .ok_or_else(|| anyhow::anyhow!("canonical result statistics must be an object"))?;
497 validate_fields(
498 object,
499 &["general", "pnls", "returns", "returns_series"],
500 "canonical result statistics",
501 )?;
502
503 for key in ["general", "pnls", "returns"] {
504 anyhow::ensure!(
505 object.get(key).is_some_and(Value::is_object),
506 "canonical statistics field '{key}' must be an object"
507 );
508 }
509 anyhow::ensure!(
510 object.get("returns_series").is_some_and(Value::is_array),
511 "canonical returns series must be an array"
512 );
513 Ok(())
514}
515
516fn optional_nanos(value: Option<UnixNanos>) -> Value {
517 value.map_or(Value::Null, |nanos| Value::String(nanos.to_string()))
518}
519
520fn canonical_order(order: &OrderAny) -> anyhow::Result<Value> {
521 let mut value = serde_json::to_value(order)?;
522 let payload = variant_payload_mut(&mut value)?;
523 let core = payload
524 .get_mut("core")
525 .and_then(Value::as_object_mut)
526 .ok_or_else(|| anyhow::anyhow!("serialized order did not contain an object core"))?;
527 set_decimal(core, "avg_px", order.avg_px());
528 set_decimal(core, "slippage", order.slippage());
529
530 if let Some(events) = core.get_mut("events").and_then(Value::as_array_mut) {
531 for (source, encoded) in order.events().into_iter().zip(events) {
532 patch_order_event_decimals(source, encoded)?;
533 }
534 }
535 set_decimal_if_present(payload, "limit_offset", order.limit_offset());
536 set_decimal_if_present(payload, "trailing_offset", order.trailing_offset());
537 canonicalize_value(&mut value)?;
538 Ok(value)
539}
540
541fn canonical_fills(orders: &[OrderAny]) -> anyhow::Result<Vec<Value>> {
542 let mut fills = Vec::new();
543
544 for order in orders {
545 for (ordinal, event) in order.events().into_iter().enumerate() {
546 if !matches!(event, OrderEventAny::Filled(_)) {
547 continue;
548 }
549 let mut encoded = serde_json::to_value(event)?;
550 canonicalize_value(&mut encoded)?;
551 fills.push(json!({
552 "client_order_id": order.client_order_id().to_string(),
553 "event": encoded,
554 "order_event_ordinal": ordinal.to_string(),
555 }));
556 }
557 }
558 Ok(fills)
559}
560
561fn canonical_position(position: &Position) -> anyhow::Result<Value> {
562 let mut value = serde_json::to_value(position)?;
563 let object = value
564 .as_object_mut()
565 .ok_or_else(|| anyhow::anyhow!("serialized position was not an object"))?;
566 anyhow::ensure!(
567 object.remove("id").is_some(),
568 "serialized position did not contain its identifier"
569 );
570 object.insert(
571 "position_id".to_string(),
572 Value::String(position.id.to_string()),
573 );
574 set_f64(object, "avg_px_close", position.avg_px_close);
575 set_f64(object, "avg_px_open", Some(position.avg_px_open));
576 set_f64(object, "realized_return", Some(position.realized_return));
577 set_f64(object, "signed_qty", Some(position.signed_qty));
578
579 if let Some(adjustments) = object.get_mut("adjustments").and_then(Value::as_array_mut) {
580 for (source, encoded) in position.adjustments.iter().zip(adjustments) {
581 patch_position_adjustment(source, encoded)?;
582 }
583 }
584
585 if let Some(events) = object
586 .get_mut("replay_events")
587 .and_then(Value::as_array_mut)
588 {
589 for (source, encoded) in position.replay_events.iter().zip(events) {
590 if let PositionReplayEvent::Adjusted(adjustment) = source {
591 set_decimal(
592 variant_payload_mut(encoded)?,
593 "quantity_change",
594 adjustment.quantity_change,
595 );
596 }
597 }
598 }
599 canonicalize_value(&mut value)?;
600 Ok(value)
601}
602
603fn canonical_account(account: &AccountAny) -> anyhow::Result<Value> {
604 let mut value = serde_json::to_value(account)?;
605 let payload = variant_payload_mut(&mut value)?;
606
607 match account {
608 AccountAny::Margin(margin) => {
609 let leverages = margin
610 .leverages
611 .iter()
612 .map(|(instrument_id, leverage)| {
613 (
614 instrument_id.to_string(),
615 Value::String(canonical_decimal(*leverage)),
616 )
617 })
618 .collect::<Map<_, _>>();
619 let margin_model = margin.margin_model().name();
620 payload.insert(
621 "default_leverage".to_string(),
622 Value::String(canonical_decimal(margin.default_leverage)),
623 );
624 payload.insert("leverages".to_string(), Value::Object(leverages));
625 payload.insert(
626 "margin_model".to_string(),
627 Value::String(margin_model.to_string()),
628 );
629 }
630 AccountAny::Cash(cash) => {
631 payload.insert(
632 "balances_locked_transient".to_string(),
633 locked_balances(&cash.balances_locked),
634 );
635 }
636 AccountAny::Betting(betting) => {
637 payload.insert(
638 "balances_locked_transient".to_string(),
639 locked_balances(&betting.balances_locked),
640 );
641 }
642 AccountAny::Wallet(wallet) => {
643 payload.insert(
644 "balances_locked_transient".to_string(),
645 locked_balances(&wallet.balances_locked),
646 );
647 }
648 }
649 canonicalize_value(&mut value)?;
650 Ok(value)
651}
652
653fn locked_balances(balances: &AHashMap<(InstrumentId, Currency), Money>) -> Value {
654 let mut values = balances
655 .iter()
656 .map(|((instrument_id, currency), money)| {
657 json!({
658 "currency": currency.code.to_string(),
659 "instrument_id": instrument_id.to_string(),
660 "money": money.to_string(),
661 })
662 })
663 .collect::<Vec<_>>();
664 values.sort_by_cached_key(|value| canonical_sort_key(value, true));
665 Value::Array(values)
666}
667
668fn canonical_statistics(statistics: PortfolioStatistics) -> Value {
669 let pnls = statistics
670 .pnls
671 .into_iter()
672 .map(|(currency, values)| (currency, canonical_f64_map(values)))
673 .collect::<Map<_, _>>();
674 let returns_series = statistics
675 .returns_series
676 .into_iter()
677 .map(|(timestamp, value)| {
678 json!({
679 "timestamp_ns": timestamp.to_string(),
680 "value": canonical_f64(value),
681 })
682 })
683 .collect::<Vec<_>>();
684 json!({
685 "general": canonical_f64_map(statistics.general),
686 "pnls": pnls,
687 "returns": canonical_f64_map(statistics.returns),
688 "returns_series": returns_series,
689 })
690}
691
692fn canonical_f64_map(values: AHashMap<String, f64>) -> Value {
693 Value::Object(
694 values
695 .into_iter()
696 .map(|(name, value)| (name, Value::String(canonical_f64(value))))
697 .collect(),
698 )
699}
700
701fn canonical_f64(value: f64) -> String {
702 if value.is_nan() {
703 "nan".to_string()
704 } else if value == f64::INFINITY {
705 "+inf".to_string()
706 } else if value == f64::NEG_INFINITY {
707 "-inf".to_string()
708 } else {
709 format!("{:016x}", value.to_bits())
710 }
711}
712
713fn canonical_decimal(value: rust_decimal::Decimal) -> String {
714 value.normalize().to_string()
715}
716
717fn set_f64(object: &mut Map<String, Value>, key: &str, value: Option<f64>) {
718 object.insert(
719 key.to_string(),
720 value.map_or(Value::Null, |value| Value::String(canonical_f64(value))),
721 );
722}
723
724fn set_decimal(object: &mut Map<String, Value>, key: &str, value: Option<rust_decimal::Decimal>) {
725 object.insert(
726 key.to_string(),
727 value.map_or(Value::Null, |value| Value::String(canonical_decimal(value))),
728 );
729}
730
731fn set_decimal_if_present(
732 object: &mut Map<String, Value>,
733 key: &str,
734 value: Option<rust_decimal::Decimal>,
735) {
736 if object.contains_key(key) {
737 set_decimal(object, key, value);
738 }
739}
740
741fn patch_order_event_decimals(event: &OrderEventAny, value: &mut Value) -> anyhow::Result<()> {
742 if let OrderEventAny::Initialized(initialized) = event {
743 let payload = variant_payload_mut(value)?;
744 set_decimal(payload, "limit_offset", initialized.limit_offset);
745 set_decimal(payload, "trailing_offset", initialized.trailing_offset);
746 }
747 Ok(())
748}
749
750fn patch_position_adjustment(
751 adjustment: &PositionAdjusted,
752 value: &mut Value,
753) -> anyhow::Result<()> {
754 let object = value
755 .as_object_mut()
756 .ok_or_else(|| anyhow::anyhow!("serialized position adjustment was not an object"))?;
757 set_decimal(object, "quantity_change", adjustment.quantity_change);
758 Ok(())
759}
760
761fn variant_payload_mut(value: &mut Value) -> anyhow::Result<&mut Map<String, Value>> {
762 let variant = value
763 .as_object_mut()
764 .and_then(|object| object.values_mut().next())
765 .and_then(Value::as_object_mut)
766 .ok_or_else(|| anyhow::anyhow!("serialized enum variant was not an object"))?;
767 Ok(variant)
768}
769
770fn canonical_value<T: Serialize>(source: &T) -> anyhow::Result<Value> {
771 let mut value = serde_json::to_value(source)?;
772 canonicalize_value(&mut value)?;
773 Ok(value)
774}
775
776fn canonicalize_value(value: &mut Value) -> anyhow::Result<()> {
777 value.sort_all_objects();
778 sort_named_arrays(value, false);
779 stringify_numbers(value)?;
780 Ok(())
781}
782
783fn canonicalize_document(value: &mut Value) -> anyhow::Result<()> {
784 value.sort_all_objects();
785 sort_named_arrays(value, false);
786 normalize_identities(value);
787 sort_named_arrays(value, true);
788 stringify_numbers(value)
789}
790
791fn sort_named_arrays(value: &mut Value, identities_normalized: bool) {
792 match value {
793 Value::Array(values) => {
794 for value in values {
795 sort_named_arrays(value, identities_normalized);
796 }
797 }
798 Value::Object(object) => {
799 for (key, value) in object {
800 sort_named_arrays(value, identities_normalized);
801 if UNORDERED_ARRAY_KEYS.contains(&key.as_str())
802 && let Value::Array(values) = value
803 && (identities_normalized || identity_array_class(key).is_none())
804 {
805 values.sort_by_cached_key(|value| {
806 canonical_sort_key(value, !identities_normalized)
807 });
808 }
809 }
810 }
811 _ => {}
812 }
813}
814
815fn canonical_sort_key(value: &Value, strip_identity_fields: bool) -> Vec<u8> {
816 let mut value = value.clone();
817 if strip_identity_fields {
818 strip_identities(&mut value, false);
819 }
820 serde_json::to_vec(&value).expect("serializing a JSON value cannot fail")
821}
822
823fn strip_identities(value: &mut Value, metadata: bool) {
824 match value {
825 Value::Array(values) => {
826 for value in values {
827 strip_identities(value, metadata);
828 }
829 }
830 Value::Object(object) => {
831 if !metadata {
832 object.retain(|key, _| {
833 identity_scalar_class(key).is_none() && identity_array_class(key).is_none()
834 });
835 }
836
837 for (key, value) in object {
838 strip_identities(value, metadata || METADATA_KEYS.contains(&key.as_str()));
839 }
840 }
841 _ => {}
842 }
843}
844
845fn normalize_identities(document: &mut Value) {
846 let mut identities = BTreeMap::<IdentityClass, BTreeMap<String, String>>::new();
847 collect_primary_identities(document, false, &mut identities);
848 let mut next_external = BTreeMap::<IdentityClass, usize>::new();
849 replace_identities(document, false, &mut identities, &mut next_external);
850}
851
852fn collect_primary_identities(
853 value: &Value,
854 metadata: bool,
855 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
856) {
857 match value {
858 Value::Array(values) => {
859 for value in values {
860 collect_primary_identities(value, metadata, identities);
861 }
862 }
863 Value::Object(object) => {
864 if !metadata {
865 for (key, value) in object {
866 let Some(class) = primary_identity_class(key) else {
867 continue;
868 };
869 let Some(identity) = value.as_str() else {
870 continue;
871 };
872 let class_identities = identities.entry(class).or_default();
873 let next = class_identities.len() + 1;
874 class_identities
875 .entry(identity.to_string())
876 .or_insert_with(|| format!("{}-{next}", class.prefix()));
877 }
878 }
879
880 for (key, value) in object {
881 collect_primary_identities(
882 value,
883 metadata || METADATA_KEYS.contains(&key.as_str()),
884 identities,
885 );
886 }
887 }
888 _ => {}
889 }
890}
891
892fn replace_identities(
893 value: &mut Value,
894 metadata: bool,
895 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
896 next_external: &mut BTreeMap<IdentityClass, usize>,
897) {
898 match value {
899 Value::Array(values) => {
900 for value in values {
901 replace_identities(value, metadata, identities, next_external);
902 }
903 }
904 Value::Object(object) => {
905 if !metadata {
906 for (key, value) in object.iter_mut() {
907 if let Some(class) = identity_scalar_class(key) {
908 replace_identity(value, class, identities, next_external);
909 } else if let Some(class) = identity_array_class(key)
910 && let Value::Array(values) = value
911 {
912 for value in values {
913 replace_identity(value, class, identities, next_external);
914 }
915 }
916 }
917 }
918
919 for (key, value) in object {
920 replace_identities(
921 value,
922 metadata || METADATA_KEYS.contains(&key.as_str()),
923 identities,
924 next_external,
925 );
926 }
927 }
928 _ => {}
929 }
930}
931
932fn replace_identity(
933 value: &mut Value,
934 class: IdentityClass,
935 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
936 next_external: &mut BTreeMap<IdentityClass, usize>,
937) {
938 let Some(raw) = value.as_str() else {
939 return;
940 };
941 let class_identities = identities.entry(class).or_default();
942 let token = class_identities.entry(raw.to_string()).or_insert_with(|| {
943 let next = next_external.entry(class).or_default();
944 *next += 1;
945 format!("{}-external-{next}", class.prefix())
946 });
947 *value = Value::String(token.clone());
948}
949
950fn primary_identity_class(key: &str) -> Option<IdentityClass> {
951 match key {
952 "client_order_id" => Some(IdentityClass::ClientOrder),
953 "event_id" => Some(IdentityClass::Event),
954 "order_list_id" => Some(IdentityClass::OrderList),
955 "position_id" => Some(IdentityClass::Position),
956 "trade_id" => Some(IdentityClass::Trade),
957 "venue_order_id" => Some(IdentityClass::VenueOrder),
958 _ => None,
959 }
960}
961
962fn identity_scalar_class(key: &str) -> Option<IdentityClass> {
963 match key {
964 "client_order_id" | "closing_order_id" | "exec_spawn_id" | "opening_order_id"
965 | "parent_order_id" => Some(IdentityClass::ClientOrder),
966 "causation_id" | "event_id" | "init_id" => Some(IdentityClass::Event),
967 "order_list_id" => Some(IdentityClass::OrderList),
968 "position_id" => Some(IdentityClass::Position),
969 "last_trade_id" | "trade_id" => Some(IdentityClass::Trade),
970 "venue_order_id" => Some(IdentityClass::VenueOrder),
971 _ => None,
972 }
973}
974
975fn identity_array_class(key: &str) -> Option<IdentityClass> {
976 match key {
977 "linked_order_ids" => Some(IdentityClass::ClientOrder),
978 "trade_ids" => Some(IdentityClass::Trade),
979 "venue_order_ids" => Some(IdentityClass::VenueOrder),
980 _ => None,
981 }
982}
983
984fn stringify_numbers(value: &mut Value) -> anyhow::Result<()> {
985 match value {
986 Value::Array(values) => {
987 for value in values {
988 stringify_numbers(value)?;
989 }
990 }
991 Value::Object(object) => {
992 for value in object.values_mut() {
993 stringify_numbers(value)?;
994 }
995 }
996 Value::Number(number) => {
997 anyhow::ensure!(
998 number.is_i64() || number.is_u64(),
999 "canonical projection contains an unencoded floating-point value"
1000 );
1001 *value = Value::String(number.to_string());
1002 }
1003 _ => {}
1004 }
1005 Ok(())
1006}
1007
1008fn first_divergence(
1009 expected: &Value,
1010 actual: &Value,
1011 path: String,
1012) -> Option<CanonicalResultDivergence> {
1013 match (expected, actual) {
1014 (Value::Object(expected), Value::Object(actual)) => {
1015 let keys = expected
1016 .keys()
1017 .chain(actual.keys())
1018 .cloned()
1019 .collect::<BTreeSet<_>>();
1020
1021 for key in keys {
1022 let next_path = format!("{path}/{}", escape_pointer_token(&key));
1023 match (expected.get(&key), actual.get(&key)) {
1024 (Some(expected), Some(actual)) => {
1025 if let Some(divergence) = first_divergence(expected, actual, next_path) {
1026 return Some(divergence);
1027 }
1028 }
1029 (expected, actual) => {
1030 return Some(CanonicalResultDivergence {
1031 path: next_path,
1032 expected: expected.cloned(),
1033 actual: actual.cloned(),
1034 });
1035 }
1036 }
1037 }
1038 None
1039 }
1040 (Value::Array(expected), Value::Array(actual)) => {
1041 let len = expected.len().max(actual.len());
1042 for index in 0..len {
1043 let next_path = format!("{path}/{index}");
1044 match (expected.get(index), actual.get(index)) {
1045 (Some(expected), Some(actual)) => {
1046 if let Some(divergence) = first_divergence(expected, actual, next_path) {
1047 return Some(divergence);
1048 }
1049 }
1050 (expected, actual) => {
1051 return Some(CanonicalResultDivergence {
1052 path: next_path,
1053 expected: expected.cloned(),
1054 actual: actual.cloned(),
1055 });
1056 }
1057 }
1058 }
1059 None
1060 }
1061 _ if expected == actual => None,
1062 _ => Some(CanonicalResultDivergence {
1063 path,
1064 expected: Some(expected.clone()),
1065 actual: Some(actual.clone()),
1066 }),
1067 }
1068}
1069
1070fn escape_pointer_token(token: &str) -> String {
1071 token.replace('~', "~0").replace('/', "~1")
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use ahash::AHashMap;
1077 use nautilus_model::{
1078 accounts::{
1079 MarginAccount, WalletAccount,
1080 margin_model::{LeveragedMarginModel, MarginModelAny, StandardMarginModel},
1081 },
1082 enums::{AccountType, OrderType, TrailingOffsetType},
1083 events::AccountState,
1084 identifiers::{AccountId, InstrumentId},
1085 orders::OrderTestBuilder,
1086 types::{AccountBalance, Quantity},
1087 };
1088 use rstest::rstest;
1089 use rust_decimal::Decimal;
1090 use serde_json::json;
1091
1092 use super::*;
1093
1094 #[rstest]
1095 fn test_backtest_result_serializes_to_json() {
1096 let instance_id = UUID4::from("11111111-1111-4111-8111-111111111111");
1097 let run_id = UUID4::from("22222222-2222-4222-8222-222222222222");
1098 let mut summary = AHashMap::new();
1099 summary.insert("PnL (total)".to_string(), "10.00 USD".to_string());
1100 let mut usd_pnls = AHashMap::new();
1101 usd_pnls.insert("Returns Volatility (252 days)".to_string(), 1.25);
1102 let mut stats_pnls = AHashMap::new();
1103 stats_pnls.insert("USD".to_string(), usd_pnls);
1104 let mut stats_returns = AHashMap::new();
1105 stats_returns.insert("Sharpe Ratio (252 days)".to_string(), 0.75);
1106 let mut stats_general = AHashMap::new();
1107 stats_general.insert("Long Ratio".to_string(), 1.0);
1108
1109 let result = BacktestResult {
1110 trader_id: "TRADER-001".to_string(),
1111 machine_id: "machine-1".to_string(),
1112 instance_id,
1113 run_config_id: Some("config-1".to_string()),
1114 run_id: Some(run_id),
1115 run_started: Some(UnixNanos::new(1)),
1116 run_finished: Some(UnixNanos::new(2)),
1117 backtest_start: Some(UnixNanos::new(3)),
1118 backtest_end: Some(UnixNanos::new(4)),
1119 elapsed_time_secs: 1.5,
1120 iterations: 10,
1121 total_events: 20,
1122 total_orders: 2,
1123 total_positions: 1,
1124 summary,
1125 stats_pnls,
1126 stats_returns,
1127 stats_general,
1128 returns_series: BTreeMap::from([(UnixNanos::new(3), 0.25)]),
1129 };
1130
1131 let value = serde_json::to_value(&result).unwrap();
1132
1133 assert_eq!(value["trader_id"], json!("TRADER-001"));
1134 assert_eq!(value["machine_id"], json!("machine-1"));
1135 assert_eq!(value["instance_id"], json!(instance_id.to_string()));
1136 assert_eq!(value["run_id"], json!(run_id.to_string()));
1137 assert_eq!(value["run_started"], json!(1));
1138 assert_eq!(value["backtest_end"], json!(4));
1139 assert_eq!(value["elapsed_time_secs"], json!(1.5));
1140 assert_eq!(value["iterations"], json!(10));
1141 assert_eq!(value["summary"]["PnL (total)"], json!("10.00 USD"));
1142 assert_eq!(
1143 value["stats_pnls"]["USD"]["Returns Volatility (252 days)"],
1144 json!(1.25)
1145 );
1146 assert_eq!(
1147 value["stats_returns"]["Sharpe Ratio (252 days)"],
1148 json!(0.75)
1149 );
1150 assert_eq!(value["stats_general"]["Long Ratio"], json!(1.0));
1151 assert_eq!(value["returns_series"]["3"], json!(0.25));
1152 }
1153
1154 #[rstest]
1155 fn test_canonical_account_wallet_includes_transient_locks() {
1156 let eth = Currency::ETH();
1157 let state = AccountState::new(
1158 AccountId::from("WALLET-001"),
1159 AccountType::Wallet,
1160 vec![AccountBalance::new(
1161 Money::new(10.0, eth),
1162 Money::zero(eth),
1163 Money::new(10.0, eth),
1164 )],
1165 vec![],
1166 true,
1167 UUID4::new(),
1168 UnixNanos::default(),
1169 UnixNanos::default(),
1170 None,
1171 );
1172 let mut account = WalletAccount::new(state, true);
1173 let instrument_id = InstrumentId::from("WETHUSDC.BLOCKCHAIN");
1174 account
1175 .update_balance_locked(instrument_id, Money::new(2.0, eth))
1176 .unwrap();
1177
1178 let value = canonical_account(&AccountAny::Wallet(account)).unwrap();
1179
1180 let payload = value.get("Wallet").unwrap();
1181 let locks = payload["balances_locked_transient"].as_array().unwrap();
1182 assert_eq!(locks.len(), 1);
1183 assert_eq!(locks[0]["currency"], "ETH");
1184 assert_eq!(locks[0]["instrument_id"], "WETHUSDC.BLOCKCHAIN");
1185 assert_eq!(locks[0]["money"], "2.00000000 ETH");
1186 }
1187
1188 #[rstest]
1189 fn test_canonical_margin_account_preserves_builtin_model_names() {
1190 let state = AccountState::new(
1191 AccountId::from("SIM-001"),
1192 AccountType::Margin,
1193 vec![AccountBalance::new(
1194 Money::from("1_000_000 USD"),
1195 Money::zero(Currency::USD()),
1196 Money::from("1_000_000 USD"),
1197 )],
1198 vec![],
1199 true,
1200 UUID4::new(),
1201 UnixNanos::default(),
1202 UnixNanos::default(),
1203 None,
1204 );
1205 let mut account = MarginAccount::new(state, true);
1206
1207 for (model, expected_name) in [
1208 (MarginModelAny::Standard(StandardMarginModel), "standard"),
1209 (MarginModelAny::Leveraged(LeveragedMarginModel), "leveraged"),
1210 ] {
1211 account.set_margin_model(model.into());
1212 let value = canonical_account(&AccountAny::Margin(account.clone())).unwrap();
1213
1214 assert_eq!(value["Margin"]["margin_model"], expected_name);
1215 }
1216 }
1217
1218 #[rstest]
1219 fn test_canonical_f64_encodes_finite_and_non_finite_values() {
1220 let nan_with_payload = f64::from_bits(0x7ff8_0000_0000_0042);
1221
1222 assert_eq!(canonical_f64(1.5), "3ff8000000000000");
1223 assert_eq!(canonical_f64(-0.0), "8000000000000000");
1224 assert_eq!(canonical_f64(f64::NAN), "nan");
1225 assert_eq!(canonical_f64(nan_with_payload), "nan");
1226 assert_eq!(canonical_f64(f64::INFINITY), "+inf");
1227 assert_eq!(canonical_f64(f64::NEG_INFINITY), "-inf");
1228 }
1229
1230 #[rstest]
1231 fn test_canonical_decimal_removes_redundant_scale() {
1232 let value = "001.2300".parse().unwrap();
1233
1234 assert_eq!(canonical_decimal(value), "1.23");
1235 }
1236
1237 #[rstest]
1238 fn test_canonical_order_normalizes_core_and_event_decimals() {
1239 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
1240 .instrument_id(InstrumentId::from("AUDUSD.SIM"))
1241 .quantity(Quantity::from(100_000))
1242 .limit_offset(Decimal::new(12_300, 4))
1243 .trailing_offset(Decimal::new(45_600, 4))
1244 .trailing_offset_type(TrailingOffsetType::Price)
1245 .build();
1246
1247 let value = canonical_order(&order).unwrap();
1248 let payload = value["TrailingStopLimit"].as_object().unwrap();
1249 let core = payload["core"].as_object().unwrap();
1250 let initialized = &core["events"][0]["Initialized"];
1251
1252 assert!(!payload.contains_key("avg_px"));
1253 assert!(!payload.contains_key("slippage"));
1254 assert_eq!(payload["limit_offset"], "1.23");
1255 assert_eq!(payload["trailing_offset"], "4.56");
1256 assert_eq!(core["avg_px"], Value::Null);
1257 assert_eq!(core["slippage"], Value::Null);
1258 assert_eq!(initialized["limit_offset"], "1.23");
1259 assert_eq!(initialized["trailing_offset"], "4.56");
1260 }
1261
1262 #[rstest]
1263 fn test_normalize_identities_preserves_event_relationships() {
1264 let mut first = json!({
1265 "events": [
1266 {"event_id": "11111111-1111-4111-8111-111111111111"},
1267 {
1268 "causation_id": "11111111-1111-4111-8111-111111111111",
1269 "event_id": "22222222-2222-4222-8222-222222222222",
1270 "init_id": "22222222-2222-4222-8222-222222222222"
1271 }
1272 ]
1273 });
1274 let mut repeated = json!({
1275 "events": [
1276 {"event_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
1277 {
1278 "causation_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
1279 "event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
1280 "init_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
1281 }
1282 ]
1283 });
1284
1285 normalize_identities(&mut first);
1286 normalize_identities(&mut repeated);
1287
1288 assert_eq!(first, repeated);
1289 assert_eq!(first["events"][0]["event_id"], "event-1");
1290 assert_eq!(first["events"][1]["event_id"], "event-2");
1291 assert_eq!(first["events"][1]["init_id"], "event-2");
1292 assert_eq!(first["events"][1]["causation_id"], "event-1");
1293 }
1294
1295 #[rstest]
1296 fn test_canonicalize_document_normalizes_random_domain_identities() {
1297 let mut first = test_document();
1298 first["fills"] = json!([
1299 {
1300 "client_order_id": "11111111-1111-4111-8111-111111111111",
1301 "event": {
1302 "Filled": {
1303 "event_id": "22222222-2222-4222-8222-222222222222",
1304 "position_id": "33333333-3333-4333-8333-333333333333",
1305 "trade_id": "44444444-4444-4444-8444-444444444444",
1306 "venue_order_id": "55555555-5555-4555-8555-555555555555"
1307 }
1308 },
1309 "order_event_ordinal": "1"
1310 }
1311 ]);
1312 first["orders"] = json!([
1313 {
1314 "Market": {
1315 "core": {
1316 "client_order_id": "11111111-1111-4111-8111-111111111111",
1317 "position_id": "33333333-3333-4333-8333-333333333333",
1318 "trade_ids": ["44444444-4444-4444-8444-444444444444"],
1319 "venue_order_id": "55555555-5555-4555-8555-555555555555"
1320 }
1321 }
1322 }
1323 ]);
1324 first["positions"] = json!([
1325 {
1326 "position_id": "33333333-3333-4333-8333-333333333333"
1327 }
1328 ]);
1329 let mut repeated = first.clone();
1330 replace_test_identity(&mut repeated["fills"]);
1331 replace_test_identity(&mut repeated["orders"]);
1332 replace_test_identity(&mut repeated["positions"]);
1333
1334 canonicalize_document(&mut first).unwrap();
1335 canonicalize_document(&mut repeated).unwrap();
1336
1337 assert_eq!(first, repeated);
1338 assert_eq!(first["fills"][0]["client_order_id"], "client-order-1");
1339 assert_eq!(first["fills"][0]["event"]["Filled"]["event_id"], "event-1");
1340 assert_eq!(
1341 first["fills"][0]["event"]["Filled"]["position_id"],
1342 "position-1"
1343 );
1344 assert_eq!(first["fills"][0]["event"]["Filled"]["trade_id"], "trade-1");
1345 assert_eq!(
1346 first["fills"][0]["event"]["Filled"]["venue_order_id"],
1347 "venue-order-1"
1348 );
1349 assert_eq!(first["positions"][0]["position_id"], "position-1");
1350 }
1351
1352 #[rstest]
1353 fn test_canonicalize_document_orders_records_by_normalized_identity() {
1354 let mut first = test_document();
1355 first["fills"] = json!([
1356 {
1357 "event": {
1358 "Filled": {
1359 "position_id": "11111111-1111-4111-8111-111111111111"
1360 }
1361 }
1362 },
1363 {
1364 "event": {
1365 "Filled": {
1366 "position_id": "22222222-2222-4222-8222-222222222222"
1367 }
1368 }
1369 }
1370 ]);
1371 first["positions"] = json!([
1372 {
1373 "position_id": "11111111-1111-4111-8111-111111111111",
1374 "side": "LONG"
1375 },
1376 {
1377 "position_id": "22222222-2222-4222-8222-222222222222",
1378 "side": "LONG"
1379 }
1380 ]);
1381 let mut reordered = first.clone();
1382 reordered["positions"].as_array_mut().unwrap().reverse();
1383
1384 canonicalize_document(&mut first).unwrap();
1385 canonicalize_document(&mut reordered).unwrap();
1386
1387 assert_eq!(first, reordered);
1388 assert_eq!(reordered["positions"][0]["position_id"], "position-1");
1389 assert_eq!(reordered["positions"][1]["position_id"], "position-2");
1390 }
1391
1392 #[rstest]
1393 fn test_canonical_result_reports_first_divergence_with_escaped_pointer() {
1394 let mut expected_document = test_document();
1395 expected_document["summary"] = json!({"order/price~open": "1.00000"});
1396 let mut actual_document = expected_document.clone();
1397 actual_document["summary"]["order/price~open"] = json!("1.00001");
1398 let expected = CanonicalBacktestResult {
1399 document: expected_document,
1400 };
1401 let actual = CanonicalBacktestResult {
1402 document: actual_document,
1403 };
1404
1405 let divergence = expected.first_divergence(&actual).unwrap();
1406
1407 assert_eq!(divergence.path, "/summary/order~1price~0open");
1408 assert_eq!(divergence.expected, Some(json!("1.00000")));
1409 assert_eq!(divergence.actual, Some(json!("1.00001")));
1410 }
1411
1412 #[rstest]
1413 fn test_canonical_result_reports_missing_record() {
1414 let mut expected_document = test_document();
1415 expected_document["fills"] = json!([{"trade_id": "T-001"}]);
1416 let expected = CanonicalBacktestResult {
1417 document: expected_document,
1418 };
1419 let actual = CanonicalBacktestResult {
1420 document: test_document(),
1421 };
1422
1423 let divergence = expected.first_divergence(&actual).unwrap();
1424
1425 assert_eq!(divergence.path, "/fills/0");
1426 assert_eq!(divergence.expected, Some(json!({"trade_id": "T-001"})));
1427 assert_eq!(divergence.actual, None);
1428 }
1429
1430 #[rstest]
1431 fn test_canonical_result_rejects_non_canonical_bytes() {
1432 let document = test_document();
1433 let canonical = serde_json::to_vec(&document).unwrap();
1434 let pretty = serde_json::to_vec_pretty(&document).unwrap();
1435
1436 let result = CanonicalBacktestResult::from_slice(&canonical).unwrap();
1437 let error = CanonicalBacktestResult::from_slice(&pretty).unwrap_err();
1438
1439 assert_eq!(result.as_value(), &document);
1440 assert_eq!(
1441 error.to_string(),
1442 "canonical backtest result bytes do not use the canonical encoding"
1443 );
1444 }
1445
1446 #[rstest]
1447 fn test_canonical_result_rejects_wrong_schema() {
1448 let mut document = test_document();
1449 document["schema"] = json!("nautilus-backtest-result/v2");
1450
1451 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1452 .unwrap_err();
1453
1454 assert_eq!(
1455 error.to_string(),
1456 "unsupported canonical backtest result schema"
1457 );
1458 }
1459
1460 #[rstest]
1461 fn test_canonical_result_rejects_non_v1_fields() {
1462 let mut document = test_document();
1463 document["unexpected"] = Value::Null;
1464
1465 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1466 .unwrap_err();
1467
1468 assert_eq!(
1469 error.to_string(),
1470 "canonical result fields do not match the version 1 schema"
1471 );
1472 }
1473
1474 #[rstest]
1475 fn test_canonical_result_rejects_non_v1_numeric_encoding() {
1476 let mut document = test_document();
1477 document["run"]["iterations"] = json!(1);
1478
1479 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1480 .unwrap_err();
1481
1482 assert_eq!(
1483 error.to_string(),
1484 "canonical run field 'iterations' must be a decimal string"
1485 );
1486 }
1487
1488 #[rstest]
1489 fn test_canonical_result_rejects_non_v1_collection_order() {
1490 let mut document = test_document();
1491 document["components"]["actor_ids"] = json!(["ACTOR-002", "ACTOR-001"]);
1492
1493 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1494 .unwrap_err();
1495
1496 assert_eq!(
1497 error.to_string(),
1498 "canonical backtest result violates the version 1 encoding rules"
1499 );
1500 }
1501
1502 #[rstest]
1503 fn test_canonical_result_digest_covers_exact_bytes() {
1504 let first = CanonicalBacktestResult {
1505 document: test_document(),
1506 };
1507 let mut changed_document = test_document();
1508 changed_document["run"]["iterations"] = json!("2");
1509 let changed = CanonicalBacktestResult {
1510 document: changed_document,
1511 };
1512
1513 let digest = first.digest().unwrap();
1514 let changed_digest = changed.digest().unwrap();
1515
1516 assert_eq!(digest.len(), "blake3:".len() + 64);
1517 assert!(digest.starts_with("blake3:"));
1518 assert_ne!(digest, changed_digest);
1519 }
1520
1521 #[rstest]
1522 fn test_stringify_numbers_rejects_unencoded_float() {
1523 let mut value = json!({"value": 1.25});
1524
1525 let error = stringify_numbers(&mut value).unwrap_err();
1526
1527 assert_eq!(
1528 error.to_string(),
1529 "canonical projection contains an unencoded floating-point value"
1530 );
1531 }
1532
1533 fn test_document() -> Value {
1534 json!({
1535 "accounts": [],
1536 "components": {
1537 "actor_ids": [],
1538 "exec_algorithm_ids": [],
1539 "strategy_ids": [],
1540 "trader_state": "STOPPED",
1541 },
1542 "diagnostics": [],
1543 "fills": [],
1544 "orders": [],
1545 "portfolio_snapshots": [],
1546 "position_snapshots": [],
1547 "positions": [],
1548 "run": {
1549 "backtest_end_ns": "2",
1550 "backtest_start_ns": "1",
1551 "iterations": "1",
1552 "outcome": "completed",
1553 "run_config_id": null,
1554 "total_events": "0",
1555 "total_orders": "0",
1556 "total_positions": "0",
1557 "trader_id": "TRADER-001",
1558 },
1559 "schema": CANONICAL_SCHEMA,
1560 "statistics": {
1561 "general": {},
1562 "pnls": {},
1563 "returns": {},
1564 "returns_series": [],
1565 },
1566 "summary": {},
1567 })
1568 }
1569
1570 fn replace_test_identity(value: &mut Value) {
1571 match value {
1572 Value::Array(values) => {
1573 for value in values {
1574 replace_test_identity(value);
1575 }
1576 }
1577 Value::Object(object) => {
1578 for value in object.values_mut() {
1579 replace_test_identity(value);
1580 }
1581 }
1582 Value::String(value) if value.contains('-') && value.len() == 36 => {
1583 *value = value
1584 .replace('1', "a")
1585 .replace('2', "b")
1586 .replace('3', "c")
1587 .replace('4', "d")
1588 .replace('5', "e");
1589 }
1590 _ => {}
1591 }
1592 }
1593}