Skip to main content

futucli/cmd/analysis/
trading.rs

1//! v1.4.110+ split (from cmd/analysis.rs): trading domain.
2//!
3//! pub items: parse helpers, run_trading_days, run_rehab, run_suspend.
4
5use anyhow::{Result, anyhow, bail};
6use prost::Message;
7use serde::Serialize;
8use tabled::Tabled;
9
10use crate::common::{connect_gateway, parse_symbol};
11use crate::output::OutputFormat;
12
13// ============================================================
14// v1.4.30: trading-days / rehab / suspend / user-security /
15//          user-security-groups / warrant / ipo-list / future-info /
16//          stock-filter
17// ============================================================
18
19/// `--market` 字符串 / 数字 → `Qot_Common.TradeDateMarket` 枚举 int.
20pub(super) fn parse_trade_date_market(s: &str) -> Result<i32> {
21    if let Ok(value) = s.trim().parse::<i32>() {
22        return match value {
23            1..=10 | 11 | 21 | 22 => Ok(value),
24            _ => bail!(
25                "unknown trade-date market {value} \
26                 (valid TradeDateMarket = 1..=10; legacy aliases = 11|21|22)"
27            ),
28        };
29    }
30
31    Ok(match s.trim().to_ascii_uppercase().as_str() {
32        "HK" => 1,
33        "US" => 2,
34        "CN" => 3,
35        "NT" => 4, // Northbound (SZ/SH 股通)
36        "ST" => 5, // Southbound (HK 股通)
37        "JP_FUTURE" | "JPFUTURE" | "JPF" => 6,
38        "SG_FUTURE" | "SGFUTURE" | "SGF" => 7,
39        "SG" | "SG_STOCK" | "SG_SECURITY" => 8,
40        "MY" | "MY_STOCK" | "MY_SECURITY" => 9,
41        "JP" | "JP_STOCK" | "JP_SECURITY" => 10,
42        other => bail!(
43            "unknown trade-date market {other:?} \
44             (HK|US|CN|NT|ST|JP_FUTURE|SG_FUTURE|SG|MY|JP)"
45        ),
46    })
47}
48
49/// `--market` 字符串 / 数字 → C++ StockFilter endpoint-local market bucket。
50pub(super) fn parse_qot_market(s: &str) -> Result<i32> {
51    let value = parse_qot_market_value(s)?;
52    match value {
53        1 | 2 | 11 | 21 | 22 | 31 | 41 | 61 => Ok(value),
54        _ => bail!("unknown qot market {value} (valid = 1|2|11|21|22|31|41|61)"),
55    }
56}
57
58/// `--market` 字符串 / 数字 → C++ GetIpoList endpoint-local market bucket.
59pub(super) fn parse_ipo_market(s: &str) -> Result<i32> {
60    let value = parse_qot_market_value(s)?;
61    match value {
62        1 | 2 | 11 | 21 | 22 | 31 | 41 | 61 => Ok(value),
63        _ => bail!("unknown IPO market {value} (valid = 1|2|11|21|22|31|41|61)"),
64    }
65}
66
67fn parse_qot_market_value(s: &str) -> Result<i32> {
68    if let Ok(value) = s.trim().parse::<i32>() {
69        return Ok(value);
70    }
71
72    Ok(match s.trim().to_ascii_uppercase().as_str() {
73        "HK" => 1,
74        "HK_FUTURE" | "HKFUTURE" => 2,
75        "US" => 11,
76        "CN" | "SH" => 21, // 沪 + 深不区分,按 proto 文档 A 股整体返回
77        "SZ" => 22,
78        "SG" => 31,
79        "JP" => 41,
80        "MY" => 61,
81        other => bail!("unknown qot market {other:?} (HK|HK_FUTURE|US|CN|SH|SZ|SG|JP|MY)"),
82    })
83}
84
85#[derive(Tabled)]
86struct TradingDayRow {
87    #[tabled(rename = "Time")]
88    time: String,
89    #[tabled(rename = "Type")]
90    type_label: String,
91}
92
93#[derive(Serialize)]
94struct TradingDayJson {
95    time: String,
96    trade_date_type: i32,
97}
98
99pub async fn run_trading_days(
100    gateway: &str,
101    market: &str,
102    begin: &str,
103    end: &str,
104    format: OutputFormat,
105) -> Result<()> {
106    let m = parse_trade_date_market(market)?;
107    let (client, _rx) = connect_gateway(gateway, "futucli-trading-days").await?;
108    let req = futu_proto::qot_request_trade_date::Request {
109        c2s: futu_proto::qot_request_trade_date::C2s {
110            market: m,
111            begin_time: begin.to_string(),
112            end_time: end.to_string(),
113            security: None,
114            header: None,
115        },
116    };
117    let body = req.encode_to_vec();
118    let frame = client
119        .request(futu_core::proto_id::QOT_REQUEST_TRADE_DATE, body)
120        .await?;
121    let resp = futu_proto::qot_request_trade_date::Response::decode(frame.body.as_ref())
122        .map_err(|e| anyhow!("decode trading_days: {e}"))?;
123    if resp.ret_type != 0 {
124        bail!(
125            "trading_days ret_type={} msg={:?}",
126            resp.ret_type,
127            resp.ret_msg
128        );
129    }
130    let s2c = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
131    let mut rows = Vec::new();
132    let mut jsons = Vec::new();
133    for t in &s2c.trade_date_list {
134        let ty = t.trade_date_type.unwrap_or(0);
135        rows.push(TradingDayRow {
136            time: t.time.clone(),
137            type_label: match ty {
138                0 => "Whole".to_string(),
139                1 => "Morning".to_string(),
140                2 => "Afternoon".to_string(),
141                _ => format!("({ty})"),
142            },
143        });
144        jsons.push(TradingDayJson {
145            time: t.time.clone(),
146            trade_date_type: ty,
147        });
148    }
149    format.print_rows(&rows, &jsons)?;
150    Ok(())
151}
152
153#[derive(Tabled)]
154struct RehabRow {
155    #[tabled(rename = "Time")]
156    time: String,
157    #[tabled(rename = "FwdA")]
158    fwd_a: String,
159    #[tabled(rename = "FwdB")]
160    fwd_b: String,
161    #[tabled(rename = "BwdA")]
162    bwd_a: String,
163    #[tabled(rename = "Dividend")]
164    dividend: String,
165    #[tabled(rename = "ActFlag")]
166    act_flag: i64,
167}
168
169#[derive(Serialize)]
170struct RehabJson {
171    time: String,
172    fwd_factor_a: f64,
173    fwd_factor_b: f64,
174    bwd_factor_a: f64,
175    bwd_factor_b: f64,
176    company_act_flag: i64,
177    dividend: Option<f64>,
178    sp_dividend: Option<f64>,
179    bonus: Option<(i32, i32)>,
180    transfer: Option<(i32, i32)>,
181    allot: Option<(i32, i32, Option<f64>)>,
182}
183
184pub async fn run_rehab(gateway: &str, symbol: &str, format: OutputFormat) -> Result<()> {
185    let sec = parse_symbol(symbol)?;
186    let (client, _rx) = connect_gateway(gateway, "futucli-rehab").await?;
187    let s2c = futu_qot::market_misc::get_rehab(&client, &sec).await?;
188    let mut rows = Vec::new();
189    let mut jsons = Vec::new();
190    for r in &s2c.rehab_list {
191        rows.push(RehabRow {
192            time: r.time.clone(),
193            fwd_a: format!("{:.6}", r.fwd_factor_a),
194            fwd_b: format!("{:.6}", r.fwd_factor_b),
195            bwd_a: format!("{:.6}", r.bwd_factor_a),
196            dividend: r
197                .dividend
198                .map(|d| format!("{d:.4}"))
199                .unwrap_or_else(|| "-".to_string()),
200            act_flag: r.company_act_flag,
201        });
202        jsons.push(RehabJson {
203            time: r.time.clone(),
204            fwd_factor_a: r.fwd_factor_a,
205            fwd_factor_b: r.fwd_factor_b,
206            bwd_factor_a: r.bwd_factor_a,
207            bwd_factor_b: r.bwd_factor_b,
208            company_act_flag: r.company_act_flag,
209            dividend: r.dividend,
210            sp_dividend: r.sp_dividend,
211            bonus: r.bonus_base.zip(r.bonus_ert),
212            transfer: r.transfer_base.zip(r.transfer_ert),
213            allot: r
214                .allot_base
215                .zip(r.allot_ert)
216                .map(|(b, e)| (b, e, r.allot_price)),
217        });
218    }
219    format.print_rows(&rows, &jsons)?;
220    Ok(())
221}
222
223#[derive(Tabled)]
224struct SuspendRow {
225    #[tabled(rename = "Symbol")]
226    symbol: String,
227    #[tabled(rename = "Suspend Days")]
228    days: String,
229}
230
231#[derive(Serialize)]
232struct SuspendJson {
233    symbol: String,
234    suspend_days: Vec<String>,
235}
236
237pub async fn run_suspend(
238    gateway: &str,
239    symbols: &[String],
240    begin: &str,
241    end: &str,
242    format: OutputFormat,
243) -> Result<()> {
244    if symbols.is_empty() {
245        bail!("no symbols");
246    }
247    let secs: Vec<_> = symbols
248        .iter()
249        .map(|s| parse_symbol(s))
250        .collect::<Result<Vec<_>>>()?;
251    let (client, _rx) = connect_gateway(gateway, "futucli-suspend").await?;
252    let s2c = futu_qot::market_misc::get_suspend(&client, &secs, begin, end).await?;
253    let mut rows = Vec::new();
254    let mut jsons = Vec::new();
255    for ss in &s2c.security_suspend_list {
256        let sym = format!("{}.{}", ss.security.market, ss.security.code);
257        let days: Vec<String> = ss.suspend_list.iter().map(|s| s.time.clone()).collect();
258        rows.push(SuspendRow {
259            symbol: sym.clone(),
260            days: if days.is_empty() {
261                "-".into()
262            } else {
263                days.join(", ")
264            },
265        });
266        jsons.push(SuspendJson {
267            symbol: sym,
268            suspend_days: days,
269        });
270    }
271    format.print_rows(&rows, &jsons)?;
272    Ok(())
273}