Skip to main content

nautilus_backtest/
result.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Results from completed backtest runs.
17
18use 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/// Results from a completed backtest run.
85#[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/// Versioned deterministic projection of observable backtest state.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct CanonicalBacktestResult {
119    document: Value,
120}
121
122/// The first difference between two canonical backtest results.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct CanonicalResultDivergence {
125    /// RFC 6901 JSON Pointer to the differing field or record.
126    pub path: String,
127    /// The expected value, or `None` when the field or record is absent.
128    pub expected: Option<Value>,
129    /// The actual value, or `None` when the field or record is absent.
130    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    /// Decodes canonical result bytes and verifies their envelope, normalization, and exact
179    /// encoding.
180    ///
181    /// Inner records retain their NautilusTrader model serialization and are compared as semantic
182    /// content. Producers must use the version 1 writer rather than construct records independently.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the bytes are not a canonical version 1 result.
187    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    /// Returns the canonical compact UTF-8 JSON bytes without trailing data.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the in-memory document cannot be serialized.
210    pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
211        Ok(serde_json::to_vec(&self.document)?)
212    }
213
214    /// Returns `blake3:` followed by the 32-byte BLAKE3 digest as 64 lowercase hex digits.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if the in-memory document cannot be serialized.
219    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    /// Returns the decoded canonical document.
225    #[must_use]
226    pub const fn as_value(&self) -> &Value {
227        &self.document
228    }
229
230    /// Returns the first field-level or record-level difference.
231    #[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}