Skip to main content

nautilus_bitmex/broadcast/
submitter.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//! Submit request broadcaster for redundant order submission.
17//!
18//! This module provides the [`SubmitBroadcaster`] which fans out submit requests
19//! to multiple HTTP clients in parallel for redundancy. The broadcaster is triggered
20//! when the `SubmitOrder` command contains `params["broadcast_submit_tries"]`.
21//!
22//! Key design patterns:
23//!
24//! - **Dependency injection via traits**: Uses `SubmitExecutor` trait to abstract
25//!   the HTTP client, enabling testing without `#[cfg(test)]` conditional compilation.
26//! - **Trait objects over generics**: Uses `Arc<dyn SubmitExecutor>` to avoid
27//!   generic type parameters on the public API (simpler Python FFI).
28//! - **Short-circuit on first success**: Aborts remaining requests once any client
29//!   succeeds, minimizing latency.
30//! - **Idempotent rejection handling**: Recognizes duplicate clOrdID as expected
31//!   rejections for debug-level logging without noise.
32
33// TODO: Replace boxed futures in `SubmitExecutor` once stable async trait object support
34// lands so we can drop the per-call heap allocation
35
36use std::{
37    fmt::Debug,
38    future::Future,
39    pin::Pin,
40    sync::{
41        Arc,
42        atomic::{AtomicBool, AtomicU64, Ordering},
43    },
44    time::Duration,
45};
46
47use futures_util::future;
48use nautilus_common::live::get_runtime;
49use nautilus_core::string::secret::SecretString;
50use nautilus_model::{
51    enums::{ContingencyType, OrderSide, OrderType, TimeInForce, TrailingOffsetType, TriggerType},
52    identifiers::{ClientOrderId, InstrumentId, OrderListId},
53    instruments::InstrumentAny,
54    reports::OrderStatusReport,
55    types::{Price, Quantity},
56};
57use tokio::{sync::RwLock, task::JoinHandle, time::interval};
58
59use crate::{
60    common::{
61        consts::BITMEX_HTTP_TESTNET_URL,
62        enums::{BitmexEnvironment, BitmexPegPriceType},
63    },
64    config::validate_broadcaster_pool_size,
65    http::{client::BitmexHttpClient, error::BitmexHttpError},
66};
67
68pub(crate) const DEFINITIVE_SUBMIT_REJECTION: &str = "DEFINITIVE_SUBMIT_REJECTION";
69
70/// Trait for order submission operations.
71///
72/// This trait abstracts the execution layer to enable dependency injection and testing
73/// without conditional compilation. The broadcaster holds executors as `Arc<dyn SubmitExecutor>`
74/// to avoid generic type parameters that would complicate the Python FFI boundary.
75///
76/// # Thread Safety
77///
78/// All methods must be safe to call concurrently from multiple threads. Implementations
79/// should use interior mutability (e.g., `Arc<Mutex<T>>`) if mutable state is required.
80///
81/// # Error Handling
82///
83/// Methods return `anyhow::Result` for flexibility. Implementers should provide
84/// meaningful error messages that can be logged and tracked by the broadcaster.
85///
86/// # Implementation Note
87///
88/// This trait does not require `Clone` because executors are wrapped in `Arc` at the
89/// `TransportClient` level. This allows `BitmexHttpClient` (which doesn't implement
90/// `Clone`) to be used without modification.
91trait SubmitExecutor: Send + Sync {
92    /// Adds an instrument for caching.
93    fn add_instrument(&self, instrument: InstrumentAny);
94
95    /// Performs a health check on the executor.
96    fn health_check(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>>;
97
98    /// Submits a single order.
99    #[expect(clippy::too_many_arguments)]
100    fn submit_order(
101        &self,
102        instrument_id: InstrumentId,
103        client_order_id: ClientOrderId,
104        order_side: OrderSide,
105        order_type: OrderType,
106        quantity: Quantity,
107        time_in_force: TimeInForce,
108        price: Option<Price>,
109        trigger_price: Option<Price>,
110        trigger_type: Option<TriggerType>,
111        trailing_offset: Option<f64>,
112        trailing_offset_type: Option<TrailingOffsetType>,
113        display_qty: Option<Quantity>,
114        post_only: bool,
115        reduce_only: bool,
116        order_list_id: Option<OrderListId>,
117        contingency_type: Option<ContingencyType>,
118        peg_price_type: Option<BitmexPegPriceType>,
119        peg_offset_value: Option<f64>,
120    ) -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send + '_>>;
121}
122
123impl SubmitExecutor for BitmexHttpClient {
124    fn add_instrument(&self, instrument: InstrumentAny) {
125        Self::cache_instrument(self, instrument);
126    }
127
128    fn health_check(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>> {
129        Box::pin(async move {
130            Self::get_server_time(self)
131                .await
132                .map(|_| ())
133                .map_err(|e| anyhow::anyhow!("{e}"))
134        })
135    }
136
137    fn submit_order(
138        &self,
139        instrument_id: InstrumentId,
140        client_order_id: ClientOrderId,
141        order_side: OrderSide,
142        order_type: OrderType,
143        quantity: Quantity,
144        time_in_force: TimeInForce,
145        price: Option<Price>,
146        trigger_price: Option<Price>,
147        trigger_type: Option<TriggerType>,
148        trailing_offset: Option<f64>,
149        trailing_offset_type: Option<TrailingOffsetType>,
150        display_qty: Option<Quantity>,
151        post_only: bool,
152        reduce_only: bool,
153        order_list_id: Option<OrderListId>,
154        contingency_type: Option<ContingencyType>,
155        peg_price_type: Option<BitmexPegPriceType>,
156        peg_offset_value: Option<f64>,
157    ) -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send + '_>> {
158        Box::pin(async move {
159            Self::submit_order(
160                self,
161                instrument_id,
162                client_order_id,
163                order_side,
164                order_type,
165                quantity,
166                time_in_force,
167                price,
168                trigger_price,
169                trigger_type,
170                trailing_offset,
171                trailing_offset_type,
172                display_qty,
173                post_only,
174                reduce_only,
175                order_list_id,
176                contingency_type,
177                peg_price_type,
178                peg_offset_value,
179            )
180            .await
181        })
182    }
183}
184
185/// Configuration for the submit broadcaster.
186#[derive(Debug, Clone)]
187pub struct SubmitBroadcasterConfig {
188    /// Number of HTTP clients in the pool (range `[1, 16]`).
189    pub pool_size: usize,
190    /// BitMEX API key (None will source from environment).
191    pub api_key: Option<SecretString>,
192    /// BitMEX API secret (None will source from environment).
193    pub api_secret: Option<SecretString>,
194    /// Base URL for BitMEX HTTP API.
195    pub base_url: Option<String>,
196    /// BitMEX environment (mainnet or testnet).
197    pub environment: BitmexEnvironment,
198    /// Timeout in seconds for HTTP requests.
199    pub timeout_secs: u64,
200    /// Maximum number of retry attempts for failed requests.
201    pub max_retries: u32,
202    /// Initial delay in milliseconds between retry attempts.
203    pub retry_delay_ms: u64,
204    /// Maximum delay in milliseconds between retry attempts.
205    pub retry_delay_max_ms: u64,
206    /// Expiration window in milliseconds for signed requests.
207    pub recv_window_ms: u64,
208    /// Maximum REST burst rate (requests per second).
209    pub max_requests_per_second: u32,
210    /// Maximum REST rolling rate (requests per minute).
211    pub max_requests_per_minute: u32,
212    /// Interval in seconds between health check pings.
213    pub health_check_interval_secs: u64,
214    /// Timeout in seconds for health check requests.
215    pub health_check_timeout_secs: u64,
216    /// Substrings to identify expected submit rejections for debug-level logging.
217    pub expected_reject_patterns: Vec<String>,
218    /// Optional list of proxy URLs for path diversity.
219    ///
220    /// Each transport instance uses the proxy at its index. If the list is shorter
221    /// than pool_size, remaining transports will use no proxy. If longer, extra proxies
222    /// are ignored.
223    pub proxy_urls: Vec<Option<SecretString>>,
224}
225
226impl Default for SubmitBroadcasterConfig {
227    fn default() -> Self {
228        Self {
229            pool_size: 3,
230            api_key: None,
231            api_secret: None,
232            base_url: None,
233            environment: BitmexEnvironment::Mainnet,
234            timeout_secs: 60,
235            max_retries: 3,
236            retry_delay_ms: 1_000,
237            retry_delay_max_ms: 5_000,
238            recv_window_ms: 10_000,
239            max_requests_per_second: 10,
240            max_requests_per_minute: 120,
241            health_check_interval_secs: 30,
242            health_check_timeout_secs: 5,
243            expected_reject_patterns: vec!["Duplicate clOrdID".to_string()],
244            proxy_urls: vec![],
245        }
246    }
247}
248
249/// Transport client wrapper with health monitoring.
250#[derive(Clone)]
251struct TransportClient {
252    /// Executor wrapped in Arc to enable cloning without requiring Clone on SubmitExecutor.
253    ///
254    /// BitmexHttpClient doesn't implement Clone, so we use reference counting to share
255    /// the executor across multiple TransportClient clones.
256    executor: Arc<dyn SubmitExecutor>,
257    client_id: String,
258    healthy: Arc<AtomicBool>,
259    submit_count: Arc<AtomicU64>,
260    error_count: Arc<AtomicU64>,
261}
262
263impl Debug for TransportClient {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct(stringify!(TransportClient))
266            .field("client_id", &self.client_id)
267            .field("healthy", &self.healthy)
268            .field("submit_count", &self.submit_count)
269            .field("error_count", &self.error_count)
270            .finish()
271    }
272}
273
274impl TransportClient {
275    fn new<E: SubmitExecutor + 'static>(executor: E, client_id: String) -> Self {
276        Self {
277            executor: Arc::new(executor),
278            client_id,
279            healthy: Arc::new(AtomicBool::new(true)),
280            submit_count: Arc::new(AtomicU64::new(0)),
281            error_count: Arc::new(AtomicU64::new(0)),
282        }
283    }
284
285    fn is_healthy(&self) -> bool {
286        self.healthy.load(Ordering::Relaxed)
287    }
288
289    fn mark_healthy(&self) {
290        self.healthy.store(true, Ordering::Relaxed);
291    }
292
293    fn mark_unhealthy(&self) {
294        self.healthy.store(false, Ordering::Relaxed);
295    }
296
297    fn get_submit_count(&self) -> u64 {
298        self.submit_count.load(Ordering::Relaxed)
299    }
300
301    fn get_error_count(&self) -> u64 {
302        self.error_count.load(Ordering::Relaxed)
303    }
304
305    async fn health_check(&self, timeout_secs: u64) -> bool {
306        match tokio::time::timeout(
307            Duration::from_secs(timeout_secs),
308            self.executor.health_check(),
309        )
310        .await
311        {
312            Ok(Ok(())) => {
313                self.mark_healthy();
314                true
315            }
316            Ok(Err(e)) => {
317                log::warn!("Health check failed for client {}: {e:?}", self.client_id);
318                self.mark_unhealthy();
319                false
320            }
321            Err(_) => {
322                log::warn!("Health check timeout for client {}", self.client_id);
323                self.mark_unhealthy();
324                false
325            }
326        }
327    }
328
329    #[expect(clippy::too_many_arguments)]
330    async fn submit_order(
331        &self,
332        instrument_id: InstrumentId,
333        client_order_id: ClientOrderId,
334        order_side: OrderSide,
335        order_type: OrderType,
336        quantity: Quantity,
337        time_in_force: TimeInForce,
338        price: Option<Price>,
339        trigger_price: Option<Price>,
340        trigger_type: Option<TriggerType>,
341        trailing_offset: Option<f64>,
342        trailing_offset_type: Option<TrailingOffsetType>,
343        display_qty: Option<Quantity>,
344        post_only: bool,
345        reduce_only: bool,
346        order_list_id: Option<OrderListId>,
347        contingency_type: Option<ContingencyType>,
348        peg_price_type: Option<BitmexPegPriceType>,
349        peg_offset_value: Option<f64>,
350    ) -> anyhow::Result<OrderStatusReport> {
351        self.submit_count.fetch_add(1, Ordering::Relaxed);
352
353        match self
354            .executor
355            .submit_order(
356                instrument_id,
357                client_order_id,
358                order_side,
359                order_type,
360                quantity,
361                time_in_force,
362                price,
363                trigger_price,
364                trigger_type,
365                trailing_offset,
366                trailing_offset_type,
367                display_qty,
368                post_only,
369                reduce_only,
370                order_list_id,
371                contingency_type,
372                peg_price_type,
373                peg_offset_value,
374            )
375            .await
376        {
377            Ok(report) => {
378                self.mark_healthy();
379                Ok(report)
380            }
381            Err(e) => {
382                self.error_count.fetch_add(1, Ordering::Relaxed);
383                Err(e)
384            }
385        }
386    }
387}
388
389/// Broadcasts submit requests to multiple HTTP clients for redundancy.
390///
391/// This broadcaster fans out submit requests to multiple pre-warmed HTTP clients
392/// in parallel, short-circuits when the first successful acknowledgement is received,
393/// and handles expected rejection patterns (duplicate clOrdID) with appropriate log levels.
394///
395/// The client pool must contain `[1, 16]` clients.
396#[cfg_attr(feature = "python", pyo3::pyclass)]
397#[cfg_attr(
398    feature = "python",
399    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
400)]
401#[derive(Debug)]
402pub struct SubmitBroadcaster {
403    config: SubmitBroadcasterConfig,
404    transports: Arc<[TransportClient]>,
405    health_check_task: Arc<RwLock<Option<JoinHandle<()>>>>,
406    running: Arc<AtomicBool>,
407    total_submits: Arc<AtomicU64>,
408    successful_submits: Arc<AtomicU64>,
409    failed_submits: Arc<AtomicU64>,
410    expected_rejects: Arc<AtomicU64>,
411}
412
413impl SubmitBroadcaster {
414    /// Creates a new [`SubmitBroadcaster`] with internal HTTP client pool.
415    ///
416    /// # Errors
417    ///
418    /// Returns an error if `pool_size` is outside `[1, 16]` or any HTTP client fails to initialize.
419    pub fn new(config: SubmitBroadcasterConfig) -> anyhow::Result<Self> {
420        validate_broadcaster_pool_size(config.pool_size, "pool_size")?;
421        let mut transports = Vec::with_capacity(config.pool_size);
422
423        let base_url = match config.environment {
424            BitmexEnvironment::Testnet if config.base_url.is_none() => {
425                Some(BITMEX_HTTP_TESTNET_URL.to_string())
426            }
427            _ => config.base_url.clone(),
428        };
429
430        for i in 0..config.pool_size {
431            // Assign proxy from config list, or None if index exceeds list length
432            let proxy_url = config
433                .proxy_urls
434                .get(i)
435                .and_then(|value| value.as_ref())
436                .map(|value| value.expose_secret().to_owned());
437
438            let client = BitmexHttpClient::with_credentials(
439                config.api_key.clone().map(SecretString::into_inner),
440                config.api_secret.clone().map(SecretString::into_inner),
441                base_url.clone(),
442                config.timeout_secs,
443                config.max_retries,
444                config.retry_delay_ms,
445                config.retry_delay_max_ms,
446                config.recv_window_ms,
447                config.max_requests_per_second,
448                config.max_requests_per_minute,
449                proxy_url,
450            )
451            .map_err(|e| anyhow::anyhow!("Failed to create HTTP client {i}: {e}"))?;
452
453            transports.push(TransportClient::new(client, format!("bitmex-submit-{i}")));
454        }
455
456        Ok(Self {
457            config,
458            transports: Arc::from(transports),
459            health_check_task: Arc::new(RwLock::new(None)),
460            running: Arc::new(AtomicBool::new(false)),
461            total_submits: Arc::new(AtomicU64::new(0)),
462            successful_submits: Arc::new(AtomicU64::new(0)),
463            failed_submits: Arc::new(AtomicU64::new(0)),
464            expected_rejects: Arc::new(AtomicU64::new(0)),
465        })
466    }
467
468    /// Starts the broadcaster and health check loop.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if the broadcaster is already running.
473    pub async fn start(&self) -> anyhow::Result<()> {
474        if self.running.load(Ordering::Relaxed) {
475            return Ok(());
476        }
477
478        self.running.store(true, Ordering::Relaxed);
479
480        // Initial health check for all clients
481        self.run_health_checks().await;
482
483        // Start periodic health check task
484        let transports = Arc::clone(&self.transports);
485        let running = Arc::clone(&self.running);
486        let interval_secs = self.config.health_check_interval_secs;
487        let timeout_secs = self.config.health_check_timeout_secs;
488
489        let task = get_runtime().spawn(async move {
490            let mut ticker = interval(Duration::from_secs(interval_secs));
491            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
492
493            loop {
494                ticker.tick().await;
495
496                if !running.load(Ordering::Relaxed) {
497                    break;
498                }
499
500                let tasks: Vec<_> = transports
501                    .iter()
502                    .map(|t| t.health_check(timeout_secs))
503                    .collect();
504
505                let results = future::join_all(tasks).await;
506                let healthy_count = results.iter().filter(|&&r| r).count();
507
508                log::debug!(
509                    "Health check complete: {healthy_count}/{} clients healthy",
510                    results.len()
511                );
512            }
513        });
514
515        *self.health_check_task.write().await = Some(task);
516
517        log::debug!(
518            "SubmitBroadcaster started with {} clients",
519            self.transports.len()
520        );
521
522        Ok(())
523    }
524
525    /// Stops the broadcaster and health check loop.
526    pub async fn stop(&self) {
527        if !self.running.load(Ordering::Relaxed) {
528            return;
529        }
530
531        self.running.store(false, Ordering::Relaxed);
532
533        if let Some(task) = self.health_check_task.write().await.take() {
534            task.abort();
535        }
536
537        log::debug!("SubmitBroadcaster stopped");
538    }
539
540    async fn run_health_checks(&self) {
541        let tasks: Vec<_> = self
542            .transports
543            .iter()
544            .map(|t| t.health_check(self.config.health_check_timeout_secs))
545            .collect();
546
547        let results = future::join_all(tasks).await;
548        let healthy_count = results.iter().filter(|&&r| r).count();
549
550        log::debug!(
551            "Health check complete: {healthy_count}/{} clients healthy",
552            results.len()
553        );
554    }
555
556    fn is_expected_reject(&self, error_message: &str) -> bool {
557        self.config
558            .expected_reject_patterns
559            .iter()
560            .any(|pattern| error_message.contains(pattern))
561    }
562
563    /// Processes submit request results, handling success and failures.
564    ///
565    /// This method consolidates the error handling loop used for submit broadcasts.
566    async fn process_submit_results<T>(
567        &self,
568        mut handles: Vec<JoinHandle<(String, anyhow::Result<T>)>>,
569        operation: &str,
570        params: String,
571    ) -> anyhow::Result<T>
572    where
573        T: Send + 'static,
574    {
575        let mut errors = Vec::new();
576        let mut all_duplicate_clordid = true;
577        let mut all_definitive_refusals = true;
578
579        while !handles.is_empty() {
580            let current_handles = std::mem::take(&mut handles);
581            let (result, _idx, remaining) = future::select_all(current_handles).await;
582            handles = remaining.into_iter().collect();
583
584            match result {
585                Ok((client_id, Ok(result))) => {
586                    // First success - abort remaining handles
587                    for handle in &handles {
588                        handle.abort();
589                    }
590                    self.successful_submits.fetch_add(1, Ordering::Relaxed);
591                    log::debug!("{operation} broadcast succeeded [{client_id}] {params}",);
592                    return Ok(result);
593                }
594                Ok((client_id, Err(e))) => {
595                    let error_msg = e.to_string();
596                    let is_duplicate = error_msg.contains("Duplicate clOrdID");
597                    let is_definitive_refusal = is_definitive_submit_refusal(&e);
598
599                    if !is_duplicate {
600                        all_duplicate_clordid = false;
601                    }
602
603                    if !is_definitive_refusal {
604                        all_definitive_refusals = false;
605                    }
606
607                    if self.is_expected_reject(&error_msg) {
608                        self.expected_rejects.fetch_add(1, Ordering::Relaxed);
609                        log::debug!(
610                            "Expected {} rejection [{client_id}]: {error_msg} {params}",
611                            operation.to_lowercase(),
612                        );
613                        errors.push(error_msg);
614                    } else {
615                        log::warn!(
616                            "{operation} request failed [{client_id}]: {error_msg} {params}",
617                        );
618                        errors.push(error_msg);
619                    }
620                }
621                Err(e) => {
622                    all_duplicate_clordid = false;
623                    all_definitive_refusals = false;
624                    log::warn!("{operation} task join error: {e:?}");
625                    errors.push(format!("Task panicked: {e:?}"));
626                }
627            }
628        }
629
630        // All tasks failed
631        self.failed_submits.fetch_add(1, Ordering::Relaxed);
632
633        // If all errors were "Duplicate clOrdID", this is likely an idempotent scenario
634        // where the order exists but the success response was lost
635        if all_duplicate_clordid && !errors.is_empty() {
636            log::warn!(
637                "All {} requests returned 'Duplicate clOrdID' - order likely exists {params}",
638                operation.to_lowercase(),
639            );
640            anyhow::bail!("IDEMPOTENT_DUPLICATE: Order likely exists but confirmation was lost");
641        }
642
643        if all_definitive_refusals && !errors.is_empty() {
644            log::error!(
645                "All {} requests were refused by BitMEX: {errors:?} {params}",
646                operation.to_lowercase(),
647            );
648            anyhow::bail!(
649                "{DEFINITIVE_SUBMIT_REJECTION}: All {} requests were refused by BitMEX: {errors:?}",
650                operation.to_lowercase(),
651            );
652        }
653
654        log::error!(
655            "All {} requests failed: {errors:?} {params}",
656            operation.to_lowercase(),
657        );
658        Err(anyhow::anyhow!(
659            "All {} requests failed: {:?}",
660            operation.to_lowercase(),
661            errors
662        ))
663    }
664
665    /// Broadcasts a submit request to all healthy clients in parallel.
666    ///
667    /// # Returns
668    ///
669    /// - `Ok(report)` if successfully submitted with a report.
670    /// - `Err` if all requests failed.
671    ///
672    /// # Errors
673    ///
674    /// Returns an error if all submit requests fail or no healthy clients are available.
675    #[expect(clippy::too_many_arguments)]
676    pub async fn broadcast_submit(
677        &self,
678        instrument_id: InstrumentId,
679        client_order_id: ClientOrderId,
680        order_side: OrderSide,
681        order_type: OrderType,
682        quantity: Quantity,
683        time_in_force: TimeInForce,
684        price: Option<Price>,
685        trigger_price: Option<Price>,
686        trigger_type: Option<TriggerType>,
687        trailing_offset: Option<f64>,
688        trailing_offset_type: Option<TrailingOffsetType>,
689        display_qty: Option<Quantity>,
690        post_only: bool,
691        reduce_only: bool,
692        order_list_id: Option<OrderListId>,
693        contingency_type: Option<ContingencyType>,
694        submit_tries: Option<usize>,
695        peg_price_type: Option<BitmexPegPriceType>,
696        peg_offset_value: Option<f64>,
697    ) -> anyhow::Result<OrderStatusReport> {
698        self.total_submits.fetch_add(1, Ordering::Relaxed);
699
700        let pool_size = self.config.pool_size;
701        let actual_tries = if let Some(t) = submit_tries {
702            if t > pool_size {
703                // Use log macro for Python visibility for now
704                log::warn!("submit_tries={t} exceeds pool_size={pool_size}, capping at pool_size");
705            }
706            std::cmp::min(t, pool_size)
707        } else {
708            pool_size
709        };
710
711        log::debug!(
712            "Submit broadcast requested for client_order_id={client_order_id} (tries={actual_tries}/{pool_size})",
713        );
714
715        let healthy_transports: Vec<TransportClient> = self
716            .transports
717            .iter()
718            .filter(|t| t.is_healthy())
719            .take(actual_tries)
720            .cloned()
721            .collect();
722
723        if healthy_transports.is_empty() {
724            self.failed_submits.fetch_add(1, Ordering::Relaxed);
725            anyhow::bail!("No healthy transport clients available");
726        }
727
728        log::debug!(
729            "Broadcasting submit to {} clients: client_order_id={client_order_id}, instrument_id={instrument_id}",
730            healthy_transports.len(),
731        );
732
733        let mut handles = Vec::new();
734
735        for transport in healthy_transports {
736            // All transports use the same client_order_id. If multiple succeed,
737            // BitMEX rejects duplicates with "duplicate clOrdID" (expected rejection).
738            let handle = get_runtime().spawn(async move {
739                let client_id = transport.client_id.clone();
740                let result = transport
741                    .submit_order(
742                        instrument_id,
743                        client_order_id,
744                        order_side,
745                        order_type,
746                        quantity,
747                        time_in_force,
748                        price,
749                        trigger_price,
750                        trigger_type,
751                        trailing_offset,
752                        trailing_offset_type,
753                        display_qty,
754                        post_only,
755                        reduce_only,
756                        order_list_id,
757                        contingency_type,
758                        peg_price_type,
759                        peg_offset_value,
760                    )
761                    .await;
762                (client_id, result)
763            });
764            handles.push(handle);
765        }
766
767        self.process_submit_results(
768            handles,
769            "Submit",
770            format!("(client_order_id={client_order_id:?})"),
771        )
772        .await
773    }
774
775    /// Gets broadcaster metrics.
776    pub fn get_metrics(&self) -> BroadcasterMetrics {
777        let healthy_clients = self.transports.iter().filter(|t| t.is_healthy()).count();
778        let total_clients = self.transports.len();
779
780        BroadcasterMetrics {
781            total_submits: self.total_submits.load(Ordering::Relaxed),
782            successful_submits: self.successful_submits.load(Ordering::Relaxed),
783            failed_submits: self.failed_submits.load(Ordering::Relaxed),
784            expected_rejects: self.expected_rejects.load(Ordering::Relaxed),
785            healthy_clients,
786            total_clients,
787        }
788    }
789
790    /// Gets broadcaster metrics (async version for use within async context).
791    pub async fn get_metrics_async(&self) -> BroadcasterMetrics {
792        self.get_metrics()
793    }
794
795    /// Gets per-client statistics.
796    pub fn get_client_stats(&self) -> Vec<ClientStats> {
797        self.transports
798            .iter()
799            .map(|t| ClientStats {
800                client_id: t.client_id.clone(),
801                healthy: t.is_healthy(),
802                submit_count: t.get_submit_count(),
803                error_count: t.get_error_count(),
804            })
805            .collect()
806    }
807
808    /// Gets per-client statistics (async version for use within async context).
809    pub async fn get_client_stats_async(&self) -> Vec<ClientStats> {
810        self.get_client_stats()
811    }
812
813    /// Caches an instrument in all HTTP clients in the pool.
814    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
815        for transport in self.transports.iter() {
816            transport.executor.add_instrument(instrument.clone());
817        }
818    }
819
820    #[must_use]
821    pub fn clone_for_async(&self) -> Self {
822        Self {
823            config: self.config.clone(),
824            transports: Arc::clone(&self.transports),
825            health_check_task: Arc::clone(&self.health_check_task),
826            running: Arc::clone(&self.running),
827            total_submits: Arc::clone(&self.total_submits),
828            successful_submits: Arc::clone(&self.successful_submits),
829            failed_submits: Arc::clone(&self.failed_submits),
830            expected_rejects: Arc::clone(&self.expected_rejects),
831        }
832    }
833
834    #[cfg(test)]
835    fn new_with_transports(
836        config: SubmitBroadcasterConfig,
837        transports: Vec<TransportClient>,
838    ) -> Self {
839        Self {
840            config,
841            transports: Arc::from(transports),
842            health_check_task: Arc::new(RwLock::new(None)),
843            running: Arc::new(AtomicBool::new(false)),
844            total_submits: Arc::new(AtomicU64::new(0)),
845            successful_submits: Arc::new(AtomicU64::new(0)),
846            failed_submits: Arc::new(AtomicU64::new(0)),
847            expected_rejects: Arc::new(AtomicU64::new(0)),
848        }
849    }
850}
851
852fn is_definitive_submit_refusal(err: &anyhow::Error) -> bool {
853    if err.chain().any(|cause| {
854        cause
855            .downcast_ref::<BitmexHttpError>()
856            .is_some_and(|e| matches!(e, BitmexHttpError::BitmexError { .. }))
857    }) {
858        return true;
859    }
860
861    err.to_string().starts_with("Order rejected:")
862}
863
864/// Broadcaster metrics snapshot.
865#[derive(Debug, Clone)]
866pub struct BroadcasterMetrics {
867    pub total_submits: u64,
868    pub successful_submits: u64,
869    pub failed_submits: u64,
870    pub expected_rejects: u64,
871    pub healthy_clients: usize,
872    pub total_clients: usize,
873}
874
875/// Per-client statistics.
876#[derive(Debug, Clone)]
877pub struct ClientStats {
878    pub client_id: String,
879    pub healthy: bool,
880    pub submit_count: u64,
881    pub error_count: u64,
882}
883
884#[cfg(test)]
885mod tests {
886    use std::{str::FromStr, sync::atomic::Ordering, time::Duration};
887
888    use nautilus_core::UUID4;
889    use nautilus_model::{
890        enums::{
891            ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType,
892        },
893        identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
894        reports::OrderStatusReport,
895        types::{Price, Quantity},
896    };
897    use rstest::rstest;
898
899    use super::*;
900
901    #[rstest]
902    fn test_config_debug_redacts_credentials() {
903        let config = SubmitBroadcasterConfig {
904            api_key: Some("submit-key-sentinel".into()),
905            api_secret: Some("submit-secret-sentinel".into()),
906            proxy_urls: vec![Some("http://submit-user:submit-password@localhost".into())],
907            ..Default::default()
908        };
909
910        let debug = format!("{config:?}");
911
912        assert!(!debug.contains("submit-key-sentinel"));
913        assert!(!debug.contains("submit-secret-sentinel"));
914        assert!(!debug.contains("submit-password"));
915    }
916
917    /// Mock executor for testing.
918    #[derive(Clone)]
919    #[expect(clippy::type_complexity)]
920    struct MockExecutor {
921        handler: Arc<
922            dyn Fn() -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send>>
923                + Send
924                + Sync,
925        >,
926    }
927
928    impl MockExecutor {
929        fn new<F, Fut>(handler: F) -> Self
930        where
931            F: Fn() -> Fut + Send + Sync + 'static,
932            Fut: Future<Output = anyhow::Result<OrderStatusReport>> + Send + 'static,
933        {
934            Self {
935                handler: Arc::new(move || Box::pin(handler())),
936            }
937        }
938    }
939
940    impl SubmitExecutor for MockExecutor {
941        fn health_check(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>> {
942            Box::pin(async { Ok(()) })
943        }
944
945        fn submit_order(
946            &self,
947            _instrument_id: InstrumentId,
948            _client_order_id: ClientOrderId,
949            _order_side: OrderSide,
950            _order_type: OrderType,
951            _quantity: Quantity,
952            _time_in_force: TimeInForce,
953            _price: Option<Price>,
954            _trigger_price: Option<Price>,
955            _trigger_type: Option<TriggerType>,
956            _trailing_offset: Option<f64>,
957            _trailing_offset_type: Option<TrailingOffsetType>,
958            _display_qty: Option<Quantity>,
959            _post_only: bool,
960            _reduce_only: bool,
961            _order_list_id: Option<OrderListId>,
962            _contingency_type: Option<ContingencyType>,
963            _peg_price_type: Option<BitmexPegPriceType>,
964            _peg_offset_value: Option<f64>,
965        ) -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send + '_>> {
966            (self.handler)()
967        }
968
969        fn add_instrument(&self, _instrument: InstrumentAny) {
970            // No-op for mock
971        }
972    }
973
974    fn create_test_report(venue_order_id: &str) -> OrderStatusReport {
975        OrderStatusReport {
976            account_id: AccountId::from("BITMEX-001"),
977            instrument_id: InstrumentId::from_str("XBTUSD.BITMEX").unwrap(),
978            venue_order_id: VenueOrderId::from(venue_order_id),
979            order_side: OrderSide::Buy.into(),
980            order_type: OrderType::Limit,
981            time_in_force: TimeInForce::Gtc,
982            order_status: OrderStatus::Accepted,
983            price: Some(Price::new(50000.0, 2)),
984            quantity: Quantity::new(100.0, 0),
985            filled_qty: Quantity::new(0.0, 0),
986            report_id: UUID4::new(),
987            ts_accepted: 0.into(),
988            ts_last: 0.into(),
989            ts_init: 0.into(),
990            client_order_id: None,
991            avg_px: None,
992            activation_price: None,
993            trigger_price: None,
994            trigger_type: None,
995            contingency_type: None,
996            expire_time: None,
997            order_list_id: None,
998            venue_position_id: None,
999            linked_order_ids: None,
1000            parent_order_id: None,
1001            display_qty: None,
1002            limit_offset: None,
1003            trailing_offset: None,
1004            trailing_offset_type: None,
1005            post_only: false,
1006            reduce_only: false,
1007            cancel_reason: None,
1008            ts_triggered: None,
1009        }
1010    }
1011
1012    fn create_stub_transport<F, Fut>(client_id: &str, handler: F) -> TransportClient
1013    where
1014        F: Fn() -> Fut + Send + Sync + 'static,
1015        Fut: Future<Output = anyhow::Result<OrderStatusReport>> + Send + 'static,
1016    {
1017        let executor = MockExecutor::new(handler);
1018        TransportClient::new(executor, client_id.to_string())
1019    }
1020
1021    #[tokio::test]
1022    async fn test_broadcast_submit_immediate_success() {
1023        let report = create_test_report("ORDER-1");
1024        let report_clone = report.clone();
1025
1026        let transports = vec![
1027            create_stub_transport("client-0", move || {
1028                let report = report_clone.clone();
1029                async move { Ok(report) }
1030            }),
1031            create_stub_transport("client-1", || async {
1032                tokio::time::sleep(Duration::from_secs(10)).await;
1033                anyhow::bail!("Should be aborted")
1034            }),
1035        ];
1036
1037        let config = SubmitBroadcasterConfig::default();
1038        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1039
1040        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1041        let result = broadcaster
1042            .broadcast_submit(
1043                instrument_id,
1044                ClientOrderId::from("O-123"),
1045                OrderSide::Buy,
1046                OrderType::Limit,
1047                Quantity::new(100.0, 0),
1048                TimeInForce::Gtc,
1049                Some(Price::new(50000.0, 2)),
1050                None,
1051                None,
1052                None,
1053                None,
1054                None,
1055                false,
1056                false,
1057                None,
1058                None,
1059                None,
1060                None,
1061                None,
1062            )
1063            .await;
1064
1065        assert!(result.is_ok());
1066        let returned_report = result.unwrap();
1067        assert_eq!(returned_report.venue_order_id, report.venue_order_id);
1068
1069        let metrics = broadcaster.get_metrics_async().await;
1070        assert_eq!(metrics.successful_submits, 1);
1071        assert_eq!(metrics.failed_submits, 0);
1072        assert_eq!(metrics.total_submits, 1);
1073    }
1074
1075    #[tokio::test]
1076    async fn test_broadcast_submit_duplicate_clordid_expected() {
1077        let transports = vec![
1078            create_stub_transport("client-0", || async { anyhow::bail!("Duplicate clOrdID") }),
1079            create_stub_transport("client-1", || async { anyhow::bail!("Connection timeout") }),
1080        ];
1081
1082        let config = SubmitBroadcasterConfig::default();
1083        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1084
1085        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1086        let result = broadcaster
1087            .broadcast_submit(
1088                instrument_id,
1089                ClientOrderId::from("O-123"),
1090                OrderSide::Buy,
1091                OrderType::Limit,
1092                Quantity::new(100.0, 0),
1093                TimeInForce::Gtc,
1094                Some(Price::new(50000.0, 2)),
1095                None,
1096                None,
1097                None,
1098                None,
1099                None,
1100                false,
1101                false,
1102                None,
1103                None,
1104                None,
1105                None,
1106                None,
1107            )
1108            .await;
1109
1110        assert!(result.is_err());
1111
1112        let metrics = broadcaster.get_metrics_async().await;
1113        assert_eq!(metrics.expected_rejects, 1);
1114        assert_eq!(metrics.successful_submits, 0);
1115        assert_eq!(metrics.failed_submits, 1);
1116    }
1117
1118    #[tokio::test]
1119    async fn test_broadcast_submit_all_failures() {
1120        let transports = vec![
1121            create_stub_transport("client-0", || async { anyhow::bail!("502 Bad Gateway") }),
1122            create_stub_transport("client-1", || async { anyhow::bail!("Connection refused") }),
1123        ];
1124
1125        let config = SubmitBroadcasterConfig::default();
1126        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1127
1128        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1129        let result = broadcaster
1130            .broadcast_submit(
1131                instrument_id,
1132                ClientOrderId::from("O-456"),
1133                OrderSide::Sell,
1134                OrderType::Market,
1135                Quantity::new(50.0, 0),
1136                TimeInForce::Ioc,
1137                None,
1138                None,
1139                None,
1140                None,
1141                None,
1142                None,
1143                false,
1144                false,
1145                None,
1146                None,
1147                None,
1148                None,
1149                None,
1150            )
1151            .await;
1152
1153        assert!(result.is_err());
1154        assert!(
1155            result
1156                .unwrap_err()
1157                .to_string()
1158                .contains("All submit requests failed")
1159        );
1160
1161        let metrics = broadcaster.get_metrics_async().await;
1162        assert_eq!(metrics.failed_submits, 1);
1163        assert_eq!(metrics.successful_submits, 0);
1164    }
1165
1166    #[tokio::test]
1167    async fn test_broadcast_submit_all_bitmex_refusals_preserves_definitive_outcome() {
1168        let transports = vec![
1169            create_stub_transport("client-0", || async {
1170                Err(anyhow::Error::new(BitmexHttpError::BitmexError {
1171                    error_name: "HTTPError".to_string(),
1172                    message: "Invalid price".to_string(),
1173                }))
1174            }),
1175            create_stub_transport("client-1", || async {
1176                Err(anyhow::Error::new(BitmexHttpError::BitmexError {
1177                    error_name: "HTTPError".to_string(),
1178                    message: "Invalid price".to_string(),
1179                }))
1180            }),
1181        ];
1182
1183        let config = SubmitBroadcasterConfig::default();
1184        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1185
1186        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1187        let result = broadcaster
1188            .broadcast_submit(
1189                instrument_id,
1190                ClientOrderId::from("O-REFUSED"),
1191                OrderSide::Sell,
1192                OrderType::Limit,
1193                Quantity::new(50.0, 0),
1194                TimeInForce::Gtc,
1195                Some(Price::new(50000.0, 2)),
1196                None,
1197                None,
1198                None,
1199                None,
1200                None,
1201                false,
1202                false,
1203                None,
1204                None,
1205                None,
1206                None,
1207                None,
1208            )
1209            .await;
1210
1211        let err = result.unwrap_err().to_string();
1212
1213        assert!(err.starts_with(DEFINITIVE_SUBMIT_REJECTION));
1214
1215        let metrics = broadcaster.get_metrics_async().await;
1216        assert_eq!(metrics.failed_submits, 1);
1217        assert_eq!(metrics.successful_submits, 0);
1218    }
1219
1220    #[tokio::test]
1221    async fn test_broadcast_submit_mixed_refusal_and_network_failure_stays_ambiguous() {
1222        let transports = vec![
1223            create_stub_transport("client-0", || async {
1224                Err(anyhow::Error::new(BitmexHttpError::BitmexError {
1225                    error_name: "HTTPError".to_string(),
1226                    message: "Invalid price".to_string(),
1227                }))
1228            }),
1229            create_stub_transport("client-1", || async { anyhow::bail!("Connection refused") }),
1230        ];
1231
1232        let config = SubmitBroadcasterConfig::default();
1233        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1234
1235        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1236        let result = broadcaster
1237            .broadcast_submit(
1238                instrument_id,
1239                ClientOrderId::from("O-MIXED-FAILURE"),
1240                OrderSide::Sell,
1241                OrderType::Limit,
1242                Quantity::from("50"),
1243                TimeInForce::Gtc,
1244                Some(Price::from("50000.00")),
1245                None,
1246                None,
1247                None,
1248                None,
1249                None,
1250                false,
1251                false,
1252                None,
1253                None,
1254                None,
1255                None,
1256                None,
1257            )
1258            .await;
1259
1260        let err = result.unwrap_err().to_string();
1261
1262        assert!(err.starts_with("All submit requests failed"));
1263        assert!(!err.starts_with(DEFINITIVE_SUBMIT_REJECTION));
1264
1265        let metrics = broadcaster.get_metrics_async().await;
1266        assert_eq!(metrics.failed_submits, 1);
1267        assert_eq!(metrics.successful_submits, 0);
1268    }
1269
1270    #[tokio::test]
1271    async fn test_broadcast_submit_no_healthy_clients() {
1272        let transport =
1273            create_stub_transport("client-0", || async { Ok(create_test_report("ORDER-1")) });
1274        transport.healthy.store(false, Ordering::Relaxed);
1275
1276        let config = SubmitBroadcasterConfig::default();
1277        let broadcaster = SubmitBroadcaster::new_with_transports(config, vec![transport]);
1278
1279        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1280        let result = broadcaster
1281            .broadcast_submit(
1282                instrument_id,
1283                ClientOrderId::from("O-789"),
1284                OrderSide::Buy,
1285                OrderType::Limit,
1286                Quantity::new(100.0, 0),
1287                TimeInForce::Gtc,
1288                Some(Price::new(50000.0, 2)),
1289                None,
1290                None,
1291                None,
1292                None,
1293                None,
1294                false,
1295                false,
1296                None,
1297                None,
1298                None,
1299                None,
1300                None,
1301            )
1302            .await;
1303
1304        assert!(result.is_err());
1305        assert!(
1306            result
1307                .unwrap_err()
1308                .to_string()
1309                .contains("No healthy transport clients available")
1310        );
1311
1312        let metrics = broadcaster.get_metrics_async().await;
1313        assert_eq!(metrics.failed_submits, 1);
1314    }
1315
1316    #[tokio::test]
1317    async fn test_default_config() {
1318        let report = create_test_report("ORDER-1");
1319        let transports: Vec<TransportClient> = (0..3)
1320            .map(|i| {
1321                let r = report.clone();
1322                create_stub_transport(&format!("client-{i}"), move || {
1323                    let r = r.clone();
1324                    async move { Ok(r) }
1325                })
1326            })
1327            .collect();
1328
1329        let config = SubmitBroadcasterConfig::default();
1330        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1331        let metrics = broadcaster.get_metrics_async().await;
1332
1333        assert_eq!(metrics.total_clients, 3);
1334    }
1335
1336    #[tokio::test]
1337    async fn test_broadcaster_lifecycle() {
1338        let report = create_test_report("ORDER-1");
1339        let transports: Vec<TransportClient> = (0..2)
1340            .map(|i| {
1341                let r = report.clone();
1342                create_stub_transport(&format!("client-{i}"), move || {
1343                    let r = r.clone();
1344                    async move { Ok(r) }
1345                })
1346            })
1347            .collect();
1348
1349        let config = SubmitBroadcasterConfig::default();
1350        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1351
1352        // Should not be running initially
1353        assert!(!broadcaster.running.load(Ordering::Relaxed));
1354
1355        // Start broadcaster
1356        let start_result = broadcaster.start().await;
1357        assert!(start_result.is_ok());
1358        assert!(broadcaster.running.load(Ordering::Relaxed));
1359
1360        // Starting again should be idempotent
1361        let start_again = broadcaster.start().await;
1362        assert!(start_again.is_ok());
1363
1364        // Stop broadcaster
1365        broadcaster.stop().await;
1366        assert!(!broadcaster.running.load(Ordering::Relaxed));
1367
1368        // Stopping again should be safe
1369        broadcaster.stop().await;
1370        assert!(!broadcaster.running.load(Ordering::Relaxed));
1371    }
1372
1373    #[tokio::test]
1374    async fn test_broadcast_submit_metrics_increment() {
1375        let report = create_test_report("ORDER-1");
1376        let report_clone = report.clone();
1377
1378        let transports = vec![create_stub_transport("client-0", move || {
1379            let report = report_clone.clone();
1380            async move { Ok(report) }
1381        })];
1382
1383        let config = SubmitBroadcasterConfig::default();
1384        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1385
1386        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1387        let _ = broadcaster
1388            .broadcast_submit(
1389                instrument_id,
1390                ClientOrderId::from("O-123"),
1391                OrderSide::Buy,
1392                OrderType::Limit,
1393                Quantity::new(100.0, 0),
1394                TimeInForce::Gtc,
1395                Some(Price::new(50000.0, 2)),
1396                None,
1397                None,
1398                None,
1399                None,
1400                None,
1401                false,
1402                false,
1403                None,
1404                None,
1405                None,
1406                None,
1407                None,
1408            )
1409            .await;
1410
1411        let metrics = broadcaster.get_metrics_async().await;
1412        assert_eq!(metrics.total_submits, 1);
1413        assert_eq!(metrics.successful_submits, 1);
1414        assert_eq!(metrics.failed_submits, 0);
1415    }
1416
1417    #[tokio::test]
1418    async fn test_broadcaster_creation_with_pool() {
1419        let report = create_test_report("ORDER-1");
1420        let transports: Vec<TransportClient> = (0..4)
1421            .map(|i| {
1422                let r = report.clone();
1423                create_stub_transport(&format!("client-{i}"), move || {
1424                    let r = r.clone();
1425                    async move { Ok(r) }
1426                })
1427            })
1428            .collect();
1429
1430        let config = SubmitBroadcasterConfig::default();
1431        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1432        let metrics = broadcaster.get_metrics_async().await;
1433        assert_eq!(metrics.total_clients, 4);
1434    }
1435
1436    #[tokio::test]
1437    async fn test_client_stats_collection() {
1438        // Both clients fail so broadcast waits for all of them (no early abort on success).
1439        // This ensures both clients execute and record their stats before the function returns.
1440        let transports = vec![
1441            create_stub_transport("client-0", || async { anyhow::bail!("Timeout error") }),
1442            create_stub_transport("client-1", || async { anyhow::bail!("Connection error") }),
1443        ];
1444
1445        let config = SubmitBroadcasterConfig::default();
1446        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1447
1448        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1449        let _ = broadcaster
1450            .broadcast_submit(
1451                instrument_id,
1452                ClientOrderId::from("O-123"),
1453                OrderSide::Buy,
1454                OrderType::Limit,
1455                Quantity::new(100.0, 0),
1456                TimeInForce::Gtc,
1457                Some(Price::new(50000.0, 2)),
1458                None,
1459                None,
1460                None,
1461                None,
1462                None,
1463                false,
1464                false,
1465                None,
1466                None,
1467                None,
1468                None,
1469                None,
1470            )
1471            .await;
1472
1473        let stats = broadcaster.get_client_stats_async().await;
1474        assert_eq!(stats.len(), 2);
1475
1476        let client0 = stats.iter().find(|s| s.client_id == "client-0").unwrap();
1477        assert_eq!(client0.submit_count, 1);
1478        assert_eq!(client0.error_count, 1);
1479
1480        let client1 = stats.iter().find(|s| s.client_id == "client-1").unwrap();
1481        assert_eq!(client1.submit_count, 1);
1482        assert_eq!(client1.error_count, 1);
1483    }
1484
1485    #[tokio::test]
1486    async fn test_testnet_config_sets_base_url() {
1487        let config = SubmitBroadcasterConfig {
1488            pool_size: 1,
1489            api_key: Some("test_key".into()),
1490            api_secret: Some("test_secret".into()),
1491            environment: BitmexEnvironment::Testnet,
1492            base_url: None,
1493            ..Default::default()
1494        };
1495
1496        let broadcaster = SubmitBroadcaster::new(config);
1497        assert!(broadcaster.is_ok());
1498    }
1499
1500    #[tokio::test]
1501    async fn test_constructor_honors_default_pool_size() {
1502        let config = SubmitBroadcasterConfig {
1503            api_key: Some("test_key".into()),
1504            api_secret: Some("test_secret".into()),
1505            base_url: Some("http://127.0.0.1:19999".to_string()),
1506            ..Default::default()
1507        };
1508
1509        let expected_pool = config.pool_size;
1510        let broadcaster = SubmitBroadcaster::new(config).unwrap();
1511        let metrics = broadcaster.get_metrics_async().await;
1512
1513        assert_eq!(metrics.total_clients, expected_pool);
1514    }
1515
1516    #[tokio::test]
1517    async fn test_constructor_accepts_maximum_pool_size() {
1518        let config = SubmitBroadcasterConfig {
1519            pool_size: crate::config::MAX_BROADCASTER_POOL_SIZE,
1520            api_key: Some("test_key".into()),
1521            api_secret: Some("test_secret".into()),
1522            base_url: Some("http://127.0.0.1:19999".to_string()),
1523            ..Default::default()
1524        };
1525
1526        let broadcaster = SubmitBroadcaster::new(config).unwrap();
1527        let metrics = broadcaster.get_metrics_async().await;
1528
1529        assert_eq!(
1530            metrics.total_clients,
1531            crate::config::MAX_BROADCASTER_POOL_SIZE
1532        );
1533    }
1534
1535    #[rstest]
1536    fn test_constructor_rejects_oversized_pool_before_allocation() {
1537        let config = SubmitBroadcasterConfig {
1538            pool_size: crate::config::MAX_BROADCASTER_POOL_SIZE + 1,
1539            ..Default::default()
1540        };
1541
1542        let result = SubmitBroadcaster::new(config);
1543        let err = result.expect_err("oversized pool must be rejected");
1544
1545        assert_eq!(
1546            err.to_string(),
1547            format!(
1548                "invalid usize for 'pool_size' not in range [1, {}], was {}",
1549                crate::config::MAX_BROADCASTER_POOL_SIZE,
1550                crate::config::MAX_BROADCASTER_POOL_SIZE + 1,
1551            ),
1552        );
1553    }
1554
1555    #[tokio::test]
1556    async fn test_clone_for_async() {
1557        let report = create_test_report("ORDER-1");
1558        let transports = vec![create_stub_transport("client-0", move || {
1559            let r = report.clone();
1560            async move { Ok(r) }
1561        })];
1562
1563        let config = SubmitBroadcasterConfig::default();
1564        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1565        let cloned = broadcaster.clone_for_async();
1566
1567        // Verify they share the same atomics
1568        broadcaster.total_submits.fetch_add(1, Ordering::Relaxed);
1569        assert_eq!(cloned.total_submits.load(Ordering::Relaxed), 1);
1570    }
1571
1572    #[tokio::test]
1573    async fn test_pattern_matching() {
1574        let config = SubmitBroadcasterConfig {
1575            expected_reject_patterns: vec![
1576                "Duplicate clOrdID".to_string(),
1577                "Order already exists".to_string(),
1578            ],
1579            ..Default::default()
1580        };
1581
1582        let broadcaster = SubmitBroadcaster::new_with_transports(config, vec![]);
1583
1584        assert!(broadcaster.is_expected_reject("Error: Duplicate clOrdID for order"));
1585        assert!(broadcaster.is_expected_reject("Order already exists in system"));
1586        assert!(!broadcaster.is_expected_reject("Rate limit exceeded"));
1587        assert!(!broadcaster.is_expected_reject("Internal server error"));
1588    }
1589
1590    #[tokio::test]
1591    async fn test_submit_metrics_with_mixed_responses() {
1592        let report = create_test_report("ORDER-1");
1593        let report_clone = report.clone();
1594
1595        let transports = vec![
1596            create_stub_transport("client-0", move || {
1597                let report = report_clone.clone();
1598                async move { Ok(report) }
1599            }),
1600            create_stub_transport("client-1", || async { anyhow::bail!("Timeout") }),
1601        ];
1602
1603        let config = SubmitBroadcasterConfig::default();
1604        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1605
1606        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1607        let result = broadcaster
1608            .broadcast_submit(
1609                instrument_id,
1610                ClientOrderId::from("O-123"),
1611                OrderSide::Buy,
1612                OrderType::Limit,
1613                Quantity::new(100.0, 0),
1614                TimeInForce::Gtc,
1615                Some(Price::new(50000.0, 2)),
1616                None,
1617                None,
1618                None,
1619                None,
1620                None,
1621                false,
1622                false,
1623                None,
1624                None,
1625                None,
1626                None,
1627                None,
1628            )
1629            .await;
1630
1631        assert!(result.is_ok());
1632
1633        let metrics = broadcaster.get_metrics_async().await;
1634        assert_eq!(metrics.total_submits, 1);
1635        assert_eq!(metrics.successful_submits, 1);
1636        assert_eq!(metrics.failed_submits, 0);
1637    }
1638
1639    #[tokio::test]
1640    async fn test_metrics_initialization_and_health() {
1641        let report = create_test_report("ORDER-1");
1642        let transports: Vec<TransportClient> = (0..2)
1643            .map(|i| {
1644                let r = report.clone();
1645                create_stub_transport(&format!("client-{i}"), move || {
1646                    let r = r.clone();
1647                    async move { Ok(r) }
1648                })
1649            })
1650            .collect();
1651
1652        let config = SubmitBroadcasterConfig::default();
1653        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1654        let metrics = broadcaster.get_metrics_async().await;
1655
1656        assert_eq!(metrics.total_submits, 0);
1657        assert_eq!(metrics.successful_submits, 0);
1658        assert_eq!(metrics.failed_submits, 0);
1659        assert_eq!(metrics.expected_rejects, 0);
1660        assert_eq!(metrics.total_clients, 2);
1661        assert_eq!(metrics.healthy_clients, 2);
1662    }
1663
1664    #[tokio::test]
1665    async fn test_health_check_task_lifecycle() {
1666        let report = create_test_report("ORDER-1");
1667        let transports: Vec<TransportClient> = (0..2)
1668            .map(|i| {
1669                let r = report.clone();
1670                create_stub_transport(&format!("client-{i}"), move || {
1671                    let r = r.clone();
1672                    async move { Ok(r) }
1673                })
1674            })
1675            .collect();
1676
1677        let config = SubmitBroadcasterConfig::default();
1678        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1679
1680        // Start should spawn health check task
1681        broadcaster.start().await.unwrap();
1682        assert!(broadcaster.running.load(Ordering::Relaxed));
1683        assert!(
1684            broadcaster
1685                .health_check_task
1686                .read()
1687                .await
1688                .as_ref()
1689                .is_some()
1690        );
1691
1692        // Stop should clean up task
1693        broadcaster.stop().await;
1694        assert!(!broadcaster.running.load(Ordering::Relaxed));
1695    }
1696
1697    #[tokio::test]
1698    async fn test_expected_reject_pattern_comprehensive() {
1699        let transports = vec![
1700            create_stub_transport("client-0", || async {
1701                anyhow::bail!("Duplicate clOrdID: O-123 already exists")
1702            }),
1703            create_stub_transport("client-1", || async { anyhow::bail!("Connection timeout") }),
1704        ];
1705
1706        let config = SubmitBroadcasterConfig::default();
1707        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1708
1709        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1710        let result = broadcaster
1711            .broadcast_submit(
1712                instrument_id,
1713                ClientOrderId::from("O-123"),
1714                OrderSide::Buy,
1715                OrderType::Limit,
1716                Quantity::new(100.0, 0),
1717                TimeInForce::Gtc,
1718                Some(Price::new(50000.0, 2)),
1719                None,
1720                None,
1721                None,
1722                None,
1723                None,
1724                false,
1725                false,
1726                None,
1727                None,
1728                None,
1729                None,
1730                None,
1731            )
1732            .await;
1733
1734        // All failed with expected reject
1735        assert!(result.is_err());
1736
1737        let metrics = broadcaster.get_metrics_async().await;
1738        assert_eq!(metrics.expected_rejects, 1);
1739        assert_eq!(metrics.failed_submits, 1);
1740        assert_eq!(metrics.successful_submits, 0);
1741    }
1742
1743    #[tokio::test]
1744    async fn test_client_order_id_suffix_for_multiple_clients() {
1745        use std::sync::Arc;
1746
1747        use parking_lot::Mutex;
1748
1749        #[derive(Clone)]
1750        struct CaptureExecutor {
1751            captured_ids: Arc<Mutex<Vec<String>>>,
1752            barrier: Arc<tokio::sync::Barrier>,
1753            report: OrderStatusReport,
1754        }
1755
1756        impl SubmitExecutor for CaptureExecutor {
1757            fn health_check(
1758                &self,
1759            ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>> {
1760                Box::pin(async { Ok(()) })
1761            }
1762
1763            fn submit_order(
1764                &self,
1765                _instrument_id: InstrumentId,
1766                client_order_id: ClientOrderId,
1767                _order_side: OrderSide,
1768                _order_type: OrderType,
1769                _quantity: Quantity,
1770                _time_in_force: TimeInForce,
1771                _price: Option<Price>,
1772                _trigger_price: Option<Price>,
1773                _trigger_type: Option<TriggerType>,
1774                _trailing_offset: Option<f64>,
1775                _trailing_offset_type: Option<TrailingOffsetType>,
1776                _display_qty: Option<Quantity>,
1777                _post_only: bool,
1778                _reduce_only: bool,
1779                _order_list_id: Option<OrderListId>,
1780                _contingency_type: Option<ContingencyType>,
1781                _peg_price_type: Option<BitmexPegPriceType>,
1782                _peg_offset_value: Option<f64>,
1783            ) -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send + '_>>
1784            {
1785                // Capture the client_order_id
1786                self.captured_ids
1787                    .lock()
1788                    .push(client_order_id.as_str().to_string());
1789                let report = self.report.clone();
1790                let barrier = Arc::clone(&self.barrier);
1791                // Wait for all tasks to capture their IDs before any completes
1792                // (with concurrent execution, first success aborts others)
1793                Box::pin(async move {
1794                    barrier.wait().await;
1795                    Ok(report)
1796                })
1797            }
1798
1799            fn add_instrument(&self, _instrument: InstrumentAny) {}
1800        }
1801
1802        let captured_ids = Arc::new(Mutex::new(Vec::new()));
1803        let barrier = Arc::new(tokio::sync::Barrier::new(3));
1804        let report = create_test_report("ORDER-1");
1805
1806        let transports = vec![
1807            TransportClient::new(
1808                CaptureExecutor {
1809                    captured_ids: Arc::clone(&captured_ids),
1810                    barrier: Arc::clone(&barrier),
1811                    report: report.clone(),
1812                },
1813                "client-0".to_string(),
1814            ),
1815            TransportClient::new(
1816                CaptureExecutor {
1817                    captured_ids: Arc::clone(&captured_ids),
1818                    barrier: Arc::clone(&barrier),
1819                    report: report.clone(),
1820                },
1821                "client-1".to_string(),
1822            ),
1823            TransportClient::new(
1824                CaptureExecutor {
1825                    captured_ids: Arc::clone(&captured_ids),
1826                    barrier: Arc::clone(&barrier),
1827                    report: report.clone(),
1828                },
1829                "client-2".to_string(),
1830            ),
1831        ];
1832
1833        let config = SubmitBroadcasterConfig::default();
1834        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1835
1836        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1837        let result = broadcaster
1838            .broadcast_submit(
1839                instrument_id,
1840                ClientOrderId::from("O-123"),
1841                OrderSide::Buy,
1842                OrderType::Limit,
1843                Quantity::new(100.0, 0),
1844                TimeInForce::Gtc,
1845                Some(Price::new(50000.0, 2)),
1846                None,
1847                None,
1848                None,
1849                None,
1850                None,
1851                false,
1852                false,
1853                None,
1854                None,
1855                None,
1856                None,
1857                None,
1858            )
1859            .await;
1860
1861        assert!(result.is_ok());
1862
1863        // All transports receive the same client_order_id (no suffixing)
1864        let ids = captured_ids.lock();
1865        assert_eq!(ids.len(), 3);
1866        assert!(ids.iter().all(|id| id == "O-123")); // All clients get the same ID
1867    }
1868
1869    #[tokio::test]
1870    async fn test_client_order_id_suffix_with_partial_failure() {
1871        use std::sync::Arc;
1872
1873        use parking_lot::Mutex;
1874
1875        #[derive(Clone)]
1876        struct CaptureAndFailExecutor {
1877            captured_ids: Arc<Mutex<Vec<String>>>,
1878            barrier: Arc<tokio::sync::Barrier>,
1879            should_succeed: bool,
1880        }
1881
1882        impl SubmitExecutor for CaptureAndFailExecutor {
1883            fn health_check(
1884                &self,
1885            ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>> {
1886                Box::pin(async { Ok(()) })
1887            }
1888
1889            fn submit_order(
1890                &self,
1891                _instrument_id: InstrumentId,
1892                client_order_id: ClientOrderId,
1893                _order_side: OrderSide,
1894                _order_type: OrderType,
1895                _quantity: Quantity,
1896                _time_in_force: TimeInForce,
1897                _price: Option<Price>,
1898                _trigger_price: Option<Price>,
1899                _trigger_type: Option<TriggerType>,
1900                _trailing_offset: Option<f64>,
1901                _trailing_offset_type: Option<TrailingOffsetType>,
1902                _display_qty: Option<Quantity>,
1903                _post_only: bool,
1904                _reduce_only: bool,
1905                _order_list_id: Option<OrderListId>,
1906                _contingency_type: Option<ContingencyType>,
1907                _peg_price_type: Option<BitmexPegPriceType>,
1908                _peg_offset_value: Option<f64>,
1909            ) -> Pin<Box<dyn Future<Output = anyhow::Result<OrderStatusReport>> + Send + '_>>
1910            {
1911                // Capture the client_order_id
1912                self.captured_ids
1913                    .lock()
1914                    .push(client_order_id.as_str().to_string());
1915                let barrier = Arc::clone(&self.barrier);
1916                let should_succeed = self.should_succeed;
1917                // Wait for all tasks to capture their IDs before any completes
1918                // (with concurrent execution, first success aborts others)
1919                Box::pin(async move {
1920                    barrier.wait().await;
1921
1922                    if should_succeed {
1923                        Ok(create_test_report("ORDER-1"))
1924                    } else {
1925                        anyhow::bail!("Network error")
1926                    }
1927                })
1928            }
1929
1930            fn add_instrument(&self, _instrument: InstrumentAny) {}
1931        }
1932
1933        let captured_ids = Arc::new(Mutex::new(Vec::new()));
1934        let barrier = Arc::new(tokio::sync::Barrier::new(2));
1935
1936        let transports = vec![
1937            TransportClient::new(
1938                CaptureAndFailExecutor {
1939                    captured_ids: Arc::clone(&captured_ids),
1940                    barrier: Arc::clone(&barrier),
1941                    should_succeed: false,
1942                },
1943                "client-0".to_string(),
1944            ),
1945            TransportClient::new(
1946                CaptureAndFailExecutor {
1947                    captured_ids: Arc::clone(&captured_ids),
1948                    barrier: Arc::clone(&barrier),
1949                    should_succeed: true,
1950                },
1951                "client-1".to_string(),
1952            ),
1953        ];
1954
1955        let config = SubmitBroadcasterConfig::default();
1956        let broadcaster = SubmitBroadcaster::new_with_transports(config, transports);
1957
1958        let instrument_id = InstrumentId::from_str("XBTUSD.BITMEX").unwrap();
1959        let result = broadcaster
1960            .broadcast_submit(
1961                instrument_id,
1962                ClientOrderId::from("O-456"),
1963                OrderSide::Sell,
1964                OrderType::Market,
1965                Quantity::new(50.0, 0),
1966                TimeInForce::Ioc,
1967                None,
1968                None,
1969                None,
1970                None,
1971                None,
1972                None,
1973                false,
1974                false,
1975                None,
1976                None,
1977                None,
1978                None,
1979                None,
1980            )
1981            .await;
1982
1983        assert!(result.is_ok());
1984
1985        // All transports receive the same client_order_id (no suffixing)
1986        let ids = captured_ids.lock();
1987        assert_eq!(ids.len(), 2);
1988        assert!(ids.iter().all(|id| id == "O-456")); // All clients get the same ID
1989    }
1990
1991    #[tokio::test]
1992    async fn test_proxy_urls_populated_from_config() {
1993        let config = SubmitBroadcasterConfig {
1994            pool_size: 3,
1995            api_key: Some("test_key".into()),
1996            api_secret: Some("test_secret".into()),
1997            proxy_urls: vec![
1998                Some("http://proxy1:8080".into()),
1999                Some("http://proxy2:8080".into()),
2000                Some("http://proxy3:8080".into()),
2001            ],
2002            ..Default::default()
2003        };
2004
2005        assert_eq!(config.proxy_urls.len(), 3);
2006        assert_eq!(
2007            config.proxy_urls[0]
2008                .as_ref()
2009                .map(SecretString::expose_secret),
2010            Some("http://proxy1:8080"),
2011        );
2012        assert_eq!(
2013            config.proxy_urls[1]
2014                .as_ref()
2015                .map(SecretString::expose_secret),
2016            Some("http://proxy2:8080"),
2017        );
2018        assert_eq!(
2019            config.proxy_urls[2]
2020                .as_ref()
2021                .map(SecretString::expose_secret),
2022            Some("http://proxy3:8080"),
2023        );
2024    }
2025}