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, TriggerType},
23    events::OrderDeniedReason,
24    identifiers::{ClientId, Venue},
25};
26use ustr::Ustr;
27
28use super::enums::{
29    OKXBookChannel, OKXInstrumentType, OKXTargetCurrency, OKXTradeMode, OKXVipLevel,
30};
31
32/// Venue identifier string.
33pub const OKX: &str = "OKX";
34
35/// Static venue instance.
36pub static OKX_VENUE: LazyLock<Venue> = LazyLock::new(|| Venue::new(Ustr::from(OKX)));
37
38/// Static client ID instance.
39pub static OKX_CLIENT_ID: LazyLock<ClientId> = LazyLock::new(|| ClientId::new(Ustr::from(OKX)));
40
41/// See <https://www.okx.com/docs-v5/en/#overview-broker-program> for further details.
42pub const OKX_NAUTILUS_BROKER_ID: &str = "5328c82e5542BCDE";
43
44/// Default lookback for terminal orders and fills during reconciliation.
45///
46/// Active orders and current positions are requested independently of this window. The three-day
47/// default matches the retention of `GET /api/v5/trade/fills`.
48pub const OKX_RECONCILIATION_LOOKBACK_DEFAULT_MINS: u64 = 3 * 24 * 60;
49
50/// Maximum lookback for terminal orders and fills during reconciliation.
51///
52/// Seven days is the longest complete window across the regular order history and spread trade
53/// history endpoints used for reconciliation.
54pub const OKX_RECONCILIATION_LOOKBACK_MAX_MINS: u64 = 7 * 24 * 60;
55
56// Use the canonical host with www to avoid cross-domain redirects which may
57// strip authentication headers in some HTTP clients and middleboxes.
58pub 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
68/// WebSocket heartbeat (ping/pong) interval in seconds.
69pub const OKX_WS_HEARTBEAT_SECS: u64 = 20;
70
71/// OKX success response code for WebSocket operations.
72pub const OKX_SUCCESS_CODE: &str = "0";
73
74/// OKX WebSocket code indicating a service upgrade and required reconnect.
75pub const OKX_SERVICE_UPGRADE_RECONNECT_CODE: &str = "64008";
76
77/// JSON field key for sub-error code in order operation responses.
78pub const OKX_FIELD_SCODE: &str = "sCode";
79
80/// JSON field key for sub-error message in order operation responses.
81pub const OKX_FIELD_SMSG: &str = "sMsg";
82
83/// JSON field key for detailed sub-error code in order operation responses.
84pub const OKX_FIELD_SUBCODE: &str = "subCode";
85
86/// JSON field key for client order ID in order operation responses.
87pub const OKX_FIELD_CLORDID: &str = "clOrdId";
88
89/// Maximum length of a `clOrdId` accepted by OKX.
90///
91/// OKX requires `clOrdId` to be 1-32 case-sensitive alphanumeric characters.
92/// See <https://www.okx.com/docs-v5/en/#order-book-trading-trade>.
93pub const OKX_MAX_CLORDID_LEN: usize = 32;
94
95/// Validates a `clOrdId` against OKX's length and charset rules.
96///
97/// # Errors
98///
99/// Returns an error if the ID exceeds [`OKX_MAX_CLORDID_LEN`] characters or contains
100/// non-alphanumeric characters (such as hyphens or underscores). The error gives a human-readable
101/// reason.
102pub 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
121/// Resolves the OKX wire representation for a Nautilus reduce-only instruction.
122///
123/// A closing `side` and `posSide` pair enforces the same intent in OKX long/short mode, where the
124/// literal `reduceOnly` field is not applicable.
125///
126/// # Errors
127///
128/// Returns an error if OKX cannot enforce reduce-only for the selected product or when a
129/// long/short-mode order would increase the selected position side.
130pub(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
164/// Resolves explicit sizing for algo orders.
165///
166/// # Errors
167///
168/// Returns an error if the requested quantity denomination has no supported wire representation.
169pub(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
212/// Validates a native algo order's trigger feed.
213///
214/// # Errors
215///
216/// Returns an error if OKX cannot represent the requested trigger feed.
217pub(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
249/// OKX supported order time in force.
250///
251/// # Notes
252///
253/// - OKX implements IOC and FOK as order types rather than separate time-in-force parameters.
254/// - FOK is only supported with Limit orders (Market + FOK is not supported).
255/// - IOC with Market orders uses `OptimalLimitIoc`, with Limit orders uses Ioc.
256/// - GTD is supported via `expire_time` parameter.
257pub const OKX_SUPPORTED_TIME_IN_FORCE: &[TimeInForce] = &[
258    TimeInForce::Gtc, // Good Till Cancel (default)
259    TimeInForce::Ioc, // Immediate or Cancel (mapped to OKXOrderType::Ioc or OptimalLimitIoc)
260    TimeInForce::Fok, // Fill or Kill (only with Limit orders, mapped to OKXOrderType::Fok)
261];
262
263/// OKX supported order types.
264///
265/// # Notes
266///
267/// - `PostOnly` is supported as a flag on limit orders.
268/// - Conditional orders (stop/trigger) are supported via algo orders.
269pub const OKX_SUPPORTED_ORDER_TYPES: &[OrderType] = &[
270    OrderType::Market,
271    OrderType::Limit,
272    OrderType::MarketToLimit,   // Mapped to IOC when no price is specified
273    OrderType::StopMarket,      // Supported via algo order API
274    OrderType::StopLimit,       // Supported via algo order API
275    OrderType::MarketIfTouched, // Supported via algo order API
276    OrderType::LimitIfTouched,  // Supported via algo order API
277    OrderType::TrailingStopMarket, // Supported via algo order API (move_order_stop)
278];
279
280/// Conditional order types that require the OKX algo order API.
281pub const OKX_CONDITIONAL_ORDER_TYPES: &[OrderType] = &[
282    OrderType::StopMarket,
283    OrderType::StopLimit,
284    OrderType::MarketIfTouched,
285    OrderType::LimitIfTouched,
286    OrderType::TrailingStopMarket,
287];
288
289/// Advance algo order types that require `cancel-advance-algos` for cancellation.
290/// These cannot be cancelled via the standard `cancel-algos` endpoint.
291pub const OKX_ADVANCE_ALGO_ORDER_TYPES: &[OrderType] = &[OrderType::TrailingStopMarket];
292
293/// OKX error codes that should trigger retries.
294///
295/// Only retry on temporary network/system issues. Retries never apply to
296/// order submission POSTs: OKX rejects a duplicate `clOrdId` only while the
297/// first order rests open, so a submit whose response was lost can already
298/// have filled, and retrying could place a second live order. Submits are
299/// sent once, and an ambiguous outcome resolves through stream updates and
300/// reconciliation.
301///
302/// # References
303///
304/// Based on OKX API documentation: <https://www.okx.com/docs-v5/en/#error-codes>
305pub static OKX_RETRY_ERROR_CODES: LazyLock<AHashSet<&'static str>> = LazyLock::new(|| {
306    let mut codes = AHashSet::new();
307
308    // Temporary system errors
309    codes.insert("50001"); // Service temporarily unavailable
310    codes.insert("50004"); // API endpoint request timeout (does not mean that the request was successful or failed, please check the request result)
311    codes.insert("50005"); // API is offline or unavailable
312    codes.insert("50013"); // System busy, please try again later
313    codes.insert("50026"); // System error, please try again later
314
315    // Rate limit errors (temporary)
316    codes.insert("50011"); // Request too frequent
317
318    // WebSocket connection issues (temporary)
319    codes.insert("60001"); // OK not received in time
320    codes.insert("60005"); // Connection closed as there was no data transmission in the last 30 seconds
321    codes.insert(OKX_SERVICE_UPGRADE_RECONNECT_CODE); // Service upgrade, please reconnect
322
323    codes
324});
325
326/// Determines if an OKX error code should trigger a retry.
327pub fn should_retry_error_code(error_code: &str) -> bool {
328    OKX_RETRY_ERROR_CODES.contains(error_code)
329}
330
331/// OKX error code returned when an order request timed out and the outcome is unknown.
332pub const OKX_ORDER_REQUEST_TIMEOUT_CODE: &str = "51149";
333
334/// OKX error code returned when a post-only order would immediately take liquidity.
335pub const OKX_POST_ONLY_ERROR_CODE: &str = "51019";
336
337/// OKX cancel source code used when a post-only order is auto-cancelled for taking liquidity.
338pub const OKX_POST_ONLY_CANCEL_SOURCE: &str = "31";
339
340/// Human-readable reason used when a post-only order is auto-cancelled for taking liquidity.
341pub const OKX_POST_ONLY_CANCEL_REASON: &str = "POST_ONLY would take liquidity";
342
343/// OKX error code returned when a market order's `slippagePct` would be exceeded by the
344/// projected fill, so the order is rejected.
345pub const OKX_SLIPPAGE_EXCEEDED_ERROR_CODE: &str = "54084";
346
347/// OKX error code returned when the supplied `slippagePct` value is outside the
348/// venue-permitted range.
349pub const OKX_SLIPPAGE_INVALID_ERROR_CODE: &str = "54085";
350
351/// Returns `true` if the OKX `sCode` identifies a slippage-related rejection emitted
352/// in response to the `slippagePct` parameter on market orders.
353#[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
361/// Target currency literal for base currency.
362pub const OKX_TARGET_CCY_BASE: &str = "base_ccy";
363
364/// Target currency literal for quote currency.
365pub const OKX_TARGET_CCY_QUOTE: &str = "quote_ccy";
366
367/// `feature` value for `POST /api/v5/account/activate-feature` USDC order book trading.
368pub const OKX_FEATURE_USDC_ORDER_BOOK: &str = "1";
369
370/// Resolves the optional `tradeQuoteCcy` wire value for a SPOT order.
371///
372/// Non-spot orders omit the field. When `configured` is unset or blank, the venue
373/// default (the quote currency in `instId`) is used. When set, `available` must be
374/// non-empty and contain the value.
375///
376/// # Errors
377///
378/// Returns an error if `configured` is set and `available` is empty, or when `configured` is not
379/// present in `available`.
380pub 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
414/// Resolves instrument families for a given instrument type.
415///
416/// Returns `Some(families)` when the type supports family filtering, or `None`
417/// to skip the instrument type entirely (Option without configured families).
418/// An empty vec means no family filter is needed (Spot, Margin), or all
419/// discoverable families should be loaded (Events).
420pub 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
438/// Clamps a requested book depth to the nearest OKX-supported value.
439///
440/// OKX WebSocket channels support depths of 50 and 400. Depth 0 means
441/// auto-select based on VIP level. Any other value rounds up to the nearest
442/// supported depth so the subscription succeeds and the data engine can
443/// truncate to the originally requested depth.
444pub 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)] // exactly 32 chars
511    #[case("abcdefghij0123456789ABCDEFGHIJ123", false)] // 33 chars
512    #[case("O-20260101-000000-001-001-1", false)] // hyphens
513    #[case("O_20260101_000000", false)] // underscores
514    #[case("", true)] // empty: OKX rejects, but core ClientOrderId never produces empty
515    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        // 35-char compact ID (the shape reported in the original bug report).
522        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}