1use std::sync::LazyLock;
19
20use ahash::AHashSet;
21use nautilus_model::{
22 enums::{OrderSide, OrderType, PositionSide, TimeInForce, TriggerType},
23 events::OrderDeniedReason,
24 identifiers::{ClientId, Venue},
25};
26use ustr::Ustr;
27
28use super::enums::{
29 OKXBookChannel, OKXInstrumentType, OKXTargetCurrency, OKXTradeMode, OKXVipLevel,
30};
31
32pub const OKX: &str = "OKX";
34
35pub static OKX_VENUE: LazyLock<Venue> = LazyLock::new(|| Venue::new(Ustr::from(OKX)));
37
38pub static OKX_CLIENT_ID: LazyLock<ClientId> = LazyLock::new(|| ClientId::new(Ustr::from(OKX)));
40
41pub const OKX_NAUTILUS_BROKER_ID: &str = "5328c82e5542BCDE";
43
44pub const OKX_RECONCILIATION_LOOKBACK_DEFAULT_MINS: u64 = 3 * 24 * 60;
49
50pub const OKX_RECONCILIATION_LOOKBACK_MAX_MINS: u64 = 7 * 24 * 60;
55
56pub const OKX_HTTP_URL: &str = "https://www.okx.com";
59pub const OKX_WS_PUBLIC_URL: &str = "wss://ws.okx.com/ws/v5/public";
60pub const OKX_WS_PRIVATE_URL: &str = "wss://ws.okx.com/ws/v5/private";
61pub const OKX_WS_BUSINESS_URL: &str = "wss://ws.okx.com/ws/v5/business";
62pub const OKX_WS_DEMO_PUBLIC_URL: &str = "wss://wspap.okx.com/ws/v5/public";
63pub const OKX_WS_DEMO_PRIVATE_URL: &str = "wss://wspap.okx.com/ws/v5/private";
64pub const OKX_WS_DEMO_BUSINESS_URL: &str = "wss://wspap.okx.com/ws/v5/business";
65
66pub const OKX_WS_TOPIC_DELIMITER: char = ':';
67
68pub const OKX_WS_HEARTBEAT_SECS: u64 = 20;
70
71pub const OKX_SUCCESS_CODE: &str = "0";
73
74pub const OKX_SERVICE_UPGRADE_RECONNECT_CODE: &str = "64008";
76
77pub const OKX_FIELD_SCODE: &str = "sCode";
79
80pub const OKX_FIELD_SMSG: &str = "sMsg";
82
83pub const OKX_FIELD_SUBCODE: &str = "subCode";
85
86pub const OKX_FIELD_CLORDID: &str = "clOrdId";
88
89pub const OKX_MAX_CLORDID_LEN: usize = 32;
94
95pub fn validate_okx_client_order_id(cl_ord_id: &str) -> Result<(), String> {
103 let len = cl_ord_id.len();
104 if len > OKX_MAX_CLORDID_LEN {
105 return Err(format!(
106 "OKX requires clOrdId to be at most {OKX_MAX_CLORDID_LEN} characters, was {len} ({cl_ord_id:?}); \
107 set `use_uuid_client_order_ids=True` and `use_hyphens_in_client_order_ids=False` on the strategy config"
108 ));
109 }
110
111 if !cl_ord_id.bytes().all(|b| b.is_ascii_alphanumeric()) {
112 return Err(format!(
113 "OKX requires clOrdId to be alphanumeric only, was {cl_ord_id:?}; \
114 set `use_hyphens_in_client_order_ids=False` on the strategy config"
115 ));
116 }
117
118 Ok(())
119}
120
121pub(crate) fn okx_reduce_only_wire_value(
131 instrument_type: OKXInstrumentType,
132 td_mode: OKXTradeMode,
133 order_side: OrderSide,
134 position_side: Option<PositionSide>,
135 reduce_only: Option<bool>,
136) -> Result<Option<bool>, String> {
137 if reduce_only != Some(true) {
138 return Ok(None);
139 }
140
141 match instrument_type {
142 OKXInstrumentType::Spot | OKXInstrumentType::Margin => {
143 if td_mode == OKXTradeMode::Cash {
144 Err("OKX cash orders do not support reduce-only instructions".to_string())
145 } else {
146 Ok(Some(true))
147 }
148 }
149 OKXInstrumentType::Swap | OKXInstrumentType::Futures => match position_side {
150 None => Ok(Some(true)),
151 Some(PositionSide::Long) if order_side == OrderSide::Sell => Ok(None),
152 Some(PositionSide::Short) if order_side == OrderSide::Buy => Ok(None),
153 Some(position_side) => Err(format!(
154 "OKX {order_side} orders on the {position_side} side do not enforce reduce-only"
155 )),
156 },
157 OKXInstrumentType::Option | OKXInstrumentType::Events => Err(format!(
158 "OKX {instrument_type} orders do not support reduce-only instructions"
159 )),
160 OKXInstrumentType::Any => Ok(Some(true)),
161 }
162}
163
164pub(crate) fn okx_algo_target_currency(
170 instrument_type: OKXInstrumentType,
171 td_mode: OKXTradeMode,
172 order_side: OrderSide,
173 order_type: OrderType,
174 quote_quantity: bool,
175) -> Result<Option<OKXTargetCurrency>, OrderDeniedReason> {
176 if !OKX_CONDITIONAL_ORDER_TYPES.contains(&order_type) {
177 return Ok(None);
178 }
179
180 if instrument_type == OKXInstrumentType::Spot && order_side == OrderSide::Buy {
181 if matches!(
182 order_type,
183 OrderType::StopMarket | OrderType::MarketIfTouched
184 ) {
185 return Ok(Some(if quote_quantity {
186 OKXTargetCurrency::QuoteCcy
187 } else {
188 OKXTargetCurrency::BaseCcy
189 }));
190 }
191
192 if order_type == OrderType::TrailingStopMarket {
193 return if quote_quantity {
194 Ok(None)
195 } else {
196 Err(OrderDeniedReason::ValidationFailed {
197 detail: format!(
198 "OKX {instrument_type} {order_type} algo buys require quote quantity in {td_mode} mode"
199 ),
200 })
201 };
202 }
203 }
204
205 if quote_quantity {
206 return Err(OrderDeniedReason::unsupported_quote_quantity());
207 }
208
209 Ok(None)
210}
211
212pub(crate) fn validate_algo_trigger_type(
218 instrument_type: OKXInstrumentType,
219 td_mode: OKXTradeMode,
220 order_type: OrderType,
221 trigger_type: Option<TriggerType>,
222) -> Result<(), String> {
223 if !OKX_CONDITIONAL_ORDER_TYPES.contains(&order_type) {
224 return Ok(());
225 }
226
227 let Some(trigger_type) = trigger_type else {
228 return Ok(());
229 };
230
231 let supported = match trigger_type {
232 TriggerType::Default | TriggerType::LastPrice => true,
233 TriggerType::MarkPrice | TriggerType::IndexPrice => {
234 (instrument_type != OKXInstrumentType::Spot || td_mode != OKXTradeMode::Cash)
235 && order_type != OrderType::TrailingStopMarket
236 }
237 _ => false,
238 };
239
240 if !supported {
241 return Err(format!(
242 "OKX {instrument_type} {order_type} algo orders do not support {trigger_type} triggers"
243 ));
244 }
245
246 Ok(())
247}
248
249pub const OKX_SUPPORTED_TIME_IN_FORCE: &[TimeInForce] = &[
258 TimeInForce::Gtc, TimeInForce::Ioc, TimeInForce::Fok, ];
262
263pub const OKX_SUPPORTED_ORDER_TYPES: &[OrderType] = &[
270 OrderType::Market,
271 OrderType::Limit,
272 OrderType::MarketToLimit, OrderType::StopMarket, OrderType::StopLimit, OrderType::MarketIfTouched, OrderType::LimitIfTouched, OrderType::TrailingStopMarket, ];
279
280pub const OKX_CONDITIONAL_ORDER_TYPES: &[OrderType] = &[
282 OrderType::StopMarket,
283 OrderType::StopLimit,
284 OrderType::MarketIfTouched,
285 OrderType::LimitIfTouched,
286 OrderType::TrailingStopMarket,
287];
288
289pub const OKX_ADVANCE_ALGO_ORDER_TYPES: &[OrderType] = &[OrderType::TrailingStopMarket];
292
293pub static OKX_RETRY_ERROR_CODES: LazyLock<AHashSet<&'static str>> = LazyLock::new(|| {
306 let mut codes = AHashSet::new();
307
308 codes.insert("50001"); codes.insert("50004"); codes.insert("50005"); codes.insert("50013"); codes.insert("50026"); codes.insert("50011"); codes.insert("60001"); codes.insert("60005"); codes.insert(OKX_SERVICE_UPGRADE_RECONNECT_CODE); codes
324});
325
326pub fn should_retry_error_code(error_code: &str) -> bool {
328 OKX_RETRY_ERROR_CODES.contains(error_code)
329}
330
331pub const OKX_ORDER_REQUEST_TIMEOUT_CODE: &str = "51149";
333
334pub const OKX_POST_ONLY_ERROR_CODE: &str = "51019";
336
337pub const OKX_POST_ONLY_CANCEL_SOURCE: &str = "31";
339
340pub const OKX_POST_ONLY_CANCEL_REASON: &str = "POST_ONLY would take liquidity";
342
343pub const OKX_SLIPPAGE_EXCEEDED_ERROR_CODE: &str = "54084";
346
347pub const OKX_SLIPPAGE_INVALID_ERROR_CODE: &str = "54085";
350
351#[must_use]
354pub fn is_slippage_rejection(error_code: &str) -> bool {
355 matches!(
356 error_code,
357 OKX_SLIPPAGE_EXCEEDED_ERROR_CODE | OKX_SLIPPAGE_INVALID_ERROR_CODE
358 )
359}
360
361pub const OKX_TARGET_CCY_BASE: &str = "base_ccy";
363
364pub const OKX_TARGET_CCY_QUOTE: &str = "quote_ccy";
366
367pub const OKX_FEATURE_USDC_ORDER_BOOK: &str = "1";
369
370pub fn spot_trade_quote_ccy_wire_value(
381 instrument_type: OKXInstrumentType,
382 configured: Option<&str>,
383 available: &[Ustr],
384) -> Result<Option<Ustr>, String> {
385 if instrument_type != OKXInstrumentType::Spot {
386 return Ok(None);
387 }
388
389 let Some(ccy) = configured.map(str::trim).filter(|value| !value.is_empty()) else {
390 return Ok(None);
391 };
392 let ccy = Ustr::from(ccy);
393
394 if available.is_empty() {
395 return Err(format!(
396 "tradeQuoteCcyList is unknown for this instrument; cannot validate tradeQuoteCcy '{ccy}'"
397 ));
398 }
399
400 if !available.contains(&ccy) {
401 let listed = available
402 .iter()
403 .map(Ustr::as_str)
404 .collect::<Vec<_>>()
405 .join(", ");
406 return Err(format!(
407 "tradeQuoteCcy '{ccy}' is not in tradeQuoteCcyList for this instrument, was [{listed}]"
408 ));
409 }
410
411 Ok(Some(ccy))
412}
413
414pub fn resolve_instrument_families(
421 configured: &Option<Vec<String>>,
422 inst_type: OKXInstrumentType,
423) -> Option<Vec<String>> {
424 match (configured, inst_type) {
425 (Some(families), OKXInstrumentType::Option) => Some(families.clone()),
426 (
427 Some(families),
428 OKXInstrumentType::Futures | OKXInstrumentType::Swap | OKXInstrumentType::Events,
429 ) => Some(families.clone()),
430 (None, OKXInstrumentType::Option) => {
431 log::warn!("Skipping OPTION type: instrument_families required but not configured");
432 None
433 }
434 _ => Some(vec![]),
435 }
436}
437
438pub fn resolve_book_depth(raw_depth: usize) -> usize {
445 match raw_depth {
446 0 | 400 => raw_depth,
447 1..=50 => 50,
448 _ => 400,
449 }
450}
451
452pub(crate) fn select_book_channel(depth: usize, vip: OKXVipLevel) -> OKXBookChannel {
453 match depth {
454 50 if vip >= OKXVipLevel::Vip4 => OKXBookChannel::Books50L2Tbt,
455 0 | 400 if vip >= OKXVipLevel::Vip4 => OKXBookChannel::BookL2Tbt,
456 0 | 50 | 400 => OKXBookChannel::Book,
457 _ => unreachable!("book depth must be resolved before channel selection"),
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use rstest::rstest;
464
465 use super::*;
466
467 #[rstest]
468 #[case::auto_default(0, OKXVipLevel::Vip0, OKXBookChannel::Book)]
469 #[case::auto_vip3(0, OKXVipLevel::Vip3, OKXBookChannel::Book)]
470 #[case::auto_vip4(0, OKXVipLevel::Vip4, OKXBookChannel::BookL2Tbt)]
471 #[case::auto_vip5(0, OKXVipLevel::Vip5, OKXBookChannel::BookL2Tbt)]
472 #[case::depth_50_vip3(50, OKXVipLevel::Vip3, OKXBookChannel::Book)]
473 #[case::depth_50_vip4(50, OKXVipLevel::Vip4, OKXBookChannel::Books50L2Tbt)]
474 #[case::depth_400_vip3(400, OKXVipLevel::Vip3, OKXBookChannel::Book)]
475 #[case::depth_400_vip4(400, OKXVipLevel::Vip4, OKXBookChannel::BookL2Tbt)]
476 #[case::depth_400_vip5(400, OKXVipLevel::Vip5, OKXBookChannel::BookL2Tbt)]
477 fn test_select_book_channel(
478 #[case] depth: usize,
479 #[case] vip: OKXVipLevel,
480 #[case] expected: OKXBookChannel,
481 ) {
482 assert_eq!(select_book_channel(depth, vip), expected);
483 }
484
485 #[rstest]
486 #[case("54084", true)]
487 #[case("54085", true)]
488 #[case("51019", false)]
489 #[case("", false)]
490 fn test_is_slippage_rejection(#[case] code: &str, #[case] expected: bool) {
491 assert_eq!(is_slippage_rejection(code), expected);
492 }
493
494 #[rstest]
495 #[case("50001", true)]
496 #[case("50011", true)]
497 #[case("60005", true)]
498 #[case("60014", false)]
499 #[case("64007", false)]
500 #[case(OKX_SERVICE_UPGRADE_RECONNECT_CODE, true)]
501 #[case("50113", false)]
502 #[case("60012", false)]
503 fn test_should_retry_error_code(#[case] code: &str, #[case] expected: bool) {
504 assert_eq!(should_retry_error_code(code), expected);
505 }
506
507 #[rstest]
508 #[case("O20260101000000ABC1", true)]
509 #[case("aB9", true)]
510 #[case("abcdefghij0123456789ABCDEFGHIJ12", true)] #[case("abcdefghij0123456789ABCDEFGHIJ123", false)] #[case("O-20260101-000000-001-001-1", false)] #[case("O_20260101_000000", false)] #[case("", true)] fn test_validate_okx_client_order_id(#[case] cl_ord_id: &str, #[case] expected_ok: bool) {
516 assert_eq!(validate_okx_client_order_id(cl_ord_id).is_ok(), expected_ok);
517 }
518
519 #[rstest]
520 fn test_validate_okx_client_order_id_length_message() {
521 let cl_ord_id = "O20260522145501532392555aceLTCUSDT5";
523 let err = validate_okx_client_order_id(cl_ord_id).unwrap_err();
524 assert!(err.contains("at most 32"));
525 assert!(err.contains("was 35"));
526 assert!(err.contains("use_uuid_client_order_ids"));
527 }
528
529 #[rstest]
530 #[case(OrderSide::Buy, false, Some(OKXTargetCurrency::BaseCcy))]
531 #[case(OrderSide::Buy, true, Some(OKXTargetCurrency::QuoteCcy))]
532 #[case(OrderSide::Sell, false, None)]
533 fn test_okx_algo_target_currency_cash_market_orders(
534 #[case] side: OrderSide,
535 #[case] quote_quantity: bool,
536 #[case] expected: Option<OKXTargetCurrency>,
537 #[values(OrderType::StopMarket, OrderType::MarketIfTouched)] order_type: OrderType,
538 ) {
539 assert_eq!(
540 okx_algo_target_currency(
541 OKXInstrumentType::Spot,
542 OKXTradeMode::Cash,
543 side,
544 order_type,
545 quote_quantity,
546 ),
547 Ok(expected),
548 );
549 }
550
551 #[rstest]
552 fn test_okx_algo_target_currency_denies_quote_sells(
553 #[values(
554 OrderType::StopMarket,
555 OrderType::MarketIfTouched,
556 OrderType::TrailingStopMarket
557 )]
558 order_type: OrderType,
559 #[values(OKXTradeMode::Cash, OKXTradeMode::Cross, OKXTradeMode::Isolated)]
560 trade_mode: OKXTradeMode,
561 ) {
562 assert_eq!(
563 okx_algo_target_currency(
564 OKXInstrumentType::Spot,
565 trade_mode,
566 OrderSide::Sell,
567 order_type,
568 true,
569 ),
570 Err(OrderDeniedReason::unsupported_quote_quantity()),
571 );
572 }
573
574 #[rstest]
575 #[case(OKXInstrumentType::Swap, OrderType::StopMarket)]
576 #[case(OKXInstrumentType::Spot, OrderType::StopLimit)]
577 #[case(OKXInstrumentType::Spot, OrderType::LimitIfTouched)]
578 fn test_okx_algo_target_currency_preserves_base_routes(
579 #[case] instrument_type: OKXInstrumentType,
580 #[case] order_type: OrderType,
581 ) {
582 assert_eq!(
583 okx_algo_target_currency(
584 instrument_type,
585 OKXTradeMode::Cash,
586 OrderSide::Buy,
587 order_type,
588 false,
589 ),
590 Ok(None),
591 );
592 }
593
594 #[rstest]
595 fn test_okx_algo_target_currency_spot_trailing_buy_uses_native_quote_size(
596 #[values(OKXTradeMode::Cash, OKXTradeMode::Cross, OKXTradeMode::Isolated)]
597 trade_mode: OKXTradeMode,
598 ) {
599 assert_eq!(
600 okx_algo_target_currency(
601 OKXInstrumentType::Spot,
602 trade_mode,
603 OrderSide::Buy,
604 OrderType::TrailingStopMarket,
605 true,
606 ),
607 Ok(None),
608 );
609 }
610
611 #[rstest]
612 fn test_okx_algo_target_currency_does_not_restrict_ordinary_market_orders(
613 #[values(OrderSide::Buy, OrderSide::Sell)] side: OrderSide,
614 #[values(false, true)] quote_quantity: bool,
615 ) {
616 assert_eq!(
617 okx_algo_target_currency(
618 OKXInstrumentType::Spot,
619 OKXTradeMode::Cash,
620 side,
621 OrderType::Market,
622 quote_quantity,
623 ),
624 Ok(None),
625 );
626 }
627
628 #[rstest]
629 #[case(OKXTradeMode::Cross, false, OKXTargetCurrency::BaseCcy)]
630 #[case(OKXTradeMode::Cross, true, OKXTargetCurrency::QuoteCcy)]
631 #[case(OKXTradeMode::Isolated, false, OKXTargetCurrency::BaseCcy)]
632 #[case(OKXTradeMode::Isolated, true, OKXTargetCurrency::QuoteCcy)]
633 fn test_okx_algo_target_currency_non_cash_spot_buys(
634 #[case] trade_mode: OKXTradeMode,
635 #[case] quote_quantity: bool,
636 #[case] expected: OKXTargetCurrency,
637 #[values(OrderType::StopMarket, OrderType::MarketIfTouched)] order_type: OrderType,
638 ) {
639 assert_eq!(
640 okx_algo_target_currency(
641 OKXInstrumentType::Spot,
642 trade_mode,
643 OrderSide::Buy,
644 order_type,
645 quote_quantity,
646 ),
647 Ok(Some(expected)),
648 );
649 }
650
651 #[rstest]
652 #[case(OKXInstrumentType::Spot, OrderType::StopLimit)]
653 #[case(OKXInstrumentType::Spot, OrderType::LimitIfTouched)]
654 #[case(OKXInstrumentType::Swap, OrderType::StopMarket)]
655 #[case(OKXInstrumentType::Futures, OrderType::MarketIfTouched)]
656 fn test_okx_algo_target_currency_denies_unsupported_quote_orders(
657 #[case] instrument_type: OKXInstrumentType,
658 #[case] order_type: OrderType,
659 #[values(OrderSide::Buy, OrderSide::Sell)] side: OrderSide,
660 #[values(OKXTradeMode::Cash, OKXTradeMode::Cross, OKXTradeMode::Isolated)]
661 trade_mode: OKXTradeMode,
662 ) {
663 assert_eq!(
664 okx_algo_target_currency(instrument_type, trade_mode, side, order_type, true),
665 Err(OrderDeniedReason::unsupported_quote_quantity()),
666 );
667 }
668
669 #[rstest]
670 fn test_okx_algo_target_currency_denies_base_spot_trailing_buy() {
671 assert_eq!(
672 okx_algo_target_currency(
673 OKXInstrumentType::Spot,
674 OKXTradeMode::Cash,
675 OrderSide::Buy,
676 OrderType::TrailingStopMarket,
677 false,
678 ),
679 Err(OrderDeniedReason::ValidationFailed {
680 detail:
681 "OKX Spot TRAILING_STOP_MARKET algo buys require quote quantity in Cash mode"
682 .to_string(),
683 }),
684 );
685 }
686
687 #[rstest]
688 #[case(OKXInstrumentType::Spot, OrderType::StopMarket, None)]
689 #[case(
690 OKXInstrumentType::Spot,
691 OrderType::StopLimit,
692 Some(TriggerType::Default)
693 )]
694 #[case(
695 OKXInstrumentType::Spot,
696 OrderType::MarketIfTouched,
697 Some(TriggerType::LastPrice)
698 )]
699 #[case(
700 OKXInstrumentType::Spot,
701 OrderType::TrailingStopMarket,
702 Some(TriggerType::LastPrice)
703 )]
704 #[case(
705 OKXInstrumentType::Swap,
706 OrderType::StopMarket,
707 Some(TriggerType::MarkPrice)
708 )]
709 #[case(
710 OKXInstrumentType::Futures,
711 OrderType::LimitIfTouched,
712 Some(TriggerType::IndexPrice)
713 )]
714 fn test_validate_algo_trigger_type_accepts_supported_feeds(
715 #[case] instrument_type: OKXInstrumentType,
716 #[case] order_type: OrderType,
717 #[case] trigger_type: Option<TriggerType>,
718 ) {
719 assert_eq!(
720 validate_algo_trigger_type(
721 instrument_type,
722 OKXTradeMode::Cash,
723 order_type,
724 trigger_type
725 ),
726 Ok(())
727 );
728 }
729
730 #[rstest]
731 #[case(OKXInstrumentType::Spot, OrderType::StopMarket, TriggerType::MarkPrice)]
732 #[case(
733 OKXInstrumentType::Spot,
734 OrderType::MarketIfTouched,
735 TriggerType::IndexPrice
736 )]
737 #[case(OKXInstrumentType::Spot, OrderType::StopLimit, TriggerType::MarkPrice)]
738 #[case(
739 OKXInstrumentType::Spot,
740 OrderType::LimitIfTouched,
741 TriggerType::IndexPrice
742 )]
743 #[case(OKXInstrumentType::Swap, OrderType::StopMarket, TriggerType::BidAsk)]
744 #[case(
745 OKXInstrumentType::Swap,
746 OrderType::TrailingStopMarket,
747 TriggerType::MarkPrice
748 )]
749 fn test_validate_algo_trigger_type_denies_unsupported_feeds(
750 #[case] instrument_type: OKXInstrumentType,
751 #[case] order_type: OrderType,
752 #[case] trigger_type: TriggerType,
753 ) {
754 assert_eq!(
755 validate_algo_trigger_type(
756 instrument_type,
757 OKXTradeMode::Cash,
758 order_type,
759 Some(trigger_type)
760 ),
761 Err(format!(
762 "OKX {instrument_type} {order_type} algo orders do not support {trigger_type} triggers"
763 )),
764 );
765 }
766
767 #[rstest]
768 fn test_validate_algo_trigger_type_allows_non_cash_margin_feeds(
769 #[values(OKXTradeMode::Cross, OKXTradeMode::Isolated)] trade_mode: OKXTradeMode,
770 #[values(OrderType::StopMarket, OrderType::MarketIfTouched)] order_type: OrderType,
771 #[values(TriggerType::MarkPrice, TriggerType::IndexPrice)] trigger_type: TriggerType,
772 ) {
773 assert_eq!(
774 validate_algo_trigger_type(
775 OKXInstrumentType::Spot,
776 trade_mode,
777 order_type,
778 Some(trigger_type),
779 ),
780 Ok(()),
781 );
782 }
783
784 #[rstest]
785 #[case::cash(
786 OKXInstrumentType::Spot,
787 OKXTradeMode::Cash,
788 OrderSide::Sell,
789 None,
790 Err("OKX cash orders do not support reduce-only instructions".to_string()),
791 )]
792 #[case::margin(
793 OKXInstrumentType::Spot,
794 OKXTradeMode::Cross,
795 OrderSide::Sell,
796 None,
797 Ok(Some(true))
798 )]
799 #[case::net(
800 OKXInstrumentType::Swap,
801 OKXTradeMode::Cross,
802 OrderSide::Sell,
803 None,
804 Ok(Some(true))
805 )]
806 #[case::close_long(
807 OKXInstrumentType::Swap,
808 OKXTradeMode::Cross,
809 OrderSide::Sell,
810 Some(PositionSide::Long),
811 Ok(None)
812 )]
813 #[case::close_short(
814 OKXInstrumentType::Futures,
815 OKXTradeMode::Isolated,
816 OrderSide::Buy,
817 Some(PositionSide::Short),
818 Ok(None)
819 )]
820 #[case::increase_long(
821 OKXInstrumentType::Swap,
822 OKXTradeMode::Cross,
823 OrderSide::Buy,
824 Some(PositionSide::Long),
825 Err("OKX BUY orders on the LONG side do not enforce reduce-only".to_string()),
826 )]
827 #[case::option(
828 OKXInstrumentType::Option,
829 OKXTradeMode::Cross,
830 OrderSide::Sell,
831 None,
832 Err("OKX Option orders do not support reduce-only instructions".to_string()),
833 )]
834 fn test_okx_reduce_only_wire_value(
835 #[case] instrument_type: OKXInstrumentType,
836 #[case] td_mode: OKXTradeMode,
837 #[case] order_side: OrderSide,
838 #[case] position_side: Option<PositionSide>,
839 #[case] expected: Result<Option<bool>, String>,
840 ) {
841 assert_eq!(
842 okx_reduce_only_wire_value(
843 instrument_type,
844 td_mode,
845 order_side,
846 position_side,
847 Some(true),
848 ),
849 expected
850 );
851 }
852
853 #[rstest]
854 fn test_spot_trade_quote_ccy_wire_value_omits_non_spot() {
855 assert_eq!(
856 spot_trade_quote_ccy_wire_value(
857 OKXInstrumentType::Swap,
858 Some("USD"),
859 &[Ustr::from("USD")],
860 ),
861 Ok(None)
862 );
863 }
864
865 #[rstest]
866 fn test_spot_trade_quote_ccy_wire_value_omits_when_unset() {
867 assert_eq!(
868 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, None, &[Ustr::from("USD")]),
869 Ok(None)
870 );
871 assert_eq!(
872 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, Some(" "), &[]),
873 Ok(None)
874 );
875 }
876
877 #[rstest]
878 fn test_spot_trade_quote_ccy_wire_value_sends_usd_when_listed() {
879 assert_eq!(
880 spot_trade_quote_ccy_wire_value(
881 OKXInstrumentType::Spot,
882 Some("USD"),
883 &[Ustr::from("USD"), Ustr::from("USDC")],
884 ),
885 Ok(Some(Ustr::from("USD")))
886 );
887 }
888
889 #[rstest]
890 fn test_spot_trade_quote_ccy_wire_value_rejects_when_list_unknown() {
891 let err =
892 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, Some("USD"), &[]).unwrap_err();
893 assert!(err.contains("tradeQuoteCcyList is unknown"));
894 assert!(err.contains("tradeQuoteCcy 'USD'"));
895 }
896
897 #[rstest]
898 fn test_spot_trade_quote_ccy_wire_value_rejects_unlisted() {
899 let err = spot_trade_quote_ccy_wire_value(
900 OKXInstrumentType::Spot,
901 Some("USD"),
902 &[Ustr::from("USDC")],
903 )
904 .unwrap_err();
905 assert!(err.contains("tradeQuoteCcy 'USD'"));
906 assert!(err.contains("was [USDC]"));
907 assert!(!err.contains(&format!(", {}", "got")));
908 }
909}