Skip to main content

futucli/cmd/
repl.rs

1//! `futucli repl` — 交互式 REPL
2//!
3//! 特性:
4//! - 共享一条 FutuClient 长连接,避免每条命令重连网关
5//! - 复用所有子命令(通过 `cli::dispatch`)
6//! - rustyline 行编辑:↑↓ 历史、Ctrl-R 反向搜索、Ctrl-D 退出
7//! - 历史持久化到 ~/.cache/futucli/history(或 $XDG_CACHE_HOME)
8//! - 订阅推送实时打印且不打断 prompt(`ExternalPrinter`)
9//! - REPL 专属命令:help / exit / reconnect / subs / unsub
10//!
11//! 实现要点:`rustyline::readline` 是阻塞式 API,放进 `tokio::task::spawn_blocking`
12//! 以与 async 命令共存。
13
14use 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
33/// REPL 运行时状态。订阅状态按 `Security → 订阅的 SubType 集合` 记录,
34/// 用 `BTreeMap` 保证 `subs` 命令输出稳定可读。
35struct ReplState {
36    gateway: String,
37    output: OutputFormat,
38    client: Mutex<Arc<FutuClient>>,
39    subs: Mutex<BTreeMap<String, HashSet<SubType>>>,
40}
41
42/// 推送打印器:tty 下走 rustyline ExternalPrinter(不撞 prompt),
43/// 非 tty(pipe / 重定向)下降级到 stderr,保证脚本 / 测试里 REPL 仍可用。
44enum 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
69/// 包一层 tokio Mutex,跨 task 共享。
70type SharedPrinter = Arc<Mutex<PushPrinter>>;
71
72pub async fn run(gateway: &str, output: OutputFormat) -> Result<()> {
73    // 1. 建立长连接
74    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    // 2. rustyline editor + 历史文件
85    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    // 3. ExternalPrinter:供 push task 打印,不打断 prompt;
92    //    非 tty 环境(pipe)直接降级到 stderr。
93    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    // 4. 启动 push 消费 task
103    tokio::spawn(push_loop(push_rx, printer.clone()));
104
105    // 5. 主循环:readline → 解析 → dispatch
106    print_banner(gateway);
107    loop {
108        // rustyline 的 readline 是阻塞的,放到 blocking 线程里
109        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        // 6. 派发:REPL 专属 > 子命令
142        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    // 退出前保存历史
150    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            // 首次运行无历史文件属正常。
161        }
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
197/// 路由一行输入。REPL 专属命令优先;否则走 clap 解析再转 `cli::dispatch`。
198async 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            // 覆盖顶层 `sub` 子命令,改为 REPL 内部后台订阅
224            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    // clap 解析(注入一个伪 argv[0])
237    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            // clap 的帮助 / 错误消息自己会带换行;直接打出
244            eprint!("{e}");
245            return Ok(ShouldContinue::Continue);
246        }
247    };
248
249    // REPL 里禁止再次进入 repl
250    if matches!(parsed.command, Command::Repl) {
251        bail!("already in REPL");
252    }
253
254    // 使用 REPL 的 gateway/output,忽略解析到的(避免 REPL 里切换网关造成长连接失效)
255    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
263// ========== REPL 专属命令实现 ==========
264
265fn 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    // 用法: sub <SYMBOL...> [-t csv]
328    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
401/// 拆 `sub HK.00700 US.AAPL -t basic,rt` → (["HK.00700","US.AAPL"], "basic,rt")
402fn 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
429// ========== push 异步打印 ==========
430
431struct 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
528// ========== util ==========
529
530fn 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}