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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
31#[allow(non_camel_case_types)]
32#[repr(i32)]
33pub enum Language {
34 ZH_CN = 0,
36 ZH_HK = 2,
38 #[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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
74pub enum PushCandlestickMode {
75 #[default]
77 Realtime,
78 Confirmed,
80}
81
82pub(crate) enum AuthMode {
84 ApiKey {
86 app_key: String,
87 app_secret: String,
88 access_token: String,
89 },
90 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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
133pub enum QuoteTransport {
134 #[default]
136 WebSocket,
137 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#[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 pub(crate) custom_headers: HashMap<String, String>,
169 pub(crate) enable_papertrading: bool,
170 pub(crate) quote_transport: QuoteTransport,
171}
172
173fn 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
181fn 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
191struct 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 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 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 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 #[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 #[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 #[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 pub fn language(self, language: Language) -> Self {
432 Self { language, ..self }
433 }
434
435 pub fn enable_overnight(self) -> Self {
439 Self {
440 enable_overnight: Some(true),
441 ..self
442 }
443 }
444
445 pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
449 Self {
450 push_candlestick_mode: Some(mode),
451 ..self
452 }
453 }
454
455 pub fn dont_print_quote_packages(self) -> Self {
457 Self {
458 enable_print_quote_packages: false,
459 ..self
460 }
461 }
462
463 pub fn enable_papertrading(mut self) -> Self {
476 self.enable_papertrading = true;
477 self
478 }
479
480 pub fn quote_transport(self, quote_transport: QuoteTransport) -> Self {
485 Self {
486 quote_transport,
487 ..self
488 }
489 }
490
491 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 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 #[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 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 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 pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
680 self.log_path = Some(path.into());
681 self
682 }
683
684 #[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 #[must_use]
698 pub fn dc_region(self, region: DcRegion) -> Self {
699 self.header(DC_REGION_HEADER, region.as_str())
700 }
701
702 pub fn set_http_url(&mut self, url: impl Into<String>) {
704 self.http_url = Some(url.into());
705 }
706
707 pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
709 self.quote_ws_url = Some(url.into());
710 }
711
712 pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
714 self.trade_ws_url = Some(url.into());
715 }
716
717 pub fn set_language(&mut self, language: Language) {
719 self.language = language;
720 }
721
722 pub fn set_enable_overnight(&mut self) {
724 self.enable_overnight = Some(true);
725 }
726
727 pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
729 self.push_candlestick_mode = Some(mode);
730 }
731
732 pub fn set_dont_print_quote_packages(&mut self) {
734 self.enable_print_quote_packages = false;
735 }
736
737 pub fn set_enable_papertrading(&mut self) {
741 self.enable_papertrading = true;
742 }
743
744 pub fn set_quote_transport(&mut self, quote_transport: QuoteTransport) {
748 self.quote_transport = quote_transport;
749 }
750
751 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 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}