nautilus_execution/reconciliation/
ids.rs1use 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
35const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
37const FNV_PRIME: u64 = 0x0100_0000_01b3;
38
39const DETERMINISTIC_UUID_VERSION: u8 = 5;
40
41#[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#[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#[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#[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#[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
158pub 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}