Skip to main content

nautilus_kraken/execution/
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//! Kraken Futures execution client implementation.
17
18use std::{
19    collections::{HashMap, HashSet},
20    future::Future,
21    sync::Arc,
22    time::{Duration, Instant},
23};
24
25use anyhow::Context;
26use async_trait::async_trait;
27use indexmap::IndexMap;
28use jiff::Timestamp;
29use nautilus_common::{
30    cache::{Cache, InstrumentLookupError},
31    clients::ExecutionClient,
32    live::runner::get_exec_event_sender,
33    messages::execution::{
34        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
35        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
36        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
37    },
38};
39use nautilus_core::{
40    AtomicMap, DurationNanos, Params, UUID4, UnixNanos,
41    time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{
44    ExecutionClientCore, ExecutionEventEmitter, SocketControl, execution::failure::CommandFailure,
45    task::TaskGroup,
46};
47use nautilus_model::{
48    accounts::AccountAny,
49    enums::{AccountType, OmsType, OrderSide, OrderStatus, OrderType},
50    events::OrderDeniedReason,
51    identifiers::{
52        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, Venue, VenueOrderId,
53    },
54    instruments::{Instrument, InstrumentAny},
55    orders::{Order, OrderAny},
56    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
57    types::{AccountBalance, MarginBalance, Quantity},
58};
59use rust_decimal::Decimal;
60use tokio_util::sync::CancellationToken;
61
62use super::{
63    absent_unless_unread, cached_order_for_status_command, command_failure_from_cancel_error,
64    command_failure_from_futures_batch_error, command_failure_from_futures_batch_item,
65    command_failure_from_modify_error, command_failure_from_submit_error, earliest_venue_time,
66    status_command_label,
67};
68use crate::{
69    common::{
70        consts::{KRAKEN_FUTURES_MAX_CLIENT_ORDER_ID_LEN, KRAKEN_VENUE},
71        credential::KrakenCredential,
72        enums::{KrakenApiResult, KrakenProductType, KrakenSendStatus, product_type_from_symbol},
73        parse::truncate_cl_ord_id,
74    },
75    config::KrakenExecutionClientConfig,
76    http::{
77        KrakenFuturesHttpClient, KrakenHttpError,
78        futures::{
79            client::{KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND, order_identifier, supersedes},
80            models::FuturesBatchCancelStatus,
81            query::KrakenFuturesBatchCancelItem,
82        },
83    },
84    websocket::{
85        dispatch::{self, OrderIdentity, WsDispatchState},
86        futures::{client::KrakenFuturesWebSocketClient, messages::KrakenFuturesWsMessage},
87    },
88};
89
90const FUTURES_BATCH_CANCEL_LIMIT: usize = 50;
91
92/// Maximum order IDs per `/orders/status` request for Kraken Futures API.
93const FUTURES_ORDERS_STATUS_LIMIT: usize = 50;
94
95/// Allowance for skew between the node's clock, which stamps an order's creation, and the venue's,
96/// which stamps its placement, kept short so an older order whose client order ID shortens alike
97/// stays withheld.
98const ORDERS_STATUS_CLOCK_SKEW: DurationNanos = DurationNanos::from_secs(5);
99
100/// Kraken Futures execution client.
101///
102/// Provides order management, account operations, and position management
103/// for Kraken Futures markets.
104#[allow(dead_code)]
105#[derive(Debug)]
106pub struct KrakenFuturesExecutionClient {
107    core: ExecutionClientCore,
108    clock: &'static AtomicTime,
109    config: KrakenExecutionClientConfig,
110    emitter: ExecutionEventEmitter,
111    http: KrakenFuturesHttpClient,
112    ws: KrakenFuturesWebSocketClient,
113    cancellation_token: CancellationToken,
114    session_tasks: TaskGroup,
115    pending_tasks: TaskGroup,
116    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
117    truncated_id_map: Arc<AtomicMap<String, ClientOrderId>>,
118    order_instrument_map: Arc<AtomicMap<String, InstrumentId>>,
119    venue_client_map: Arc<AtomicMap<String, ClientOrderId>>,
120    venue_order_qty: Arc<AtomicMap<String, Quantity>>,
121    ws_dispatch_state: Arc<WsDispatchState>,
122}
123
124impl KrakenFuturesExecutionClient {
125    /// Creates a new [`KrakenFuturesExecutionClient`].
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if configuration validation or client initialization fails.
130    pub fn new(
131        core: ExecutionClientCore,
132        config: KrakenExecutionClientConfig,
133    ) -> anyhow::Result<Self> {
134        config.validate()?;
135
136        let clock = get_atomic_clock_realtime();
137        let emitter = ExecutionEventEmitter::new(
138            clock,
139            core.trader_id,
140            core.account_id,
141            AccountType::Margin,
142            None,
143        );
144
145        let session_tasks = TaskGroup::new();
146        let cancellation_token = session_tasks.cancellation_token();
147        let pending_tasks = TaskGroup::new();
148        let api_key = config.api_key.expose_secret().to_owned();
149        let api_secret = config.api_secret.expose_secret().to_owned();
150        let proxy_url = config
151            .proxy_url
152            .as_ref()
153            .map(|value| value.expose_secret().to_owned());
154
155        let http = KrakenFuturesHttpClient::with_credentials(
156            api_key.clone(),
157            api_secret.clone(),
158            config.environment,
159            config.base_url.clone(),
160            config.timeout_secs,
161            Some(config.max_retries),
162            None,
163            None,
164            proxy_url.clone(),
165            config
166                .max_requests_per_second
167                .unwrap_or(KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND),
168        )?;
169
170        let credential = KrakenCredential::new(api_key, api_secret);
171        let ws = KrakenFuturesWebSocketClient::with_credentials(
172            config.ws_url(),
173            config.heartbeat_interval_secs,
174            Some(credential),
175            config.auth_timeout_secs,
176            config.transport_backend,
177            proxy_url,
178        )
179        .with_socket_control(SocketControl::new(
180            core.client_id,
181            Some(*KRAKEN_VENUE),
182            "kraken-futures-user-streams",
183        ));
184
185        Ok(Self {
186            core,
187            clock,
188            config,
189            emitter,
190            http,
191            ws,
192            cancellation_token,
193            session_tasks,
194            pending_tasks,
195            instruments: Arc::new(AtomicMap::new()),
196            truncated_id_map: Arc::new(AtomicMap::new()),
197            order_instrument_map: Arc::new(AtomicMap::new()),
198            venue_client_map: Arc::new(AtomicMap::new()),
199            venue_order_qty: Arc::new(AtomicMap::new()),
200            ws_dispatch_state: Arc::new(WsDispatchState::new()),
201        })
202    }
203
204    fn register_order_identity(&self, order: &OrderAny) {
205        self.ws_dispatch_state.register_identity(
206            order.client_order_id(),
207            OrderIdentity {
208                strategy_id: order.strategy_id(),
209                instrument_id: order.instrument_id(),
210                order_side: order.order_side(),
211                order_type: order.order_type(),
212                quantity: order.quantity(),
213            },
214        );
215    }
216
217    /// Returns a reference to the clock.
218    #[must_use]
219    pub fn clock(&self) -> &'static AtomicTime {
220        self.clock
221    }
222
223    /// Returns a reference to the event emitter.
224    #[must_use]
225    pub fn emitter(&self) -> &ExecutionEventEmitter {
226        &self.emitter
227    }
228
229    fn spawn_task<F>(&self, description: &'static str, fut: F)
230    where
231        F: Future<Output = anyhow::Result<()>> + Send + 'static,
232    {
233        let future = async move {
234            if let Err(e) = fut.await {
235                log::warn!("{description} failed: {e:?}");
236            }
237        };
238
239        if let Err(e) = self.pending_tasks.spawn(future) {
240            log::warn!("Skipping Kraken Futures {description} after shutdown began: {e}");
241        }
242    }
243
244    async fn finish_tasks(&self) -> anyhow::Result<()> {
245        let (session_result, pending_result) = tokio::join!(
246            self.session_tasks
247                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
248            self.pending_tasks
249                .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2)),
250        );
251        session_result.context("failed to finish Kraken Futures execution session tasks")?;
252        pending_result.context("failed to finish Kraken Futures execution command tasks")?;
253        Ok(())
254    }
255
256    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
257        if !self.session_tasks.is_open() || !self.pending_tasks.is_open() {
258            self.session_tasks.begin_shutdown();
259            self.pending_tasks.begin_shutdown();
260            self.finish_tasks().await?;
261            self.session_tasks
262                .start_generation()
263                .context("failed to start Kraken Futures execution session task generation")?;
264            self.pending_tasks
265                .start_generation()
266                .context("failed to start Kraken Futures execution command task generation")?;
267            self.cancellation_token = self.session_tasks.cancellation_token();
268        }
269        Ok(())
270    }
271
272    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
273        self.http.cancel_all_requests();
274        self.pending_tasks.begin_shutdown();
275        let ws_result = self.ws.close().await;
276        self.session_tasks.begin_shutdown();
277        let tasks_result = self.finish_tasks().await;
278        self.core.set_disconnected();
279        tasks_result?;
280        Ok(ws_result?)
281    }
282
283    fn submit_single_order(&self, order: &OrderAny, task_name: &'static str) {
284        if order.is_closed() {
285            log::warn!(
286                "Cannot submit closed order: client_order_id={}",
287                order.client_order_id()
288            );
289            return;
290        }
291
292        if let Err(reason) = validate_order(order) {
293            self.emitter.emit_order_denied(order, &reason.to_string());
294            return;
295        }
296
297        let account_id = self.core.account_id;
298        let client_order_id = order.client_order_id();
299        let strategy_id = order.strategy_id();
300        let instrument_id = order.instrument_id();
301        let order_side = order.order_side();
302        let order_type = order.order_type();
303        let quantity = order.quantity();
304        let time_in_force = order.time_in_force();
305        let price = order.price();
306        let trigger_price = order.trigger_price();
307        let trigger_type = order.trigger_type();
308        let is_reduce_only = order.is_reduce_only();
309        let is_post_only = order.is_post_only();
310
311        log::debug!("OrderSubmitted: client_order_id={client_order_id}");
312        self.register_order_identity(order);
313        self.emitter.emit_order_submitted(order);
314
315        let http = self.http.clone();
316        let emitter = self.emitter.clone();
317        let clock = self.clock;
318        let dispatch_state = self.ws_dispatch_state.clone();
319
320        self.spawn_task(task_name, async move {
321            let result = http
322                .submit_order(
323                    account_id,
324                    instrument_id,
325                    client_order_id,
326                    order_side,
327                    order_type,
328                    quantity,
329                    time_in_force,
330                    price,
331                    trigger_price,
332                    trigger_type,
333                    is_reduce_only,
334                    is_post_only,
335                )
336                .await;
337
338            match result {
339                Ok(_) => {}
340                Err(e) => match command_failure_from_submit_error(&e) {
341                    CommandFailure::Ambiguous(reason) => {
342                        log::warn!(
343                            "{task_name} outcome is ambiguous for client_order_id={client_order_id}: {reason}"
344                        );
345                    }
346                    CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
347                        let ts_event = clock.get_time_ns();
348                        let error_msg = format!("{task_name} error: {reason}");
349                        let due_post_only = error_msg.contains("POST_ONLY_REJECTED");
350                        dispatch_state.cleanup_terminal(&client_order_id);
351                        emitter.emit_order_rejected_event(
352                            strategy_id,
353                            instrument_id,
354                            client_order_id,
355                            &error_msg,
356                            ts_event,
357                            due_post_only,
358                        );
359                    }
360                },
361            }
362            Ok(())
363        });
364    }
365
366    fn cancel_single_order(&self, cmd: &CancelOrder) {
367        let account_id = self.core.account_id;
368        let client_order_id = cmd.client_order_id;
369        let venue_order_id = cmd.venue_order_id;
370        let strategy_id = cmd.strategy_id;
371        let instrument_id = cmd.instrument_id;
372
373        log::debug!(
374            "Canceling order: venue_order_id={venue_order_id:?}, client_order_id={client_order_id}"
375        );
376
377        let http = self.http.clone();
378        let emitter = self.emitter.clone();
379        let clock = self.clock;
380
381        self.spawn_task("cancel_order", async move {
382            if let Err(failure) = cancel_order_for_futures(
383                &http,
384                account_id,
385                instrument_id,
386                Some(client_order_id),
387                venue_order_id,
388            )
389            .await
390            {
391                handle_cancel_failure(
392                    &emitter,
393                    clock,
394                    strategy_id,
395                    instrument_id,
396                    client_order_id,
397                    venue_order_id,
398                    failure,
399                );
400            }
401            Ok(())
402        });
403    }
404
405    fn spawn_message_handler(&mut self) -> anyhow::Result<()> {
406        let mut rx = self
407            .ws
408            .take_output_rx()
409            .context("Failed to take futures WebSocket output receiver")?;
410        let emitter = self.emitter.clone();
411        let instruments = self.instruments.clone();
412        let truncated_id_map = self.truncated_id_map.clone();
413        let order_instrument_map = self.order_instrument_map.clone();
414        let venue_client_map = self.venue_client_map.clone();
415        let venue_order_qty = self.venue_order_qty.clone();
416        let dispatch_state = self.ws_dispatch_state.clone();
417        let account_id = self.core.account_id;
418        let clock = self.clock;
419        let cancellation_token = self.cancellation_token.clone();
420
421        let future = async move {
422            loop {
423                tokio::select! {
424                    () = cancellation_token.cancelled() => {
425                        log::debug!("Futures execution message handler cancelled");
426                        break;
427                    }
428                    msg = rx.recv() => {
429                        match msg {
430                            Some(ws_msg) => {
431                                Self::handle_ws_message(
432                                    ws_msg,
433                                    &emitter,
434                                    &dispatch_state,
435                                    &instruments,
436                                    &truncated_id_map,
437                                    &order_instrument_map,
438                                    &venue_client_map,
439                                    &venue_order_qty,
440                                    account_id,
441                                    clock,
442                                );
443                            }
444                            None => {
445                                log::debug!("Futures execution WebSocket stream ended");
446                                break;
447                            }
448                        }
449                    }
450                }
451            }
452        };
453
454        self.session_tasks
455            .spawn(future)
456            .context("failed to register Kraken Futures execution stream task")
457    }
458
459    #[expect(clippy::too_many_arguments)]
460    fn handle_ws_message(
461        msg: KrakenFuturesWsMessage,
462        emitter: &ExecutionEventEmitter,
463        dispatch_state: &Arc<WsDispatchState>,
464        instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
465        truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
466        order_instrument_map: &Arc<AtomicMap<String, InstrumentId>>,
467        venue_client_map: &Arc<AtomicMap<String, ClientOrderId>>,
468        venue_order_qty: &Arc<AtomicMap<String, Quantity>>,
469        account_id: AccountId,
470        clock: &'static AtomicTime,
471    ) {
472        let ts_init = clock.get_time_ns();
473
474        match msg {
475            KrakenFuturesWsMessage::OpenOrdersDelta(delta) => {
476                dispatch::futures::open_orders_delta(
477                    &delta,
478                    dispatch_state,
479                    emitter,
480                    instruments,
481                    truncated_id_map,
482                    order_instrument_map,
483                    venue_client_map,
484                    venue_order_qty,
485                    account_id,
486                    ts_init,
487                );
488            }
489            KrakenFuturesWsMessage::OpenOrdersCancel(cancel) => {
490                dispatch::futures::open_orders_cancel(
491                    &cancel,
492                    dispatch_state,
493                    emitter,
494                    truncated_id_map,
495                    order_instrument_map,
496                    venue_client_map,
497                    venue_order_qty,
498                    account_id,
499                    ts_init,
500                );
501            }
502            KrakenFuturesWsMessage::FillsDelta(fills_delta) => {
503                dispatch::futures::fills_delta(
504                    &fills_delta,
505                    dispatch_state,
506                    emitter,
507                    instruments,
508                    truncated_id_map,
509                    venue_client_map,
510                    account_id,
511                    ts_init,
512                );
513            }
514            KrakenFuturesWsMessage::Challenge(challenge) => {
515                log::debug!("Received challenge: length={}", challenge.len());
516            }
517            KrakenFuturesWsMessage::Reconnected => {
518                log::info!("Futures execution WebSocket reconnected");
519            }
520            KrakenFuturesWsMessage::Ticker(_)
521            | KrakenFuturesWsMessage::Trade(_)
522            | KrakenFuturesWsMessage::BookSnapshot(_)
523            | KrakenFuturesWsMessage::BookDelta(_) => {}
524        }
525    }
526
527    async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
528        let account_id = self.core.account_id;
529
530        if self.core.cache().account(&account_id).is_some() {
531            log::info!("Account {account_id} registered");
532            return Ok(());
533        }
534
535        let start = Instant::now();
536        let timeout = Duration::from_secs_f64(timeout_secs);
537        let interval = Duration::from_millis(10);
538
539        loop {
540            tokio::time::sleep(interval).await;
541
542            if self.core.cache().account(&account_id).is_some() {
543                log::info!("Account {account_id} registered");
544                return Ok(());
545            }
546
547            if start.elapsed() >= timeout {
548                anyhow::bail!(
549                    "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
550                );
551            }
552        }
553    }
554
555    fn modify_single_order(&self, cmd: &ModifyOrder) {
556        let client_order_id = cmd.client_order_id;
557        let venue_order_id = cmd.venue_order_id;
558        let strategy_id = cmd.strategy_id;
559        let instrument_id = cmd.instrument_id;
560        let quantity = cmd.quantity;
561        let price = cmd.price;
562
563        log::debug!(
564            "Modifying order: venue_order_id={venue_order_id:?}, client_order_id={client_order_id}"
565        );
566
567        let http = self.http.clone();
568        let emitter = self.emitter.clone();
569        let clock = self.clock;
570
571        self.spawn_task("modify_order", async move {
572            match http
573                .modify_order(
574                    instrument_id,
575                    Some(client_order_id),
576                    venue_order_id,
577                    quantity,
578                    price,
579                    None,
580                )
581                .await
582            {
583                Ok(_) => {}
584                Err(e) => match command_failure_from_modify_error(&e) {
585                    CommandFailure::Ambiguous(reason) => {
586                        log::warn!(
587                            "modify_order outcome is ambiguous for client_order_id={client_order_id}: {reason}"
588                        );
589                    }
590                    CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
591                        let ts_event = clock.get_time_ns();
592                        emitter.emit_order_modify_rejected_event(
593                            strategy_id,
594                            instrument_id,
595                            client_order_id,
596                            venue_order_id,
597                            &format!("modify-order error: {reason}"),
598                            ts_event,
599                        );
600                    }
601                },
602            }
603            Ok(())
604        });
605    }
606}
607
608#[async_trait(?Send)]
609impl ExecutionClient for KrakenFuturesExecutionClient {
610    fn is_connected(&self) -> bool {
611        self.core.is_connected()
612    }
613
614    fn client_id(&self) -> ClientId {
615        self.core.client_id
616    }
617
618    fn account_id(&self) -> AccountId {
619        self.core.account_id
620    }
621
622    fn venue(&self) -> Venue {
623        *KRAKEN_VENUE
624    }
625
626    fn oms_type(&self) -> OmsType {
627        self.core.oms_type
628    }
629
630    fn get_account(&self) -> Option<AccountAny> {
631        self.core.cache().account_owned(&self.core.account_id)
632    }
633
634    fn generate_account_state(
635        &self,
636        balances: Vec<AccountBalance>,
637        margins: Vec<MarginBalance>,
638        reported: bool,
639        ts_event: UnixNanos,
640        info: Option<Params>,
641    ) -> anyhow::Result<()> {
642        self.emitter
643            .emit_account_state(balances, margins, reported, ts_event, info);
644        Ok(())
645    }
646
647    fn start(&mut self) -> anyhow::Result<()> {
648        if self.core.is_started() {
649            return Ok(());
650        }
651
652        self.emitter.set_sender(get_exec_event_sender());
653        self.core.set_started();
654
655        log::info!(
656            "Started: client_id={}, account_id={}, product_type=Futures, environment={:?}",
657            self.core.client_id,
658            self.core.account_id,
659            self.config.environment
660        );
661        Ok(())
662    }
663
664    fn stop(&mut self) -> anyhow::Result<()> {
665        if self.core.is_stopped() {
666            return Ok(());
667        }
668
669        self.http.cancel_all_requests();
670        self.session_tasks.begin_shutdown();
671        self.pending_tasks.begin_shutdown();
672        self.ws.begin_shutdown();
673        self.core.set_stopped();
674        self.core.set_disconnected();
675        log::info!("Stopped: client_id={}", self.core.client_id);
676        Ok(())
677    }
678
679    async fn connect(&mut self) -> anyhow::Result<()> {
680        if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
681        {
682            return Ok(());
683        }
684
685        self.http.reset_cancellation_token();
686        self.prepare_task_groups().await?;
687
688        if !self.core.instruments_initialized() {
689            let instruments = self
690                .http
691                .request_instruments()
692                .await
693                .context("Failed to load Kraken futures instruments")?;
694            log::debug!("Loaded {} Futures instruments", instruments.len());
695            self.http.cache_instruments(&instruments);
696            self.core.set_instruments_initialized();
697        }
698
699        self.instruments.rcu(|m| {
700            for instrument in self.http.instruments_cache.load().values() {
701                m.insert(instrument.id(), instrument.clone());
702            }
703        });
704
705        let session_result = async {
706            self.ws
707                .connect()
708                .await
709                .context("Failed to connect futures WebSocket")?;
710            self.ws
711                .wait_until_active(10.0)
712                .await
713                .context("Futures WebSocket failed to become active")?;
714
715            self.ws
716                .authenticate()
717                .await
718                .context("Failed to authenticate futures WebSocket")?;
719
720            let account_state = self
721                .http
722                .request_account_state(self.core.account_id)
723                .await
724                .context("Failed to request Kraken futures account state")?;
725
726            if !account_state.balances.is_empty() {
727                log::debug!(
728                    "Received account state with {} balance(s)",
729                    account_state.balances.len()
730                );
731            }
732            self.emitter.send_account_state(account_state);
733            self.await_account_registered(30.0).await?;
734
735            self.spawn_message_handler()?;
736
737            self.ws
738                .subscribe_executions()
739                .await
740                .context("Failed to subscribe to executions")?;
741
742            log::debug!("Futures WebSocket authenticated and subscribed to executions");
743
744            Ok::<(), anyhow::Error>(())
745        }
746        .await;
747
748        if let Err(e) = session_result {
749            if let Err(teardown_error) = self.teardown_partial_connect().await {
750                return Err(e.context(format!(
751                    "Kraken Futures execution startup teardown failed: {teardown_error}"
752                )));
753            }
754            return Err(e);
755        }
756
757        self.core.set_connected();
758        log::info!("Connected: client_id={}", self.core.client_id);
759        Ok(())
760    }
761
762    async fn disconnect(&mut self) -> anyhow::Result<()> {
763        self.teardown_partial_connect().await?;
764        log::info!("Disconnected: client_id={}", self.core.client_id);
765        Ok(())
766    }
767
768    async fn generate_order_status_report(
769        &self,
770        cmd: &GenerateOrderStatusReport,
771    ) -> anyhow::Result<Option<OrderStatusReport>> {
772        log::debug!(
773            "Generating order status report: venue_order_id={:?}, client_order_id={:?}",
774            cmd.venue_order_id,
775            cmd.client_order_id
776        );
777
778        let account_id = self.core.account_id;
779        let cached = cached_order_for_status_command(&self.core.cache(), cmd);
780        let queried = QueriedOrder::new(cmd, cached.as_ref());
781
782        // Scoped to the command's instrument, so an unresolvable open order on another contract
783        // is out of scope for this lookup rather than failing it.
784        let (mut reports, coverage) = self
785            .http
786            .request_order_status_reports_checked(account_id, cmd.instrument_id, None, None, false)
787            .await?;
788        name_reports_by_cached_order(&self.core.cache(), &mut reports);
789
790        let matched = queried.find_report(&self.core.cache(), &reports)?;
791        let mut stray_rows = queried.has_stray_report(&self.core.cache(), &reports);
792
793        if let Some(mut report) = matched {
794            // A terminal history match is priced from its fills; short of them the query fails so
795            // the engine defers, as the orders-status window does below. A failed fills read counts
796            // as an empty page, so a cached order holding every execution still prices it.
797            if is_unpriced_terminal_report(&report) {
798                let fills = self
799                    .http
800                    .request_fill_reports(account_id, Some(report.instrument_id), None, None)
801                    .await;
802                let page = fills.as_deref().unwrap_or_default();
803
804                if let Err(covered) = price_from_fills(&mut report, cached.as_ref(), page) {
805                    fills?;
806                    anyhow::bail!(
807                        "Order {} executed per the order history with fills covering {covered} \
808                         of {}; deferring until the fills feed prices it",
809                        report.venue_order_id,
810                        report.filled_qty,
811                    );
812                }
813            }
814
815            return Ok(Some(report));
816        }
817
818        // Absence answers that the venue has no such order, so it needs a read that rules the
819        // order out; otherwise reconciliation defers.
820        let Some(order) = cached else {
821            if stray_rows {
822                anyhow::bail!(
823                    "No report names order {:?}, but a venue row of another venue order carries \
824                     its client order ID; deferring rather than reporting it missing",
825                    queried.client_order_id,
826                );
827            }
828            return absent_unless_unread(
829                &coverage,
830                cmd.venue_order_id,
831                None,
832                &status_command_label(cmd, cmd.venue_order_id),
833            );
834        };
835
836        // Held orders never appear on /openorders; query the 5-second window
837        let order_ids: Vec<String> = queried
838            .venue_order_id
839            .map(|id| id.to_string())
840            .into_iter()
841            .collect();
842        let cli_ord_ids: Vec<String> = venue_client_order_ids(&order.client_order_id()).collect();
843
844        let mut recent_reports = self
845            .http
846            .request_orders_status_reports(account_id, &order_ids, &cli_ord_ids)
847            .await?;
848        name_reports_by_cached_order(&self.core.cache(), &mut recent_reports);
849
850        let matched_recent = queried.find_report(&self.core.cache(), &recent_reports)?;
851        stray_rows |= queried.has_stray_report(&self.core.cache(), &recent_reports);
852
853        // Window filled reports have no avg_px; price them from fills below
854        if matched_recent
855            .as_ref()
856            .is_some_and(|report| report.order_status != OrderStatus::Filled)
857        {
858            return Ok(matched_recent);
859        }
860
861        let now = Timestamp::now();
862        let start = now - Duration::from_secs(5 * 60);
863        let mut fills = self
864            .http
865            .request_fill_reports(
866                account_id,
867                Some(order.instrument_id()),
868                Some(start),
869                Some(now),
870            )
871            .await?;
872        name_fills_by_cached_order(&self.core.cache(), &mut fills);
873
874        let synthesized =
875            synthesize_filled_order_status_report(&self.core.cache(), &queried, &order, &fills);
876
877        match (synthesized, matched_recent) {
878            (Some(report), _) => Ok(Some(report)),
879            // Unpriced filled reports would close at the order price
880            (None, Some(_)) => anyhow::bail!(
881                "Order {} fully executed in the orders-status window without visible \
882                 fills; deferring until the fills feed prices it",
883                order.client_order_id(),
884            ),
885            (None, None) if stray_rows || queried.has_stray_fill(&self.core.cache(), &fills) => {
886                anyhow::bail!(
887                    "No report names order {}, yet a venue row or fill that may be its own carries \
888                     its client order ID; deferring rather than reporting it missing",
889                    order.client_order_id(),
890                )
891            }
892            (None, None) => absent_unless_unread(
893                &coverage,
894                cmd.venue_order_id.or(order.venue_order_id()),
895                Some(earliest_venue_time(&order)),
896                order.client_order_id().as_str(),
897            ),
898        }
899    }
900
901    async fn generate_order_status_reports(
902        &self,
903        cmd: &GenerateOrderStatusReports,
904    ) -> anyhow::Result<Vec<OrderStatusReport>> {
905        log::debug!(
906            "Generating order status reports: instrument_id={:?}, open_only={}",
907            cmd.instrument_id,
908            cmd.open_only
909        );
910
911        let account_id = self.core.account_id;
912        let start = cmd.start.map(Timestamp::from);
913        let end = cmd.end.map(Timestamp::from);
914        let mut reports = self
915            .http
916            .request_order_status_reports(account_id, cmd.instrument_id, start, end, cmd.open_only)
917            .await?;
918
919        // A terminal history report with an executed quantity carries no price: it is priced from
920        // its fills, else withheld. A withheld cached order counts as missing and the engine
921        // resolves it through the single-order query, which prices it or defers; an uncached one
922        // stays withheld until its fills reach the fills page.
923        if reports.iter().any(is_unpriced_terminal_report) {
924            // A failed fills read counts as an empty page: a cached order holding every execution
925            // still prices its report, and only the reports nothing covers are withheld.
926            let fills = match self
927                .http
928                .request_fill_reports(account_id, cmd.instrument_id, None, None)
929                .await
930            {
931                Ok(fills) => fills,
932                Err(e) => {
933                    log::warn!("Failed to read fills to price executed history orders: {e}");
934                    Vec::new()
935                }
936            };
937            let cache = self.core.cache();
938            reports.retain_mut(|report| {
939                if !is_unpriced_terminal_report(report) {
940                    return true;
941                }
942
943                let cached = cached_order_for_report(&cache, report);
944
945                match price_from_fills(report, cached.as_ref(), &fills) {
946                    Ok(()) => true,
947                    Err(covered) => {
948                        log::warn!(
949                            "Withholding executed order {} from the bulk response: fills cover \
950                             {covered} of {}",
951                            report.venue_order_id,
952                            report.filled_qty,
953                        );
954                        false
955                    }
956                }
957            });
958        }
959
960        if cmd.open_only {
961            let (extension, _) = self
962                .reports_for_open_orders_absent_from_venue(account_id, cmd.instrument_id, &reports)
963                .await?;
964
965            let (window_only, window_stated) = fold_window_into_venue_read(&mut reports, extension);
966
967            // The window carries no fill price, so a filled report would be inferred at the
968            // order price. A canceled one with executions passes, since reconciliation waits for
969            // its fills rather than infer them.
970            reports.retain(|report| {
971                let withhold = window_stated.contains(&report.venue_order_id)
972                    && report.order_status == OrderStatus::Filled;
973
974                if withhold {
975                    log::warn!(
976                        "Withholding fully executed order {} from the bulk response: the \
977                         orders-status window carries no fill price",
978                        report.venue_order_id,
979                    );
980                }
981                !withhold
982            });
983
984            for report in window_only {
985                if report.order_status == OrderStatus::Filled {
986                    log::warn!(
987                        "Withholding fully executed order {} from the bulk response: the \
988                         orders-status window carries no fill price",
989                        report.venue_order_id,
990                    );
991                    continue;
992                }
993
994                reports.push(report);
995            }
996        }
997
998        name_reports_by_cached_order(&self.core.cache(), &mut reports);
999        Ok(reports)
1000    }
1001
1002    async fn generate_fill_reports(
1003        &self,
1004        cmd: GenerateFillReports,
1005    ) -> anyhow::Result<Vec<FillReport>> {
1006        log::debug!(
1007            "Generating fill reports: instrument_id={:?}",
1008            cmd.instrument_id
1009        );
1010
1011        let account_id = self.core.account_id;
1012        let start = cmd.start.map(Timestamp::from);
1013        let end = cmd.end.map(Timestamp::from);
1014        let mut reports = self
1015            .http
1016            .request_fill_reports(account_id, cmd.instrument_id, start, end)
1017            .await?;
1018
1019        if let Some(venue_order_id) = cmd.venue_order_id {
1020            reports.retain(|report| report.venue_order_id == venue_order_id);
1021        }
1022
1023        name_fills_by_cached_order(&self.core.cache(), &mut reports);
1024        Ok(reports)
1025    }
1026
1027    async fn generate_position_status_reports(
1028        &self,
1029        cmd: &GeneratePositionStatusReports,
1030    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1031        log::debug!(
1032            "Generating position status reports: instrument_id={:?}",
1033            cmd.instrument_id
1034        );
1035
1036        let account_id = self.core.account_id;
1037        self.http
1038            .request_position_status_reports(account_id, cmd.instrument_id)
1039            .await
1040    }
1041
1042    async fn generate_mass_status(
1043        &self,
1044        lookback_mins: Option<u64>,
1045    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1046        log::debug!("Generating mass status: lookback_mins={lookback_mins:?}");
1047
1048        let ts_init = self.clock.get_time_ns();
1049        // Saturating arithmetic: an unclamped lookback must not overflow, and a cutoff before the
1050        // epoch must not panic converting into `UnixNanos`.
1051        let lookback_start = lookback_mins.map(|mins| {
1052            let nanos = mins.saturating_mul(60).saturating_mul(1_000_000_000);
1053            ts_init.saturating_sub(DurationNanos::new(nanos))
1054        });
1055        let start = lookback_start.map(Timestamp::from);
1056        let account_id = self.core.account_id;
1057
1058        // Read the order-event history as well as open orders, matching the shared default. An
1059        // order that reached a terminal state while the node was down is only visible there. The
1060        // venue can refuse the history page, since every `/history` endpoint draws on one token
1061        // pool; a refusal degrades startup to the open-only read and marks the set incomplete.
1062        // Any other failure fails the mass status, as a failed open-order read does.
1063        let (mut order_reports, orders_complete) = match self
1064            .http
1065            .request_order_status_reports_checked(account_id, None, start, None, false)
1066            .await
1067        {
1068            Ok((reports, coverage)) => (reports, coverage.is_complete()),
1069            Err(e) if is_refused_history_read(&e) => {
1070                log::warn!(
1071                    "Order history read refused for mass status, reading open orders only and \
1072                     marking the set incomplete: {e}"
1073                );
1074                let (reports, _) = self
1075                    .http
1076                    .request_order_status_reports_checked(account_id, None, start, None, true)
1077                    .await?;
1078                (reports, false)
1079            }
1080            Err(e) => return Err(e),
1081        };
1082        // Captured before the orders-status extension is merged in, so the safeguard below applies
1083        // to the venue read alone and leaves the extension to its own rule.
1084        let mut from_venue_read: HashSet<VenueOrderId> = order_reports
1085            .iter()
1086            .map(|report| report.venue_order_id)
1087            .collect();
1088
1089        let (extension, mut extension_withheld) = self
1090            .reports_for_open_orders_absent_from_venue(account_id, None, &order_reports)
1091            .await?;
1092
1093        let (window_only, window_stated) =
1094            fold_window_into_venue_read(&mut order_reports, extension);
1095
1096        // The window carries no fill price, so a filled report would be inferred at the order
1097        // price. A canceled one with executions passes, since reconciliation waits for its fills
1098        // rather than infer them. A withheld window report leaves the set incomplete. A venue-read
1099        // report that took a window state answers to these rules, not the venue read's.
1100        order_reports.retain(|report| {
1101            let withhold = window_stated.contains(&report.venue_order_id)
1102                && report.order_status == OrderStatus::Filled;
1103
1104            if withhold {
1105                log::warn!(
1106                    "Withholding fully executed order {} from mass status: the orders-status \
1107                     window carries no fill price",
1108                    report.venue_order_id,
1109                );
1110                extension_withheld = true;
1111            }
1112            !withhold
1113        });
1114
1115        for venue_order_id in &window_stated {
1116            from_venue_read.remove(venue_order_id);
1117        }
1118
1119        for report in window_only {
1120            if report.order_status == OrderStatus::Filled {
1121                log::warn!(
1122                    "Withholding fully executed order {} from mass status: the orders-status \
1123                     window carries no fill price",
1124                    report.venue_order_id,
1125                );
1126                extension_withheld = true;
1127                continue;
1128            }
1129
1130            order_reports.push(report);
1131        }
1132
1133        let orders_complete = orders_complete && !extension_withheld;
1134
1135        let (mut fill_reports, fills_complete) = self
1136            .http
1137            .request_fill_reports_checked(account_id, None, start, None)
1138            .await?;
1139
1140        // A terminal history report with an executed quantity carries no price, so it is priced
1141        // from its fills as on the bulk path, which requires exact coverage. The adapter reads
1142        // only the latest fills page, so an execution older than that page is absent and does
1143        // not come back on a later read. Unpriced, reconciliation would infer the remainder at
1144        // the order's limit price, so such a report is withheld and the set marked incomplete.
1145        // A withheld report's page fills stay for a cached order, since the engine reconciles
1146        // priced fills against a cached order with no report. An uncached order's fills go with
1147        // its report. With no order, and no venue position ID on a Kraken fill, the engine drops
1148        // them, but on an instrument with a position report its partial-window adjustment first
1149        // counts every fill there when sizing a synthetic opening, so they would skew that
1150        // opening without ever being applied.
1151        let mut withheld = false;
1152        let mut withheld_fills: HashSet<VenueOrderId> = HashSet::new();
1153        {
1154            let cache = self.core.cache();
1155            order_reports.retain_mut(|report| {
1156                if !from_venue_read.contains(&report.venue_order_id)
1157                    || !is_unpriced_terminal_report(report)
1158                {
1159                    return true;
1160                }
1161
1162                let cached = cached_order_for_report(&cache, report);
1163
1164                match price_from_fills(report, cached.as_ref(), &fill_reports) {
1165                    Ok(()) => true,
1166                    Err(covered) => {
1167                        log::warn!(
1168                            "Withholding executed order {} from mass status: fills cover \
1169                             {covered} of {}; {}",
1170                            report.venue_order_id,
1171                            report.filled_qty,
1172                            if cached.is_some() {
1173                                "its page fills stay for the cached order"
1174                            } else {
1175                                "its page fills are withheld with it"
1176                            },
1177                        );
1178                        withheld = true;
1179
1180                        if cached.is_none() {
1181                            withheld_fills.insert(report.venue_order_id);
1182                        }
1183                        false
1184                    }
1185                }
1186            });
1187        }
1188        fill_reports.retain(|fill| !withheld_fills.contains(&fill.venue_order_id));
1189        let orders_complete = orders_complete && !withheld;
1190
1191        let position_reports = self
1192            .http
1193            .request_position_status_reports(account_id, None)
1194            .await?;
1195
1196        // Terminal history orders must not open a position on an instrument the venue reports
1197        // flat. The position read returns open positions only, so an instrument with no position
1198        // report is flat at the venue. With no lookback declared the engine applies every kept
1199        // fill to positions, and the fills read is a single page, so a round trip whose opening
1200        // fill is older than that page would leave its closing side alone and open a position
1201        // opposite to the one the venue closed. On a flat instrument the fills of every order on
1202        // the read pages that the cache does not hold are netted, open orders included, since an
1203        // open order's fill can offset a terminal one. When they do not net to zero and a terminal
1204        // order is among them, the terminal orders are withheld with their fills and the set is
1205        // marked incomplete; open orders stay, and an open order's own fill can still open a
1206        // position. With a bounded lookback the engine projects
1207        // terminal orders onto order state only, so this applies to the unbounded read alone.
1208        // Cached orders are left out, since their fills reconcile against the cached order and
1209        // its position.
1210        let orders_complete = if lookback_start.is_none() {
1211            let held: HashSet<InstrumentId> = position_reports
1212                .iter()
1213                .map(|report| report.instrument_id)
1214                .collect();
1215            let netted: HashSet<VenueOrderId> = {
1216                let cache = self.core.cache();
1217                order_reports
1218                    .iter()
1219                    .filter(|report| {
1220                        from_venue_read.contains(&report.venue_order_id)
1221                            && !held.contains(&report.instrument_id)
1222                            && cached_order_for_report(&cache, report).is_none()
1223                    })
1224                    .map(|report| report.venue_order_id)
1225                    .collect()
1226            };
1227            let candidates: HashMap<VenueOrderId, InstrumentId> = order_reports
1228                .iter()
1229                .filter(|report| {
1230                    netted.contains(&report.venue_order_id)
1231                        && matches!(
1232                            report.order_status,
1233                            OrderStatus::Filled
1234                                | OrderStatus::Canceled
1235                                | OrderStatus::Expired
1236                                | OrderStatus::Voided
1237                        )
1238                })
1239                .map(|report| (report.venue_order_id, report.instrument_id))
1240                .collect();
1241
1242            let mut net: HashMap<InstrumentId, Decimal> = HashMap::new();
1243
1244            for fill in fill_reports
1245                .iter()
1246                .filter(|fill| netted.contains(&fill.venue_order_id))
1247            {
1248                let qty = fill.last_qty.as_decimal();
1249                let signed = match fill.order_side {
1250                    OrderSide::Buy => qty,
1251                    OrderSide::Sell => -qty,
1252                };
1253                *net.entry(fill.instrument_id).or_default() += signed;
1254            }
1255            net.retain(|instrument_id, net| {
1256                !net.is_zero() && candidates.values().any(|id| id == instrument_id)
1257            });
1258
1259            if net.is_empty() {
1260                orders_complete
1261            } else {
1262                for (instrument_id, net) in &net {
1263                    log::warn!(
1264                        "Withholding terminal orders on {instrument_id} from mass status: the venue \
1265                         reports it flat but the fills of orders the cache does not hold net to \
1266                         {net}, so reconciliation would open a position; marking the set incomplete",
1267                    );
1268                }
1269                let withheld_flat: HashSet<VenueOrderId> = candidates
1270                    .iter()
1271                    .filter(|(_, instrument_id)| net.contains_key(instrument_id))
1272                    .map(|(venue_order_id, _)| *venue_order_id)
1273                    .collect();
1274                order_reports.retain(|report| !withheld_flat.contains(&report.venue_order_id));
1275                fill_reports.retain(|fill| !withheld_flat.contains(&fill.venue_order_id));
1276                false
1277            }
1278        } else {
1279            orders_complete
1280        };
1281
1282        let mut mass_status = ExecutionMassStatus::new(
1283            self.core.client_id,
1284            self.core.account_id,
1285            *KRAKEN_VENUE,
1286            ts_init,
1287            None,
1288        );
1289        {
1290            let cache = self.core.cache();
1291            name_reports_by_cached_order(&cache, &mut order_reports);
1292            name_fills_by_cached_order(&cache, &mut fill_reports);
1293        }
1294        mass_status.add_order_reports(order_reports);
1295        mass_status.add_fill_reports(fill_reports);
1296        mass_status.add_position_reports(position_reports);
1297        // As for spot: one cutoff for every historical query, recorded with its completeness.
1298        mass_status.set_report_window(lookback_start, orders_complete && fills_complete);
1299
1300        Ok(Some(mass_status))
1301    }
1302
1303    fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
1304        log::debug!("Querying account: {cmd}");
1305
1306        let account_id = self.core.account_id;
1307        let http = self.http.clone();
1308        let emitter = self.emitter.clone();
1309
1310        self.spawn_task("query_account", async move {
1311            let account_state = http.request_account_state(account_id).await?;
1312            emitter.emit_account_state(
1313                account_state.balances.clone(),
1314                account_state.margins.clone(),
1315                account_state.is_reported,
1316                account_state.ts_event,
1317                account_state.info,
1318            );
1319            Ok(())
1320        });
1321
1322        Ok(())
1323    }
1324
1325    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1326        log::debug!("Querying order: {cmd}");
1327
1328        let venue_order_id = cmd
1329            .venue_order_id
1330            .context("venue_order_id required for query_order")?;
1331        // The cached order the venue order ID indexes names the report, as on every other path
1332        let cached_client_order_id = self.core.cache().client_order_id(&venue_order_id).copied();
1333        let account_id = self.core.account_id;
1334        let instrument_id = cmd.instrument_id;
1335        let http = self.http.clone();
1336        let emitter = self.emitter.clone();
1337
1338        self.spawn_task("query_order", async move {
1339            // Scoped to the queried instrument, so an unresolvable open order on another contract
1340            // is out of scope for this lookup rather than failing it.
1341            let reports = http
1342                .request_order_status_reports(account_id, Some(instrument_id), None, None, true)
1343                .await
1344                .context("Failed to query order")?;
1345
1346            match reports
1347                .into_iter()
1348                .find(|r| r.venue_order_id == venue_order_id)
1349            {
1350                Some(mut report) => {
1351                    if let Some(client_order_id) = cached_client_order_id {
1352                        report.client_order_id = Some(client_order_id);
1353                    }
1354                    emitter.send_order_status_report(report);
1355                }
1356                None => log::warn!(
1357                    "No open order {venue_order_id} found for {instrument_id}; no report emitted"
1358                ),
1359            }
1360            Ok(())
1361        });
1362
1363        Ok(())
1364    }
1365
1366    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1367        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1368        self.submit_single_order(&order, "submit_order");
1369        Ok(())
1370    }
1371
1372    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1373        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1374
1375        log::debug!(
1376            "Submitting order list: order_list_id={}, count={}",
1377            cmd.order_list.id,
1378            orders.len()
1379        );
1380
1381        // A list's open members go out together or not at all, so no leg is stranded
1382        if let Some(reason) = orders
1383            .iter()
1384            .filter(|order| !order.is_closed())
1385            .find_map(|order| validate_order(order).err())
1386        {
1387            for order in orders.iter().filter(|order| !order.is_closed()) {
1388                self.emitter.emit_order_denied(order, &reason.to_string());
1389            }
1390            return Ok(());
1391        }
1392
1393        let mut order_tuples = Vec::with_capacity(orders.len());
1394        let mut order_meta = Vec::with_capacity(orders.len());
1395
1396        for order in &orders {
1397            if order.is_closed() {
1398                log::warn!(
1399                    "Cannot submit closed order: client_order_id={}",
1400                    order.client_order_id()
1401                );
1402                continue;
1403            }
1404
1405            // Kraken batch endpoint only supports limit and stop orders,
1406            // submit market orders individually
1407            if order.order_type() == OrderType::Market {
1408                self.submit_single_order(order, "submit_order_list");
1409                continue;
1410            }
1411
1412            let client_order_id = order.client_order_id();
1413            self.register_order_identity(order);
1414            self.emitter.emit_order_submitted(order);
1415
1416            order_tuples.push((
1417                order.instrument_id(),
1418                client_order_id,
1419                order.order_side(),
1420                order.order_type(),
1421                order.quantity(),
1422                order.time_in_force(),
1423                order.price(),
1424                order.trigger_price(),
1425                order.trigger_type(),
1426                order.is_reduce_only(),
1427                order.is_post_only(),
1428            ));
1429
1430            order_meta.push((order.strategy_id(), order.instrument_id(), client_order_id));
1431        }
1432
1433        if order_tuples.is_empty() {
1434            return Ok(());
1435        }
1436
1437        let http = self.http.clone();
1438        let emitter = self.emitter.clone();
1439        let clock = self.clock;
1440        let dispatch_state = self.ws_dispatch_state.clone();
1441
1442        self.spawn_task("submit_order_list", async move {
1443            let results = http.send_order_batches(order_tuples).await;
1444            for (result, (strategy_id, instrument_id, client_order_id)) in
1445                results.into_iter().zip(&order_meta)
1446            {
1447                let outcome = match result {
1448                    Ok(item) => command_failure_from_futures_batch_item(item),
1449                    Err(e) => Err(command_failure_from_futures_batch_error(&e)),
1450                };
1451
1452                match outcome {
1453                    Ok(()) => {}
1454                    Err(CommandFailure::Ambiguous(reason)) => {
1455                        log::warn!(
1456                            "submit_order_list outcome is ambiguous for client_order_id={client_order_id}: {reason}"
1457                        );
1458                    }
1459                    Err(
1460                        CommandFailure::NotSent(reason)
1461                        | CommandFailure::VenueRejected(reason),
1462                    ) => {
1463                        let ts_event = clock.get_time_ns();
1464                        let error_msg =
1465                            format!("submit_order_list batch item rejected: {reason}");
1466                        dispatch_state.cleanup_terminal(client_order_id);
1467                        emitter.emit_order_rejected_event(
1468                            *strategy_id,
1469                            *instrument_id,
1470                            *client_order_id,
1471                            &error_msg,
1472                            ts_event,
1473                            reason == "postWouldExecute",
1474                        );
1475                    }
1476                }
1477            }
1478            Ok(())
1479        });
1480
1481        Ok(())
1482    }
1483
1484    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1485        self.modify_single_order(&cmd);
1486        Ok(())
1487    }
1488
1489    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1490        self.cancel_single_order(&cmd);
1491        Ok(())
1492    }
1493
1494    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1495        let instrument_id = cmd.instrument_id;
1496
1497        if cmd.order_side.is_none() {
1498            log::debug!("Canceling all orders: instrument_id={instrument_id} (bulk)");
1499
1500            let http = self.http.clone();
1501            let symbol = instrument_id.symbol.to_string();
1502
1503            self.spawn_task("cancel_all_orders", async move {
1504                match http.inner.cancel_all_orders(Some(symbol)).await {
1505                    Ok(response) => {
1506                        if response.result != KrakenApiResult::Success
1507                            && response.cancel_status.cancelled_orders.is_empty()
1508                        {
1509                            log::warn!(
1510                                "Cancel-all failed without per-order results, awaiting reconciliation: status={}",
1511                                response.cancel_status.status
1512                            );
1513                        }
1514                    }
1515                    Err(e) => match command_failure_from_cancel_error(e) {
1516                        CommandFailure::NotSent(reason) => {
1517                            log::warn!("Cancel-all failed local validation: {reason}");
1518                        }
1519                        CommandFailure::Ambiguous(reason)
1520                        | CommandFailure::VenueRejected(reason) => {
1521                            log::warn!(
1522                                "Cancel-all ambiguous failure, awaiting reconciliation: {reason}"
1523                            );
1524                        }
1525                    },
1526                }
1527                Ok(())
1528            });
1529
1530            return Ok(());
1531        }
1532
1533        log::debug!(
1534            "Canceling all orders: instrument_id={instrument_id}, side={:?}",
1535            cmd.order_side
1536        );
1537
1538        // Side-filtered cancellation reuses the explicit-id batch path rather than sending one
1539        // HTTP cancel per order.
1540        let cancels: Vec<CancelOrder> = {
1541            let cache = self.core.cache();
1542            let ts_init = self.clock.get_time_ns();
1543            let correlation_id = cmd.correlation_id.or(Some(cmd.command_id));
1544
1545            // As for spot: the venue can have accepted an order the cache still records as
1546            // `Submitted`, so in-flight orders are selected alongside open ones.
1547            let mut seen = HashSet::new();
1548
1549            cache
1550                .orders_open(None, Some(&instrument_id), None, None, None)
1551                .into_iter()
1552                .chain(cache.orders_inflight(None, Some(&instrument_id), None, None, None))
1553                .filter(|order| Some(order.order_side()) == cmd.order_side)
1554                .filter(|order| seen.insert(order.client_order_id()))
1555                .map(|order| {
1556                    CancelOrder::new(
1557                        cmd.trader_id,
1558                        cmd.client_id,
1559                        // Each cancel keeps the owning strategy of the order it targets.
1560                        order.strategy_id(),
1561                        order.instrument_id(),
1562                        order.client_order_id(),
1563                        order.venue_order_id(),
1564                        UUID4::new(),
1565                        ts_init,
1566                        cmd.params.clone(),
1567                        correlation_id,
1568                    )
1569                })
1570                .collect()
1571        };
1572
1573        if cancels.is_empty() {
1574            log::debug!("No open orders to cancel for {instrument_id}");
1575            return Ok(());
1576        }
1577
1578        let http = self.http.clone();
1579        let emitter = self.emitter.clone();
1580        let clock = self.clock;
1581
1582        self.spawn_task("cancel_all_orders_by_side", async move {
1583            batch_cancel_orders_for_futures(&http, &emitter, clock, &cancels).await;
1584            Ok(())
1585        });
1586
1587        Ok(())
1588    }
1589
1590    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1591        log::debug!(
1592            "Batch canceling orders: instrument_id={}, count={}",
1593            cmd.instrument_id,
1594            cmd.cancels.len()
1595        );
1596
1597        let http = self.http.clone();
1598        let emitter = self.emitter.clone();
1599        let clock = self.clock;
1600        let cancels = cmd.cancels;
1601
1602        self.spawn_task("batch_cancel_orders", async move {
1603            batch_cancel_orders_for_futures(&http, &emitter, clock, &cancels).await;
1604            Ok(())
1605        });
1606
1607        Ok(())
1608    }
1609}
1610
1611#[derive(Debug, Clone)]
1612struct CancelRequestContext {
1613    strategy_id: StrategyId,
1614    instrument_id: InstrumentId,
1615    client_order_id: ClientOrderId,
1616    /// The client order ID an item without a venue order ID sends, which the venue echoes on its
1617    /// rejection.
1618    cli_ord_id: String,
1619    venue_order_id: Option<VenueOrderId>,
1620}
1621
1622async fn cancel_order_for_futures(
1623    http: &KrakenFuturesHttpClient,
1624    _account_id: AccountId,
1625    instrument_id: InstrumentId,
1626    client_order_id: Option<ClientOrderId>,
1627    venue_order_id: Option<VenueOrderId>,
1628) -> Result<(), CommandFailure> {
1629    http.get_cached_instrument(&instrument_id.symbol.inner())
1630        .ok_or_else(|| {
1631            CommandFailure::not_sent(InstrumentLookupError::not_found(instrument_id).to_string())
1632        })?;
1633
1634    let (order_id, cli_ord_id) =
1635        order_identifier(client_order_id.as_ref(), venue_order_id.as_ref());
1636
1637    if order_id.is_none() && cli_ord_id.is_none() {
1638        return Err(CommandFailure::not_sent(
1639            "Either client_order_id or venue_order_id must be provided",
1640        ));
1641    }
1642
1643    let response = http
1644        .inner
1645        .cancel_order(order_id, cli_ord_id)
1646        .await
1647        .map_err(command_failure_from_cancel_error)?;
1648
1649    if response.result != KrakenApiResult::Success
1650        || response.cancel_status.status != KrakenSendStatus::Cancelled
1651    {
1652        return Err(CommandFailure::venue_rejected(format!(
1653            "cancel-order rejected: status={}",
1654            response.cancel_status.status
1655        )));
1656    }
1657
1658    Ok(())
1659}
1660
1661async fn batch_cancel_orders_for_futures(
1662    http: &KrakenFuturesHttpClient,
1663    emitter: &ExecutionEventEmitter,
1664    clock: &'static AtomicTime,
1665    cancels: &[CancelOrder],
1666) {
1667    let mut contexts = Vec::new();
1668    let mut items = Vec::new();
1669
1670    for cancel in cancels {
1671        match batch_cancel_item_for_futures(http, cancel) {
1672            Ok((context, item)) => {
1673                contexts.push(context);
1674                items.push(item);
1675            }
1676            Err(CommandFailure::NotSent(reason)) => {
1677                log::warn!(
1678                    "Batch cancel command failed local validation for {}: {reason}",
1679                    cancel.client_order_id
1680                );
1681            }
1682            Err(CommandFailure::Ambiguous(reason) | CommandFailure::VenueRejected(reason)) => {
1683                log::warn!(
1684                    "Batch cancel command ambiguous failure for {}, awaiting reconciliation: {reason}",
1685                    cancel.client_order_id
1686                );
1687            }
1688        }
1689    }
1690
1691    for (item_chunk, context_chunk) in items
1692        .chunks(FUTURES_BATCH_CANCEL_LIMIT)
1693        .zip(contexts.chunks(FUTURES_BATCH_CANCEL_LIMIT))
1694    {
1695        let response = match http
1696            .inner
1697            .cancel_order_items_batch(item_chunk.to_vec())
1698            .await
1699        {
1700            Ok(response) => response,
1701            Err(e) => {
1702                match command_failure_from_cancel_error(e) {
1703                    CommandFailure::NotSent(reason) => {
1704                        log::warn!("Batch cancel failed local validation: {reason}");
1705                    }
1706                    CommandFailure::Ambiguous(reason) | CommandFailure::VenueRejected(reason) => {
1707                        log::warn!(
1708                            "Batch cancel failed without per-order results, awaiting reconciliation: {reason}"
1709                        );
1710                    }
1711                }
1712                continue;
1713            }
1714        };
1715
1716        if response.batch_status.is_empty() {
1717            if response.result != KrakenApiResult::Success {
1718                let reason = response.error.as_deref().unwrap_or("Unknown error");
1719                log::warn!(
1720                    "Batch cancel failed without per-order results, awaiting reconciliation: {reason}"
1721                );
1722            }
1723            continue;
1724        }
1725
1726        if response.batch_status.len() != context_chunk.len() {
1727            log::warn!(
1728                "Batch cancel returned {} per-order result(s) for {} request(s); unmatched results await reconciliation",
1729                response.batch_status.len(),
1730                context_chunk.len()
1731            );
1732        }
1733
1734        for (index, status) in response.batch_status.iter().enumerate() {
1735            let Some(cancel_status) = batch_cancel_status(status) else {
1736                log::warn!("Batch cancel result without status at index {index}");
1737                continue;
1738            };
1739
1740            if cancel_status == KrakenSendStatus::Cancelled {
1741                continue;
1742            }
1743
1744            let Some(context) = batch_cancel_context(status, context_chunk, index) else {
1745                log::warn!(
1746                    "Batch cancel rejected item without matching request context at index {index}: status={cancel_status}"
1747                );
1748                continue;
1749            };
1750
1751            emitter.emit_order_cancel_rejected_event(
1752                context.strategy_id,
1753                context.instrument_id,
1754                context.client_order_id,
1755                context.venue_order_id,
1756                &format!("batch-cancel rejected: status={cancel_status}"),
1757                clock.get_time_ns(),
1758            );
1759        }
1760    }
1761}
1762
1763fn batch_cancel_item_for_futures(
1764    http: &KrakenFuturesHttpClient,
1765    cancel: &CancelOrder,
1766) -> Result<(CancelRequestContext, KrakenFuturesBatchCancelItem), CommandFailure> {
1767    http.get_cached_instrument(&cancel.instrument_id.symbol.inner())
1768        .ok_or_else(|| {
1769            CommandFailure::not_sent(
1770                InstrumentLookupError::not_found(cancel.instrument_id).to_string(),
1771            )
1772        })?;
1773
1774    let cli_ord_id = cancel.client_order_id.to_string();
1775    let item = if let Some(venue_order_id) = cancel.venue_order_id {
1776        KrakenFuturesBatchCancelItem::from_order_id(venue_order_id.to_string())
1777    } else {
1778        KrakenFuturesBatchCancelItem::from_client_order_id(cli_ord_id.clone())
1779    };
1780
1781    Ok((
1782        CancelRequestContext {
1783            strategy_id: cancel.strategy_id,
1784            instrument_id: cancel.instrument_id,
1785            client_order_id: cancel.client_order_id,
1786            cli_ord_id,
1787            venue_order_id: cancel.venue_order_id,
1788        },
1789        item,
1790    ))
1791}
1792
1793fn batch_cancel_status(status: &FuturesBatchCancelStatus) -> Option<KrakenSendStatus> {
1794    status
1795        .cancel_status
1796        .as_ref()
1797        .map(|cancel_status| cancel_status.status)
1798        .or(status.status)
1799}
1800
1801fn batch_cancel_context<'a>(
1802    status: &FuturesBatchCancelStatus,
1803    contexts: &'a [CancelRequestContext],
1804    index: usize,
1805) -> Option<&'a CancelRequestContext> {
1806    if let Some(order_id) = status.order_id.as_deref()
1807        && let Some(context) = contexts.iter().find(|context| {
1808            context
1809                .venue_order_id
1810                .is_some_and(|venue_order_id| venue_order_id.as_str() == order_id)
1811        })
1812    {
1813        return Some(context);
1814    }
1815
1816    if let Some(cli_ord_id) = status.cli_ord_id.as_deref()
1817        && let Some(context) = contexts
1818            .iter()
1819            .find(|context| context.cli_ord_id == cli_ord_id)
1820    {
1821        return Some(context);
1822    }
1823
1824    if index < contexts.len() && status.order_id.is_none() && status.cli_ord_id.is_none() {
1825        return contexts.get(index);
1826    }
1827
1828    None
1829}
1830
1831fn handle_cancel_failure(
1832    emitter: &ExecutionEventEmitter,
1833    clock: &'static AtomicTime,
1834    strategy_id: StrategyId,
1835    instrument_id: InstrumentId,
1836    client_order_id: ClientOrderId,
1837    venue_order_id: Option<VenueOrderId>,
1838    failure: CommandFailure,
1839) {
1840    match failure {
1841        CommandFailure::VenueRejected(reason) => {
1842            emitter.emit_order_cancel_rejected_event(
1843                strategy_id,
1844                instrument_id,
1845                client_order_id,
1846                venue_order_id,
1847                &reason,
1848                clock.get_time_ns(),
1849            );
1850        }
1851        CommandFailure::NotSent(reason) => {
1852            log::warn!("Cancel command failed local validation for {client_order_id}: {reason}");
1853        }
1854        CommandFailure::Ambiguous(reason) => {
1855            log::warn!(
1856                "Ambiguous cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
1857            );
1858        }
1859    }
1860}
1861
1862impl KrakenFuturesExecutionClient {
1863    /// Returns `/orders/status` reports for cached-open orders the given
1864    /// venue reports do not cover, and whether it withheld any report.
1865    ///
1866    /// A Maker Protection hold never reaches the book, so it is invisible to
1867    /// an open-orders snapshot while the venue still knows the order within
1868    /// its 5-second `/orders/status` window. Orders without a venue order ID
1869    /// yet (their submit acknowledgement is still inside the hold window) are
1870    /// queried by their client order ID, in both forms `venue_client_order_ids`
1871    /// gives, the only venue handle that exists during the window. Each report
1872    /// is named by the cached order it describes, as
1873    /// `attribute_orders_status_reports` resolves it.
1874    ///
1875    /// # Errors
1876    ///
1877    /// Returns an error if the underlying request fails or the venue rejects
1878    /// it, so callers defer rather than treat the orders as missing.
1879    async fn reports_for_open_orders_absent_from_venue(
1880        &self,
1881        account_id: AccountId,
1882        instrument_id: Option<InstrumentId>,
1883        reported: &[OrderStatusReport],
1884    ) -> anyhow::Result<(Vec<OrderStatusReport>, bool)> {
1885        let mut order_ids = Vec::new();
1886        let mut queried_by_cli_ord_id = Vec::new();
1887
1888        {
1889            let cache = self.core.cache();
1890            for order in cache.orders_open(
1891                Some(&*KRAKEN_VENUE),
1892                instrument_id.as_ref(),
1893                None,
1894                None,
1895                None,
1896            ) {
1897                // Spot and Futures share the KRAKEN venue and one cache
1898                if product_type_from_symbol(order.instrument_id().symbol.inner().as_str())
1899                    != KrakenProductType::Futures
1900                {
1901                    continue;
1902                }
1903
1904                match order.venue_order_id() {
1905                    Some(venue_order_id) => {
1906                        let already_reported = reported
1907                            .iter()
1908                            .any(|report| report.venue_order_id == venue_order_id);
1909                        if !already_reported {
1910                            order_ids.push(venue_order_id.to_string());
1911                        }
1912                    }
1913                    None => {
1914                        queried_by_cli_ord_id.push(HeldOrder {
1915                            client_order_id: order.client_order_id(),
1916                            instrument_id: order.instrument_id(),
1917                            side: order.order_side(),
1918                            ts_init: order.ts_init(),
1919                        });
1920                    }
1921                }
1922            }
1923        }
1924
1925        if order_ids.is_empty() && queried_by_cli_ord_id.is_empty() {
1926            return Ok((Vec::new(), false));
1927        }
1928
1929        let cli_ord_ids: Vec<String> = queried_by_cli_ord_id
1930            .iter()
1931            .flat_map(|order| venue_client_order_ids(&order.client_order_id))
1932            .collect();
1933
1934        log::debug!(
1935            "Resolving {} venue order ID(s) and {} client order ID(s) from the orders-status window",
1936            order_ids.len(),
1937            cli_ord_ids.len(),
1938        );
1939
1940        let mut reports = Vec::new();
1941        for chunk in order_ids.chunks(FUTURES_ORDERS_STATUS_LIMIT) {
1942            reports.extend(
1943                self.http
1944                    .request_orders_status_reports(account_id, chunk, &[])
1945                    .await?,
1946            );
1947        }
1948
1949        for chunk in cli_ord_ids.chunks(FUTURES_ORDERS_STATUS_LIMIT) {
1950            reports.extend(
1951                self.http
1952                    .request_orders_status_reports(account_id, &[], chunk)
1953                    .await?,
1954            );
1955        }
1956
1957        let (mut attributed, withheld) = attribute_orders_status_reports(
1958            &self.core.cache(),
1959            &queried_by_cli_ord_id,
1960            latest_report_per_order(reports),
1961        );
1962
1963        // A client order ID request returns every order holding the ID, so a run scoped to one
1964        // instrument keeps only that instrument's reports.
1965        if let Some(instrument_id) = instrument_id {
1966            attributed.retain(|report| report.instrument_id == instrument_id);
1967        }
1968
1969        Ok((attributed, withheld))
1970    }
1971}
1972
1973/// Checks an order against the venue's documented limits before it is submitted.
1974fn validate_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
1975    let len = order.client_order_id().as_str().len();
1976
1977    if len > KRAKEN_FUTURES_MAX_CLIENT_ORDER_ID_LEN {
1978        return Err(OrderDeniedReason::InvalidClientOrderId {
1979            detail: format!(
1980                "Kraken Futures accepts a client order ID of at most \
1981                 {KRAKEN_FUTURES_MAX_CLIENT_ORDER_ID_LEN} characters, was {len}"
1982            ),
1983        });
1984    }
1985
1986    Ok(())
1987}
1988
1989/// The cached order a report describes, found by its venue order ID.
1990fn cached_order_for_report(cache: &Cache, report: &OrderStatusReport) -> Option<OrderAny> {
1991    let client_order_id = cache.client_order_id(&report.venue_order_id)?;
1992    cache.order(client_order_id).map(|order| order.clone())
1993}
1994
1995/// The order a single-order query asks about, and how a venue row is tied to it.
1996///
1997/// With its venue order ID known, the row carrying that ID is the order. Otherwise a row is the
1998/// order's when it echoes the order's client order ID in either form `venue_client_order_ids`
1999/// gives, on the order's instrument and side, placed no more than `ORDERS_STATUS_CLOCK_SKEW`
2000/// before the order's creation: an older row is another order whose ID shortens alike or that
2001/// reused the ID. A fill carries no placement time, so its fill time stands in, which rules out
2002/// only a fill executed before the order's creation. Without a cached order to check against, the
2003/// echo alone ties a row to it. A row of a venue order the cache indexes for another order is that
2004/// order's whatever it echoes, since another order's full ID can be this order's shortened one.
2005/// A row echoing the full ID of another cached order sent but carrying no venue order ID, on that
2006/// order's instrument and side and placed no more than `ORDERS_STATUS_CLOCK_SKEW` before its
2007/// creation, can be either order's while the cache indexes its venue order for neither, and leaves
2008/// the answer open.
2009struct QueriedOrder {
2010    venue_order_id: Option<VenueOrderId>,
2011    client_order_id: Option<ClientOrderId>,
2012    cached: Option<(InstrumentId, OrderSide, UnixNanos)>,
2013}
2014
2015impl QueriedOrder {
2016    fn new(cmd: &GenerateOrderStatusReport, cached: Option<&OrderAny>) -> Self {
2017        Self {
2018            venue_order_id: cmd
2019                .venue_order_id
2020                .or_else(|| cached.and_then(Order::venue_order_id)),
2021            client_order_id: cmd
2022                .client_order_id
2023                .or_else(|| cached.map(Order::client_order_id)),
2024            cached: cached
2025                .map(|order| (order.instrument_id(), order.order_side(), order.ts_init())),
2026        }
2027    }
2028
2029    /// Whether a row echoing `echoed` on `instrument_id` and `side`, placed at `placed`, can be
2030    /// this order by its client order ID.
2031    fn echoes(
2032        &self,
2033        echoed: Option<ClientOrderId>,
2034        instrument_id: InstrumentId,
2035        side: OrderSide,
2036        placed: UnixNanos,
2037    ) -> bool {
2038        let echoes_id = echoed
2039            .zip(self.client_order_id)
2040            .is_some_and(|(echoed, queried)| {
2041                venue_client_order_ids(&queried).any(|id| id == echoed.as_str())
2042            });
2043
2044        echoes_id
2045            && self
2046                .cached
2047                .is_none_or(|(order_instrument, order_side, created)| {
2048                    instrument_id == order_instrument
2049                        && side == order_side
2050                        && placed.saturating_add(ORDERS_STATUS_CLOCK_SKEW) >= created
2051                })
2052    }
2053
2054    /// Whether the cache indexes `venue_order_id` for an order other than this one.
2055    fn indexed_for_another(&self, cache: &Cache, venue_order_id: &VenueOrderId) -> bool {
2056        cache
2057            .client_order_id(venue_order_id)
2058            .is_some_and(|owner| Some(*owner) != self.client_order_id)
2059    }
2060
2061    fn echoes_report(&self, cache: &Cache, report: &OrderStatusReport) -> bool {
2062        !self.indexed_for_another(cache, &report.venue_order_id)
2063            && report.order_side.is_some_and(|side| {
2064                self.echoes(
2065                    report.client_order_id,
2066                    report.instrument_id,
2067                    side,
2068                    report.ts_accepted,
2069                )
2070            })
2071    }
2072
2073    fn echoes_fill(&self, cache: &Cache, fill: &FillReport) -> bool {
2074        !self.indexed_for_another(cache, &fill.venue_order_id)
2075            && self.echoes(
2076                fill.client_order_id,
2077                fill.instrument_id,
2078                fill.order_side,
2079                fill.ts_event,
2080            )
2081    }
2082
2083    /// Whether another cached order, sent but without a venue order ID, holds `echoed` as its full
2084    /// client order ID and a row on `instrument_id` and `side`, placed at `placed`, can be that
2085    /// order's.
2086    fn echo_is_another_order(
2087        &self,
2088        cache: &Cache,
2089        echoed: Option<ClientOrderId>,
2090        instrument_id: InstrumentId,
2091        side: OrderSide,
2092        placed: UnixNanos,
2093    ) -> bool {
2094        echoed
2095            .filter(|echoed| Some(*echoed) != self.client_order_id)
2096            .and_then(|echoed| cache.order(&echoed))
2097            .is_some_and(|other| {
2098                other.ts_submitted().is_some()
2099                    && other.venue_order_id().is_none()
2100                    && other.instrument_id() == instrument_id
2101                    && other.order_side() == side
2102                    && placed.saturating_add(ORDERS_STATUS_CLOCK_SKEW) >= other.ts_init()
2103            })
2104    }
2105
2106    fn report_may_be_another_order(&self, cache: &Cache, report: &OrderStatusReport) -> bool {
2107        report.order_side.is_some_and(|side| {
2108            self.echo_is_another_order(
2109                cache,
2110                report.client_order_id,
2111                report.instrument_id,
2112                side,
2113                report.ts_accepted,
2114            )
2115        })
2116    }
2117
2118    fn fill_may_be_another_order(&self, cache: &Cache, fill: &FillReport) -> bool {
2119        self.echo_is_another_order(
2120            cache,
2121            fill.client_order_id,
2122            fill.instrument_id,
2123            fill.order_side,
2124            fill.ts_event,
2125        )
2126    }
2127
2128    /// The report of this order, named by its client order ID when matched by it.
2129    ///
2130    /// # Errors
2131    ///
2132    /// Returns an error if reports of several venue orders can be this order, or a report that can
2133    /// be this order can also be another cached order's by its full client order ID, so the query
2134    /// defers rather than take one of them.
2135    fn find_report(
2136        &self,
2137        cache: &Cache,
2138        reports: &[OrderStatusReport],
2139    ) -> anyhow::Result<Option<OrderStatusReport>> {
2140        if let Some(venue_order_id) = self.venue_order_id {
2141            return Ok(reports
2142                .iter()
2143                .find(|report| report.venue_order_id == venue_order_id)
2144                .cloned());
2145        }
2146
2147        let mut candidates = reports
2148            .iter()
2149            .filter(|report| self.echoes_report(cache, report));
2150        let Some(first) = candidates.next() else {
2151            return Ok(None);
2152        };
2153
2154        if candidates.any(|report| report.venue_order_id != first.venue_order_id) {
2155            anyhow::bail!(
2156                "Reports of several venue orders carry the client order ID of order {:?}; \
2157                 deferring rather than taking one of them",
2158                self.client_order_id,
2159            );
2160        }
2161
2162        if let Some(shared) = reports.iter().find(|report| {
2163            self.echoes_report(cache, report) && self.report_may_be_another_order(cache, report)
2164        }) {
2165            anyhow::bail!(
2166                "Report of venue order {} carries {:?}, the full ID of another cached order and a \
2167                 form of order {:?}; deferring until the cache ties the venue order to one of them",
2168                shared.venue_order_id,
2169                shared.client_order_id,
2170                self.client_order_id,
2171            );
2172        }
2173
2174        let mut report = first.clone();
2175        report.client_order_id = self.client_order_id;
2176        Ok(Some(report))
2177    }
2178
2179    /// Whether a report of another venue order can be this order, which leaves absence open:
2180    /// the venue may hold the order under that report.
2181    fn has_stray_report(&self, cache: &Cache, reports: &[OrderStatusReport]) -> bool {
2182        self.venue_order_id.is_some_and(|venue_order_id| {
2183            reports.iter().any(|report| {
2184                report.venue_order_id != venue_order_id && self.echoes_report(cache, report)
2185            })
2186        })
2187    }
2188
2189    /// Whether a fill other than one of this order's known venue order can be this order's, which
2190    /// leaves absence open. With the venue order ID unknown, any fill echoing the order counts: it
2191    /// may be this order's, short of its quantity or beside another venue order's.
2192    fn has_stray_fill(&self, cache: &Cache, fills: &[FillReport]) -> bool {
2193        fills.iter().any(|fill| {
2194            Some(fill.venue_order_id) != self.venue_order_id && self.echoes_fill(cache, fill)
2195        })
2196    }
2197}
2198
2199/// The client order IDs the venue can hold `client_order_id` under: the full ID the adapter sends
2200/// and, when it differs, the form `truncate_cl_ord_id` gives, which an adapter that shortened
2201/// futures IDs past 18 characters sent and which another order's ID can share.
2202fn venue_client_order_ids(client_order_id: &ClientOrderId) -> impl Iterator<Item = String> {
2203    let full = client_order_id.to_string();
2204    let shortened = truncate_cl_ord_id(client_order_id);
2205    let shortened = (shortened != full).then_some(shortened);
2206    std::iter::once(full).chain(shortened)
2207}
2208
2209/// An open order the orders-status window is asked about by its client order ID.
2210#[derive(Clone, Copy, Debug)]
2211struct HeldOrder {
2212    client_order_id: ClientOrderId,
2213    instrument_id: InstrumentId,
2214    side: OrderSide,
2215    ts_init: UnixNanos,
2216}
2217
2218/// Names each report by the cached order indexing its venue order ID. The venue echoes the client
2219/// order ID an order was sent with, which for an order sent with a shortened ID is not the cached
2220/// one, and reconciliation matches a report carrying a client order ID by that ID alone.
2221fn name_reports_by_cached_order(cache: &Cache, reports: &mut [OrderStatusReport]) {
2222    for report in reports {
2223        if let Some(client_order_id) = cache.client_order_id(&report.venue_order_id) {
2224            report.client_order_id = Some(*client_order_id);
2225        }
2226    }
2227}
2228
2229/// Names each fill by the cached order indexing its venue order ID, as
2230/// [`name_reports_by_cached_order`] names order reports.
2231fn name_fills_by_cached_order(cache: &Cache, fills: &mut [FillReport]) {
2232    for fill in fills {
2233        if let Some(client_order_id) = cache.client_order_id(&fill.venue_order_id) {
2234            fill.client_order_id = Some(*client_order_id);
2235        }
2236    }
2237}
2238
2239/// Folds window reports into the venue read by venue order ID, returning the reports of the orders
2240/// the venue read did not return and the venue order IDs of the reports that took a window state.
2241///
2242/// A window copy of an order the venue read returned names that report by the cached order the
2243/// window attributed it to, then is dropped, so the order reaches reconciliation once with the
2244/// venue read's fields, such as time in force, post-only and trigger fields, which the window
2245/// lacks. When the report is open and the copy describes a later state, by `supersedes`, the
2246/// report takes the copy's status, quantities, price, average price, cancel reason and last
2247/// update, and answers to the window's rules; a closed report keeps its own state and the pricing
2248/// path it has.
2249fn fold_window_into_venue_read(
2250    venue_read: &mut [OrderStatusReport],
2251    window: Vec<OrderStatusReport>,
2252) -> (Vec<OrderStatusReport>, HashSet<VenueOrderId>) {
2253    let positions: HashMap<VenueOrderId, usize> = venue_read
2254        .iter()
2255        .enumerate()
2256        .map(|(position, report)| (report.venue_order_id, position))
2257        .collect();
2258    let mut window_stated = HashSet::new();
2259
2260    let window_only = window
2261        .into_iter()
2262        .filter_map(|copy| {
2263            let Some(&position) = positions.get(&copy.venue_order_id) else {
2264                return Some(copy);
2265            };
2266            let report = &mut venue_read[position];
2267            report.client_order_id = copy.client_order_id;
2268
2269            if !report.order_status.is_closed() && supersedes(&copy, report) {
2270                report.order_status = copy.order_status;
2271                report.quantity = copy.quantity;
2272                report.filled_qty = copy.filled_qty;
2273                report.price = copy.price;
2274                report.avg_px = copy.avg_px;
2275                report.cancel_reason = copy.cancel_reason;
2276                report.ts_last = copy.ts_last;
2277                window_stated.insert(copy.venue_order_id);
2278            }
2279            None
2280        })
2281        .collect();
2282
2283    (window_only, window_stated)
2284}
2285
2286/// Keeps one report per venue order ID, the one describing the order's latest state.
2287///
2288/// The venue-ID and client-order-ID requests run one after the other, so both can return one order
2289/// in different states. `supersedes` decides between them rather than the order the requests ran
2290/// in: a closed state over an open one, else the later venue update, else the larger filled
2291/// quantity.
2292fn latest_report_per_order(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
2293    let mut latest: IndexMap<VenueOrderId, OrderStatusReport> =
2294        IndexMap::with_capacity(reports.len());
2295
2296    for report in reports {
2297        match latest.get(&report.venue_order_id) {
2298            Some(existing) if !supersedes(&report, existing) => {}
2299            _ => {
2300                latest.insert(report.venue_order_id, report);
2301            }
2302        }
2303    }
2304
2305    latest.into_values().collect()
2306}
2307
2308/// Names each `/orders/status` report by the client order ID of the cached order it describes,
2309/// returning the reports it attributes and whether it withheld any. `reports` holds one report per
2310/// venue order ID.
2311///
2312/// The venue echoes the client order ID an order was sent with, in either form
2313/// `venue_client_order_ids` gives, and reconciliation matches a report carrying a client order ID
2314/// by that ID alone. A report takes the ID of the cached order indexing its venue order ID.
2315/// Otherwise its echoed ID names an order queried by client order ID only when exactly one such
2316/// order holds it, exactly one report without an indexed venue order ID carries it, the report's
2317/// instrument and side are the order's, and the venue placed it no more than
2318/// `ORDERS_STATUS_CLOCK_SKEW` before the order's creation, which keeps out an older order whose ID
2319/// shortens alike or that reused the ID. Two reports that would name one order, one echoing each
2320/// form, name neither. Any other report is withheld with a warning naming the reason, since it
2321/// cannot be tied to exactly one cached order: reconciliation would take it for an external order
2322/// or for the wrong one.
2323fn attribute_orders_status_reports(
2324    cache: &Cache,
2325    queried_by_cli_ord_id: &[HeldOrder],
2326    reports: Vec<OrderStatusReport>,
2327) -> (Vec<OrderStatusReport>, bool) {
2328    // None marks an echoed ID that several queried orders share
2329    let mut held: HashMap<String, Option<HeldOrder>> = HashMap::new();
2330
2331    for order in queried_by_cli_ord_id {
2332        for echoed in venue_client_order_ids(&order.client_order_id) {
2333            held.entry(echoed)
2334                .and_modify(|named| *named = None)
2335                .or_insert(Some(*order));
2336        }
2337    }
2338
2339    let indexed =
2340        |report: &OrderStatusReport| cache.client_order_id(&report.venue_order_id).copied();
2341
2342    let mut unindexed: HashMap<ClientOrderId, HashSet<VenueOrderId>> = HashMap::new();
2343
2344    for report in &reports {
2345        if indexed(report).is_none()
2346            && let Some(echoed) = report.client_order_id
2347        {
2348            unindexed
2349                .entry(echoed)
2350                .or_default()
2351                .insert(report.venue_order_id);
2352        }
2353    }
2354
2355    let by_echoed_id = |report: &OrderStatusReport| -> Result<ClientOrderId, &'static str> {
2356        let echoed = report
2357            .client_order_id
2358            .ok_or("it carries no client order ID")?;
2359        if unindexed
2360            .get(&echoed)
2361            .is_none_or(|venue_order_ids| venue_order_ids.len() != 1)
2362        {
2363            return Err("several reports carry its client order ID");
2364        }
2365        let order = held
2366            .get(echoed.as_str())
2367            .ok_or("no order queried by client order ID holds its client order ID")?
2368            .ok_or("several queried orders hold its client order ID")?;
2369
2370        if report.instrument_id != order.instrument_id || report.order_side != Some(order.side) {
2371            return Err("its instrument or side is not the queried order's");
2372        }
2373
2374        if report.ts_accepted.saturating_add(ORDERS_STATUS_CLOCK_SKEW) < order.ts_init {
2375            return Err(
2376                "the venue placed it before the queried order's creation, beyond the clock skew \
2377                 allowance; check the node clock if the order is recent",
2378            );
2379        }
2380
2381        Ok(order.client_order_id)
2382    };
2383
2384    let named: Vec<(OrderStatusReport, Result<ClientOrderId, &'static str>, bool)> = reports
2385        .into_iter()
2386        .map(|report| match indexed(&report) {
2387            Some(client_order_id) => (report, Ok(client_order_id), false),
2388            None => {
2389                let named = by_echoed_id(&report);
2390                (report, named, true)
2391            }
2392        })
2393        .collect();
2394
2395    // A queried order echoed in full on one report and shortened on another is named by both
2396    let mut named_by_echo: HashMap<ClientOrderId, usize> = HashMap::new();
2397
2398    for (_, named, by_echo) in &named {
2399        if *by_echo && let Ok(client_order_id) = named {
2400            *named_by_echo.entry(*client_order_id).or_default() += 1;
2401        }
2402    }
2403
2404    let mut attributed = Vec::with_capacity(named.len());
2405    let mut withheld = false;
2406
2407    for (mut report, named, by_echo) in named {
2408        let named = match named {
2409            Ok(client_order_id)
2410                if by_echo && named_by_echo.get(&client_order_id).is_some_and(|n| *n > 1) =>
2411            {
2412                Err("several reports name the queried order it would be")
2413            }
2414            named => named,
2415        };
2416
2417        match named {
2418            Ok(client_order_id) => {
2419                report.client_order_id = Some(client_order_id);
2420                attributed.push(report);
2421            }
2422            Err(reason) => {
2423                log::warn!(
2424                    "Withholding orders-status report {} with client order ID {:?}: {reason}",
2425                    report.venue_order_id,
2426                    report.client_order_id,
2427                );
2428                withheld = true;
2429            }
2430        }
2431    }
2432
2433    (attributed, withheld)
2434}
2435
2436/// Whether a combined order read failed because the venue refused its history page.
2437///
2438/// The rule goes by the response's shape: an error body under a success status, whatever its
2439/// code, or HTTP 429 is a refusal. Any other HTTP status, a transport failure and a body that
2440/// cannot be parsed are faults, as is any failure of the open-order read, which carries no typed
2441/// error. A credential failure fails that open-order read, which runs first with the same key.
2442fn is_refused_history_read(error: &anyhow::Error) -> bool {
2443    error
2444        .chain()
2445        .filter_map(|cause| cause.downcast_ref::<KrakenHttpError>())
2446        .any(|e| match e {
2447            KrakenHttpError::ApiError(_) => true,
2448            KrakenHttpError::NetworkError(message) => message.starts_with("HTTP error 429"),
2449            _ => false,
2450        })
2451}
2452
2453/// Whether `report` is terminal with an executed quantity it carries no price for.
2454///
2455/// An order history row has no average price. Unpriced, reconciliation infers the executed
2456/// quantity at the report's limit price, and cannot price a market order at all.
2457fn is_unpriced_terminal_report(report: &OrderStatusReport) -> bool {
2458    matches!(
2459        report.order_status,
2460        OrderStatus::Filled | OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Voided
2461    ) && !report.filled_qty.is_zero()
2462        && report.avg_px.is_none()
2463}
2464
2465/// Sets `report.avg_px` to the quantity-weighted price of the order's executions.
2466///
2467/// The venue's own fills price the report when they cover its filled quantity exactly. Otherwise
2468/// the fills a cached order has recorded complete the page, each counted once by trade ID, and
2469/// must cover it exactly too. Exact coverage rejects a double count: an execution the engine
2470/// inferred carries a synthetic trade ID, so it would otherwise be counted beside the venue fill
2471/// it stands for. The adapter reads only the latest fills page, so an older execution can be absent
2472/// from it.
2473///
2474/// # Errors
2475///
2476/// Returns an error if neither source covers the report's filled quantity exactly. The error
2477/// contains the covered quantity, and the report remains unpriced.
2478fn price_from_fills(
2479    report: &mut OrderStatusReport,
2480    cached: Option<&OrderAny>,
2481    fills: &[FillReport],
2482) -> Result<(), Decimal> {
2483    let target = report.filled_qty.as_decimal();
2484    let order_fills: Vec<&FillReport> = fills
2485        .iter()
2486        .filter(|fill| fill.venue_order_id == report.venue_order_id)
2487        .collect();
2488    let total = |fills: &mut dyn Iterator<Item = &&FillReport>| {
2489        fills.fold((Decimal::ZERO, Decimal::ZERO), |(qty, notional), fill| {
2490            let fill_qty = fill.last_qty.as_decimal();
2491            (
2492                qty + fill_qty,
2493                notional + fill_qty * fill.last_px.as_decimal(),
2494            )
2495        })
2496    };
2497
2498    let (page_qty, page_notional) = total(&mut order_fills.iter());
2499
2500    if !target.is_zero() && page_qty == target {
2501        report.avg_px = Some(page_notional / target);
2502        return Ok(());
2503    }
2504
2505    let Some(order) = cached else {
2506        return Err(page_qty);
2507    };
2508    let recorded: HashSet<TradeId> = order.trade_ids().into_iter().copied().collect();
2509    let (new_qty, new_notional) = total(
2510        &mut order_fills
2511            .iter()
2512            .filter(|fill| !recorded.contains(&fill.trade_id)),
2513    );
2514    let cached_qty = order.filled_qty().as_decimal();
2515    let covered = cached_qty + new_qty;
2516
2517    if !target.is_zero() && covered == target {
2518        let cached_notional = order.avg_px().unwrap_or(Decimal::ZERO) * cached_qty;
2519        report.avg_px = Some((cached_notional + new_notional) / target);
2520        return Ok(());
2521    }
2522
2523    Err(covered.max(page_qty))
2524}
2525
2526fn synthesize_filled_order_status_report(
2527    cache: &Cache,
2528    queried: &QueriedOrder,
2529    order: &OrderAny,
2530    fills: &[FillReport],
2531) -> Option<OrderStatusReport> {
2532    let venue_order_id = queried.venue_order_id;
2533
2534    // A fill of another venue order can carry the same client order ID, so with the venue order
2535    // ID known only its fills count, and without it the fills must belong to one venue order that
2536    // no other cached order can hold by its full client order ID.
2537    let mut matched: Vec<&FillReport> = match venue_order_id {
2538        Some(venue_order_id) => fills
2539            .iter()
2540            .filter(|fill| fill.venue_order_id == venue_order_id)
2541            .collect(),
2542        None => {
2543            let matched: Vec<&FillReport> = fills
2544                .iter()
2545                .filter(|fill| queried.echoes_fill(cache, fill))
2546                .collect();
2547            let first_venue_order_id = matched.first()?.venue_order_id;
2548
2549            if matched.iter().any(|fill| {
2550                fill.venue_order_id != first_venue_order_id
2551                    || queried.fill_may_be_another_order(cache, fill)
2552            }) {
2553                return None;
2554            }
2555            matched
2556        }
2557    };
2558
2559    if matched.is_empty() {
2560        return None;
2561    }
2562
2563    matched.sort_by_key(|fill| fill.ts_event);
2564    let first_fill = *matched.first()?;
2565    let last_fill = *matched.last()?;
2566
2567    let total_filled = matched
2568        .iter()
2569        .fold(Decimal::ZERO, |acc, fill| acc + fill.last_qty.as_decimal());
2570    if total_filled < order.quantity().as_decimal() {
2571        return None;
2572    }
2573
2574    let total_notional = matched.iter().fold(Decimal::ZERO, |acc, fill| {
2575        acc + fill.last_qty.as_decimal() * fill.last_px.as_decimal()
2576    });
2577    let avg_px = if total_filled.is_zero() {
2578        None
2579    } else {
2580        Some(total_notional / total_filled)
2581    };
2582    let venue_order_id = venue_order_id.unwrap_or(first_fill.venue_order_id);
2583
2584    let mut report = OrderStatusReport::new(
2585        first_fill.account_id,
2586        order.instrument_id(),
2587        Some(order.client_order_id()),
2588        venue_order_id,
2589        order.order_side().into(),
2590        order.order_type(),
2591        order.time_in_force(),
2592        OrderStatus::Filled,
2593        order.quantity(),
2594        order.quantity(),
2595        first_fill.ts_event,
2596        last_fill.ts_event,
2597        last_fill.ts_init,
2598        None,
2599    );
2600    report.order_list_id = order.order_list_id();
2601    report.venue_position_id = matched.iter().rev().find_map(|fill| fill.venue_position_id);
2602    report.linked_order_ids = order
2603        .linked_order_ids()
2604        .map(|linked_order_ids| linked_order_ids.to_vec());
2605    report.parent_order_id = order.parent_order_id();
2606    report.expire_time = order.expire_time();
2607    report.price = order.price();
2608    report.trigger_price = order.trigger_price();
2609    report.trigger_type = order.trigger_type();
2610    report.avg_px = avg_px;
2611    report.display_qty = order.display_qty();
2612    report.post_only = order.is_post_only();
2613    report.reduce_only = order.is_reduce_only();
2614    Some(report)
2615}
2616
2617#[cfg(test)]
2618mod tests {
2619    use nautilus_core::{UUID4, UnixNanos};
2620    use nautilus_model::{
2621        enums::{LiquiditySide, OrderSide, OrderType, TimeInForce},
2622        identifiers::{
2623            AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
2624        },
2625        orders::OrderTestBuilder,
2626        reports::FillReport,
2627        types::{Currency, Money, Price, Quantity},
2628    };
2629    use rstest::rstest;
2630
2631    use super::*;
2632
2633    const TEST_INSTRUMENT_ID: &str = "PF_XBTUSD.KRAKEN";
2634
2635    #[tokio::test]
2636    async fn test_cancel_order_for_futures_missing_cached_instrument_returns_canonical_error() {
2637        let http = KrakenFuturesHttpClient::default();
2638        let instrument_id = InstrumentId::from(TEST_INSTRUMENT_ID);
2639        let client_order_id = ClientOrderId::from("C-001");
2640        let venue_order_id = VenueOrderId::from("V-001");
2641
2642        let result = cancel_order_for_futures(
2643            &http,
2644            AccountId::from("KRAKEN-001"),
2645            instrument_id,
2646            Some(client_order_id),
2647            Some(venue_order_id),
2648        )
2649        .await;
2650
2651        match result {
2652            Err(CommandFailure::NotSent(reason)) => {
2653                assert_eq!(
2654                    reason,
2655                    InstrumentLookupError::not_found(instrument_id).to_string()
2656                );
2657            }
2658            _ => panic!("Expected local validation failure"),
2659        }
2660    }
2661
2662    #[rstest]
2663    fn test_batch_cancel_item_for_futures_missing_cached_instrument_returns_canonical_error() {
2664        let http = KrakenFuturesHttpClient::default();
2665        let instrument_id = InstrumentId::from(TEST_INSTRUMENT_ID);
2666        let cancel = CancelOrder::new(
2667            TraderId::from("TESTER-001"),
2668            None,
2669            StrategyId::from("S-001"),
2670            instrument_id,
2671            ClientOrderId::from("C-001"),
2672            Some(VenueOrderId::from("V-001")),
2673            UUID4::new(),
2674            UnixNanos::default(),
2675            None,
2676            None,
2677        );
2678
2679        let result = batch_cancel_item_for_futures(&http, &cancel);
2680
2681        match result {
2682            Err(CommandFailure::NotSent(reason)) => {
2683                assert_eq!(
2684                    reason,
2685                    InstrumentLookupError::not_found(instrument_id).to_string()
2686                );
2687            }
2688            _ => panic!("Expected local validation failure"),
2689        }
2690    }
2691
2692    fn make_fill(
2693        venue_order_id: &str,
2694        client_order_id: Option<&str>,
2695        quantity: &str,
2696        price: &str,
2697        ts_event: u64,
2698    ) -> FillReport {
2699        FillReport::new(
2700            AccountId::from("KRAKEN-001"),
2701            InstrumentId::from(TEST_INSTRUMENT_ID),
2702            VenueOrderId::from(venue_order_id),
2703            TradeId::from(format!("T-{ts_event}").as_str()),
2704            OrderSide::Buy,
2705            Quantity::from(quantity),
2706            Price::from(price),
2707            Money::from_decimal(Decimal::ZERO, Currency::USD()).unwrap(),
2708            LiquiditySide::Taker,
2709            client_order_id.map(ClientOrderId::from),
2710            None,
2711            UnixNanos::from(ts_event),
2712            UnixNanos::from(ts_event),
2713            None,
2714        )
2715    }
2716
2717    fn make_cmd(
2718        client_order_id: Option<&str>,
2719        venue_order_id: Option<&str>,
2720    ) -> GenerateOrderStatusReport {
2721        GenerateOrderStatusReport::new(
2722            UUID4::new(),
2723            UnixNanos::default(),
2724            Some(InstrumentId::from(TEST_INSTRUMENT_ID)),
2725            client_order_id.map(ClientOrderId::from),
2726            venue_order_id.map(VenueOrderId::from),
2727            None,
2728            None,
2729        )
2730    }
2731
2732    fn make_order(client_order_id: &str) -> OrderAny {
2733        OrderTestBuilder::new(OrderType::Market)
2734            .instrument_id(InstrumentId::from(TEST_INSTRUMENT_ID))
2735            .client_order_id(ClientOrderId::from(client_order_id))
2736            .side(OrderSide::Buy)
2737            .quantity(Quantity::from("100"))
2738            .time_in_force(TimeInForce::Ioc)
2739            .build()
2740    }
2741
2742    #[rstest]
2743    fn test_synthesize_filled_order_status_report_matches_full_fill_by_venue_order_id() {
2744        let order = make_order("O-123456");
2745        let cmd = make_cmd(Some("O-123456"), Some("KRAKEN-789"));
2746        let fills = vec![
2747            make_fill("KRAKEN-789", Some("O-123456"), "40", "50000.0", 1),
2748            make_fill("KRAKEN-789", Some("O-123456"), "60", "50010.0", 2),
2749            make_fill("KRAKEN-OTHER", Some("O-123456"), "999", "1.0", 3),
2750        ];
2751
2752        let report = synthesize_filled_order_status_report(
2753            &Cache::default(),
2754            &QueriedOrder::new(&cmd, Some(&order)),
2755            &order,
2756            &fills,
2757        )
2758        .expect("expected a filled report");
2759
2760        assert_eq!(report.venue_order_id, VenueOrderId::from("KRAKEN-789"));
2761        assert_eq!(
2762            report.client_order_id,
2763            Some(ClientOrderId::from("O-123456"))
2764        );
2765        assert_eq!(report.order_status, OrderStatus::Filled);
2766        assert_eq!(report.order_type, OrderType::Market);
2767        assert_eq!(report.time_in_force, TimeInForce::Ioc);
2768        assert_eq!(report.quantity, Quantity::from("100"));
2769        assert_eq!(report.filled_qty, Quantity::from("100"));
2770        assert_eq!(
2771            report.avg_px,
2772            Some(Decimal::from_str_exact("50006.0").unwrap())
2773        );
2774    }
2775
2776    #[rstest]
2777    fn test_synthesize_filled_order_status_report_requires_full_fill_size() {
2778        let order = make_order("O-123457");
2779        let cmd = make_cmd(Some("O-123457"), Some("KRAKEN-790"));
2780        let fills = vec![make_fill(
2781            "KRAKEN-790",
2782            Some("O-123457"),
2783            "40",
2784            "50000.0",
2785            1,
2786        )];
2787
2788        assert!(
2789            synthesize_filled_order_status_report(
2790                &Cache::default(),
2791                &QueriedOrder::new(&cmd, Some(&order)),
2792                &order,
2793                &fills
2794            )
2795            .is_none()
2796        );
2797    }
2798
2799    /// Without a venue order ID the fills must carry the order's client order ID, in full or in the
2800    /// shortened form the venue holds an order sent with one under, be executed no earlier than the
2801    /// clock skew allowance before the order's creation, and belong to one venue order. Fills of
2802    /// two venue orders sharing the ID, or executed earlier than that, prove nothing about this
2803    /// one.
2804    #[rstest]
2805    #[case::full_id_on_one_venue_order(&[("KRAKEN-791", false)], 0, Some("KRAKEN-791"))]
2806    #[case::shortened_id_on_one_venue_order(&[("KRAKEN-791", true)], 0, Some("KRAKEN-791"))]
2807    #[case::full_id_on_two_venue_orders(&[("KRAKEN-791", false), ("KRAKEN-792", false)], 0, None)]
2808    #[case::executed_before_the_order(&[("KRAKEN-791", true)], 10_000_000_000, None)]
2809    fn test_synthesize_filled_order_status_report_without_a_venue_order_id(
2810        #[case] fills: &[(&str, bool)],
2811        #[case] created_ns: u64,
2812        #[case] expected_venue_order_id: Option<&str>,
2813    ) {
2814        let long_client_order_id = "O-20260227-002321-004-001-1";
2815        let shortened = truncate_cl_ord_id(&ClientOrderId::from(long_client_order_id));
2816        let order = OrderTestBuilder::new(OrderType::Market)
2817            .instrument_id(InstrumentId::from(TEST_INSTRUMENT_ID))
2818            .client_order_id(ClientOrderId::from(long_client_order_id))
2819            .side(OrderSide::Buy)
2820            .quantity(Quantity::from("100"))
2821            .time_in_force(TimeInForce::Ioc)
2822            .ts_init(UnixNanos::from(created_ns))
2823            .build();
2824        let cmd = make_cmd(Some(long_client_order_id), None);
2825        let quantity = format!("{}", 100 / fills.len());
2826        let fills: Vec<FillReport> = fills
2827            .iter()
2828            .enumerate()
2829            .map(|(i, (venue_order_id, shortened_echo))| {
2830                let echoed = if *shortened_echo {
2831                    shortened.as_str()
2832                } else {
2833                    long_client_order_id
2834                };
2835                make_fill(
2836                    venue_order_id,
2837                    Some(echoed),
2838                    &quantity,
2839                    "50000.0",
2840                    i as u64 + 1,
2841                )
2842            })
2843            .collect();
2844
2845        let report = synthesize_filled_order_status_report(
2846            &Cache::default(),
2847            &QueriedOrder::new(&cmd, Some(&order)),
2848            &order,
2849            &fills,
2850        );
2851
2852        assert_eq!(
2853            report.as_ref().map(|report| report.venue_order_id),
2854            expected_venue_order_id.map(VenueOrderId::from)
2855        );
2856
2857        if let Some(report) = report {
2858            assert_eq!(
2859                report.client_order_id,
2860                Some(ClientOrderId::from(long_client_order_id))
2861            );
2862            assert_eq!(report.order_status, OrderStatus::Filled);
2863        }
2864    }
2865}