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                self.shared.set_snapshot(BusSnap { rows, services });
80            }
81            Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e:?}")),
82        }
83        // S5 交易监控:ask oms 全量查询组帧(oms 不在/超时 → 空帧,不抛错)。
84        let orders = ctx
85            .ask_service::<GetAllOrdersReq, Vec<OrderData>>(
86                "oms.get_all_orders",
87                GetAllOrdersReq,
88                Duration::from_secs(2),
89            )
90            .unwrap_or_default();
91        let trades = ctx
92            .ask_service::<GetAllTradesReq, Vec<TradeData>>(
93                "oms.get_all_trades",
94                GetAllTradesReq,
95                Duration::from_secs(2),
96            )
97            .unwrap_or_default();
98        let positions = ctx
99            .ask_service::<GetAllPositionsReq, Vec<PositionData>>(
100                "oms.get_all_positions",
101                GetAllPositionsReq,
102                Duration::from_secs(2),
103            )
104            .unwrap_or_default();
105        let accounts = ctx
106            .ask_service::<GetAllAccountsReq, Vec<AccountData>>(
107                "oms.get_all_accounts",
108                GetAllAccountsReq,
109                Duration::from_secs(2),
110            )
111            .unwrap_or_default();
112        self.shared.set_trade_snap(crate::state::TradeSnap {
113            orders,
114            trades,
115            positions,
116            accounts,
117        });
118    }
119
120    /// 辞行指令队列(S2 svctl 操作面;回执进日志,面板语义可断言)。
121    fn run_cmds(&self, ctx: &PluginContext) {
122        for cmd in self.shared.drain_cmds() {
123            match cmd {
124                crate::state::UiCmd::Start { factory } => {
125                    match ctx.ask_service::<StartReq, StartRes>(
126                        SVC_START,
127                        StartReq {
128                            factory: factory.clone(),
129                        },
130                        Duration::from_secs(3),
131                    ) {
132                        Ok(StartRes::Started { name }) => {
133                            self.shared
134                                .log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
135                        }
136                        Ok(StartRes::Rejected { reason }) => {
137                            self.shared
138                                .log_line(format!("[ops] start {factory} 被拒: {reason}"));
139                        }
140                        Err(e) => {
141                            self.shared
142                                .log_line(format!("[ops] start {factory} 失败: {e:?}"));
143                        }
144                    }
145                }
146                crate::state::UiCmd::Remove { name } => {
147                    match ctx.ask_service::<RemoveReq, RemoveRes>(
148                        SVC_REMOVE,
149                        RemoveReq { name: name.clone() },
150                        Duration::from_secs(3),
151                    ) {
152                        Ok(RemoveRes::Removed { name }) => {
153                            self.shared
154                                .log_line(format!("[ops] svctl.remove {name} 完成"));
155                        }
156                        Ok(RemoveRes::NoSuchName { name }) => {
157                            self.shared
158                                .log_line(format!("[ops] remove {name} 不存在/不可摘"));
159                        }
160                        Err(e) => {
161                            self.shared
162                                .log_line(format!("[ops] remove {name} 失败: {e:?}"));
163                        }
164                    }
165                }
166                crate::state::UiCmd::Reload {
167                    interval,
168                    symbol,
169                    bars,
170                } => {
171                    // 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
172                    match ctx.ask_service::<ReloadReq, usize>(
173                        "dm.reload",
174                        ReloadReq {
175                            interval,
176                            symbol: symbol.clone(),
177                            bars: bars.clone(),
178                            size: Some(bars.len()),
179                            push: true,
180                        },
181                        Duration::from_secs(5),
182                    ) {
183                        Ok(n) => {
184                            self.shared.market().note_reload();
185                            self.shared.log_line(format!(
186                                "[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
187                            ));
188                        }
189                        Err(e) => {
190                            self.shared
191                                .log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
192                        }
193                    }
194                }
195                crate::state::UiCmd::SendOrder { req, gateway, mode } => {
196                    // 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
197                    // (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
198                    // 守卫不可绕过)。parent 聚合逐腿显形进日志。
199                    match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
200                        "oms.submit_order",
201                        SubmitOrderReq {
202                            req: req.clone(),
203                            gateway_name: gateway.clone(),
204                            mode,
205                        },
206                        Duration::from_secs(10),
207                    ) {
208                        Ok(reply) => {
209                            let tag = match reply.status {
210                                SubmitStatus::Sent => "SENT",
211                                SubmitStatus::Partial => "PARTIAL",
212                                SubmitStatus::Rejected => "REJECTED",
213                            };
214                            for leg in &reply.legs {
215                                match (leg.ok, &leg.en_orderid, &leg.reason) {
216                                    (true, Some(id), _) => self.shared.log_line(format!(
217                                        "[ops] submit {} {} {}x{} → {id} (parent {})",
218                                        leg.req.symbol,
219                                        leg.req.direction.as_str(),
220                                        leg.req.offset.as_str(),
221                                        leg.req.volume,
222                                        reply.parent_id
223                                    )),
224                                    _ => self.shared.log_line(format!(
225                                        "[ops] submit {} {} {}x{} 被拒: {} (parent {})",
226                                        leg.req.symbol,
227                                        leg.req.direction.as_str(),
228                                        leg.req.offset.as_str(),
229                                        leg.req.volume,
230                                        leg.reason.as_deref().unwrap_or("?"),
231                                        reply.parent_id
232                                    )),
233                                }
234                            }
235                            if reply.legs.is_empty() {
236                                self.shared.log_line(format!(
237                                    "[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
238                                    req.symbol,
239                                    req.offset.as_str(),
240                                    req.volume,
241                                    reply.parent_id
242                                ));
243                            }
244                        }
245                        Err(e) => {
246                            self.shared
247                                .log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
248                        }
249                    }
250                }
251                crate::state::UiCmd::CancelOrder { orderid, gateway } => {
252                    // 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
253                    // {gw}.cancel_order 旁路口删除)。en_orderid 组式与
254                    // OrderData.en_orderid() 同式。
255                    match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
256                        "oms.cancel_order",
257                        OmsCancelOrderReq {
258                            en_orderid: format!("{gateway}.{orderid}"),
259                            gateway_name: gateway.clone(),
260                        },
261                        Duration::from_secs(10),
262                    ) {
263                        Ok(OmsCancelReply { ok: true, msg }) => {
264                            self.shared.log_line(format!("[ops] {msg}"));
265                        }
266                        Ok(OmsCancelReply { ok: false, msg }) => {
267                            self.shared.log_line(format!("[ops] 撤单被拒: {msg}"));
268                        }
269                        Err(e) => {
270                            self.shared
271                                .log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
272                        }
273                    }
274                }
275                crate::state::UiCmd::CtpConfigure(form) => {
276                    // S6 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
277                    match ctx.ask_service::<ConfigureReq, ConfigureReply>(
278                        "ctp.configure",
279                        form.to_configure_req(),
280                        Duration::from_secs(5),
281                    ) {
282                        // `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
283                        // 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
284                        // 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
285                        Ok(ConfigureReply { ok: true, msg, .. }) => {
286                            self.shared
287                                .log_line(format!("[ops] ctp.configure 受理:{msg}"));
288                        }
289                        Ok(ConfigureReply { ok: false, msg, .. }) => {
290                            self.shared
291                                .log_line(format!("[ops] ctp.configure 被拒:{msg}"));
292                        }
293                        Err(e) => {
294                            self.shared
295                                .log_line(format!("[ops] ctp.configure 失败: {e:?}"));
296                        }
297                    }
298                }
299                crate::state::UiCmd::CtpSubscribe { symbols } => {
300                    match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
301                        "ctp.subscribe",
302                        CtpSubscribeReq {
303                            symbols: symbols.clone(),
304                        },
305                        Duration::from_secs(5),
306                    ) {
307                        Ok(CtpSubscribeReply { ok: true, msg }) => {
308                            self.shared.log_line(format!(
309                                "[ops] ctp.subscribe {} 受理:{}",
310                                symbols.join(","),
311                                msg
312                            ));
313                        }
314                        Ok(CtpSubscribeReply { ok: false, msg }) => {
315                            self.shared.log_line(format!(
316                                "[ops] ctp.subscribe {} 被拒:{}",
317                                symbols.join(","),
318                                msg
319                            ));
320                        }
321                        Err(e) => {
322                            self.shared
323                                .log_line(format!("[ops] ctp.subscribe 失败: {e:?}"));
324                        }
325                    }
326                }
327            }
328        }
329    }
330}
331
332impl Plugin for UiPlugin {
333    fn name(&self) -> &str {
334        UI_NAME
335    }
336
337    fn deps(&self) -> &[&str] {
338        &[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
339    }
340
341    fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
342        vec![
343            std::borrow::Cow::Borrowed(EVENT_LOG),
344            std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
345            std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
346            std::borrow::Cow::Borrowed(EVENT_BAR),
347            std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
348            std::borrow::Cow::Borrowed(EVENT_TICK),
349            std::borrow::Cow::Borrowed(EVENT_CONTRACT),
350            std::borrow::Cow::Borrowed("eContractFinish"),
351            std::borrow::Cow::Borrowed(EVENT_TIMER),
352        ]
353    }
354
355    fn mailbox_cap(&self) -> usize {
356        1024
357    }
358
359    fn version(&self) -> std::borrow::Cow<'static, str> {
360        std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
361    }
362
363    fn services(&self) -> Vec<ServiceDesc> {
364        Vec::new() // 纯视图零服务;管制面在 svctl
365    }
366
367    fn on_init(&self, ctx: &PluginContext) {
368        let _ = self.ctx.set(ctx.clone());
369    }
370
371    fn on_start(&self) {
372        self.shared.log_line("[ui] 界面插件上线");
373    }
374
375    fn on_stop(&self) {
376        self.shared.log_line("[ui] 界面插件下线");
377    }
378
379    fn on_mail(&self, event: &ek_event::Event) {
380        let ty = event.event_type.as_str();
381        if ty == EVENT_LOG {
382            if let Some(d) = event.data.downcast_ref::<LogData>() {
383                let tag = if d.is_warning_or_above() {
384                    "WARN"
385                } else {
386                    "INFO"
387                };
388                self.shared
389                    .log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
390            } else {
391                self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
392            }
393            return;
394        }
395        if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
396            if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
397                let verb = if ty == EVENT_PLUG_UP {
398                    "上线"
399                } else {
400                    "下线"
401                };
402                self.shared
403                    .log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
404            }
405            self.shared.bump();
406            return;
407        }
408        if ty == EVENT_BAR {
409            if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
410                self.shared.market().on_frame(f);
411            }
412            self.shared.bump();
413            return;
414        }
415        if ty == EVENT_BAR_UPDATE {
416            if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
417                self.shared.market().on_update(b);
418            }
419            self.shared.bump();
420            return;
421        }
422        if ty == EVENT_TICK {
423            if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
424                self.shared.update_tick(t);
425            }
426            return;
427        }
428        if ty == EVENT_CONTRACT {
429            if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
430                self.shared.update_contract(c);
431            }
432            return;
433        }
434        if ty == "eContractFinish" {
435            let gw = event
436                .data
437                .downcast_ref::<String>()
438                .cloned()
439                .unwrap_or_else(|| "?".to_string());
440            self.shared
441                .log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
442            return;
443        }
444        if ty == EVENT_TIMER {
445            self.refresh();
446        }
447    }
448}