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