Skip to main content

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