Skip to main content

nautilus_kraken/execution/
mod.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//! Live execution client implementations for the Kraken adapter.
17//!
18//! This module provides separate execution clients for Kraken Spot and Futures markets:
19//!
20//! - [`KrakenSpotExecutionClient`] - For Spot markets using WebSocket v2
21//! - [`KrakenFuturesExecutionClient`] - For Futures markets
22//!
23//! # Supported Operations
24//!
25//! ## Common
26//! - Order submission (market, limit, stop)
27//! - Order modification
28//! - Order cancellation (single, batch, cancel-all)
29//! - Account state and balance queries
30//!
31//! ## Futures Only
32//! - Position management
33
34mod futures;
35mod spot;
36
37pub use futures::KrakenFuturesExecutionClient;
38use nautilus_common::{cache::Cache, messages::execution::GenerateOrderStatusReport};
39use nautilus_core::UnixNanos;
40use nautilus_live::execution::failure::CommandFailure;
41use nautilus_model::{
42    identifiers::VenueOrderId,
43    orders::{Order, OrderAny},
44    reports::OrderStatusReport,
45};
46pub use spot::KrakenSpotExecutionClient;
47
48use crate::{
49    common::enums::KrakenApiResult,
50    http::{
51        coverage::ReportCoverage,
52        error::{
53            KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
54        },
55        futures::client::{FuturesBatchSubmitItem, is_futures_submit_rejection},
56        spot::models::SpotBatchOrderResponse,
57    },
58};
59
60/// The cached order a single-order status command names, by its client order ID or else through
61/// the venue order ID index.
62fn cached_order_for_status_command(
63    cache: &Cache,
64    cmd: &GenerateOrderStatusReport,
65) -> Option<OrderAny> {
66    if let Some(client_order_id) = cmd.client_order_id {
67        return cache.order(&client_order_id).map(|o| o.clone());
68    }
69
70    let venue_order_id = cmd.venue_order_id?;
71    let client_order_id = *cache.client_order_id(&venue_order_id)?;
72    cache.order(&client_order_id).map(|o| o.clone())
73}
74
75/// Answers a single-order lookup that found no report of `order`: absent only when the read rules
76/// the order out, else an error so reconciliation defers rather than resolving an order the read
77/// may have missed.
78fn absent_unless_unread(
79    coverage: &ReportCoverage,
80    venue_order_id: Option<VenueOrderId>,
81    placed_from: Option<UnixNanos>,
82    order: &str,
83) -> anyhow::Result<Option<OrderStatusReport>> {
84    if coverage.rules_out(venue_order_id, placed_from) {
85        return Ok(None);
86    }
87    anyhow::bail!(
88        "No report for order {order} in an order read that does not rule it out; deferring rather \
89         than reporting it missing"
90    )
91}
92
93/// Names the order a status command asks about: its client order ID, else its venue order ID.
94fn status_command_label(
95    cmd: &GenerateOrderStatusReport,
96    venue_order_id: Option<VenueOrderId>,
97) -> String {
98    match (cmd.client_order_id, venue_order_id) {
99        (Some(client_order_id), _) => client_order_id.to_string(),
100        (None, Some(venue_order_id)) => venue_order_id.to_string(),
101        (None, None) => "with neither ID".to_string(),
102    }
103}
104
105/// The earliest time an order can carry at the venue: its creation, or its acceptance when the
106/// engine adopted it from a venue report after the venue had placed it.
107fn earliest_venue_time(order: &OrderAny) -> UnixNanos {
108    let created = order.ts_init();
109    order
110        .ts_accepted()
111        .map_or(created, |accepted| accepted.min(created))
112}
113
114fn command_failure_from_submit_error(error: &anyhow::Error) -> CommandFailure {
115    for cause in error.chain() {
116        if let Some(error) = cause.downcast_ref::<KrakenSubmitOrderError>() {
117            return match error {
118                KrakenSubmitOrderError::Rejected { reason } => {
119                    CommandFailure::venue_rejected(reason)
120                }
121                KrakenSubmitOrderError::MissingStatus
122                | KrakenSubmitOrderError::UnknownStatus { .. }
123                | KrakenSubmitOrderError::MissingOrderId { .. }
124                | KrakenSubmitOrderError::PostSubmitLookup { .. } => {
125                    CommandFailure::ambiguous(error.to_string())
126                }
127            };
128        }
129    }
130
131    command_failure_from_order_error(error, true)
132}
133
134fn command_failure_from_modify_error(error: &anyhow::Error) -> CommandFailure {
135    for cause in error.chain() {
136        if let Some(error) = cause.downcast_ref::<KrakenModifyOrderError>() {
137            return match error {
138                KrakenModifyOrderError::Rejected { reason } => {
139                    CommandFailure::venue_rejected(reason)
140                }
141                KrakenModifyOrderError::UnknownStatus { .. }
142                | KrakenModifyOrderError::MissingOrderId => {
143                    CommandFailure::ambiguous(error.to_string())
144                }
145            };
146        }
147    }
148
149    command_failure_from_order_error(error, true)
150}
151
152fn command_failure_from_spot_batch_error(error: &anyhow::Error) -> CommandFailure {
153    command_failure_from_batch_error(error, true)
154}
155
156fn command_failure_from_futures_batch_error(error: &anyhow::Error) -> CommandFailure {
157    command_failure_from_batch_error(error, false)
158}
159
160fn command_failure_from_batch_error(
161    error: &anyhow::Error,
162    whole_order_rejection: bool,
163) -> CommandFailure {
164    for cause in error.chain() {
165        if let Some(error) = cause.downcast_ref::<KrakenBatchOrderError>() {
166            return match error {
167                KrakenBatchOrderError::Validation { .. } | KrakenBatchOrderError::NotAttempted => {
168                    CommandFailure::not_sent(error.to_string())
169                }
170                KrakenBatchOrderError::ResponseCount { .. }
171                | KrakenBatchOrderError::MissingResponse { .. }
172                | KrakenBatchOrderError::DuplicateResponse { .. } => {
173                    CommandFailure::ambiguous(error.to_string())
174                }
175            };
176        }
177    }
178
179    command_failure_from_order_error(error, whole_order_rejection)
180}
181
182fn command_failure_from_order_error(
183    error: &anyhow::Error,
184    order_api_rejection: bool,
185) -> CommandFailure {
186    let reason = error.to_string();
187
188    for cause in error.chain() {
189        let Some(error) = cause.downcast_ref::<KrakenHttpError>() else {
190            continue;
191        };
192
193        return match error {
194            KrakenHttpError::RequestNotStarted(_) | KrakenHttpError::MissingCredentials => {
195                CommandFailure::not_sent(reason)
196            }
197            KrakenHttpError::ApiError(errors)
198                if order_api_rejection && contains_spot_order_rejection(errors) =>
199            {
200                CommandFailure::venue_rejected(format_api_errors(errors))
201            }
202            KrakenHttpError::NetworkError(_)
203            | KrakenHttpError::ApiError(_)
204            | KrakenHttpError::ParseError(_)
205            | KrakenHttpError::AuthenticationError(_) => CommandFailure::ambiguous(reason),
206        };
207    }
208
209    CommandFailure::not_sent(reason)
210}
211
212fn command_failure_from_spot_batch_item(
213    item: SpotBatchOrderResponse,
214) -> Result<(), CommandFailure> {
215    match (item.txid, item.error) {
216        (Some(_), None) => Ok(()),
217        (None, Some(reason)) => Err(CommandFailure::venue_rejected(reason)),
218        (Some(_), Some(_)) => Err(CommandFailure::ambiguous(
219            "Batch item response contained both a transaction ID and an error",
220        )),
221        (None, None) => Err(CommandFailure::ambiguous(
222            "Batch item response had no transaction ID or error",
223        )),
224    }
225}
226
227fn command_failure_from_futures_batch_item(
228    item: FuturesBatchSubmitItem,
229) -> Result<(), CommandFailure> {
230    let status = item.status.status;
231
232    if item.result != KrakenApiResult::Success {
233        return if is_futures_submit_rejection(&status) {
234            Err(CommandFailure::venue_rejected(status))
235        } else {
236            Err(CommandFailure::ambiguous(format!(
237                "Batch response reported an error with item status: {status}"
238            )))
239        };
240    }
241
242    match status.as_str() {
243        "placed" | "filled" => Ok(()),
244        reason if is_futures_submit_rejection(reason) => {
245            Err(CommandFailure::venue_rejected(reason))
246        }
247        "" => Err(CommandFailure::ambiguous("Empty batch item status")),
248        reason => Err(CommandFailure::ambiguous(format!(
249            "Unknown batch item status: {reason}"
250        ))),
251    }
252}
253
254fn command_failure_from_cancel_error(error: KrakenHttpError) -> CommandFailure {
255    match error {
256        KrakenHttpError::RequestNotStarted(message) => CommandFailure::not_sent(message),
257        KrakenHttpError::AuthenticationError(message) => CommandFailure::not_sent(message),
258        KrakenHttpError::MissingCredentials => CommandFailure::not_sent("Missing credentials"),
259        KrakenHttpError::NetworkError(message) | KrakenHttpError::ParseError(message) => {
260            CommandFailure::ambiguous(message)
261        }
262        KrakenHttpError::ApiError(message) => {
263            CommandFailure::ambiguous(format_cancel_api_errors(&message))
264        }
265    }
266}
267
268fn command_failure_from_spot_cancel_error(error: KrakenHttpError) -> CommandFailure {
269    match error {
270        KrakenHttpError::ApiError(message) if contains_spot_cancel_rejection(&message) => {
271            CommandFailure::venue_rejected(format_cancel_api_errors(&message))
272        }
273        KrakenHttpError::ApiError(message) => {
274            CommandFailure::ambiguous(format_cancel_api_errors(&message))
275        }
276        other => command_failure_from_cancel_error(other),
277    }
278}
279
280fn contains_spot_order_rejection(errors: &[String]) -> bool {
281    errors.iter().any(|e| e.trim_start().starts_with("EOrder:"))
282}
283
284fn contains_spot_cancel_rejection(errors: &[String]) -> bool {
285    contains_spot_order_rejection(errors)
286}
287
288fn format_api_errors(errors: &[String]) -> String {
289    if errors.is_empty() {
290        "unknown error (empty error list)".to_string()
291    } else {
292        errors.join(", ")
293    }
294}
295
296fn format_cancel_api_errors(errors: &[String]) -> String {
297    format_api_errors(errors)
298}
299
300#[cfg(test)]
301mod tests {
302    use rstest::rstest;
303
304    use super::*;
305
306    #[rstest]
307    fn test_post_submit_phase_overrides_nested_not_sent_error() {
308        let error = anyhow::Error::new(KrakenSubmitOrderError::PostSubmitLookup {
309            source: KrakenHttpError::RequestNotStarted("lookup was not sent".to_string()).into(),
310        });
311
312        assert_eq!(
313            command_failure_from_submit_error(&error),
314            CommandFailure::Ambiguous(
315                "Order lookup failed after submission: Request not started: lookup was not sent"
316                    .to_string()
317            )
318        );
319    }
320
321    #[rstest]
322    fn test_request_not_started_is_not_sent() {
323        let error = anyhow::Error::new(KrakenHttpError::RequestNotStarted(
324            "request encoding failed".to_string(),
325        ));
326
327        assert_eq!(
328            command_failure_from_submit_error(&error),
329            CommandFailure::NotSent("Request not started: request encoding failed".to_string())
330        );
331    }
332}