Skip to main content

futucli/
common.rs

1//! 共享基础设施:symbol 解析、网关连接、错误格式化
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{Context, Result, anyhow, bail};
7use futu_core::{qot_subscription, qot_symbol};
8use futu_net::client::{ClientConfig, FutuClient, PushReceiver, ReconnectingClient};
9use futu_net::reconnect::ReconnectPolicy;
10use futu_qot::types::{Security, SubType};
11
12use crate::qot_sdk_adapter;
13
14// CLI commands are one-shot automation surfaces, unlike the daemon's long-lived
15// reconnect loop. Keep gateway connection attempts bounded so CI/agents can
16// decide from exit status and structured output instead of wrapping futucli in
17// an external timeout.
18//
19// External v1.4.113 verification reproduced `max retries reached after 1 attempts`
20// during real-account automation. These are client reliability budgets, not
21// Futu protocol constants: enough to ride out a busy daemon accept/InitConnect
22// window, still short enough for scripts to fail fast when the gateway is dead.
23const CLI_CONNECT_TOTAL_TIMEOUT: Duration = Duration::from_secs(6);
24const CLI_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(200);
25const CLI_CONNECT_MAX_RETRIES: u32 = 4;
26
27pub fn parse_symbol(s: &str) -> Result<Security> {
28    let parsed = qot_symbol::parse_qot_symbol_parts(s).map_err(|err| anyhow!("{err}"))?;
29    Ok(qot_sdk_adapter::security_from_parsed_symbol(parsed))
30}
31
32/// 格式化 Security 为 "MARKET.CODE"
33pub fn format_symbol(sec: &Security) -> String {
34    qot_symbol::format_qot_symbol(sec.market as i32, &sec.code)
35}
36
37/// 解析订阅类型字符串
38pub fn parse_sub_type(s: &str) -> Result<SubType> {
39    let Some(t) = qot_subscription::qot_sub_type_from_str_alias(s)
40        .and_then(qot_sdk_adapter::sub_type_from_id)
41    else {
42        let other = s.trim().to_ascii_lowercase();
43        bail!(
44            "unknown sub type {other:?} (supported: basic, orderbook, orderbook_odd, ticker, rt, kl_day, kl_1min, kl_10min, kl_120min, ...)"
45        );
46    };
47    Ok(t)
48}
49
50/// 拆分逗号分隔的订阅类型列表
51pub fn parse_sub_types(csv: &str) -> Result<Vec<SubType>> {
52    csv.split(',')
53        .map(parse_sub_type)
54        .collect::<Result<Vec<_>>>()
55}
56
57/// v1.4.106 codex 0641 F6 (P3): 拆分逗号分隔的 symbol 列表,**整体 reject** 空 token。
58///
59/// 之前各 CLI 命令 (`market-state` / `owner-plate` / `suspend` / `future-info` /
60/// `margin-ratio` 等) 都用 `s.split(',').map(trim).collect()` 直接展开,
61/// 三种 silent-success 风险:
62/// 1. `""` 整串输入 → `[""]` 单元素空字符串列表 (downstream 可能 silent fallback)
63/// 2. `"a,,b"` 中间空 token → `["a", "", "b"]` (\"\" 项被当 symbol 发到 daemon)
64/// 3. `"a,"` 末尾空 token → `["a", ""]` 同上
65///
66/// 本 helper 整体 reject 这三种情况, 让用户看到清晰错误而非 silent miss.
67///
68/// **整体语义**: 任一 token 为空 / 整串为空 → 整体 fail. 不 filter / 不 silent.
69pub fn parse_symbol_csv(s: &str) -> Result<Vec<String>> {
70    let trimmed = s.trim();
71    if trimmed.is_empty() {
72        bail!("CSV symbol 列表为空: 必须传入非空 \"MARKET.CODE\" 列表");
73    }
74    let mut out: Vec<String> = Vec::new();
75    for (i, token) in trimmed.split(',').enumerate() {
76        let t = token.trim();
77        if t.is_empty() {
78            bail!("CSV symbol[{i}] 为空 token (输入 \"{s}\"): 整体 reject, 不 silent skip 空项");
79        }
80        out.push(t.to_string());
81    }
82    Ok(out)
83}
84
85/// 连接网关
86///
87/// 返回 (client, push_rx);调用方负责在需要订阅推送时消费 push_rx。
88pub async fn connect_gateway(
89    addr: &str,
90    client_id: &str,
91) -> Result<(Arc<FutuClient>, PushReceiver)> {
92    let config = ClientConfig {
93        addr: addr.to_string(),
94        client_ver: env!("CARGO_PKG_VERSION").to_string(),
95        client_id: client_id.to_string(),
96        recv_notify: false,
97        rsa_key: None,
98    };
99    let policy = ReconnectPolicy::new(
100        CLI_CONNECT_RETRY_DELAY,
101        CLI_CONNECT_RETRY_DELAY,
102        Some(CLI_CONNECT_MAX_RETRIES),
103    );
104    let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
105    let connect_result =
106        tokio::time::timeout(CLI_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
107    let (client, push_rx, _info) = match connect_result {
108        Ok(result) => result.with_context(|| format!("connect to futu gateway at {addr}"))?,
109        Err(_) => bail!(
110            "connect to futu gateway at {addr} timed out after {}s",
111            CLI_CONNECT_TOTAL_TIMEOUT.as_secs()
112        ),
113    };
114    Ok((Arc::new(client), push_rx))
115}
116
117#[cfg(test)]
118mod tests;