ccxt_exchanges/okx/
mod.rs

1//! OKX exchange implementation.
2//!
3//! Supports spot trading and futures trading (USDT-M and Coin-M) with REST API and WebSocket support.
4//! OKX uses V5 unified API with HMAC-SHA256 + Base64 authentication.
5
6use ccxt_core::types::default_type::{DefaultSubType, DefaultType};
7use ccxt_core::{BaseExchange, ExchangeConfig, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11pub mod auth;
12pub mod builder;
13pub mod endpoint_router;
14pub mod error;
15pub mod exchange_impl;
16pub mod parser;
17pub mod rest;
18pub mod signed_request;
19pub mod symbol;
20pub mod ws;
21pub mod ws_exchange_impl;
22
23pub use auth::OkxAuth;
24pub use builder::OkxBuilder;
25pub use endpoint_router::{OkxChannelType, OkxEndpointRouter};
26pub use error::{OkxErrorCode, is_error_response, parse_error};
27pub use signed_request::{HttpMethod, OkxSignedRequestBuilder};
28
29/// OKX exchange structure.
30#[derive(Debug)]
31pub struct Okx {
32    /// Base exchange instance.
33    base: BaseExchange,
34    /// OKX-specific options.
35    options: OkxOptions,
36}
37
38/// OKX-specific options.
39///
40/// # Example
41///
42/// ```rust
43/// use ccxt_exchanges::okx::OkxOptions;
44/// use ccxt_core::types::default_type::{DefaultType, DefaultSubType};
45///
46/// let options = OkxOptions {
47///     default_type: DefaultType::Swap,
48///     default_sub_type: Some(DefaultSubType::Linear),
49///     ..Default::default()
50/// };
51/// ```
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct OkxOptions {
54    /// Account mode: cash (spot), cross (cross margin), isolated (isolated margin).
55    ///
56    /// This is kept for backward compatibility with existing configurations.
57    pub account_mode: String,
58    /// Default trading type (spot/margin/swap/futures/option).
59    ///
60    /// This determines which instrument type (instType) to use for API calls.
61    /// OKX uses a unified V5 API, so this primarily affects market filtering
62    /// rather than endpoint selection.
63    #[serde(default)]
64    pub default_type: DefaultType,
65    /// Default sub-type for contract settlement (linear/inverse).
66    ///
67    /// - `Linear`: USDT-margined contracts
68    /// - `Inverse`: Coin-margined contracts
69    ///
70    /// Only applicable when `default_type` is `Swap`, `Futures`, or `Option`.
71    /// Ignored for `Spot` and `Margin` types.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub default_sub_type: Option<DefaultSubType>,
74    /// Enables demo trading environment.
75    pub testnet: bool,
76}
77
78impl Default for OkxOptions {
79    fn default() -> Self {
80        Self {
81            account_mode: "cash".to_string(),
82            default_type: DefaultType::default(), // Defaults to Spot
83            default_sub_type: None,
84            testnet: false,
85        }
86    }
87}
88
89impl Okx {
90    /// Creates a new OKX instance using the builder pattern.
91    ///
92    /// This is the recommended way to create an OKX instance.
93    ///
94    /// # Example
95    ///
96    /// ```no_run
97    /// use ccxt_exchanges::okx::Okx;
98    ///
99    /// let okx = Okx::builder()
100    ///     .api_key("your-api-key")
101    ///     .secret("your-secret")
102    ///     .passphrase("your-passphrase")
103    ///     .sandbox(true)
104    ///     .build()
105    ///     .unwrap();
106    /// ```
107    pub fn builder() -> OkxBuilder {
108        OkxBuilder::new()
109    }
110
111    /// Creates a new OKX instance.
112    ///
113    /// # Arguments
114    ///
115    /// * `config` - Exchange configuration.
116    pub fn new(config: ExchangeConfig) -> Result<Self> {
117        let base = BaseExchange::new(config)?;
118        let options = OkxOptions::default();
119
120        Ok(Self { base, options })
121    }
122
123    /// Creates a new OKX instance with custom options.
124    ///
125    /// This is used internally by the builder pattern.
126    ///
127    /// # Arguments
128    ///
129    /// * `config` - Exchange configuration.
130    /// * `options` - OKX-specific options.
131    pub fn new_with_options(config: ExchangeConfig, options: OkxOptions) -> Result<Self> {
132        let base = BaseExchange::new(config)?;
133        Ok(Self { base, options })
134    }
135
136    /// Returns a reference to the base exchange.
137    pub fn base(&self) -> &BaseExchange {
138        &self.base
139    }
140
141    /// Returns a mutable reference to the base exchange.
142    pub fn base_mut(&mut self) -> &mut BaseExchange {
143        &mut self.base
144    }
145
146    /// Returns the OKX options.
147    pub fn options(&self) -> &OkxOptions {
148        &self.options
149    }
150
151    /// Sets the OKX options.
152    pub fn set_options(&mut self, options: OkxOptions) {
153        self.options = options;
154    }
155
156    /// Returns the exchange ID.
157    pub fn id(&self) -> &'static str {
158        "okx"
159    }
160
161    /// Returns the exchange name.
162    pub fn name(&self) -> &'static str {
163        "OKX"
164    }
165
166    /// Returns the API version.
167    pub fn version(&self) -> &'static str {
168        "v5"
169    }
170
171    /// Returns `true` if the exchange is CCXT-certified.
172    pub fn certified(&self) -> bool {
173        false
174    }
175
176    /// Returns `true` if Pro version (WebSocket) is supported.
177    pub fn pro(&self) -> bool {
178        true
179    }
180
181    /// Returns the rate limit in requests per second.
182    pub fn rate_limit(&self) -> u32 {
183        20
184    }
185
186    /// Returns `true` if sandbox/demo mode is enabled.
187    ///
188    /// Sandbox mode is enabled when either:
189    /// - `config.sandbox` is set to `true`
190    /// - `options.demo` is set to `true`
191    ///
192    /// # Returns
193    ///
194    /// `true` if sandbox mode is enabled, `false` otherwise.
195    ///
196    /// # Example
197    ///
198    /// ```no_run
199    /// use ccxt_exchanges::okx::Okx;
200    /// use ccxt_core::ExchangeConfig;
201    ///
202    /// let config = ExchangeConfig {
203    ///     sandbox: true,
204    ///     ..Default::default()
205    /// };
206    /// let okx = Okx::new(config).unwrap();
207    /// assert!(okx.is_sandbox());
208    /// ```
209    pub fn is_sandbox(&self) -> bool {
210        self.base().config.sandbox || self.options.testnet
211    }
212
213    /// Returns `true` if demo trading mode is enabled.
214    ///
215    /// This is an OKX-specific alias for `is_sandbox()`. Demo trading mode
216    /// is enabled when either:
217    /// - `config.sandbox` is set to `true`
218    /// - `options.demo` is set to `true`
219    ///
220    /// When demo trading is enabled, the client will:
221    /// - Add the `x-simulated-trading: 1` header to all REST API requests
222    /// - Use demo WebSocket URLs (`wss://wspap.okx.com:8443/ws/v5/*?brokerId=9999`)
223    /// - Continue using the production REST domain (`https://www.okx.com`)
224    ///
225    /// # Returns
226    ///
227    /// `true` if demo trading mode is enabled, `false` otherwise.
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// use ccxt_exchanges::okx::Okx;
233    /// use ccxt_core::ExchangeConfig;
234    ///
235    /// let config = ExchangeConfig {
236    ///     sandbox: true,
237    ///     ..Default::default()
238    /// };
239    /// let okx = Okx::new(config).unwrap();
240    /// assert!(okx.is_testnet_trading());
241    /// ```
242    pub fn is_testnet_trading(&self) -> bool {
243        self.base().config.sandbox || self.options.testnet
244    }
245
246    /// Returns the supported timeframes.
247    pub fn timeframes(&self) -> HashMap<String, String> {
248        let mut timeframes = HashMap::new();
249        timeframes.insert("1m".to_string(), "1m".to_string());
250        timeframes.insert("3m".to_string(), "3m".to_string());
251        timeframes.insert("5m".to_string(), "5m".to_string());
252        timeframes.insert("15m".to_string(), "15m".to_string());
253        timeframes.insert("30m".to_string(), "30m".to_string());
254        timeframes.insert("1h".to_string(), "1H".to_string());
255        timeframes.insert("2h".to_string(), "2H".to_string());
256        timeframes.insert("4h".to_string(), "4H".to_string());
257        timeframes.insert("6h".to_string(), "6Hutc".to_string());
258        timeframes.insert("12h".to_string(), "12Hutc".to_string());
259        timeframes.insert("1d".to_string(), "1Dutc".to_string());
260        timeframes.insert("1w".to_string(), "1Wutc".to_string());
261        timeframes.insert("1M".to_string(), "1Mutc".to_string());
262        timeframes
263    }
264
265    /// Returns the API URLs.
266    pub fn urls(&self) -> OkxUrls {
267        if self.base().config.sandbox || self.options.testnet {
268            OkxUrls::demo()
269        } else {
270            OkxUrls::production()
271        }
272    }
273
274    /// Returns the default type configuration.
275    pub fn default_type(&self) -> DefaultType {
276        self.options.default_type
277    }
278
279    /// Returns the default sub-type configuration.
280    pub fn default_sub_type(&self) -> Option<DefaultSubType> {
281        self.options.default_sub_type
282    }
283
284    /// Checks if the current default_type is a contract type (Swap, Futures, or Option).
285    ///
286    /// This is useful for determining whether contract-specific API parameters should be used.
287    ///
288    /// # Returns
289    ///
290    /// `true` if the default_type is Swap, Futures, or Option; `false` otherwise.
291    pub fn is_contract_type(&self) -> bool {
292        self.options.default_type.is_contract()
293    }
294
295    /// Checks if the current configuration uses inverse (coin-margined) contracts.
296    ///
297    /// # Returns
298    ///
299    /// `true` if default_sub_type is Inverse; `false` otherwise.
300    pub fn is_inverse(&self) -> bool {
301        matches!(self.options.default_sub_type, Some(DefaultSubType::Inverse))
302    }
303
304    /// Checks if the current configuration uses linear (USDT-margined) contracts.
305    ///
306    /// # Returns
307    ///
308    /// `true` if default_sub_type is Linear or not specified (defaults to Linear); `false` otherwise.
309    pub fn is_linear(&self) -> bool {
310        !self.is_inverse()
311    }
312
313    /// Creates a new WebSocket client for OKX.
314    ///
315    /// Returns an `OkxWs` instance configured with the appropriate WebSocket URL
316    /// based on the exchange configuration (production or demo).
317    ///
318    /// # Example
319    /// ```no_run
320    /// use ccxt_exchanges::okx::Okx;
321    /// use ccxt_core::ExchangeConfig;
322    ///
323    /// # async fn example() -> ccxt_core::error::Result<()> {
324    /// let okx = Okx::new(ExchangeConfig::default())?;
325    /// let ws = okx.create_ws();
326    /// ws.connect().await?;
327    /// # Ok(())
328    /// # }
329    /// ```
330    pub fn create_ws(&self) -> ws::OkxWs {
331        let urls = self.urls();
332        ws::OkxWs::new(urls.ws_public)
333    }
334
335    /// Creates a new signed request builder for authenticated API requests.
336    ///
337    /// This builder encapsulates the common signing workflow for OKX API requests,
338    /// including credential validation, timestamp generation, HMAC-SHA256 signing,
339    /// and authentication header injection.
340    ///
341    /// # Arguments
342    ///
343    /// * `endpoint` - API endpoint path (e.g., "/api/v5/account/balance")
344    ///
345    /// # Returns
346    ///
347    /// A `OkxSignedRequestBuilder` instance for fluent API construction.
348    ///
349    /// # Example
350    ///
351    /// ```no_run
352    /// use ccxt_exchanges::okx::Okx;
353    /// use ccxt_exchanges::okx::signed_request::HttpMethod;
354    /// use ccxt_core::ExchangeConfig;
355    ///
356    /// # async fn example() -> ccxt_core::Result<()> {
357    /// let okx = Okx::new(ExchangeConfig::default())?;
358    ///
359    /// // Simple GET request
360    /// let data = okx.signed_request("/api/v5/account/balance")
361    ///     .execute()
362    ///     .await?;
363    ///
364    /// // POST request with parameters
365    /// let data = okx.signed_request("/api/v5/trade/order")
366    ///     .method(HttpMethod::Post)
367    ///     .param("instId", "BTC-USDT")
368    ///     .param("tdMode", "cash")
369    ///     .param("side", "buy")
370    ///     .execute()
371    ///     .await?;
372    /// # Ok(())
373    /// # }
374    /// ```
375    pub fn signed_request(
376        &self,
377        endpoint: impl Into<String>,
378    ) -> signed_request::OkxSignedRequestBuilder<'_> {
379        signed_request::OkxSignedRequestBuilder::new(self, endpoint)
380    }
381}
382
383/// OKX API URLs.
384#[derive(Debug, Clone)]
385pub struct OkxUrls {
386    /// REST API base URL.
387    pub rest: String,
388    /// Public WebSocket URL.
389    pub ws_public: String,
390    /// Private WebSocket URL.
391    pub ws_private: String,
392    /// Business WebSocket URL.
393    pub ws_business: String,
394}
395
396impl OkxUrls {
397    /// Returns production environment URLs.
398    pub fn production() -> Self {
399        Self {
400            rest: "https://www.okx.com".to_string(),
401            ws_public: "wss://ws.okx.com:8443/ws/v5/public".to_string(),
402            ws_private: "wss://ws.okx.com:8443/ws/v5/private".to_string(),
403            ws_business: "wss://ws.okx.com:8443/ws/v5/business".to_string(),
404        }
405    }
406
407    /// Returns demo environment URLs.
408    pub fn demo() -> Self {
409        Self {
410            rest: "https://www.okx.com".to_string(),
411            ws_public: "wss://wspap.okx.com:8443/ws/v5/public?brokerId=9999".to_string(),
412            ws_private: "wss://wspap.okx.com:8443/ws/v5/private?brokerId=9999".to_string(),
413            ws_business: "wss://wspap.okx.com:8443/ws/v5/business?brokerId=9999".to_string(),
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_okx_creation() {
424        let config = ExchangeConfig {
425            id: "okx".to_string(),
426            name: "OKX".to_string(),
427            ..Default::default()
428        };
429
430        let okx = Okx::new(config);
431        assert!(okx.is_ok());
432
433        let okx = okx.unwrap();
434        assert_eq!(okx.id(), "okx");
435        assert_eq!(okx.name(), "OKX");
436        assert_eq!(okx.version(), "v5");
437        assert!(!okx.certified());
438        assert!(okx.pro());
439    }
440
441    #[test]
442    fn test_timeframes() {
443        let config = ExchangeConfig::default();
444        let okx = Okx::new(config).unwrap();
445        let timeframes = okx.timeframes();
446
447        assert!(timeframes.contains_key("1m"));
448        assert!(timeframes.contains_key("1h"));
449        assert!(timeframes.contains_key("1d"));
450        assert_eq!(timeframes.len(), 13);
451    }
452
453    #[test]
454    fn test_urls() {
455        let config = ExchangeConfig::default();
456        let okx = Okx::new(config).unwrap();
457        let urls = okx.urls();
458
459        assert!(urls.rest.contains("okx.com"));
460        assert!(urls.ws_public.contains("ws.okx.com"));
461    }
462
463    #[test]
464    fn test_sandbox_urls() {
465        let config = ExchangeConfig {
466            sandbox: true,
467            ..Default::default()
468        };
469        let okx = Okx::new(config).unwrap();
470        let urls = okx.urls();
471
472        assert!(urls.ws_public.contains("wspap.okx.com"));
473        assert!(urls.ws_public.contains("brokerId=9999"));
474    }
475
476    #[test]
477    fn test_demo_rest_url_uses_production_domain() {
478        // Verify that OKX demo mode uses the production REST domain
479        // OKX uses the same REST domain for demo trading, but adds a special header
480        let demo_urls = OkxUrls::demo();
481        let production_urls = OkxUrls::production();
482
483        // REST URL should be the same (production domain)
484        assert_eq!(demo_urls.rest, production_urls.rest);
485        assert_eq!(demo_urls.rest, "https://www.okx.com");
486
487        // WebSocket URLs should be different (demo uses wspap.okx.com)
488        assert_ne!(demo_urls.ws_public, production_urls.ws_public);
489        assert!(demo_urls.ws_public.contains("wspap.okx.com"));
490        assert!(demo_urls.ws_public.contains("brokerId=9999"));
491    }
492
493    #[test]
494    fn test_sandbox_mode_rest_url_is_production() {
495        // When sandbox mode is enabled, REST URL should still be production domain
496        let config = ExchangeConfig {
497            sandbox: true,
498            ..Default::default()
499        };
500        let okx = Okx::new(config).unwrap();
501        let urls = okx.urls();
502
503        // REST URL should be production domain
504        assert_eq!(urls.rest, "https://www.okx.com");
505    }
506
507    #[test]
508    fn test_is_sandbox_with_config_sandbox() {
509        let config = ExchangeConfig {
510            sandbox: true,
511            ..Default::default()
512        };
513        let okx = Okx::new(config).unwrap();
514        assert!(okx.is_sandbox());
515    }
516
517    #[test]
518    fn test_is_sandbox_with_options_demo() {
519        let config = ExchangeConfig::default();
520        let options = OkxOptions {
521            testnet: true,
522            ..Default::default()
523        };
524        let okx = Okx::new_with_options(config, options).unwrap();
525        assert!(okx.is_sandbox());
526    }
527
528    #[test]
529    fn test_is_sandbox_false_by_default() {
530        let config = ExchangeConfig::default();
531        let okx = Okx::new(config).unwrap();
532        assert!(!okx.is_sandbox());
533    }
534
535    #[test]
536    fn test_is_demo_trading_with_config_sandbox() {
537        let config = ExchangeConfig {
538            sandbox: true,
539            ..Default::default()
540        };
541        let okx = Okx::new(config).unwrap();
542        assert!(okx.is_testnet_trading());
543    }
544
545    #[test]
546    fn test_is_demo_trading_with_options_demo() {
547        let config = ExchangeConfig::default();
548        let options = OkxOptions {
549            testnet: true,
550            ..Default::default()
551        };
552        let okx = Okx::new_with_options(config, options).unwrap();
553        assert!(okx.is_testnet_trading());
554    }
555
556    #[test]
557    fn test_is_demo_trading_false_by_default() {
558        let config = ExchangeConfig::default();
559        let okx = Okx::new(config).unwrap();
560        assert!(!okx.is_testnet_trading());
561    }
562
563    #[test]
564    fn test_is_demo_trading_equals_is_sandbox() {
565        // Test that is_demo_trading() and is_sandbox() return the same value
566        let config = ExchangeConfig::default();
567        let okx = Okx::new(config).unwrap();
568        assert_eq!(okx.is_testnet_trading(), okx.is_sandbox());
569
570        let config_sandbox = ExchangeConfig {
571            sandbox: true,
572            ..Default::default()
573        };
574        let okx_sandbox = Okx::new(config_sandbox).unwrap();
575        assert_eq!(okx_sandbox.is_testnet_trading(), okx_sandbox.is_sandbox());
576    }
577
578    #[test]
579    fn test_default_options() {
580        let options = OkxOptions::default();
581        assert_eq!(options.account_mode, "cash");
582        assert_eq!(options.default_type, DefaultType::Spot);
583        assert_eq!(options.default_sub_type, None);
584        assert!(!options.testnet);
585    }
586
587    #[test]
588    fn test_okx_options_with_default_type() {
589        let options = OkxOptions {
590            default_type: DefaultType::Swap,
591            default_sub_type: Some(DefaultSubType::Linear),
592            ..Default::default()
593        };
594        assert_eq!(options.default_type, DefaultType::Swap);
595        assert_eq!(options.default_sub_type, Some(DefaultSubType::Linear));
596    }
597
598    #[test]
599    fn test_okx_options_serialization() {
600        let options = OkxOptions {
601            default_type: DefaultType::Swap,
602            default_sub_type: Some(DefaultSubType::Linear),
603            ..Default::default()
604        };
605        let json = serde_json::to_string(&options).unwrap();
606        assert!(json.contains("\"default_type\":\"swap\""));
607        assert!(json.contains("\"default_sub_type\":\"linear\""));
608    }
609
610    #[test]
611    fn test_okx_options_deserialization() {
612        let json = r#"{
613            "account_mode": "cross",
614            "default_type": "swap",
615            "default_sub_type": "inverse",
616            "testnet": true
617        }"#;
618        let options: OkxOptions = serde_json::from_str(json).unwrap();
619        assert_eq!(options.account_mode, "cross");
620        assert_eq!(options.default_type, DefaultType::Swap);
621        assert_eq!(options.default_sub_type, Some(DefaultSubType::Inverse));
622        assert!(options.testnet);
623    }
624
625    #[test]
626    fn test_okx_options_deserialization_without_default_type() {
627        // Test backward compatibility - default_type should default to Spot
628        let json = r#"{
629            "account_mode": "cash",
630            "testnet": false
631        }"#;
632        let options: OkxOptions = serde_json::from_str(json).unwrap();
633        assert_eq!(options.default_type, DefaultType::Spot);
634        assert_eq!(options.default_sub_type, None);
635    }
636}