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