1use anyhow::{Result, anyhow, bail};
11use prost::Message;
12use serde::Serialize;
13use tabled::Tabled;
14
15use crate::common::connect_gateway;
16use crate::output::OutputFormat;
17
18mod quote_rights;
19mod subscription;
20#[cfg(test)]
21mod tests;
22mod user_info;
23
24pub use quote_rights::run_quote_rights;
25#[cfg(test)]
26use quote_rights::{quote_right_quota_rows, quote_right_rows, quote_right_user_rows};
27pub use subscription::{run_query_subscription, run_unsubscribe, run_used_quota};
28pub use user_info::run_user_info;
29#[cfg(test)]
30use user_info::user_attribution_region_label;
31
32#[derive(Tabled)]
37struct GlobalStateRow {
38 #[tabled(rename = "Field")]
39 field: String,
40 #[tabled(rename = "Value")]
41 value: String,
42}
43
44#[derive(Serialize)]
45struct GlobalStateJson {
46 market_hk: i32,
47 market_us: i32,
48 market_sh: i32,
49 market_sz: i32,
50 market_hk_future: i32,
51 market_us_future: Option<i32>,
52 market_sg_future: Option<i32>,
53 market_jp_future: Option<i32>,
54 qot_logined: bool,
55 trd_logined: bool,
56 server_ver: i32,
57 server_build_no: i32,
58 server_time: i64,
59 conn_id: Option<u64>,
60}
61
62use futu_qot::types::market_state_label;
65
66pub async fn run_global_state(gateway: &str, format: OutputFormat) -> Result<()> {
67 let (client, _rx) = connect_gateway(gateway, "futucli-global-state").await?;
68 let req = futu_proto::get_global_state::Request {
69 c2s: futu_proto::get_global_state::C2s { user_id: 0 },
70 };
71 let body = req.encode_to_vec();
72 let frame = client
73 .request(futu_core::proto_id::GET_GLOBAL_STATE, body)
74 .await?;
75 let resp = futu_proto::get_global_state::Response::decode(frame.body.as_ref())
76 .map_err(|e| anyhow!("decode global_state: {e}"))?;
77 if resp.ret_type != 0 {
78 bail!(
79 "global_state ret_type={} msg={:?}",
80 resp.ret_type,
81 resp.ret_msg
82 );
83 }
84 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
85 let rows = vec![
86 GlobalStateRow {
87 field: "market_hk".into(),
88 value: format!("{} ({})", market_state_label(s.market_hk), s.market_hk),
89 },
90 GlobalStateRow {
91 field: "market_us".into(),
92 value: format!("{} ({})", market_state_label(s.market_us), s.market_us),
93 },
94 GlobalStateRow {
95 field: "market_sh".into(),
96 value: format!("{} ({})", market_state_label(s.market_sh), s.market_sh),
97 },
98 GlobalStateRow {
99 field: "market_sz".into(),
100 value: format!("{} ({})", market_state_label(s.market_sz), s.market_sz),
101 },
102 GlobalStateRow {
103 field: "market_hk_future".into(),
104 value: format!(
105 "{} ({})",
106 market_state_label(s.market_hk_future),
107 s.market_hk_future
108 ),
109 },
110 GlobalStateRow {
111 field: "qot_logined".into(),
112 value: s.qot_logined.to_string(),
113 },
114 GlobalStateRow {
115 field: "trd_logined".into(),
116 value: s.trd_logined.to_string(),
117 },
118 GlobalStateRow {
119 field: "server_ver".into(),
120 value: s.server_ver.to_string(),
121 },
122 GlobalStateRow {
123 field: "server_build_no".into(),
124 value: s.server_build_no.to_string(),
125 },
126 GlobalStateRow {
127 field: "server_time".into(),
128 value: s.time.to_string(),
129 },
130 GlobalStateRow {
131 field: "conn_id".into(),
132 value: s
133 .conn_id
134 .map(|c| c.to_string())
135 .unwrap_or_else(|| "-".into()),
136 },
137 ];
138 let json = GlobalStateJson {
139 market_hk: s.market_hk,
140 market_us: s.market_us,
141 market_sh: s.market_sh,
142 market_sz: s.market_sz,
143 market_hk_future: s.market_hk_future,
144 market_us_future: s.market_us_future,
145 market_sg_future: s.market_sg_future,
146 market_jp_future: s.market_jp_future,
147 qot_logined: s.qot_logined,
148 trd_logined: s.trd_logined,
149 server_ver: s.server_ver,
150 server_build_no: s.server_build_no,
151 server_time: s.time,
152 conn_id: s.conn_id,
153 };
154 format.print_rows(&rows, &[json])?;
155 Ok(())
156}
157
158#[derive(Tabled)]
163struct DelayStatRow {
164 #[tabled(rename = "Category")]
165 category: String,
166 #[tabled(rename = "Samples")]
167 samples: usize,
168}
169
170#[derive(Serialize)]
171struct DelayStatJson {
172 qot_push_categories: usize,
173 req_reply_samples: usize,
174 place_order_samples: usize,
175}
176
177pub async fn run_delay_statistics(gateway: &str, format: OutputFormat) -> Result<()> {
178 let (client, _rx) = connect_gateway(gateway, "futucli-delay-statistics").await?;
179 let req = futu_proto::get_delay_statistics::Request {
180 c2s: futu_proto::get_delay_statistics::C2s {
181 type_list: vec![],
182 qot_push_stage: None,
183 segment_list: vec![],
184 },
185 };
186 let body = req.encode_to_vec();
187 let frame = client
188 .request(futu_core::proto_id::GET_DELAY_STATISTICS, body)
189 .await?;
190 let resp = futu_proto::get_delay_statistics::Response::decode(frame.body.as_ref())
191 .map_err(|e| anyhow!("decode delay_statistics: {e}"))?;
192 if resp.ret_type != 0 {
193 bail!(
194 "delay_statistics ret_type={} msg={:?}",
195 resp.ret_type,
196 resp.ret_msg
197 );
198 }
199 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
200 let rows = vec![
201 DelayStatRow {
202 category: "qot_push".into(),
203 samples: s.qot_push_statistics_list.len(),
204 },
205 DelayStatRow {
206 category: "req_reply".into(),
207 samples: s.req_reply_statistics_list.len(),
208 },
209 DelayStatRow {
210 category: "place_order".into(),
211 samples: s.place_order_statistics_list.len(),
212 },
213 ];
214 let json = DelayStatJson {
215 qot_push_categories: s.qot_push_statistics_list.len(),
216 req_reply_samples: s.req_reply_statistics_list.len(),
217 place_order_samples: s.place_order_statistics_list.len(),
218 };
219 format.print_rows(&rows, &[json])?;
220 Ok(())
221}
222
223#[derive(serde::Serialize, tabled::Tabled)]
229struct TokenStateRow {
230 brand: String,
232 bind: u32,
234 enable: u32,
236}
237
238#[derive(serde::Serialize)]
239struct TokenStateJson {
240 nn_token_enable: u32,
241 nn_token_bind: u32,
242 mm_token_enable: u32,
243 mm_token_bind: u32,
244}
245
246pub async fn run_token_state(gateway: &str, format: OutputFormat) -> Result<()> {
247 use futu_backend::proto_internal::futu_token_state;
248
249 let (client, _rx) = connect_gateway(gateway, "futucli-token-state").await?;
250 let req = futu_token_state::DaemonGetTokenStateReq {
251 c2s: futu_token_state::daemon_get_token_state_req::C2s { app_id: None },
252 };
253 let body = req.encode_to_vec();
254 let frame = client
255 .request(futu_core::proto_id::GET_TOKEN_STATE, body)
256 .await?;
257 let resp = futu_token_state::DaemonGetTokenStateRsp::decode(frame.body.as_ref())
258 .map_err(|e| anyhow!("decode token_state: {e}"))?;
259 if resp.ret_type != 0 {
260 bail!(
261 "token_state ret_type={} msg={:?}",
262 resp.ret_type,
263 resp.ret_msg
264 );
265 }
266 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
267 let rows = vec![
268 TokenStateRow {
269 brand: "NN (Futu Token)".into(),
270 bind: s.nn_token_bind.unwrap_or(0),
271 enable: s.nn_token_enable.unwrap_or(0),
272 },
273 TokenStateRow {
274 brand: "MM (moomoo Token)".into(),
275 bind: s.mm_token_bind.unwrap_or(0),
276 enable: s.mm_token_enable.unwrap_or(0),
277 },
278 ];
279 let json = TokenStateJson {
280 nn_token_enable: s.nn_token_enable.unwrap_or(0),
281 nn_token_bind: s.nn_token_bind.unwrap_or(0),
282 mm_token_enable: s.mm_token_enable.unwrap_or(0),
283 mm_token_bind: s.mm_token_bind.unwrap_or(0),
284 };
285 format.print_rows(&rows, &[json])?;
286 Ok(())
287}
288
289#[derive(serde::Serialize, tabled::Tabled)]
295struct RiskFreeRateRow {
296 market: String,
297 rate_pct: String,
299 raw: u64,
301}
302
303#[derive(serde::Serialize)]
304struct RiskFreeRateJson {
305 hk_rate_pct: Option<f64>,
306 us_rate_pct: Option<f64>,
307 jp_rate_pct: Option<f64>,
308 update_time: Option<i64>,
309 hk_rate_raw: Option<u64>,
310 us_rate_raw: Option<u64>,
311 jp_rate_raw: Option<u64>,
312}
313
314pub async fn run_risk_free_rate(gateway: &str, format: OutputFormat) -> Result<()> {
315 use futu_backend::proto_internal::risk_free_rate;
316
317 let (client, _rx) = connect_gateway(gateway, "futucli-risk-free-rate").await?;
318 let req = risk_free_rate::DaemonGetRiskFreeRateReq {
319 c2s: risk_free_rate::daemon_get_risk_free_rate_req::C2s { rate_time: None },
320 };
321 let body = req.encode_to_vec();
322 let frame = client
323 .request(futu_core::proto_id::QOT_GET_RISK_FREE_RATE, body)
324 .await?;
325 let resp = risk_free_rate::DaemonGetRiskFreeRateRsp::decode(frame.body.as_ref())
326 .map_err(|e| anyhow!("decode risk_free_rate: {e}"))?;
327 if resp.ret_type != 0 {
328 bail!(
329 "risk_free_rate ret_type={} msg={:?}",
330 resp.ret_type,
331 resp.ret_msg
332 );
333 }
334 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
335 let fmt_pct = |v: Option<f64>| -> String {
339 match v {
340 Some(p) => format!("{:.4}%", p),
341 None => "—".into(),
342 }
343 };
344 let rows = vec![
345 RiskFreeRateRow {
346 market: "HK".into(),
347 rate_pct: fmt_pct(s.hk_rate_pct),
348 raw: s.hk_rate_raw.unwrap_or(0),
349 },
350 RiskFreeRateRow {
351 market: "US".into(),
352 rate_pct: fmt_pct(s.us_rate_pct),
353 raw: s.us_rate_raw.unwrap_or(0),
354 },
355 RiskFreeRateRow {
356 market: "JP".into(),
357 rate_pct: fmt_pct(s.jp_rate_pct),
358 raw: s.jp_rate_raw.unwrap_or(0),
359 },
360 ];
361 let json = RiskFreeRateJson {
362 hk_rate_pct: s.hk_rate_pct,
363 us_rate_pct: s.us_rate_pct,
364 jp_rate_pct: s.jp_rate_pct,
365 update_time: s.update_time,
366 hk_rate_raw: s.hk_rate_raw,
367 us_rate_raw: s.us_rate_raw,
368 jp_rate_raw: s.jp_rate_raw,
369 };
370 format.print_rows(&rows, &[json])?;
371 Ok(())
372}
373
374pub async fn run_spread_table(gateway: &str, format: OutputFormat) -> Result<()> {
379 use futu_backend::proto_internal::spread_table_6503;
380
381 let (client, _rx) = connect_gateway(gateway, "futucli-spread-table").await?;
382 let req = spread_table_6503::DaemonGetSpreadTableReq {
383 c2s: spread_table_6503::daemon_get_spread_table_req::C2s { reserved: None },
384 };
385 let body = req.encode_to_vec();
386 let frame = client
387 .request(futu_core::proto_id::QOT_GET_SPREAD_TABLE, body)
388 .await?;
389 let resp = spread_table_6503::DaemonGetSpreadTableRsp::decode(frame.body.as_ref())
390 .map_err(|e| anyhow!("decode spread_table: {e}"))?;
391 if resp.ret_type != 0 {
392 bail!(
393 "spread_table ret_type={} msg={:?}",
394 resp.ret_type,
395 resp.ret_msg
396 );
397 }
398 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
400 match format {
401 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
405 OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
406 OutputFormat::Table => {
407 println!(
409 "Spread tables: {} (use --output json for full data)",
410 s.spread_table_list.len()
411 );
412 for t in &s.spread_table_list {
413 println!(
414 " spread_code={:?} items={}",
415 t.spread_code,
416 t.spread_item_list.len()
417 );
418 }
419 }
420 }
421 Ok(())
422}
423
424pub async fn run_ticker_statistic(
429 gateway: &str,
430 symbol: &str,
431 ticker_type: Option<i32>,
432 stat_type: Option<u32>,
433 format: OutputFormat,
434) -> Result<()> {
435 use futu_backend::proto_internal::ticker_statistic_daemon;
436
437 let (client, _rx) = connect_gateway(gateway, "futucli-ticker-statistic").await?;
438 let sec = crate::common::parse_symbol(symbol)?;
442 let req = ticker_statistic_daemon::DaemonGetTickerStatisticReq {
443 c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_req::C2s {
444 security: ticker_statistic_daemon::Security {
445 market: sec.market as i32,
446 code: sec.code,
447 },
448 ticker_type,
449 ticker_time: None,
450 stat_type,
451 },
452 };
453 let body = req.encode_to_vec();
454 let frame = client
455 .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC, body)
456 .await?;
457 let resp = ticker_statistic_daemon::DaemonGetTickerStatisticRsp::decode(frame.body.as_ref())
458 .map_err(|e| anyhow!("decode ticker_statistic: {e}"))?;
459 if resp.ret_type != 0 {
460 bail!(
461 "ticker_statistic ret_type={} msg={:?}",
462 resp.ret_type,
463 resp.ret_msg
464 );
465 }
466 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
467 match format {
468 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
471 OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
472 OutputFormat::Table => {
473 println!("symbol: {}", symbol);
474 if let Some(stid) = s.resolved_stock_id {
475 println!("resolved_stock_id: {}", stid);
476 }
477 if let Some(tt) = s.ticker_time {
478 println!("ticker_time: {}", tt);
479 }
480 if !s.date_list.is_empty() {
481 println!("date_list (returned when ticker_time=0): {:?}", s.date_list);
482 }
483 if let Some(stat) = &s.stat {
484 println!("avg_price: {:?}", stat.avg_price);
485 println!("trade_volume: {:?}", stat.trade_volume);
486 println!("trade_num: {:?}", stat.trade_num);
487 println!("buy_volume: {:?}", stat.buy_volume);
488 println!("sell_volume: {:?}", stat.sell_volume);
489 println!("neutral_volume: {:?}", stat.neutral_volume);
490 println!("last_close_price: {:?}", stat.last_close_price);
491 }
492 }
493 }
494 Ok(())
495}
496
497pub struct TickerStatisticDetailCommand<'a> {
502 pub gateway: &'a str,
503 pub symbol: &'a str,
504 pub ticker_type: Option<i32>,
505 pub ticker_time: Option<u64>,
506 pub select_num: Option<u32>,
507 pub data_from: Option<u32>,
508 pub data_max_count: Option<u32>,
509 pub stat_type: Option<u32>,
510 pub format: OutputFormat,
511}
512
513pub async fn run_ticker_statistic_detail(input: TickerStatisticDetailCommand<'_>) -> Result<()> {
514 use futu_backend::proto_internal::ticker_statistic_daemon;
515
516 let (client, _rx) = connect_gateway(input.gateway, "futucli-ticker-statistic-detail").await?;
517 let sec = crate::common::parse_symbol(input.symbol)?;
518 let req = ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq {
519 c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_detail_req::C2s {
520 security: ticker_statistic_daemon::Security {
521 market: sec.market as i32,
522 code: sec.code,
523 },
524 ticker_type: input.ticker_type,
525 ticker_time: input.ticker_time,
526 select_num: input.select_num,
527 data_from: input.data_from,
528 data_max_count: input.data_max_count,
529 stat_type: input.stat_type,
530 },
531 };
532 let body = req.encode_to_vec();
533 let frame = client
534 .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC_DETAIL, body)
535 .await?;
536 let resp =
537 ticker_statistic_daemon::DaemonGetTickerStatisticDetailRsp::decode(frame.body.as_ref())
538 .map_err(|e| anyhow!("decode ticker_statistic_detail: {e}"))?;
539 if resp.ret_type != 0 {
540 bail!(
541 "ticker_statistic_detail ret_type={} msg={:?}",
542 resp.ret_type,
543 resp.ret_msg
544 );
545 }
546 let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
547 match input.format {
548 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
549 OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
550 OutputFormat::Table => {
551 println!("symbol: {}", input.symbol);
552 if let Some(stid) = s.resolved_stock_id {
553 println!("resolved_stock_id: {}", stid);
554 }
555 if let Some(tt) = s.ticker_time {
556 println!("ticker_time: {}", tt);
557 }
558 if let Some(have_more) = s.have_more {
559 println!("have_more: {}", have_more);
560 }
561 if let Some(mr) = s.max_ratio {
562 println!("max_ratio: {:.5}", mr);
563 }
564 println!("items ({}):", s.items.len());
565 for (i, item) in s.items.iter().enumerate() {
566 println!(
567 " [{:2}] price={:?} buy={:?} sell={:?} vol={:?} ratio={:?} neutral={:?}",
568 i,
569 item.price,
570 item.buy_volume,
571 item.sell_volume,
572 item.volume,
573 item.ratio,
574 item.neutral_volume
575 );
576 }
577 }
578 }
579 Ok(())
580}