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::MarginModelAny},
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(&document)?;
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 = match margin.margin_model() {
616 MarginModelAny::Standard(_) => "standard",
617 MarginModelAny::Leveraged(_) => "leveraged",
618 };
619 payload.insert(
620 "default_leverage".to_string(),
621 Value::String(canonical_decimal(margin.default_leverage)),
622 );
623 payload.insert("leverages".to_string(), Value::Object(leverages));
624 payload.insert(
625 "margin_model".to_string(),
626 Value::String(margin_model.to_string()),
627 );
628 }
629 AccountAny::Cash(cash) => {
630 payload.insert(
631 "balances_locked_transient".to_string(),
632 locked_balances(&cash.balances_locked),
633 );
634 }
635 AccountAny::Betting(betting) => {
636 payload.insert(
637 "balances_locked_transient".to_string(),
638 locked_balances(&betting.balances_locked),
639 );
640 }
641 AccountAny::Wallet(wallet) => {
642 payload.insert(
643 "balances_locked_transient".to_string(),
644 locked_balances(&wallet.balances_locked),
645 );
646 }
647 }
648 canonicalize_value(&mut value)?;
649 Ok(value)
650}
651
652fn locked_balances(balances: &AHashMap<(InstrumentId, Currency), Money>) -> Value {
653 let mut values = balances
654 .iter()
655 .map(|((instrument_id, currency), money)| {
656 json!({
657 "currency": currency.code.to_string(),
658 "instrument_id": instrument_id.to_string(),
659 "money": money.to_string(),
660 })
661 })
662 .collect::<Vec<_>>();
663 values.sort_by_cached_key(|value| canonical_sort_key(value, true));
664 Value::Array(values)
665}
666
667fn canonical_statistics(statistics: PortfolioStatistics) -> Value {
668 let pnls = statistics
669 .pnls
670 .into_iter()
671 .map(|(currency, values)| (currency, canonical_f64_map(values)))
672 .collect::<Map<_, _>>();
673 let returns_series = statistics
674 .returns_series
675 .into_iter()
676 .map(|(timestamp, value)| {
677 json!({
678 "timestamp_ns": timestamp.to_string(),
679 "value": canonical_f64(value),
680 })
681 })
682 .collect::<Vec<_>>();
683 json!({
684 "general": canonical_f64_map(statistics.general),
685 "pnls": pnls,
686 "returns": canonical_f64_map(statistics.returns),
687 "returns_series": returns_series,
688 })
689}
690
691fn canonical_f64_map(values: AHashMap<String, f64>) -> Value {
692 Value::Object(
693 values
694 .into_iter()
695 .map(|(name, value)| (name, Value::String(canonical_f64(value))))
696 .collect(),
697 )
698}
699
700fn canonical_f64(value: f64) -> String {
701 if value.is_nan() {
702 "nan".to_string()
703 } else if value == f64::INFINITY {
704 "+inf".to_string()
705 } else if value == f64::NEG_INFINITY {
706 "-inf".to_string()
707 } else {
708 format!("{:016x}", value.to_bits())
709 }
710}
711
712fn canonical_decimal(value: rust_decimal::Decimal) -> String {
713 value.normalize().to_string()
714}
715
716fn set_f64(object: &mut Map<String, Value>, key: &str, value: Option<f64>) {
717 object.insert(
718 key.to_string(),
719 value.map_or(Value::Null, |value| Value::String(canonical_f64(value))),
720 );
721}
722
723fn set_decimal(object: &mut Map<String, Value>, key: &str, value: Option<rust_decimal::Decimal>) {
724 object.insert(
725 key.to_string(),
726 value.map_or(Value::Null, |value| Value::String(canonical_decimal(value))),
727 );
728}
729
730fn set_decimal_if_present(
731 object: &mut Map<String, Value>,
732 key: &str,
733 value: Option<rust_decimal::Decimal>,
734) {
735 if object.contains_key(key) {
736 set_decimal(object, key, value);
737 }
738}
739
740fn patch_order_event_decimals(event: &OrderEventAny, value: &mut Value) -> anyhow::Result<()> {
741 if let OrderEventAny::Initialized(initialized) = event {
742 let payload = variant_payload_mut(value)?;
743 set_decimal(payload, "limit_offset", initialized.limit_offset);
744 set_decimal(payload, "trailing_offset", initialized.trailing_offset);
745 }
746 Ok(())
747}
748
749fn patch_position_adjustment(
750 adjustment: &PositionAdjusted,
751 value: &mut Value,
752) -> anyhow::Result<()> {
753 let object = value
754 .as_object_mut()
755 .ok_or_else(|| anyhow::anyhow!("serialized position adjustment was not an object"))?;
756 set_decimal(object, "quantity_change", adjustment.quantity_change);
757 Ok(())
758}
759
760fn variant_payload_mut(value: &mut Value) -> anyhow::Result<&mut Map<String, Value>> {
761 let variant = value
762 .as_object_mut()
763 .and_then(|object| object.values_mut().next())
764 .and_then(Value::as_object_mut)
765 .ok_or_else(|| anyhow::anyhow!("serialized enum variant was not an object"))?;
766 Ok(variant)
767}
768
769fn canonical_value<T: Serialize>(source: &T) -> anyhow::Result<Value> {
770 let mut value = serde_json::to_value(source)?;
771 canonicalize_value(&mut value)?;
772 Ok(value)
773}
774
775fn canonicalize_value(value: &mut Value) -> anyhow::Result<()> {
776 sort_named_arrays(value, false);
777 stringify_numbers(value)?;
778 Ok(())
779}
780
781fn canonicalize_document(value: &mut Value) -> anyhow::Result<()> {
782 sort_named_arrays(value, false);
783 normalize_identities(value);
784 sort_named_arrays(value, true);
785 stringify_numbers(value)
786}
787
788fn sort_named_arrays(value: &mut Value, identities_normalized: bool) {
789 match value {
790 Value::Array(values) => {
791 for value in values {
792 sort_named_arrays(value, identities_normalized);
793 }
794 }
795 Value::Object(object) => {
796 for (key, value) in object {
797 sort_named_arrays(value, identities_normalized);
798 if UNORDERED_ARRAY_KEYS.contains(&key.as_str())
799 && let Value::Array(values) = value
800 && (identities_normalized || identity_array_class(key).is_none())
801 {
802 values.sort_by_cached_key(|value| {
803 canonical_sort_key(value, !identities_normalized)
804 });
805 }
806 }
807 }
808 _ => {}
809 }
810}
811
812fn canonical_sort_key(value: &Value, strip_identity_fields: bool) -> Vec<u8> {
813 let mut value = value.clone();
814 if strip_identity_fields {
815 strip_identities(&mut value, false);
816 }
817 serde_json::to_vec(&value).expect("serializing a JSON value cannot fail")
818}
819
820fn strip_identities(value: &mut Value, metadata: bool) {
821 match value {
822 Value::Array(values) => {
823 for value in values {
824 strip_identities(value, metadata);
825 }
826 }
827 Value::Object(object) => {
828 if !metadata {
829 object.retain(|key, _| {
830 identity_scalar_class(key).is_none() && identity_array_class(key).is_none()
831 });
832 }
833
834 for (key, value) in object {
835 strip_identities(value, metadata || METADATA_KEYS.contains(&key.as_str()));
836 }
837 }
838 _ => {}
839 }
840}
841
842fn normalize_identities(document: &mut Value) {
843 let mut identities = BTreeMap::<IdentityClass, BTreeMap<String, String>>::new();
844 collect_primary_identities(document, false, &mut identities);
845 let mut next_external = BTreeMap::<IdentityClass, usize>::new();
846 replace_identities(document, false, &mut identities, &mut next_external);
847}
848
849fn collect_primary_identities(
850 value: &Value,
851 metadata: bool,
852 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
853) {
854 match value {
855 Value::Array(values) => {
856 for value in values {
857 collect_primary_identities(value, metadata, identities);
858 }
859 }
860 Value::Object(object) => {
861 if !metadata {
862 for (key, value) in object {
863 let Some(class) = primary_identity_class(key) else {
864 continue;
865 };
866 let Some(identity) = value.as_str() else {
867 continue;
868 };
869 let class_identities = identities.entry(class).or_default();
870 let next = class_identities.len() + 1;
871 class_identities
872 .entry(identity.to_string())
873 .or_insert_with(|| format!("{}-{next}", class.prefix()));
874 }
875 }
876
877 for (key, value) in object {
878 collect_primary_identities(
879 value,
880 metadata || METADATA_KEYS.contains(&key.as_str()),
881 identities,
882 );
883 }
884 }
885 _ => {}
886 }
887}
888
889fn replace_identities(
890 value: &mut Value,
891 metadata: bool,
892 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
893 next_external: &mut BTreeMap<IdentityClass, usize>,
894) {
895 match value {
896 Value::Array(values) => {
897 for value in values {
898 replace_identities(value, metadata, identities, next_external);
899 }
900 }
901 Value::Object(object) => {
902 if !metadata {
903 for (key, value) in object.iter_mut() {
904 if let Some(class) = identity_scalar_class(key) {
905 replace_identity(value, class, identities, next_external);
906 } else if let Some(class) = identity_array_class(key)
907 && let Value::Array(values) = value
908 {
909 for value in values {
910 replace_identity(value, class, identities, next_external);
911 }
912 }
913 }
914 }
915
916 for (key, value) in object {
917 replace_identities(
918 value,
919 metadata || METADATA_KEYS.contains(&key.as_str()),
920 identities,
921 next_external,
922 );
923 }
924 }
925 _ => {}
926 }
927}
928
929fn replace_identity(
930 value: &mut Value,
931 class: IdentityClass,
932 identities: &mut BTreeMap<IdentityClass, BTreeMap<String, String>>,
933 next_external: &mut BTreeMap<IdentityClass, usize>,
934) {
935 let Some(raw) = value.as_str() else {
936 return;
937 };
938 let class_identities = identities.entry(class).or_default();
939 let token = class_identities.entry(raw.to_string()).or_insert_with(|| {
940 let next = next_external.entry(class).or_default();
941 *next += 1;
942 format!("{}-external-{next}", class.prefix())
943 });
944 *value = Value::String(token.clone());
945}
946
947fn primary_identity_class(key: &str) -> Option<IdentityClass> {
948 match key {
949 "client_order_id" => Some(IdentityClass::ClientOrder),
950 "event_id" => Some(IdentityClass::Event),
951 "order_list_id" => Some(IdentityClass::OrderList),
952 "position_id" => Some(IdentityClass::Position),
953 "trade_id" => Some(IdentityClass::Trade),
954 "venue_order_id" => Some(IdentityClass::VenueOrder),
955 _ => None,
956 }
957}
958
959fn identity_scalar_class(key: &str) -> Option<IdentityClass> {
960 match key {
961 "client_order_id" | "closing_order_id" | "exec_spawn_id" | "opening_order_id"
962 | "parent_order_id" => Some(IdentityClass::ClientOrder),
963 "causation_id" | "event_id" | "init_id" => Some(IdentityClass::Event),
964 "order_list_id" => Some(IdentityClass::OrderList),
965 "position_id" => Some(IdentityClass::Position),
966 "last_trade_id" | "trade_id" => Some(IdentityClass::Trade),
967 "venue_order_id" => Some(IdentityClass::VenueOrder),
968 _ => None,
969 }
970}
971
972fn identity_array_class(key: &str) -> Option<IdentityClass> {
973 match key {
974 "linked_order_ids" => Some(IdentityClass::ClientOrder),
975 "trade_ids" => Some(IdentityClass::Trade),
976 "venue_order_ids" => Some(IdentityClass::VenueOrder),
977 _ => None,
978 }
979}
980
981fn stringify_numbers(value: &mut Value) -> anyhow::Result<()> {
982 match value {
983 Value::Array(values) => {
984 for value in values {
985 stringify_numbers(value)?;
986 }
987 }
988 Value::Object(object) => {
989 for value in object.values_mut() {
990 stringify_numbers(value)?;
991 }
992 }
993 Value::Number(number) => {
994 anyhow::ensure!(
995 number.is_i64() || number.is_u64(),
996 "canonical projection contains an unencoded floating-point value"
997 );
998 *value = Value::String(number.to_string());
999 }
1000 _ => {}
1001 }
1002 Ok(())
1003}
1004
1005fn first_divergence(
1006 expected: &Value,
1007 actual: &Value,
1008 path: String,
1009) -> Option<CanonicalResultDivergence> {
1010 match (expected, actual) {
1011 (Value::Object(expected), Value::Object(actual)) => {
1012 let keys = expected
1013 .keys()
1014 .chain(actual.keys())
1015 .cloned()
1016 .collect::<BTreeSet<_>>();
1017
1018 for key in keys {
1019 let next_path = format!("{path}/{}", escape_pointer_token(&key));
1020 match (expected.get(&key), actual.get(&key)) {
1021 (Some(expected), Some(actual)) => {
1022 if let Some(divergence) = first_divergence(expected, actual, next_path) {
1023 return Some(divergence);
1024 }
1025 }
1026 (expected, actual) => {
1027 return Some(CanonicalResultDivergence {
1028 path: next_path,
1029 expected: expected.cloned(),
1030 actual: actual.cloned(),
1031 });
1032 }
1033 }
1034 }
1035 None
1036 }
1037 (Value::Array(expected), Value::Array(actual)) => {
1038 let len = expected.len().max(actual.len());
1039 for index in 0..len {
1040 let next_path = format!("{path}/{index}");
1041 match (expected.get(index), actual.get(index)) {
1042 (Some(expected), Some(actual)) => {
1043 if let Some(divergence) = first_divergence(expected, actual, next_path) {
1044 return Some(divergence);
1045 }
1046 }
1047 (expected, actual) => {
1048 return Some(CanonicalResultDivergence {
1049 path: next_path,
1050 expected: expected.cloned(),
1051 actual: actual.cloned(),
1052 });
1053 }
1054 }
1055 }
1056 None
1057 }
1058 _ if expected == actual => None,
1059 _ => Some(CanonicalResultDivergence {
1060 path,
1061 expected: Some(expected.clone()),
1062 actual: Some(actual.clone()),
1063 }),
1064 }
1065}
1066
1067fn escape_pointer_token(token: &str) -> String {
1068 token.replace('~', "~0").replace('/', "~1")
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use ahash::AHashMap;
1074 use nautilus_model::{
1075 accounts::WalletAccount,
1076 enums::{AccountType, OrderType, TrailingOffsetType},
1077 events::AccountState,
1078 identifiers::{AccountId, InstrumentId},
1079 orders::OrderTestBuilder,
1080 types::{AccountBalance, Quantity},
1081 };
1082 use rstest::rstest;
1083 use rust_decimal::Decimal;
1084 use serde_json::json;
1085
1086 use super::*;
1087
1088 #[rstest]
1089 fn test_backtest_result_serializes_to_json() {
1090 let instance_id = UUID4::from("11111111-1111-4111-8111-111111111111");
1091 let run_id = UUID4::from("22222222-2222-4222-8222-222222222222");
1092 let mut summary = AHashMap::new();
1093 summary.insert("PnL (total)".to_string(), "10.00 USD".to_string());
1094 let mut usd_pnls = AHashMap::new();
1095 usd_pnls.insert("Returns Volatility (252 days)".to_string(), 1.25);
1096 let mut stats_pnls = AHashMap::new();
1097 stats_pnls.insert("USD".to_string(), usd_pnls);
1098 let mut stats_returns = AHashMap::new();
1099 stats_returns.insert("Sharpe Ratio (252 days)".to_string(), 0.75);
1100 let mut stats_general = AHashMap::new();
1101 stats_general.insert("Long Ratio".to_string(), 1.0);
1102
1103 let result = BacktestResult {
1104 trader_id: "TRADER-001".to_string(),
1105 machine_id: "machine-1".to_string(),
1106 instance_id,
1107 run_config_id: Some("config-1".to_string()),
1108 run_id: Some(run_id),
1109 run_started: Some(UnixNanos::new(1)),
1110 run_finished: Some(UnixNanos::new(2)),
1111 backtest_start: Some(UnixNanos::new(3)),
1112 backtest_end: Some(UnixNanos::new(4)),
1113 elapsed_time_secs: 1.5,
1114 iterations: 10,
1115 total_events: 20,
1116 total_orders: 2,
1117 total_positions: 1,
1118 summary,
1119 stats_pnls,
1120 stats_returns,
1121 stats_general,
1122 returns_series: BTreeMap::from([(UnixNanos::new(3), 0.25)]),
1123 };
1124
1125 let value = serde_json::to_value(&result).unwrap();
1126
1127 assert_eq!(value["trader_id"], json!("TRADER-001"));
1128 assert_eq!(value["machine_id"], json!("machine-1"));
1129 assert_eq!(value["instance_id"], json!(instance_id.to_string()));
1130 assert_eq!(value["run_id"], json!(run_id.to_string()));
1131 assert_eq!(value["run_started"], json!(1));
1132 assert_eq!(value["backtest_end"], json!(4));
1133 assert_eq!(value["elapsed_time_secs"], json!(1.5));
1134 assert_eq!(value["iterations"], json!(10));
1135 assert_eq!(value["summary"]["PnL (total)"], json!("10.00 USD"));
1136 assert_eq!(
1137 value["stats_pnls"]["USD"]["Returns Volatility (252 days)"],
1138 json!(1.25)
1139 );
1140 assert_eq!(
1141 value["stats_returns"]["Sharpe Ratio (252 days)"],
1142 json!(0.75)
1143 );
1144 assert_eq!(value["stats_general"]["Long Ratio"], json!(1.0));
1145 assert_eq!(value["returns_series"]["3"], json!(0.25));
1146 }
1147
1148 #[rstest]
1149 fn test_canonical_account_wallet_includes_transient_locks() {
1150 let eth = Currency::ETH();
1151 let state = AccountState::new(
1152 AccountId::from("WALLET-001"),
1153 AccountType::Wallet,
1154 vec![AccountBalance::new(
1155 Money::new(10.0, eth),
1156 Money::zero(eth),
1157 Money::new(10.0, eth),
1158 )],
1159 vec![],
1160 true,
1161 UUID4::new(),
1162 UnixNanos::default(),
1163 UnixNanos::default(),
1164 None,
1165 );
1166 let mut account = WalletAccount::new(state, true);
1167 let instrument_id = InstrumentId::from("WETHUSDC.BLOCKCHAIN");
1168 account
1169 .update_balance_locked(instrument_id, Money::new(2.0, eth))
1170 .unwrap();
1171
1172 let value = canonical_account(&AccountAny::Wallet(account)).unwrap();
1173
1174 let payload = value.get("Wallet").unwrap();
1175 let locks = payload["balances_locked_transient"].as_array().unwrap();
1176 assert_eq!(locks.len(), 1);
1177 assert_eq!(locks[0]["currency"], "ETH");
1178 assert_eq!(locks[0]["instrument_id"], "WETHUSDC.BLOCKCHAIN");
1179 assert_eq!(locks[0]["money"], "2.00000000 ETH");
1180 }
1181
1182 #[rstest]
1183 fn test_canonical_f64_encodes_finite_and_non_finite_values() {
1184 let nan_with_payload = f64::from_bits(0x7ff8_0000_0000_0042);
1185
1186 assert_eq!(canonical_f64(1.5), "3ff8000000000000");
1187 assert_eq!(canonical_f64(-0.0), "8000000000000000");
1188 assert_eq!(canonical_f64(f64::NAN), "nan");
1189 assert_eq!(canonical_f64(nan_with_payload), "nan");
1190 assert_eq!(canonical_f64(f64::INFINITY), "+inf");
1191 assert_eq!(canonical_f64(f64::NEG_INFINITY), "-inf");
1192 }
1193
1194 #[rstest]
1195 fn test_canonical_decimal_removes_redundant_scale() {
1196 let value = "001.2300".parse().unwrap();
1197
1198 assert_eq!(canonical_decimal(value), "1.23");
1199 }
1200
1201 #[rstest]
1202 fn test_canonical_order_normalizes_core_and_event_decimals() {
1203 let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
1204 .instrument_id(InstrumentId::from("AUDUSD.SIM"))
1205 .quantity(Quantity::from(100_000))
1206 .limit_offset(Decimal::new(12_300, 4))
1207 .trailing_offset(Decimal::new(45_600, 4))
1208 .trailing_offset_type(TrailingOffsetType::Price)
1209 .build();
1210
1211 let value = canonical_order(&order).unwrap();
1212 let payload = value["TrailingStopLimit"].as_object().unwrap();
1213 let core = payload["core"].as_object().unwrap();
1214 let initialized = &core["events"][0]["Initialized"];
1215
1216 assert!(!payload.contains_key("avg_px"));
1217 assert!(!payload.contains_key("slippage"));
1218 assert_eq!(payload["limit_offset"], "1.23");
1219 assert_eq!(payload["trailing_offset"], "4.56");
1220 assert_eq!(core["avg_px"], Value::Null);
1221 assert_eq!(core["slippage"], Value::Null);
1222 assert_eq!(initialized["limit_offset"], "1.23");
1223 assert_eq!(initialized["trailing_offset"], "4.56");
1224 }
1225
1226 #[rstest]
1227 fn test_normalize_identities_preserves_event_relationships() {
1228 let mut first = json!({
1229 "events": [
1230 {"event_id": "11111111-1111-4111-8111-111111111111"},
1231 {
1232 "causation_id": "11111111-1111-4111-8111-111111111111",
1233 "event_id": "22222222-2222-4222-8222-222222222222",
1234 "init_id": "22222222-2222-4222-8222-222222222222"
1235 }
1236 ]
1237 });
1238 let mut repeated = json!({
1239 "events": [
1240 {"event_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
1241 {
1242 "causation_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
1243 "event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
1244 "init_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
1245 }
1246 ]
1247 });
1248
1249 normalize_identities(&mut first);
1250 normalize_identities(&mut repeated);
1251
1252 assert_eq!(first, repeated);
1253 assert_eq!(first["events"][0]["event_id"], "event-1");
1254 assert_eq!(first["events"][1]["event_id"], "event-2");
1255 assert_eq!(first["events"][1]["init_id"], "event-2");
1256 assert_eq!(first["events"][1]["causation_id"], "event-1");
1257 }
1258
1259 #[rstest]
1260 fn test_canonicalize_document_normalizes_random_domain_identities() {
1261 let mut first = test_document();
1262 first["fills"] = json!([
1263 {
1264 "client_order_id": "11111111-1111-4111-8111-111111111111",
1265 "event": {
1266 "Filled": {
1267 "event_id": "22222222-2222-4222-8222-222222222222",
1268 "position_id": "33333333-3333-4333-8333-333333333333",
1269 "trade_id": "44444444-4444-4444-8444-444444444444",
1270 "venue_order_id": "55555555-5555-4555-8555-555555555555"
1271 }
1272 },
1273 "order_event_ordinal": "1"
1274 }
1275 ]);
1276 first["orders"] = json!([
1277 {
1278 "Market": {
1279 "core": {
1280 "client_order_id": "11111111-1111-4111-8111-111111111111",
1281 "position_id": "33333333-3333-4333-8333-333333333333",
1282 "trade_ids": ["44444444-4444-4444-8444-444444444444"],
1283 "venue_order_id": "55555555-5555-4555-8555-555555555555"
1284 }
1285 }
1286 }
1287 ]);
1288 first["positions"] = json!([
1289 {
1290 "position_id": "33333333-3333-4333-8333-333333333333"
1291 }
1292 ]);
1293 let mut repeated = first.clone();
1294 replace_test_identity(&mut repeated["fills"]);
1295 replace_test_identity(&mut repeated["orders"]);
1296 replace_test_identity(&mut repeated["positions"]);
1297
1298 canonicalize_document(&mut first).unwrap();
1299 canonicalize_document(&mut repeated).unwrap();
1300
1301 assert_eq!(first, repeated);
1302 assert_eq!(first["fills"][0]["client_order_id"], "client-order-1");
1303 assert_eq!(first["fills"][0]["event"]["Filled"]["event_id"], "event-1");
1304 assert_eq!(
1305 first["fills"][0]["event"]["Filled"]["position_id"],
1306 "position-1"
1307 );
1308 assert_eq!(first["fills"][0]["event"]["Filled"]["trade_id"], "trade-1");
1309 assert_eq!(
1310 first["fills"][0]["event"]["Filled"]["venue_order_id"],
1311 "venue-order-1"
1312 );
1313 assert_eq!(first["positions"][0]["position_id"], "position-1");
1314 }
1315
1316 #[rstest]
1317 fn test_canonicalize_document_orders_records_by_normalized_identity() {
1318 let mut first = test_document();
1319 first["fills"] = json!([
1320 {
1321 "event": {
1322 "Filled": {
1323 "position_id": "11111111-1111-4111-8111-111111111111"
1324 }
1325 }
1326 },
1327 {
1328 "event": {
1329 "Filled": {
1330 "position_id": "22222222-2222-4222-8222-222222222222"
1331 }
1332 }
1333 }
1334 ]);
1335 first["positions"] = json!([
1336 {
1337 "position_id": "11111111-1111-4111-8111-111111111111",
1338 "side": "LONG"
1339 },
1340 {
1341 "position_id": "22222222-2222-4222-8222-222222222222",
1342 "side": "LONG"
1343 }
1344 ]);
1345 let mut reordered = first.clone();
1346 reordered["positions"].as_array_mut().unwrap().reverse();
1347
1348 canonicalize_document(&mut first).unwrap();
1349 canonicalize_document(&mut reordered).unwrap();
1350
1351 assert_eq!(first, reordered);
1352 assert_eq!(reordered["positions"][0]["position_id"], "position-1");
1353 assert_eq!(reordered["positions"][1]["position_id"], "position-2");
1354 }
1355
1356 #[rstest]
1357 fn test_canonical_result_reports_first_divergence_with_escaped_pointer() {
1358 let mut expected_document = test_document();
1359 expected_document["summary"] = json!({"order/price~open": "1.00000"});
1360 let mut actual_document = expected_document.clone();
1361 actual_document["summary"]["order/price~open"] = json!("1.00001");
1362 let expected = CanonicalBacktestResult {
1363 document: expected_document,
1364 };
1365 let actual = CanonicalBacktestResult {
1366 document: actual_document,
1367 };
1368
1369 let divergence = expected.first_divergence(&actual).unwrap();
1370
1371 assert_eq!(divergence.path, "/summary/order~1price~0open");
1372 assert_eq!(divergence.expected, Some(json!("1.00000")));
1373 assert_eq!(divergence.actual, Some(json!("1.00001")));
1374 }
1375
1376 #[rstest]
1377 fn test_canonical_result_reports_missing_record() {
1378 let mut expected_document = test_document();
1379 expected_document["fills"] = json!([{"trade_id": "T-001"}]);
1380 let expected = CanonicalBacktestResult {
1381 document: expected_document,
1382 };
1383 let actual = CanonicalBacktestResult {
1384 document: test_document(),
1385 };
1386
1387 let divergence = expected.first_divergence(&actual).unwrap();
1388
1389 assert_eq!(divergence.path, "/fills/0");
1390 assert_eq!(divergence.expected, Some(json!({"trade_id": "T-001"})));
1391 assert_eq!(divergence.actual, None);
1392 }
1393
1394 #[rstest]
1395 fn test_canonical_result_rejects_non_canonical_bytes() {
1396 let document = test_document();
1397 let canonical = serde_json::to_vec(&document).unwrap();
1398 let pretty = serde_json::to_vec_pretty(&document).unwrap();
1399
1400 let result = CanonicalBacktestResult::from_slice(&canonical).unwrap();
1401 let error = CanonicalBacktestResult::from_slice(&pretty).unwrap_err();
1402
1403 assert_eq!(result.as_value(), &document);
1404 assert_eq!(
1405 error.to_string(),
1406 "canonical backtest result bytes do not use the canonical encoding"
1407 );
1408 }
1409
1410 #[rstest]
1411 fn test_canonical_result_rejects_wrong_schema() {
1412 let mut document = test_document();
1413 document["schema"] = json!("nautilus-backtest-result/v2");
1414
1415 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1416 .unwrap_err();
1417
1418 assert_eq!(
1419 error.to_string(),
1420 "unsupported canonical backtest result schema"
1421 );
1422 }
1423
1424 #[rstest]
1425 fn test_canonical_result_rejects_non_v1_fields() {
1426 let mut document = test_document();
1427 document["unexpected"] = Value::Null;
1428
1429 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1430 .unwrap_err();
1431
1432 assert_eq!(
1433 error.to_string(),
1434 "canonical result fields do not match the version 1 schema"
1435 );
1436 }
1437
1438 #[rstest]
1439 fn test_canonical_result_rejects_non_v1_numeric_encoding() {
1440 let mut document = test_document();
1441 document["run"]["iterations"] = json!(1);
1442
1443 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1444 .unwrap_err();
1445
1446 assert_eq!(
1447 error.to_string(),
1448 "canonical run field 'iterations' must be a decimal string"
1449 );
1450 }
1451
1452 #[rstest]
1453 fn test_canonical_result_rejects_non_v1_collection_order() {
1454 let mut document = test_document();
1455 document["components"]["actor_ids"] = json!(["ACTOR-002", "ACTOR-001"]);
1456
1457 let error = CanonicalBacktestResult::from_slice(&serde_json::to_vec(&document).unwrap())
1458 .unwrap_err();
1459
1460 assert_eq!(
1461 error.to_string(),
1462 "canonical backtest result violates the version 1 encoding rules"
1463 );
1464 }
1465
1466 #[rstest]
1467 fn test_canonical_result_digest_covers_exact_bytes() {
1468 let first = CanonicalBacktestResult {
1469 document: test_document(),
1470 };
1471 let mut changed_document = test_document();
1472 changed_document["run"]["iterations"] = json!("2");
1473 let changed = CanonicalBacktestResult {
1474 document: changed_document,
1475 };
1476
1477 let digest = first.digest().unwrap();
1478 let changed_digest = changed.digest().unwrap();
1479
1480 assert_eq!(digest.len(), "blake3:".len() + 64);
1481 assert!(digest.starts_with("blake3:"));
1482 assert_ne!(digest, changed_digest);
1483 }
1484
1485 #[rstest]
1486 fn test_stringify_numbers_rejects_unencoded_float() {
1487 let mut value = json!({"value": 1.25});
1488
1489 let error = stringify_numbers(&mut value).unwrap_err();
1490
1491 assert_eq!(
1492 error.to_string(),
1493 "canonical projection contains an unencoded floating-point value"
1494 );
1495 }
1496
1497 fn test_document() -> Value {
1498 json!({
1499 "accounts": [],
1500 "components": {
1501 "actor_ids": [],
1502 "exec_algorithm_ids": [],
1503 "strategy_ids": [],
1504 "trader_state": "STOPPED",
1505 },
1506 "diagnostics": [],
1507 "fills": [],
1508 "orders": [],
1509 "portfolio_snapshots": [],
1510 "position_snapshots": [],
1511 "positions": [],
1512 "run": {
1513 "backtest_end_ns": "2",
1514 "backtest_start_ns": "1",
1515 "iterations": "1",
1516 "outcome": "completed",
1517 "run_config_id": null,
1518 "total_events": "0",
1519 "total_orders": "0",
1520 "total_positions": "0",
1521 "trader_id": "TRADER-001",
1522 },
1523 "schema": CANONICAL_SCHEMA,
1524 "statistics": {
1525 "general": {},
1526 "pnls": {},
1527 "returns": {},
1528 "returns_series": [],
1529 },
1530 "summary": {},
1531 })
1532 }
1533
1534 fn replace_test_identity(value: &mut Value) {
1535 match value {
1536 Value::Array(values) => {
1537 for value in values {
1538 replace_test_identity(value);
1539 }
1540 }
1541 Value::Object(object) => {
1542 for value in object.values_mut() {
1543 replace_test_identity(value);
1544 }
1545 }
1546 Value::String(value) if value.contains('-') && value.len() == 36 => {
1547 *value = value
1548 .replace('1', "a")
1549 .replace('2', "b")
1550 .replace('3', "c")
1551 .replace('4', "d")
1552 .replace('5', "e");
1553 }
1554 _ => {}
1555 }
1556 }
1557}