Skip to main content

futucli/cmd/
sub.rs

1//! `futucli sub` — 订阅推送并持续打印,Ctrl-C 停止
2//!
3//! v1.4.98 external reviewer BUG-005 fix (P2, 2026-04-27): `-o jsonl` 之前忽略, 仍打印
4//! `[time] basic HK.00700 price=...` 文本. 修后 `-o jsonl` 每条 push 一行
5//! 合法 JSON object (脚本 / agent 用).
6
7use anyhow::Result;
8use async_trait::async_trait;
9use futu_qot::push::{QuoteHandler, QuotePushDispatcher};
10use futu_qot::sub::SubscribeOptions;
11use futu_qot::types::{BasicQot, KLine, OrderBookData, Security};
12use futu_surface_spec::input::parse_subscription_session_id;
13
14use crate::common::{connect_gateway, format_symbol, parse_sub_types, parse_symbol};
15use crate::output::OutputFormat;
16
17/// PrintHandler — text format (default & --output table).
18struct PrintHandler;
19
20/// JsonlHandler — emit one JSON object per push event (--output jsonl/json).
21struct JsonlHandler;
22
23#[async_trait]
24impl QuoteHandler for PrintHandler {
25    async fn on_basic_qot_update(&self, qot_list: Vec<BasicQot>) {
26        for q in qot_list {
27            let sym = format_symbol(&q.security);
28            let change = q.cur_price - q.last_close_price;
29            let pct = if q.last_close_price != 0.0 {
30                change / q.last_close_price * 100.0
31            } else {
32                0.0
33            };
34            let sign = if change >= 0.0 { "+" } else { "" };
35            println!(
36                "[{}] basic {sym:<12} price={:.3} change={sign}{change:.3} ({sign}{pct:.2}%) vol={}",
37                q.update_time, q.cur_price, q.volume
38            );
39        }
40    }
41
42    async fn on_kl_update(&self, security: Security, kl_list: Vec<KLine>) {
43        let sym = format_symbol(&security);
44        for k in kl_list {
45            println!(
46                "[{}] kl    {sym:<12} O={:.3} H={:.3} L={:.3} C={:.3} V={}",
47                k.time, k.open_price, k.high_price, k.low_price, k.close_price, k.volume
48            );
49        }
50    }
51
52    async fn on_order_book_update(&self, data: OrderBookData) {
53        let sym = format_symbol(&data.security);
54        let top_bid = data.bid_list.first();
55        let top_ask = data.ask_list.first();
56        match (top_bid, top_ask) {
57            (Some(b), Some(a)) => println!(
58                "              ob    {sym:<12} bid={:.3}x{}  ask={:.3}x{}  spread={:.3}",
59                b.price,
60                b.volume,
61                a.price,
62                a.volume,
63                a.price - b.price
64            ),
65            _ => println!("              ob    {sym:<12} (empty)"),
66        }
67    }
68
69    async fn on_ticker_update(
70        &self,
71        security: Security,
72        ticker_list: Vec<futu_qot::ticker::Ticker>,
73    ) {
74        let sym = format_symbol(&security);
75        for t in ticker_list {
76            println!(
77                "              tick  {sym:<12} px={:.3} vol={} dir={}",
78                t.price, t.volume, t.dir
79            );
80        }
81    }
82
83    async fn on_rt_update(&self, security: Security, rt_list: Vec<futu_qot::rt::TimeShare>) {
84        let sym = format_symbol(&security);
85        for r in rt_list {
86            println!(
87                "[{}] rt    {sym:<12} px={:.3} avg={:.3} vol={}",
88                r.time, r.price, r.avg_price, r.volume
89            );
90        }
91    }
92}
93
94#[async_trait]
95impl QuoteHandler for JsonlHandler {
96    async fn on_basic_qot_update(&self, qot_list: Vec<BasicQot>) {
97        for q in qot_list {
98            let line = serde_json::json!({
99                "kind": "basic",
100                "symbol": format_symbol(&q.security),
101                "update_time": q.update_time,
102                "cur_price": q.cur_price,
103                "last_close": q.last_close_price,
104                "volume": q.volume,
105                "turnover": q.turnover,
106                "open_price": q.open_price,
107                "high_price": q.high_price,
108                "low_price": q.low_price,
109            });
110            println!("{}", line);
111        }
112    }
113    async fn on_kl_update(&self, security: Security, kl_list: Vec<KLine>) {
114        for k in kl_list {
115            let line = serde_json::json!({
116                "kind": "kline",
117                "symbol": format_symbol(&security),
118                "time": k.time,
119                "open": k.open_price,
120                "high": k.high_price,
121                "low": k.low_price,
122                "close": k.close_price,
123                "volume": k.volume,
124            });
125            println!("{}", line);
126        }
127    }
128    async fn on_order_book_update(&self, data: OrderBookData) {
129        let line = serde_json::json!({
130            "kind": "orderbook",
131            "symbol": format_symbol(&data.security),
132            "bid_top": data.bid_list.first().map(|b| serde_json::json!({"price": b.price, "volume": b.volume})),
133            "ask_top": data.ask_list.first().map(|a| serde_json::json!({"price": a.price, "volume": a.volume})),
134        });
135        println!("{}", line);
136    }
137    async fn on_ticker_update(
138        &self,
139        security: Security,
140        ticker_list: Vec<futu_qot::ticker::Ticker>,
141    ) {
142        for t in ticker_list {
143            let line = serde_json::json!({
144                "kind": "ticker",
145                "symbol": format_symbol(&security),
146                "price": t.price,
147                "volume": t.volume,
148                "dir": t.dir,
149            });
150            println!("{}", line);
151        }
152    }
153    async fn on_rt_update(&self, security: Security, rt_list: Vec<futu_qot::rt::TimeShare>) {
154        for r in rt_list {
155            let line = serde_json::json!({
156                "kind": "rt",
157                "symbol": format_symbol(&security),
158                "time": r.time,
159                "price": r.price,
160                "avg_price": r.avg_price,
161                "volume": r.volume,
162            });
163            println!("{}", line);
164        }
165    }
166}
167
168pub async fn run(
169    gateway: &str,
170    symbols: &[String],
171    types_csv: &str,
172    extended_time: bool,
173    session: Option<&str>,
174    orderbook_detail: bool,
175    format: OutputFormat,
176) -> Result<()> {
177    let secs: Vec<_> = symbols
178        .iter()
179        .map(|s| parse_symbol(s))
180        .collect::<Result<_>>()?;
181    let sub_types = parse_sub_types(types_csv)?;
182    let session = parse_qot_sub_session(session)?;
183
184    let (client, mut push_rx) = connect_gateway(gateway, "futucli-sub").await?;
185
186    // 订阅
187    futu_qot::sub::subscribe_with_options(
188        &client,
189        &secs,
190        &sub_types,
191        SubscribeOptions {
192            is_reg_push: true,
193            is_first_push: true,
194            extended_time: extended_time.then_some(true),
195            session,
196            orderbook_detail: orderbook_detail.then_some(true),
197        },
198    )
199    .await?;
200    eprintln!(
201        "✓ subscribed: symbols={:?}, types={types_csv}, extended_time={extended_time}, session={session:?}, orderbook_detail={orderbook_detail}  (Ctrl-C to stop)",
202        symbols,
203    );
204
205    // v1.4.98 external reviewer BUG-005: jsonl/json → JsonlHandler 输出每条 push 一行
206    // JSON object; 否则 PrintHandler 文本.
207    let use_jsonl = matches!(format, OutputFormat::Json | OutputFormat::Jsonl);
208
209    loop {
210        tokio::select! {
211            maybe = push_rx.recv() => {
212                let Some(msg) = maybe else {
213                    eprintln!("⚠️  push channel closed");
214                    break;
215                };
216                let dispatch_err = if use_jsonl {
217                    QuotePushDispatcher::dispatch(&JsonlHandler, msg.proto_id, &msg.body).await
218                } else {
219                    QuotePushDispatcher::dispatch(&PrintHandler, msg.proto_id, &msg.body).await
220                };
221                if let Err(e) = dispatch_err {
222                    eprintln!("push dispatch error: {e}");
223                }
224            }
225            _ = tokio::signal::ctrl_c() => {
226                eprintln!("\n⏹  stopping (ctrl-c)");
227                // 退出路径保持 best-effort, 但失败必须可见;否则用户会误以为
228                // server-side subscription 已清理。
229                if let Err(err) = futu_qot::sub::unsubscribe(&client, &secs, &sub_types).await {
230                    eprintln!("⚠️  unsubscribe during shutdown failed: {err}");
231                }
232                break;
233            }
234        }
235    }
236    Ok(())
237}
238
239fn parse_qot_sub_session(value: Option<&str>) -> Result<Option<i32>> {
240    parse_subscription_session_id(value)
241        .map_err(|error| anyhow::anyhow!("invalid --session: {error}"))
242}
243
244#[cfg(test)]
245mod tests;