Skip to main content

brokapi_bybit_protocol/
config.rs

1//! Client configuration: the key pair, the contour, the receive window and the base URLs.
2//!
3//! Bybit has three contours with their own hosts. The main one is the default. The demo contour
4//! (`api-demo.bybit.com`) is a separate account with virtual money, open round the clock: it needs a key made in the
5//! demo mode of the site, and it serves only the private streams on its own WebSocket host (public data comes from the
6//! main host). The test contour is the exchange's testnet.
7
8use std::time::Duration;
9
10/// REST host of the main contour.
11pub const MAINNET_HTTP_BASE_URL: &str = "https://api.bybit.com";
12/// WebSocket host of the main contour (public and private streams).
13pub const MAINNET_WS_BASE_URL: &str = "wss://stream.bybit.com";
14/// REST host of the demo contour.
15pub const DEMO_HTTP_BASE_URL: &str = "https://api-demo.bybit.com";
16/// WebSocket host of the private streams of the demo contour.
17pub const DEMO_WS_PRIVATE_BASE_URL: &str = "wss://stream-demo.bybit.com";
18/// REST host of the test contour.
19pub const TESTNET_HTTP_BASE_URL: &str = "https://api-testnet.bybit.com";
20/// WebSocket host of the test contour.
21pub const TESTNET_WS_BASE_URL: &str = "wss://stream-testnet.bybit.com";
22
23/// The contour of the exchange the client talks to.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BybitEnv {
26    /// The real exchange. Orders spend real money.
27    Mainnet,
28    /// The demo account: real prices, virtual money. Needs a key made in the demo mode of the site.
29    Demo,
30    /// The testnet of the exchange (`testnet.bybit.com`).
31    Testnet,
32}
33
34impl BybitEnv {
35    /// `"mainnet"`, `"demo"` or `"testnet"`.
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            BybitEnv::Mainnet => "mainnet",
39            BybitEnv::Demo => "demo",
40            BybitEnv::Testnet => "testnet",
41        }
42    }
43
44    /// Reads the name written by [`BybitEnv::as_str`] (case does not matter). `main` is taken for `mainnet`.
45    pub fn parse(name: &str) -> Option<Self> {
46        match name.trim().to_ascii_lowercase().as_str() {
47            "mainnet" | "main" | "live" => Some(BybitEnv::Mainnet),
48            "demo" => Some(BybitEnv::Demo),
49            "testnet" | "test" => Some(BybitEnv::Testnet),
50            _ => None,
51        }
52    }
53}
54
55/// The product category of the instruments. The category is part of every instrument id.
56///
57/// Only `linear` (USDT perpetuals and USDT futures) is implemented. The type is open for `spot`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59#[non_exhaustive]
60pub enum Category {
61    /// USDT and USDC perpetuals and futures.
62    Linear,
63}
64
65impl Category {
66    /// The word of the API: `"linear"`.
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Category::Linear => "linear",
70        }
71    }
72
73    /// Reads the word of the API.
74    pub fn parse(name: &str) -> Option<Self> {
75        match name {
76            "linear" => Some(Category::Linear),
77            _ => None,
78        }
79    }
80}
81
82/// How long a request is valid after its timestamp, in milliseconds (the exchange default).
83pub const DEFAULT_RECV_WINDOW_MS: u64 = 5_000;
84const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
85
86/// Settings of the client: the key pair, the contour and the addresses.
87///
88/// The secret never appears in `Debug` output.
89#[derive(Clone)]
90pub struct BybitConfig {
91    /// The API key (public half).
92    pub api_key: String,
93    /// The API secret (signs requests).
94    pub api_secret: String,
95    /// The contour.
96    pub env: BybitEnv,
97    /// REST base URL, without a trailing slash.
98    pub http_base_url: String,
99    /// Base URL of the public streams (the path `/v5/public/<category>` is added).
100    pub ws_public_base_url: String,
101    /// Base URL of the private streams (the path `/v5/private` is added).
102    pub ws_private_base_url: String,
103    /// Timeout of one HTTP request.
104    pub request_timeout: Duration,
105    /// The receive window sent with every private request, in milliseconds.
106    pub recv_window_ms: u64,
107    /// The product category.
108    pub category: Category,
109    /// The settle coin used to list orders and positions of a whole category (`USDT` for linear).
110    pub settle_coin: String,
111}
112
113impl std::fmt::Debug for BybitConfig {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("BybitConfig")
116            .field("api_key", &self.api_key)
117            .field("api_secret", &"<redacted>")
118            .field("env", &self.env)
119            .field("http_base_url", &self.http_base_url)
120            .field("ws_public_base_url", &self.ws_public_base_url)
121            .field("ws_private_base_url", &self.ws_private_base_url)
122            .field("request_timeout", &self.request_timeout)
123            .field("recv_window_ms", &self.recv_window_ms)
124            .field("category", &self.category)
125            .field("settle_coin", &self.settle_coin)
126            .finish()
127    }
128}
129
130impl BybitConfig {
131    /// A config for the main contour.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use brokapi_bybit_protocol::config::{BybitConfig, BybitEnv};
137    ///
138    /// let cfg = BybitConfig::new("key", "secret");
139    /// assert_eq!(cfg.env, BybitEnv::Mainnet);
140    /// assert_eq!(cfg.http_base_url, "https://api.bybit.com");
141    /// assert_eq!(format!("{cfg:?}").contains("secret\""), false, "the secret is never printed");
142    /// ```
143    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
144        Self {
145            api_key: api_key.into(),
146            api_secret: api_secret.into(),
147            env: BybitEnv::Mainnet,
148            http_base_url: MAINNET_HTTP_BASE_URL.to_string(),
149            ws_public_base_url: MAINNET_WS_BASE_URL.to_string(),
150            ws_private_base_url: MAINNET_WS_BASE_URL.to_string(),
151            request_timeout: DEFAULT_REQUEST_TIMEOUT,
152            recv_window_ms: DEFAULT_RECV_WINDOW_MS,
153            category: Category::Linear,
154            settle_coin: "USDT".to_string(),
155        }
156    }
157
158    /// Reads the key pair and the contour from the environment (after loading `.env` from the current directory).
159    ///
160    /// `BYBIT_ENV` is `mainnet` (default), `demo` or `testnet`. The key pair is `BYBIT_API_KEY` and `BYBIT_API_SECRET`,
161    /// and for the demo contour `BYBIT_DEMO_API_KEY` and `BYBIT_DEMO_API_SECRET`: a demo key is not valid on the main
162    /// host, so the two pairs can live in one `.env`.
163    ///
164    /// # Errors
165    ///
166    /// The name of the missing variable. An unknown `BYBIT_ENV` is an error too.
167    pub fn from_env() -> Result<Self, String> {
168        dotenvy::from_path(".env").ok();
169        Self::from_vars()
170    }
171
172    /// Like [`BybitConfig::from_env`], with the `.env` file at `path`.
173    pub fn from_env_file(path: impl AsRef<std::path::Path>) -> Result<Self, String> {
174        dotenvy::from_path(path.as_ref()).ok();
175        Self::from_vars()
176    }
177
178    fn from_vars() -> Result<Self, String> {
179        let env = match std::env::var("BYBIT_ENV").ok().filter(|v| !v.is_empty()) {
180            None => BybitEnv::Mainnet,
181            Some(name) => BybitEnv::parse(&name)
182                .ok_or_else(|| format!("BYBIT_ENV={name:?}: use mainnet, demo or testnet"))?,
183        };
184        let (key_var, secret_var) = if env == BybitEnv::Demo {
185            ("BYBIT_DEMO_API_KEY", "BYBIT_DEMO_API_SECRET")
186        } else {
187            ("BYBIT_API_KEY", "BYBIT_API_SECRET")
188        };
189        let get = |name: &str| {
190            std::env::var(name)
191                .ok()
192                .map(|v| v.trim().to_string())
193                .filter(|v| !v.is_empty())
194                .ok_or_else(|| format!("{name} is not set"))
195        };
196        Ok(Self::new(get(key_var)?, get(secret_var)?).with_env(env))
197    }
198
199    /// Points the config at a contour: the hosts of the contour replace the current ones.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use brokapi_bybit_protocol::config::{BybitConfig, BybitEnv};
205    ///
206    /// let cfg = BybitConfig::new("key", "secret").with_env(BybitEnv::Demo);
207    /// assert_eq!(cfg.http_base_url, "https://api-demo.bybit.com");
208    /// // The demo host has no public streams: they come from the main host.
209    /// assert_eq!(cfg.ws_public_base_url, "wss://stream.bybit.com");
210    /// assert_eq!(cfg.ws_private_base_url, "wss://stream-demo.bybit.com");
211    /// ```
212    pub fn with_env(mut self, env: BybitEnv) -> Self {
213        let (http, ws_public, ws_private) = match env {
214            BybitEnv::Mainnet => (
215                MAINNET_HTTP_BASE_URL,
216                MAINNET_WS_BASE_URL,
217                MAINNET_WS_BASE_URL,
218            ),
219            BybitEnv::Demo => (
220                DEMO_HTTP_BASE_URL,
221                MAINNET_WS_BASE_URL,
222                DEMO_WS_PRIVATE_BASE_URL,
223            ),
224            BybitEnv::Testnet => (
225                TESTNET_HTTP_BASE_URL,
226                TESTNET_WS_BASE_URL,
227                TESTNET_WS_BASE_URL,
228            ),
229        };
230        self.env = env;
231        self.http_base_url = http.to_string();
232        self.ws_public_base_url = ws_public.to_string();
233        self.ws_private_base_url = ws_private.to_string();
234        self
235    }
236
237    /// Sets the receive window in milliseconds.
238    pub fn with_recv_window_ms(mut self, ms: u64) -> Self {
239        self.recv_window_ms = ms;
240        self
241    }
242
243    /// A config for the in-process imitation: every host is `http_base_url` (the streams use `ws://`).
244    ///
245    /// The config is marked as demo, so a connector built from it reports a virtual account.
246    pub fn for_mock(
247        api_key: impl Into<String>,
248        api_secret: impl Into<String>,
249        http_base_url: impl Into<String>,
250    ) -> Self {
251        let http = http_base_url.into();
252        let ws = http
253            .replace("https://", "wss://")
254            .replace("http://", "ws://");
255        let mut cfg = Self::new(api_key, api_secret);
256        cfg.env = BybitEnv::Demo;
257        cfg.http_base_url = http;
258        cfg.ws_public_base_url = ws.clone();
259        cfg.ws_private_base_url = ws;
260        cfg
261    }
262
263    /// The URL of the public stream of the category.
264    pub fn ws_public_url(&self) -> String {
265        format!(
266            "{}/v5/public/{}",
267            self.ws_public_base_url.trim_end_matches('/'),
268            self.category.as_str()
269        )
270    }
271
272    /// The URL of the private stream.
273    pub fn ws_private_url(&self) -> String {
274        format!(
275            "{}/v5/private",
276            self.ws_private_base_url.trim_end_matches('/')
277        )
278    }
279
280    /// Whether the account is virtual (the demo contour or the imitation).
281    pub fn is_demo(&self) -> bool {
282        self.env == BybitEnv::Demo
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn the_demo_contour_takes_public_data_from_the_main_host() {
292        let cfg = BybitConfig::new("k", "s").with_env(BybitEnv::Demo);
293        assert_eq!(
294            cfg.ws_public_url(),
295            "wss://stream.bybit.com/v5/public/linear"
296        );
297        assert_eq!(
298            cfg.ws_private_url(),
299            "wss://stream-demo.bybit.com/v5/private"
300        );
301        assert!(cfg.is_demo());
302        let main = BybitConfig::new("k", "s");
303        assert_eq!(main.ws_private_url(), "wss://stream.bybit.com/v5/private");
304        assert!(!main.is_demo());
305    }
306
307    #[test]
308    fn contour_names_round_trip() {
309        for env in [BybitEnv::Mainnet, BybitEnv::Demo, BybitEnv::Testnet] {
310            assert_eq!(BybitEnv::parse(env.as_str()), Some(env));
311        }
312        assert_eq!(BybitEnv::parse("DEMO"), Some(BybitEnv::Demo));
313        assert_eq!(BybitEnv::parse("nope"), None);
314        assert_eq!(Category::parse("linear"), Some(Category::Linear));
315        assert_eq!(Category::parse("spot"), None);
316    }
317
318    #[test]
319    fn the_secret_is_redacted() {
320        let cfg = BybitConfig::new("the-key", "very-secret-value");
321        let text = format!("{cfg:?}");
322        assert!(text.contains("the-key"));
323        assert!(!text.contains("very-secret-value"));
324    }
325}