1use std::time::Duration;
9
10pub const MAINNET_HTTP_BASE_URL: &str = "https://api.bybit.com";
12pub const MAINNET_WS_BASE_URL: &str = "wss://stream.bybit.com";
14pub const DEMO_HTTP_BASE_URL: &str = "https://api-demo.bybit.com";
16pub const DEMO_WS_PRIVATE_BASE_URL: &str = "wss://stream-demo.bybit.com";
18pub const TESTNET_HTTP_BASE_URL: &str = "https://api-testnet.bybit.com";
20pub const TESTNET_WS_BASE_URL: &str = "wss://stream-testnet.bybit.com";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BybitEnv {
26 Mainnet,
28 Demo,
30 Testnet,
32}
33
34impl BybitEnv {
35 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59#[non_exhaustive]
60pub enum Category {
61 Linear,
63}
64
65impl Category {
66 pub const fn as_str(self) -> &'static str {
68 match self {
69 Category::Linear => "linear",
70 }
71 }
72
73 pub fn parse(name: &str) -> Option<Self> {
75 match name {
76 "linear" => Some(Category::Linear),
77 _ => None,
78 }
79 }
80}
81
82pub const DEFAULT_RECV_WINDOW_MS: u64 = 5_000;
84const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
85
86#[derive(Clone)]
90pub struct BybitConfig {
91 pub api_key: String,
93 pub api_secret: String,
95 pub env: BybitEnv,
97 pub http_base_url: String,
99 pub ws_public_base_url: String,
101 pub ws_private_base_url: String,
103 pub request_timeout: Duration,
105 pub recv_window_ms: u64,
107 pub category: Category,
109 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 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 pub fn from_env() -> Result<Self, String> {
168 dotenvy::from_path(".env").ok();
169 Self::from_vars()
170 }
171
172 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 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 pub fn with_recv_window_ms(mut self, ms: u64) -> Self {
239 self.recv_window_ms = ms;
240 self
241 }
242
243 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 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 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 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}