nautilus-execution 0.67.0

Core execution machinery for the Nautilus trading engine
Documentation
// -------------------------------------------------------------------------------------------------
//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
//  https://nautechsystems.io
//
//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
//  You may not use this file except in compliance with the License.
//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
// -------------------------------------------------------------------------------------------------

//! Deterministic ID generation for reconciliation.
//!
//! Hashes the logical fill/order fields so that replayed reconciliation
//! events (e.g. after a process restart) produce the same `TradeId` and
//! `VenueOrderId` as the original run. That stability is what lets the engine's
//! duplicate-fill sanitizer dedupe replays instead of treating them as new events.

use nautilus_core::UnixNanos;
use nautilus_model::{
    enums::{OrderSide, OrderType},
    events::{OrderEventAny, OrderFilled},
    identifiers::{AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId},
    orders::{Order, OrderAny},
    types::{Price, Quantity},
};
use uuid::Uuid;

use super::types::FillSnapshot;

// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0100_0000_01b3;

const DETERMINISTIC_UUID_VERSION: u8 = 5;

/// Create a synthetic `VenueOrderId` for a derived fill.
///
/// The suffix is hashed from the fill fields and instrument so distinct fills never collide,
/// and the same logical fill always yields the same `VenueOrderId` across restarts.
///
/// Format: `S-{hex_timestamp}-{hash_suffix}`
#[must_use]
pub(super) fn create_synthetic_venue_order_id(
    fill: &FillSnapshot,
    instrument_id: InstrumentId,
) -> VenueOrderId {
    let hash_suffix = synthetic_fill_id_suffix("venue_order", fill, Some(instrument_id));
    let venue_order_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
    VenueOrderId::new(&venue_order_id_value)
}

/// Create a synthetic `TradeId` using stable fill fields.
///
/// Format: `S-{hex_timestamp}-{hash_suffix}`
#[must_use]
pub(super) fn create_synthetic_trade_id(fill: &FillSnapshot) -> TradeId {
    let hash_suffix = synthetic_fill_id_suffix("trade", fill, None);
    let trade_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
    TradeId::new(&trade_id_value)
}

/// Create a deterministic `TradeId` for an inferred reconciliation fill.
///
/// The `account_id` scopes the ID to the venue account, preventing cross-account
/// collisions on venues where `venue_order_id` is only account-unique. The `ts_last`
/// (venue-provided) differentiates successive reconciliation incidents with the same
/// shape while keeping cross-restart replays deterministic.
#[must_use]
#[expect(clippy::too_many_arguments)]
pub fn create_inferred_reconciliation_trade_id(
    account_id: AccountId,
    instrument_id: InstrumentId,
    client_order_id: ClientOrderId,
    venue_order_id: Option<VenueOrderId>,
    order_side: OrderSide,
    order_type: OrderType,
    filled_qty: Quantity,
    last_qty: Quantity,
    last_px: Price,
    position_id: PositionId,
    ts_last: UnixNanos,
) -> TradeId {
    let mut seed = String::from("reconciliation-fill");
    append_seed_part(&mut seed, account_id.as_str());
    append_seed_part(&mut seed, &instrument_id.to_string());
    append_seed_part(&mut seed, client_order_id.as_str());
    append_seed_part(
        &mut seed,
        venue_order_id.as_ref().map_or("", |value| value.as_ref()),
    );
    append_seed_part(&mut seed, order_side.as_ref());
    append_seed_part(&mut seed, order_type.as_ref());
    append_seed_part(&mut seed, &filled_qty.to_string());
    append_seed_part(&mut seed, &last_qty.to_string());
    append_seed_part(&mut seed, &last_px.to_string());
    append_seed_part(&mut seed, position_id.as_str());
    append_seed_part(&mut seed, &ts_last.as_u64().to_string());

    TradeId::new(deterministic_uuid_from_seed("reconciliation-fill", &seed))
}

/// Checks whether `trade_id` has the format of an inferred reconciliation `TradeId`.
///
/// A mismatch proves [`create_inferred_reconciliation_trade_id`] did not produce the ID, so callers
/// can skip regenerating it; a match does not prove the ID was inferred.
#[must_use]
pub fn is_inferred_reconciliation_trade_id_format(trade_id: &TradeId) -> bool {
    Uuid::parse_str(trade_id.as_str())
        .is_ok_and(|uuid| uuid.get_version_num() == usize::from(DETERMINISTIC_UUID_VERSION))
}

/// The `account_id` scopes the ID to the venue account, preventing cross-account
/// collisions where the engine would otherwise fall back to `ClientOrderId::from(venue_order_id)`
/// and conflate orders from different accounts. The `ts_last` (venue-provided) ensures that
/// successive reconciliation incidents with the same shape get distinct IDs, while the same
/// logical event replayed after restart still hashes the same (venue re-reports identical ts).
#[must_use]
#[expect(clippy::too_many_arguments)]
pub fn create_position_reconciliation_venue_order_id(
    account_id: AccountId,
    instrument_id: InstrumentId,
    order_side: OrderSide,
    order_type: OrderType,
    quantity: Quantity,
    price: Option<Price>,
    venue_position_id: Option<PositionId>,
    tag: Option<&str>,
    ts_last: UnixNanos,
) -> VenueOrderId {
    let mut seed = String::from("position-reconciliation-order");
    append_seed_part(&mut seed, account_id.as_str());
    append_seed_part(&mut seed, &instrument_id.to_string());
    append_seed_part(&mut seed, order_side.as_ref());
    append_seed_part(&mut seed, order_type.as_ref());
    append_seed_part(&mut seed, &quantity.to_string());
    append_seed_part(
        &mut seed,
        &price.map_or_else(String::new, |value| value.to_string()),
    );
    append_seed_part(
        &mut seed,
        &venue_position_id.map_or_else(String::new, |value| value.to_string()),
    );
    append_seed_part(&mut seed, tag.unwrap_or(""));
    append_seed_part(&mut seed, &ts_last.as_u64().to_string());

    VenueOrderId::new(deterministic_uuid_from_seed(
        "position-reconciliation-order",
        &seed,
    ))
}

