1use std::{
2 collections::HashMap,
3 fmt::{self, Display},
4 path::{Path, PathBuf},
5 str::FromStr,
6 sync::Arc,
7};
8
9use http::Method;
10pub(crate) use http::{HeaderValue, Request, header};
11use longport_httpcli::{HttpClient, HttpClientConfig, Json, is_cn};
12use num_enum::IntoPrimitive;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use tokio_tungstenite::tungstenite::client::IntoClientRequest;
16use tracing::{Level, Subscriber, subscriber::NoSubscriber};
17use tracing_appender::rolling::{RollingFileAppender, Rotation};
18use tracing_subscriber::{filter::Targets, layer::SubscriberExt};
19
20use crate::error::Result;
21
22const DEFAULT_QUOTE_WS_URL: &str = "wss://openapi-quote.longportapp.com/v2";
23const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longportapp.com/v2";
24const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longportapp.cn/v2";
25const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longportapp.cn/v2";
26
27#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
29#[allow(non_camel_case_types)]
30#[repr(i32)]
31pub enum Language {
32 ZH_CN = 0,
34 ZH_HK = 2,
36 #[default]
38 EN = 1,
39}
40
41impl Language {
42 pub(crate) fn as_str(&self) -> &'static str {
43 match self {
44 Language::ZH_CN => "zh-CN",
45 Language::ZH_HK => "zh-HK",
46 Language::EN => "en",
47 }
48 }
49}
50
51impl FromStr for Language {
52 type Err = ();
53
54 fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
55 match s {
56 "zh-CN" => Ok(Language::ZH_CN),
57 "zh-HK" => Ok(Language::ZH_HK),
58 "en" => Ok(Language::EN),
59 _ => Err(()),
60 }
61 }
62}
63
64impl Display for Language {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str(self.as_str())
67 }
68}
69
70#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
72pub enum PushCandlestickMode {
73 #[default]
75 Realtime,
76 Confirmed,
78}
79
80#[derive(Debug, Clone)]
82pub struct Config {
83 pub(crate) http_cli_config: HttpClientConfig,
84 pub(crate) quote_ws_url: Option<String>,
85 pub(crate) trade_ws_url: Option<String>,
86 pub(crate) enable_overnight: Option<bool>,
87 pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
88 pub(crate) enable_print_quote_packages: bool,
89 pub(crate) language: Language,
90 pub(crate) log_path: Option<PathBuf>,
91}
92
93impl Config {
94 pub fn new(
96 app_key: impl Into<String>,
97 app_secret: impl Into<String>,
98 access_token: impl Into<String>,
99 ) -> Self {
100 Self {
101 http_cli_config: HttpClientConfig::new(app_key, app_secret, access_token),
102 quote_ws_url: None,
103 trade_ws_url: None,
104 language: Language::EN,
105 enable_overnight: None,
106 push_candlestick_mode: None,
107 enable_print_quote_packages: true,
108 log_path: None,
109 }
110 }
111
112 pub fn from_env() -> Result<Self> {
138 let _ = dotenv::dotenv();
139
140 let http_cli_config = HttpClientConfig::from_env()?;
141 let language = std::env::var("LONGPORT_LANGUAGE")
142 .ok()
143 .and_then(|value| value.parse::<Language>().ok())
144 .unwrap_or(Language::EN);
145 let quote_ws_url = std::env::var("LONGPORT_QUOTE_WS_URL").ok();
146 let trade_ws_url = std::env::var("LONGPORT_TRADE_WS_URL").ok();
147 let enable_overnight = std::env::var("LONGPORT_ENABLE_OVERNIGHT")
148 .map(|value| value == "true")
149 .ok();
150 let push_candlestick_mode = std::env::var("LONGPORT_PUSH_CANDLESTICK_MODE")
151 .map(|value| match value.as_str() {
152 "confirmed" => PushCandlestickMode::Confirmed,
153 _ => PushCandlestickMode::Realtime,
154 })
155 .ok();
156 let enable_print_quote_packages = std::env::var("LONGPORT_PRINT_QUOTE_PACKAGES")
157 .as_deref()
158 .unwrap_or("true")
159 == "true";
160 let log_path = std::env::var("LONGPORT_LOG_PATH").ok().map(PathBuf::from);
161
162 Ok(Config {
163 http_cli_config,
164 quote_ws_url,
165 trade_ws_url,
166 language,
167 enable_overnight,
168 push_candlestick_mode,
169 enable_print_quote_packages,
170 log_path,
171 })
172 }
173
174 #[must_use]
180 pub fn http_url(mut self, url: impl Into<String>) -> Self {
181 self.http_cli_config = self.http_cli_config.http_url(url);
182 self
183 }
184
185 #[must_use]
191 pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
192 Self {
193 quote_ws_url: Some(url.into()),
194 ..self
195 }
196 }
197
198 #[must_use]
204 pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
205 Self {
206 trade_ws_url: Some(url.into()),
207 ..self
208 }
209 }
210
211 pub fn language(self, language: Language) -> Self {
215 Self { language, ..self }
216 }
217
218 pub fn enable_overnight(self) -> Self {
222 Self {
223 enable_overnight: Some(true),
224 ..self
225 }
226 }
227
228 pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
232 Self {
233 push_candlestick_mode: Some(mode),
234 ..self
235 }
236 }
237
238 pub fn dont_print_quote_packages(self) -> Self {
240 Self {
241 enable_print_quote_packages: false,
242 ..self
243 }
244 }
245
246 pub fn create_metadata(&self) -> HashMap<String, String> {
248 let mut metadata = HashMap::new();
249 metadata.insert("accept-language".to_string(), self.language.to_string());
250 if self.enable_overnight.unwrap_or_default() {
251 metadata.insert("need_over_night_quote".to_string(), "true".to_string());
252 }
253 metadata
254 }
255
256 #[inline]
257 pub(crate) fn create_http_client(&self) -> HttpClient {
258 HttpClient::new(self.http_cli_config.clone())
259 .header(header::ACCEPT_LANGUAGE, self.language.as_str())
260 }
261
262 fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
263 let mut request = url.into_client_request()?;
264 request.headers_mut().append(
265 header::ACCEPT_LANGUAGE,
266 HeaderValue::from_str(self.language.as_str()).unwrap(),
267 );
268 Ok(request)
269 }
270
271 pub(crate) async fn create_quote_ws_request(
272 &self,
273 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
274 match self.quote_ws_url.as_deref() {
275 Some(url) => (url, self.create_ws_request(url)),
276 None => {
277 let url = if is_cn().await {
278 DEFAULT_QUOTE_WS_URL_CN
279 } else {
280 DEFAULT_QUOTE_WS_URL
281 };
282 (url, self.create_ws_request(url))
283 }
284 }
285 }
286
287 pub(crate) async fn create_trade_ws_request(
288 &self,
289 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
290 match self.trade_ws_url.as_deref() {
291 Some(url) => (url, self.create_ws_request(url)),
292 None => {
293 let url = if is_cn().await {
294 DEFAULT_TRADE_WS_URL_CN
295 } else {
296 DEFAULT_TRADE_WS_URL
297 };
298 (url, self.create_ws_request(url))
299 }
300 }
301 }
302
303 pub async fn refresh_access_token(&self, expired_at: Option<OffsetDateTime>) -> Result<String> {
310 #[derive(Debug, Serialize)]
311 struct Request {
312 expired_at: String,
313 }
314
315 #[derive(Debug, Deserialize)]
316 struct Response {
317 token: String,
318 }
319
320 let request = Request {
321 expired_at: expired_at
322 .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(90))
323 .format(&time::format_description::well_known::Rfc3339)
324 .unwrap(),
325 };
326
327 let new_token = self
328 .create_http_client()
329 .request(Method::GET, "/v1/token/refresh")
330 .query_params(request)
331 .response::<Json<Response>>()
332 .send()
333 .await?
334 .0
335 .token;
336 Ok(new_token)
337 }
338
339 #[cfg(feature = "blocking")]
347 #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
348 pub fn refresh_access_token_blocking(
349 &self,
350 expired_at: Option<OffsetDateTime>,
351 ) -> Result<String> {
352 tokio::runtime::Builder::new_current_thread()
353 .enable_all()
354 .build()
355 .expect("create tokio runtime")
356 .block_on(self.refresh_access_token(expired_at))
357 }
358
359 pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
363 self.log_path = Some(path.into());
364 self
365 }
366
367 pub(crate) fn create_log_subscriber(
368 &self,
369 path: impl AsRef<Path>,
370 ) -> Arc<dyn Subscriber + Send + Sync> {
371 fn internal_create_log_subscriber(
372 config: &Config,
373 path: impl AsRef<Path>,
374 ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
375 let log_path = config.log_path.as_ref()?;
376 let appender = RollingFileAppender::builder()
377 .rotation(Rotation::DAILY)
378 .filename_suffix("log")
379 .build(log_path.join(path))
380 .ok()?;
381 Some(Arc::new(
382 tracing_subscriber::fmt()
383 .with_writer(appender)
384 .with_ansi(false)
385 .finish()
386 .with(Targets::new().with_targets([("longport", Level::INFO)])),
387 ))
388 }
389
390 internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
391 }
392}