Skip to main content

futucli/cmd/sys/
quote_rights.rs

1use std::sync::Arc;
2
3use anyhow::{Result, anyhow, bail};
4use futu_net::client::FutuClient;
5use futu_qot::quote_rights::{
6    QuoteCapabilityReport, QuoteRightsProfile, SYS_QUERY_GET_QUOTE_CAPABILITY,
7    SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
8};
9use prost::Message;
10use tabled::{Table, Tabled, settings::Style};
11
12use crate::common::connect_gateway;
13use crate::output::OutputFormat;
14
15#[derive(Tabled)]
16pub(super) struct QuoteRightsInfoRow {
17    #[tabled(rename = "字段")]
18    pub(super) field: String,
19    #[tabled(rename = "值")]
20    pub(super) value: String,
21}
22
23#[derive(Tabled)]
24pub(super) struct QuoteRightsRow {
25    #[tabled(rename = "市场")]
26    pub(super) market: String,
27    #[tabled(rename = "品类")]
28    pub(super) category: String,
29    #[tabled(rename = "权限")]
30    pub(super) value: String,
31}
32
33#[derive(Tabled)]
34struct QuoteCapabilityRow {
35    #[tabled(rename = "字段")]
36    field: String,
37    #[tabled(rename = "值")]
38    value: String,
39}
40
41pub(super) fn quote_right_user_rows(profile: &QuoteRightsProfile) -> Vec<QuoteRightsInfoRow> {
42    let mut rows = Vec::with_capacity(3);
43    rows.push(QuoteRightsInfoRow {
44        field: "昵称".to_string(),
45        value: profile
46            .nick_name
47            .clone()
48            .unwrap_or_else(|| "未知".to_string()),
49    });
50    rows.push(QuoteRightsInfoRow {
51        field: "牛牛号".to_string(),
52        value: profile
53            .user_id
54            .map(|v| v.to_string())
55            .unwrap_or_else(|| "未知".to_string()),
56    });
57    rows.push(QuoteRightsInfoRow {
58        field: "注册归属地".to_string(),
59        value: match (&profile.user_attribution_region, profile.user_attribution) {
60            (Some(region), Some(raw)) => format!("{region} ({raw})"),
61            (Some(region), None) => region.clone(),
62            (None, Some(raw)) => raw.to_string(),
63            (None, None) => "未知".to_string(),
64        },
65    });
66
67    rows
68}
69
70pub(super) fn quote_right_quota_rows(profile: &QuoteRightsProfile) -> Vec<QuoteRightsInfoRow> {
71    let mut rows = Vec::with_capacity(3);
72    rows.push(QuoteRightsInfoRow {
73        field: "已用订阅额度/总额".to_string(),
74        value: profile
75            .quota
76            .subscribe_total
77            .map(|v| format!("0/{v}"))
78            .unwrap_or_else(|| "未知".to_string()),
79    });
80    rows.push(QuoteRightsInfoRow {
81        field: "已用历史K线额度/总额".to_string(),
82        value: profile
83            .quota
84            .history_kl_total
85            .map(|v| format!("0/{v}"))
86            .unwrap_or_else(|| "未知".to_string()),
87    });
88    if let Some(msg) = &profile.ret_msg {
89        rows.push(QuoteRightsInfoRow {
90            field: "刷新状态".to_string(),
91            value: msg.clone(),
92        });
93    }
94
95    rows
96}
97
98pub(super) fn quote_right_rows(profile: &QuoteRightsProfile) -> Vec<QuoteRightsRow> {
99    profile
100        .items
101        .iter()
102        .map(|item| QuoteRightsRow {
103            market: item.market.clone(),
104            category: item.category.clone(),
105            value: match item.raw {
106                Some(raw) => format!("{} ({raw})", item.label),
107                None => item.label.clone(),
108            },
109        })
110        .collect()
111}
112
113fn print_quote_rights_table(profile: &QuoteRightsProfile) {
114    println!("用户");
115    let user_rows = quote_right_user_rows(profile);
116    let mut user_table = Table::new(user_rows);
117    user_table.with(Style::rounded());
118    println!("{user_table}");
119
120    println!();
121    println!("额度");
122    let quota_rows = quote_right_quota_rows(profile);
123    let mut quota_table = Table::new(quota_rows);
124    quota_table.with(Style::rounded());
125    println!("{quota_table}");
126
127    println!();
128    println!("权限");
129    let rows = quote_right_rows(profile);
130    let mut table = Table::new(rows);
131    table.with(Style::rounded());
132    println!("{table}");
133}
134
135async fn refresh_quote_rights(client: &Arc<FutuClient>) -> Result<()> {
136    let req = futu_proto::test_cmd::Request {
137        c2s: futu_proto::test_cmd::C2s {
138            cmd: "request_highest_quote_right".to_string(),
139            param_str: None,
140            param_bytes: None,
141        },
142    };
143    let frame = client
144        .request(futu_core::proto_id::TEST_CMD, req.encode_to_vec())
145        .await?;
146    let resp = futu_proto::test_cmd::Response::decode(frame.body.as_ref())
147        .map_err(|e| anyhow!("decode request_highest_quote_right: {e}"))?;
148    if resp.ret_type != 0 {
149        bail!(
150            "request_highest_quote_right ret_type={} msg={:?}",
151            resp.ret_type,
152            resp.ret_msg
153        );
154    }
155    Ok(())
156}
157
158async fn fetch_quote_rights_profile(client: &Arc<FutuClient>) -> Result<QuoteRightsProfile> {
159    let req = futu_proto::test_cmd::Request {
160        c2s: futu_proto::test_cmd::C2s {
161            cmd: SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE.to_string(),
162            param_str: None,
163            param_bytes: None,
164        },
165    };
166    let frame = client
167        .request(futu_core::proto_id::TEST_CMD, req.encode_to_vec())
168        .await?;
169    let resp = futu_proto::test_cmd::Response::decode(frame.body.as_ref())
170        .map_err(|e| anyhow!("decode {SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: {e}"))?;
171    if resp.ret_type != 0 {
172        bail!(
173            "{SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE} ret_type={} msg={:?}",
174            resp.ret_type,
175            resp.ret_msg
176        );
177    }
178    let json = resp
179        .s2c
180        .and_then(|s| s.result_str)
181        .ok_or_else(|| anyhow!("{SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: missing result_str"))?;
182    serde_json::from_str(&json)
183        .map_err(|e| anyhow!("parse {SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE} profile: {e}"))
184}
185
186async fn fetch_quote_capability(
187    client: &Arc<FutuClient>,
188    symbol: &str,
189) -> Result<QuoteCapabilityReport> {
190    let req = futu_proto::test_cmd::Request {
191        c2s: futu_proto::test_cmd::C2s {
192            cmd: SYS_QUERY_GET_QUOTE_CAPABILITY.to_string(),
193            param_str: Some(symbol.to_string()),
194            param_bytes: None,
195        },
196    };
197    let frame = client
198        .request(futu_core::proto_id::TEST_CMD, req.encode_to_vec())
199        .await?;
200    let resp = futu_proto::test_cmd::Response::decode(frame.body.as_ref())
201        .map_err(|e| anyhow!("decode {SYS_QUERY_GET_QUOTE_CAPABILITY}: {e}"))?;
202    if resp.ret_type != 0 {
203        bail!(
204            "{SYS_QUERY_GET_QUOTE_CAPABILITY} ret_type={} msg={:?}",
205            resp.ret_type,
206            resp.ret_msg
207        );
208    }
209    let json = resp
210        .s2c
211        .and_then(|s| s.result_str)
212        .ok_or_else(|| anyhow!("{SYS_QUERY_GET_QUOTE_CAPABILITY}: missing result_str"))?;
213    serde_json::from_str(&json)
214        .map_err(|e| anyhow!("parse {SYS_QUERY_GET_QUOTE_CAPABILITY} report: {e}"))
215}
216
217fn print_quote_capability_table(
218    report: &QuoteCapabilityReport,
219    format: OutputFormat,
220) -> Result<()> {
221    let lv2_subs = if report.lv2_subs.is_empty() {
222        "(none)".to_string()
223    } else {
224        report
225            .lv2_subs
226            .iter()
227            .map(|sub| format!("{}:level{}", sub.name, sub.level))
228            .collect::<Vec<_>>()
229            .join(", ")
230    };
231    let rows = vec![
232        QuoteCapabilityRow {
233            field: "symbol".to_string(),
234            value: report.security.symbol.clone(),
235        },
236        QuoteCapabilityRow {
237            field: "security".to_string(),
238            value: format!(
239                "market={} code={} stock_id={} mkt_id={} sec_type={}",
240                report.security.market,
241                report.security.code,
242                report.security.stock_id,
243                report.security.mkt_id,
244                report.security.sec_type
245            ),
246        },
247        QuoteCapabilityRow {
248            field: "source".to_string(),
249            value: format!(
250                "static_cache={} static_source={} qot_right_freshness={}",
251                report.source.static_cache,
252                report.source.static_source,
253                report.source.qot_right_freshness
254            ),
255        },
256        QuoteCapabilityRow {
257            field: "raw_rights".to_string(),
258            value: format!(
259                "hk={} us={} us_internal={} arca={} tv={} jp={} sg={} my={} crypto={}",
260                report.raw_rights.hk_stock,
261                report.raw_rights.us_stock,
262                report.raw_rights.us_stock_internal,
263                report.raw_rights.us_lv2_arca,
264                report.raw_rights.us_lv2_nasdaq_totalview,
265                report.raw_rights.jp_stock,
266                report.raw_rights.sg_stock,
267                report.raw_rights.my_stock,
268                report.raw_rights.crypto
269            ),
270        },
271        QuoteCapabilityRow {
272            field: "orderbook".to_string(),
273            value: format!(
274                "max_depth={} base_max_depth={} depth_policy={} read_uses_requested_count={} requires_accepted_lv2_push={}",
275                report
276                    .orderbook
277                    .max_depth
278                    .map(|depth| depth.to_string())
279                    .unwrap_or_else(|| "requested_count".to_string()),
280                report.orderbook.base_max_depth,
281                if report.orderbook.depth_policy.is_empty() {
282                    "unknown"
283                } else {
284                    report.orderbook.depth_policy.as_str()
285                },
286                report.orderbook.read_uses_requested_count,
287                report.orderbook.requires_accepted_lv2_push
288            ),
289        },
290        QuoteCapabilityRow {
291            field: "snapshot".to_string(),
292            value: format!(
293                "masks_hk_bmp_bid_ask={}",
294                report.snapshot.masks_hk_bmp_bid_ask
295            ),
296        },
297        QuoteCapabilityRow {
298            field: "crypto".to_string(),
299            value: format!(
300                "has_pt_orderbook_level1={}",
301                report.crypto.has_pt_orderbook_level1
302            ),
303        },
304        QuoteCapabilityRow {
305            field: "lv2_subs".to_string(),
306            value: lv2_subs,
307        },
308    ];
309    format.print_rows(&rows, &[report])?;
310    Ok(())
311}
312
313pub async fn run_quote_rights(gateway: &str, refresh: bool, format: OutputFormat) -> Result<()> {
314    let (client, _rx) = connect_gateway(gateway, "futucli-quote-rights").await?;
315    if refresh {
316        refresh_quote_rights(&client).await?;
317    }
318    let profile = fetch_quote_rights_profile(&client).await?;
319    match format {
320        OutputFormat::Table | OutputFormat::Markdown => print_quote_rights_table(&profile),
321        OutputFormat::Json | OutputFormat::Jsonl => {
322            let rows = quote_right_rows(&profile);
323            format.print_rows(&rows, &[profile])?;
324        }
325    }
326    Ok(())
327}
328
329pub async fn run_quote_capability(gateway: &str, symbol: &str, format: OutputFormat) -> Result<()> {
330    let (client, _rx) = connect_gateway(gateway, "futucli-quote-capability").await?;
331    let report = fetch_quote_capability(&client, symbol).await?;
332    match format {
333        OutputFormat::Table | OutputFormat::Markdown => {
334            print_quote_capability_table(&report, format)?;
335        }
336        OutputFormat::Json => {
337            println!("{}", serde_json::to_string_pretty(&report)?);
338        }
339        OutputFormat::Jsonl => {
340            println!("{}", serde_json::to_string(&report)?);
341        }
342    }
343    Ok(())
344}