1use std::collections::{BTreeMap, HashSet};
15use std::io::ErrorKind;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use anyhow::{Context, Result, anyhow, bail};
20use async_trait::async_trait;
21use clap::Parser;
22use futu_net::client::{FutuClient, PushReceiver};
23use futu_qot::push::{QuoteHandler, QuotePushDispatcher};
24use futu_qot::types::{BasicQot, KLine, OrderBookData, Security, SubType};
25use rustyline::error::ReadlineError;
26use rustyline::{DefaultEditor, ExternalPrinter};
27use tokio::sync::Mutex;
28
29use crate::cli::{Cli, Command};
30use crate::common::{connect_gateway, format_symbol, parse_sub_type, parse_symbol};
31use crate::output::OutputFormat;
32
33struct ReplState {
36 gateway: String,
37 output: OutputFormat,
38 client: Mutex<Arc<FutuClient>>,
39 subs: Mutex<BTreeMap<String, HashSet<SubType>>>,
40}
41
42enum PushPrinter {
45 External(Box<dyn ExternalPrinter + Send>),
46 Stderr,
47}
48
49impl PushPrinter {
50 fn print(&mut self, msg: String) {
51 let print_err = match self {
52 PushPrinter::External(p) => p.print(msg.clone()).err(),
53 PushPrinter::Stderr => {
54 eprint!("{msg}");
55 return;
56 }
57 };
58
59 let Some(err) = print_err else {
60 return;
61 };
62
63 eprintln!("warning: REPL push printer failed ({err}); falling back to stderr");
64 eprint!("{msg}");
65 *self = PushPrinter::Stderr;
66 }
67}
68
69type SharedPrinter = Arc<Mutex<PushPrinter>>;
71
72pub async fn run(gateway: &str, output: OutputFormat) -> Result<()> {
73 let (client, push_rx) = connect_gateway(gateway, "futucli-repl").await?;
75 eprintln!("✓ connected to {gateway}");
76
77 let state = Arc::new(ReplState {
78 gateway: gateway.to_string(),
79 output,
80 client: Mutex::new(client),
81 subs: Mutex::new(BTreeMap::new()),
82 });
83
84 let history_path = history_file_path();
86 let mut rl = DefaultEditor::new().context("init rustyline editor")?;
87 if let Some(p) = &history_path {
88 load_history_best_effort(&mut rl, p);
89 }
90
91 let printer_inner = match rl.create_external_printer() {
94 Ok(p) => PushPrinter::External(Box::new(p)),
95 Err(e) => {
96 eprintln!("note: external printer unavailable ({e}); push output will go to stderr");
97 PushPrinter::Stderr
98 }
99 };
100 let printer: SharedPrinter = Arc::new(Mutex::new(printer_inner));
101
102 tokio::spawn(push_loop(push_rx, printer.clone()));
104
105 print_banner(gateway);
107 loop {
108 let prompt = format!("futu ({}) > ", state.gateway);
110 let read_result = tokio::task::spawn_blocking(move || {
111 let res = rl.readline(&prompt);
112 (rl, res)
113 })
114 .await
115 .context("readline task panicked")?;
116 rl = read_result.0;
117
118 let line = match read_result.1 {
119 Ok(l) => l,
120 Err(ReadlineError::Interrupted) => {
121 eprintln!("(Ctrl-C) — type 'exit' or Ctrl-D to quit");
122 continue;
123 }
124 Err(ReadlineError::Eof) => {
125 eprintln!("bye");
126 break;
127 }
128 Err(e) => {
129 eprintln!("readline error: {e}");
130 break;
131 }
132 };
133 let trimmed = line.trim();
134 if trimmed.is_empty() {
135 continue;
136 }
137 if let Err(err) = rl.add_history_entry(trimmed) {
138 eprintln!("warning: add REPL history entry failed: {err}");
139 }
140
141 match handle_line(trimmed, &state, &printer).await {
143 Ok(ShouldContinue::Continue) => {}
144 Ok(ShouldContinue::Exit) => break,
145 Err(e) => eprintln!("error: {e:#}"),
146 }
147 }
148
149 if let Some(p) = &history_path {
151 save_history_best_effort(&mut rl, p);
152 }
153 Ok(())
154}
155
156fn load_history_best_effort(rl: &mut DefaultEditor, path: &Path) {
157 match rl.load_history(path) {
158 Ok(()) => {}
159 Err(ReadlineError::Io(err)) if err.kind() == ErrorKind::NotFound => {
160 }
162 Err(err) => {
163 eprintln!(
164 "warning: load REPL history failed for {}: {err}",
165 path.display()
166 );
167 }
168 }
169}
170
171fn save_history_best_effort(rl: &mut DefaultEditor, path: &Path) {
172 let parent = path.parent().unwrap_or_else(|| Path::new("."));
173 if let Err(err) = std::fs::create_dir_all(parent) {
174 eprintln!(
175 "warning: create REPL history directory failed for {}: {err}",
176 parent.display()
177 );
178 return;
179 }
180 if let Err(err) = rl.save_history(path) {
181 eprintln!(
182 "warning: save REPL history failed for {}: {err}",
183 path.display()
184 );
185 }
186}
187
188enum ShouldContinue {
189 Continue,
190 Exit,
191}
192
193fn tokenize_repl_line(line: &str) -> Result<Vec<String>> {
194 shlex::split(line).ok_or_else(|| anyhow!("failed to tokenize input"))
195}
196
197async fn handle_line(
199 line: &str,
200 state: &Arc<ReplState>,
201 printer: &SharedPrinter,
202) -> Result<ShouldContinue> {
203 let tokens = tokenize_repl_line(line)?;
204 if tokens.is_empty() {
205 return Ok(ShouldContinue::Continue);
206 }
207
208 match tokens[0].as_str() {
209 "exit" | "quit" | ":q" => return Ok(ShouldContinue::Exit),
210 "help" | "?" => {
211 print_help();
212 return Ok(ShouldContinue::Continue);
213 }
214 "reconnect" => {
215 reconnect(state, printer).await?;
216 return Ok(ShouldContinue::Continue);
217 }
218 "subs" => {
219 list_subs(state).await;
220 return Ok(ShouldContinue::Continue);
221 }
222 "sub" if tokens.len() >= 2 && looks_like_symbol(&tokens[1]) => {
223 return sub_inline(&tokens[1..], state, printer)
225 .await
226 .map(|_| ShouldContinue::Continue);
227 }
228 "unsub" => {
229 return unsub_inline(&tokens[1..], state)
230 .await
231 .map(|_| ShouldContinue::Continue);
232 }
233 _ => {}
234 }
235
236 let argv = std::iter::once("futucli".to_string())
238 .chain(tokens)
239 .collect::<Vec<_>>();
240 let parsed = match Cli::try_parse_from(&argv) {
241 Ok(p) => p,
242 Err(e) => {
243 eprint!("{e}");
245 return Ok(ShouldContinue::Continue);
246 }
247 };
248
249 if matches!(parsed.command, Command::Repl) {
251 bail!("already in REPL");
252 }
253
254 crate::cli::dispatch(&state.gateway, state.output, parsed.command).await?;
256 Ok(ShouldContinue::Continue)
257}
258
259#[cfg(test)]
260#[path = "repl/tests.rs"]
261mod tests;
262
263fn print_banner(gateway: &str) {
266 eprintln!("futucli REPL — gateway {gateway}");
267 eprintln!("type 'help' for commands, 'exit' or Ctrl-D to quit");
268}
269
270fn print_help() {
271 let lines = [
272 "REPL 内置命令:",
273 " help | ? 显示本帮助",
274 " exit | quit | :q 退出",
275 " reconnect 断开并重新连接网关",
276 " subs 列出当前活跃订阅",
277 " sub <SYMBOL> [-t csv] 后台订阅(推送在 prompt 上方实时显示)",
278 " unsub <SYMBOL> [csv] 取消订阅(csv 省略则全部类型)",
279 "",
280 "子命令(和外层 futucli 一致):",
281 " ping / quote / snapshot / kline / orderbook / ticker / rt / static /",
282 " broker / plate-list / plate-stocks / account / funds / position /",
283 " order / deal / unlock-trade",
284 "",
285 "示例:",
286 " quote HK.00700 US.AAPL",
287 " kline HK.00700 -t day -n 5",
288 " sub HK.00700 -t basic,orderbook",
289 ];
290 for l in lines {
291 eprintln!("{l}");
292 }
293}
294
295async fn reconnect(state: &Arc<ReplState>, printer: &SharedPrinter) -> Result<()> {
296 let (new_client, new_rx) = connect_gateway(&state.gateway, "futucli-repl").await?;
297
298 *state.client.lock().await = new_client;
299 tokio::spawn(push_loop(new_rx, printer.clone()));
300 eprintln!("✓ reconnected to {}", state.gateway);
301 Ok(())
302}
303
304async fn list_subs(state: &Arc<ReplState>) {
305 let guard = state.subs.lock().await;
306 if guard.is_empty() {
307 println!("(no active subscriptions)");
308 return;
309 }
310 println!("Active subscriptions:");
311 for (sym, types) in guard.iter() {
312 let mut names: Vec<&'static str> = types.iter().copied().map(sub_type_label).collect();
313 names.sort_unstable();
314 println!(" {sym:<14} {}", names.join(","));
315 }
316}
317
318fn looks_like_symbol(tok: &str) -> bool {
319 tok.contains('.') && !tok.starts_with('-')
320}
321
322async fn sub_inline(
323 args: &[String],
324 state: &Arc<ReplState>,
325 _printer: &SharedPrinter,
326) -> Result<()> {
327 let (symbols, types_csv) = split_sub_args(args)?;
329 let secs: Vec<Security> = symbols
330 .iter()
331 .map(|s| parse_symbol(s))
332 .collect::<Result<_>>()?;
333 let sub_types: Vec<SubType> = types_csv
334 .split(',')
335 .map(parse_sub_type)
336 .collect::<Result<_>>()?;
337
338 let client = state.client.lock().await.clone();
339 futu_qot::sub::subscribe(&client, &secs, &sub_types, true, true).await?;
340 {
341 let mut guard = state.subs.lock().await;
342 for s in &symbols {
343 let entry = guard.entry(s.clone()).or_default();
344 for t in &sub_types {
345 entry.insert(*t);
346 }
347 }
348 }
349 eprintln!("✓ subscribed symbols={symbols:?} types={types_csv}");
350 Ok(())
351}
352
353async fn unsub_inline(args: &[String], state: &Arc<ReplState>) -> Result<()> {
354 if args.is_empty() {
355 bail!("usage: unsub <SYMBOL> [csv]");
356 }
357 let symbol = args[0].clone();
358 let types_csv = args.get(1).cloned();
359
360 let sec = parse_symbol(&symbol)?;
361 let sub_types: Vec<SubType> = match types_csv {
362 Some(csv) if !csv.is_empty() => {
363 csv.split(',').map(parse_sub_type).collect::<Result<_>>()?
364 }
365 _ => {
366 let guard = state.subs.lock().await;
367 guard
368 .get(&symbol)
369 .map(|s| s.iter().copied().collect())
370 .unwrap_or_default()
371 }
372 };
373 if sub_types.is_empty() {
374 bail!("no active subscription for {symbol}");
375 }
376
377 let client = state.client.lock().await.clone();
378 futu_qot::sub::unsubscribe(&client, std::slice::from_ref(&sec), &sub_types).await?;
379 {
380 let mut guard = state.subs.lock().await;
381 if let Some(set) = guard.get_mut(&symbol) {
382 for t in &sub_types {
383 set.remove(t);
384 }
385 if set.is_empty() {
386 guard.remove(&symbol);
387 }
388 }
389 }
390 eprintln!(
391 "✓ unsubscribed {symbol} types=[{}]",
392 sub_types
393 .iter()
394 .map(|t| sub_type_label(*t))
395 .collect::<Vec<_>>()
396 .join(",")
397 );
398 Ok(())
399}
400
401fn split_sub_args(args: &[String]) -> Result<(Vec<String>, String)> {
403 let mut symbols = Vec::new();
404 let mut types = "basic".to_string();
405 let mut i = 0;
406 while i < args.len() {
407 let a = &args[i];
408 if a == "-t" || a == "--type" {
409 i += 1;
410 types = args
411 .get(i)
412 .ok_or_else(|| anyhow!("-t needs a value"))?
413 .clone();
414 } else if let Some(rest) = a.strip_prefix("--type=") {
415 types = rest.to_string();
416 } else if looks_like_symbol(a) {
417 symbols.push(a.clone());
418 } else {
419 bail!("unexpected token {a:?}");
420 }
421 i += 1;
422 }
423 if symbols.is_empty() {
424 bail!("usage: sub <SYMBOL...> [-t csv]");
425 }
426 Ok((symbols, types))
427}
428
429struct PrintHandler {
432 printer: SharedPrinter,
433}
434
435impl PrintHandler {
436 async fn emit(&self, s: String) {
437 let mut g = self.printer.lock().await;
438 g.print(format!("{s}\n"));
439 }
440}
441
442#[async_trait]
443impl QuoteHandler for PrintHandler {
444 async fn on_basic_qot_update(&self, qot_list: Vec<BasicQot>) {
445 for q in qot_list {
446 let sym = format_symbol(&q.security);
447 let change = q.cur_price - q.last_close_price;
448 let pct = if q.last_close_price != 0.0 {
449 change / q.last_close_price * 100.0
450 } else {
451 0.0
452 };
453 let sign = if change >= 0.0 { "+" } else { "" };
454 self.emit(format!(
455 "[{}] basic {sym:<12} px={:.3} {sign}{change:.3} ({sign}{pct:.2}%) vol={}",
456 q.update_time, q.cur_price, q.volume
457 ))
458 .await;
459 }
460 }
461
462 async fn on_kl_update(&self, security: Security, kl_list: Vec<KLine>) {
463 let sym = format_symbol(&security);
464 for k in kl_list {
465 self.emit(format!(
466 "[{}] kl {sym:<12} O={:.3} H={:.3} L={:.3} C={:.3} V={}",
467 k.time, k.open_price, k.high_price, k.low_price, k.close_price, k.volume
468 ))
469 .await;
470 }
471 }
472
473 async fn on_order_book_update(&self, data: OrderBookData) {
474 let sym = format_symbol(&data.security);
475 let top_bid = data.bid_list.first();
476 let top_ask = data.ask_list.first();
477 let msg = match (top_bid, top_ask) {
478 (Some(b), Some(a)) => format!(
479 " ob {sym:<12} bid={:.3}x{} ask={:.3}x{} spr={:.3}",
480 b.price,
481 b.volume,
482 a.price,
483 a.volume,
484 a.price - b.price
485 ),
486 _ => format!(" ob {sym:<12} (empty)"),
487 };
488 self.emit(msg).await;
489 }
490
491 async fn on_ticker_update(
492 &self,
493 security: Security,
494 ticker_list: Vec<futu_qot::ticker::Ticker>,
495 ) {
496 let sym = format_symbol(&security);
497 for t in ticker_list {
498 self.emit(format!(
499 " tick {sym:<12} px={:.3} vol={} dir={}",
500 t.price, t.volume, t.dir
501 ))
502 .await;
503 }
504 }
505
506 async fn on_rt_update(&self, security: Security, rt_list: Vec<futu_qot::rt::TimeShare>) {
507 let sym = format_symbol(&security);
508 for r in rt_list {
509 self.emit(format!(
510 "[{}] rt {sym:<12} px={:.3} avg={:.3} vol={}",
511 r.time, r.price, r.avg_price, r.volume
512 ))
513 .await;
514 }
515 }
516}
517
518async fn push_loop(mut rx: PushReceiver, printer: SharedPrinter) {
519 let handler = PrintHandler { printer };
520 while let Some(msg) = rx.recv().await {
521 if let Err(e) = QuotePushDispatcher::dispatch(&handler, msg.proto_id, &msg.body).await {
522 let mut g = handler.printer.lock().await;
523 g.print(format!("push dispatch error: {e}\n"));
524 }
525 }
526}
527
528fn sub_type_label(t: SubType) -> &'static str {
531 t.short_label().unwrap_or("other")
532}
533
534fn history_file_path() -> Option<PathBuf> {
535 let base = dirs::cache_dir()?;
536 Some(base.join("futucli").join("history"))
537}