Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v5(BD-UI-29 面板化,官方面板组合;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单侧) →
5//! CentralPanel(2×2 监控格) → Window 弹窗**,两带物理分离。
6//! - 行情带(TopBottomPanel::resizable = Python 分带 splitter 语义,缺省 240):
7//!   交易所二级 tab(只列有 tick 的所)+ tick 表(唯一源 = `eTick.`
8//!   订阅流,不从 bar 回);行单击 = 带入下单面板(ek 增值,方案钉),行双击 =
9//!   弹 K线窗。
10//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
11//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
12//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
13//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
14//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
15//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
16//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
17//!   D7 口径)。
18//! - 弹窗(egui::Window,依附主窗,kittest 可断言):运维窗(菜单「运维」:
19//!   插件/服务/依赖/流控查询)、CTP 配置窗(菜单「网关」,CTP 插件自己的
20//!   界面)、查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python
21//!   ContractManager 等价)、K线窗(双击弹出,按合约多开)。
22//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
23//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
24//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
25//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
26//!
27//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
28//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
29//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
30
31use ek_data::constant::{Direction, Offset, OrderType};
32use ek_station::wire::PlugRow;
33
34use crate::state::{
35    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
36};
37
38/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
39pub const BTN_CLEAR_LOGS: &str = "清空日志";
40/// 取消待确认按钮标签。
41pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
42/// 下单提交按钮标签(v4 = Python「委托」钮)。
43pub const BTN_SEND_ORDER: &str = "委托";
44/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
45pub const BTN_CANCEL_ALL: &str = "全撤";
46/// 流控查询按钮标签(S5;v4 收纳进运维窗)。
47pub const BTN_QUERY_RATE: &str = "流控查询";
48/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
49pub const BTN_CANCEL_ORDER: &str = "撤单";
50/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
51pub const BTN_CTP_CONNECT: &str = "连接 CTP";
52/// 菜单项:运维(弹运维窗)。
53pub const MENU_OPS: &str = "运维";
54/// 菜单项:网关(弹 CTP 配置窗)。
55pub const MENU_CTP: &str = "网关";
56/// 菜单项:帮助。
57pub const MENU_HELP: &str = "帮助";
58/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
59pub const MENU_CONTRACTS: &str = "查询合约";
60/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
61/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
62/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
63pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
64
65fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
66    if armed_on == Some(name) {
67        format!("确认移除 {name} ?")
68    } else {
69        format!("移除 {name}")
70    }
71}
72
73fn state_text(r: &PlugRow) -> String {
74    r.state.clone()
75}
76
77/// DtNs → HH:MM:SS(UTC;表内时间列统一口径)。
78fn fmt_dt(dt: Option<i64>) -> String {
79    dt.map(|ns| {
80        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
81            .format("%H:%M:%S")
82            .to_string()
83    })
84    .unwrap_or_else(|| "-".to_string())
85}
86
87// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
88
89/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
90/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
91/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
92fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
93    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
94        return;
95    };
96    let hovered = ui
97        .input(|i| i.pointer.interact_pos())
98        .is_some_and(|p| union.expand(3.0).contains(p));
99    if selected {
100        ui.painter().rect_filled(
101            union.expand(3.0),
102            3.0,
103            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
104        );
105    } else if hovered {
106        ui.painter().rect_filled(
107            union.expand(3.0),
108            3.0,
109            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
110        );
111    }
112}
113
114/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
115fn order_active(status: ek_data::constant::Status) -> bool {
116    ek_data::constant::ACTIVE_STATUSES.contains(&status)
117}
118
119/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
120/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
121fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
122    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
123}
124
125/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
126/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
127/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
128pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
129    let mut f = shared.order_form();
130    f.symbol = t.symbol.clone();
131    f.exchange = exchange.to_string();
132    match col {
133        8 => {
134            f.direction = Direction::SHORT;
135            f.price = format!("{:.1}", t.bid_price_1);
136        }
137        9 => f.direction = Direction::SHORT,
138        10 => {
139            f.direction = Direction::LONG;
140            f.price = format!("{:.1}", t.ask_price_1);
141        }
142        11 => f.direction = Direction::LONG,
143        _ => {}
144    }
145    shared.set_order_form(f);
146}
147
148/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
149/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
150fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
151    if symbol.is_empty() {
152        return None;
153    }
154    shared.tick_rows().into_iter().find(|t| {
155        t.symbol == symbol
156            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
157    })
158}
159
160/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
161///
162/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
163/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
164/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
165/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
166/// Window 挂在所有面板之后。
167pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
168    // ---- 菜单栏(顶层面板,最先 = 最外) ----
169    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
170        render_menu_bar(ui, shared);
171    });
172
173    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
174    egui::TopBottomPanel::top("market_band")
175        .resizable(true)
176        .default_height(240.0)
177        .show(ctx, |ui| {
178            // ScrollArea auto_shrink(false) 吃满面板可用空间(resizable 面板的官方前提)
179            egui::ScrollArea::both()
180                .id_salt("market_band_scroll")
181                .auto_shrink([false, false])
182                .show(ui, |ui| {
183                    render_market_band(ui, shared);
184                });
185        });
186
187    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
188    egui::SidePanel::left("order_side")
189        .resizable(true)
190        .default_width(250.0)
191        .show(ctx, |ui| {
192            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
193            egui::ScrollArea::vertical()
194                .id_salt("order_side_scroll")
195                .auto_shrink([false, false])
196                .show(ui, |ui| {
197                    render_order_panel(ui, shared);
198                    ui.separator();
199                    render_book_frame(ui, shared);
200                });
201        });
202
203    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
204    egui::CentralPanel::default().show(ctx, |ui| {
205        let avail = ui.available_size();
206        let sp = ui.spacing().item_spacing;
207        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
208        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
209        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
210        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
211        // Frame+ScrollArea 在预留矩形内才吃满。
212        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
213        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
214        ui.horizontal(|ui| {
215            group_frame(ui, "grp_topleft", gw, gh, |ui| {
216                render_topleft_group(ui, shared);
217            });
218            group_frame(ui, "grp_trades", gw, gh, |ui| {
219                render_trades_group(ui, shared);
220            });
221        });
222        ui.horizontal(|ui| {
223            group_frame(ui, "grp_positions", gw, gh, |ui| {
224                render_positions_group(ui, shared);
225            });
226            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
227                render_bottomright_group(ui, shared);
228            });
229        });
230    });
231
232    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
233    render_windows(ctx, shared);
234}
235
236/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
237/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
238/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
239/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
240/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
241/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
242/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
243fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
244    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
245    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
246    egui::Frame::group(ui.style()).show(&mut child, |ui| {
247        egui::ScrollArea::both()
248            .id_salt(id)
249            .auto_shrink([false, false])
250            .show(ui, |ui| {
251                ui.vertical(content);
252            });
253    });
254}
255
256// ================= 菜单栏 =================
257
258fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
259    ui.horizontal(|ui| {
260        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
261        if ui.button(MENU_OPS).clicked() {
262            shared.set_ops_window_open(true);
263        }
264        if ui.button(MENU_CTP).clicked() {
265            shared.set_ctp_window_open(true);
266        }
267        if ui.button(MENU_CONTRACTS).clicked() {
268            shared.set_contracts_window_open(true);
269        }
270        ui.menu_button(MENU_HELP, |ui| {
271            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
272            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
273        });
274    });
275}
276
277// ================= 行情带 =================
278
279fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
280    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
281    let groups = shared.tick_groups();
282    if groups.is_empty() {
283        ui.label("(等待 eTick 订阅)");
284        return;
285    }
286    // 二级 tab:只列有 tick 的所
287    let sel = shared.selected_exchange();
288    let active = if groups.iter().any(|(e, _)| *e == sel) {
289        sel.clone()
290    } else {
291        groups[0].0.clone()
292    };
293    ui.horizontal(|ui| {
294        for (ex, _) in &groups {
295            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
296                shared.set_selected_exchange(ex.clone());
297            }
298        }
299    });
300    let ticks = groups
301        .iter()
302        .find(|(e, _)| *e == active)
303        .map(|(_, v)| v.clone())
304        .unwrap_or_default();
305    let last_time = ticks
306        .iter()
307        .map(|t| {
308            if t.update_time.is_empty() {
309                fmt_dt(Some(t.datetime))
310            } else {
311                t.update_time.clone()
312            }
313        })
314        .max()
315        .unwrap_or_else(|| "-".to_string());
316    ui.label(format!(
317        "tick {} 格 / 最后更新 {last_time}",
318        shared.tick_count()
319    ));
320    egui::Grid::new("tick_grid")
321        .striped(true)
322        .num_columns(14)
323        .show(ui, |ui| {
324            for h in [
325                "代码",
326                "名称",
327                "最新价",
328                "涨跌幅",
329                "成交量",
330                "今开",
331                "最高",
332                "最低",
333                "买1价",
334                "买1量",
335                "卖1价",
336                "卖1量",
337                "时间",
338                "接口",
339            ] {
340                ui.strong(h);
341            }
342            ui.end_row();
343            for t in &ticks {
344                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
345                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
346                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
347                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
348                let row_sel = shared.order_form().symbol == t.symbol;
349                let mut cells = Vec::with_capacity(14);
350                let mut dbl = false;
351                let resp = ui.button(t.symbol.clone());
352                if resp.clicked() {
353                    shared.set_selected_exchange(active.clone());
354                    import_tick(shared, t, &active, 0);
355                }
356                dbl |= resp.double_clicked();
357                cells.push(resp.rect);
358                macro_rules! cell {
359                    ($base:expr, $col:expr) => {{
360                        let base = $base;
361                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
362                        if r.clicked() {
363                            shared.set_selected_exchange(active.clone());
364                            import_tick(shared, t, &active, $col);
365                        }
366                        dbl |= r.double_clicked();
367                        cells.push(base.rect);
368                    }};
369                }
370                cell!(
371                    ui.label(if t.name.is_empty() {
372                        "-".to_string()
373                    } else {
374                        t.name.clone()
375                    }),
376                    1
377                );
378                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
379                cell!(
380                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
381                        if t.pct_change < 0.0 {
382                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
383                        } else {
384                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
385                        },
386                    )),
387                    3
388                );
389                cell!(ui.label(format!("{:.0}", t.volume)), 4);
390                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
391                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
392                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
393                cell!(
394                    ui.label(
395                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
396                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
397                    ),
398                    8
399                );
400                cell!(
401                    ui.label(
402                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
403                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
404                    ),
405                    9
406                );
407                cell!(
408                    ui.label(
409                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
410                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
411                    ),
412                    10
413                );
414                cell!(
415                    ui.label(
416                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
417                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
418                    ),
419                    11
420                );
421                cell!(
422                    ui.label(if t.update_time.is_empty() {
423                        fmt_dt(Some(t.datetime))
424                    } else {
425                        t.update_time.clone()
426                    }),
427                    12
428                );
429                cell!(ui.label(t.gateway_name.clone()), 13);
430                if dbl {
431                    shared.open_kline(t.symbol.clone());
432                }
433                paint_row_bg(ui, &cells, row_sel);
434                ui.end_row();
435            }
436        });
437}
438
439// ================= 下单面板(常驻) =================
440
441fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
442    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
443    let mut f = shared.order_form();
444    let before = f.clone();
445    let mut err: Option<String> = None;
446
447    // 随价勾选:价格框随该合约最新 tick(勾选态)
448    if f.follow_price {
449        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
450            if t.last_price > 0.0 {
451                f.price = format!("{:.1}", t.last_price);
452            }
453        }
454    }
455
456    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
457    let mut ex_opts: Vec<String> = shared
458        .tick_groups()
459        .iter()
460        .map(|(e, _)| e.clone())
461        .collect();
462    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
463        if !ex_opts.iter().any(|x| x == e) {
464            ex_opts.push(e.to_string());
465        }
466    }
467    if !ex_opts.contains(&f.exchange) {
468        ex_opts.insert(0, f.exchange.clone());
469    }
470    // 接口下拉:trade_snap 网关去重集 ∪ {sim, ctp}(审计钉:不新增总线口)
471    let ts = shared.trade_snap();
472    let mut gw_opts: Vec<String> = Vec::new();
473    for g in ts
474        .orders
475        .iter()
476        .map(|o| o.gateway_name.clone())
477        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
478        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
479    {
480        if !g.is_empty() && !gw_opts.contains(&g) {
481            gw_opts.push(g);
482        }
483    }
484    for g in ["sim", "ctp"] {
485        if !gw_opts.iter().any(|x| x == g) {
486            gw_opts.push(g.to_string());
487        }
488    }
489    if !gw_opts.contains(&f.gateway) {
490        gw_opts.insert(0, f.gateway.clone());
491    }
492
493    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
494    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
495    egui::Grid::new("order_form_grid")
496        .num_columns(2)
497        .show(ui, |ui| {
498            ui.label("交易所");
499            egui::ComboBox::from_id_salt("of_exchange")
500                .selected_text(f.exchange.clone())
501                .show_ui(ui, |ui| {
502                    for e in &ex_opts {
503                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
504                    }
505                });
506            ui.end_row();
507            ui.label("代码");
508            let sym_resp = ui.add(
509                egui::TextEdit::singleline(&mut f.symbol)
510                    .hint_text("cu2709")
511                    .desired_width(150.0),
512            );
513            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
514            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
515            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
516            // has_focus 口径回车订阅从不触发)。
517            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
518                let s = f.symbol.trim().to_string();
519                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
520                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
521                let bare = match s.split_once('.') {
522                    Some((_, b)) => b.trim().to_string(),
523                    None => s,
524                };
525                if !bare.is_empty() {
526                    f.symbol = bare.clone();
527                    shared.push_cmd(UiCmd::CtpSubscribe {
528                        symbols: vec![bare.clone()],
529                    });
530                    shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
531                }
532            }
533            ui.end_row();
534            ui.label("名称");
535            ui.label(
536                tick_of(shared, &f.exchange, &f.symbol)
537                    .map(|t| {
538                        if t.name.is_empty() {
539                            "-".to_string()
540                        } else {
541                            t.name
542                        }
543                    })
544                    .unwrap_or_else(|| "-".to_string()),
545            );
546            ui.end_row();
547            ui.label("方向");
548            egui::ComboBox::from_id_salt("of_direction")
549                .selected_text(f.direction.as_str())
550                .show_ui(ui, |ui| {
551                    for d in [Direction::LONG, Direction::SHORT] {
552                        ui.selectable_value(&mut f.direction, d, d.as_str());
553                    }
554                });
555            ui.end_row();
556            ui.label("开平");
557            ui.horizontal(|ui| {
558                egui::ComboBox::from_id_salt("of_offset")
559                    .selected_text(f.offset.as_str())
560                    .show_ui(ui, |ui| {
561                        for o in [
562                            Offset::OPEN,
563                            Offset::CLOSE,
564                            Offset::CLOSETODAY,
565                            Offset::CLOSEYESTERDAY,
566                        ] {
567                            ui.selectable_value(&mut f.offset, o, o.as_str());
568                        }
569                    });
570                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
571                ui.checkbox(&mut f.auto_split, "自动开平");
572            });
573            ui.end_row();
574            ui.label("类型");
575            egui::ComboBox::from_id_salt("of_order_type")
576                .selected_text(f.order_type.as_str())
577                .show_ui(ui, |ui| {
578                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
579                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
580                    }
581                });
582            ui.end_row();
583            ui.label("价格");
584            ui.horizontal(|ui| {
585                ui.add(
586                    egui::TextEdit::singleline(&mut f.price)
587                        .hint_text("107660")
588                        .desired_width(90.0),
589                );
590                ui.checkbox(&mut f.follow_price, "随价");
591            });
592            ui.end_row();
593            ui.label("数量");
594            ui.add(
595                egui::TextEdit::singleline(&mut f.volume)
596                    .hint_text("1")
597                    .desired_width(90.0),
598            );
599            ui.end_row();
600            ui.label("接口");
601            egui::ComboBox::from_id_salt("of_gateway")
602                .selected_text(if f.gateway.is_empty() {
603                    "sim".to_string()
604                } else {
605                    f.gateway.clone()
606                })
607                .show_ui(ui, |ui| {
608                    for g in &gw_opts {
609                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
610                    }
611                });
612            ui.end_row();
613            ui.label("操作");
614            ui.horizontal(|ui| {
615                if ui.button(BTN_SEND_ORDER).clicked() {
616                    match f.to_request() {
617                        Ok(req) => match crate::state::parse_gateway(&f.gateway) {
618                            Ok(gateway) => {
619                                shared.push_cmd(UiCmd::SendOrder {
620                                    req,
621                                    gateway,
622                                    mode: if f.auto_split {
623                                        ek_oms::LockMode::Auto
624                                    } else {
625                                        ek_oms::LockMode::None
626                                    },
627                                });
628                                shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
629                            }
630                            Err(e) => {
631                                shared.log_line(format!("[下单] {e}"));
632                                err = Some(e);
633                            }
634                        },
635                        Err(e) => {
636                            shared.log_line(format!("[下单] {e}"));
637                            err = Some(e);
638                        }
639                    }
640                }
641                if ui.button(BTN_CANCEL_ALL).clicked() {
642                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
643                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
644                    let mut n = 0usize;
645                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
646                        let gw = if o.gateway_name.is_empty() {
647                            "sim".to_string()
648                        } else {
649                            o.gateway_name.clone()
650                        };
651                        shared.push_cmd(UiCmd::CancelOrder {
652                            orderid: o.orderid.clone(),
653                            gateway: gw,
654                        });
655                        n += 1;
656                    }
657                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
658                }
659            });
660            ui.end_row();
661            ui.label("代码回车 = 订阅");
662            ui.label("");
663            ui.end_row();
664        });
665    if let Some(e) = err {
666        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
667    }
668    if f != before {
669        shared.set_order_form(f.clone());
670    }
671}
672
673/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
674/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
675/// 行情带跳色行指认,盘口帧不重复报幕)。
676fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
677    let f = shared.order_form();
678    match tick_of(shared, &f.exchange, &f.symbol) {
679        Some(t) => {
680            for i in (1..=5).rev() {
681                let (p, v) = match i {
682                    1 => (t.ask_price_1, t.ask_volume_1),
683                    2 => (t.ask_price_2, t.ask_volume_2),
684                    3 => (t.ask_price_3, t.ask_volume_3),
685                    4 => (t.ask_price_4, t.ask_volume_4),
686                    _ => (t.ask_price_5, t.ask_volume_5),
687                };
688                ui.label(
689                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
690                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
691                );
692            }
693            for i in 1..=5 {
694                let (p, v) = match i {
695                    1 => (t.bid_price_1, t.bid_volume_1),
696                    2 => (t.bid_price_2, t.bid_volume_2),
697                    3 => (t.bid_price_3, t.bid_volume_3),
698                    4 => (t.bid_price_4, t.bid_volume_4),
699                    _ => (t.bid_price_5, t.bid_volume_5),
700                };
701                ui.label(
702                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
703                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
704                );
705            }
706        }
707        None => {
708            ui.label("(无 tick)");
709        }
710    }
711}
712
713// ================= 2×2 监控格 =================
714
715fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
716    let tab = shared.topleft_tab();
717    ui.horizontal(|ui| {
718        if ui
719            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
720            .clicked()
721        {
722            shared.set_topleft_tab(TopLeftTab::Orders);
723        }
724        if ui
725            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
726            .clicked()
727        {
728            shared.set_topleft_tab(TopLeftTab::Active);
729        }
730    });
731    render_orders_table(ui, shared, tab == TopLeftTab::Active);
732}
733
734/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
735/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
736/// 活动行撤单钮 + 双击撤单,终态行只读)。
737fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
738    let ts = shared.trade_snap();
739    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
740    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
741    let rows: Vec<_> = ts
742        .orders
743        .iter()
744        .filter(|o| !active_only || order_active(o.status))
745        .collect();
746    if rows.is_empty() {
747        ui.label(if active_only {
748            "(无未成交)"
749        } else {
750            "(无委托:等网关 send_order)"
751        });
752        return;
753    }
754    egui::Grid::new(if active_only {
755        "active_grid"
756    } else {
757        "order_grid"
758    })
759    .striped(true)
760    .num_columns(17)
761    .show(ui, |ui| {
762        for h in [
763            "代码",
764            "名称",
765            "交易所",
766            "类型",
767            "方向",
768            "开平",
769            "价格",
770            "总量",
771            "已成",
772            "成交均价",
773            "状态",
774            "状态描述",
775            "时间",
776            "委托号",
777            "来源",
778            "接口",
779            "操作",
780        ] {
781            ui.strong(h);
782        }
783        ui.end_row();
784        for o in &rows {
785            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
786            // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
787            // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
788            let key = format!("{}.{}", o.gateway_name, o.orderid);
789            let row_sel = shared.row_selected("orders", &key);
790            let mut cells = Vec::with_capacity(17);
791            let mut dbl = false;
792            let sel = ui.selectable_label(row_sel, o.symbol.clone());
793            if sel.clicked() {
794                shared.select_row("orders", &key);
795            }
796            dbl |= sel.double_clicked();
797            cells.push(sel.rect);
798            macro_rules! cell {
799                ($base:expr, $col:expr) => {{
800                    let base = $base;
801                    let r = click_cell(ui, base.rect, ("ord", &key, $col));
802                    if r.clicked() {
803                        shared.select_row("orders", &key);
804                    }
805                    dbl |= r.double_clicked();
806                    cells.push(base.rect);
807                }};
808            }
809            cell!(
810                ui.label(if o.name.is_empty() {
811                    "-".to_string()
812                } else {
813                    o.name.clone()
814                }),
815                1
816            );
817            cell!(ui.label(o.exchange.as_str().to_string()), 2);
818            cell!(ui.label(o.order_type.as_str().to_string()), 3);
819            cell!(
820                ui.label(
821                    egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
822                        if o.direction == Some(Direction::SHORT) {
823                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
824                        } else {
825                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
826                        },
827                    ),
828                ),
829                4
830            );
831            cell!(ui.label(o.offset.as_str().to_string()), 5);
832            cell!(ui.label(format!("{:.1}", o.price)), 6);
833            cell!(ui.label(format!("{:.0}", o.volume)), 7);
834            cell!(ui.label(format!("{:.0}", o.traded)), 8);
835            cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
836            cell!(
837                ui.label(egui::RichText::new(o.status.as_str()).color(
838                    if o.status == ek_data::constant::Status::REJECTED {
839                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
840                    } else if o.status == ek_data::constant::Status::ALLTRADED {
841                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
842                    } else {
843                        egui::Color32::from_rgb(0xE0, 0x88, 0x10)
844                    },
845                )),
846                10
847            );
848            cell!(ui.label(o.status_msg.clone()), 11);
849            cell!(ui.label(fmt_dt(o.datetime)), 12);
850            cell!(ui.label(o.orderid.clone()), 13);
851            cell!(
852                ui.label(if o.reference.is_empty() {
853                    "-".to_string()
854                } else {
855                    o.reference.clone()
856                }),
857                14
858            );
859            cell!(ui.label(o.gateway_name.clone()), 15);
860            if dbl && order_active(o.status) {
861                let gw = if o.gateway_name.is_empty() {
862                    "sim".to_string()
863                } else {
864                    o.gateway_name.clone()
865                };
866                shared.push_cmd(UiCmd::CancelOrder {
867                    orderid: o.orderid.clone(),
868                    gateway: gw,
869                });
870            }
871            let hr = ui.horizontal(|ui| {
872                if order_active(o.status)
873                    && ui
874                        .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
875                        .clicked()
876                {
877                    // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 sim)
878                    let gw = if o.gateway_name.is_empty() {
879                        "sim".to_string()
880                    } else {
881                        o.gateway_name.clone()
882                    };
883                    shared.push_cmd(UiCmd::CancelOrder {
884                        orderid: o.orderid.clone(),
885                        gateway: gw,
886                    });
887                }
888            });
889            cells.push(hr.response.rect);
890            paint_row_bg(ui, &cells, row_sel);
891            ui.end_row();
892        }
893    });
894}
895
896/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
897fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
898    ui.label("成交");
899    let ts = shared.trade_snap();
900    if ts.trades.is_empty() {
901        ui.label("(无成交)");
902        return;
903    }
904    egui::Grid::new("trade_grid")
905        .striped(true)
906        .num_columns(12)
907        .show(ui, |ui| {
908            for h in [
909                "成交号",
910                "代码",
911                "名称",
912                "交易所",
913                "方向",
914                "开平",
915                "价格",
916                "数量",
917                "成交额",
918                "时间",
919                "委托号",
920                "接口",
921            ] {
922                ui.strong(h);
923            }
924            ui.end_row();
925            for t in &ts.trades {
926                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
927                // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
928                let key = format!("{}.{}", t.gateway_name, t.tradeid);
929                let row_sel = shared.row_selected("trades", &key);
930                let mut cells = Vec::with_capacity(12);
931                let sel = ui.selectable_label(row_sel, t.tradeid.clone());
932                if sel.clicked() {
933                    shared.select_row("trades", &key);
934                }
935                cells.push(sel.rect);
936                macro_rules! cell {
937                    ($base:expr, $col:expr) => {{
938                        let base = $base;
939                        let r = click_cell(ui, base.rect, ("trd", &key, $col));
940                        if r.clicked() {
941                            shared.select_row("trades", &key);
942                        }
943                        cells.push(base.rect);
944                    }};
945                }
946                cell!(ui.label(t.symbol.clone()), 1);
947                cell!(
948                    ui.label(if t.name.is_empty() {
949                        "-".to_string()
950                    } else {
951                        t.name.clone()
952                    }),
953                    2
954                );
955                cell!(ui.label(t.exchange.as_str().to_string()), 3);
956                cell!(
957                    ui.label(
958                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
959                            if t.direction == Some(Direction::SHORT) {
960                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
961                            } else {
962                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
963                            },
964                        ),
965                    ),
966                    4
967                );
968                cell!(ui.label(t.offset.as_str().to_string()), 5);
969                cell!(ui.label(format!("{:.1}", t.price)), 6);
970                cell!(ui.label(format!("{:.0}", t.volume)), 7);
971                cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
972                cell!(ui.label(fmt_dt(t.datetime)), 9);
973                cell!(ui.label(t.orderid.clone()), 10);
974                cell!(ui.label(t.gateway_name.clone()), 11);
975                paint_row_bg(ui, &cells, row_sel);
976                ui.end_row();
977            }
978        });
979}
980
981/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
982/// Python mainwindow.py:276 等价,不开 K线)。
983fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
984    ui.label("持仓");
985    let ts = shared.trade_snap();
986    if ts.positions.is_empty() {
987        ui.label("(无持仓)");
988        return;
989    }
990    egui::Grid::new("pos_grid")
991        .striped(true)
992        .num_columns(16)
993        .show(ui, |ui| {
994            for h in [
995                "代码",
996                "名称",
997                "交易所",
998                "方向",
999                "数量",
1000                "昨仓",
1001                "可用",
1002                "冻结",
1003                "在途",
1004                "市值",
1005                "成本",
1006                "均价",
1007                "最新价",
1008                "盈亏",
1009                "盈亏比",
1010                "接口",
1011            ] {
1012                ui.strong(h);
1013            }
1014            ui.end_row();
1015            for p in &ts.positions {
1016                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1017                // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1018                // 不开 K线;方向随持仓方向,Python 同口径)。
1019                let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1020                let row_sel = shared.row_selected("positions", &key);
1021                let mut cells = Vec::with_capacity(16);
1022                let mut dbl = false;
1023                let sel = ui.selectable_label(row_sel, p.symbol.clone());
1024                if sel.clicked() {
1025                    shared.select_row("positions", &key);
1026                }
1027                dbl |= sel.double_clicked();
1028                cells.push(sel.rect);
1029                macro_rules! cell {
1030                    ($base:expr, $col:expr) => {{
1031                        let base = $base;
1032                        let r = click_cell(ui, base.rect, ("pos", &key, $col));
1033                        if r.clicked() {
1034                            shared.select_row("positions", &key);
1035                        }
1036                        dbl |= r.double_clicked();
1037                        cells.push(base.rect);
1038                    }};
1039                }
1040                cell!(
1041                    ui.label(if p.name.is_empty() {
1042                        "-".to_string()
1043                    } else {
1044                        p.name.clone()
1045                    }),
1046                    1
1047                );
1048                cell!(ui.label(p.exchange.as_str().to_string()), 2);
1049                cell!(
1050                    ui.label(egui::RichText::new(p.direction.as_str()).color(
1051                        if p.direction == ek_data::constant::Direction::SHORT {
1052                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1053                        } else {
1054                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1055                        },
1056                    )),
1057                    3
1058                );
1059                cell!(ui.label(format!("{:.0}", p.volume)), 4);
1060                cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1061                cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1062                cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1063                cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1064                cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1065                cell!(ui.label(format!("{:.2}", p.price)), 10);
1066                cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1067                cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1068                cell!(
1069                    ui.label(
1070                        egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1071                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1072                        } else {
1073                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1074                        }),
1075                    ),
1076                    13
1077                );
1078                cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1079                cell!(ui.label(p.gateway_name.clone()), 15);
1080                if dbl {
1081                    let mut f = shared.order_form();
1082                    f.symbol = p.symbol.clone();
1083                    f.exchange = p.exchange.as_str().to_string();
1084                    f.direction = p.direction;
1085                    shared.set_order_form(f);
1086                    shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1087                }
1088                paint_row_bg(ui, &cells, row_sel);
1089                ui.end_row();
1090            }
1091        });
1092}
1093
1094fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1095    let tab = shared.bottomright_tab();
1096    ui.horizontal(|ui| {
1097        if ui
1098            .selectable_label(
1099                tab == BottomRightTab::Account,
1100                BottomRightTab::Account.label(),
1101            )
1102            .clicked()
1103        {
1104            shared.set_bottomright_tab(BottomRightTab::Account);
1105        }
1106        if ui
1107            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1108            .clicked()
1109        {
1110            shared.set_bottomright_tab(BottomRightTab::Logs);
1111        }
1112    });
1113    match tab {
1114        BottomRightTab::Account => {
1115            let ts = shared.trade_snap();
1116            if ts.accounts.is_empty() {
1117                ui.label("(无资金:等网关 eAccount.)");
1118                return;
1119            }
1120            egui::Grid::new("acct_grid")
1121                .striped(true)
1122                .num_columns(5)
1123                .show(ui, |ui| {
1124                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1125                        ui.strong(h);
1126                    }
1127                    ui.end_row();
1128                    for a in &ts.accounts {
1129                        ui.label(a.accountid.clone());
1130                        ui.label(a.gateway_name.clone());
1131                        ui.label(format!("{:.2}", a.balance));
1132                        ui.label(format!("{:.2}", a.available()));
1133                        ui.label(format!("{:.2}", a.frozen));
1134                        ui.end_row();
1135                    }
1136                });
1137        }
1138        BottomRightTab::Logs => {
1139            render_logs(ui, shared);
1140        }
1141    }
1142}
1143
1144fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1145    ui.horizontal(|ui| {
1146        if ui.button(BTN_CLEAR_LOGS).clicked() {
1147            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1148        }
1149        let (_, spilled) = shared.logs_snapshot();
1150        if spilled > 0 {
1151            ui.label(format!(
1152                "已丢弃 {spilled} 条(环形 {})",
1153                crate::state::RING_CAP
1154            ));
1155        }
1156    });
1157    let lines = shared.logs_snapshot().0;
1158    egui::ScrollArea::vertical()
1159        .id_salt("log_scroll")
1160        .max_height(180.0)
1161        .stick_to_bottom(true)
1162        .show(ui, |ui| {
1163            for line in lines {
1164                ui.monospace(line);
1165            }
1166        });
1167}
1168
1169// ================= 弹窗面 =================
1170
1171fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1172    // 运维窗(菜单「运维」)
1173    let mut ops_open = shared.ops_window_open();
1174    egui::Window::new("运维窗")
1175        .id(egui::Id::new("ops_win"))
1176        .default_pos([80.0, 320.0])
1177        .default_size([640.0, 380.0])
1178        .open(&mut ops_open)
1179        .show(ctx, |ui| {
1180            render_ops_window(ui, shared);
1181        });
1182    shared.set_ops_window_open(ops_open);
1183
1184    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面,主窗只当弹窗宿主)
1185    let mut ctp_open = shared.ctp_window_open();
1186    egui::Window::new("CTP 网关配置窗")
1187        .id(egui::Id::new("ctp_win"))
1188        .default_pos([640.0, 320.0])
1189        .default_size([560.0, 330.0])
1190        .open(&mut ctp_open)
1191        .show(ctx, |ui| {
1192            render_ctp_window(ui, shared);
1193        });
1194    shared.set_ctp_window_open(ctp_open);
1195
1196    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)
1197    let mut contracts_open = shared.contracts_window_open();
1198    egui::Window::new("查询合约窗")
1199        .id(egui::Id::new("contracts_win"))
1200        .default_pos([420.0, 300.0])
1201        .default_size([560.0, 420.0])
1202        .open(&mut contracts_open)
1203        .show(ctx, |ui| {
1204            render_contracts_window(ui, shared);
1205        });
1206    shared.set_contracts_window_open(contracts_open);
1207
1208    // K线窗(双击弹出,按合约多开;窗关闭钮 = 出表)
1209    let mut closed: Vec<String> = Vec::new();
1210    for sym in shared.kline_symbols() {
1211        let resp = egui::Window::new(format!("K线 — {sym}"))
1212            .id(egui::Id::new(("kline_win", sym.clone())))
1213            .default_pos([300.0, 340.0])
1214            .default_size([540.0, 330.0])
1215            .show(ctx, |ui| {
1216                let cell = shared
1217                    .market()
1218                    .cells()
1219                    .into_iter()
1220                    .find(|c| c.symbol == sym);
1221                render_kline_cell(ui, cell.as_ref());
1222            });
1223        if resp.is_none() {
1224            closed.push(sym);
1225        }
1226    }
1227    for s in closed {
1228        shared.close_kline(&s);
1229    }
1230}
1231
1232/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1233/// + 依赖图 + 流控查询钮。回灌界面面已退役(总线口保留)。
1234fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1235    ui.label("插件");
1236    let snap = shared.snapshot();
1237    egui::Grid::new("plug_grid")
1238        .striped(true)
1239        .num_columns(6)
1240        .show(ui, |ui| {
1241            ui.strong("名称");
1242            ui.strong("状态");
1243            ui.strong("版本");
1244            ui.strong("依赖");
1245            ui.strong("统计");
1246            ui.strong("操作");
1247            ui.end_row();
1248            if snap.rows.is_empty() {
1249                ui.label("(快照空:等 eTimer 首拉)");
1250                ui.end_row();
1251            }
1252            for r in &snap.rows {
1253                if is_quarantined(&r.state) {
1254                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1255                    ui.label(
1256                        egui::RichText::new(state_text(r))
1257                            .color(egui::Color32::RED)
1258                            .strong(),
1259                    );
1260                } else {
1261                    ui.label(r.name.clone());
1262                    ui.label(state_text(r));
1263                }
1264                ui.label(r.version.clone());
1265                ui.label(format!("deps:[{}]", r.deps.join(",")));
1266                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1267                ui.horizontal(|ui| {
1268                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1269                        shared.push_cmd(UiCmd::Start {
1270                            factory: r.name.clone(),
1271                        });
1272                    }
1273                    let armed = shared.pending_confirm();
1274                    let armed_here = ui
1275                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1276                        .clicked()
1277                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1278                    if armed_here {
1279                        shared.push_cmd(UiCmd::Remove {
1280                            name: r.name.clone(),
1281                        });
1282                    }
1283                });
1284                ui.end_row();
1285            }
1286        });
1287
1288    if let Some(name) = shared.pending_confirm() {
1289        ui.horizontal(|ui| {
1290            ui.label(
1291                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1292                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1293            );
1294            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1295                shared.confirm_cancel();
1296            }
1297        });
1298    }
1299
1300    ui.label("服务");
1301    let flat: Vec<String> = snap
1302        .services
1303        .iter()
1304        .flat_map(|(n, ids)| {
1305            if ids.is_empty() {
1306                vec![format!("{n} 服务"), "-".to_string()]
1307            } else {
1308                ids.iter()
1309                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1310                    .collect()
1311            }
1312        })
1313        .collect();
1314    if flat.is_empty() {
1315        ui.label("(无服务目录)");
1316    } else {
1317        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1318        egui::Grid::new("svc_grid")
1319            .striped(true)
1320            .num_columns(6)
1321            .show(ui, |ui| {
1322                for r in 0..rows {
1323                    for c in 0..3 {
1324                        let i = c * rows * 2 + r * 2;
1325                        if i < flat.len() {
1326                            ui.label(flat[i].clone());
1327                            ui.label(flat[i + 1].clone());
1328                        } else {
1329                            ui.label("");
1330                            ui.label("");
1331                        }
1332                    }
1333                    ui.end_row();
1334                }
1335            });
1336    }
1337
1338    ui.label("依赖");
1339    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1340        ui.label("(全部无依赖)");
1341    }
1342    for r in &snap.rows {
1343        if !r.deps.is_empty() {
1344            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1345        }
1346    }
1347
1348    ui.horizontal(|ui| {
1349        if ui.small_button(BTN_QUERY_RATE).clicked() {
1350            shared.push_cmd(UiCmd::QueryRate);
1351        }
1352    });
1353}
1354
1355/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1356/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1357/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1358fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1359    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1360    let mut form: crate::state::CtpForm = shared.ctp_form();
1361    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1362        ui.label("broker");
1363        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1364        ui.label("td 前置");
1365        ctp_edit(
1366            ui,
1367            shared,
1368            &mut form.td_addr,
1369            "tcp://host:40001",
1370            CtpField::TdAddr,
1371        );
1372        ui.label("td 用户");
1373        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1374        ui.end_row();
1375        ui.label("td 密码");
1376        ctp_edit(
1377            ui,
1378            shared,
1379            &mut form.td_password,
1380            "密码",
1381            CtpField::TdPassword,
1382        );
1383        ui.label("AppID");
1384        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1385        ui.label("AuthCode");
1386        ctp_edit(
1387            ui,
1388            shared,
1389            &mut form.td_auth_code,
1390            "授权码",
1391            CtpField::AuthCode,
1392        );
1393        ui.end_row();
1394        ui.label("md 前置");
1395        ctp_edit(
1396            ui,
1397            shared,
1398            &mut form.md_addr,
1399            "tcp://host:40011",
1400            CtpField::MdAddr,
1401        );
1402        ui.label("md 用户");
1403        ctp_edit(
1404            ui,
1405            shared,
1406            &mut form.md_user,
1407            "空=td 回落",
1408            CtpField::MdUser,
1409        );
1410        ui.label("md 密码");
1411        ctp_edit(
1412            ui,
1413            shared,
1414            &mut form.md_password,
1415            "空=td 回落",
1416            CtpField::MdPassword,
1417        );
1418        ui.end_row();
1419    });
1420    ui.horizontal(|ui| {
1421        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1422            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1423            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1424        }
1425        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1426    });
1427}
1428
1429/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1430/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1431/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1432fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1433    let mut q = shared.contract_query();
1434    ui.horizontal(|ui| {
1435        ui.label("代码或交易所(留空 = 全部):");
1436        if ui
1437            .add(
1438                egui::TextEdit::singleline(&mut q)
1439                    .hint_text("cu 或 SHFE")
1440                    .desired_width(200.0),
1441            )
1442            .changed()
1443        {
1444            shared.set_contract_query(q.clone());
1445        }
1446    });
1447    let rows = shared.contracts(&q);
1448    if rows.is_empty() {
1449        ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1450        return;
1451    }
1452    let selected = shared.selected_contracts();
1453    egui::ScrollArea::vertical()
1454        .id_salt("contract_scroll")
1455        .max_height(260.0)
1456        .auto_shrink([false, false])
1457        .show(ui, |ui| {
1458            egui::Grid::new("contract_grid")
1459                .striped(true)
1460                .num_columns(5)
1461                .show(ui, |ui| {
1462                    for h in ["选(代码)", "交易所", "名称", "乘数", "最小变动"] {
1463                        ui.strong(h);
1464                    }
1465                    ui.end_row();
1466                    for c in &rows {
1467                        let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1468                        let mut on = selected.contains(&key);
1469                        // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1470                        // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
1471                        // 行选中(动作归行,不归某一列)。
1472                        let row_sel = shared.row_selected("contracts", &key);
1473                        let mut cells = Vec::with_capacity(5);
1474                        let ck = ui.checkbox(&mut on, key.clone());
1475                        if ck.changed() {
1476                            shared.toggle_contract_selection(&key);
1477                            shared.select_row("contracts", &key);
1478                        }
1479                        cells.push(ck.rect);
1480                        macro_rules! cell {
1481                            ($base:expr, $col:expr) => {{
1482                                let base = $base;
1483                                let r = click_cell(ui, base.rect, ("crt", &key, $col));
1484                                if r.clicked() {
1485                                    shared.select_row("contracts", &key);
1486                                }
1487                                cells.push(base.rect);
1488                            }};
1489                        }
1490                        cell!(ui.label(c.exchange.as_str().to_string()), 1);
1491                        cell!(ui.label(c.name.clone()), 2);
1492                        cell!(ui.label(format!("{:.0}", c.size)), 3);
1493                        cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1494                        paint_row_bg(ui, &cells, row_sel);
1495                        ui.end_row();
1496                    }
1497                });
1498        });
1499    ui.horizontal(|ui| {
1500        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1501        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1502            if selected.is_empty() {
1503                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1504            } else {
1505                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1506                let symbols: Vec<String> = selected
1507                    .iter()
1508                    .map(|k| {
1509                        k.split_once('.')
1510                            .map(|(_, s)| s.to_string())
1511                            .unwrap_or_else(|| k.clone())
1512                    })
1513                    .collect();
1514                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1515                shared.log_line(format!(
1516                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1517                    selected.len()
1518                ));
1519            }
1520        }
1521    });
1522}
1523
1524/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1525enum CtpField {
1526    Broker,
1527    TdAddr,
1528    TdUser,
1529    TdPassword,
1530    AppId,
1531    AuthCode,
1532    MdAddr,
1533    MdUser,
1534    MdPassword,
1535}
1536
1537/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1538fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1539    let is_secret = matches!(
1540        field,
1541        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1542    );
1543    let mut w = egui::TextEdit::singleline(val)
1544        .hint_text(hint)
1545        .desired_width(110.0);
1546    if is_secret {
1547        w = w.password(true);
1548    }
1549    if ui.add(w).changed() {
1550        let mut f = shared.ctp_form();
1551        match field {
1552            CtpField::Broker => f.broker_id = val.clone(),
1553            CtpField::TdAddr => f.td_addr = val.clone(),
1554            CtpField::TdUser => f.td_user = val.clone(),
1555            CtpField::TdPassword => f.td_password = val.clone(),
1556            CtpField::AppId => f.td_app_id = val.clone(),
1557            CtpField::AuthCode => f.td_auth_code = val.clone(),
1558            CtpField::MdAddr => f.md_addr = val.clone(),
1559            CtpField::MdUser => f.md_user = val.clone(),
1560            CtpField::MdPassword => f.md_password = val.clone(),
1561        }
1562        shared.set_ctp_form(f);
1563    }
1564}
1565
1566/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1567///
1568/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1569fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1570    let Some(c) = cell else {
1571        ui.label("(K线无格:等 dm 合成)");
1572        return;
1573    };
1574    if c.bars.is_empty() {
1575        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1576        return;
1577    }
1578    let mut data = egui_charts::model::bar::BarData::new();
1579    for b in &c.bars {
1580        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1581        data.push(egui_charts::model::bar::Bar::new(
1582            dt,
1583            b.open_price,
1584            b.high_price,
1585            b.low_price,
1586            b.close_price,
1587            b.volume,
1588        ));
1589    }
1590    ui.label(format!(
1591        "K线 {}@{}s({} 根)",
1592        c.symbol,
1593        c.interval,
1594        c.bars.len()
1595    ));
1596    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1597    chart.set_symbol(&c.symbol);
1598    chart.set_timeframe_label(&format!("{}s", c.interval));
1599    chart.enable_tracking_mode();
1600    let _ = chart.show(ui);
1601}