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