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