Skip to main content

nautilus_kraken/websocket/dispatch/
futures.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//! WebSocket execution dispatch for the Kraken Futures API.
17//!
18//! Routes `OpenOrdersDelta`, `OpenOrdersCancel`, and `FillsDelta` messages to
19//! typed order events (for tracked orders) or status / fill reports (for
20//! external orders) under the two-tier dispatch contract.
21
22use std::sync::Arc;
23
24use ahash::AHashMap;
25use nautilus_core::{AtomicMap, UUID4, UnixNanos};
26use nautilus_live::ExecutionEventEmitter;
27use nautilus_model::{
28    enums::{OrderSide, OrderStatus, OrderType, TimeInForce},
29    events::{OrderCanceled, OrderEventAny, OrderUpdated},
30    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
31    instruments::{Instrument, InstrumentAny},
32    reports::OrderStatusReport,
33    types::{Price, Quantity},
34};
35
36use super::{
37    DeltaSnapshot, OrderIdentity, WsDispatchState, ensure_accepted_emitted,
38    fill_report_to_order_filled, resolve_client_order_id,
39};
40use crate::{
41    common::lookup_instrument_in_snapshot,
42    websocket::futures::{
43        messages::{
44            KrakenFuturesFill, KrakenFuturesFillsDelta, KrakenFuturesOpenOrdersCancel,
45            KrakenFuturesOpenOrdersDelta,
46        },
47        parse::{parse_futures_ws_fill_report, parse_futures_ws_order_status_report},
48    },
49};
50
51/// Dispatches a Kraken Futures `OpenOrdersDelta` message.
52///
53/// Fill-driven cancel deltas (`is_cancel=true` with reason `full_fill` /
54/// `partial_fill`) are skipped — the corresponding `FillsDelta` carries the
55/// real fill, so emitting a synthetic Canceled here would race with the
56/// genuine `OrderFilled`.
57#[expect(clippy::too_many_arguments)]
58pub fn open_orders_delta(
59    delta: &KrakenFuturesOpenOrdersDelta,
60    state: &WsDispatchState,
61    emitter: &ExecutionEventEmitter,
62    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
63    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
64    order_instrument_map: &Arc<AtomicMap<String, InstrumentId>>,
65    venue_client_map: &Arc<AtomicMap<String, ClientOrderId>>,
66    venue_order_qty: &Arc<AtomicMap<String, Quantity>>,
67    account_id: AccountId,
68    ts_init: UnixNanos,
69) {
70    if delta.is_fill_driven_cancel() {
71        log::debug!(
72            "Skipping fill-driven open_orders delta: order_id={}, reason={:?}",
73            delta.order.order_id,
74            delta.reason,
75        );
76        return;
77    }
78
79    let product_id = delta.order.instrument.as_str();
80    let instruments = instruments.load();
81    let Some(instrument) = lookup_instrument_in_snapshot(&instruments, product_id) else {
82        log::warn!("No instrument for product_id: {product_id}");
83        return;
84    };
85
86    // Cache instrument and qty by venue order id so cancel-only messages
87    // (which arrive without the order body) can be reconstructed for the
88    // external fallback path.
89    order_instrument_map.insert(delta.order.order_id.clone(), instrument.id());
90    let qty = Quantity::new(delta.order.qty, instrument.size_precision());
91    venue_order_qty.insert(delta.order.order_id.clone(), qty);
92
93    let resolved_id = delta
94        .order
95        .cli_ord_id
96        .as_ref()
97        .map(|id| resolve_client_order_id(id, truncated_id_map));
98
99    // Stale-report suppression: an order that already reached the filled
100    // terminal state should not produce more events even if a late delta
101    // arrives. `filled_orders` persists past `cleanup_terminal` precisely
102    // for this check.
103    if let Some(cid) = resolved_id
104        && state.filled_orders.contains(&cid)
105    {
106        log::debug!(
107            "Skipping stale open_orders delta for filled order: cid={cid}, order_id={}",
108            delta.order.order_id,
109        );
110        return;
111    }
112
113    if let Some(client_order_id) = resolved_id {
114        venue_client_map.insert(delta.order.order_id.clone(), client_order_id);
115
116        if let Some(identity) = state.lookup_identity(&client_order_id) {
117            delta_tracked(
118                delta,
119                client_order_id,
120                &identity,
121                instrument,
122                state,
123                emitter,
124                account_id,
125                ts_init,
126            );
127            return;
128        }
129    }
130
131    // External / untracked: fall back to a status report.
132    match parse_futures_ws_order_status_report(
133        &delta.order,
134        delta.is_cancel,
135        delta.reason.as_deref(),
136        instrument,
137        account_id,
138        ts_init,
139    ) {
140        Ok(mut report) => {
141            if let Some(cid) = resolved_id {
142                report = report.with_client_order_id(cid);
143            }
144            emitter.send_order_status_report(report);
145        }
146        Err(e) => log::error!("Failed to parse futures order status report: {e}"),
147    }
148}
149
150#[expect(clippy::too_many_arguments)]
151fn delta_tracked(
152    delta: &KrakenFuturesOpenOrdersDelta,
153    client_order_id: ClientOrderId,
154    identity: &OrderIdentity,
155    instrument: &InstrumentAny,
156    state: &WsDispatchState,
157    emitter: &ExecutionEventEmitter,
158    account_id: AccountId,
159    ts_init: UnixNanos,
160) {
161    let venue_order_id = VenueOrderId::new(&delta.order.order_id);
162    let ts_event = millis_to_nanos(delta.order.last_update_time);
163    let new_filled = Quantity::new(delta.order.filled, instrument.size_precision());
164
165    if delta.is_cancel {
166        ensure_accepted_emitted(
167            client_order_id,
168            venue_order_id,
169            account_id,
170            identity,
171            state,
172            emitter,
173            ts_event,
174            ts_init,
175        );
176        let canceled = OrderCanceled::new(
177            emitter.trader_id(),
178            identity.strategy_id,
179            identity.instrument_id,
180            client_order_id,
181            UUID4::new(),
182            ts_event,
183            ts_init,
184            false,
185            Some(venue_order_id),
186            Some(account_id),
187        );
188        emitter.send_order_event(OrderEventAny::Canceled(canceled));
189        state.cleanup_terminal(&client_order_id);
190        return;
191    }
192
193    let already_accepted = state.emitted_accepted.contains(&client_order_id);
194    ensure_accepted_emitted(
195        client_order_id,
196        venue_order_id,
197        account_id,
198        identity,
199        state,
200        emitter,
201        ts_event,
202        ts_init,
203    );
204
205    let qty = Quantity::new(delta.order.qty, instrument.size_precision());
206    let snapshot = DeltaSnapshot::new(
207        qty,
208        new_filled,
209        delta.order.limit_price,
210        delta.order.stop_price,
211    );
212
213    if !already_accepted {
214        // First delta seen for this order: the placement Accepted is enough.
215        state.record_delta_snapshot(client_order_id, snapshot);
216        return;
217    }
218
219    // Follow-up delta. The two emission-relevant signals are independent:
220    //   * filled increased       -> partial-fill notification (FillsDelta has it,
221    //                               nothing to emit from here)
222    //   * non-fill field changed -> modify acknowledgement (emit OrderUpdated)
223    // Both can be true simultaneously when a user amends a partially filled
224    // order, so check the modify branch regardless of fill movement.
225    let previous = state.previous_delta_snapshot(&client_order_id);
226    state.record_delta_snapshot(client_order_id, snapshot);
227
228    let non_fill_changed = previous.is_some_and(|prev| !snapshot.non_fill_fields_match(&prev));
229    if !non_fill_changed {
230        return;
231    }
232
233    // Modify ack: refresh tracked quantity (size may have changed) and emit
234    // OrderUpdated so the engine clears PendingUpdate.
235    state.update_identity_quantity(&client_order_id, qty);
236    let updated = OrderUpdated::new(
237        emitter.trader_id(),
238        identity.strategy_id,
239        identity.instrument_id,
240        client_order_id,
241        qty,
242        UUID4::new(),
243        ts_event,
244        ts_init,
245        false,
246        Some(venue_order_id),
247        Some(account_id),
248        delta
249            .order
250            .limit_price
251            .map(|p| Price::new(p, instrument.price_precision())),
252        delta
253            .order
254            .stop_price
255            .map(|p| Price::new(p, instrument.price_precision())),
256        None,
257        false,
258    );
259    emitter.send_order_event(OrderEventAny::Updated(updated));
260}
261
262/// Dispatches a Kraken Futures `OpenOrdersCancel` (cancel-only) message.
263#[expect(clippy::too_many_arguments)]
264pub fn open_orders_cancel(
265    cancel: &KrakenFuturesOpenOrdersCancel,
266    state: &WsDispatchState,
267    emitter: &ExecutionEventEmitter,
268    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
269    order_instrument_map: &Arc<AtomicMap<String, InstrumentId>>,
270    venue_client_map: &Arc<AtomicMap<String, ClientOrderId>>,
271    venue_order_qty: &Arc<AtomicMap<String, Quantity>>,
272    account_id: AccountId,
273    ts_init: UnixNanos,
274) {
275    // Skip fill-driven removals (the FillsDelta carries the real fill).
276    if let Some(ref reason) = cancel.reason
277        && (reason == "full_fill" || reason == "partial_fill")
278    {
279        log::debug!(
280            "Skipping fill-driven cancel: order_id={}, reason={reason}",
281            cancel.order_id,
282        );
283        return;
284    }
285
286    let venue_order_id = VenueOrderId::new(&cancel.order_id);
287    let resolved_id = cancel
288        .cli_ord_id
289        .as_ref()
290        .map(|id| resolve_client_order_id(id, truncated_id_map))
291        .or_else(|| venue_client_map.load().get(&cancel.order_id).copied());
292
293    if let Some(client_order_id) = resolved_id
294        && let Some(identity) = state.lookup_identity(&client_order_id)
295    {
296        let ts_event = ts_init;
297        ensure_accepted_emitted(
298            client_order_id,
299            venue_order_id,
300            account_id,
301            &identity,
302            state,
303            emitter,
304            ts_event,
305            ts_init,
306        );
307        let canceled = OrderCanceled::new(
308            emitter.trader_id(),
309            identity.strategy_id,
310            identity.instrument_id,
311            client_order_id,
312            UUID4::new(),
313            ts_event,
314            ts_init,
315            false,
316            Some(venue_order_id),
317            Some(account_id),
318        );
319        emitter.send_order_event(OrderEventAny::Canceled(canceled));
320        state.cleanup_terminal(&client_order_id);
321        return;
322    }
323
324    // External fallback: build a status report from the side caches.
325    let Some(instrument_id) = order_instrument_map.load().get(&cancel.order_id).copied() else {
326        log::warn!(
327            "Cannot resolve instrument for cancel: order_id={}, \
328             order not seen in previous delta",
329            cancel.order_id
330        );
331        return;
332    };
333
334    let Some(quantity) = venue_order_qty.load().get(&cancel.order_id).copied() else {
335        log::warn!(
336            "Cannot resolve quantity for cancel: order_id={}, skipping",
337            cancel.order_id
338        );
339        return;
340    };
341
342    let report = OrderStatusReport::new(
343        account_id,
344        instrument_id,
345        resolved_id,
346        venue_order_id,
347        OrderSide::NoOrderSide,
348        OrderType::Limit,
349        TimeInForce::Gtc,
350        OrderStatus::Canceled,
351        quantity,
352        Quantity::zero(0),
353        ts_init,
354        ts_init,
355        ts_init,
356        None,
357    );
358    let report = if let Some(ref reason) = cancel.reason
359        && !reason.is_empty()
360    {
361        report.with_cancel_reason(reason.clone())
362    } else {
363        report
364    };
365    emitter.send_order_status_report(report);
366}
367
368/// Dispatches a Kraken Futures `FillsDelta` message.
369#[expect(clippy::too_many_arguments)]
370pub fn fills_delta(
371    fills_delta: &KrakenFuturesFillsDelta,
372    state: &WsDispatchState,
373    emitter: &ExecutionEventEmitter,
374    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
375    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
376    venue_client_map: &Arc<AtomicMap<String, ClientOrderId>>,
377    account_id: AccountId,
378    ts_init: UnixNanos,
379) {
380    let instruments = instruments.load();
381    let venue_clients = venue_client_map.load();
382
383    for fill in &fills_delta.fills {
384        single_fill(
385            fill,
386            state,
387            emitter,
388            &instruments,
389            truncated_id_map,
390            &venue_clients,
391            account_id,
392            ts_init,
393        );
394    }
395}
396
397#[expect(clippy::too_many_arguments)]
398fn single_fill(
399    fill: &KrakenFuturesFill,
400    state: &WsDispatchState,
401    emitter: &ExecutionEventEmitter,
402    instruments: &AHashMap<InstrumentId, InstrumentAny>,
403    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
404    venue_client_map: &AHashMap<String, ClientOrderId>,
405    account_id: AccountId,
406    ts_init: UnixNanos,
407) {
408    let product_id = match &fill.instrument {
409        Some(id) => id.as_str(),
410        None => {
411            log::warn!("Fill missing instrument field: fill_id={}", fill.fill_id);
412            return;
413        }
414    };
415
416    let Some(instrument) = lookup_instrument_in_snapshot(instruments, product_id) else {
417        log::warn!("No instrument for product_id: {product_id}");
418        return;
419    };
420
421    let mut report = match parse_futures_ws_fill_report(fill, instrument, account_id, ts_init) {
422        Ok(report) => report,
423        Err(e) => {
424            log::error!("Failed to parse futures fill report: {e}");
425            return;
426        }
427    };
428
429    let resolved_id = fill
430        .cli_ord_id
431        .as_deref()
432        .filter(|s| !s.is_empty())
433        .map(|id| resolve_client_order_id(id, truncated_id_map))
434        .or_else(|| venue_client_map.get(&fill.order_id).copied());
435
436    if let Some(cid) = resolved_id
437        && state.filled_orders.contains(&cid)
438    {
439        log::debug!(
440            "Skipping stale fill for filled order: cid={cid}, order_id={}",
441            fill.order_id,
442        );
443        return;
444    }
445
446    if let Some(client_order_id) = resolved_id {
447        report.client_order_id = Some(client_order_id);
448
449        if let Some(identity) = state.lookup_identity(&client_order_id) {
450            if state.check_and_insert_trade(report.trade_id) {
451                log::debug!(
452                    "Skipping duplicate fill for {client_order_id}: trade_id={}",
453                    report.trade_id
454                );
455                return;
456            }
457            ensure_accepted_emitted(
458                client_order_id,
459                report.venue_order_id,
460                account_id,
461                &identity,
462                state,
463                emitter,
464                report.ts_event,
465                ts_init,
466            );
467            let filled = fill_report_to_order_filled(
468                &report,
469                emitter.trader_id(),
470                &identity,
471                instrument.quote_currency(),
472                client_order_id,
473            );
474            emitter.send_order_event(OrderEventAny::Filled(filled));
475
476            // Update cumulative filled and cleanup on terminal fill.
477            let previous = state
478                .previous_filled_qty(&client_order_id)
479                .unwrap_or_else(|| Quantity::zero(instrument.size_precision()));
480            let cumulative = previous + report.last_qty;
481            state.record_filled_qty(client_order_id, cumulative);
482
483            if cumulative >= identity.quantity {
484                state.insert_filled(client_order_id);
485                state.cleanup_terminal(&client_order_id);
486            }
487            return;
488        }
489    }
490
491    // External fallback.
492    if state.check_and_insert_trade(report.trade_id) {
493        log::debug!(
494            "Skipping duplicate external fill: trade_id={}",
495            report.trade_id
496        );
497        return;
498    }
499    emitter.send_fill_report(report);
500}
501
502#[inline]
503fn millis_to_nanos(millis: i64) -> UnixNanos {
504    UnixNanos::from((millis as u64) * 1_000_000)
505}