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//! Hashes 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    events::{OrderEventAny, OrderFilled},
27    identifiers::{AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId},
28    orders::{Order, OrderAny},
29    types::{Price, Quantity},
30};
31use uuid::Uuid;
32
33use super::types::FillSnapshot;
34
35// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
36const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
37const FNV_PRIME: u64 = 0x0100_0000_01b3;
38
39const DETERMINISTIC_UUID_VERSION: u8 = 5;
40
41/// Create a synthetic `VenueOrderId` for a derived fill.
42///
43/// The suffix is hashed from the fill fields and instrument so distinct fills never collide,
44/// and the same logical fill always yields the same `VenueOrderId` across restarts.
45///
46/// Format: `S-{hex_timestamp}-{hash_suffix}`
47#[must_use]
48pub(super) fn create_synthetic_venue_order_id(
49    fill: &FillSnapshot,
50    instrument_id: InstrumentId,
51) -> VenueOrderId {
52    let hash_suffix = synthetic_fill_id_suffix("venue_order", fill, Some(instrument_id));
53    let venue_order_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
54    VenueOrderId::new(&venue_order_id_value)
55}
56
57/// Create a synthetic `TradeId` using stable fill fields.
58///
59/// Format: `S-{hex_timestamp}-{hash_suffix}`
60#[must_use]
61pub(super) fn create_synthetic_trade_id(fill: &FillSnapshot) -> TradeId {
62    let hash_suffix = synthetic_fill_id_suffix("trade", fill, None);
63    let trade_id_value = format!("S-{:x}-{hash_suffix:08x}", fill.ts_event);
64    TradeId::new(&trade_id_value)
65}
66
67/// Create a deterministic `TradeId` for an inferred reconciliation fill.
68///
69/// The `account_id` scopes the ID to the venue account, preventing cross-account
70/// collisions on venues where `venue_order_id` is only account-unique. The `ts_last`
71/// (venue-provided) differentiates successive reconciliation incidents with the same
72/// shape while keeping cross-restart replays deterministic.
73#[must_use]
74#[expect(clippy::too_many_arguments)]
75pub fn create_inferred_reconciliation_trade_id(
76    account_id: AccountId,
77    instrument_id: InstrumentId,
78    client_order_id: ClientOrderId,
79    venue_order_id: Option<VenueOrderId>,
80    order_side: OrderSide,
81    order_type: OrderType,
82    filled_qty: Quantity,
83    last_qty: Quantity,
84    last_px: Price,
85    position_id: PositionId,
86    ts_last: UnixNanos,
87) -> TradeId {
88    let mut seed = String::from("reconciliation-fill");
89    append_seed_part(&mut seed, account_id.as_str());
90    append_seed_part(&mut seed, &instrument_id.to_string());
91    append_seed_part(&mut seed, client_order_id.as_str());
92    append_seed_part(
93        &mut seed,
94        venue_order_id.as_ref().map_or("", |value| value.as_ref()),
95    );
96    append_seed_part(&mut seed, order_side.as_ref());
97    append_seed_part(&mut seed, order_type.as_ref());
98    append_seed_part(&mut seed, &filled_qty.to_string());
99    append_seed_part(&mut seed, &last_qty.to_string());
100    append_seed_part(&mut seed, &last_px.to_string());
101    append_seed_part(&mut seed, position_id.as_str());
102    append_seed_part(&mut seed, &ts_last.as_u64().to_string());
103
104    TradeId::new(deterministic_uuid_from_seed("reconciliation-fill", &seed))
105}
106
107/// Checks whether `trade_id` has the format of an inferred reconciliation `TradeId`.
108///
109/// A mismatch proves [`create_inferred_reconciliation_trade_id`] did not produce the ID, so callers
110/// can skip regenerating it; a match does not prove the ID was inferred.
111#[must_use]
112pub fn is_inferred_reconciliation_trade_id_format(trade_id: &TradeId) -> bool {
113    Uuid::parse_str(trade_id.as_str())
114        .is_ok_and(|uuid| uuid.get_version_num() == usize::from(DETERMINISTIC_UUID_VERSION))
115}
116
117/// The `account_id` scopes the ID to the venue account, preventing cross-account
118/// collisions where the engine would otherwise fall back to `ClientOrderId::from(venue_order_id)`
119/// and conflate orders from different accounts. The `ts_last` (venue-provided) ensures that
120/// successive reconciliation incidents with the same shape get distinct IDs, while the same
121/// logical event replayed after restart still hashes the same (venue re-reports identical ts).
122#[must_use]
123#[expect(clippy::too_many_arguments)]
124pub fn create_position_reconciliation_venue_order_id(
125    account_id: AccountId,
126    instrument_id: InstrumentId,
127    order_side: OrderSide,
128    order_type: OrderType,
129    quantity: Quantity,
130    price: Option<Price>,
131    venue_position_id: Option<PositionId>,
132    tag: Option<&str>,
133    ts_last: UnixNanos,
134) -> VenueOrderId {
135    let mut seed = String::from("position-reconciliation-order");
136    append_seed_part(&mut seed, account_id.as_str());
137    append_seed_part(&mut seed, &instrument_id.to_string());
138    append_seed_part(&mut seed, order_side.as_ref());
139    append_seed_part(&mut seed, order_type.as_ref());
140    append_seed_part(&mut seed, &quantity.to_string());
141    append_seed_part(
142        &mut seed,
143        &price.map_or_else(String::new, |value| value.to_string()),
144    );
145    append_seed_part(
146        &mut seed,
147        &venue_position_id.map_or_else(String::new, |value| value.to_string()),
148    );
149    append_seed_part(&mut seed, tag.unwrap_or(""));
150    append_seed_part(&mut seed, &ts_last.as_u64().to_string());
151
152    VenueOrderId::new(deterministic_uuid_from_seed(
153        "position-reconciliation-order",
154        &seed,
155    ))
156}
157
158/// Returns active fill IDs proven to come from deterministic reconciliation inference.
159///
160/// Replays candidate order history to recover each fill's cumulative quantity before comparing
161/// the complete deterministic ID. A matching UUID format alone does not establish provenance.
162///
163/// # Errors
164///
165/// Returns an error if candidate order history cannot be replayed.
166pub fn inferred_reconciliation_trade_ids(order: &OrderAny) -> anyhow::Result<Vec<TradeId>> {
167    let events = order.events();
168    let trade_ids = order.trade_ids();
169
170    let is_candidate = |fill: &OrderFilled| {
171        fill.reconciliation
172            && is_inferred_reconciliation_trade_id_format(&fill.trade_id)
173            && trade_ids.contains(&&fill.trade_id)
174    };
175
176    if !events
177        .iter()
178        .any(|event| matches!(event, OrderEventAny::Filled(fill) if is_candidate(fill)))
179    {
180        return Ok(Vec::new());
181    }
182
183    let Some((first, remaining)) = events.split_first() else {
184        return Ok(Vec::new());
185    };
186
187    let mut projected = OrderAny::from_events(vec![(*first).clone()]).map_err(|e| {
188        anyhow::anyhow!(
189            "cannot replay order {} for inferred fill detection: {e}",
190            order.client_order_id(),
191        )
192    })?;
193
194    let mut inferred_trade_ids = Vec::new();
195
196    for event in remaining {
197        projected.apply((*event).clone()).map_err(|e| {
198            anyhow::anyhow!(
199                "cannot replay order {} for inferred fill detection: {e}",
200                order.client_order_id(),
201            )
202        })?;
203
204        let OrderEventAny::Filled(fill) = event else {
205            continue;
206        };
207
208        if !is_candidate(fill) {
209            continue;
210        }
211
212        let external_position_id = PositionId::new(format!("{}-EXTERNAL", fill.instrument_id));
213        let position_ids = [fill.position_id, Some(external_position_id)];
214
215        let inferred = position_ids.into_iter().flatten().any(|position_id| {
216            create_inferred_reconciliation_trade_id(
217                fill.account_id,
218                fill.instrument_id,
219                fill.client_order_id,
220                Some(fill.venue_order_id),
221                fill.order_side,
222                fill.order_type,
223                projected.filled_qty(),
224                fill.last_qty,
225                fill.last_px,
226                position_id,
227                fill.ts_event,
228            ) == fill.trade_id
229        });
230
231        if inferred {
232            inferred_trade_ids.push(fill.trade_id);
233        }
234    }
235
236    Ok(inferred_trade_ids)
237}
238
239fn synthetic_fill_id_suffix(
240    namespace: &str,
241    fill: &FillSnapshot,
242    instrument_id: Option<InstrumentId>,
243) -> u32 {
244    let mut hash: u64 = FNV_OFFSET_BASIS;
245
246    update_synthetic_fill_hash(&mut hash, namespace.as_bytes());
247    if let Some(instrument_id) = instrument_id {
248        update_synthetic_fill_hash(&mut hash, instrument_id.to_string().as_bytes());
249    }
250    update_synthetic_fill_hash(&mut hash, &fill.ts_event.to_le_bytes());
251    update_synthetic_fill_hash(&mut hash, fill.venue_order_id.as_str().as_bytes());
252    update_synthetic_fill_hash(&mut hash, order_side_tag(fill.side).as_bytes());
253    update_synthetic_fill_hash(&mut hash, fill.qty.to_string().as_bytes());
254    update_synthetic_fill_hash(&mut hash, fill.px.to_string().as_bytes());
255
256    hash as u32
257}
258
259fn deterministic_uuid_from_seed(namespace: &str, seed: &str) -> String {
260    let primary = stable_hash64(namespace.as_bytes(), &[seed.as_bytes()]);
261    let secondary = stable_hash64(b"uuid-alt", &[namespace.as_bytes(), seed.as_bytes()]);
262    let mut bytes = [0_u8; 16];
263    bytes[..8].copy_from_slice(&primary.to_be_bytes());
264    bytes[8..].copy_from_slice(&secondary.to_be_bytes());
265    bytes[6] = (bytes[6] & 0x0f) | (DETERMINISTIC_UUID_VERSION << 4);
266    bytes[8] = (bytes[8] & 0x3f) | 0x80;
267
268    Uuid::from_bytes(bytes).to_string()
269}
270
271fn stable_hash64(namespace: &[u8], parts: &[&[u8]]) -> u64 {
272    let mut hash: u64 = FNV_OFFSET_BASIS;
273
274    update_synthetic_fill_hash(&mut hash, namespace);
275
276    for part in parts {
277        update_synthetic_fill_hash(&mut hash, part);
278    }
279
280    hash
281}
282
283fn append_seed_part(seed: &mut String, value: &str) {
284    seed.push('|');
285    seed.push_str(value);
286}
287
288fn update_synthetic_fill_hash(hash: &mut u64, bytes: &[u8]) {
289    for &byte in bytes {
290        *hash ^= byte as u64;
291        *hash = hash.wrapping_mul(FNV_PRIME);
292    }
293
294    *hash ^= 0xff;
295    *hash = hash.wrapping_mul(FNV_PRIME);
296}
297
298fn order_side_tag(side: OrderSide) -> &'static str {
299    match side {
300        OrderSide::Buy => "BUY",
301        OrderSide::Sell => "SELL",
302    }
303}