Skip to main content

nautilus_okx/common/
consts.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Core constants shared across the OKX adapter components.
17
18use 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
29/// Venue identifier string.
30pub const OKX: &str = "OKX";
31
32/// Static venue instance.
33pub static OKX_VENUE: LazyLock<Venue> = LazyLock::new(|| Venue::new(Ustr::from(OKX)));
34
35/// Static client ID instance.
36pub static OKX_CLIENT_ID: LazyLock<ClientId> = LazyLock::new(|| ClientId::new(Ustr::from(OKX)));
37
38/// See <https://www.okx.com/docs-v5/en/#overview-broker-program> for further details.
39pub const OKX_NAUTILUS_BROKER_ID: &str = "5328c82e5542BCDE";
40
41/// Default lookback for terminal orders and fills during reconciliation.
42///
43/// Active orders and current positions are requested independently of this window. The three-day
44/// default matches the retention of `GET /api/v5/trade/fills`.
45pub const OKX_RECONCILIATION_LOOKBACK_DEFAULT_MINS: u64 = 3 * 24 * 60;
46
47/// Maximum lookback for terminal orders and fills during reconciliation.
48///
49/// Seven days is the longest complete window across the regular order history and spread trade
50/// history endpoints used for reconciliation.
51pub const OKX_RECONCILIATION_LOOKBACK_MAX_MINS: u64 = 7 * 24 * 60;
52
53// Use the canonical host with www to avoid cross-domain redirects which may
54// strip authentication headers in some HTTP clients and middleboxes.
55pub 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
65/// WebSocket heartbeat (ping/pong) interval in seconds.
66pub const OKX_WS_HEARTBEAT_SECS: u64 = 20;
67
68/// OKX success response code for WebSocket operations.
69pub const OKX_SUCCESS_CODE: &str = "0";
70
71/// OKX WebSocket code indicating a service upgrade and required reconnect.
72pub const OKX_SERVICE_UPGRADE_RECONNECT_CODE: &str = "64008";
73
74/// JSON field key for sub-error code in order operation responses.
75pub const OKX_FIELD_SCODE: &str = "sCode";
76
77/// JSON field key for sub-error message in order operation responses.
78pub const OKX_FIELD_SMSG: &str = "sMsg";
79
80/// JSON field key for detailed sub-error code in order operation responses.
81pub const OKX_FIELD_SUBCODE: &str = "subCode";
82
83/// JSON field key for client order ID in order operation responses.
84pub const OKX_FIELD_CLORDID: &str = "clOrdId";
85
86/// Maximum length of a `clOrdId` accepted by OKX.
87///
88/// OKX requires `clOrdId` to be 1-32 case-sensitive alphanumeric characters.
89/// See <https://www.okx.com/docs-v5/en/#order-book-trading-trade>.
90pub const OKX_MAX_CLORDID_LEN: usize = 32;
91
92/// Validates a `clOrdId` against OKX's length and charset rules.
93///
94/// # Errors
95///
96/// Returns a human-readable reason when the ID exceeds
97/// [`OKX_MAX_CLORDID_LEN`] characters or contains non-alphanumeric characters
98/// (such as hyphens or underscores).
99pub 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
118/// Resolves the OKX wire representation for a Nautilus reduce-only instruction.
119///
120/// A closing `side` and `posSide` pair enforces the same intent in OKX long/short mode, where the
121/// literal `reduceOnly` field is not applicable.
122///
123/// # Errors
124///
125/// Returns an error when OKX cannot enforce reduce-only for the selected product or when a
126/// long/short-mode order would increase the selected position side.
127pub(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
161/// OKX supported order time in force.
162///
163/// # Notes
164///
165/// - OKX implements IOC and FOK as order types rather than separate time-in-force parameters.
166/// - FOK is only supported with Limit orders (Market + FOK is not supported).
167/// - IOC with Market orders uses `OptimalLimitIoc`, with Limit orders uses Ioc.
168/// - GTD is supported via `expire_time` parameter.
169pub const OKX_SUPPORTED_TIME_IN_FORCE: &[TimeInForce] = &[
170    TimeInForce::Gtc, // Good Till Cancel (default)
171    TimeInForce::Ioc, // Immediate or Cancel (mapped to OKXOrderType::Ioc or OptimalLimitIoc)
172    TimeInForce::Fok, // Fill or Kill (only with Limit orders, mapped to OKXOrderType::Fok)
173];
174
175/// OKX supported order types.
176///
177/// # Notes
178///
179/// - `PostOnly` is supported as a flag on limit orders.
180/// - Conditional orders (stop/trigger) are supported via algo orders.
181pub const OKX_SUPPORTED_ORDER_TYPES: &[OrderType] = &[
182    OrderType::Market,
183    OrderType::Limit,
184    OrderType::MarketToLimit,   // Mapped to IOC when no price is specified
185    OrderType::StopMarket,      // Supported via algo order API
186    OrderType::StopLimit,       // Supported via algo order API
187    OrderType::MarketIfTouched, // Supported via algo order API
188    OrderType::LimitIfTouched,  // Supported via algo order API
189    OrderType::TrailingStopMarket, // Supported via algo order API (move_order_stop)
190];
191
192/// Conditional order types that require the OKX algo order API.
193pub const OKX_CONDITIONAL_ORDER_TYPES: &[OrderType] = &[
194    OrderType::StopMarket,
195    OrderType::StopLimit,
196    OrderType::MarketIfTouched,
197    OrderType::LimitIfTouched,
198    OrderType::TrailingStopMarket,
199];
200
201/// Advance algo order types that require `cancel-advance-algos` for cancellation.
202/// These cannot be cancelled via the standard `cancel-algos` endpoint.
203pub const OKX_ADVANCE_ALGO_ORDER_TYPES: &[OrderType] = &[OrderType::TrailingStopMarket];
204
205/// OKX error codes that should trigger retries.
206///
207/// Only retry on temporary network/system issues. Retries never apply to
208/// order submission POSTs: OKX rejects a duplicate `clOrdId` only while the
209/// first order rests open, so a submit whose response was lost can already
210/// have filled, and retrying could place a second live order. Submits are
211/// sent once, and an ambiguous outcome resolves through stream updates and
212/// reconciliation.
213///
214/// # References
215///
216/// Based on OKX API documentation: <https://www.okx.com/docs-v5/en/#error-codes>
217pub static OKX_RETRY_ERROR_CODES: LazyLock<AHashSet<&'static str>> = LazyLock::new(|| {
218    let mut codes = AHashSet::new();
219
220    // Temporary system errors
221    codes.insert("50001"); // Service temporarily unavailable
222    codes.insert("50004"); // API endpoint request timeout (does not mean that the request was successful or failed, please check the request result)
223    codes.insert("50005"); // API is offline or unavailable
224    codes.insert("50013"); // System busy, please try again later
225    codes.insert("50026"); // System error, please try again later
226
227    // Rate limit errors (temporary)
228    codes.insert("50011"); // Request too frequent
229
230    // WebSocket connection issues (temporary)
231    codes.insert("60001"); // OK not received in time
232    codes.insert("60005"); // Connection closed as there was no data transmission in the last 30 seconds
233    codes.insert(OKX_SERVICE_UPGRADE_RECONNECT_CODE); // Service upgrade, please reconnect
234
235    codes
236});
237
238/// Determines if an OKX error code should trigger a retry.
239pub fn should_retry_error_code(error_code: &str) -> bool {
240    OKX_RETRY_ERROR_CODES.contains(error_code)
241}
242
243/// OKX error code returned when an order request timed out and the outcome is unknown.
244pub const OKX_ORDER_REQUEST_TIMEOUT_CODE: &str = "51149";
245
246/// OKX error code returned when a post-only order would immediately take liquidity.
247pub const OKX_POST_ONLY_ERROR_CODE: &str = "51019";
248
249/// OKX cancel source code used when a post-only order is auto-cancelled for taking liquidity.
250pub const OKX_POST_ONLY_CANCEL_SOURCE: &str = "31";
251
252/// Human-readable reason used when a post-only order is auto-cancelled for taking liquidity.
253pub const OKX_POST_ONLY_CANCEL_REASON: &str = "POST_ONLY would take liquidity";
254
255/// OKX error code returned when a market order's `slippagePct` would be exceeded by the
256/// projected fill, so the order is rejected.
257pub const OKX_SLIPPAGE_EXCEEDED_ERROR_CODE: &str = "54084";
258
259/// OKX error code returned when the supplied `slippagePct` value is outside the
260/// venue-permitted range.
261pub const OKX_SLIPPAGE_INVALID_ERROR_CODE: &str = "54085";
262
263/// Returns `true` if the OKX `sCode` identifies a slippage-related rejection emitted
264/// in response to the `slippagePct` parameter on market orders.
265#[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
273/// Target currency literal for base currency.
274pub const OKX_TARGET_CCY_BASE: &str = "base_ccy";
275
276/// Target currency literal for quote currency.
277pub const OKX_TARGET_CCY_QUOTE: &str = "quote_ccy";
278
279/// `feature` value for `POST /api/v5/account/activate-feature` USDC order book trading.
280pub const OKX_FEATURE_USDC_ORDER_BOOK: &str = "1";
281
282/// Resolves the optional `tradeQuoteCcy` wire value for a SPOT order.
283///
284/// Non-spot orders omit the field. When `configured` is unset or blank, the venue
285/// default (the quote currency in `instId`) is used. When set, `available` must be
286/// non-empty and contain the value.
287///
288/// # Errors
289///
290/// Returns an error when `configured` is set and `available` is empty, or when
291/// `configured` is not present in `available`.
292pub 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
326/// Resolves instrument families for a given instrument type.
327///
328/// Returns `Some(families)` when the type supports family filtering, or `None`
329/// to skip the instrument type entirely (Option without configured families).
330/// An empty vec means no family filter is needed (Spot, Margin), or all
331/// discoverable families should be loaded (Events).
332pub 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
350/// Clamps a requested book depth to the nearest OKX-supported value.
351///
352/// OKX WebSocket channels support depths of 50 and 400. Depth 0 means
353/// auto-select based on VIP level. Any other value rounds up to the nearest
354/// supported depth so the subscription succeeds and the data engine can
355/// truncate to the originally requested depth.
356pub 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)] // exactly 32 chars
423    #[case("abcdefghij0123456789ABCDEFGHIJ123", false)] // 33 chars
424    #[case("O-20260101-000000-001-001-1", false)] // hyphens
425    #[case("O_20260101_000000", false)] // underscores
426    #[case("", true)] // empty: OKX rejects, but core ClientOrderId never produces empty
427    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        // 35-char compact ID (the shape reported in the original bug report).
434        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}