1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
//! `UiPlugin` —— 总线面:`eLog` 入环形日志、`ePlugUp/Down` 记事件 + rev、
//! `eBar`./`eBarUpdate.` 记行情库(K线窗数据面)、`eTick.` 记 tick 库(v4 行情带
//! 唯一源,S7 上屏地面)、
//! `eTimer`(1s 宿主自带)拉一次 `svctl.list` + 逐插件服务目录组快照 +
//! ask oms 全量查询组交易监控帧(S5),
//! 并辞行窗口面的操作指令队列(svctl 起停 + dm.reload 回灌 + oms 五段管线
//! 下单/守卫撤单)。
//!
//! 纪律:UI 不直调任何插件;唯一管制面是 ask 契约(svctl / dm.reload /
//! oms 查询与 submit_order·cancel_order / ctp 配置订阅,全部与
//! headless 同源;`PluginContext` 只在 worker 可用)。0.1.1 BD-UI-30:
//! 下单/撤单直发网关的旁路口删除——风控与守卫不可绕过。0.2.10 BD-UI-38:
//! sim 纸面网关退场(流控查询口随之撤)。
use std::sync::OnceLock;
use std::time::Duration;
use ek_core::context::PluginContext;
use ek_core::plugin::Plugin;
use ek_core::service::{PlugNotice, ServiceDesc};
use ek_core::{EVENT_PLUG_DOWN, EVENT_PLUG_UP};
use ek_ctp::plugin::{
ConfigureReply, ConfigureReq, SubscribeReply as CtpSubscribeReply,
SubscribeReq as CtpSubscribeReq,
};
use ek_data::account::AccountData;
use ek_data::event::{EVENT_BAR, EVENT_CONTRACT, EVENT_LOG, EVENT_TICK};
use ek_data::log::LogData;
use ek_data::order::OrderData;
use ek_data::position::PositionData;
use ek_data::trade::TradeData;
use ek_dm::plugin::{EVENT_BAR_UPDATE, ReloadReq};
use ek_event::EVENT_TIMER;
use ek_oms::plugin::{
GetAllAccountsReq, GetAllOrdersReq, GetAllPositionsReq, GetAllTradesReq, OmsCancelOrderReq,
OmsCancelReply, SubmitOrderReq,
};
use ek_oms::{SubmitReply, SubmitStatus};
use ek_station::wire::{
ListReq, PlugRow, RemoveReq, RemoveRes, SVC_LIST, SVC_REMOVE, SVC_START, StartReq, StartRes,
};
use crate::state::{BusSnap, UiShared};
/// 注册名 = 工厂名 = `"ui"`(BD-U5:两者一致)。
pub const UI_NAME: &str = "ui";
/// 常驻观测插件(窗口面在 [`crate::runtime`] 持 [`UiShared`] 渲染)。
pub struct UiPlugin {
shared: std::sync::Arc<UiShared>,
ctx: OnceLock<PluginContext>,
}
impl UiPlugin {
/// 与窗口面共用的共享状态句柄。
pub fn new(shared: std::sync::Arc<UiShared>) -> Self {
Self {
shared,
ctx: OnceLock::new(),
}
}
/// eTimer 节流回调:先辞行操作指令(有则记录),再组一次快照。
fn refresh(&self) {
let Some(ctx) = self.ctx.get() else {
return; // 未 init 就吃到 eTimer:忽略,等下轮
};
self.run_cmds(ctx);
match ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(2)) {
Ok(rows) => {
let mut services = Vec::with_capacity(rows.len());
for r in &rows {
let ids = ctx
.services_of(&r.name)
.map(|v| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>())
.unwrap_or_default();
services.push((r.name.clone(), ids));
}
// 账户连接位回填 + 新上线检测(订阅重放判据,规划 §5.C)。
let newly = self.shared.sync_accounts_connected(&rows);
self.shared.set_snapshot(BusSnap { rows, services });
let md = self.shared.md_source();
let subs = self.shared.subscriptions();
for gw in newly {
if !md.is_empty() && gw == md && !subs.is_empty() {
self.shared.push_cmd(crate::state::UiCmd::CtpSubscribe {
gateway: gw.clone(),
symbols: subs.clone(),
});
self.shared
.log_line(format!("[ops] 行情源 {gw} 上线,重放订阅 {} 项", subs.len()));
}
}
}
Err(e) => self.shared.log_line(format!("[ui] svctl.list 失败: {e:?}")),
}
// S5 交易监控:ask oms 全量查询组帧(oms 不在/超时 → 空帧,不抛错)。
let orders = ctx
.ask_service::<GetAllOrdersReq, Vec<OrderData>>(
"oms.get_all_orders",
GetAllOrdersReq,
Duration::from_secs(2),
)
.unwrap_or_default();
let trades = ctx
.ask_service::<GetAllTradesReq, Vec<TradeData>>(
"oms.get_all_trades",
GetAllTradesReq,
Duration::from_secs(2),
)
.unwrap_or_default();
let positions = ctx
.ask_service::<GetAllPositionsReq, Vec<PositionData>>(
"oms.get_all_positions",
GetAllPositionsReq,
Duration::from_secs(2),
)
.unwrap_or_default();
let accounts = ctx
.ask_service::<GetAllAccountsReq, Vec<AccountData>>(
"oms.get_all_accounts",
GetAllAccountsReq,
Duration::from_secs(2),
)
.unwrap_or_default();
self.shared.set_trade_snap(crate::state::TradeSnap {
orders,
trades,
positions,
accounts,
});
}
/// 辞行指令队列(S2 svctl 操作面;回执进日志,面板语义可断言)。
fn run_cmds(&self, ctx: &PluginContext) {
for cmd in self.shared.drain_cmds() {
match cmd {
crate::state::UiCmd::Start { factory, name } => {
match ctx.ask_service::<StartReq, StartRes>(
SVC_START,
StartReq {
factory: factory.clone(),
name: name.clone(),
},
Duration::from_secs(3),
) {
Ok(StartRes::Started { name }) => {
self.shared
.log_line(format!("[ops] svctl.start {factory} → {name} 上线"));
}
Ok(StartRes::Rejected { reason }) => {
self.shared
.log_line(format!("[ops] start {factory} 被拒: {reason}"));
}
Err(e) => {
self.shared
.log_line(format!("[ops] start {factory} 失败: {e:?}"));
}
}
}
crate::state::UiCmd::Remove { name } => {
match ctx.ask_service::<RemoveReq, RemoveRes>(
SVC_REMOVE,
RemoveReq { name: name.clone() },
Duration::from_secs(3),
) {
Ok(RemoveRes::Removed { name }) => {
self.shared
.log_line(format!("[ops] svctl.remove {name} 完成"));
}
Ok(RemoveRes::NoSuchName { name }) => {
self.shared
.log_line(format!("[ops] remove {name} 不存在/不可摘"));
}
Err(e) => {
self.shared
.log_line(format!("[ops] remove {name} 失败: {e:?}"));
}
}
}
crate::state::UiCmd::Reload {
interval,
symbol,
bars,
} => {
// 回灌走 dm.reload ask(push=true 逐条广播 → 行情板实时见回执面)。
match ctx.ask_service::<ReloadReq, usize>(
"dm.reload",
ReloadReq {
interval,
symbol: symbol.clone(),
bars: bars.clone(),
size: Some(bars.len()),
push: true,
},
Duration::from_secs(5),
) {
Ok(n) => {
self.shared.market().note_reload();
self.shared.log_line(format!(
"[ops] dm.reload {symbol}@{interval}s 回灌 {n} 条"
));
}
Err(e) => {
self.shared
.log_line(format!("[ops] dm.reload {symbol} 失败: {e:?}"));
}
}
}
crate::state::UiCmd::SendOrder { req, gateway, mode } => {
// 0.1.1 BD-UI-30 薄下单者:五段管线全走 oms.submit_order
// (拆单/风控/逐腿发送/记账;直发 {gw}.send_order 旁路口删除,
// 守卫不可绕过)。parent 聚合逐腿显形进日志。
match ctx.ask_service::<SubmitOrderReq, SubmitReply>(
"oms.submit_order",
SubmitOrderReq {
req: req.clone(),
gateway_name: gateway.clone(),
mode,
},
Duration::from_secs(10),
) {
Ok(reply) => {
let tag = match reply.status {
SubmitStatus::Sent => "SENT",
SubmitStatus::Partial => "PARTIAL",
SubmitStatus::Rejected => "REJECTED",
};
for leg in &reply.legs {
match (leg.ok, &leg.en_orderid, &leg.reason) {
(true, Some(id), _) => self.shared.log_line(format!(
"[ops] submit {} {} {}x{} → {id} (parent {})",
leg.req.symbol,
leg.req.direction.as_str(),
leg.req.offset.as_str(),
leg.req.volume,
reply.parent_id
)),
_ => self.shared.log_line(format!(
"[ops] submit {} {} {}x{} 被拒: {} (parent {})",
leg.req.symbol,
leg.req.direction.as_str(),
leg.req.offset.as_str(),
leg.req.volume,
leg.reason.as_deref().unwrap_or("?"),
reply.parent_id
)),
}
}
if reply.legs.is_empty() {
self.shared.log_line(format!(
"[ops] submit {} {}x{} {tag}:可平不足零腿 (parent {})",
req.symbol,
req.offset.as_str(),
req.volume,
reply.parent_id
));
}
}
Err(e) => {
self.shared
.log_line(format!("[ops] oms.submit_order 失败: {e:?}"));
}
}
}
crate::state::UiCmd::CancelOrder { orderid, gateway } => {
// 0.1.1 BD-UI-30:守卫撤单(尝试 ≤4000 在 oms;直发
// {gw}.cancel_order 旁路口删除)。en_orderid 组式与
// OrderData.en_orderid() 同式。
match ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
"oms.cancel_order",
OmsCancelOrderReq {
en_orderid: format!("{gateway}.{orderid}"),
gateway_name: gateway.clone(),
},
Duration::from_secs(10),
) {
Ok(OmsCancelReply { ok: true, msg }) => {
self.shared.log_line(format!("[ops] {msg}"));
}
Ok(OmsCancelReply { ok: false, msg }) => {
self.shared.log_line(format!("[ops] 撤单被拒: {msg}"));
}
Err(e) => {
self.shared
.log_line(format!("[ops] oms.cancel_order 失败: {e:?}"));
}
}
}
crate::state::UiCmd::CtpConfigure { gateway, form } => {
// 定盘口径:界面手工输入 → 插件自带配置口(插件不读文件)。
// 多账号面:服务名随网关走(`{gw}.configure`),不再硬编码 ctp。
let svc = format!("{gateway}.configure");
match ctx.ask_service::<ConfigureReq, ConfigureReply>(
svc.as_str(),
form.to_configure_req(),
Duration::from_secs(5),
) {
// `..`:ConfigureReply 0.6.0 起带 product_classes(实际生效值)。
// 这里不回填表单勾选 —— 被拒时回带的是「网关当前仍在跑」的那一份,
// 用它覆盖用户刚点的勾选会把他的意图吃掉;日志里网关已写明收录产品类。
Ok(ConfigureReply { ok: true, msg, .. }) => {
self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
}
Ok(ConfigureReply { ok: false, msg, .. }) => {
self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
}
Err(e) => {
self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
}
}
}
crate::state::UiCmd::Connect { gateway, form } => {
// **四态分离的「连接」**(规划裁定「添加 ≠ 连接」):一次做完
// `svctl.start(factory=ctp)` + `{gw}.configure`,结果回写账户行
// (`Assembling → Assembled / AssembleFailed` + 失败原因留显形)。
// 失败**不回滚清册行** —— 行留着给『重试』(旧口径"必须先移除才能重试"作废)。
self.shared.note_connect_sent(&gateway);
// 单源纪律在此裁决:非行情源账户的 md 位就地清空,**留档与下发同一份**
// (旧口径只清下发不清留档,编辑页回填会带着一根用不上的行情前置)。
let form = self.shared.prepare_connect_form(&gateway, form);
let started = match ctx.ask_service::<StartReq, StartRes>(
SVC_START,
StartReq {
factory: "ctp".to_string(),
name: gateway.clone(),
},
Duration::from_secs(3),
) {
Ok(StartRes::Started { name }) => {
self.shared
.log_line(format!("[ops] svctl.start ctp → {name} 上线"));
true
}
Ok(StartRes::Rejected { reason }) => {
// 防重入(规划 Q7):实例已在场时重复点『连接』/『重试』不是失败,
// 重投凭据即可(否则会把正在跑的账户误判成"装配失败")。
if self.shared.account(&gateway).is_some_and(|a| a.connected) {
self.shared
.log_line(format!("[ops] {gateway} 已在场,重投凭据: {reason}"));
true
} else {
self.shared
.log_line(format!("[ops] {gateway} 装配被拒: {reason}"));
self.shared.note_connect_result(&gateway, Err(reason));
continue;
}
}
Err(e) => {
self.shared
.log_line(format!("[ops] {gateway} 装配失败: {e:?}"));
self.shared
.note_connect_result(&gateway, Err(format!("{e:?}")));
continue;
}
};
if !started {
continue;
}
let svc = format!("{gateway}.configure");
match ctx.ask_service::<ConfigureReq, ConfigureReply>(
svc.as_str(),
form.to_configure_req(),
Duration::from_secs(5),
) {
// `..`:ConfigureReply 带的 product_classes(实际生效值)不回填勾选
// (被拒时回带的是「网关仍在跑」的那一份,覆盖用户刚点的勾选会吃掉意图);
// 0.2.18 起把它显示出来(见 state::note_effective_product_classes)。
Ok(ConfigureReply {
ok: true,
msg,
product_classes,
}) => {
self.shared.log_line(format!("[ops] {svc} 受理:{msg}"));
self.shared
.note_effective_product_classes(&gateway, &product_classes);
self.shared.note_connect_result(&gateway, Ok(()));
}
Ok(ConfigureReply { ok: false, msg, .. }) => {
self.shared.log_line(format!("[ops] {svc} 被拒:{msg}"));
self.shared.note_connect_result(&gateway, Err(msg));
}
Err(e) => {
self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
self.shared
.note_connect_result(&gateway, Err(format!("{e:?}")));
}
}
}
crate::state::UiCmd::CtpSubscribe { gateway, symbols } => {
// 多账号面:一律路由到行情元账号(渲染侧已按此入列);
// 纯 TD 账号收到订阅会**静默成功**(do_subscribe 恒 ok:true),
// 所以路由正确性靠界面纪律,不靠网关报错(规划 §9.4-21)。
let svc = format!("{gateway}.subscribe");
match ctx.ask_service::<CtpSubscribeReq, CtpSubscribeReply>(
svc.as_str(),
CtpSubscribeReq {
symbols: symbols.clone(),
},
Duration::from_secs(5),
) {
Ok(CtpSubscribeReply { ok: true, msg }) => {
self.shared.note_subscriptions(&symbols);
self.shared.log_line(format!(
"[ops] {svc} {} 受理:{}",
symbols.join(","),
msg
));
}
Ok(CtpSubscribeReply { ok: false, msg }) => {
self.shared.log_line(format!(
"[ops] {svc} {} 被拒:{}",
symbols.join(","),
msg
));
}
Err(e) => {
self.shared.log_line(format!("[ops] {svc} 失败: {e:?}"));
}
}
}
}
}
}
}
impl Plugin for UiPlugin {
fn name(&self) -> &str {
UI_NAME
}
fn deps(&self) -> &[&str] {
&[] // 纯观测;谁先上都能看到(bus 契约不依赖启动序)
}
fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
vec![
std::borrow::Cow::Borrowed(EVENT_LOG),
std::borrow::Cow::Borrowed(EVENT_PLUG_UP),
std::borrow::Cow::Borrowed(EVENT_PLUG_DOWN),
std::borrow::Cow::Borrowed(EVENT_BAR),
std::borrow::Cow::Borrowed(EVENT_BAR_UPDATE),
std::borrow::Cow::Borrowed(EVENT_TICK),
std::borrow::Cow::Borrowed(EVENT_CONTRACT),
std::borrow::Cow::Borrowed("eContractFinish"),
std::borrow::Cow::Borrowed(EVENT_TIMER),
]
}
fn mailbox_cap(&self) -> usize {
1024
}
fn version(&self) -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION"))
}
fn services(&self) -> Vec<ServiceDesc> {
Vec::new() // 纯视图零服务;管制面在 svctl
}
fn on_init(&self, ctx: &PluginContext) {
let _ = self.ctx.set(ctx.clone());
}
fn on_start(&self) {
self.shared.log_line("[ui] 界面插件上线");
}
fn on_stop(&self) {
self.shared.log_line("[ui] 界面插件下线");
}
fn on_mail(&self, event: &ek_event::Event) {
let ty = event.event_type.as_str();
if ty == EVENT_LOG {
if let Some(d) = event.data.downcast_ref::<LogData>() {
let tag = if d.is_warning_or_above() {
"WARN"
} else {
"INFO"
};
self.shared
.log_line(format!("[{tag}][{}] {}", d.gateway_name, d.msg));
} else {
self.shared.log_line("[eLog] 载荷类型异常(非 LogData)");
}
return;
}
if ty == EVENT_PLUG_UP || ty == EVENT_PLUG_DOWN {
if let Some(n) = event.data.downcast_ref::<PlugNotice>() {
let verb = if ty == EVENT_PLUG_UP {
"上线"
} else {
"下线"
};
self.shared
.log_line(format!("[host] 插件{verb} {} v{}", n.name, n.version));
}
self.shared.bump();
return;
}
if ty == EVENT_BAR {
if let Some(f) = event.data.downcast_ref::<ek_dm::wire::BarsFrame>() {
self.shared.market().on_frame(f);
}
self.shared.bump();
return;
}
if ty == EVENT_BAR_UPDATE {
if let Some(b) = event.data.downcast_ref::<ek_data::bar::BarData>() {
self.shared.market().on_update(b);
}
self.shared.bump();
return;
}
if ty == EVENT_TICK {
if let Some(t) = event.data.downcast_ref::<ek_data::tick::TickData>() {
self.shared.update_tick(t);
}
return;
}
if ty == EVENT_CONTRACT {
if let Some(c) = event.data.downcast_ref::<ek_data::contract::ContractData>() {
self.shared.update_contract(c);
}
return;
}
if ty == "eContractFinish" {
let gw = event
.data
.downcast_ref::<String>()
.cloned()
.unwrap_or_else(|| "?".to_string());
self.shared
.log_line(format!("[ctp] 合约流完成 {gw}(查询合约窗可勾选订阅)"));
return;
}
if ty == EVENT_TIMER {
self.refresh();
}
}
}