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}