1use 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
70trait SubmitExecutor: Send + Sync {
92 fn add_instrument(&self, instrument: InstrumentAny);
94
95 fn health_check(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + '_>>;
97
98 #[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#[derive(Debug, Clone)]
187pub struct SubmitBroadcasterConfig {
188 pub pool_size: usize,
190 pub api_key: Option<SecretString>,
192 pub api_secret: Option<SecretString>,
194 pub base_url: Option<String>,
196 pub environment: BitmexEnvironment,
198 pub timeout_secs: u64,
200 pub max_retries: u32,
202 pub retry_delay_ms: u64,
204 pub retry_delay_max_ms: u64,
206 pub recv_window_ms: u64,
208 pub max_requests_per_second: u32,
210 pub max_requests_per_minute: u32,
212 pub health_check_interval_secs: u64,
214 pub health_check_timeout_secs: u64,
216 pub expected_reject_patterns: Vec<String>,
218 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#[derive(Clone)]
251struct TransportClient {
252 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#[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 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 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 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 self.run_health_checks().await;
482
483 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 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 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 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 self.failed_submits.fetch_add(1, Ordering::Relaxed);
632
633 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 #[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 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 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 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 pub async fn get_metrics_async(&self) -> BroadcasterMetrics {
792 self.get_metrics()
793 }
794
795 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 pub async fn get_client_stats_async(&self) -> Vec<ClientStats> {
810 self.get_client_stats()
811 }
812
813 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#[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#[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 #[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 }
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 assert!(!broadcaster.running.load(Ordering::Relaxed));
1354
1355 let start_result = broadcaster.start().await;
1357 assert!(start_result.is_ok());
1358 assert!(broadcaster.running.load(Ordering::Relaxed));
1359
1360 let start_again = broadcaster.start().await;
1362 assert!(start_again.is_ok());
1363
1364 broadcaster.stop().await;
1366 assert!(!broadcaster.running.load(Ordering::Relaxed));
1367
1368 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 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 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 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 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 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 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 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 let ids = captured_ids.lock();
1865 assert_eq!(ids.len(), 3);
1866 assert!(ids.iter().all(|id| id == "O-123")); }
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 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 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 let ids = captured_ids.lock();
1987 assert_eq!(ids.len(), 2);
1988 assert!(ids.iter().all(|id| id == "O-456")); }
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}