Skip to main content

futucli/
common.rs

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