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}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub(crate) enum CanonicalRunOutcome {
147 Completed,
148 Failed,
149 Incomplete,
150 Stopped,
151}
152
153pub(crate) struct CanonicalBacktestState {
154 pub trader_id: String,
155 pub run_config_id: Option<String>,
156 pub backtest_start: Option<UnixNanos>,
157 pub backtest_end: Option<UnixNanos>,
158 pub iterations: usize,
159 pub total_events: usize,
160 pub total_orders: usize,
161 pub total_positions: usize,
162 pub outcome: CanonicalRunOutcome,
163 pub diagnostics: Vec<CanonicalDiagnostic>,
164 pub trader_state: String,
165 pub actor_ids: Vec<String>,
166 pub strategy_ids: Vec<String>,
167 pub exec_algorithm_ids: Vec<String>,
168 pub summary: BTreeMap<String, String>,
169 pub orders: Vec<OrderAny>,
170 pub positions: Vec<Position>,
171 pub position_snapshots: Vec<Position>,
172 pub accounts: Vec<AccountAny>,
173 pub portfolio_snapshots: Vec<PortfolioSnapshot>,
174 pub statistics: PortfolioStatistics,
175}
176
177impl CanonicalBacktestResult {
178 pub fn from_slice(bytes: &[u8]) -> anyhow::Result<Self> {
188 let document: Value = serde_json::from_slice(bytes)
189 .map_err(|e| anyhow::anyhow!("invalid canonical backtest result JSON: {e}"))?;
190 validate_document(&document)?;
191 let mut normalized = document.clone();
192 canonicalize_document(&mut normalized)?;
193 anyhow::ensure!(
194 normalized == document,
195 "canonical backtest result violates the version 1 encoding rules"
196 );
197 let canonical = serde_json::to_vec(&normalized)?;
198 anyhow::ensure!(
199 canonical == bytes,
200 "canonical backtest result bytes do not use the canonical encoding"
201 );
202 Ok(Self { document })
203 }
204
205 pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
211 Ok(serde_json::to_vec(&self.document)?)
212 }
213
214 pub fn digest(&self) -> anyhow::Result<String> {
220 let bytes = self.to_bytes()?;
221 Ok(format!("blake3:{}", blake3::hash(&bytes).to_hex()))
222 }
223
224 #[must_use]
226 pub const fn as_value(&self) -> &Value {
227 &self.document
228 }
229
230 #[must_use]
232 pub fn first_divergence(&self, actual: &Self) -> Option<CanonicalResultDivergence> {
233 first_divergence(&self.document, &actual.document, String::new())
234 }
235
236 pub(crate) fn from_state(mut state: CanonicalBacktestState) -> anyhow::Result<Self> {
237 state.actor_ids.sort();
238 state.strategy_ids.sort();
239 state.exec_algorithm_ids.sort();
240
241 let orders = state
242 .orders
243 .iter()
244 .map(canonical_order)
245 .collect::<anyhow::Result<Vec<_>>>()?;
246 let fills = canonical_fills(&state.orders)?;
247 let positions = state
248 .positions
249 .iter()
250 .map(canonical_position)
251 .collect::<anyhow::Result<Vec<_>>>()?;
252 let position_snapshots = state
253 .position_snapshots
254 .iter()
255 .map(canonical_position)
256 .collect::<anyhow::Result<Vec<_>>>()?;
257 let accounts = state
258 .accounts
259 .iter()
260 .map(canonical_account)
261 .collect::<anyhow::Result<Vec<_>>>()?;
262 let portfolio_snapshots = state
263 .portfolio_snapshots
264 .iter()
265 .map(canonical_value)
266 .collect::<anyhow::Result<Vec<_>>>()?;
267
268 let mut document = json!({
269 "accounts": accounts,
270 "components": {
271 "actor_ids": state.actor_ids,
272 "exec_algorithm_ids": state.exec_algorithm_ids,
273 "strategy_ids": state.strategy_ids,
274 "trader_state": state.trader_state,
275 },
276 "diagnostics": state.diagnostics,
277 "fills": fills,
278 "orders": orders,
279 "portfolio_snapshots": portfolio_snapshots,
280 "position_snapshots": position_snapshots,
281 "positions": positions,
282 "run": {
283 "backtest_end_ns": optional_nanos(state.backtest_end),
284 "backtest_start_ns": optional_nanos(state.backtest_start),
285 "iterations": state.iterations.to_string(),
286 "outcome": state.outcome,
287 "run_config_id": state.run_config_id,
288 "total_events": state.total_events.to_string(),
289 "total_orders": state.total_orders.to_string(),
290 "total_positions": state.total_positions.to_string(),
291 "trader_id": state.trader_id,
292 },
293 "schema": CANONICAL_SCHEMA,
294 "statistics": canonical_statistics(state.statistics),
295 "summary": state.summary,
296 });
297
298 canonicalize_document(&mut document)?;
299 validate_document(&document)?;
300 Ok(Self { document })
301 }
302}
303
304fn validate_document(document: &Value) -> anyhow::Result<()> {
305 let object = document
306 .as_object()
307 .ok_or_else(|| anyhow::anyhow!("canonical backtest result must be a JSON object"))?;
308 anyhow::ensure!(
309 object.get("schema").and_then(Value::as_str) == Some(CANONICAL_SCHEMA),
310 "unsupported canonical backtest result schema"
311 );
312 let fields = [
313 "accounts",
314 "components",
315 "diagnostics",
316 "fills",
317 "orders",
318 "portfolio_snapshots",
319 "position_snapshots",
320 "positions",
321 "run",
322 "schema",
323 "statistics",
324 "summary",
325 ];
326 validate_fields(object, &fields, "canonical result")?;
327
328 for key in [
329 "accounts",
330 "diagnostics",
331 "fills",
332 "orders",
333 "portfolio_snapshots",
334 "position_snapshots",
335 "positions",
336 ] {
337 anyhow::ensure!(
338 object.get(key).is_some_and(Value::is_array),
339 "canonical result field '{key}' must be an array"
340 );
341 }
342 validate_components(object.get("components").expect("validated field"))?;
343 validate_diagnostics(object.get("diagnostics").expect("validated field"))?;
344 validate_run(object.get("run").expect("validated field"))?;
345 validate_statistics(object.get("statistics").expect("validated field"))?;
346 let summary = object
347 .get("summary")
348 .and_then(Value::as_object)
349 .ok_or_else(|| anyhow::anyhow!("canonical result summary must be an object"))?;
350 anyhow::ensure!(
351 summary.values().all(Value::is_string),
352 "canonical result summary values must be strings"
353 );
354 Ok(())
355}
356
357fn validate_fields(
358 object: &Map<String, Value>,
359 expected: &[&str],
360 context: &str,
361) -> anyhow::Result<()> {
362 let actual = object.keys().map(String::as_str).collect::<BTreeSet<_>>();
363 let expected = expected.iter().copied().collect::<BTreeSet<_>>();
364 anyhow::ensure!(
365 actual == expected,
366 "{context} fields do not match the version 1 schema"
367 );
368 Ok(())
369}
370
371fn validate_components(value: &Value) -> anyhow::Result<()> {
372 let object = value
373 .as_object()
374 .ok_or_else(|| anyhow::anyhow!("canonical result components must be an object"))?;
375 validate_fields(
376 object,
377 &[
378 "actor_ids",
379 "exec_algorithm_ids",
380 "strategy_ids",
381 "trader_state",
382 ],
383 "canonical result components",
384 )?;
385
386 for key in ["actor_ids", "exec_algorithm_ids", "strategy_ids"] {
387 let values = object
388 .get(key)
389 .and_then(Value::as_array)
390 .ok_or_else(|| anyhow::anyhow!("canonical component field '{key}' must be an array"))?;
391 anyhow::ensure!(
392 values.iter().all(Value::is_string),
393 "canonical component field '{key}' must contain strings"
394 );
395 }
396 anyhow::ensure!(
397 object.get("trader_state").is_some_and(Value::is_string),
398 "canonical trader state must be a string"
399 );
400 Ok(())
401}
402
403fn validate_diagnostics(value: &Value) -> anyhow::Result<()> {
404 let diagnostics = value
405 .as_array()
406 .ok_or_else(|| anyhow::anyhow!("canonical diagnostics must be an array"))?;
407 for diagnostic in diagnostics {
408 let object = diagnostic
409 .as_object()
410 .ok_or_else(|| anyhow::anyhow!("canonical diagnostic must be an object"))?;
411 validate_fields(object, &["code"], "canonical diagnostic")?;
412 anyhow::ensure!(
413 object.get("code").and_then(Value::as_str) == Some("funding-settlement-failed"),
414 "unsupported canonical diagnostic code"
415 );
416 }
417 Ok(())
418}
419
420fn validate_run(value: &Value) -> anyhow::Result<()> {
421 let object = value
422 .as_object()
423 .ok_or_else(|| anyhow::anyhow!("canonical result run must be an object"))?;
424 validate_fields(
425 object,
426 &[
427 "backtest_end_ns",
428 "backtest_start_ns",
429 "iterations",
430 "outcome",
431 "run_config_id",
432 "total_events",
433 "total_orders",
434 "total_positions",
435 "trader_id",
436 ],
437 "canonical result run",
438 )?;
439
440 for key in [
441 "iterations",
442 "total_events",
443 "total_orders",
444 "total_positions",
445 ] {
446 validate_unsigned_decimal(object.get(key).expect("validated field"), key, false)?;
447 }
448
449 for key in ["backtest_start_ns", "backtest_end_ns"] {
450 validate_unsigned_decimal(object.get(key).expect("validated field"), key, true)?;
451 }
452 anyhow::ensure!(
453 object
454 .get("run_config_id")
455 .is_some_and(|value| value.is_null() || value.is_string()),
456 "canonical run configuration ID must be a string or null"
457 );
458 anyhow::ensure!(
459 object.get("trader_id").is_some_and(Value::is_string),
460 "canonical trader ID must be a string"
461 );
462 anyhow::ensure!(
463 matches!(
464 object.get("outcome").and_then(Value::as_str),
465 Some("completed" | "failed" | "incomplete" | "stopped")
466 ),
467 "unsupported canonical run outcome"
468 );
469 Ok(())
470}
471
472fn validate_unsigned_decimal(value: &Value, field: &str, nullable: bool) -> anyhow::Result<()> {
473 if nullable && value.is_null() {
474 return Ok(());
475 }
476 let value = value
477 .as_str()
478 .ok_or_else(|| anyhow::anyhow!("canonical run field '{field}' must be a decimal string"))?;
479 anyhow::ensure!(
480 value == "0"
481 || (!value.is_empty()
482 && !value.starts_with('0')
483 && value.bytes().all(|byte| byte.is_ascii_digit())),
484 "canonical run field '{field}' is not a canonical unsigned decimal"
485 );
486 Ok(())
487}
488
489fn validate_statistics(value: &Value) -> anyhow::Result<()> {
490 let object = value
491 .as_object()
492 .ok_or_else(|| anyhow::anyhow!("canonical result statistics must be an object"))?;
493 validate_fields(
494 object,
495 &["general", "pnls", "returns", "returns_series"],
496 "canonical result statistics",
497 )?;
498
499 for key in ["general", "pnls", "returns"] {
500 anyhow::ensure!(
501 object.get(key).is_some_and(Value::is_object),
502 "canonical statistics field '{key}' must be an object"
503 );
504 }
505 anyhow::ensure!(
506 object.get("returns_series").is_some_and(Value::is_array),
507 "canonical returns series must be an array"
508 );
509 Ok(())
510}
511
512fn optional_nanos(value: Option<UnixNanos>) -> Value {
513 value.map_or(Value::Null, |nanos| Value::String(nanos.to_string()))
514}
515
516fn canonical_order(order: &OrderAny) -> anyhow::Result<Value> {
517 let mut value = serde_json::to_value(order)?;
518 let payload = variant_payload_mut(&mut value)?;
519 let core = payload
520 .get_mut("core")
521 .and_then(Value::as_object_mut)
522 .ok_or_else(|| anyhow::anyhow!("serialized order did not contain an object core"))?;
523 set_decimal(core, "avg_px", order.avg_px());
524 set_decimal(core, "slippage", order.slippage());
525
526 if let Some(events) = core.get_mut("events").and_then(Value::as_array_mut) {
527 for (source, encoded) in order.events().into_iter().zip(events) {
528 patch_order_event_decimals(source, encoded)?;
529 }
530 }
531 set_decimal_if_present(payload, "limit_offset", order.limit_offset());
532 set_decimal_if_present(payload, "trailing_offset", order.trailing_offset());
533 canonicalize_value(&mut value)?;
534 Ok(value)
535}
536
537fn canonical_fills(orders: &[OrderAny]) -> anyhow::Result<Vec<Value>> {
538 let mut fills = Vec::new();
539
540 for order in orders {
541 for (ordinal, event) in order.events().into_iter().enumerate() {
542 if !matches!(event, OrderEventAny::Filled(_)) {
543 continue;
544 }
545 let mut encoded = serde_json::to_value(event)?;
546 canonicalize_value(&mut encoded)?;
547 fills.push(json!({
548 "client_order_id": order.client_order_id().to_string(),
549 "event": encoded,
550 "order_event_ordinal": ordinal.to_string(),
551 }));
552 }
553 }
554 Ok(fills)
555}
556
557fn canonical_position(position: &Position) -> anyhow::Result<Value> {
558 let mut value = serde_json::to_value(position)?;
559 let object = value
560 .as_object_mut()
561 .ok_or_else(|| anyhow::anyhow!("serialized position was not an object"))?;
562 anyhow::ensure!(
563 object.remove("id").is_some(),
564 "serialized position did not contain its identifier"
565 );
566 object.insert(
567 "position_id".to_string(),
568 Value::String(position.id.to_string()),
569 );
570 set_f64(object, "avg_px_close", position.avg_px_close);
571 set_f64(object, "avg_px_open", Some(position.avg_px_open));
572 set_f64(object, "realized_return", Some(position.realized_return));
573 set_f64(object, "signed_qty", Some(position.signed_qty));
574
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}