1use std::{
19 fmt::Debug,
20 num::NonZeroU32,
21 sync::{
22 Arc,
23 atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering},
24 },
25 time::Duration,
26};
27
28use arc_swap::ArcSwap;
29use dashmap::{DashMap, mapref::entry::Entry};
30use nautilus_common::cache::InstrumentLookupError;
31use nautilus_core::{
32 AtomicMap,
33 nanos::UnixNanos,
34 string::secret::SecretString,
35 time::{AtomicTime, get_atomic_clock_realtime},
36};
37use nautilus_live::{
38 SocketControl,
39 task::{SharedTaskSlot, TaskJoinOutcome},
40};
41use nautilus_model::{
42 enums::{OrderSide, TimeInForce},
43 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
44 instruments::{Instrument, InstrumentAny},
45 types::{Price, Quantity},
46};
47use nautilus_network::{
48 ConnectionUrl,
49 http::create_standard_nautilus_headers,
50 mode::ConnectionMode,
51 websocket::{
52 AuthTracker, InitialConnectRetryPolicy, PingHandler, ReconnectHeaders, TransportBackend,
53 WebSocketClient, WebSocketConfig, channel_message_handler,
54 },
55};
56use parking_lot::Mutex;
57use tokio_util::sync::CancellationToken;
58use ustr::Ustr;
59
60use super::handler::{AxOrdersWsFeedHandler, HandlerCommand, WsOrderInfo};
61use crate::{
62 common::{
63 consts::AX_NAUTILUS_TAG,
64 enums::{AxOrderRequestType, AxOrderSide, AxTimeInForce},
65 parse::{client_order_id_to_cid, quantity_to_contracts},
66 },
67 websocket::messages::{AxOrdersWsMessage, AxWsPlaceOrder, OrderMetadata},
68};
69
70pub type AxOrdersWsResult<T> = Result<T, AxOrdersWsClientError>;
72
73#[derive(Debug, Clone)]
75pub struct OrdersCaches {
76 pub orders_metadata: Arc<DashMap<ClientOrderId, OrderMetadata>>,
78 pub venue_to_client_id: Arc<DashMap<VenueOrderId, ClientOrderId>>,
80 pub cid_to_client_order_id: Arc<DashMap<u64, ClientOrderId>>,
82}
83
84impl Default for OrdersCaches {
85 fn default() -> Self {
86 Self {
87 orders_metadata: Arc::new(DashMap::new()),
88 venue_to_client_id: Arc::new(DashMap::new()),
89 cid_to_client_order_id: Arc::new(DashMap::new()),
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96pub enum AxOrdersWsClientError {
97 Transport(String),
99 ChannelError(String),
101 AuthenticationError(String),
103 ClientError(String),
105}
106
107impl core::fmt::Display for AxOrdersWsClientError {
108 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
109 match self {
110 Self::Transport(msg) => write!(f, "Transport error: {msg}"),
111 Self::ChannelError(msg) => write!(f, "Channel error: {msg}"),
112 Self::AuthenticationError(msg) => write!(f, "Authentication error: {msg}"),
113 Self::ClientError(msg) => write!(f, "Client error: {msg}"),
114 }
115 }
116}
117
118impl std::error::Error for AxOrdersWsClientError {}
119
120impl From<&'static str> for AxOrdersWsClientError {
121 fn from(msg: &'static str) -> Self {
122 Self::ClientError(msg.to_string())
123 }
124}
125
126pub struct AxOrdersWebSocketClient {
131 clock: &'static AtomicTime,
132 url: ConnectionUrl,
133 heartbeat: Option<u64>,
134 reconnect_headers: Arc<Mutex<Option<ReconnectHeaders>>>,
135 connection_mode: Arc<ArcSwap<AtomicU8>>,
136 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
137 out_rx: Option<Arc<tokio::sync::mpsc::UnboundedReceiver<AxOrdersWsMessage>>>,
138 signal: Arc<AtomicBool>,
139 cancellation_token: Arc<ArcSwap<CancellationToken>>,
140 task_handle: Arc<SharedTaskSlot<()>>,
141 connect_lock: Arc<tokio::sync::Mutex<()>>,
142 auth_tracker: AuthTracker,
143 instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
144 caches: OrdersCaches,
145 request_id_counter: Arc<AtomicI64>,
146 account_id: AccountId,
147 trader_id: TraderId,
148 transport_backend: TransportBackend,
149 proxy_url: Option<SecretString>,
150 socket_control: Option<SocketControl>,
151}
152
153impl Debug for AxOrdersWebSocketClient {
154 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
155 f.debug_struct(stringify!(AxOrdersWebSocketClient))
156 .field("url", &self.url)
157 .field("heartbeat", &self.heartbeat)
158 .field("account_id", &self.account_id)
159 .finish()
160 }
161}
162
163impl Clone for AxOrdersWebSocketClient {
164 fn clone(&self) -> Self {
165 Self {
166 clock: self.clock,
167 url: self.url.clone(),
168 heartbeat: self.heartbeat,
169 reconnect_headers: Arc::clone(&self.reconnect_headers),
170 connection_mode: Arc::clone(&self.connection_mode),
171 cmd_tx: Arc::clone(&self.cmd_tx),
172 out_rx: None, signal: Arc::clone(&self.signal),
174 cancellation_token: Arc::clone(&self.cancellation_token),
175 task_handle: Arc::clone(&self.task_handle),
176 connect_lock: Arc::clone(&self.connect_lock),
177 auth_tracker: self.auth_tracker.clone(),
178 instruments_cache: Arc::clone(&self.instruments_cache),
179 caches: self.caches.clone(),
180 request_id_counter: Arc::clone(&self.request_id_counter),
181 account_id: self.account_id,
182 trader_id: self.trader_id,
183 transport_backend: self.transport_backend,
184 proxy_url: self.proxy_url.clone(),
185 socket_control: self.socket_control.clone(),
186 }
187 }
188}
189
190impl AxOrdersWebSocketClient {
191 fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
192 InitialConnectRetryPolicy {
193 max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
194 delay_initial: Duration::from_millis(500),
195 delay_max: Duration::from_secs(5),
196 backoff_factor: 2.0,
197 jitter_ms: 250,
198 }
199 }
200
201 #[must_use]
203 pub fn new(
204 url: String,
205 account_id: AccountId,
206 trader_id: TraderId,
207 heartbeat: u64,
208 transport_backend: TransportBackend,
209 proxy_url: Option<String>,
210 ) -> Self {
211 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
212
213 let initial_mode = AtomicU8::new(ConnectionMode::Closed.as_u8());
214 let connection_mode = Arc::new(ArcSwap::from_pointee(initial_mode));
215
216 Self {
217 clock: get_atomic_clock_realtime(),
218 url: url.into(),
219 heartbeat: Some(heartbeat),
220 reconnect_headers: Arc::new(Mutex::new(None)),
221 connection_mode,
222 cmd_tx: Arc::new(tokio::sync::RwLock::new(cmd_tx)),
223 out_rx: None,
224 signal: Arc::new(AtomicBool::new(false)),
225 cancellation_token: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
226 task_handle: Arc::new(SharedTaskSlot::new()),
227 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
228 auth_tracker: AuthTracker::default(),
229 instruments_cache: Arc::new(AtomicMap::new()),
230 caches: OrdersCaches::default(),
231 request_id_counter: Arc::new(AtomicI64::new(1)),
232 account_id,
233 trader_id,
234 transport_backend,
235 proxy_url: proxy_url.map(SecretString::from),
236 socket_control: None,
237 }
238 }
239
240 #[must_use]
242 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
243 self.socket_control = Some(control);
244 self
245 }
246
247 fn generate_ts_init(&self) -> UnixNanos {
248 self.clock.get_time_ns()
249 }
250
251 #[must_use]
253 pub fn url(&self) -> &str {
254 self.url.expose()
255 }
256
257 #[must_use]
259 pub fn account_id(&self) -> AccountId {
260 self.account_id
261 }
262
263 #[must_use]
265 pub fn is_active(&self) -> bool {
266 let connection_mode_arc = self.connection_mode.load();
267 ConnectionMode::from_atomic(&connection_mode_arc).is_active()
268 && !self.signal.load(Ordering::Acquire)
269 }
270
271 #[must_use]
273 pub fn is_closed(&self) -> bool {
274 let connection_mode_arc = self.connection_mode.load();
275 ConnectionMode::from_atomic(&connection_mode_arc).is_closed()
276 || self.signal.load(Ordering::Acquire)
277 }
278
279 fn next_request_id(&self) -> i64 {
281 self.request_id_counter.fetch_add(1, Ordering::Relaxed)
282 }
283
284 pub fn cache_instrument(&self, instrument: InstrumentAny) {
286 let symbol = instrument.symbol().inner();
287 self.instruments_cache.insert(symbol, instrument);
288 }
289
290 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
292 self.instruments_cache.rcu(|m| {
293 for inst in instruments {
294 m.insert(inst.symbol().inner(), inst.clone());
295 }
296 });
297 }
298
299 pub fn update_auth_token(&self, token: &str) -> AxOrdersWsResult<()> {
307 let value = format!("Bearer {token}");
308
309 if let Some(headers) = self.reconnect_headers.lock().as_ref() {
310 headers
311 .update("Authorization", &value)
312 .map_err(|e| AxOrdersWsClientError::Transport(e.to_string()))?;
313 }
314 Ok(())
315 }
316
317 #[must_use]
319 pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
320 self.instruments_cache.get_cloned(symbol)
321 }
322
323 #[must_use]
325 pub fn caches(&self) -> &OrdersCaches {
326 &self.caches
327 }
328
329 #[must_use]
331 pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
332 Arc::clone(&self.instruments_cache)
333 }
334
335 #[must_use]
337 pub fn orders_metadata(&self) -> &Arc<DashMap<ClientOrderId, OrderMetadata>> {
338 &self.caches.orders_metadata
339 }
340
341 #[must_use]
343 pub fn cid_to_client_order_id(&self) -> &Arc<DashMap<u64, ClientOrderId>> {
344 &self.caches.cid_to_client_order_id
345 }
346
347 #[must_use]
349 pub fn resolve_cid(&self, cid: u64) -> Option<ClientOrderId> {
350 self.caches.cid_to_client_order_id.get(&cid).map(|v| *v)
351 }
352
353 pub fn register_external_order(
360 &self,
361 client_order_id: ClientOrderId,
362 venue_order_id: VenueOrderId,
363 instrument_id: InstrumentId,
364 strategy_id: StrategyId,
365 ) -> bool {
366 if self.caches.orders_metadata.contains_key(&client_order_id) {
367 return true;
368 }
369
370 let symbol = instrument_id.symbol.inner();
372 let Some(instrument) = self.get_cached_instrument(&symbol) else {
373 log::warn!(
374 "Cannot register external order {client_order_id}: \
375 instrument {instrument_id} not in cache"
376 );
377 return false;
378 };
379
380 let metadata = OrderMetadata {
381 trader_id: self.trader_id,
382 strategy_id,
383 instrument_id,
384 client_order_id,
385 venue_order_id: Some(venue_order_id),
386 ts_init: self.generate_ts_init(),
387 size_precision: instrument.size_precision(),
388 price_precision: instrument.price_precision(),
389 quote_currency: instrument.quote_currency(),
390 };
391
392 self.caches
393 .orders_metadata
394 .insert(client_order_id, metadata);
395 self.caches
396 .venue_to_client_id
397 .insert(venue_order_id, client_order_id);
398
399 log::debug!(
400 "Registered external order {client_order_id} ({venue_order_id}) for {instrument_id} [{strategy_id}]"
401 );
402
403 true
404 }
405
406 pub async fn connect(&mut self, bearer_token: &str) -> AxOrdersWsResult<()> {
416 let connect_lock = Arc::clone(&self.connect_lock);
417 let _guard = connect_lock.lock().await;
418
419 if !self.task_handle.is_empty() && !self.task_handle.is_finished() {
420 return Err(AxOrdersWsClientError::ClientError(
421 "WebSocket handler is already running".to_string(),
422 ));
423 }
424
425 if let Some(outcome) = self
426 .task_handle
427 .finish(Duration::from_secs(2), Duration::from_secs(2))
428 .await
429 {
430 match outcome {
431 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
432 TaskJoinOutcome::Failed(error) => {
433 return Err(AxOrdersWsClientError::ClientError(format!(
434 "Previous WebSocket handler failed: {error}"
435 )));
436 }
437 TaskJoinOutcome::Incomplete => {
438 return Err(AxOrdersWsClientError::ClientError(
439 "Previous WebSocket handler did not stop within shutdown bounds"
440 .to_string(),
441 ));
442 }
443 }
444 }
445
446 self.signal.store(false, Ordering::Release);
447 let cancellation_token = CancellationToken::new();
448 self.cancellation_token
449 .store(Arc::new(cancellation_token.clone()));
450
451 let (raw_handler, raw_rx) = channel_message_handler();
452
453 let ping_handler: PingHandler = Arc::new(move |_payload: Vec<u8>| {
455 });
457
458 let mut headers = create_standard_nautilus_headers();
459 headers.push((
460 "Authorization".to_string(),
461 format!("Bearer {bearer_token}"),
462 ));
463
464 let config = WebSocketConfig {
465 url: self.url.expose().to_owned(),
466 headers,
467 heartbeat_interval_secs: self.heartbeat,
468 heartbeat_payload: None, connect_timeout_ms: Some(5_000),
470 reconnect_delay_initial_ms: Some(500),
471 reconnect_delay_max_ms: Some(5_000),
472 reconnect_backoff_factor: Some(1.5),
473 reconnect_jitter_ms: Some(250),
474 reconnect_max_attempts: None,
475 heartbeat_timeout_secs: None,
476 idle_timeout_ms: None,
477 writer_capacity: None,
478 backend: self.transport_backend,
479 proxy_url: self
480 .proxy_url
481 .as_ref()
482 .map(|url| url.expose_secret().to_owned()),
483 max_message_size_bytes: None,
484 max_frame_size_bytes: None,
485 };
486
487 let client = WebSocketClient::builder()
488 .config(config.clone())
489 .message_handler(raw_handler.clone())
490 .ping_handler(ping_handler.clone())
491 .initial_connect_retry_policy(Self::initial_connect_retry_policy())
492 .cancellation_token(cancellation_token)
493 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
494 .connect()
495 .await
496 .map_err(|e| {
497 AxOrdersWsClientError::Transport(format!(
498 "WebSocket connection failed: {}: {}",
499 self.url.origin(),
500 e.redacted_message()
501 ))
502 })?;
503
504 self.connection_mode.store(client.connection_mode_atomic());
505 let reconnect_handle = client.reconnect_handle();
506 *self.reconnect_headers.lock() = Some(client.reconnect_headers());
507
508 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<AxOrdersWsMessage>();
509 self.out_rx = Some(Arc::new(out_rx));
510
511 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
512 *self.cmd_tx.write().await = cmd_tx.clone();
513
514 self.send_cmd(HandlerCommand::SetClient(client)).await?;
515
516 self.send_cmd(HandlerCommand::SessionAuthenticated).await?;
517
518 let signal = Arc::clone(&self.signal);
519 let auth_tracker = self.auth_tracker.clone();
520 let orders_metadata = Arc::clone(&self.caches.orders_metadata);
521 let venue_to_client_order_id = Arc::clone(&self.caches.venue_to_client_id);
522 let cid_to_client_order_id = Arc::clone(&self.caches.cid_to_client_order_id);
523
524 if let Err(e) = self.task_handle.spawn(async move {
525 let mut handler = AxOrdersWsFeedHandler::new(
526 signal.clone(),
527 cmd_rx,
528 raw_rx,
529 auth_tracker.clone(),
530 orders_metadata,
531 venue_to_client_order_id,
532 cid_to_client_order_id,
533 );
534
535 while let Some(msg) = handler.next().await {
536 if matches!(msg, AxOrdersWsMessage::Reconnected) {
537 log::info!("WebSocket reconnected, authentication will be restored");
538 }
539
540 if out_tx.send(msg).is_err() {
541 log::debug!("Output channel closed");
542 break;
543 }
544 }
545
546 log::debug!("Handler loop exited");
547 }) {
548 self.out_rx = None;
549 return Err(AxOrdersWsClientError::Transport(format!(
550 "Failed to start WebSocket handler task: {e}"
551 )));
552 }
553
554 if let Some(control) = &self.socket_control {
555 control.register(move || reconnect_handle.request_reconnect());
556 }
557
558 Ok(())
559 }
560
561 #[expect(clippy::too_many_arguments)]
573 pub async fn submit_order(
574 &self,
575 trader_id: TraderId,
576 strategy_id: StrategyId,
577 instrument_id: InstrumentId,
578 client_order_id: ClientOrderId,
579 order_side: OrderSide,
580 quantity: Quantity,
581 time_in_force: TimeInForce,
582 price: Price,
583 post_only: bool,
584 ) -> AxOrdersWsResult<i64> {
585 let symbol = instrument_id.symbol.inner();
587 let instrument = self.get_cached_instrument(&symbol).ok_or_else(|| {
588 AxOrdersWsClientError::ClientError(
589 InstrumentLookupError::not_found(instrument_id).to_string(),
590 )
591 })?;
592
593 let ax_side = AxOrderSide::from(order_side);
594
595 let qty_contracts = quantity_to_contracts(quantity)
596 .map_err(|e| AxOrdersWsClientError::ClientError(e.to_string()))?;
597
598 let request_id = self.next_request_id();
599 let ax_tif = AxTimeInForce::try_from(time_in_force)?;
600 let cid = client_order_id_to_cid(&client_order_id);
601
602 reserve_cid_mapping(&self.caches, cid, client_order_id)?;
603
604 let metadata = OrderMetadata {
606 trader_id,
607 strategy_id,
608 instrument_id,
609 client_order_id,
610 venue_order_id: None,
611 ts_init: self.generate_ts_init(),
612 size_precision: instrument.size_precision(),
613 price_precision: instrument.price_precision(),
614 quote_currency: instrument.quote_currency(),
615 };
616 self.caches
617 .orders_metadata
618 .insert(client_order_id, metadata);
619
620 let order = AxWsPlaceOrder {
621 rid: request_id,
622 t: AxOrderRequestType::PlaceOrder,
623 s: symbol,
624 d: ax_side,
625 q: qty_contracts,
626 p: price.as_decimal(),
627 tif: ax_tif,
628 po: post_only,
629 tag: Some(AX_NAUTILUS_TAG.to_string()),
630 cid: Some(cid),
631 };
632
633 let order_info = WsOrderInfo {
634 client_order_id,
635 symbol,
636 cid,
637 };
638
639 let result = self
640 .send_cmd(HandlerCommand::PlaceOrder {
641 request_id,
642 order,
643 order_info,
644 })
645 .await;
646
647 if result.is_err() {
648 self.caches.orders_metadata.remove(&client_order_id);
649 self.caches.cid_to_client_order_id.remove(&cid);
650 }
651
652 result?;
653 Ok(request_id)
654 }
655
656 pub async fn cancel_order(
664 &self,
665 client_order_id: ClientOrderId,
666 venue_order_id: Option<VenueOrderId>,
667 ) -> AxOrdersWsResult<i64> {
668 let order_id = venue_order_id.map(|v| v.to_string()).ok_or_else(|| {
669 AxOrdersWsClientError::ClientError(format!(
670 "Cannot cancel order {client_order_id}: missing venue_order_id"
671 ))
672 })?;
673
674 let request_id = self.next_request_id();
675
676 self.send_cmd(HandlerCommand::CancelOrder {
677 request_id,
678 order_id,
679 })
680 .await?;
681
682 Ok(request_id)
683 }
684
685 pub async fn get_open_orders(&self) -> AxOrdersWsResult<i64> {
691 let request_id = self.next_request_id();
692
693 self.send_cmd(HandlerCommand::GetOpenOrders { request_id })
694 .await?;
695
696 Ok(request_id)
697 }
698
699 pub fn stream(&mut self) -> impl futures_util::Stream<Item = AxOrdersWsMessage> + 'static {
705 let rx = self
706 .out_rx
707 .take()
708 .expect("Stream receiver already taken or client not connected - stream() can only be called once");
709 let mut rx = Arc::try_unwrap(rx).expect(
710 "Cannot take ownership of stream - client was cloned and other references exist",
711 );
712 async_stream::stream! {
713 while let Some(msg) = rx.recv().await {
714 yield msg;
715 }
716 }
717 }
718
719 pub(crate) fn begin_shutdown(&self) {
720 self.cancellation_token.load().cancel();
721 self.signal.store(true, Ordering::Release);
722 }
723
724 pub async fn disconnect(&self) {
726 log::debug!("Disconnecting WebSocket");
727 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
728 }
729
730 pub async fn close(&mut self) -> anyhow::Result<()> {
736 let connect_lock = Arc::clone(&self.connect_lock);
737 let _guard = connect_lock.lock().await;
738 log::debug!("Closing WebSocket client");
739
740 self.cancellation_token.load().cancel();
742 let _ = self.send_cmd(HandlerCommand::Disconnect).await;
743 tokio::time::sleep(Duration::from_millis(50)).await;
744 self.signal.store(true, Ordering::Release);
745
746 let outcome = self
747 .task_handle
748 .finish(Duration::from_secs(2), Duration::from_secs(2))
749 .await;
750 *self.reconnect_headers.lock() = None;
751
752 if let Some(control) = &self.socket_control {
753 control.deregister();
754 }
755
756 match outcome {
757 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
758 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
759 "Architect AX orders WebSocket handler failed: {error}"
760 )),
761 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
762 "Architect AX orders WebSocket handler did not stop after abort"
763 )),
764 }
765 }
766
767 async fn send_cmd(&self, cmd: HandlerCommand) -> AxOrdersWsResult<()> {
768 let guard = self.cmd_tx.read().await;
769 guard
770 .send(cmd)
771 .map_err(|e| AxOrdersWsClientError::ChannelError(e.to_string()))
772 }
773}
774
775impl Drop for AxOrdersWebSocketClient {
776 fn drop(&mut self) {
777 if Arc::strong_count(&self.task_handle) == 1 && !self.task_handle.is_empty() {
778 self.cancellation_token.load().cancel();
779 self.signal.store(true, Ordering::Release);
780 self.task_handle.abort();
781
782 if let Some(control) = &self.socket_control {
783 control.deregister();
784 }
785 }
786 }
787}
788
789fn reserve_cid_mapping(
790 caches: &OrdersCaches,
791 cid: u64,
792 client_order_id: ClientOrderId,
793) -> AxOrdersWsResult<()> {
794 match caches.cid_to_client_order_id.entry(cid) {
795 Entry::Vacant(entry) => {
796 entry.insert(client_order_id);
797 Ok(())
798 }
799 Entry::Occupied(entry) => Err(AxOrdersWsClientError::ClientError(format!(
800 "AX cid {cid} is already mapped to {}",
801 entry.get(),
802 ))),
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use std::sync::Arc;
809
810 use rstest::rstest;
811
812 use super::*;
813
814 #[rstest]
815 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
816 #[case(
817 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
818 "wss://stream.example.com:9443"
819 )]
820 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
821 let client = AxOrdersWebSocketClient::new(
822 url.to_string(),
823 AccountId::from("AX-001"),
824 TraderId::from("TRADER-001"),
825 30,
826 TransportBackend::default(),
827 None,
828 );
829 let debug = format!("{client:?}");
830 assert!(debug.contains(&format!("url: {origin:?}")));
831 assert!(!debug.contains("SYN-"));
832 assert_eq!(client.url.expose(), url);
833 }
834
835 #[tokio::test]
836 async fn test_drop_aborts_handler_task() {
837 let client = AxOrdersWebSocketClient::new(
838 "wss://example.com/orders/ws".to_string(),
839 AccountId::from("AX-001"),
840 TraderId::from("TRADER-001"),
841 30,
842 TransportBackend::default(),
843 None,
844 );
845 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
846 let handle = tokio::spawn(async move {
847 started_tx.send(()).expect("started receiver");
848 std::future::pending::<()>().await;
849 });
850 let abort_handle = handle.abort_handle();
851 client.task_handle.insert(handle);
852 started_rx.await.expect("handler task started");
853
854 drop(client);
855
856 tokio::time::timeout(Duration::from_secs(1), async {
857 while !abort_handle.is_finished() {
858 tokio::task::yield_now().await;
859 }
860 })
861 .await
862 .expect("handler task aborted");
863 }
864
865 #[rstest]
866 fn test_reserve_cid_mapping_rejects_collision() {
867 let caches = OrdersCaches::default();
868 let cid = 123;
869 let first_client_order_id = ClientOrderId::from("CID-123-A");
870 let second_client_order_id = ClientOrderId::from("CID-123-B");
871
872 reserve_cid_mapping(&caches, cid, first_client_order_id).unwrap();
873 let result = reserve_cid_mapping(&caches, cid, second_client_order_id);
874
875 assert!(matches!(
876 result,
877 Err(AxOrdersWsClientError::ClientError(msg))
878 if msg == "AX cid 123 is already mapped to CID-123-A"
879 ));
880 assert_eq!(
881 caches
882 .cid_to_client_order_id
883 .get(&cid)
884 .map(|client_order_id| *client_order_id),
885 Some(first_client_order_id),
886 );
887 }
888
889 #[tokio::test]
890 async fn test_cancel_order_rejects_without_venue_order_id() {
891 let client = AxOrdersWebSocketClient::new(
892 "wss://example.com/orders/ws".to_string(),
893 AccountId::from("AX-001"),
894 TraderId::from("TRADER-001"),
895 30,
896 TransportBackend::default(),
897 None,
898 );
899 let client_order_id = ClientOrderId::from("CID-123");
900
901 let result = client.cancel_order(client_order_id, None).await;
902
903 assert!(matches!(
904 result,
905 Err(AxOrdersWsClientError::ClientError(msg))
906 if msg.contains("missing venue_order_id")
907 ));
908 }
909
910 #[tokio::test]
911 async fn test_cancel_order_sends_known_venue_order_id() {
912 let mut client = AxOrdersWebSocketClient::new(
913 "wss://example.com/orders/ws".to_string(),
914 AccountId::from("AX-001"),
915 TraderId::from("TRADER-001"),
916 30,
917 TransportBackend::default(),
918 None,
919 );
920
921 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
922 client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
923
924 let client_order_id = ClientOrderId::from("CID-456");
925 let venue_order_id = VenueOrderId::from("V-ORDER-789");
926
927 let request_id = client
928 .cancel_order(client_order_id, Some(venue_order_id))
929 .await
930 .unwrap();
931
932 assert_eq!(request_id, 1);
933 let cmd = cmd_rx.recv().await.unwrap();
934 match cmd {
935 HandlerCommand::CancelOrder {
936 request_id,
937 order_id,
938 } => {
939 assert_eq!(request_id, 1);
940 assert_eq!(order_id, "V-ORDER-789");
941 }
942 other => panic!("unexpected command: {other:?}"),
943 }
944 }
945}