1use std::sync::LazyLock;
19
20use ahash::AHashSet;
21use nautilus_model::{
22 enums::{OrderSide, OrderType, PositionSide, TimeInForce},
23 identifiers::{ClientId, Venue},
24};
25use ustr::Ustr;
26
27use super::enums::{OKXBookChannel, OKXInstrumentType, OKXTradeMode, OKXVipLevel};
28
29pub const OKX: &str = "OKX";
31
32pub static OKX_VENUE: LazyLock<Venue> = LazyLock::new(|| Venue::new(Ustr::from(OKX)));
34
35pub static OKX_CLIENT_ID: LazyLock<ClientId> = LazyLock::new(|| ClientId::new(Ustr::from(OKX)));
37
38pub const OKX_NAUTILUS_BROKER_ID: &str = "5328c82e5542BCDE";
40
41pub const OKX_RECONCILIATION_LOOKBACK_DEFAULT_MINS: u64 = 3 * 24 * 60;
46
47pub const OKX_RECONCILIATION_LOOKBACK_MAX_MINS: u64 = 7 * 24 * 60;
52
53pub const OKX_HTTP_URL: &str = "https://www.okx.com";
56pub const OKX_WS_PUBLIC_URL: &str = "wss://ws.okx.com:8443/ws/v5/public";
57pub const OKX_WS_PRIVATE_URL: &str = "wss://ws.okx.com:8443/ws/v5/private";
58pub const OKX_WS_BUSINESS_URL: &str = "wss://ws.okx.com:8443/ws/v5/business";
59pub const OKX_WS_DEMO_PUBLIC_URL: &str = "wss://wspap.okx.com:8443/ws/v5/public";
60pub const OKX_WS_DEMO_PRIVATE_URL: &str = "wss://wspap.okx.com:8443/ws/v5/private";
61pub const OKX_WS_DEMO_BUSINESS_URL: &str = "wss://wspap.okx.com:8443/ws/v5/business";
62
63pub const OKX_WS_TOPIC_DELIMITER: char = ':';
64
65pub const OKX_WS_HEARTBEAT_SECS: u64 = 20;
67
68pub const OKX_SUCCESS_CODE: &str = "0";
70
71pub const OKX_SERVICE_UPGRADE_RECONNECT_CODE: &str = "64008";
73
74pub const OKX_FIELD_SCODE: &str = "sCode";
76
77pub const OKX_FIELD_SMSG: &str = "sMsg";
79
80pub const OKX_FIELD_SUBCODE: &str = "subCode";
82
83pub const OKX_FIELD_CLORDID: &str = "clOrdId";
85
86pub const OKX_MAX_CLORDID_LEN: usize = 32;
91
92pub fn validate_okx_client_order_id(cl_ord_id: &str) -> Result<(), String> {
100 let len = cl_ord_id.len();
101 if len > OKX_MAX_CLORDID_LEN {
102 return Err(format!(
103 "OKX requires clOrdId to be at most {OKX_MAX_CLORDID_LEN} characters, was {len} ({cl_ord_id:?}); \
104 set `use_uuid_client_order_ids=True` and `use_hyphens_in_client_order_ids=False` on the strategy config"
105 ));
106 }
107
108 if !cl_ord_id.bytes().all(|b| b.is_ascii_alphanumeric()) {
109 return Err(format!(
110 "OKX requires clOrdId to be alphanumeric only, was {cl_ord_id:?}; \
111 set `use_hyphens_in_client_order_ids=False` on the strategy config"
112 ));
113 }
114
115 Ok(())
116}
117
118pub(crate) fn okx_reduce_only_wire_value(
128 instrument_type: OKXInstrumentType,
129 td_mode: OKXTradeMode,
130 order_side: OrderSide,
131 position_side: Option<PositionSide>,
132 reduce_only: Option<bool>,
133) -> Result<Option<bool>, String> {
134 if reduce_only != Some(true) {
135 return Ok(None);
136 }
137
138 match instrument_type {
139 OKXInstrumentType::Spot | OKXInstrumentType::Margin => {
140 if td_mode == OKXTradeMode::Cash {
141 Err("OKX cash orders do not support reduce-only instructions".to_string())
142 } else {
143 Ok(Some(true))
144 }
145 }
146 OKXInstrumentType::Swap | OKXInstrumentType::Futures => match position_side {
147 None => Ok(Some(true)),
148 Some(PositionSide::Long) if order_side == OrderSide::Sell => Ok(None),
149 Some(PositionSide::Short) if order_side == OrderSide::Buy => Ok(None),
150 Some(position_side) => Err(format!(
151 "OKX {order_side} orders on the {position_side} side do not enforce reduce-only"
152 )),
153 },
154 OKXInstrumentType::Option | OKXInstrumentType::Events => Err(format!(
155 "OKX {instrument_type} orders do not support reduce-only instructions"
156 )),
157 OKXInstrumentType::Any => Ok(Some(true)),
158 }
159}
160
161pub const OKX_SUPPORTED_TIME_IN_FORCE: &[TimeInForce] = &[
170 TimeInForce::Gtc, TimeInForce::Ioc, TimeInForce::Fok, ];
174
175pub const OKX_SUPPORTED_ORDER_TYPES: &[OrderType] = &[
182 OrderType::Market,
183 OrderType::Limit,
184 OrderType::MarketToLimit, OrderType::StopMarket, OrderType::StopLimit, OrderType::MarketIfTouched, OrderType::LimitIfTouched, OrderType::TrailingStopMarket, ];
191
192pub const OKX_CONDITIONAL_ORDER_TYPES: &[OrderType] = &[
194 OrderType::StopMarket,
195 OrderType::StopLimit,
196 OrderType::MarketIfTouched,
197 OrderType::LimitIfTouched,
198 OrderType::TrailingStopMarket,
199];
200
201pub const OKX_ADVANCE_ALGO_ORDER_TYPES: &[OrderType] = &[OrderType::TrailingStopMarket];
204
205pub static OKX_RETRY_ERROR_CODES: LazyLock<AHashSet<&'static str>> = LazyLock::new(|| {
218 let mut codes = AHashSet::new();
219
220 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
236});
237
238pub fn should_retry_error_code(error_code: &str) -> bool {
240 OKX_RETRY_ERROR_CODES.contains(error_code)
241}
242
243pub const OKX_ORDER_REQUEST_TIMEOUT_CODE: &str = "51149";
245
246pub const OKX_POST_ONLY_ERROR_CODE: &str = "51019";
248
249pub const OKX_POST_ONLY_CANCEL_SOURCE: &str = "31";
251
252pub const OKX_POST_ONLY_CANCEL_REASON: &str = "POST_ONLY would take liquidity";
254
255pub const OKX_SLIPPAGE_EXCEEDED_ERROR_CODE: &str = "54084";
258
259pub const OKX_SLIPPAGE_INVALID_ERROR_CODE: &str = "54085";
262
263#[must_use]
266pub fn is_slippage_rejection(error_code: &str) -> bool {
267 matches!(
268 error_code,
269 OKX_SLIPPAGE_EXCEEDED_ERROR_CODE | OKX_SLIPPAGE_INVALID_ERROR_CODE
270 )
271}
272
273pub const OKX_TARGET_CCY_BASE: &str = "base_ccy";
275
276pub const OKX_TARGET_CCY_QUOTE: &str = "quote_ccy";
278
279pub const OKX_FEATURE_USDC_ORDER_BOOK: &str = "1";
281
282pub fn spot_trade_quote_ccy_wire_value(
293 instrument_type: OKXInstrumentType,
294 configured: Option<&str>,
295 available: &[Ustr],
296) -> Result<Option<Ustr>, String> {
297 if instrument_type != OKXInstrumentType::Spot {
298 return Ok(None);
299 }
300
301 let Some(ccy) = configured.map(str::trim).filter(|value| !value.is_empty()) else {
302 return Ok(None);
303 };
304 let ccy = Ustr::from(ccy);
305
306 if available.is_empty() {
307 return Err(format!(
308 "tradeQuoteCcyList is unknown for this instrument; cannot validate tradeQuoteCcy '{ccy}'"
309 ));
310 }
311
312 if !available.contains(&ccy) {
313 let listed = available
314 .iter()
315 .map(Ustr::as_str)
316 .collect::<Vec<_>>()
317 .join(", ");
318 return Err(format!(
319 "tradeQuoteCcy '{ccy}' is not in tradeQuoteCcyList for this instrument, was [{listed}]"
320 ));
321 }
322
323 Ok(Some(ccy))
324}
325
326pub fn resolve_instrument_families(
333 configured: &Option<Vec<String>>,
334 inst_type: OKXInstrumentType,
335) -> Option<Vec<String>> {
336 match (configured, inst_type) {
337 (Some(families), OKXInstrumentType::Option) => Some(families.clone()),
338 (
339 Some(families),
340 OKXInstrumentType::Futures | OKXInstrumentType::Swap | OKXInstrumentType::Events,
341 ) => Some(families.clone()),
342 (None, OKXInstrumentType::Option) => {
343 log::warn!("Skipping OPTION type: instrument_families required but not configured");
344 None
345 }
346 _ => Some(vec![]),
347 }
348}
349
350pub fn resolve_book_depth(raw_depth: usize) -> usize {
357 match raw_depth {
358 0 | 400 => raw_depth,
359 1..=50 => 50,
360 _ => 400,
361 }
362}
363
364pub(crate) fn select_book_channel(depth: usize, vip: OKXVipLevel) -> OKXBookChannel {
365 match depth {
366 50 if vip >= OKXVipLevel::Vip4 => OKXBookChannel::Books50L2Tbt,
367 0 | 400 if vip >= OKXVipLevel::Vip4 => OKXBookChannel::BookL2Tbt,
368 0 | 50 | 400 => OKXBookChannel::Book,
369 _ => unreachable!("book depth must be resolved before channel selection"),
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use rstest::rstest;
376
377 use super::*;
378
379 #[rstest]
380 #[case::auto_default(0, OKXVipLevel::Vip0, OKXBookChannel::Book)]
381 #[case::auto_vip3(0, OKXVipLevel::Vip3, OKXBookChannel::Book)]
382 #[case::auto_vip4(0, OKXVipLevel::Vip4, OKXBookChannel::BookL2Tbt)]
383 #[case::auto_vip5(0, OKXVipLevel::Vip5, OKXBookChannel::BookL2Tbt)]
384 #[case::depth_50_vip3(50, OKXVipLevel::Vip3, OKXBookChannel::Book)]
385 #[case::depth_50_vip4(50, OKXVipLevel::Vip4, OKXBookChannel::Books50L2Tbt)]
386 #[case::depth_400_vip3(400, OKXVipLevel::Vip3, OKXBookChannel::Book)]
387 #[case::depth_400_vip4(400, OKXVipLevel::Vip4, OKXBookChannel::BookL2Tbt)]
388 #[case::depth_400_vip5(400, OKXVipLevel::Vip5, OKXBookChannel::BookL2Tbt)]
389 fn test_select_book_channel(
390 #[case] depth: usize,
391 #[case] vip: OKXVipLevel,
392 #[case] expected: OKXBookChannel,
393 ) {
394 assert_eq!(select_book_channel(depth, vip), expected);
395 }
396
397 #[rstest]
398 #[case("54084", true)]
399 #[case("54085", true)]
400 #[case("51019", false)]
401 #[case("", false)]
402 fn test_is_slippage_rejection(#[case] code: &str, #[case] expected: bool) {
403 assert_eq!(is_slippage_rejection(code), expected);
404 }
405
406 #[rstest]
407 #[case("50001", true)]
408 #[case("50011", true)]
409 #[case("60005", true)]
410 #[case("60014", false)]
411 #[case("64007", false)]
412 #[case(OKX_SERVICE_UPGRADE_RECONNECT_CODE, true)]
413 #[case("50113", false)]
414 #[case("60012", false)]
415 fn test_should_retry_error_code(#[case] code: &str, #[case] expected: bool) {
416 assert_eq!(should_retry_error_code(code), expected);
417 }
418
419 #[rstest]
420 #[case("O20260101000000ABC1", true)]
421 #[case("aB9", true)]
422 #[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) {
428 assert_eq!(validate_okx_client_order_id(cl_ord_id).is_ok(), expected_ok);
429 }
430
431 #[rstest]
432 fn test_validate_okx_client_order_id_length_message() {
433 let cl_ord_id = "O20260522145501532392555aceLTCUSDT5";
435 let err = validate_okx_client_order_id(cl_ord_id).unwrap_err();
436 assert!(err.contains("at most 32"));
437 assert!(err.contains("was 35"));
438 assert!(err.contains("use_uuid_client_order_ids"));
439 }
440
441 #[rstest]
442 #[case::cash(
443 OKXInstrumentType::Spot,
444 OKXTradeMode::Cash,
445 OrderSide::Sell,
446 None,
447 Err("OKX cash orders do not support reduce-only instructions".to_string()),
448 )]
449 #[case::margin(
450 OKXInstrumentType::Spot,
451 OKXTradeMode::Cross,
452 OrderSide::Sell,
453 None,
454 Ok(Some(true))
455 )]
456 #[case::net(
457 OKXInstrumentType::Swap,
458 OKXTradeMode::Cross,
459 OrderSide::Sell,
460 None,
461 Ok(Some(true))
462 )]
463 #[case::close_long(
464 OKXInstrumentType::Swap,
465 OKXTradeMode::Cross,
466 OrderSide::Sell,
467 Some(PositionSide::Long),
468 Ok(None)
469 )]
470 #[case::close_short(
471 OKXInstrumentType::Futures,
472 OKXTradeMode::Isolated,
473 OrderSide::Buy,
474 Some(PositionSide::Short),
475 Ok(None)
476 )]
477 #[case::increase_long(
478 OKXInstrumentType::Swap,
479 OKXTradeMode::Cross,
480 OrderSide::Buy,
481 Some(PositionSide::Long),
482 Err("OKX BUY orders on the LONG side do not enforce reduce-only".to_string()),
483 )]
484 #[case::option(
485 OKXInstrumentType::Option,
486 OKXTradeMode::Cross,
487 OrderSide::Sell,
488 None,
489 Err("OKX Option orders do not support reduce-only instructions".to_string()),
490 )]
491 fn test_okx_reduce_only_wire_value(
492 #[case] instrument_type: OKXInstrumentType,
493 #[case] td_mode: OKXTradeMode,
494 #[case] order_side: OrderSide,
495 #[case] position_side: Option<PositionSide>,
496 #[case] expected: Result<Option<bool>, String>,
497 ) {
498 assert_eq!(
499 okx_reduce_only_wire_value(
500 instrument_type,
501 td_mode,
502 order_side,
503 position_side,
504 Some(true),
505 ),
506 expected
507 );
508 }
509
510 #[rstest]
511 fn test_spot_trade_quote_ccy_wire_value_omits_non_spot() {
512 assert_eq!(
513 spot_trade_quote_ccy_wire_value(
514 OKXInstrumentType::Swap,
515 Some("USD"),
516 &[Ustr::from("USD")],
517 ),
518 Ok(None)
519 );
520 }
521
522 #[rstest]
523 fn test_spot_trade_quote_ccy_wire_value_omits_when_unset() {
524 assert_eq!(
525 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, None, &[Ustr::from("USD")]),
526 Ok(None)
527 );
528 assert_eq!(
529 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, Some(" "), &[]),
530 Ok(None)
531 );
532 }
533
534 #[rstest]
535 fn test_spot_trade_quote_ccy_wire_value_sends_usd_when_listed() {
536 assert_eq!(
537 spot_trade_quote_ccy_wire_value(
538 OKXInstrumentType::Spot,
539 Some("USD"),
540 &[Ustr::from("USD"), Ustr::from("USDC")],
541 ),
542 Ok(Some(Ustr::from("USD")))
543 );
544 }
545
546 #[rstest]
547 fn test_spot_trade_quote_ccy_wire_value_rejects_when_list_unknown() {
548 let err =
549 spot_trade_quote_ccy_wire_value(OKXInstrumentType::Spot, Some("USD"), &[]).unwrap_err();
550 assert!(err.contains("tradeQuoteCcyList is unknown"));
551 assert!(err.contains("tradeQuoteCcy 'USD'"));
552 }
553
554 #[rstest]
555 fn test_spot_trade_quote_ccy_wire_value_rejects_unlisted() {
556 let err = spot_trade_quote_ccy_wire_value(
557 OKXInstrumentType::Spot,
558 Some("USD"),
559 &[Ustr::from("USDC")],
560 )
561 .unwrap_err();
562 assert!(err.contains("tradeQuoteCcy 'USD'"));
563 assert!(err.contains("was [USDC]"));
564 assert!(!err.contains(&format!(", {}", "got")));
565 }
566}