/// Returns active fill IDs proven to come from deterministic reconciliation inference.
///
/// Replays candidate order history to recover each fill's cumulative quantity before comparing
/// the complete deterministic ID. A matching UUID format alone does not establish provenance.
///
/// # Errors
///
/// Returns an error if candidate order history cannot be replayed.
pub fn inferred_reconciliation_trade_ids(order: &OrderAny) -> anyhow::Result<Vec<TradeId>> {
    let events = order.events();
    let trade_ids = order.trade_ids();

    let is_candidate = |fill: &OrderFilled| {
        fill.reconciliation
            && is_inferred_reconciliation_trade_id_format(&fill.trade_id)
            && trade_ids.contains(&&fill.trade_id)
    };

    if !events
        .iter()
        .any(|event| matches!(event, OrderEventAny::Filled(fill) if is_candidate(fill)))
    {
        return Ok(Vec::new());
    }

    let Some((first, remaining)) = events.split_first() else {
        return Ok(Vec::new());
    };

    let mut projected = OrderAny::from_events(vec![(*first).clone()]).map_err(|e| {
        anyhow::anyhow!(
            "cannot replay order {} for inferred fill detection: {e}",
            order.client_order_id(),
        )
    })?;

    let mut inferred_trade_ids = Vec::new();

    for event in remaining {
        projected.apply((*event).clone()).map_err(|e| {
            anyhow::anyhow!(
                "cannot replay order {} for inferred fill detection: {e}",
                order.client_order_id(),
            )
        })?;

        let OrderEventAny::Filled(fill) = event else {
            continue;
        };

        if !is_candidate(fill) {
            continue;
        }

        let external_position_id = PositionId::new(format!("{}-EXTERNAL", fill.instrument_id));
        let position_ids = [fill.position_id, Some(external_position_id)];

        let inferred = position_ids.into_iter().flatten().any(|position_id| {
            create_inferred_reconciliation_trade_id(
                fill.account_id,
                fill.instrument_id,
                fill.client_order_id,
                Some(fill.venue_order_id),
                fill.order_side,
                fill.order_type,
                projected.filled_qty(),
                fill.last_qty,
                fill.last_px,
                position_id,
                fill.ts_event,
            ) == fill.trade_id
        });

        if inferred {
            inferred_trade_ids.push(fill.trade_id);
        }
    }

    Ok(inferred_trade_ids)
}

fn synthetic_fill_id_suffix(
    namespace: &str,
    fill: &FillSnapshot,
    instrument_id: Option<InstrumentId>,
) -> u32 {
    let mut hash: u64 = FNV_OFFSET_BASIS;

    update_synthetic_fill_hash(&mut hash, namespace.as_bytes());
    if let Some(instrument_id) = instrument_id {
        update_synthetic_fill_hash(&mut hash, instrument_id.to_string().as_bytes());
    }
    update_synthetic_fill_hash(&mut hash, &fill.ts_event.to_le_bytes());
    update_synthetic_fill_hash(&mut hash, fill.venue_order_id.as_str().as_bytes());
    update_synthetic_fill_hash(&mut hash, order_side_tag(fill.side).as_bytes());
    update_synthetic_fill_hash(&mut hash, fill.qty.to_string().as_bytes());
    update_synthetic_fill_hash(&mut hash, fill.px.to_string().as_bytes());

    hash as u32
}

fn deterministic_uuid_from_seed(namespace: &str, seed: &str) -> String {
    let primary = stable_hash64(namespace.as_bytes(), &[seed.as_bytes()]);
    let secondary = stable_hash64(b"uuid-alt", &[namespace.as_bytes(), seed.as_bytes()]);
    let mut bytes = [0_u8; 16];
    bytes[..8].copy_from_slice(&primary.to_be_bytes());
    bytes[8..].copy_from_slice(&secondary.to_be_bytes());
    bytes[6] = (bytes[6] & 0x0f) | (DETERMINISTIC_UUID_VERSION << 4);
    bytes[8] = (bytes[8] & 0x3f) | 0x80;

    Uuid::from_bytes(bytes).to_string()
}

fn stable_hash64(namespace: &[u8], parts: &[&[u8]]) -> u64 {
    let mut hash: u64 = FNV_OFFSET_BASIS;

    update_synthetic_fill_hash(&mut hash, namespace);

    for part in parts {
        update_synthetic_fill_hash(&mut hash, part);
    }

    hash
}

fn append_seed_part(seed: &mut String, value: &str) {
    seed.push('|');
    seed.push_str(value);
}

fn update_synthetic_fill_hash(hash: &mut u64, bytes: &[u8]) {
    for &byte in bytes {
        *hash ^= byte as u64;
        *hash = hash.wrapping_mul(FNV_PRIME);
    }

    *hash ^= 0xff;
    *hash = hash.wrapping_mul(FNV_PRIME);
}

fn order_side_tag(side: OrderSide) -> &'static str {
    match side {
        OrderSide::Buy => "BUY",
        OrderSide::Sell => "SELL",
    }
}