Skip to main content

nautilus_execution/reconciliation/
ids.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//! Deterministic ID generation for reconciliation.
17//!
18//! These helpers hash the logical fill/order fields so that replayed reconciliation
19//! events (e.g. after a process restart) produce the same `TradeId` and
20//! `VenueOrderId` as the original run. That stability is what lets the engine's
21//! duplicate-fill sanitizer dedupe replays instead of treating them as new events.
22
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25    enums::{OrderSide, OrderType},
26    identifiers::{AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId},
27    types::{Price, Quantity},
28};
29use uuid::Uuid;
30
31use super::types::FillSnapshot;
32
33// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
34const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
35const FNV_PRIME: u64 = 0x0100_0000_01b3;
36
37/// Create a synthetic `VenueOrderId` for a derived fill.
38///
39/// The suffix is hashed from the fill fields and instrument so distinct fills never collide,
40/// and the same logical fill always yields the same `VenueOrderId` across restarts.
41///
42/// Format: `S-{hex_timestamp}-{hash_suffix}`
43#[must_use]
44pub fn create_synthetic_venue_order_id(
45    fill: &FillSnapshot,
46    instrument_id: InstrumentId,
47) -> VenueOrderId {
48    let hash_suffix = synthetic_fill_id_suffix("venue_order", fill, Some(instrument_id));
49    let venue_order_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
50    VenueOrderId::new(&venue_order_id_value)
51}
52
53/// Create a synthetic `TradeId` using stable fill fields.
54///
55/// Format: `S-{hex_timestamp}-{hash_suffix}`
56#[must_use]
57pub fn create_synthetic_trade_id(fill: &FillSnapshot) -> TradeId {
58    let hash_suffix = synthetic_fill_id_suffix("trade", fill, None);
59    let trade_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
60    TradeId::new(&trade_id_value)
61}
62
63/// Create a deterministic `TradeId` for an inferred reconciliation fill.
64///
65/// The `account_id` scopes the ID to the venue account, preventing cross-account
66/// collisions on venues where `venue_order_id` is only account-unique. The `ts_last`
67/// (venue-provided) differentiates successive reconciliation incidents with the same
68/// shape while keeping cross-restart replays deterministic.
69#[must_use]
70#[expect(clippy::too_many_arguments)]
71pub fn create_inferred_reconciliation_trade_id(
72    account_id: AccountId,
73    instrument_id: InstrumentId,
74    client_order_id: ClientOrderId,
75    venue_order_id: Option<VenueOrderId>,
76    order_side: OrderSide,
77    order_type: OrderType,
78    filled_qty: Quantity,
79    last_qty: Quantity,
80    last_px: Price,
81    position_id: PositionId,
82    ts_last: UnixNanos,
83) -> TradeId {
84    let mut seed = String::from("reconciliation-fill");
85    append_seed_part(&mut seed, account_id.as_str());
86    append_seed_part(&mut seed, &instrument_id.to_string());
87    append_seed_part(&mut seed, client_order_id.as_str());
88    append_seed_part(
89        &mut seed,
90        venue_order_id.as_ref().map_or("", |value| value.as_ref()),
91    );
92    append_seed_part(&mut seed, order_side.as_ref());
93    append_seed_part(&mut seed, order_type.as_ref());
94    append_seed_part(&mut seed, &filled_qty.to_string());
95    append_seed_part(&mut seed, &last_qty.to_string());
96    append_seed_part(&mut seed, &last_px.to_string());
97    append_seed_part(&mut seed, position_id.as_str());
98    append_seed_part(&mut seed, &ts_last.as_u64().to_string());
99
100    TradeId::new(deterministic_uuid_from_seed("reconciliation-fill", &seed))
101}
102
103/// The `account_id` scopes the ID to the venue account, preventing cross-account
104/// collisions where the engine would otherwise fall back to `ClientOrderId::from(venue_order_id)`
105/// and conflate orders from different accounts. The `ts_last` (venue-provided) ensures that
106/// successive reconciliation incidents with the same shape get distinct IDs, while the same
107/// logical event replayed after restart still hashes the same (venue re-reports identical ts).
108#[must_use]
109#[expect(clippy::too_many_arguments)]
110pub fn create_position_reconciliation_venue_order_id(
111    account_id: AccountId,
112    instrument_id: InstrumentId,
113    order_side: OrderSide,
114    order_type: OrderType,
115    quantity: Quantity,
116    price: Option<Price>,
117    venue_position_id: Option<PositionId>,
118    tag: Option<&str>,
119    ts_last: UnixNanos,
120) -> VenueOrderId {
121    let mut seed = String::from("position-reconciliation-order");
122    append_seed_part(&mut seed, account_id.as_str());
123    append_seed_part(&mut seed, &instrument_id.to_string());
124    append_seed_part(&mut seed, order_side.as_ref());
125    append_seed_part(&mut seed, order_type.as_ref());
126    append_seed_part(&mut seed, &quantity.to_string());
127    append_seed_part(
128        &mut seed,
129        &price.map_or_else(String::new, |value| value.to_string()),
130    );
131    append_seed_part(
132        &mut seed,
133        &venue_position_id.map_or_else(String::new, |value| value.to_string()),
134    );
135    append_seed_part(&mut seed, tag.unwrap_or(""));
136    append_seed_part(&mut seed, &ts_last.as_u64().to_string());
137
138    VenueOrderId::new(deterministic_uuid_from_seed(
139        "position-reconciliation-order",
140        &seed,
141    ))
142}
143
144fn synthetic_fill_id_suffix(
145    namespace: &str,
146    fill: &FillSnapshot,
147    instrument_id: Option<InstrumentId>,
148) -> u32 {
149    let mut hash: u64 = FNV_OFFSET_BASIS;
150
151    update_synthetic_fill_hash(&mut hash, namespace.as_bytes());
152    if let Some(instrument_id) = instrument_id {
153        update_synthetic_fill_hash(&mut hash, instrument_id.to_string().as_bytes());
154    }
155    update_synthetic_fill_hash(&mut hash, &fill.ts_event.to_le_bytes());
156    update_synthetic_fill_hash(&mut hash, fill.venue_order_id.as_str().as_bytes());
157    update_synthetic_fill_hash(&mut hash, order_side_tag(fill.side).as_bytes());
158    update_synthetic_fill_hash(&mut hash, fill.qty.to_string().as_bytes());
159    update_synthetic_fill_hash(&mut hash, fill.px.to_string().as_bytes());
160
161    hash as u32
162}
163
164fn deterministic_uuid_from_seed(namespace: &str, seed: &str) -> String {
165    let primary = stable_hash64(namespace.as_bytes(), &[seed.as_bytes()]);
166    let secondary = stable_hash64(b"uuid-alt", &[namespace.as_bytes(), seed.as_bytes()]);
167    let mut bytes = [0_u8; 16];
168    bytes[..8].copy_from_slice(&primary.to_be_bytes());
169    bytes[8..].copy_from_slice(&secondary.to_be_bytes());
170    bytes[6] = (bytes[6] & 0x0f) | 0x50;
171    bytes[8] = (bytes[8] & 0x3f) | 0x80;
172
173    Uuid::from_bytes(bytes).to_string()
174}
175
176fn stable_hash64(namespace: &[u8], parts: &[&[u8]]) -> u64 {
177    let mut hash: u64 = FNV_OFFSET_BASIS;
178
179    update_synthetic_fill_hash(&mut hash, namespace);
180
181    for part in parts {
182        update_synthetic_fill_hash(&mut hash, part);
183    }
184
185    hash
186}
187
188fn append_seed_part(seed: &mut String, value: &str) {
189    seed.push('|');
190    seed.push_str(value);
191}
192
193fn update_synthetic_fill_hash(hash: &mut u64, bytes: &[u8]) {
194    for &byte in bytes {
195        *hash ^= byte as u64;
196        *hash = hash.wrapping_mul(FNV_PRIME);
197    }
198
199    *hash ^= 0xff;
200    *hash = hash.wrapping_mul(FNV_PRIME);
201}
202
203fn order_side_tag(side: OrderSide) -> &'static str {
204    match side {
205        OrderSide::Buy => "BUY",
206        OrderSide::Sell => "SELL",
207        _ => "UNSPECIFIED",
208    }
209}