1use 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
16struct PrintHandler;
18
19struct 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 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 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 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;