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