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