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, SubscribeReply as CtpSubscribeReply,
24    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, 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    }
133
134    /// 辞行指令队列(S2 svctl 操作面;回执进日志,面板语义可断言)。
135    fn run_cmds(&self, ctx: &PluginContext) {
136        for cmd in self.shared.drain_cmds() {
137            match cmd {
138                crate::state::UiCmd::Start { factory, name } => {
139                    match ctx.ask_service::<StartReq, StartRes>(
140                        SVC_START,
141                        StartReq {
142                            factory: factory.clone(),
143                            name: name.clone(),
144                        },
145                        Duration::from_secs(3),
146                    ) {
147                        Ok(StartRes::Started { name }) => {
148                            self.shared
149                                .log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
150                        }
151                        Ok(StartRes::Rejected { reason }) => {
152                            self.shared
153                                .log_line(format!("[ops] start {factory} 被拒: {reason}"));
154                        }
155                        Err(e) => {
156                            self.shared
157                                .log_line(format!("[ops] start {factory} 失败: {e:?}"));
158                        }
159                    }
160                }
161                crate::state::UiCmd::Remove { name } => {
162                    match ctx.ask_service::<RemoveReq, RemoveRes>(
163                        SVC_REMOVE,
164                        RemoveReq { name: name.clone() },
165                        Duration::from_secs(3),
166                    ) {
167                        Ok(RemoveRes::Removed { name }) => {
168                            self.shared
169                                .log_line(format!("[ops] svctl.remove {name} 完成"));
170                        }
171                        Ok(RemoveRes::NoSuchName { name }) => {
172                            self.shared
173                                .log_line(format!("[ops] remove {name} 不存在/不可摘"));
174                        }
175                        Err(e) => {
176                            self.shared
177                                .log_line(format!("[ops] remove {name} 失败: {e:?}"));
178                        }
179                    }
180                }
181                crate::state::UiCmd::Reload {
182                    interval,
183                    symbol,
184                    bars,
185                } => {
186                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
187                    match ctx.ask_service::<ReloadReq, usize>(
188                        "dm.reload",
189                        ReloadReq {
190                            interval,
191                            symbol: symbol.clone(),
192                            bars: bars.clone(),
193                            size: Some(bars.len()),
194                            push: true,
195                        },
196                        Duration::from_secs(5),
197                    ) {
198                        Ok(n) => {
199                            self.shared.market().note_reload();
200                            self.shared.log_line(format!(
201                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
202                            ));
203                        }
204                        Err(e) => {
205                            self.shared
206                                .log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
207                        }
208                    }
209                }
210                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
211                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
212                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
213                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
214                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
215                        "oms.submit_order",
216                        SubmitOrderReq {
217                            req: req.clone(),
218                            gateway_name: gateway.clone(),
219                            mode,
220                        },
221                        Duration::from_secs(10),
222                    ) {
223                        Ok(reply) => {
224                            let tag = match reply.status {
225                                SubmitStatus::Sent => "SENT",
226                                SubmitStatus::Partial => "PARTIAL",
227                                SubmitStatus::Rejected => "REJECTED",
228                            };
229                            for leg in &reply.legs {
230                                match (leg.ok, &leg.en_orderid, &leg.reason) {
231                                    (true, Some(id), _) => self.shared.log_line(format!(
232                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
233                                        leg.req.symbol,
234                                        leg.req.direction.as_str(),
235                                        leg.req.offset.as_str(),
236                                        leg.req.volume,
237                                        reply.parent_id
238                                    )),
239                                    _ => self.shared.log_line(format!(
240                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
241                                        leg.req.symbol,
242                                        leg.req.direction.as_str(),
243                                        leg.req.offset.as_str(),
244                                        leg.req.volume,
245                                        leg.reason.as_deref().unwrap_or("?"),
246                                        reply.parent_id
247                                    )),
248                                }
249                            }
250                            if reply.legs.is_empty() {
251                                self.shared.log_line(format!(
252                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
253                                    req.symbol,
254                                    req.offset.as_str(),
255                                    req.volume,
256                                    reply.parent_id
257                                ));
258                            }
259                        }
260                        Err(e) => {
261                            self.shared
262                                .log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
263                        }
264                    }
265                }
266                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
267                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
268                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
269                    // OrderData.en_orderid() 同式。
270                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
271                        "oms.cancel_order",
272                        OmsCancelOrderReq {
273                            en_orderid: format!("{gateway}.{orderid}"),
274                            gateway_name: gateway.clone(),
275                        },
276                        Duration::from_secs(10),
277                    ) {
278                        Ok(OmsCancelReply { ok: true, msg }) => {
279                            self.shared.log_line(format!("[ops] {msg}"));
280                        }
281                        Ok(OmsCancelReply { ok: false, msg }) => {
282                            self.shared.log_line(format!("[ops] 撤单被拒: {msg}"));
283                        }
284                        Err(e) => {
285                            self.shared
286                                .log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
287                        }
288                    }
289                }
290                crate::state::UiCmd::CtpConfigure { gateway, form } => {
291                    // 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
292                    // 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
293                    let svc = format!("{gateway}.configure");
294                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
295                        svc.as_str(),
296                        form.to_configure_req(),
297                        Duration::from_secs(5),
298                    ) {
299                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
300                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
301                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
302                        Ok(ConfigureReply { ok: true, msg, .. }) => {
303                            self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
304                        }
305                        Ok(ConfigureReply { ok: false, msg, .. }) => {
306                            self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
307                        }
308                        Err(e) => {
309                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
310                        }
311                    }
312                }
313                crate::state::UiCmd::Connect { gateway, form } => {
314                    // **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
315                    // `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
316                    // (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
317                    // 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
318                    self.shared.note_connect_sent(&gateway);
319                    // 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
320                    // (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
321                    let form = self.shared.prepare_connect_form(&gateway, form);
322                    let started = match ctx.ask_service::<StartReq, StartRes>(
323                        SVC_START,
324                        StartReq {
325                            factory: "ctp".to_string(),
326                            name: gateway.clone(),
327                        },
328                        Duration::from_secs(3),
329                    ) {
330                        Ok(StartRes::Started { name }) => {
331                            self.shared
332                                .log_line(format!("[ops] svctl.start ctp → {name} 上线"));
333                            true
334                        }
335                        Ok(StartRes::Rejected { reason }) => {
336                            // 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
337                            // 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
338                            if self.shared.account(&gateway).is_some_and(|a| a.connected) {
339                                self.shared
340                                    .log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
341                                true
342                            } else {
343                                self.shared
344                                    .log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
345                                self.shared.note_connect_result(&gateway, Err(reason));
346                                continue;
347                            }
348                        }
349                        Err(e) => {
350                            self.shared
351                                .log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
352                            self.shared
353                                .note_connect_result(&gateway, Err(format!("{e:?}")));
354                            continue;
355                        }
356                    };
357                    if !started {
358                        continue;
359                    }
360                    let svc = format!("{gateway}.configure");
361                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
362                        svc.as_str(),
363                        form.to_configure_req(),
364                        Duration::from_secs(5),
365                    ) {
366                        // `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
367                        // (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
368                        // 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
369                        Ok(ConfigureReply {
370                            ok: true,
371                            msg,
372                            product_classes,
373                        }) => {
374                            self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
375                            self.shared
376                                .note_effective_product_classes(&gateway, &product_classes);
377                            self.shared.note_connect_result(&gateway, Ok(()));
378                        }
379                        Ok(ConfigureReply { ok: false, msg, .. }) => {
380                            self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
381                            self.shared.note_connect_result(&gateway, Err(msg));
382                        }
383                        Err(e) => {
384                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
385                            self.shared
386                                .note_connect_result(&gateway, Err(format!("{e:?}")));
387                        }
388                    }
389                }
390                crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
391                    // 多账号面:一律路由到行情元账号(渲染侧已按此入列);
392                    // 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
393                    // 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
394                    let svc = format!("{gateway}.subscribe");
395                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
396                        svc.as_str(),
397                        CtpSubscribeReq {
398                            symbols: symbols.clone(),
399                        },
400                        Duration::from_secs(5),
401                    ) {
402                        Ok(CtpSubscribeReply { ok: true, msg }) => {
403                            self.shared.note_subscriptions(&symbols);
404                            self.shared.log_line(format!(
405                                "[ops] {svc} {} 受理:{}",
406                                symbols.join(","),
407                                msg
408                            ));
409                        }
410                        Ok(CtpSubscribeReply { ok: false, msg }) => {
411                            self.shared.log_line(format!(
412                                "[ops] {svc} {} 被拒:{}",
413                                symbols.join(","),
414                                msg
415                            ));
416                        }
417                        Err(e) => {
418                            self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
419                        }
420                    }
421                }
422            }
423        }
424    }
425}
426
427impl Plugin for UiPlugin {
428    fn name(&self) -> &str {
429        UI_NAME
430    }
431
432    fn deps(&self) -> &[&str] {
433        &[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
434    }
435
436    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
437        vec![
438            std::borrow::Cow::Borrowed(EVENT_LOG),
439            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
440            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
441            std::borrow::Cow::Borrowed(EVENT_BAR),
442            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
443            std::borrow::Cow::Borrowed(EVENT_TICK),
444            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
445            std::borrow::Cow::Borrowed("eContractFinish"),
446            std::borrow::Cow::Borrowed(EVENT_TIMER),
447        ]
448    }
449
450    fn mailbox_cap(&self) -> usize {
451        1024
452    }
453
454    fn version(&self) -> std::borrow::Cow<'static, str> {
455        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
456    }
457
458    fn services(&self) -> Vec<ServiceDesc> {
459        Vec::new() // 纯视图零服务;管制面在 svctl
460    }
461
462    fn on_init(&self, ctx: &PluginContext) {
463        let _ = self.ctx.set(ctx.clone());
464    }
465
466    fn on_start(&self) {
467        self.shared.log_line("[ui] 界面插件上线");
468    }
469
470    fn on_stop(&self) {
471        self.shared.log_line("[ui] 界面插件下线");
472    }
473
474    fn on_mail(&self, event: &ek_event::Event) {
475        let ty = event.event_type.as_str();
476        if ty == EVENT_LOG {
477            if let Some(d) = event.data.downcast_ref::<LogData>() {
478                let tag = if d.is_warning_or_above() {
479                    "WARN"
480                } else {
481                    "INFO"
482                };
483                self.shared
484                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
485            } else {
486                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
487            }
488            return;
489        }
490        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
491            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
492                let verb = if ty == EVENT_PLUG_UP {
493                    "上线"
494                } else {
495                    "下线"
496                };
497                self.shared
498                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
499            }
500            self.shared.bump();
501            return;
502        }
503        if ty == EVENT_BAR {
504            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
505                self.shared.market().on_frame(f);
506            }
507            self.shared.bump();
508            return;
509        }
510        if ty == EVENT_BAR_UPDATE {
511            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
512                self.shared.market().on_update(b);
513            }
514            self.shared.bump();
515            return;
516        }
517        if ty == EVENT_TICK {
518            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
519                self.shared.update_tick(t);
520            }
521            return;
522        }
523        if ty == EVENT_CONTRACT {
524            if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
525                self.shared.update_contract(c);
526            }
527            return;
528        }
529        if ty == "eContractFinish" {
530            let gw = event
531                .data
532                .downcast_ref::<String>()
533                .cloned()
534                .unwrap_or_else(|| "?".to_string());
535            self.shared
536                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
537            return;
538        }
539        if ty == EVENT_TIMER {
540            self.refresh();
541        }
542    }
543}