Skip to main content

ek_ui/
plugin.rs

1//! `UiPlugin` —— 总线面:`eLog` 入环形日志、`ePlugUp/Down` 记事件 + rev、
2//! `eBar`./`eBarUpdate.` 记行情库(K线窗数据面)、`eTick.` 记 tick 库(v4 行情带
3//! 唯一源,S7 上屏地面)、
4//! `eTimer`(1s 宿主自带)拉一次 `svctl.list` + 逐插件服务目录组快照 +
5//! ask oms 全量查询组交易监控帧(S5),
6//! 并辞行窗口面的操作指令队列(svctl 起停 + dm.reload 回灌 + oms 五段管线
7//! 下单/守卫撤单)。
8//!
9//! 纪律:UI 不直调任何插件;唯一管制面是 ask 契约(svctl / dm.reload /
10//! oms 查询与 submit_order·cancel_order / ctp 配置订阅,全部与
11//! headless 同源;`PluginContext` 只在 worker 可用)。0.1.1 BD-UI-30:
12//! 下单/撤单直发网关的旁路口删除——风控与守卫不可绕过。0.2.10 BD-UI-38:
13//! sim 纸面网关退场(流控查询口随之撤)。
14
15use std::sync::OnceLock;
16use std::time::Duration;
17
18use ek_core::context::PluginContext;
19use ek_core::plugin::Plugin;
20use ek_core::service::{PlugNotice, ServiceDesc};
21use ek_core::{EVENT_PLUG_DOWN, EVENT_PLUG_UP};
22use ek_ctp::plugin::{
23    ConfigureReply, ConfigureReq, DisconnectReply, DisconnectReq, GatewayStatus, StatusReq,
24    SubscribeReply as CtpSubscribeReply, SubscribeReq as CtpSubscribeReq,
25};
26use ek_data::account::AccountData;
27use ek_data::event::{EVENT_BAR, EVENT_CONTRACT, EVENT_LOG, EVENT_TICK};
28use ek_data::log::LogData;
29use ek_data::order::OrderData;
30use ek_data::position::PositionData;
31use ek_data::trade::TradeData;
32use ek_dm::plugin::{EVENT_BAR_UPDATE, ReloadReq};
33use ek_event::EVENT_TIMER;
34use ek_oms::plugin::{
35    GetAllAccountsReq, GetAllOrdersReq, GetAllPositionsReq, GetAllTradesReq, OmsCancelOrderReq,
36    OmsCancelReply, PipelineStatusReq, SubmitOrderReq,
37};
38use ek_oms::{SubmitReply, SubmitStatus};
39use ek_station::wire::{
40    ListReq, PlugRow, RemoveReq, RemoveRes, SVC_LIST, SVC_REMOVE, SVC_START, StartReq, StartRes,
41};
42
43use crate::state::{BusSnap, UiShared};
44
45/// 注册名 = 工厂名 = `"ui"`(BD-U5:两者一致)。
46pub const UI_NAME: &str = "ui";
47
48/// oms 四张全量表的 ask 时限(0.4.5,P7b 超时分级)。
49///
50/// 1 秒而不是 2 秒:这四口是 oms 内存查询,正常个位数毫秒,2 秒从来不是给
51/// 「查询本身」留的余量,而是给「信封排在几百条成交回报后面」留的。分级把
52/// 单拍停顿上限从 `4 × 2s` 压到 `4 × 1s`。`svctl.list` 与 `{gw}.status` 留 2 秒
53/// (前者可能正撞 `close_one` 的 2 秒摘除窗口,后者可能正撞网关查询协调点的
54/// 900ms 墙钟护栏 —— 那两处是真会慢的)。
55/// 这一条必须与 P7a(超时保旧帧)同版落地:单独降时限只会让空帧更频繁。
56pub const OMS_ASK_TIMEOUT: Duration = Duration::from_secs(1);
57
58/// 一张表的读数裁决(0.4.5,P7a)。
59///
60/// 三档必须各自判得出来(钉在 `tests/ui_ops.rs` 的 u4):
61/// - `Ok` → 用新读数;
62/// - `Timeout` → **保留上一帧**。问不到 ≠ 没有:原先 `unwrap_or_default()` 把四张表
63///   一起清空,而那一刻可能正有一笔单在场上 —— 空帧是「断言此刻没有委托」,
64///   比「可能旧的」更坏。
65/// - 其余(`TargetGone` / `UnknownService` / `InvalidPayload` / `BusStopped`)→ 清空。
66///   这一档是**事实**:oms 不在场就没有委托,留着旧帧才是骗人。
67///
68/// 与 `svctl.list` 那侧对齐:那里超时从来不碰快照(`set_snapshot` 只在 `Ok` 分支),
69/// 同一个「超时」在两处口径不一致就是本轮读 P7 时发现的病。
70pub fn merge_frame<T>(res: Result<Vec<T>, ek_core::error::AskError>, prev: Vec<T>) -> Vec<T> {
71    match res {
72        Ok(v) => v,
73        Err(ek_core::error::AskError::Timeout { .. }) => prev,
74        Err(_) => Vec::new(),
75    }
76}
77
78/// 常驻观测插件(窗口面在 [`crate::runtime`] 持 [`UiShared`] 渲染)。
79pub struct UiPlugin {
80    shared: std::sync::Arc<UiShared>,
81    ctx: OnceLock<PluginContext>,
82}
83
84impl UiPlugin {
85    /// 与窗口面共用的共享状态句柄。
86    pub fn new(shared: std::sync::Arc<UiShared>) -> Self {
87        Self {
88            shared,
89            ctx: OnceLock::new(),
90        }
91    }
92
93    /// eTimer 节流回调:先辞行操作指令(有则记录),再组一次快照。
94    fn refresh(&self) {
95        let Some(ctx) = self.ctx.get() else {
96            return; // 未 init 就吃到 eTimer:忽略,等下轮
97        };
98        self.run_cmds(ctx);
99        match ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(2)) {
100            Ok(rows) => {
101                let mut services = Vec::with_capacity(rows.len());
102                for r in &rows {
103                    let ids = ctx
104                        .services_of(&r.name)
105                        .map(|v| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>())
106                        .unwrap_or_default();
107                    services.push((r.name.clone(), ids));
108                }
109                // 账户连接位回填 + 新上线检测(订阅重放判据,规划 §5.C)。
110                let newly = self.shared.sync_accounts_connected(&rows);
111                // 0.4.5(P7d):邮箱溢出的当场显形。运维窗那一列一直有 spilled,
112                // 但 ek-core 的 stderr 诊断每 128 条才打一条 —— 屏上此前没有任何东西。
113                self.shared.note_plugin_spill(&rows);
114                self.shared.set_snapshot(BusSnap { rows, services });
115                let md = self.shared.md_source();
116                let subs = self.shared.subscriptions();
117                for gw in newly {
118                    if !md.is_empty() && gw == md && !subs.is_empty() {
119                        self.shared.push_cmd(crate::state::UiCmd::CtpSubscribe {
120                            gateway: gw.clone(),
121                            symbols: subs.clone(),
122                        });
123                        self.shared
124                            .log_line(format!("[ops] 行情源 {gw} 上线,重放订阅 {} 项", subs.len()));
125                    }
126                }
127            }
128            Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e:?}")),
129        }
130        // S5 交易监控:ask oms 全量查询组帧(0.4.5 起三档裁决,见 `merge_frame`)。
131        // P7b 超时分级(1s)+ P7a 超时保旧帧,两条同版落地 —— 单独降时限只会让空帧更频繁。
132        let orders = ctx.ask_service::<GetAllOrdersReq, Vec<OrderData>>(
133            "oms.get_all_orders",
134            GetAllOrdersReq,
135            OMS_ASK_TIMEOUT,
136        );
137        let trades = ctx.ask_service::<GetAllTradesReq, Vec<TradeData>>(
138            "oms.get_all_trades",
139            GetAllTradesReq,
140            OMS_ASK_TIMEOUT,
141        );
142        let positions = ctx.ask_service::<GetAllPositionsReq, Vec<PositionData>>(
143            "oms.get_all_positions",
144            GetAllPositionsReq,
145            OMS_ASK_TIMEOUT,
146        );
147        let accounts = ctx.ask_service::<GetAllAccountsReq, Vec<AccountData>>(
148            "oms.get_all_accounts",
149            GetAllAccountsReq,
150            OMS_ASK_TIMEOUT,
151        );
152        let timed_out =
153            |e: &ek_core::error::AskError| matches!(e, ek_core::error::AskError::Timeout { .. });
154        let stale = orders.as_ref().err().is_some_and(timed_out)
155            || trades.as_ref().err().is_some_and(timed_out)
156            || positions.as_ref().err().is_some_and(timed_out)
157            || accounts.as_ref().err().is_some_and(timed_out);
158        let prev = self.shared.trade_snap();
159        self.shared.set_trade_snap(crate::state::TradeSnap {
160            orders: merge_frame(orders, prev.orders.clone()),
161            trades: merge_frame(trades, prev.trades.clone()),
162            positions: merge_frame(positions, prev.positions.clone()),
163            accounts: merge_frame(accounts, prev.accounts.clone()),
164        });
165        // 旧帧必须被标明是旧的:状态位给渲染侧(`trade_frame_stale`),日志只在**迁移**
166        // 那一次落一条 —— 拥堵期每拍都问不到,每拍刷一条会把日志环挤满。
167        if self.shared.note_trade_frame_stale(stale) {
168            self.shared.log_line(if stale {
169                "[ui] oms 四问有超时:交易监控显示的是上一帧(不是此刻没有委托)".to_string()
170            } else {
171                "[ui] oms 四问恢复:交易监控回到当前帧".to_string()
172            });
173        }
174        // 0.2.19 状态轮询(规划 P3 / 裁定 3):每 `STATUS_POLL_TICKS` 拍向每个
175        // **已装配且在场**的账户问一次 `{gw}.status`,把读数折成状态列的登录态。
176        //
177        // 只问在场的:实例已被摘除时 ask 会等到超时(2s),按账户串行就是按账户把
178        // 整个刷新循环拖住 —— 而那种账户本来也没有会话可问。
179        // 只读口不写日志(ek-ctp D-STATUS ④),所以这条轮询不会冲 eLog 环。
180        if self.shared.status_poll_due() {
181            for a in self.shared.accounts() {
182                if a.stage != crate::state::AccountStage::Assembled || !a.connected {
183                    continue;
184                }
185                let svc = format!("{}.status", a.gateway);
186                match ctx.ask_service::<StatusReq, GatewayStatus>(
187                    &svc,
188                    StatusReq,
189                    Duration::from_secs(2),
190                ) {
191                    Ok(st) => self.shared.note_gateway_status(&a.gateway, &st),
192                    Err(e) => self
193                        .shared
194                        .note_status_unreachable(&a.gateway, &format!("{e:?}")),
195                }
196            }
197        }
198        // 0.4.4(P5-a 裁定接的那条服务):`oms.pipeline_status` 上运维窗。
199        // 插件表给的是「谁在场」,这一节给的是「oms 里面装了什么」——五段管线、
200        // 限速与撤单守卫、拆单器、每道风控闸一行(工厂注入的是 ek_risk::standard_gates
201        // 缺省限额那四道)。此前这条服务在全链**零消费方**(0.3.x 加它时是「可观测性
202        // 补偿」,但界面没有出口),只在 ek-oms 的 on_start 日志里显形一次。
203        // 节拍比状态轮询再慢一档(PIPELINE_POLL_TICKS = 4):装配很少变,而它是 oms
204        // worker 上又一次串行 ask(DESIGN 0.4.3 段 P7 那本账)。
205        if self.shared.pipeline_poll_due() {
206            match ctx.ask_service::<PipelineStatusReq, Vec<String>>(
207                "oms.pipeline_status",
208                PipelineStatusReq,
209                Duration::from_secs(2),
210            ) {
211                Ok(lines) => self.shared.set_pipeline_lines(lines),
212                Err(e) => {
213                    // 不每拍刷日志:oms 不在场是合法态(清单里没选它,ask 走 TargetGone
214                    // 快错)。只在「从有到无」那一次显形一条,与状态轮询「只读口不冲 eLog 环」
215                    // 同口径。读数清空 → 运维窗那一节整节不画。
216                    if !self.shared.pipeline_lines().is_empty() {
217                        self.shared
218                            .log_line(format!("[ui] oms.pipeline_status 失手: {e:?}"));
219                    }
220                    self.shared.set_pipeline_lines(Vec::new());
221                }
222            }
223        }
224    }
225
226    /// 辞行指令队列(S2 svctl 操作面;回执进日志,面板语义可断言)。
227    fn run_cmds(&self, ctx: &PluginContext) {
228        for cmd in self.shared.drain_cmds() {
229            match cmd {
230                crate::state::UiCmd::Start { factory, name } => {
231                    match ctx.ask_service::<StartReq, StartRes>(
232                        SVC_START,
233                        StartReq {
234                            factory: factory.clone(),
235                            name: name.clone(),
236                        },
237                        Duration::from_secs(3),
238                    ) {
239                        Ok(StartRes::Started { name }) => {
240                            self.shared
241                                .log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
242                        }
243                        Ok(StartRes::Rejected { reason }) => {
244                            self.shared
245                                .log_line(format!("[ops] start {factory} 被拒: {reason}"));
246                        }
247                        Err(e) => {
248                            self.shared
249                                .log_line(format!("[ops] start {factory} 失败: {e:?}"));
250                        }
251                    }
252                }
253                crate::state::UiCmd::Remove { name } => {
254                    // 观测面自身的闸(0.4.3 BD-UI-64,审核 P1):这条指令是在 ui 插件自己的
255                    // worker 里执行的 —— 摘 ui = 自己等自己(ek-core `close_one` 只能走到
256                    // 2 秒超时的 detach 分支),而且摘完就没有下一拍 eTimer 来执行任何指令,
257                    // 窗口还开着但快照/日志/指令队列全停。所以在这里直拒,不发 svctl。
258                    // 管制面 `svctl` 的同一道闸长在 ek-station supervisor 侧(0.2.11)。
259                    if name == UI_NAME {
260                        self.shared.log_line(
261                            "[ops] 拒绝移除 ui:观测面自身不可自摘(摘掉后没有下一拍执行任何指令)"
262                                .to_string(),
263                        );
264                        continue;
265                    }
266                    match ctx.ask_service::<RemoveReq, RemoveRes>(
267                        SVC_REMOVE,
268                        RemoveReq { name: name.clone() },
269                        Duration::from_secs(3),
270                    ) {
271                        Ok(RemoveRes::Removed { name }) => {
272                            self.shared
273                                .log_line(format!("[ops] svctl.remove {name} 完成"));
274                        }
275                        Ok(RemoveRes::NoSuchName { name }) => {
276                            self.shared
277                                .log_line(format!("[ops] remove {name} 不存在/不可摘"));
278                        }
279                        // 0.4.3:回执枚举增项(ek-station 0.2.11)—— 名字在场但不可摘,
280                        // 或宿主另有错。原先这一档被塞进 NoSuchName 的 name 栏(错误文本
281                        // 当名字打),这一栏现在照请求回填,错因只进 reason。
282                        Ok(RemoveRes::Rejected { name, reason }) => {
283                            self.shared
284                                .log_line(format!("[ops] remove {name} 被拒: {reason}"));
285                        }
286                        Err(e) => {
287                            self.shared
288                                .log_line(format!("[ops] remove {name} 失败: {e:?}"));
289                        }
290                    }
291                }
292                crate::state::UiCmd::Reload {
293                    interval,
294                    symbol,
295                    bars,
296                } => {
297                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
298                    match ctx.ask_service::<ReloadReq, usize>(
299                        "dm.reload",
300                        ReloadReq {
301                            interval,
302                            symbol: symbol.clone(),
303                            bars: bars.clone(),
304                            size: Some(bars.len()),
305                            push: true,
306                        },
307                        Duration::from_secs(5),
308                    ) {
309                        Ok(n) => {
310                            self.shared.market().note_reload();
311                            self.shared.log_line(format!(
312                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
313                            ));
314                        }
315                        Err(e) => {
316                            self.shared
317                                .log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
318                        }
319                    }
320                }
321                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
322                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
323                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
324                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
325                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
326                        "oms.submit_order",
327                        SubmitOrderReq {
328                            req: req.clone(),
329                            gateway_name: gateway.clone(),
330                            mode,
331                        },
332                        Duration::from_secs(10),
333                    ) {
334                        Ok(reply) => {
335                            let tag = match reply.status {
336                                SubmitStatus::Sent => "SENT",
337                                SubmitStatus::Partial => "PARTIAL",
338                                SubmitStatus::Rejected => "REJECTED",
339                            };
340                            for leg in &reply.legs {
341                                match (leg.ok, &leg.en_orderid, &leg.reason) {
342                                    (true, Some(id), _) => self.shared.log_line(format!(
343                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
344                                        leg.req.symbol,
345                                        leg.req.direction.as_str(),
346                                        leg.req.offset.as_str(),
347                                        leg.req.volume,
348                                        reply.parent_id
349                                    )),
350                                    _ => self.shared.log_line(format!(
351                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
352                                        leg.req.symbol,
353                                        leg.req.direction.as_str(),
354                                        leg.req.offset.as_str(),
355                                        leg.req.volume,
356                                        leg.reason.as_deref().unwrap_or("?"),
357                                        reply.parent_id
358                                    )),
359                                }
360                            }
361                            if reply.legs.is_empty() {
362                                self.shared.log_line(format!(
363                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
364                                    req.symbol,
365                                    req.offset.as_str(),
366                                    req.volume,
367                                    reply.parent_id
368                                ));
369                            }
370                        }
371                        Err(e) => {
372                            self.shared
373                                .log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
374                        }
375                    }
376                }
377                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
378                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
379                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
380                    // OrderData.en_orderid() 同式。
381                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
382                        "oms.cancel_order",
383                        OmsCancelOrderReq {
384                            en_orderid: format!("{gateway}.{orderid}"),
385                            gateway_name: gateway.clone(),
386                        },
387                        Duration::from_secs(10),
388                    ) {
389                        Ok(OmsCancelReply { ok: true, msg }) => {
390                            self.shared.log_line(format!("[ops] {msg}"));
391                        }
392                        Ok(OmsCancelReply { ok: false, msg }) => {
393                            self.shared.log_line(format!("[ops] 撤单被拒: {msg}"));
394                        }
395                        Err(e) => {
396                            self.shared
397                                .log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
398                        }
399                    }
400                }
401                crate::state::UiCmd::CtpConfigure { gateway, form } => {
402                    // 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
403                    // 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
404                    let svc = format!("{gateway}.configure");
405                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
406                        svc.as_str(),
407                        form.to_configure_req(),
408                        Duration::from_secs(5),
409                    ) {
410                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
411                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
412                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
413                        Ok(ConfigureReply { ok: true, msg, .. }) => {
414                            self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
415                        }
416                        Ok(ConfigureReply { ok: false, msg, .. }) => {
417                            self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
418                        }
419                        Err(e) => {
420                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
421                        }
422                    }
423                }
424                crate::state::UiCmd::Connect { gateway, form } => {
425                    // **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
426                    // `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
427                    // (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
428                    // 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
429                    self.shared.note_connect_sent(&gateway);
430                    // 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
431                    // (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
432                    let form = self.shared.prepare_connect_form(&gateway, form);
433                    let started = match ctx.ask_service::<StartReq, StartRes>(
434                        SVC_START,
435                        StartReq {
436                            factory: "ctp".to_string(),
437                            name: gateway.clone(),
438                        },
439                        Duration::from_secs(3),
440                    ) {
441                        Ok(StartRes::Started { name }) => {
442                            self.shared
443                                .log_line(format!("[ops] svctl.start ctp → {name} 上线"));
444                            true
445                        }
446                        Ok(StartRes::Rejected { reason }) => {
447                            // 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
448                            // 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
449                            if self.shared.account(&gateway).is_some_and(|a| a.connected) {
450                                self.shared
451                                    .log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
452                                true
453                            } else {
454                                self.shared
455                                    .log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
456                                self.shared.note_connect_result(&gateway, Err(reason));
457                                continue;
458                            }
459                        }
460                        Err(e) => {
461                            self.shared
462                                .log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
463                            self.shared
464                                .note_connect_result(&gateway, Err(format!("{e:?}")));
465                            continue;
466                        }
467                    };
468                    if !started {
469                        continue;
470                    }
471                    let svc = format!("{gateway}.configure");
472                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
473                        svc.as_str(),
474                        form.to_configure_req(),
475                        Duration::from_secs(5),
476                    ) {
477                        // `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
478                        // (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
479                        // 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
480                        Ok(ConfigureReply {
481                            ok: true,
482                            msg,
483                            product_classes,
484                        }) => {
485                            self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
486                            self.shared
487                                .note_effective_product_classes(&gateway, &product_classes);
488                            self.shared.note_connect_result(&gateway, Ok(()));
489                        }
490                        Ok(ConfigureReply { ok: false, msg, .. }) => {
491                            self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
492                            self.shared.note_connect_result(&gateway, Err(msg));
493                        }
494                        Err(e) => {
495                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
496                            self.shared
497                                .note_connect_result(&gateway, Err(format!("{e:?}")));
498                        }
499                    }
500                }
501                crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
502                    // 多账号面:一律路由到行情元账号(渲染侧已按此入列);
503                    // 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
504                    // 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
505                    let svc = format!("{gateway}.subscribe");
506                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
507                        svc.as_str(),
508                        CtpSubscribeReq {
509                            symbols: symbols.clone(),
510                        },
511                        Duration::from_secs(5),
512                    ) {
513                        Ok(CtpSubscribeReply { ok: true, msg }) => {
514                            self.shared.note_subscriptions(&symbols);
515                            self.shared.log_line(format!(
516                                "[ops] {svc} {} 受理:{}",
517                                symbols.join(","),
518                                msg
519                            ));
520                        }
521                        Ok(CtpSubscribeReply { ok: false, msg }) => {
522                            self.shared.log_line(format!(
523                                "[ops] {svc} {} 被拒:{}",
524                                symbols.join(","),
525                                msg
526                            ));
527                        }
528                        Err(e) => {
529                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
530                        }
531                    }
532                }
533                crate::state::UiCmd::Disconnect { gateway } => {
534                    // 0.2.19 规划裁定 1:『断开』= `{gw}.disconnect`(停会话),
535                    // 『移除』= `svctl.remove`(停实例)—— 两条走两条不同的口,不共用。
536                    // 发令即把该行标成「已断开」:用户按了钮就要立刻看到,不等下一轮轮询。
537                    let svc = format!("{gateway}.disconnect");
538                    match ctx.ask_service::<DisconnectReq, DisconnectReply>(
539                        svc.as_str(),
540                        DisconnectReq,
541                        Duration::from_secs(5),
542                    ) {
543                        Ok(DisconnectReply { ok: true, msg }) => {
544                            self.shared.mark_account_stopped(&gateway);
545                            self.shared
546                                .log_line(format!("[账户] 断开 {gateway}:{msg}(实例仍在场)"));
547                        }
548                        Ok(DisconnectReply { ok: false, msg }) => {
549                            self.shared
550                                .log_line(format!("[账户] 断开 {gateway} 未执行:{msg}"));
551                        }
552                        Err(e) => {
553                            self.shared.log_line(format!(
554                                "[账户] 断开 {gateway} 失败:{e:?}(网关不在场或超时)",
555                            ));
556                        }
557                    }
558                }
559            }
560        }
561    }
562}
563
564impl Plugin for UiPlugin {
565    fn name(&self) -> &str {
566        UI_NAME
567    }
568
569    fn deps(&self) -> &[&str] {
570        &[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
571    }
572
573    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
574        vec![
575            std::borrow::Cow::Borrowed(EVENT_LOG),
576            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
577            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
578            std::borrow::Cow::Borrowed(EVENT_BAR),
579            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
580            std::borrow::Cow::Borrowed(EVENT_TICK),
581            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
582            std::borrow::Cow::Borrowed("eContractFinish"),
583            std::borrow::Cow::Borrowed(EVENT_TIMER),
584        ]
585    }
586
587    fn mailbox_cap(&self) -> usize {
588        1024
589    }
590
591    fn version(&self) -> std::borrow::Cow<'static, str> {
592        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
593    }
594
595    fn services(&self) -> Vec<ServiceDesc> {
596        Vec::new() // 纯视图零服务;管制面在 svctl
597    }
598
599    fn on_init(&self, ctx: &PluginContext) {
600        let _ = self.ctx.set(ctx.clone());
601    }
602
603    fn on_start(&self) {
604        self.shared.log_line("[ui] 界面插件上线");
605    }
606
607    fn on_stop(&self) {
608        self.shared.log_line("[ui] 界面插件下线");
609    }
610
611    fn on_mail(&self, event: &ek_event::Event) {
612        let ty = event.event_type.as_str();
613        if ty == EVENT_LOG {
614            if let Some(d) = event.data.downcast_ref::<LogData>() {
615                let tag = if d.is_warning_or_above() {
616                    "WARN"
617                } else {
618                    "INFO"
619                };
620                self.shared
621                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
622            } else {
623                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
624            }
625            return;
626        }
627        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
628            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
629                let verb = if ty == EVENT_PLUG_UP {
630                    "上线"
631                } else {
632                    "下线"
633                };
634                self.shared
635                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
636            }
637            self.shared.bump();
638            return;
639        }
640        if ty == EVENT_BAR {
641            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
642                self.shared.market().on_frame(f);
643            }
644            self.shared.bump();
645            return;
646        }
647        if ty == EVENT_BAR_UPDATE {
648            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
649                self.shared.market().on_update(b);
650            }
651            self.shared.bump();
652            return;
653        }
654        if ty == EVENT_TICK {
655            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
656                self.shared.update_tick(t);
657            }
658            return;
659        }
660        if ty == EVENT_CONTRACT {
661            if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
662                self.shared.update_contract(c);
663            }
664            return;
665        }
666        if ty == "eContractFinish" {
667            let gw = event
668                .data
669                .downcast_ref::<String>()
670                .cloned()
671                .unwrap_or_else(|| "?".to_string());
672            self.shared
673                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
674            return;
675        }
676        if ty == EVENT_TIMER {
677            self.refresh();
678        }
679    }
680}