Skip to main content

nautilus_kraken/websocket/dispatch/
spot.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 Spot v2 API.
17//!
18//! A single spot execution can carry both a status update (handled via the
19//! order event path) and a fill (when `exec_id` is present, handled via the
20//! fill path). Tracked orders emit typed events; external orders fall through
21//! to reports.
22
23use std::sync::Arc;
24
25use nautilus_core::{AtomicMap, UUID4, UnixNanos};
26use nautilus_live::ExecutionEventEmitter;
27use nautilus_model::{
28    enums::OrderStatus,
29    events::{
30        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderTriggered, OrderUpdated,
31    },
32    identifiers::{AccountId, ClientOrderId, InstrumentId},
33    instruments::{Instrument, InstrumentAny},
34    reports::{FillReport, OrderStatusReport},
35    types::Quantity,
36};
37
38use super::{
39    OrderIdentity, WsDispatchState, ensure_accepted_emitted, fill_report_to_order_filled,
40    resolve_client_order_id,
41};
42use crate::{
43    common::lookup_instrument_in_snapshot,
44    websocket::spot_v2::{
45        enums::KrakenExecType,
46        messages::KrakenWsExecutionData,
47        parse::{parse_ws_fill_report, parse_ws_order_status_report},
48    },
49};
50
51/// Dispatches a Kraken Spot v2 execution message.
52#[expect(clippy::too_many_arguments)]
53pub fn execution(
54    exec: &KrakenWsExecutionData,
55    state: &WsDispatchState,
56    emitter: &ExecutionEventEmitter,
57    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
58    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
59    order_qty_cache: &Arc<AtomicMap<String, f64>>,
60    account_id: AccountId,
61    ts_init: UnixNanos,
62) {
63    execution_inner(
64        exec,
65        state,
66        emitter,
67        instruments,
68        truncated_id_map,
69        order_qty_cache,
70        account_id,
71        ts_init,
72    );
73
74    // Run terminal cache cleanup regardless of which early return the inner
75    // dispatch hit (symbol miss, instrument miss, stale-filled suppression,
76    // parse error). Keying eviction off `exec.order_id` means it does not
77    // depend on `cl_ord_id` or identity resolution succeeding.
78    if is_terminal_exec_type(exec.exec_type) {
79        state.forget_order_symbol(&exec.order_id);
80        state.forget_order_client_id(&exec.order_id);
81    }
82}
83
84#[expect(clippy::too_many_arguments)]
85fn execution_inner(
86    exec: &KrakenWsExecutionData,
87    state: &WsDispatchState,
88    emitter: &ExecutionEventEmitter,
89    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
90    truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
91    order_qty_cache: &Arc<AtomicMap<String, f64>>,
92    account_id: AccountId,
93    ts_init: UnixNanos,
94) {
95    // Resolve the trading symbol. Per Kraken's executions docs, follow-up
96    // frames (`new`, `amended`, `restated`, `status`) carry only changed
97    // fields and omit `symbol`. We cache the symbol from the first frame for
98    // the venue order id and consult it here when the current frame omits it.
99    let cached_symbol;
100    let symbol = match exec.symbol.as_deref() {
101        Some(s) => {
102            state.cache_order_symbol(&exec.order_id, s);
103            s
104        }
105        None => match state.lookup_order_symbol(&exec.order_id) {
106            Some(s) => {
107                cached_symbol = s;
108                cached_symbol.as_str()
109            }
110            None => {
111                log::debug!(
112                    "Execution message without symbol and no cached mapping: \
113                     exec_type={:?}, order_id={}",
114                    exec.exec_type,
115                    exec.order_id
116                );
117                return;
118            }
119        },
120    };
121    let instruments = instruments.load();
122    let Some(instrument) = lookup_instrument_in_snapshot(&instruments, symbol) else {
123        log::warn!("No instrument for symbol: {symbol}");
124        return;
125    };
126
127    // Mirror the existing behaviour: cache the order quantity by truncated cli
128    // ord id so the parser can fall back to it for quote-quantity orders.
129    let cached_qty = exec
130        .cl_ord_id
131        .as_ref()
132        .and_then(|id| order_qty_cache.load().get(id).copied());
133    if let (Some(qty), Some(cl_ord_id)) = (exec.order_qty, &exec.cl_ord_id) {
134        order_qty_cache.insert(cl_ord_id.clone(), qty);
135    }
136
137    // Resolve the `ClientOrderId`. When the frame carries `cl_ord_id` we
138    // resolve it through the truncation map and seed the venue-id cache so
139    // later delta frames (which routinely omit `cl_ord_id`) can recover it.
140    // When `cl_ord_id` is absent we consult the cache; without this lookup a
141    // tracked order's delta `new` would fall through to the untracked report
142    // path and the strategy would never see `OrderAccepted` (issue #4051).
143    let resolved_id = match exec.cl_ord_id.as_ref() {
144        Some(id) => {
145            let cid = resolve_client_order_id(id, truncated_id_map);
146            state.cache_order_client_id(&exec.order_id, cid);
147            Some(cid)
148        }
149        None => state.lookup_order_client_id(&exec.order_id),
150    };
151
152    // Stale-report suppression for previously-tracked orders that already
153    // reached the filled terminal state.
154    if let Some(cid) = resolved_id
155        && state.filled_orders.contains(&cid)
156    {
157        log::debug!(
158            "Skipping stale spot execution for filled order: cid={cid}, order_id={}",
159            exec.order_id,
160        );
161        return;
162    }
163
164    let identity = resolved_id.and_then(|cid| state.lookup_identity(&cid));
165
166    // Status update.
167    match parse_ws_order_status_report(exec, instrument, account_id, cached_qty, ts_init) {
168        Ok(mut report) => {
169            if let Some(cid) = resolved_id {
170                report = report.with_client_order_id(cid);
171            }
172
173            if let (Some(client_order_id), Some(identity)) = (resolved_id, identity.as_ref()) {
174                status_tracked(
175                    &report,
176                    exec.exec_type,
177                    exec.exec_id.is_some(),
178                    client_order_id,
179                    identity,
180                    state,
181                    emitter,
182                    account_id,
183                    ts_init,
184                );
185            } else {
186                emitter.send_order_status_report(report);
187            }
188        }
189        Err(e) => log::error!("Failed to parse order status report: {e}"),
190    }
191
192    // Fill (when present).
193    if exec.exec_id.is_some() {
194        match parse_ws_fill_report(exec, instrument, account_id, ts_init) {
195            Ok(mut report) => {
196                if let Some(cid) = resolved_id {
197                    report.client_order_id = Some(cid);
198                }
199
200                if let (Some(client_order_id), Some(identity)) = (resolved_id, identity.as_ref()) {
201                    fill_tracked(
202                        &report,
203                        client_order_id,
204                        identity,
205                        instrument,
206                        state,
207                        emitter,
208                        account_id,
209                        ts_init,
210                    );
211                } else {
212                    if state.check_and_insert_trade(report.trade_id) {
213                        log::debug!(
214                            "Skipping duplicate external spot fill: trade_id={}",
215                            report.trade_id
216                        );
217                        return;
218                    }
219                    emitter.send_fill_report(report);
220                }
221            }
222            Err(e) => log::error!("Failed to parse fill report: {e}"),
223        }
224    }
225}
226
227#[expect(clippy::too_many_arguments)]
228fn status_tracked(
229    report: &OrderStatusReport,
230    exec_type: KrakenExecType,
231    has_fill: bool,
232    client_order_id: ClientOrderId,
233    identity: &OrderIdentity,
234    state: &WsDispatchState,
235    emitter: &ExecutionEventEmitter,
236    account_id: AccountId,
237    ts_init: UnixNanos,
238) {
239    let venue_order_id = report.venue_order_id;
240    let ts_event = report.ts_last;
241    let trader_id = emitter.trader_id();
242
243    // Amended (user modify) and Restated (engine adjustment) both surface
244    // post-modify state. Refresh tracked quantity (size may have changed) and
245    // emit OrderUpdated so the engine clears PendingUpdate.
246    if matches!(
247        exec_type,
248        KrakenExecType::Amended | KrakenExecType::Restated
249    ) && state.emitted_accepted.contains(&client_order_id)
250    {
251        state.update_identity_quantity(&client_order_id, report.quantity);
252        let updated = OrderUpdated::new(
253            trader_id,
254            identity.strategy_id,
255            identity.instrument_id,
256            client_order_id,
257            report.quantity,
258            UUID4::new(),
259            ts_event,
260            ts_init,
261            false,
262            Some(venue_order_id),
263            Some(account_id),
264            report.price,
265            report.trigger_price,
266            None,
267            false,
268        );
269        emitter.send_order_event(OrderEventAny::Updated(updated));
270        return;
271    }
272
273    match report.order_status {
274        OrderStatus::Accepted => {
275            if !state.insert_accepted(client_order_id) {
276                // Already accepted; this is a redundant New / Restated / Status
277                // exec. The strategy already saw OrderAccepted; nothing to emit.
278                return;
279            }
280            let accepted = OrderAccepted::new(
281                trader_id,
282                identity.strategy_id,
283                identity.instrument_id,
284                client_order_id,
285                venue_order_id,
286                account_id,
287                UUID4::new(),
288                ts_event,
289                ts_init,
290                false,
291            );
292            emitter.send_order_event(OrderEventAny::Accepted(accepted));
293        }
294        OrderStatus::Triggered => {
295            // Stop / take-profit transition. Synthesize Accepted first if the
296            // venue compressed placement and trigger into one message.
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 triggered = OrderTriggered::new(
308                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::Triggered(triggered));
320        }
321        OrderStatus::PartiallyFilled => {
322            // The fill itself is emitted from the trade-side of dispatch via
323            // fill_tracked; nothing to do here.
324        }
325        OrderStatus::Filled
326            // Terminal-fill marker. If the same execution carries fill data
327            // (`exec_id` is present) the fill side runs next and is
328            // responsible for cumulative tracking + cleanup; only do the
329            // cleanup here when this is a status-only Filled marker without
330            // an accompanying fill payload.
331            if !has_fill => {
332                state.insert_filled(client_order_id);
333                state.cleanup_terminal(&client_order_id);
334            }
335        OrderStatus::Canceled => {
336            ensure_accepted_emitted(
337                client_order_id,
338                venue_order_id,
339                account_id,
340                identity,
341                state,
342                emitter,
343                ts_event,
344                ts_init,
345            );
346            let canceled = OrderCanceled::new(
347                trader_id,
348                identity.strategy_id,
349                identity.instrument_id,
350                client_order_id,
351                UUID4::new(),
352                ts_event,
353                ts_init,
354                false,
355                Some(venue_order_id),
356                Some(account_id),
357            );
358            emitter.send_order_event(OrderEventAny::Canceled(canceled));
359            state.cleanup_terminal(&client_order_id);
360        }
361        OrderStatus::Expired => {
362            ensure_accepted_emitted(
363                client_order_id,
364                venue_order_id,
365                account_id,
366                identity,
367                state,
368                emitter,
369                ts_event,
370                ts_init,
371            );
372            let expired = OrderExpired::new(
373                trader_id,
374                identity.strategy_id,
375                identity.instrument_id,
376                client_order_id,
377                UUID4::new(),
378                ts_event,
379                ts_init,
380                false,
381                Some(venue_order_id),
382                Some(account_id),
383            );
384            emitter.send_order_event(OrderEventAny::Expired(expired));
385            state.cleanup_terminal(&client_order_id);
386        }
387        _ => {}
388    }
389}
390
391#[expect(clippy::too_many_arguments)]
392fn fill_tracked(
393    report: &FillReport,
394    client_order_id: ClientOrderId,
395    identity: &OrderIdentity,
396    instrument: &InstrumentAny,
397    state: &WsDispatchState,
398    emitter: &ExecutionEventEmitter,
399    account_id: AccountId,
400    ts_init: UnixNanos,
401) {
402    if state.check_and_insert_trade(report.trade_id) {
403        log::debug!(
404            "Skipping duplicate spot fill for {client_order_id}: trade_id={}",
405            report.trade_id
406        );
407        return;
408    }
409
410    ensure_accepted_emitted(
411        client_order_id,
412        report.venue_order_id,
413        account_id,
414        identity,
415        state,
416        emitter,
417        report.ts_event,
418        ts_init,
419    );
420
421    let filled = fill_report_to_order_filled(
422        report,
423        emitter.trader_id(),
424        identity,
425        instrument.quote_currency(),
426        client_order_id,
427    );
428    emitter.send_order_event(OrderEventAny::Filled(filled));
429
430    let previous = state
431        .previous_filled_qty(&client_order_id)
432        .unwrap_or_else(|| Quantity::zero(instrument.size_precision()));
433    let cumulative = previous + report.last_qty;
434    state.record_filled_qty(client_order_id, cumulative);
435
436    if cumulative >= identity.quantity {
437        state.insert_filled(client_order_id);
438        state.cleanup_terminal(&client_order_id);
439    }
440}
441
442/// Returns true when this spot execution carries a terminal status that
443/// should remove the order from dispatch state.
444#[must_use]
445pub fn is_terminal_exec_type(exec_type: KrakenExecType) -> bool {
446    matches!(
447        exec_type,
448        KrakenExecType::Filled | KrakenExecType::Canceled | KrakenExecType::Expired
449    )
450}
451
452#[cfg(test)]
453mod tests {
454    use rstest::rstest;
455
456    use super::*;
457
458    #[rstest]
459    #[case::filled(KrakenExecType::Filled, true)]
460    #[case::canceled(KrakenExecType::Canceled, true)]
461    #[case::expired(KrakenExecType::Expired, true)]
462    #[case::new(KrakenExecType::New, false)]
463    #[case::trade(KrakenExecType::Trade, false)]
464    #[case::pending_new(KrakenExecType::PendingNew, false)]
465    fn test_is_terminal_exec_type(#[case] exec_type: KrakenExecType, #[case] expected: bool) {
466        assert_eq!(is_terminal_exec_type(exec_type), expected);
467    }
468}