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