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