Skip to main content

futucli/cmd/
quote.rs

1//! `futucli quote` — 获取基础实时报价
2
3use std::time::Duration;
4
5use anyhow::Result;
6use serde::Serialize;
7use tabled::Tabled;
8
9use crate::common::{connect_gateway, format_symbol, parse_symbol};
10use crate::output::OutputFormat;
11use futu_qot::types::{BasicQot, SubType};
12
13#[derive(Tabled)]
14struct QuoteRow {
15    #[tabled(rename = "Symbol")]
16    symbol: String,
17    #[tabled(rename = "Price")]
18    price: String,
19    #[tabled(rename = "Change")]
20    change: String,
21    #[tabled(rename = "Change%")]
22    change_pct: String,
23    #[tabled(rename = "Volume")]
24    volume: String,
25    #[tabled(rename = "Update")]
26    update: String,
27}
28
29#[derive(Serialize)]
30struct QuoteJson {
31    symbol: String,
32    cur_price: f64,
33    last_close_price: f64,
34    change: f64,
35    change_pct: f64,
36    open_price: f64,
37    high_price: f64,
38    low_price: f64,
39    volume: i64,
40    turnover: f64,
41    update_time: String,
42    is_suspended: bool,
43}
44
45impl QuoteJson {
46    fn from(q: &BasicQot) -> Self {
47        let change = q.cur_price - q.last_close_price;
48        let change_pct = if q.last_close_price != 0.0 {
49            change / q.last_close_price * 100.0
50        } else {
51            0.0
52        };
53        Self {
54            symbol: format_symbol(&q.security),
55            cur_price: q.cur_price,
56            last_close_price: q.last_close_price,
57            change,
58            change_pct,
59            open_price: q.open_price,
60            high_price: q.high_price,
61            low_price: q.low_price,
62            volume: q.volume,
63            turnover: q.turnover,
64            update_time: q.update_time.clone(),
65            is_suspended: q.is_suspended,
66        }
67    }
68}
69
70fn to_row(j: &QuoteJson) -> QuoteRow {
71    let sign = if j.change >= 0.0 { "+" } else { "" };
72    QuoteRow {
73        symbol: j.symbol.clone(),
74        price: format!("{:.3}", j.cur_price),
75        change: format!("{sign}{:.3}", j.change),
76        change_pct: format!("{sign}{:.2}%", j.change_pct),
77        volume: format_volume(j.volume),
78        update: j.update_time.clone(),
79    }
80}
81
82fn format_volume(v: i64) -> String {
83    // 千位分隔符
84    let s = v.abs().to_string();
85    let bytes = s.as_bytes();
86    let mut out = String::new();
87    for (i, b) in bytes.iter().enumerate() {
88        if i > 0 && (bytes.len() - i).is_multiple_of(3) {
89            out.push(',');
90        }
91        out.push(*b as char);
92    }
93    if v < 0 { format!("-{out}") } else { out }
94}
95
96pub async fn run(gateway: &str, symbols: &[String], format: OutputFormat) -> Result<()> {
97    let secs: Vec<_> = symbols
98        .iter()
99        .map(|s| parse_symbol(s))
100        .collect::<Result<_>>()?;
101
102    let (client, _push_rx) = connect_gateway(gateway, "futucli-quote").await?;
103
104    // 订阅 Basic(is_first_push=true 让订阅后立即推一次)
105    futu_qot::sub::subscribe(&client, &secs, &[SubType::Basic], true, true).await?;
106    // 等后端把首次推送刷进来,再查询拿到带值的 BasicQot
107    tokio::time::sleep(Duration::from_millis(200)).await;
108
109    let quotes = futu_qot::basic_qot::get_basic_qot(&client, &secs).await?;
110
111    let jsons: Vec<QuoteJson> = quotes.iter().map(QuoteJson::from).collect();
112    let rows: Vec<QuoteRow> = jsons.iter().map(to_row).collect();
113
114    format.print_rows(&rows, &jsons)?;
115    Ok(())
116}