Skip to main content

longbridge/
config.rs

1use std::{
2    collections::HashMap,
3    fmt::{self, Display},
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::Arc,
7};
8
9pub(crate) use http::{HeaderName, HeaderValue, Request, header};
10use longbridge_httpcli::{
11    DC_REGION_HEADER, DcRegion, HttpClient, HttpClientConfig, Json, Method, is_cn,
12};
13use longbridge_oauth::OAuth;
14use num_enum::IntoPrimitive;
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17use tokio_tungstenite::tungstenite::client::IntoClientRequest;
18use tracing::{Level, Subscriber, subscriber::NoSubscriber};
19use tracing_appender::rolling::{RollingFileAppender, Rotation};
20use tracing_subscriber::{filter::Targets, layer::SubscriberExt};
21
22use crate::error::Result;
23
24const DEFAULT_QUOTE_WS_URL: &str = "wss://openapi-quote.longbridge.com/v2";
25const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longbridge.com/v2";
26const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longbridge.cn/v2";
27const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longbridge.cn/v2";
28
29/// Language identifier
30#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
31#[allow(non_camel_case_types)]
32#[repr(i32)]
33pub enum Language {
34    /// zh-CN
35    ZH_CN = 0,
36    /// zh-HK
37    ZH_HK = 2,
38    /// en
39    #[default]
40    EN = 1,
41}
42
43impl Language {
44    pub(crate) fn as_str(&self) -> &'static str {
45        match self {
46            Language::ZH_CN => "zh-CN",
47            Language::ZH_HK => "zh-HK",
48            Language::EN => "en",
49        }
50    }
51}
52
53impl FromStr for Language {
54    type Err = ();
55
56    fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
57        match s {
58            "zh-CN" => Ok(Language::ZH_CN),
59            "zh-HK" => Ok(Language::ZH_HK),
60            "en" => Ok(Language::EN),
61            _ => Err(()),
62        }
63    }
64}
65
66impl Display for Language {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.write_str(self.as_str())
69    }
70}
71
72/// Push mode for candlestick
73#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
74pub enum PushCandlestickMode {
75    /// Realtime mode
76    #[default]
77    Realtime,
78    /// Confirmed mode
79    Confirmed,
80}
81
82/// Internal authentication mode (not part of the public API)
83pub(crate) enum AuthMode {
84    /// Legacy API Key mode (HMAC-SHA256 signed requests)
85    ApiKey {
86        app_key: String,
87        app_secret: String,
88        access_token: String,
89    },
90    /// OAuth 2.0 mode
91    OAuth(OAuth),
92}
93
94impl Clone for AuthMode {
95    fn clone(&self) -> Self {
96        match self {
97            AuthMode::ApiKey {
98                app_key,
99                app_secret,
100                access_token,
101            } => AuthMode::ApiKey {
102                app_key: app_key.clone(),
103                app_secret: app_secret.clone(),
104                access_token: access_token.clone(),
105            },
106            AuthMode::OAuth(oauth) => AuthMode::OAuth(oauth.clone()),
107        }
108    }
109}
110
111impl fmt::Debug for AuthMode {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            AuthMode::ApiKey { app_key, .. } => {
115                f.debug_struct("ApiKey").field("app_key", app_key).finish()
116            }
117            AuthMode::OAuth(_) => f.debug_struct("OAuth").finish(),
118        }
119    }
120}
121
122/// Transport used by the [`QuoteContext`](crate::quote::QuoteContext) pull
123/// (request/response) APIs.
124///
125/// Subscriptions, push events, `realtime_*`, `member_id` / `quote_level` /
126/// `quote_package_details` and the few APIs without a REST equivalent always
127/// use the WebSocket connection; this only selects how the other pull APIs
128/// reach the server. Return types are the same either way, and with `Http`
129/// the SDK applies the WebSocket's US overnight rule (overnight data only when
130/// [`Config::enable_overnight`] is set) client-side. Error codes, rate
131/// limiting and some session-specific values can differ; see the changelog.
132#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
133pub enum QuoteTransport {
134    /// Send pull requests over the quote WebSocket connection (default).
135    #[default]
136    WebSocket,
137    /// Send pull requests over HTTP (`POST /quote/*`) where the API has a REST
138    /// equivalent, falling back to the WebSocket for the rest. Using only
139    /// HTTP-backed APIs never opens a WebSocket connection.
140    Http,
141}
142
143impl FromStr for QuoteTransport {
144    type Err = ();
145
146    fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
147        match s.to_ascii_lowercase().as_str() {
148            "ws" => Ok(QuoteTransport::WebSocket),
149            "http" => Ok(QuoteTransport::Http),
150            _ => Err(()),
151        }
152    }
153}
154
155/// Configuration options for Longbridge SDK
156#[derive(Debug, Clone)]
157pub struct Config {
158    pub(crate) auth: AuthMode,
159    pub(crate) http_url: Option<String>,
160    pub(crate) quote_ws_url: Option<String>,
161    pub(crate) trade_ws_url: Option<String>,
162    pub(crate) enable_overnight: Option<bool>,
163    pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
164    pub(crate) enable_print_quote_packages: bool,
165    pub(crate) language: Language,
166    pub(crate) log_path: Option<PathBuf>,
167    /// Extra headers injected into every HTTP and WebSocket upgrade request.
168    pub(crate) custom_headers: HashMap<String, String>,
169    pub(crate) enable_papertrading: bool,
170    pub(crate) quote_transport: QuoteTransport,
171}
172
173/// Reads an env var by trying `LONGBRIDGE_<suffix>` first, then falling back
174/// to `LONGPORT_<suffix>`.  Returns `None` if neither is set.
175fn env_var(suffix: &str) -> Option<String> {
176    std::env::var(format!("LONGBRIDGE_{suffix}"))
177        .ok()
178        .or_else(|| std::env::var(format!("LONGPORT_{suffix}")).ok())
179}
180
181/// Like [`env_var`] but returns an error if the variable is not set.
182fn env_var_required(suffix: &str) -> Result<String> {
183    env_var(suffix).ok_or_else(|| {
184        longbridge_httpcli::HttpClientError::MissingEnvVar {
185            name: format!("LONGBRIDGE_{suffix}"),
186        }
187        .into()
188    })
189}
190
191/// Non-credential environment variables shared by `from_apikey` and
192/// `from_oauth`.  Callers must have already invoked `dotenv::dotenv()`.
193struct ConfigExtras {
194    http_url: Option<String>,
195    quote_ws_url: Option<String>,
196    trade_ws_url: Option<String>,
197    language: Language,
198    enable_overnight: Option<bool>,
199    push_candlestick_mode: Option<PushCandlestickMode>,
200    enable_print_quote_packages: bool,
201    log_path: Option<PathBuf>,
202    enable_papertrading: bool,
203    quote_transport: QuoteTransport,
204}
205
206impl ConfigExtras {
207    fn from_env() -> Self {
208        let language = env_var("LANGUAGE")
209            .and_then(|v| v.parse::<Language>().ok())
210            .unwrap_or(Language::EN);
211        let enable_overnight = env_var("ENABLE_OVERNIGHT").map(|v| v == "true");
212        let push_candlestick_mode = env_var("PUSH_CANDLESTICK_MODE").map(|v| match v.as_str() {
213            "confirmed" => PushCandlestickMode::Confirmed,
214            _ => PushCandlestickMode::Realtime,
215        });
216        let enable_print_quote_packages =
217            env_var("PRINT_QUOTE_PACKAGES").as_deref().unwrap_or("true") == "true";
218        let enable_papertrading = env_var("PAPERTRADING").as_deref() == Some("true");
219        Self {
220            http_url: env_var("HTTP_URL"),
221            quote_ws_url: env_var("QUOTE_WS_URL"),
222            trade_ws_url: env_var("TRADE_WS_URL"),
223            language,
224            enable_overnight,
225            push_candlestick_mode,
226            enable_print_quote_packages,
227            log_path: env_var("LOG_PATH").map(PathBuf::from),
228            enable_papertrading,
229            quote_transport: env_var("QUOTE_TRANSPORT")
230                .and_then(|v| {
231                    let parsed = v.parse().ok();
232                    if parsed.is_none() {
233                        tracing::warn!(value = %v, "invalid LONGBRIDGE_QUOTE_TRANSPORT, using ws");
234                    }
235                    parsed
236                })
237                .unwrap_or_default(),
238        }
239    }
240}
241
242impl Config {
243    /// Create a new `Config` using API Key authentication.
244    ///
245    /// All optional environment variables (`LONGBRIDGE_HTTP_URL`,
246    /// `LONGBRIDGE_LANGUAGE`, `LONGBRIDGE_QUOTE_WS_URL`,
247    /// `LONGBRIDGE_TRADE_WS_URL`, `LONGBRIDGE_ENABLE_OVERNIGHT`,
248    /// `LONGBRIDGE_PUSH_CANDLESTICK_MODE`, `LONGBRIDGE_QUOTE_TRANSPORT`,
249    /// `LONGBRIDGE_PRINT_QUOTE_PACKAGES`, `LONGBRIDGE_LOG_PATH`) are read from
250    /// the environment (or `.env` file) and applied automatically if set.
251    ///
252    /// For OAuth 2.0, use [`Config::from_oauth`] together with
253    /// [`longbridge::oauth::OAuthBuilder`] instead.
254    pub fn from_apikey(
255        app_key: impl Into<String>,
256        app_secret: impl Into<String>,
257        access_token: impl Into<String>,
258    ) -> Self {
259        let _ = dotenv::dotenv();
260        let extras = ConfigExtras::from_env();
261        Self {
262            auth: AuthMode::ApiKey {
263                app_key: app_key.into(),
264                app_secret: app_secret.into(),
265                access_token: access_token.into(),
266            },
267            http_url: extras.http_url,
268            quote_ws_url: extras.quote_ws_url,
269            trade_ws_url: extras.trade_ws_url,
270            language: extras.language,
271            enable_overnight: extras.enable_overnight,
272            push_candlestick_mode: extras.push_candlestick_mode,
273            enable_print_quote_packages: extras.enable_print_quote_packages,
274            log_path: extras.log_path,
275            custom_headers: Default::default(),
276            enable_papertrading: extras.enable_papertrading,
277            quote_transport: extras.quote_transport,
278        }
279    }
280
281    /// Create a new `Config` for OAuth 2.0 authentication.
282    ///
283    /// All optional environment variables (`LONGBRIDGE_HTTP_URL`,
284    /// `LONGBRIDGE_LANGUAGE`, `LONGBRIDGE_QUOTE_WS_URL`,
285    /// `LONGBRIDGE_TRADE_WS_URL`, `LONGBRIDGE_ENABLE_OVERNIGHT`,
286    /// `LONGBRIDGE_PUSH_CANDLESTICK_MODE`, `LONGBRIDGE_QUOTE_TRANSPORT`,
287    /// `LONGBRIDGE_PRINT_QUOTE_PACKAGES`, `LONGBRIDGE_LOG_PATH`) are read from
288    /// the environment (or `.env` file) and applied automatically if set.
289    ///
290    /// # Arguments
291    ///
292    /// * `oauth` - An [`OAuth`] client obtained from
293    ///   [`longbridge::oauth::OAuthBuilder`].
294    ///
295    /// # Example
296    ///
297    /// ```rust,no_run
298    /// use std::sync::Arc;
299    ///
300    /// use longbridge::{Config, oauth::OAuthBuilder};
301    ///
302    /// #[tokio::main]
303    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
304    ///     let oauth = OAuthBuilder::new("your-client-id")
305    ///         .build(|url| println!("Visit: {url}"))
306    ///         .await?;
307    ///     let config = Arc::new(Config::from_oauth(oauth));
308    ///
309    ///     let (ctx, receiver) = longbridge::quote::QuoteContext::new(config);
310    ///     Ok(())
311    /// }
312    /// ```
313    pub fn from_oauth(oauth: OAuth) -> Self {
314        let _ = dotenv::dotenv();
315        let extras = ConfigExtras::from_env();
316        Self {
317            auth: AuthMode::OAuth(oauth),
318            http_url: extras.http_url,
319            quote_ws_url: extras.quote_ws_url,
320            trade_ws_url: extras.trade_ws_url,
321            language: extras.language,
322            enable_overnight: extras.enable_overnight,
323            push_candlestick_mode: extras.push_candlestick_mode,
324            enable_print_quote_packages: extras.enable_print_quote_packages,
325            log_path: extras.log_path,
326            custom_headers: Default::default(),
327            enable_papertrading: extras.enable_papertrading,
328            quote_transport: extras.quote_transport,
329        }
330    }
331
332    /// Create a new `Config` from environment variables (API Key
333    /// authentication).
334    ///
335    /// It first loads the environment variables from the `.env` file in the
336    /// current directory.
337    ///
338    /// # Variables
339    ///
340    /// - `LONGBRIDGE_APP_KEY` - App key
341    /// - `LONGBRIDGE_APP_SECRET` - App secret
342    /// - `LONGBRIDGE_ACCESS_TOKEN` - Access token
343    /// - `LONGBRIDGE_LANGUAGE` - Language identifier, `zh-CN`, `zh-HK` or `en`
344    ///   (Default: `en`)
345    /// - `LONGBRIDGE_HTTP_URL` - HTTP endpoint url (Default: `https://openapi.longbridge.com`)
346    /// - `LONGBRIDGE_QUOTE_WS_URL` - Quote websocket endpoint url (Default:
347    ///   `wss://openapi-quote.longbridge.com/v2`)
348    /// - `LONGBRIDGE_TRADE_WS_URL` - Trade websocket endpoint url (Default:
349    ///   `wss://openapi-trade.longbridge.com/v2`)
350    /// - `LONGBRIDGE_ENABLE_OVERNIGHT` - Enable overnight quote, `true` or
351    ///   `false` (Default: `false`)
352    /// - `LONGBRIDGE_PUSH_CANDLESTICK_MODE` - `realtime` or `confirmed`
353    ///   (Default: `realtime`)
354    /// - `LONGBRIDGE_QUOTE_TRANSPORT` - Transport for the quote pull APIs, `ws`
355    ///   or `http` (Default: `ws`)
356    /// - `LONGBRIDGE_PRINT_QUOTE_PACKAGES` - Print quote packages when
357    ///   connected, `true` or `false` (Default: `true`)
358    /// - `LONGBRIDGE_LOG_PATH` - Set the path of the log files (Default: `no
359    ///   logs`)
360    ///
361    /// For OAuth 2.0 authentication use [`from_oauth`](Config::from_oauth)
362    /// together with [`OAuthBuilder`](longbridge_oauth::OAuthBuilder).
363    pub fn from_apikey_env() -> Result<Self> {
364        let _ = dotenv::dotenv();
365
366        let app_key = env_var_required("APP_KEY")?;
367        let app_secret = env_var_required("APP_SECRET")?;
368        let access_token = env_var_required("ACCESS_TOKEN")?;
369        let extras = ConfigExtras::from_env();
370
371        Ok(Config {
372            auth: AuthMode::ApiKey {
373                app_key,
374                app_secret,
375                access_token,
376            },
377            http_url: extras.http_url,
378            quote_ws_url: extras.quote_ws_url,
379            trade_ws_url: extras.trade_ws_url,
380            language: extras.language,
381            enable_overnight: extras.enable_overnight,
382            push_candlestick_mode: extras.push_candlestick_mode,
383            enable_print_quote_packages: extras.enable_print_quote_packages,
384            log_path: extras.log_path,
385            custom_headers: Default::default(),
386            enable_papertrading: extras.enable_papertrading,
387            quote_transport: extras.quote_transport,
388        })
389    }
390
391    /// Specifies the url of the OpenAPI server.
392    ///
393    /// Default: `https://openapi.longbridge.com`
394    ///
395    /// NOTE: Usually you don't need to change it.
396    #[must_use]
397    pub fn http_url(mut self, url: impl Into<String>) -> Self {
398        self.http_url = Some(url.into());
399        self
400    }
401
402    /// Specifies the url of the OpenAPI quote websocket server.
403    ///
404    /// Default: `wss://openapi-quote.longbridge.com`
405    ///
406    /// NOTE: Usually you don't need to change it.
407    #[must_use]
408    pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
409        Self {
410            quote_ws_url: Some(url.into()),
411            ..self
412        }
413    }
414
415    /// Specifies the url of the OpenAPI trade websocket server.
416    ///
417    /// Default: `wss://openapi-trade.longbridge.com/v2`
418    ///
419    /// NOTE: Usually you don't need to change it.
420    #[must_use]
421    pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
422        Self {
423            trade_ws_url: Some(url.into()),
424            ..self
425        }
426    }
427
428    /// Specifies the language
429    ///
430    /// Default: `Language::EN`
431    pub fn language(self, language: Language) -> Self {
432        Self { language, ..self }
433    }
434
435    /// Enable overnight quote
436    ///
437    /// Default: `false`
438    pub fn enable_overnight(self) -> Self {
439        Self {
440            enable_overnight: Some(true),
441            ..self
442        }
443    }
444
445    /// Specifies the push candlestick mode
446    ///
447    /// Default: `PushCandlestickMode::Realtime`
448    pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
449        Self {
450            push_candlestick_mode: Some(mode),
451            ..self
452        }
453    }
454
455    /// Disable printing the opened quote packages when connected to the server.
456    pub fn dont_print_quote_packages(self) -> Self {
457        Self {
458            enable_print_quote_packages: false,
459            ..self
460        }
461    }
462
463    /// Enable paper trading mode.
464    ///
465    /// When enabled, all API calls target the paper trading (simulation)
466    /// environment.  The server validates the token: if it belongs to a
467    /// real-money account the server returns an error.
468    ///
469    /// By default this option is disabled (`false`): the server imposes no
470    /// restrictions and accepts requests from both paper trading and real-money
471    /// accounts.
472    ///
473    /// Paper trading users should enable this option as a safety guard to avoid
474    /// accidentally submitting orders against their real-money account.
475    pub fn enable_papertrading(mut self) -> Self {
476        self.enable_papertrading = true;
477        self
478    }
479
480    /// Specifies the transport used by the quote pull APIs.
481    ///
482    /// Default: `QuoteTransport::WebSocket` (or `LONGBRIDGE_QUOTE_TRANSPORT`
483    /// = `ws` / `http`)
484    pub fn quote_transport(self, quote_transport: QuoteTransport) -> Self {
485        Self {
486            quote_transport,
487            ..self
488        }
489    }
490
491    /// Create metadata for auth/reconnect request
492    pub fn create_metadata(&self) -> HashMap<String, String> {
493        let mut metadata = HashMap::new();
494        metadata.insert("accept-language".to_string(), self.language.to_string());
495        if self.enable_overnight.unwrap_or_default() {
496            metadata.insert("need_over_night_quote".to_string(), "true".to_string());
497        }
498        metadata
499    }
500
501    #[inline]
502    pub(crate) fn create_http_client(&self) -> HttpClient {
503        let mut config = match &self.auth {
504            AuthMode::ApiKey {
505                app_key,
506                app_secret,
507                access_token,
508            } => HttpClientConfig::from_apikey(app_key, app_secret, access_token),
509            AuthMode::OAuth(oauth) => HttpClientConfig::from_oauth(oauth.clone()),
510        };
511        if let Some(url) = &self.http_url {
512            config = config.http_url(url.clone());
513        }
514
515        let mut client =
516            HttpClient::new(config).header(header::ACCEPT_LANGUAGE, self.language.as_str());
517        for (key, value) in &self.custom_headers {
518            client = client.header(key.as_str(), value.as_str());
519        }
520        if self.enable_papertrading {
521            client = client.header("x-papertrading", "true");
522        }
523        client
524    }
525
526    /// Gets a new `access_token`
527    ///
528    /// This method is only available when using **Legacy API Key**
529    /// authentication (i.e. [`Config::from_apikey`]). It is not supported
530    /// for OAuth 2.0 mode.
531    ///
532    /// `expired_at` - The expiration time of the access token, defaults to `90`
533    /// days.
534    ///
535    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
536    pub async fn refresh_access_token(&self, expired_at: Option<OffsetDateTime>) -> Result<String> {
537        #[derive(Debug, Serialize)]
538        struct Request {
539            expired_at: String,
540        }
541
542        #[derive(Debug, Deserialize)]
543        struct Response {
544            token: String,
545        }
546
547        let request = Request {
548            expired_at: expired_at
549                .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(90))
550                .format(&time::format_description::well_known::Rfc3339)
551                .unwrap(),
552        };
553
554        let new_token = self
555            .create_http_client()
556            .request(Method::GET, "/v1/token/refresh")
557            .query_params(request)
558            .response::<Json<Response>>()
559            .send()
560            .await?
561            .0
562            .token;
563        Ok(new_token)
564    }
565
566    /// Gets a new `access_token`, and also replaces the `access_token` in
567    /// `Config`.
568    ///
569    /// This method is only available when using **Legacy API Key**
570    /// authentication (i.e. [`Config::from_apikey`]). It is not supported
571    /// for OAuth 2.0 mode.
572    ///
573    /// `expired_at` - The expiration time of the access token, defaults to `90`
574    /// days.
575    ///
576    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
577    #[cfg(feature = "blocking")]
578    #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
579    pub fn refresh_access_token_blocking(
580        &self,
581        expired_at: Option<OffsetDateTime>,
582    ) -> Result<String> {
583        tokio::runtime::Builder::new_current_thread()
584            .enable_all()
585            .build()
586            .expect("create tokio runtime")
587            .block_on(self.refresh_access_token(expired_at))
588    }
589
590    /// Resolve the data-center region from the auth credentials. For OAuth the
591    /// access token is resolved (and refreshed if needed); for legacy API-key
592    /// mode the `app_key`/`app_secret`/`access_token` prefixes are inspected.
593    async fn auth_dc_region(&self) -> DcRegion {
594        match &self.auth {
595            AuthMode::ApiKey {
596                app_key,
597                app_secret,
598                access_token,
599            } => DcRegion::from_credentials(&[app_key, access_token, app_secret]),
600            AuthMode::OAuth(oauth) => oauth
601                .access_token()
602                .await
603                .map(|token| DcRegion::from_credential(&token))
604                .unwrap_or(DcRegion::Ap),
605        }
606    }
607
608    fn create_ws_request(
609        &self,
610        url: &str,
611        dc_region: DcRegion,
612    ) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
613        let mut request = url.into_client_request()?;
614        request.headers_mut().append(
615            header::ACCEPT_LANGUAGE,
616            HeaderValue::from_str(self.language.as_str()).unwrap(),
617        );
618        for (key, value) in &self.custom_headers {
619            if let (Ok(name), Ok(val)) = (
620                HeaderName::from_bytes(key.as_bytes()),
621                HeaderValue::from_str(value),
622            ) {
623                request.headers_mut().append(name, val);
624            }
625        }
626        // Route the upgrade to the data center matching the credential's
627        // region, unless the caller already set the header via a custom
628        // header.
629        if !self
630            .custom_headers
631            .keys()
632            .any(|key| key.eq_ignore_ascii_case(DC_REGION_HEADER))
633        {
634            request.headers_mut().append(
635                HeaderName::from_static(DC_REGION_HEADER),
636                HeaderValue::from_static(dc_region.as_str()),
637            );
638        }
639        Ok(request)
640    }
641
642    pub(crate) async fn create_quote_ws_request(
643        &self,
644    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
645        let dc_region = self.auth_dc_region().await;
646        match self.quote_ws_url.as_deref() {
647            Some(url) => (url, self.create_ws_request(url, dc_region)),
648            None => {
649                let url = if is_cn().await {
650                    DEFAULT_QUOTE_WS_URL_CN
651                } else {
652                    DEFAULT_QUOTE_WS_URL
653                };
654                (url, self.create_ws_request(url, dc_region))
655            }
656        }
657    }
658
659    pub(crate) async fn create_trade_ws_request(
660        &self,
661    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
662        let dc_region = self.auth_dc_region().await;
663        match self.trade_ws_url.as_deref() {
664            Some(url) => (url, self.create_ws_request(url, dc_region)),
665            None => {
666                let url = if is_cn().await {
667                    DEFAULT_TRADE_WS_URL_CN
668                } else {
669                    DEFAULT_TRADE_WS_URL
670                };
671                (url, self.create_ws_request(url, dc_region))
672            }
673        }
674    }
675
676    /// Specifies the path of the log file
677    ///
678    /// Default: `None`
679    pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
680        self.log_path = Some(path.into());
681        self
682    }
683
684    /// Add a custom header to every HTTP request and WebSocket upgrade request.
685    #[must_use]
686    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
687        self.custom_headers.insert(key.into(), value.into());
688        self
689    }
690
691    /// Override the data-center region for every HTTP and WebSocket request by
692    /// setting the [`DC_REGION_HEADER`](crate::DC_REGION_HEADER) explicitly.
693    ///
694    /// This is rarely needed: the region is otherwise derived automatically
695    /// from the auth credentials' prefix (`us_`/`ap_`). Use this only to
696    /// force a specific data center regardless of the credential.
697    #[must_use]
698    pub fn dc_region(self, region: DcRegion) -> Self {
699        self.header(DC_REGION_HEADER, region.as_str())
700    }
701
702    /// Set the HTTP endpoint URL in place.
703    pub fn set_http_url(&mut self, url: impl Into<String>) {
704        self.http_url = Some(url.into());
705    }
706
707    /// Set the quote websocket endpoint URL in place.
708    pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
709        self.quote_ws_url = Some(url.into());
710    }
711
712    /// Set the trade websocket endpoint URL in place.
713    pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
714        self.trade_ws_url = Some(url.into());
715    }
716
717    /// Set the language in place.
718    pub fn set_language(&mut self, language: Language) {
719        self.language = language;
720    }
721
722    /// Enable overnight quote in place.
723    pub fn set_enable_overnight(&mut self) {
724        self.enable_overnight = Some(true);
725    }
726
727    /// Set the push candlestick mode in place.
728    pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
729        self.push_candlestick_mode = Some(mode);
730    }
731
732    /// Disable printing quote packages in place.
733    pub fn set_dont_print_quote_packages(&mut self) {
734        self.enable_print_quote_packages = false;
735    }
736
737    /// Enable paper trading mode in place.
738    ///
739    /// See [`Config::enable_papertrading`] for full semantics.
740    pub fn set_enable_papertrading(&mut self) {
741        self.enable_papertrading = true;
742    }
743
744    /// Set the quote transport in place.
745    ///
746    /// See [`Config::quote_transport`] for full semantics.
747    pub fn set_quote_transport(&mut self, quote_transport: QuoteTransport) {
748        self.quote_transport = quote_transport;
749    }
750
751    /// Set the log path in place.
752    pub fn set_log_path(&mut self, path: impl Into<PathBuf>) {
753        self.log_path = Some(path.into());
754    }
755
756    pub(crate) fn create_log_subscriber(
757        &self,
758        path: impl AsRef<Path>,
759    ) -> Arc<dyn Subscriber + Send + Sync> {
760        fn internal_create_log_subscriber(
761            config: &Config,
762            path: impl AsRef<Path>,
763        ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
764            let log_path = config.log_path.as_ref()?;
765            let appender = RollingFileAppender::builder()
766                .rotation(Rotation::DAILY)
767                .filename_suffix("log")
768                .build(log_path.join(path))
769                .ok()?;
770            Some(Arc::new(
771                tracing_subscriber::fmt()
772                    .with_writer(appender)
773                    .with_ansi(false)
774                    .finish()
775                    .with(Targets::new().with_targets([("longbridge", Level::INFO)])),
776            ))
777        }
778
779        internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn test_config_from_apikey() {
789        let config = Config::from_apikey("app-key", "app-secret", "token");
790        assert_eq!(config.language, Language::EN);
791        match &config.auth {
792            AuthMode::ApiKey {
793                app_key,
794                app_secret,
795                access_token,
796            } => {
797                assert_eq!(app_key, "app-key");
798                assert_eq!(app_secret, "app-secret");
799                assert_eq!(access_token, "token");
800            }
801            _ => panic!("Expected ApiKey auth mode"),
802        }
803    }
804
805    #[test]
806    fn test_config_default_values() {
807        let config = Config::from_apikey("key", "secret", "token");
808
809        // Fields not controlled by environment variables
810        assert_eq!(config.enable_overnight, None);
811        assert_eq!(config.push_candlestick_mode, None);
812        assert!(config.enable_print_quote_packages);
813    }
814
815    #[test]
816    fn test_enable_papertrading_builder() {
817        let config = Config::from_apikey("key", "secret", "token").enable_papertrading();
818        assert!(config.enable_papertrading);
819    }
820
821    #[test]
822    fn test_enable_papertrading_setter() {
823        let mut config = Config::from_apikey("key", "secret", "token");
824        config.set_enable_papertrading();
825        assert!(config.enable_papertrading);
826    }
827}