Skip to main content

ek_ui/
plugin.rs

1//! `UiPlugin` —— 总线面:`eLog` 入环形日志、`ePlugUp/Down` 记事件 + rev、
2//! `eBar`./`eBarUpdate.` 记行情库(K线窗数据面)、`eTick.` 记 tick 库(v4 行情带
3//! 唯一源,S7 上屏地面)、`eContract.`/`eContractFinish` 记合约表,
4//! **0.5 M1 起 `eOrder.`/`eTrade.`/`ePosition.`/`eAccount.` 四条推送线入交易账本**
5//! (基线 BaseMonitor 同形:后台只 put,界面自己 process_event)。
6//! `eTimer`(1s 宿主自带)那一拍只做三件事:管制面快照(`svctl.list`,每
7//! `LIST_POLL_TICKS` 拍)、摊平的状态轮询(每拍最多 `STATUS_ASKS_PER_TICK` 个账户)、
8//! 被标记时才问一次的装配显形(`oms.pipeline_status`)。**这一拍不再问交易数据,
9//! 也不再辞行操作指令** —— 指令由命令线程执行(0.5 M5,点击即发)。
10//!
11//! 纪律(D1):UI 不周期取数;唯一例外是观测面 `svctl.list`(基线里对应物是同进程
12//! 直读,不是推送)。纪律(D2):后台只 put、不驱动界面 —— 入账 worker 里没有任何 ask。唯一管制面是 ask 契约(svctl / dm.reload /
13//! oms 查询与 submit_order·cancel_order / ctp 配置订阅,全部与
14//! headless 同源;`PluginContext` 只在 worker 可用)。0.1.1 BD-UI-30:
15//! 下单/撤单直发网关的旁路口删除——风控与守卫不可绕过。0.2.10 BD-UI-38:
16//! sim 纸面网关退场(流控查询口随之撤)。
17
18use std::sync::OnceLock;
19use std::time::Duration;
20
21use ek_core::context::PluginContext;
22use ek_core::plugin::Plugin;
23use ek_core::service::{PlugNotice, ServiceDesc};
24use ek_core::{EVENT_PLUG_DOWN, EVENT_PLUG_UP};
25use ek_ctp::plugin::{
26    ConfigureReply, ConfigureReq, DisconnectReply, DisconnectReq, GatewayStatus, StatusReq,
27    SubscribeReply as CtpSubscribeReply, SubscribeReq as CtpSubscribeReq,
28};
29use ek_data::account::AccountData;
30use ek_data::event::{EVENT_BAR, EVENT_CONTRACT, EVENT_LOG, EVENT_TICK};
31use ek_data::log::LogData;
32use ek_data::order::OrderData;
33use ek_data::position::PositionData;
34use ek_data::trade::TradeData;
35use ek_dm::plugin::{EVENT_BAR_UPDATE, ReloadReq};
36use ek_event::EVENT_TIMER;
37use ek_oms::plugin::{
38    GetAllAccountsReq, GetAllOrdersReq, GetAllPositionsReq, GetAllTradesReq, OmsCancelOrderReq,
39    OmsCancelReply, PipelineStatusReq, SubmitOrderReq,
40};
41use ek_oms::{SubmitReply, SubmitStatus};
42use ek_station::wire::{
43    ListReq, PlugRow, RemoveReq, RemoveRes, SVC_LIST, SVC_REMOVE, SVC_START, StartReq, StartRes,
44};
45
46use crate::state::{BusSnap, UiShared};
47
48/// 注册名 = 工厂名 = `"ui"`(BD-U5:两者一致)。
49pub const UI_NAME: &str = "ui";
50
51/// 界面邮箱容量(0.5 M6:取代对账机制)。
52///
53/// ek-event 的总线队列是无界的(照 Python `queue.Queue`,engine.rs:21-22),会丢事件的
54/// 只有 ek-core 这一跳有界邮箱 —— 而界面不再每秒自己问四次之后,它邮箱里就只剩
55/// 「别人推来的事件」。1024 格在 30 条/秒的行情下只够 34 秒积压;10 万格 ≈ 55 分钟,
56/// 溢出从常态变成异常。溢出仍然当场显形(0.4.5 P7d 的运维窗上色 + toast),但不再触发
57/// 自动修复:基线没有对账,因为它不丢事件。
58pub const UI_MAILBOX_CAP: usize = 100_000;
59
60/// 交易四表的推送线(基线 `event.py:13-18`;ek-ctp 0.4.0 起发裸键,ek-core 精确匹配)。
61/// 名字单源取 ek-oms::wire —— 同一条线在链上只有一份拼写。
62const W_ORDER: &str = ek_oms::wire::EVENT_ORDER;
63const W_TRADE: &str = ek_oms::wire::EVENT_TRADE;
64const W_POSITION: &str = ek_oms::wire::EVENT_POSITION;
65const W_ACCOUNT: &str = ek_oms::wire::EVENT_ACCOUNT;
66
67/// 常驻观测插件(窗口面在 [`crate::runtime`] 持 [`UiShared`] 渲染)。
68pub struct UiPlugin {
69    shared: std::sync::Arc<UiShared>,
70    ctx: OnceLock<PluginContext>,
71    /// 命令线程的停线位(0.5 M5;on_stop 置真,线程自己退)。
72    cmd_stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
73}
74
75impl UiPlugin {
76    /// 与窗口面共用的共享状态句柄。
77    pub fn new(shared: std::sync::Arc<UiShared>) -> Self {
78        Self {
79            shared,
80            ctx: OnceLock::new(),
81            cmd_stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
82        }
83    }
84
85    /// eTimer 节流回调(0.5:管制面快照 + 摊平的状态轮询 + 事件触发的装配显形)。
86    fn refresh(&self) {
87        let Some(ctx) = self.ctx.get() else {
88            return; // 未 init 就吃到 eTimer:忽略,等下轮
89        };
90        // 0.5 M3/M5:这一拍只做三件事,而且都不问交易数据。
91        // 指令辞行搬到命令线程(`spawn_cmd_thread`);四张表吃 eOrder. / eTrade. /
92        // ePosition. / eAccount. 推送入账(基线 BaseMonitor 同形)—— 这一拍里再出现
93        // 任何「取数类 ask」就是回到 0.4 的病。
94        if self.shared.list_poll_due() {
95            self.poll_list(ctx);
96        }
97        self.poll_status(ctx);
98        if self.shared.take_pipeline_due() {
99            self.poll_pipeline(ctx);
100        }
101    }
102
103    /// 管制面快照(`svctl.list`):谁在场 + 服务目录 + 邮箱溢出计数。
104    ///
105    /// 0.5 M3 降到每 `LIST_POLL_TICKS` 拍一次。它给的不是交易数据而是「宿主长什么样」
106    /// 的观测面 —— 基线里的对应物是 `get_all_gateway_names` 的同进程直读,不是推送,
107    /// 所以它留在白名单里(「界面不周期取数」这条纪律的唯一例外)。
108    fn poll_list(&self, ctx: &PluginContext) {
109        match ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(2)) {
110            Ok(rows) => {
111                let mut services = Vec::with_capacity(rows.len());
112                for r in &rows {
113                    let ids = ctx
114                        .services_of(&r.name)
115                        .map(|v| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>())
116                        .unwrap_or_default();
117                    services.push((r.name.clone(), ids));
118                }
119                // 账户在场位回填 + 「实例没了就作废会话读数」。
120                // 0.5:「新装配」不再由这里检测 —— ePlugUp. 已订阅,即时且不靠轮询。
121                self.shared.sync_accounts_connected(&rows);
122                // 0.4.5(P7d):邮箱溢出的当场显形。运维窗那一列一直有 spilled,
123                // 但 ek-core 的 stderr 诊断每 128 条才打一条 —— 屏上此前没有任何东西。
124                self.shared.note_plugin_spill(&rows);
125                self.shared.set_snapshot(BusSnap { rows, services });
126            }
127            Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e:?}")),
128        }
129    }
130
131    /// 状态轮询(0.5 M3 摊平):每拍最多问 `STATUS_ASKS_PER_TICK` 个账户,环形推进。
132    ///
133    /// 只问「已装配且在场」的:实例已被摘除时 ask 会等到超时(2 秒),按账户串行就是
134    /// 按账户把整个刷新循环拖住 —— 而那种账户本来也没有会话可问。只读口不写日志
135    /// (ek-ctp D-STATUS ④),所以这条轮询不会冲 eLog 环。
136    fn poll_status(&self, ctx: &PluginContext) {
137        let present: Vec<String> = self
138            .shared
139            .accounts()
140            .iter()
141            .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.connected)
142            .map(|a| a.gateway.clone())
143            .collect();
144        for gw in self.shared.status_batch(&present) {
145            let svc = format!("{gw}.status");
146            match ctx.ask_service::<StatusReq, GatewayStatus>(
147                &svc,
148                StatusReq,
149                Duration::from_secs(2),
150            ) {
151                Ok(st) => self.shared.note_gateway_status(&gw, &st),
152                Err(e) => self.shared.note_status_unreachable(&gw, &format!("{e:?}")),
153            }
154        }
155    }
156
157    /// 装配显形(0.5 M3 改事件触发):`oms.pipeline_status` 只在被标记的那一拍问一次。
158    ///
159    /// 标记来自启动、`ePlugUp.oms` 与运维窗的刷新钮。它此前是每 4 拍一次的轮询 ——
160    /// 装配很少变,而它是 oms worker 上又一次串行 ask;在事件驱动模型里这种轮询是多余的。
161    fn poll_pipeline(&self, ctx: &PluginContext) {
162        match ctx.ask_service::<PipelineStatusReq, Vec<String>>(
163            "oms.pipeline_status",
164            PipelineStatusReq,
165            Duration::from_secs(2),
166        ) {
167            Ok(lines) => self.shared.set_pipeline_lines(lines),
168            Err(e) => {
169                // 不每拍刷日志:oms 不在场是合法态(清单里没选它,ask 走 TargetGone
170                // 快错)。只在「从有到无」那一次显形一条,与状态轮询「只读口不冲 eLog 环」
171                // 同口径。读数清空 → 运维窗那一节整节不画。
172                if !self.shared.pipeline_lines().is_empty() {
173                    self.shared
174                        .log_line(format!("[ui] oms.pipeline_status 失手: {e:?}"));
175                }
176                self.shared.set_pipeline_lines(Vec::new());
177            }
178        }
179    }
180
181    /// 启动一次四问(0.5 M4;基线无对应物 —— 插件热插拔是 ek 独有场景)。
182    ///
183    /// 一次性,不是周期:之后四张表只吃推送。走 `note_*` 而不是 `seed_trade_snap`,
184    /// 是为了**合并而不是覆盖** —— 网关可能在 oms 还没消化完之前就已经把委托推给了
185    /// 界面,覆盖式播种会把界面已经看见的那一笔抹掉。
186    ///
187    /// 执行位置 = 命令线程(0.5 执行核查改的):原先它挂在 `on_start` 里,而 `on_start`
188    /// 是宿主启动序列的一部分 —— 四口最坏 4 × 2 秒会把后面插件的启动一起拖住;挂在
189    /// 入账 worker 上又违反「入账 worker 里没有任何 ask」。所以它跟着命令线程走。
190    fn reconcile_trade(shared: &UiShared, ctx: &PluginContext) {
191        let orders = ctx.ask_service::<GetAllOrdersReq, Vec<OrderData>>(
192            "oms.get_all_orders",
193            GetAllOrdersReq,
194            Duration::from_secs(2),
195        );
196        let trades = ctx.ask_service::<GetAllTradesReq, Vec<TradeData>>(
197            "oms.get_all_trades",
198            GetAllTradesReq,
199            Duration::from_secs(2),
200        );
201        let positions = ctx.ask_service::<GetAllPositionsReq, Vec<PositionData>>(
202            "oms.get_all_positions",
203            GetAllPositionsReq,
204            Duration::from_secs(2),
205        );
206        let funds = ctx.ask_service::<GetAllAccountsReq, Vec<AccountData>>(
207            "oms.get_all_accounts",
208            GetAllAccountsReq,
209            Duration::from_secs(2),
210        );
211        let mut n = 0usize;
212        if let Ok(rows) = &orders {
213            for o in rows {
214                shared.note_order(o);
215            }
216            n += rows.len();
217        }
218        if let Ok(rows) = &trades {
219            for t in rows {
220                shared.note_trade(t);
221            }
222            n += rows.len();
223        }
224        if let Ok(rows) = &positions {
225            for p in rows {
226                shared.note_position(p);
227            }
228            n += rows.len();
229        }
230        if let Ok(rows) = &funds {
231            for a in rows {
232                shared.note_funds(a);
233            }
234            n += rows.len();
235        }
236        // 只在真播种到东西时落一条;空清单(刚装配、柜台还没回报)不刷日志。
237        if n > 0 {
238            let bad = usize::from(orders.is_err())
239                + usize::from(trades.is_err())
240                + usize::from(positions.is_err())
241                + usize::from(funds.is_err());
242            shared.log_line(format!(
243                "[ui] 启动播种 {n} 行(委托 {} / 成交 {} / 持仓 {} / 资金 {};失手 {} 口)",
244                orders.as_ref().map_or(0, |v| v.len()),
245                trades.as_ref().map_or(0, |v| v.len()),
246                positions.as_ref().map_or(0, |v| v.len()),
247                funds.as_ref().map_or(0, |v| v.len()),
248                bad
249            ));
250        }
251    }
252
253    /// 命令线程(0.5 M5):点击即发,不等 eTimer 拍,也不占 ui 的入账 worker。
254    ///
255    /// 基线对应物是「UI 主线程直调 `main_engine.send_order`,立即返回」(engine.py:915-923)。
256    /// ek-core 的 ask 是同步的,所以把「等回包」这件事从入账线程挪走:入账线程从此只
257    /// 做「收事件 → 写账本」,每事件 O(1),永不被后端拖住。
258    /// 这一条线程也是「会等的东西」唯一允许待的地方:启动一次四问在这里跑
259    /// (见 `reconcile_trade`),既不占宿主的启动序列,也不占入账 worker。
260    fn spawn_cmd_thread(&self) {
261        let Some(ctx) = self.ctx.get().cloned() else {
262            return; // 未 init:没有管制面可用
263        };
264        let shared = std::sync::Arc::clone(&self.shared);
265        let stop = std::sync::Arc::clone(&self.cmd_stop);
266        if std::thread::Builder::new()
267            .name("ui-cmd".to_string())
268            .spawn(move || {
269                loop {
270                    if stop.load(std::sync::atomic::Ordering::Acquire) {
271                        break;
272                    }
273                    // 用户指令优先于播种:播种最坏 4 × 2 秒,不能把点击排到它后面。
274                    if shared.cmd_pending() {
275                        Self::run_cmds(&shared, &ctx);
276                        continue;
277                    }
278                    // 播种档:启动一次 + 之后每次网关 / oms 上线各一次(标记是消费性的)。
279                    if shared.take_trade_reseed() {
280                        Self::reconcile_trade(&shared, &ctx);
281                        continue;
282                    }
283                    std::thread::sleep(Duration::from_millis(20));
284                }
285            })
286            .is_err()
287        {
288            // 线程起不来 = 指令只会进队列不会执行。这一条必须上屏,不能静默降级。
289            self.shared
290                .log_line("[ui] 命令线程起不来:操作指令会滞留在队列里");
291        }
292    }
293
294    /// 辞行指令队列(0.5 M5:由命令线程调用,不在 ui worker 上;回执进日志,面板语义可断言)。
295    fn run_cmds(shared: &UiShared, ctx: &PluginContext) {
296        for cmd in shared.drain_cmds() {
297            match cmd {
298                crate::state::UiCmd::Start { factory, name } => {
299                    match ctx.ask_service::<StartReq, StartRes>(
300                        SVC_START,
301                        StartReq {
302                            factory: factory.clone(),
303                            name: name.clone(),
304                        },
305                        Duration::from_secs(3),
306                    ) {
307                        Ok(StartRes::Started { name }) => {
308                            shared.log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
309                        }
310                        Ok(StartRes::Rejected { reason }) => {
311                            shared.log_line(format!("[ops] start {factory} 被拒: {reason}"));
312                        }
313                        Err(e) => {
314                            shared.log_line(format!("[ops] start {factory} 失败: {e:?}"));
315                        }
316                    }
317                    // 起停回执之后重问一次管制面:运维窗那一行与账户在场位立刻动。
318                    shared.request_list_refresh();
319                }
320                crate::state::UiCmd::Remove { name } => {
321                    // 观测面自身的闸(0.4.3 BD-UI-64,审核 P1;0.5 M5 换了理由,闸不换):
322                    // 0.4 的理由是「这条指令在 ui 自己的 worker 上执行 → 摘 ui = 自己等自己
323                    // (ek-core `close_one` 只能走到 2 秒超时的 detach 分支)」。
324                    // 0.5 执行者换成命令线程,「自己等自己」这一半不成立了,但结论一样:
325                    // 摘掉 ui 之后没有 eTimer 拍再跑快照与状态轮询、入账 worker 也没了,
326                    // 窗口还开着而里面全是上一帧。所以仍然直拒,不发 svctl。
327                    // 管制面 `svctl` 的同一道闸长在 ek-station supervisor 侧(0.2.11)。
328                    if name == UI_NAME {
329                        shared.log_line(
330                            "[ops] 拒绝移除 ui:观测面自身不可自摘(摘掉后没有下一拍执行任何指令)"
331                                .to_string(),
332                        );
333                        continue;
334                    }
335                    match ctx.ask_service::<RemoveReq, RemoveRes>(
336                        SVC_REMOVE,
337                        RemoveReq { name: name.clone() },
338                        Duration::from_secs(3),
339                    ) {
340                        Ok(RemoveRes::Removed { name }) => {
341                            shared.log_line(format!("[ops] svctl.remove {name} 完成"));
342                        }
343                        Ok(RemoveRes::NoSuchName { name }) => {
344                            shared.log_line(format!("[ops] remove {name} 不存在/不可摘"));
345                        }
346                        // 0.4.3:回执枚举增项(ek-station 0.2.11)—— 名字在场但不可摘,
347                        // 或宿主另有错。原先这一档被塞进 NoSuchName 的 name 栏(错误文本
348                        // 当名字打),这一栏现在照请求回填,错因只进 reason。
349                        Ok(RemoveRes::Rejected { name, reason }) => {
350                            shared.log_line(format!("[ops] remove {name} 被拒: {reason}"));
351                        }
352                        Err(e) => {
353                            shared.log_line(format!("[ops] remove {name} 失败: {e:?}"));
354                        }
355                    }
356                    shared.request_list_refresh();
357                }
358                crate::state::UiCmd::Reload {
359                    interval,
360                    symbol,
361                    bars,
362                } => {
363                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
364                    match ctx.ask_service::<ReloadReq, usize>(
365                        "dm.reload",
366                        ReloadReq {
367                            interval,
368                            symbol: symbol.clone(),
369                            bars: bars.clone(),
370                            size: Some(bars.len()),
371                            push: true,
372                        },
373                        Duration::from_secs(5),
374                    ) {
375                        Ok(n) => {
376                            shared.market().note_reload();
377                            shared.log_line(format!(
378                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
379                            ));
380                        }
381                        Err(e) => {
382                            shared.log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
383                        }
384                    }
385                }
386                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
387                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
388                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
389                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
390                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
391                        "oms.submit_order",
392                        SubmitOrderReq {
393                            req: req.clone(),
394                            gateway_name: gateway.clone(),
395                            mode,
396                        },
397                        Duration::from_secs(10),
398                    ) {
399                        Ok(reply) => {
400                            let tag = match reply.status {
401                                SubmitStatus::Sent => "SENT",
402                                SubmitStatus::Partial => "PARTIAL",
403                                SubmitStatus::Rejected => "REJECTED",
404                            };
405                            for leg in &reply.legs {
406                                match (leg.ok, &leg.en_orderid, &leg.reason) {
407                                    (true, Some(id), _) => shared.log_line(format!(
408                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
409                                        leg.req.symbol,
410                                        leg.req.direction.as_str(),
411                                        leg.req.offset.as_str(),
412                                        leg.req.volume,
413                                        reply.parent_id
414                                    )),
415                                    _ => shared.log_line(format!(
416                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
417                                        leg.req.symbol,
418                                        leg.req.direction.as_str(),
419                                        leg.req.offset.as_str(),
420                                        leg.req.volume,
421                                        leg.reason.as_deref().unwrap_or("?"),
422                                        reply.parent_id
423                                    )),
424                                }
425                            }
426                            if reply.legs.is_empty() {
427                                shared.log_line(format!(
428                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
429                                    req.symbol,
430                                    req.offset.as_str(),
431                                    req.volume,
432                                    reply.parent_id
433                                ));
434                            }
435                        }
436                        Err(e) => {
437                            shared.log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
438                        }
439                    }
440                }
441                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
442                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
443                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
444                    // OrderData.en_orderid() 同式。
445                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
446                        "oms.cancel_order",
447                        OmsCancelOrderReq {
448                            en_orderid: format!("{gateway}.{orderid}"),
449                            gateway_name: gateway.clone(),
450                        },
451                        Duration::from_secs(10),
452                    ) {
453                        Ok(OmsCancelReply { ok: true, msg }) => {
454                            shared.log_line(format!("[ops] {msg}"));
455                        }
456                        Ok(OmsCancelReply { ok: false, msg }) => {
457                            shared.log_line(format!("[ops] 撤单被拒: {msg}"));
458                        }
459                        Err(e) => {
460                            shared.log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
461                        }
462                    }
463                }
464                crate::state::UiCmd::CtpConfigure { gateway, form } => {
465                    // 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
466                    // 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
467                    let svc = format!("{gateway}.configure");
468                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
469                        svc.as_str(),
470                        form.to_configure_req(),
471                        Duration::from_secs(5),
472                    ) {
473                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
474                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
475                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
476                        Ok(ConfigureReply { ok: true, msg, .. }) => {
477                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
478                        }
479                        Ok(ConfigureReply { ok: false, msg, .. }) => {
480                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
481                        }
482                        Err(e) => {
483                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
484                        }
485                    }
486                }
487                crate::state::UiCmd::Connect { gateway, form } => {
488                    // **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
489                    // `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
490                    // (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
491                    // 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
492                    shared.note_connect_sent(&gateway);
493                    // 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
494                    // (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
495                    let form = shared.prepare_connect_form(&gateway, form);
496                    let started = match ctx.ask_service::<StartReq, StartRes>(
497                        SVC_START,
498                        StartReq {
499                            factory: "ctp".to_string(),
500                            name: gateway.clone(),
501                        },
502                        Duration::from_secs(3),
503                    ) {
504                        Ok(StartRes::Started { name }) => {
505                            shared.log_line(format!("[ops] svctl.start ctp → {name} 上线"));
506                            true
507                        }
508                        Ok(StartRes::Rejected { reason }) => {
509                            // 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
510                            // 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
511                            if shared.account(&gateway).is_some_and(|a| a.connected) {
512                                shared
513                                    .log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
514                                true
515                            } else {
516                                shared.log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
517                                shared.note_connect_result(&gateway, Err(reason));
518                                continue;
519                            }
520                        }
521                        Err(e) => {
522                            shared.log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
523                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
524                            continue;
525                        }
526                    };
527                    if !started {
528                        continue;
529                    }
530                    let svc = format!("{gateway}.configure");
531                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
532                        svc.as_str(),
533                        form.to_configure_req(),
534                        Duration::from_secs(5),
535                    ) {
536                        // `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
537                        // (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
538                        // 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
539                        Ok(ConfigureReply {
540                            ok: true,
541                            msg,
542                            product_classes,
543                        }) => {
544                            shared.log_line(format!("[ops] {svc} 受理:{msg}"));
545                            shared.note_effective_product_classes(&gateway, &product_classes);
546                            shared.note_connect_result(&gateway, Ok(()));
547                        }
548                        Ok(ConfigureReply { ok: false, msg, .. }) => {
549                            shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
550                            shared.note_connect_result(&gateway, Err(msg));
551                        }
552                        Err(e) => {
553                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
554                            shared.note_connect_result(&gateway, Err(format!("{e:?}")));
555                        }
556                    }
557                }
558                crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
559                    // 多账号面:一律路由到行情元账号(渲染侧已按此入列);
560                    // 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
561                    // 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
562                    let svc = format!("{gateway}.subscribe");
563                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
564                        svc.as_str(),
565                        CtpSubscribeReq {
566                            symbols: symbols.clone(),
567                        },
568                        Duration::from_secs(5),
569                    ) {
570                        Ok(CtpSubscribeReply { ok: true, msg }) => {
571                            shared.note_subscriptions(&symbols);
572                            shared.log_line(format!(
573                                "[ops] {svc} {} 受理:{}",
574                                symbols.join(","),
575                                msg
576                            ));
577                        }
578                        Ok(CtpSubscribeReply { ok: false, msg }) => {
579                            shared.log_line(format!(
580                                "[ops] {svc} {} 被拒:{}",
581                                symbols.join(","),
582                                msg
583                            ));
584                        }
585                        Err(e) => {
586                            shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
587                        }
588                    }
589                }
590                crate::state::UiCmd::Disconnect { gateway } => {
591                    // 0.2.19 规划裁定 1:『断开』= `{gw}.disconnect`(停会话),
592                    // 『移除』= `svctl.remove`(停实例)—— 两条走两条不同的口,不共用。
593                    // 发令即把该行标成「已断开」:用户按了钮就要立刻看到,不等下一轮轮询。
594                    let svc = format!("{gateway}.disconnect");
595                    match ctx.ask_service::<DisconnectReq, DisconnectReply>(
596                        svc.as_str(),
597                        DisconnectReq,
598                        Duration::from_secs(5),
599                    ) {
600                        Ok(DisconnectReply { ok: true, msg }) => {
601                            shared.mark_account_stopped(&gateway);
602                            shared.log_line(format!("[账户] 断开 {gateway}:{msg}(实例仍在场)"));
603                        }
604                        Ok(DisconnectReply { ok: false, msg }) => {
605                            shared.log_line(format!("[账户] 断开 {gateway} 未执行:{msg}"));
606                        }
607                        Err(e) => {
608                            shared.log_line(format!(
609                                "[账户] 断开 {gateway} 失败:{e:?}(网关不在场或超时)",
610                            ));
611                        }
612                    }
613                }
614            }
615        }
616    }
617}
618
619impl Plugin for UiPlugin {
620    fn name(&self) -> &str {
621        UI_NAME
622    }
623
624    fn deps(&self) -> &[&str] {
625        &[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
626    }
627
628    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
629        vec![
630            std::borrow::Cow::Borrowed(EVENT_LOG),
631            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
632            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
633            std::borrow::Cow::Borrowed(EVENT_BAR),
634            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
635            std::borrow::Cow::Borrowed(EVENT_TICK),
636            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
637            std::borrow::Cow::Borrowed("eContractFinish"),
638            // 0.5 M1 交易四表的推送线(基线 BaseMonitor 各自订阅自己的线)。
639            // 不订 eTradeSync.:ek-oms 处理完成交后重推的那一条(plugin.rs:305-308),
640            // 与 ek-ctp 的 eTrade. 是同一笔成交的两份 —— 订两条就是重复入账。
641            std::borrow::Cow::Borrowed(W_ORDER),
642            std::borrow::Cow::Borrowed(W_TRADE),
643            std::borrow::Cow::Borrowed(W_POSITION),
644            std::borrow::Cow::Borrowed(W_ACCOUNT),
645            std::borrow::Cow::Borrowed(EVENT_TIMER),
646        ]
647    }
648
649    fn mailbox_cap(&self) -> usize {
650        UI_MAILBOX_CAP
651    }
652
653    fn version(&self) -> std::borrow::Cow<'static, str> {
654        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
655    }
656
657    fn services(&self) -> Vec<ServiceDesc> {
658        Vec::new() // 纯视图零服务;管制面在 svctl
659    }
660
661    fn on_init(&self, ctx: &PluginContext) {
662        let _ = self.ctx.set(ctx.clone());
663    }
664
665    fn on_start(&self) {
666        self.shared.log_line("[ui] 界面插件上线");
667        // 0.5 M5 + M4:命令线程先起(点击即发);启动一次四问在那条线程里跑 ——
668        // `on_start` 是 `engine.start()` 启动链的一环,四口最坏 4 × 2 秒不许把后面
669        // 插件的启动一起拖住,更不许占入账 worker。
670        self.spawn_cmd_thread();
671        // 启动一次四问的标记(on_start 是 `engine.start()` 启动链的一环,四口最坏
672        // 4 × 2 秒不许把后面插件的启动一起拖住,更不许占入账 worker)。
673        self.shared.request_trade_reseed();
674        self.shared.request_pipeline_refresh();
675    }
676
677    fn on_stop(&self) {
678        self.shared.log_line("[ui] 界面插件下线");
679        // 命令线程停线:它在 20ms 空转切片自查这个位,不会卡在 sleep 里。
680        self.cmd_stop
681            .store(true, std::sync::atomic::Ordering::Release);
682    }
683
684    fn on_mail(&self, event: &ek_event::Event) {
685        let ty = event.event_type.as_str();
686        if ty == EVENT_LOG {
687            if let Some(d) = event.data.downcast_ref::<LogData>() {
688                let tag = if d.is_warning_or_above() {
689                    "WARN"
690                } else {
691                    "INFO"
692                };
693                self.shared
694                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
695                // 0.5.0(用户定「链进行的状态改为事件日志推送」):`[链] ` 前缀的行除了进日志窗,
696                // 还落进账户行的「阶段」栏(ek-ctp 0.9.8 D-QUERY-GATE ⑧)。这一条是纯显示落账,
697                // 不参与 `apply_status` 那套在线/断供判据。
698                // 前缀这一份是本地常量(ek-ui 现在钉的还是 ek-ctp 0.9.7,那上面没有这个常量);
699                // 抬 pin 到 0.9.8 之后改引用 `ek_ctp::driver::CHAIN_STATE_PREFIX`,回到单源。
700                if let Some(note) = d.msg.strip_prefix(crate::state::CHAIN_STATE_PREFIX) {
701                    self.shared.note_chain(&d.gateway_name, note);
702                }
703            } else {
704                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
705            }
706            return;
707        }
708        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
709            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
710                let verb = if ty == EVENT_PLUG_UP {
711                    "上线"
712                } else {
713                    "下线"
714                };
715                self.shared
716                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
717                // 在场位与运维窗那一行跟着这条线动(不等 4 拍的快照节拍)。
718                self.shared.request_list_refresh();
719                if ty == EVENT_PLUG_UP {
720                    // 0.5 M3:装配显形改吃这条线 —— 不再靠每拍 list 去发现「谁新上线」。
721                    if n.name == "oms" {
722                        self.shared.request_pipeline_refresh();
723                    }
724                    // 0.5 执行核查补的第三处:网关 / oms 上线之后重播种一次四张表。
725                    // 摘除一个网关账户会作废它名下的委托与成交行,而 oms 那边那些行还在;
726                    // 重新添加时不重问一次,界面就永久少一段历史。顺带覆盖「ui 先于 oms
727                    // 起来、第一次播种问空了」这一序。
728                    let is_account = self.shared.accounts().iter().any(|a| a.gateway == n.name);
729                    if n.name == "oms" || is_account {
730                        self.shared.request_trade_reseed();
731                    }
732                    // 0.5 M3:订阅重放同样改吃这条线(与 `sync_accounts_connected` 的
733                    // newly 回带是同一件事的两条路,留一条:这条即时、那条要等快照拍)。
734                    let md = self.shared.md_source();
735                    let subs = self.shared.subscriptions();
736                    if !md.is_empty() && n.name == md && !subs.is_empty() {
737                        self.shared.push_cmd(crate::state::UiCmd::CtpSubscribe {
738                            gateway: n.name.clone(),
739                            symbols: subs.clone(),
740                        });
741                        self.shared.log_line(format!(
742                            "[ops] 行情源 {} 上线,重放订阅 {} 项",
743                            n.name,
744                            subs.len()
745                        ));
746                    }
747                } else {
748                    // 0.5 M1:网关下线 = 它名下的委托 / 成交 / 持仓 / 资金行全部作废。
749                    // 推送模型里没人再告诉界面「这一行已经不存在了」,不清就是替一个
750                    // 已经不存在的会话说话(与 0.2.19「实例不在场就作废会话读数」同口径)。
751                    let removed = self.shared.clear_gateway_rows(&n.name);
752                    if removed > 0 {
753                        self.shared.log_line(format!(
754                            "[ui] 插件下线 {}:作废 {} 行交易数据",
755                            n.name, removed
756                        ));
757                    }
758                    // 0.5 执行核查补的第二处:摘掉 oms 之后管线读数不会自己清空。
759                    // 0.4 是每 4 拍问一次,4 秒内自己就空了;改事件触发后没人再问,
760                    // 运维窗那一节会替一个已经不存在的 oms 说话。标记一次:下一拍问一次
761                    // → TargetGone 快错 → `poll_pipeline` 的 Err 分支清空并落一条日志。
762                    if n.name == "oms" {
763                        self.shared.request_pipeline_refresh();
764                    }
765                }
766            }
767            self.shared.bump();
768            return;
769        }
770        if ty == EVENT_BAR {
771            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
772                self.shared.market().on_frame(f);
773            }
774            self.shared.bump();
775            return;
776        }
777        if ty == EVENT_BAR_UPDATE {
778            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
779                self.shared.market().on_update(b);
780            }
781            self.shared.bump();
782            return;
783        }
784        if ty == EVENT_TICK {
785            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
786                self.shared.update_tick(t);
787            }
788            return;
789        }
790        if ty == EVENT_CONTRACT {
791            if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
792                self.shared.update_contract(c);
793            }
794            return;
795        }
796        if ty == "eContractFinish" {
797            let gw = event
798                .data
799                .downcast_ref::<String>()
800                .cloned()
801                .unwrap_or_else(|| "?".to_string());
802            self.shared
803                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
804            return;
805        }
806        // 0.5 M1 交易四表:后台只把事件推过来,界面自己入账(基线 BaseMonitor.process_event)。
807        // 这里不 ask 任何东西,所以入账 worker 永不被后端拖住。
808        if ty == W_ORDER {
809            match event.data.downcast_ref::<OrderData>() {
810                Some(o) => self.shared.note_order(o),
811                None => self.shared.note_bad_payload(W_ORDER),
812            }
813            return;
814        }
815        if ty == W_TRADE {
816            match event.data.downcast_ref::<TradeData>() {
817                Some(t) => self.shared.note_trade(t),
818                None => self.shared.note_bad_payload(W_TRADE),
819            }
820            return;
821        }
822        if ty == W_POSITION {
823            match event.data.downcast_ref::<PositionData>() {
824                Some(p) => self.shared.note_position(p),
825                None => self.shared.note_bad_payload(W_POSITION),
826            }
827            return;
828        }
829        if ty == W_ACCOUNT {
830            match event.data.downcast_ref::<AccountData>() {
831                Some(a) => self.shared.note_funds(a),
832                None => self.shared.note_bad_payload(W_ACCOUNT),
833            }
834            return;
835        }
836        if ty == EVENT_TIMER {
837            self.refresh();
838        }
839    }
840}