1use 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
17struct PrintHandler;
19
20struct 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 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 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 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;