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