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//!   盘口四列带方向:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
10//!   方向保持默认——0.2.1 `import_tick`),行内任意格双击 = 弹 K线窗。
11//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
12//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
13//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
14//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
15//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
16//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
17//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
18//!   D7 口径)。
19//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
20//!   流控查询钮随 sim 退场撤除)、
21//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
22//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
23//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
24//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
25//!   kittest 走回退面断言)。
26//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
27//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
28//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
29//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
30//!
31//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
32//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
33//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
34
35use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
43pub const BTN_CLEAR_LOGS: &str = "清空日志";
44/// 取消待确认按钮标签。
45pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46/// 下单提交按钮标签(v4 = Python「委托」钮)。
47pub const BTN_SEND_ORDER: &str = "委托";
48/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
49pub const BTN_CANCEL_ALL: &str = "全撤";
50/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
51pub const BTN_CANCEL_ORDER: &str = "撤单";
52/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
53pub const BTN_CTP_CONNECT: &str = "连接 CTP";
54/// 菜单项:运维(弹运维窗)。
55pub const MENU_OPS: &str = "运维";
56/// 菜单项:网关(弹 CTP 配置窗)。
57pub const MENU_CTP: &str = "网关";
58/// 菜单项:帮助。
59pub const MENU_HELP: &str = "帮助";
60/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
61pub const MENU_CONTRACTS: &str = "查询合约";
62/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
63/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
64/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
65pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
66
67fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
68    if armed_on == Some(name) {
69        format!("确认移除 {name} ?")
70    } else {
71        format!("移除 {name}")
72    }
73}
74
75fn state_text(r: &PlugRow) -> String {
76    r.state.clone()
77}
78
79/// DtNs → HH:MM:SS(**北京时间 +08**;数据面时间戳统一存 UTC 纳秒 —— CTP
80/// 回报的自然日本就是北京钟面,入账按 +08 换算存 UTC,展示面换回来,表内
81/// 时间列与交易员作息同钟面)。
82fn fmt_dt(dt: Option<i64>) -> String {
83    dt.map(|ns| {
84        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
85            .with_timezone(&beijing_tz())
86            .format("%H:%M:%S")
87            .to_string()
88    })
89    .unwrap_or_else(|| "-".to_string())
90}
91
92/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
93fn beijing_tz() -> chrono::FixedOffset {
94    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
95}
96
97// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
98
99/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
100/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
101/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
102fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
103    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
104        return;
105    };
106    let hovered = ui
107        .input(|i| i.pointer.interact_pos())
108        .is_some_and(|p| union.expand(3.0).contains(p));
109    if selected {
110        ui.painter().rect_filled(
111            union.expand(3.0),
112            3.0,
113            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
114        );
115    } else if hovered {
116        ui.painter().rect_filled(
117            union.expand(3.0),
118            3.0,
119            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
120        );
121    }
122}
123
124/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
125fn order_active(status: ek_data::constant::Status) -> bool {
126    ek_data::constant::ACTIVE_STATUSES.contains(&status)
127}
128
129/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
130/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
131fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
132    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
133}
134
135/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
136/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
137/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
138pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
139    let mut f = shared.order_form();
140    f.symbol = t.symbol.clone();
141    f.exchange = exchange.to_string();
142    match col {
143        8 => {
144            f.direction = Direction::SHORT;
145            f.price = format!("{:.1}", t.bid_price_1);
146        }
147        9 => f.direction = Direction::SHORT,
148        10 => {
149            f.direction = Direction::LONG;
150            f.price = format!("{:.1}", t.ask_price_1);
151        }
152        11 => f.direction = Direction::LONG,
153        _ => {}
154    }
155    shared.set_order_form(f);
156}
157
158/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
159/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
160fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
161    if symbol.is_empty() {
162        return None;
163    }
164    shared.tick_rows().into_iter().find(|t| {
165        t.symbol == symbol
166            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
167    })
168}
169
170/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
171/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
172/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
173pub fn apply_ui_style(ctx: &egui::Context) {
174    apply_ui_style_for(ctx, crate::state::Theme::default());
175}
176
177/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
178/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
179/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
180/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
181pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
182    ctx.style_mut(|style| {
183        style
184            .text_styles
185            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
186        style
187            .text_styles
188            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
189        style
190            .text_styles
191            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
192        style
193            .text_styles
194            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
195        style
196            .text_styles
197            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
198    });
199    ctx.set_visuals(theme_visuals(theme));
200}
201
202/// 四套主题视觉(BD-UI-34)。金融深色 = egui dark 骨架换深海军蓝底
203/// (交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6 Fusion 迁移用。
204fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
205    use egui::Color32;
206    match t {
207        crate::state::Theme::EguiDark => egui::Visuals::dark(),
208        crate::state::Theme::EguiLight => egui::Visuals::light(),
209        crate::state::Theme::QtClassic => {
210            let mut v = egui::Visuals::light();
211            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
212            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
213            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
214            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
215            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
216            v
217        }
218        crate::state::Theme::FinanceDark => {
219            let mut v = egui::Visuals::dark();
220            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
221            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
222            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
223            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
224            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
225            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
226            v.widgets.noninteractive.bg_stroke =
227                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
228            v
229        }
230    }
231}
232
233/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
234///
235/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
236/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
237/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
238/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
239/// Window 挂在所有面板之后。
240pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
241    apply_ui_style_for(ctx, shared.theme());
242    // ---- 菜单栏(顶层面板,最先 = 最外) ----
243    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
244        render_menu_bar(ui, shared);
245    });
246
247    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
248    egui::TopBottomPanel::top("market_band")
249        .resizable(true)
250        .default_height(240.0)
251        .show(ctx, |ui| {
252            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
253            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
254            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
255            egui::ScrollArea::both()
256                .id_salt("market_band_scroll")
257                .auto_shrink([false, false])
258                .show(ui, |ui| {
259                    render_market_band(ui, shared);
260                });
261        });
262
263    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
264    egui::SidePanel::left("order_side")
265        .resizable(true)
266        .default_width(250.0)
267        .show(ctx, |ui| {
268            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
269            egui::ScrollArea::vertical()
270                .id_salt("order_side_scroll")
271                .auto_shrink([false, false])
272                .show(ui, |ui| {
273                    render_order_panel(ui, shared);
274                    ui.separator();
275                    render_book_frame(ui, shared);
276                });
277        });
278
279    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
280    egui::CentralPanel::default().show(ctx, |ui| {
281        let avail = ui.available_size();
282        let sp = ui.spacing().item_spacing;
283        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
284        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
285        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
286        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
287        // Frame+ScrollArea 在预留矩形内才吃满。
288        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
289        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
290        ui.horizontal(|ui| {
291            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
292                render_topleft_group(ui, shared);
293            });
294            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
295                render_trades_group(ui, shared);
296            });
297        });
298        ui.horizontal(|ui| {
299            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
300                render_positions_group(ui, shared);
301            });
302            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
303            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
304            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
305            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
306            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
307                render_bottomright_group(ui, shared);
308            });
309        });
310    });
311
312    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
313    render_windows(ctx, shared);
314}
315
316/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
317/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
318/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
319/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
320/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
321/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
322/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
323fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
324    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
325    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
326    egui::Frame::group(ui.style()).show(&mut child, |ui| {
327        egui::ScrollArea::both()
328            .id_salt(id)
329            .auto_shrink([false, false])
330            .show(ui, |ui| {
331                ui.vertical(content);
332            });
333    });
334}
335
336/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
337/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
338/// = 双滚动条且表头失去固定意义。
339/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
340/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
341/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
342fn group_frame_plain(
343    ui: &mut egui::Ui,
344    _id: &str,
345    w: f32,
346    h: f32,
347    content: impl FnOnce(&mut egui::Ui),
348) {
349    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
350    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
351    egui::Frame::group(ui.style()).show(&mut child, |ui| {
352        ui.vertical(content);
353    });
354}
355
356/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
357/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
358///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
359///   request_repaint → kittest max_steps 爆)。
360/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
361///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
362///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
363///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
364///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
365/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
366///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
367///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
368///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
369///
370/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
371fn pinned_table(
372    ui: &mut egui::Ui,
373    grid_id: &str,
374    num_columns: usize,
375    headers: &[&str],
376    body: impl FnOnce(&mut egui::Ui),
377) {
378    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
379        std::cell::RefCell::new(Vec::new());
380    let resp = egui::ScrollArea::both()
381        .id_salt(format!("{grid_id}_sc"))
382        .auto_shrink([false, false])
383        .show(ui, |ui| {
384            egui::Grid::new(grid_id)
385                .striped(true)
386                .num_columns(num_columns)
387                .show(ui, |ui| {
388                    for h in headers {
389                        let r = ui.strong(*h);
390                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
391                    }
392                    ui.end_row();
393                    body(ui);
394                });
395        });
396    let heads = heads_cell.borrow();
397    if heads.is_empty() {
398        return;
399    }
400    let area = resp.inner_rect;
401    let head_h = heads[0].0.height();
402    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
403    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
404    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
405    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
406    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
407    let m = ui.visuals().clip_rect_margin;
408    let strip = egui::Rect::from_min_max(
409        egui::pos2(area.min.x - m, area.min.y - m),
410        egui::pos2(
411            area.max.x + m,
412            area.min.y + head_h + ui.spacing().item_spacing.y,
413        ),
414    );
415    ui.scope_builder(
416        egui::UiBuilder::new()
417            .id_salt(format!("{grid_id}_hdr_ov"))
418            .max_rect(strip),
419        |ui| {
420            ui.set_clip_rect(strip);
421            let painter = ui.painter();
422            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
423            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
424            let color = ui.visuals().strong_text_color();
425            for (rect, text) in heads.iter() {
426                painter.text(
427                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
428                    egui::Align2::LEFT_CENTER,
429                    text.as_str(),
430                    font.clone(),
431                    color,
432                );
433            }
434        },
435    );
436}
437
438// ================= 菜单栏 =================
439
440fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
441    ui.horizontal(|ui| {
442        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
443        if ui.button(MENU_OPS).clicked() {
444            shared.set_ops_window_open(true);
445        }
446        if ui.button(MENU_CTP).clicked() {
447            shared.set_ctp_window_open(true);
448        }
449        if ui.button(MENU_CONTRACTS).clicked() {
450            shared.set_contracts_window_open(true);
451        }
452        ui.menu_button(MENU_HELP, |ui| {
453            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
454            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
455        });
456        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
457        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
458        if ui
459            .button(format!("主题:{}", shared.theme().label()))
460            .clicked()
461        {
462            let t = shared.cycle_theme();
463            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
464        }
465    });
466}
467
468// ================= 行情带 =================
469
470fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
471    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
472    let groups = shared.tick_groups();
473    if groups.is_empty() {
474        ui.label("(等待 eTick 订阅)");
475        return;
476    }
477    // 二级 tab:只列有 tick 的所
478    let sel = shared.selected_exchange();
479    let active = if groups.iter().any(|(e, _)| *e == sel) {
480        sel.clone()
481    } else {
482        groups[0].0.clone()
483    };
484    ui.horizontal(|ui| {
485        for (ex, _) in &groups {
486            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
487                shared.set_selected_exchange(ex.clone());
488            }
489        }
490    });
491    let ticks = groups
492        .iter()
493        .find(|(e, _)| *e == active)
494        .map(|(_, v)| v.clone())
495        .unwrap_or_default();
496    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
497    // 不需要,行情带 tab + 表格即全部)。
498    pinned_table(
499        ui,
500        "tick_grid",
501        14,
502        &[
503            "代码",
504            "名称",
505            "最新价",
506            "涨跌幅",
507            "成交量",
508            "今开",
509            "最高",
510            "最低",
511            "买1价",
512            "买1量",
513            "卖1价",
514            "卖1量",
515            "时间",
516            "接口",
517        ],
518        |ui| {
519            for t in &ticks {
520                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
521                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
522                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
523                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
524                let row_sel = shared.order_form().symbol == t.symbol;
525                let mut cells = Vec::with_capacity(14);
526                let mut dbl = false;
527                let resp = ui.button(t.symbol.clone());
528                if resp.clicked() {
529                    shared.set_selected_exchange(active.clone());
530                    import_tick(shared, t, &active, 0);
531                }
532                dbl |= resp.double_clicked();
533                cells.push(resp.rect);
534                macro_rules! cell {
535                    ($base:expr, $col:expr) => {{
536                        let base = $base;
537                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
538                        if r.clicked() {
539                            shared.set_selected_exchange(active.clone());
540                            import_tick(shared, t, &active, $col);
541                        }
542                        dbl |= r.double_clicked();
543                        cells.push(base.rect);
544                    }};
545                }
546                cell!(
547                    ui.label(if t.name.is_empty() {
548                        "-".to_string()
549                    } else {
550                        t.name.clone()
551                    }),
552                    1
553                );
554                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
555                cell!(
556                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
557                        if t.pct_change < 0.0 {
558                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
559                        } else {
560                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
561                        },
562                    )),
563                    3
564                );
565                cell!(ui.label(format!("{:.0}", t.volume)), 4);
566                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
567                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
568                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
569                cell!(
570                    ui.label(
571                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
572                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
573                    ),
574                    8
575                );
576                cell!(
577                    ui.label(
578                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
579                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
580                    ),
581                    9
582                );
583                cell!(
584                    ui.label(
585                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
586                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
587                    ),
588                    10
589                );
590                cell!(
591                    ui.label(
592                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
593                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
594                    ),
595                    11
596                );
597                cell!(
598                    ui.label(if t.update_time.is_empty() {
599                        fmt_dt(Some(t.datetime))
600                    } else {
601                        t.update_time.clone()
602                    }),
603                    12
604                );
605                cell!(ui.label(t.gateway_name.clone()), 13);
606                if dbl {
607                    shared.open_kline(t.symbol.clone());
608                }
609                paint_row_bg(ui, &cells, row_sel);
610                ui.end_row();
611            }
612        },
613    );
614}
615
616// ================= 下单面板(常驻) =================
617
618fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
619    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
620    let mut f = shared.order_form();
621    let before = f.clone();
622    let mut err: Option<String> = None;
623
624    // 随价勾选:价格框随该合约最新 tick(勾选态)
625    if f.follow_price {
626        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
627            if t.last_price > 0.0 {
628                f.price = format!("{:.1}", t.last_price);
629            }
630        }
631    }
632
633    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
634    let mut ex_opts: Vec<String> = shared
635        .tick_groups()
636        .iter()
637        .map(|(e, _)| e.clone())
638        .collect();
639    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
640        if !ex_opts.iter().any(|x| x == e) {
641            ex_opts.push(e.to_string());
642        }
643    }
644    if !ex_opts.contains(&f.exchange) {
645        ex_opts.insert(0, f.exchange.clone());
646    }
647    // 接口下拉:trade_snap 网关去重集 ∪ {ctp}(审计钉:不新增总线口;
648    // 0.2.10 BD-UI-38:sim 纸面网关退场)
649    let ts = shared.trade_snap();
650    let mut gw_opts: Vec<String> = Vec::new();
651    for g in ts
652        .orders
653        .iter()
654        .map(|o| o.gateway_name.clone())
655        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
656        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
657    {
658        if !g.is_empty() && !gw_opts.contains(&g) {
659            gw_opts.push(g);
660        }
661    }
662    for g in ["ctp"] {
663        if !gw_opts.iter().any(|x| x == g) {
664            gw_opts.push(g.to_string());
665        }
666    }
667    if !gw_opts.contains(&f.gateway) {
668        gw_opts.insert(0, f.gateway.clone());
669    }
670
671    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
672    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
673    egui::Grid::new("order_form_grid")
674        .num_columns(2)
675        .show(ui, |ui| {
676            ui.label("交易所");
677            egui::ComboBox::from_id_salt("of_exchange")
678                .selected_text(f.exchange.clone())
679                .show_ui(ui, |ui| {
680                    for e in &ex_opts {
681                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
682                    }
683                });
684            ui.end_row();
685            ui.label("代码");
686            let sym_resp = ui.add(
687                egui::TextEdit::singleline(&mut f.symbol)
688                    .hint_text("cu2709")
689                    .desired_width(150.0),
690            );
691            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
692            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
693            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
694            // has_focus 口径回车订阅从不触发)。
695            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
696                let s = f.symbol.trim().to_string();
697                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
698                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
699                let bare = match s.split_once('.') {
700                    Some((_, b)) => b.trim().to_string(),
701                    None => s,
702                };
703                if !bare.is_empty() {
704                    f.symbol = bare.clone();
705                    shared.push_cmd(UiCmd::CtpSubscribe {
706                        symbols: vec![bare.clone()],
707                    });
708                    shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
709                }
710            }
711            ui.end_row();
712            ui.label("名称");
713            ui.label(
714                tick_of(shared, &f.exchange, &f.symbol)
715                    .map(|t| {
716                        if t.name.is_empty() {
717                            "-".to_string()
718                        } else {
719                            t.name
720                        }
721                    })
722                    .unwrap_or_else(|| "-".to_string()),
723            );
724            ui.end_row();
725            ui.label("方向");
726            egui::ComboBox::from_id_salt("of_direction")
727                .selected_text(f.direction.as_str())
728                .show_ui(ui, |ui| {
729                    for d in [Direction::LONG, Direction::SHORT] {
730                        ui.selectable_value(&mut f.direction, d, d.as_str());
731                    }
732                });
733            ui.end_row();
734            ui.label("开平");
735            ui.horizontal(|ui| {
736                egui::ComboBox::from_id_salt("of_offset")
737                    .selected_text(f.offset.as_str())
738                    .show_ui(ui, |ui| {
739                        for o in [
740                            Offset::OPEN,
741                            Offset::CLOSE,
742                            Offset::CLOSETODAY,
743                            Offset::CLOSEYESTERDAY,
744                        ] {
745                            ui.selectable_value(&mut f.offset, o, o.as_str());
746                        }
747                    });
748                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
749                ui.checkbox(&mut f.auto_split, "自动开平");
750            });
751            ui.end_row();
752            ui.label("类型");
753            egui::ComboBox::from_id_salt("of_order_type")
754                .selected_text(f.order_type.as_str())
755                .show_ui(ui, |ui| {
756                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
757                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
758                    }
759                });
760            ui.end_row();
761            ui.label("价格");
762            ui.horizontal(|ui| {
763                ui.add(
764                    egui::TextEdit::singleline(&mut f.price)
765                        .hint_text("107660")
766                        .desired_width(90.0),
767                );
768                ui.checkbox(&mut f.follow_price, "随价");
769            });
770            ui.end_row();
771            ui.label("数量");
772            ui.add(
773                egui::TextEdit::singleline(&mut f.volume)
774                    .hint_text("1")
775                    .desired_width(90.0),
776            );
777            ui.end_row();
778            ui.label("接口");
779            egui::ComboBox::from_id_salt("of_gateway")
780                .selected_text(if f.gateway.is_empty() {
781                    "ctp".to_string()
782                } else {
783                    f.gateway.clone()
784                })
785                .show_ui(ui, |ui| {
786                    for g in &gw_opts {
787                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
788                    }
789                });
790            ui.end_row();
791            ui.label("操作");
792            ui.horizontal(|ui| {
793                if ui.button(BTN_SEND_ORDER).clicked() {
794                    match f.to_request() {
795                        Ok(req) => match crate::state::parse_gateway(&f.gateway) {
796                            Ok(gateway) => {
797                                shared.push_cmd(UiCmd::SendOrder {
798                                    req,
799                                    gateway,
800                                    mode: if f.auto_split {
801                                        ek_oms::LockMode::Auto
802                                    } else {
803                                        ek_oms::LockMode::None
804                                    },
805                                });
806                                shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
807                            }
808                            Err(e) => {
809                                shared.log_line(format!("[下单] {e}"));
810                                err = Some(e);
811                            }
812                        },
813                        Err(e) => {
814                            shared.log_line(format!("[下单] {e}"));
815                            err = Some(e);
816                        }
817                    }
818                }
819                if ui.button(BTN_CANCEL_ALL).clicked() {
820                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
821                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
822                    let mut n = 0usize;
823                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
824                        let gw = if o.gateway_name.is_empty() {
825                            "ctp".to_string()
826                        } else {
827                            o.gateway_name.clone()
828                        };
829                        shared.push_cmd(UiCmd::CancelOrder {
830                            orderid: o.orderid.clone(),
831                            gateway: gw,
832                        });
833                        n += 1;
834                    }
835                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
836                }
837            });
838            ui.end_row();
839            ui.label("代码回车 = 订阅");
840            ui.label("");
841            ui.end_row();
842        });
843    if let Some(e) = err {
844        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
845    }
846    if f != before {
847        shared.set_order_form(f.clone());
848    }
849}
850
851/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
852/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
853/// 行情带跳色行指认,盘口帧不重复报幕)。
854fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
855    let f = shared.order_form();
856    match tick_of(shared, &f.exchange, &f.symbol) {
857        Some(t) => {
858            for i in (1..=5).rev() {
859                let (p, v) = match i {
860                    1 => (t.ask_price_1, t.ask_volume_1),
861                    2 => (t.ask_price_2, t.ask_volume_2),
862                    3 => (t.ask_price_3, t.ask_volume_3),
863                    4 => (t.ask_price_4, t.ask_volume_4),
864                    _ => (t.ask_price_5, t.ask_volume_5),
865                };
866                ui.label(
867                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
868                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
869                );
870            }
871            for i in 1..=5 {
872                let (p, v) = match i {
873                    1 => (t.bid_price_1, t.bid_volume_1),
874                    2 => (t.bid_price_2, t.bid_volume_2),
875                    3 => (t.bid_price_3, t.bid_volume_3),
876                    4 => (t.bid_price_4, t.bid_volume_4),
877                    _ => (t.bid_price_5, t.bid_volume_5),
878                };
879                ui.label(
880                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
881                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
882                );
883            }
884        }
885        None => {
886            ui.label("(无 tick)");
887        }
888    }
889}
890
891// ================= 2×2 监控格 =================
892
893fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
894    let tab = shared.topleft_tab();
895    ui.horizontal(|ui| {
896        if ui
897            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
898            .clicked()
899        {
900            shared.set_topleft_tab(TopLeftTab::Orders);
901        }
902        if ui
903            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
904            .clicked()
905        {
906            shared.set_topleft_tab(TopLeftTab::Active);
907        }
908    });
909    render_orders_table(ui, shared, tab == TopLeftTab::Active);
910}
911
912/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
913/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
914/// 活动行撤单钮 + 双击撤单,终态行只读)。
915fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
916    let ts = shared.trade_snap();
917    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
918    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
919    let rows: Vec<_> = ts
920        .orders
921        .iter()
922        .filter(|o| !active_only || order_active(o.status))
923        .collect();
924    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
925    const ORDER_HEADERS: [&str; 17] = [
926        "代码",
927        "名称",
928        "交易所",
929        "类型",
930        "方向",
931        "开平",
932        "价格",
933        "总量",
934        "已成",
935        "成交均价",
936        "状态",
937        "状态描述",
938        "时间",
939        "委托号",
940        "来源",
941        "接口",
942        "操作",
943    ];
944    if rows.is_empty() {
945        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
946        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
947        pinned_table(
948            ui,
949            if active_only {
950                "active_grid"
951            } else {
952                "order_grid"
953            },
954            17,
955            &ORDER_HEADERS,
956            |ui| {
957                ui.label(if active_only {
958                    "(无未成交)"
959                } else {
960                    "(无委托:等网关 send_order)"
961                });
962            },
963        );
964        return;
965    }
966    pinned_table(
967        ui,
968        if active_only {
969            "active_grid"
970        } else {
971            "order_grid"
972        },
973        17,
974        &ORDER_HEADERS,
975        |ui| {
976            for o in &rows {
977                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
978                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
979                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
980                let key = format!("{}.{}", o.gateway_name, o.orderid);
981                let row_sel = shared.row_selected("orders", &key);
982                let mut cells = Vec::with_capacity(17);
983                let mut dbl = false;
984                let sel = ui.selectable_label(row_sel, o.symbol.clone());
985                if sel.clicked() {
986                    shared.select_row("orders", &key);
987                }
988                dbl |= sel.double_clicked();
989                cells.push(sel.rect);
990                macro_rules! cell {
991                    ($base:expr, $col:expr) => {{
992                        let base = $base;
993                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
994                        if r.clicked() {
995                            shared.select_row("orders", &key);
996                        }
997                        dbl |= r.double_clicked();
998                        cells.push(base.rect);
999                    }};
1000                }
1001                cell!(
1002                    ui.label(if o.name.is_empty() {
1003                        "-".to_string()
1004                    } else {
1005                        o.name.clone()
1006                    }),
1007                    1
1008                );
1009                cell!(ui.label(o.exchange.as_str().to_string()), 2);
1010                cell!(ui.label(o.order_type.as_str().to_string()), 3);
1011                cell!(
1012                    ui.label(
1013                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1014                            if o.direction == Some(Direction::SHORT) {
1015                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1016                            } else {
1017                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1018                            },
1019                        ),
1020                    ),
1021                    4
1022                );
1023                cell!(ui.label(o.offset.as_str().to_string()), 5);
1024                cell!(ui.label(format!("{:.1}", o.price)), 6);
1025                cell!(ui.label(format!("{:.0}", o.volume)), 7);
1026                cell!(ui.label(format!("{:.0}", o.traded)), 8);
1027                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1028                cell!(
1029                    ui.label(egui::RichText::new(o.status.as_str()).color(
1030                        if o.status == ek_data::constant::Status::REJECTED {
1031                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1032                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1033                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1034                        } else {
1035                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1036                        },
1037                    )),
1038                    10
1039                );
1040                cell!(ui.label(o.status_msg.clone()), 11);
1041                cell!(ui.label(fmt_dt(o.datetime)), 12);
1042                cell!(ui.label(o.orderid.clone()), 13);
1043                cell!(
1044                    ui.label(if o.reference.is_empty() {
1045                        "-".to_string()
1046                    } else {
1047                        o.reference.clone()
1048                    }),
1049                    14
1050                );
1051                cell!(ui.label(o.gateway_name.clone()), 15);
1052                if dbl && order_active(o.status) {
1053                    let gw = if o.gateway_name.is_empty() {
1054                        "ctp".to_string()
1055                    } else {
1056                        o.gateway_name.clone()
1057                    };
1058                    shared.push_cmd(UiCmd::CancelOrder {
1059                        orderid: o.orderid.clone(),
1060                        gateway: gw,
1061                    });
1062                }
1063                let hr = ui.horizontal(|ui| {
1064                    if order_active(o.status)
1065                        && ui
1066                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1067                            .clicked()
1068                    {
1069                        // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 ctp)
1070                        let gw = if o.gateway_name.is_empty() {
1071                            "ctp".to_string()
1072                        } else {
1073                            o.gateway_name.clone()
1074                        };
1075                        shared.push_cmd(UiCmd::CancelOrder {
1076                            orderid: o.orderid.clone(),
1077                            gateway: gw,
1078                        });
1079                    }
1080                });
1081                cells.push(hr.response.rect);
1082                paint_row_bg(ui, &cells, row_sel);
1083                ui.end_row();
1084            }
1085        },
1086    );
1087}
1088
1089/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1090fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1091    ui.label("成交");
1092    let ts = shared.trade_snap();
1093    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1094    const TRADE_HEADERS: [&str; 12] = [
1095        "成交号",
1096        "代码",
1097        "名称",
1098        "交易所",
1099        "方向",
1100        "开平",
1101        "价格",
1102        "数量",
1103        "成交额",
1104        "时间",
1105        "委托号",
1106        "接口",
1107    ];
1108    if ts.trades.is_empty() {
1109        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1110        pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1111            ui.label("(无成交)");
1112        });
1113        return;
1114    }
1115    pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1116        for t in &ts.trades {
1117            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1118            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1119            let key = format!("{}.{}", t.gateway_name, t.tradeid);
1120            let row_sel = shared.row_selected("trades", &key);
1121            let mut cells = Vec::with_capacity(12);
1122            let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1123            if sel.clicked() {
1124                shared.select_row("trades", &key);
1125            }
1126            cells.push(sel.rect);
1127            macro_rules! cell {
1128                ($base:expr, $col:expr) => {{
1129                    let base = $base;
1130                    let r = click_cell(ui, base.rect, ("trd", &key, $col));
1131                    if r.clicked() {
1132                        shared.select_row("trades", &key);
1133                    }
1134                    cells.push(base.rect);
1135                }};
1136            }
1137            cell!(ui.label(t.symbol.clone()), 1);
1138            cell!(
1139                ui.label(if t.name.is_empty() {
1140                    "-".to_string()
1141                } else {
1142                    t.name.clone()
1143                }),
1144                2
1145            );
1146            cell!(ui.label(t.exchange.as_str().to_string()), 3);
1147            cell!(
1148                ui.label(
1149                    egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1150                        if t.direction == Some(Direction::SHORT) {
1151                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1152                        } else {
1153                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1154                        },
1155                    ),
1156                ),
1157                4
1158            );
1159            cell!(ui.label(t.offset.as_str().to_string()), 5);
1160            cell!(ui.label(format!("{:.1}", t.price)), 6);
1161            cell!(ui.label(format!("{:.0}", t.volume)), 7);
1162            cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1163            cell!(ui.label(fmt_dt(t.datetime)), 9);
1164            cell!(ui.label(t.orderid.clone()), 10);
1165            cell!(ui.label(t.gateway_name.clone()), 11);
1166            paint_row_bg(ui, &cells, row_sel);
1167            ui.end_row();
1168        }
1169    });
1170}
1171
1172/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1173/// Python mainwindow.py:276 等价,不开 K线)。
1174fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1175    ui.label("持仓");
1176    let ts = shared.trade_snap();
1177    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1178    const POS_HEADERS: [&str; 16] = [
1179        "代码",
1180        "名称",
1181        "交易所",
1182        "方向",
1183        "数量",
1184        "昨仓",
1185        "可用",
1186        "冻结",
1187        "在途",
1188        "市值",
1189        "成本",
1190        "均价",
1191        "最新价",
1192        "盈亏",
1193        "盈亏比",
1194        "接口",
1195    ];
1196    if ts.positions.is_empty() {
1197        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1198        pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1199            ui.label("(无持仓)");
1200        });
1201        return;
1202    }
1203    pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1204        for p in &ts.positions {
1205            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1206            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1207            // 不开 K线;方向随持仓方向,Python 同口径)。
1208            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1209            let row_sel = shared.row_selected("positions", &key);
1210            let mut cells = Vec::with_capacity(16);
1211            let mut dbl = false;
1212            let sel = ui.selectable_label(row_sel, p.symbol.clone());
1213            if sel.clicked() {
1214                shared.select_row("positions", &key);
1215            }
1216            dbl |= sel.double_clicked();
1217            cells.push(sel.rect);
1218            macro_rules! cell {
1219                ($base:expr, $col:expr) => {{
1220                    let base = $base;
1221                    let r = click_cell(ui, base.rect, ("pos", &key, $col));
1222                    if r.clicked() {
1223                        shared.select_row("positions", &key);
1224                    }
1225                    dbl |= r.double_clicked();
1226                    cells.push(base.rect);
1227                }};
1228            }
1229            cell!(
1230                ui.label(if p.name.is_empty() {
1231                    "-".to_string()
1232                } else {
1233                    p.name.clone()
1234                }),
1235                1
1236            );
1237            cell!(ui.label(p.exchange.as_str().to_string()), 2);
1238            cell!(
1239                ui.label(egui::RichText::new(p.direction.as_str()).color(
1240                    if p.direction == ek_data::constant::Direction::SHORT {
1241                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1242                    } else {
1243                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1244                    },
1245                )),
1246                3
1247            );
1248            cell!(ui.label(format!("{:.0}", p.volume)), 4);
1249            cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1250            cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1251            cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1252            cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1253            cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1254            cell!(ui.label(format!("{:.2}", p.price)), 10);
1255            cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1256            cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1257            cell!(
1258                ui.label(
1259                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1260                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1261                    } else {
1262                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1263                    }),
1264                ),
1265                13
1266            );
1267            cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1268            cell!(ui.label(p.gateway_name.clone()), 15);
1269            if dbl {
1270                let mut f = shared.order_form();
1271                f.symbol = p.symbol.clone();
1272                f.exchange = p.exchange.as_str().to_string();
1273                f.direction = p.direction;
1274                shared.set_order_form(f);
1275                shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1276            }
1277            paint_row_bg(ui, &cells, row_sel);
1278            ui.end_row();
1279        }
1280    });
1281}
1282
1283fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1284    let tab = shared.bottomright_tab();
1285    ui.horizontal(|ui| {
1286        if ui
1287            .selectable_label(
1288                tab == BottomRightTab::Account,
1289                BottomRightTab::Account.label(),
1290            )
1291            .clicked()
1292        {
1293            shared.set_bottomright_tab(BottomRightTab::Account);
1294        }
1295        if ui
1296            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1297            .clicked()
1298        {
1299            shared.set_bottomright_tab(BottomRightTab::Logs);
1300        }
1301    });
1302    match tab {
1303        BottomRightTab::Account => {
1304            let ts = shared.trade_snap();
1305            if ts.accounts.is_empty() {
1306                ui.label("(无资金:等网关 eAccount.)");
1307                return;
1308            }
1309            egui::Grid::new("acct_grid")
1310                .striped(true)
1311                .num_columns(5)
1312                .show(ui, |ui| {
1313                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1314                        ui.strong(h);
1315                    }
1316                    ui.end_row();
1317                    for a in &ts.accounts {
1318                        ui.label(a.accountid.clone());
1319                        ui.label(a.gateway_name.clone());
1320                        ui.label(format!("{:.2}", a.balance));
1321                        ui.label(format!("{:.2}", a.available()));
1322                        ui.label(format!("{:.2}", a.frozen));
1323                        ui.end_row();
1324                    }
1325                });
1326        }
1327        BottomRightTab::Logs => {
1328            render_logs(ui, shared);
1329        }
1330    }
1331}
1332
1333fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1334    ui.horizontal(|ui| {
1335        if ui.button(BTN_CLEAR_LOGS).clicked() {
1336            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1337        }
1338        let (_, spilled) = shared.logs_snapshot();
1339        if spilled > 0 {
1340            ui.label(format!(
1341                "已丢弃 {spilled} 条(环形 {})",
1342                crate::state::RING_CAP
1343            ));
1344        }
1345    });
1346    let lines = shared.logs_snapshot().0;
1347    egui::ScrollArea::vertical()
1348        .id_salt("log_scroll")
1349        .max_height(180.0)
1350        .stick_to_bottom(true)
1351        .show(ui, |ui| {
1352            for line in lines {
1353                ui.monospace(line);
1354            }
1355        });
1356}
1357
1358// ================= 独立窗面(BD-UI-32/33)=================
1359
1360/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1361struct WinGeom {
1362    inner: [f32; 2],
1363    min_inner: [f32; 2],
1364    maximized: bool,
1365}
1366
1367/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1368///
1369/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
1370///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
1371///   context.rs「never used this pass」)。
1372/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
1373///   回退面关闭 = embedded 闭包返回 true。
1374/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
1375/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
1376///   Embedded 回退面无此概念 = 忽略)。
1377/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
1378///   键事件按子窗焦点路由,主窗按键不会误关)。
1379///
1380/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
1381fn show_independent_window(
1382    ctx: &egui::Context,
1383    id_salt: impl std::hash::Hash,
1384    title: &str,
1385    geom: &WinGeom,
1386    embedded: impl Fn(&egui::Context) -> bool,
1387    body: impl Fn(&mut egui::Ui) -> bool,
1388) -> bool {
1389    let closed = std::cell::Cell::new(false);
1390    let mut builder = egui::ViewportBuilder::default()
1391        .with_title(title)
1392        .with_inner_size(geom.inner)
1393        .with_min_inner_size(geom.min_inner)
1394        .with_minimize_button(true)
1395        .with_maximize_button(true)
1396        .with_close_button(true);
1397    if geom.maximized {
1398        builder = builder.with_maximized(true);
1399    }
1400    ctx.show_viewport_immediate(
1401        egui::ViewportId::from_hash_of(id_salt),
1402        builder,
1403        |ctx2, class| {
1404            if class == egui::ViewportClass::Embedded {
1405                closed.set(embedded(ctx2));
1406                return;
1407            }
1408            if ctx2.input(|i| i.viewport().close_requested()) {
1409                closed.set(true);
1410                return;
1411            }
1412            egui::CentralPanel::default().show(ctx2, |ui| {
1413                if body(ui) {
1414                    closed.set(true);
1415                }
1416            });
1417        },
1418    );
1419    closed.get()
1420}
1421
1422fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1423    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
1424    if shared.ops_window_open() {
1425        let closed = show_independent_window(
1426            ctx,
1427            "ops_win",
1428            "运维窗",
1429            &WinGeom {
1430                inner: [680.0, 420.0],
1431                min_inner: [420.0, 260.0],
1432                maximized: false,
1433            },
1434            |ctx2| {
1435                let mut open = shared.ops_window_open();
1436                egui::Window::new("运维窗")
1437                    .id(egui::Id::new("ops_win"))
1438                    .default_pos([80.0, 320.0])
1439                    .default_size([640.0, 380.0])
1440                    .open(&mut open)
1441                    .show(ctx2, |ui| {
1442                        render_ops_window(ui, shared);
1443                    });
1444                shared.set_ops_window_open(open);
1445                !open
1446            },
1447            |ui| {
1448                render_ops_window(ui, shared);
1449                false
1450            },
1451        );
1452        if closed {
1453            shared.set_ops_window_open(false);
1454        }
1455    }
1456
1457    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面;BD-UI-33 转独立 OS 窗)。
1458    if shared.ctp_window_open() {
1459        let closed = show_independent_window(
1460            ctx,
1461            "ctp_win",
1462            "CTP 网关配置窗",
1463            &WinGeom {
1464                inner: [600.0, 380.0],
1465                min_inner: [420.0, 260.0],
1466                maximized: false,
1467            },
1468            |ctx2| {
1469                let mut open = shared.ctp_window_open();
1470                egui::Window::new("CTP 网关配置窗")
1471                    .id(egui::Id::new("ctp_win"))
1472                    .default_pos([640.0, 320.0])
1473                    .default_size([560.0, 330.0])
1474                    .open(&mut open)
1475                    .show(ctx2, |ui| {
1476                        render_ctp_window(ui, shared);
1477                    });
1478                shared.set_ctp_window_open(open);
1479                !open
1480            },
1481            |ui| {
1482                render_ctp_window(ui, shared);
1483                false
1484            },
1485        );
1486        if closed {
1487            shared.set_ctp_window_open(false);
1488        }
1489    }
1490
1491    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
1492    if shared.contracts_window_open() {
1493        let closed = show_independent_window(
1494            ctx,
1495            "contracts_win",
1496            "查询合约窗",
1497            &WinGeom {
1498                inner: [620.0, 460.0],
1499                min_inner: [420.0, 260.0],
1500                maximized: false,
1501            },
1502            |ctx2| {
1503                let mut open = shared.contracts_window_open();
1504                egui::Window::new("查询合约窗")
1505                    .id(egui::Id::new("contracts_win"))
1506                    .default_pos([420.0, 300.0])
1507                    .default_size([560.0, 420.0])
1508                    .open(&mut open)
1509                    .show(ctx2, |ui| {
1510                        render_contracts_window(ui, shared);
1511                    });
1512                shared.set_contracts_window_open(open);
1513                !open
1514            },
1515            |ui| {
1516                render_contracts_window(ui, shared);
1517                false
1518            },
1519        );
1520        if closed {
1521            shared.set_contracts_window_open(false);
1522        }
1523    }
1524
1525    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
1526    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
1527    // 键按子窗焦点路由,主窗按键不误关)。
1528    let mut closed: Vec<String> = Vec::new();
1529    for sym in shared.kline_symbols() {
1530        let is_closed = show_independent_window(
1531            ctx,
1532            format!("kline_win:{sym}"),
1533            &format!("K线 — {sym}"),
1534            &WinGeom {
1535                inner: [560.0, 360.0],
1536                min_inner: [360.0, 220.0],
1537                maximized: true,
1538            },
1539            |ctx2| {
1540                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1541                let resp = egui::Window::new(format!("K线 — {sym}"))
1542                    .id(egui::Id::new(("kline_win", sym.clone())))
1543                    .default_pos([300.0, 340.0])
1544                    .default_size([540.0, 330.0])
1545                    .show(ctx2, |ui| {
1546                        let cell = shared
1547                            .market()
1548                            .cells()
1549                            .into_iter()
1550                            .find(|c| c.symbol == sym);
1551                        render_kline_cell(ui, cell.as_ref());
1552                    });
1553                esc || resp.is_none()
1554            },
1555            |ui| {
1556                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1557                    return true;
1558                }
1559                let cell = shared
1560                    .market()
1561                    .cells()
1562                    .into_iter()
1563                    .find(|c| c.symbol == sym);
1564                render_kline_cell(ui, cell.as_ref());
1565                false
1566            },
1567        );
1568        if is_closed {
1569            closed.push(sym);
1570        }
1571    }
1572    for s in closed {
1573        shared.close_kline(&s);
1574    }
1575}
1576
1577/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1578/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
1579fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1580    ui.label("插件");
1581    let snap = shared.snapshot();
1582    egui::Grid::new("plug_grid")
1583        .striped(true)
1584        .num_columns(6)
1585        .show(ui, |ui| {
1586            ui.strong("名称");
1587            ui.strong("状态");
1588            ui.strong("版本");
1589            ui.strong("依赖");
1590            ui.strong("统计");
1591            ui.strong("操作");
1592            ui.end_row();
1593            if snap.rows.is_empty() {
1594                ui.label("(快照空:等 eTimer 首拉)");
1595                ui.end_row();
1596            }
1597            for r in &snap.rows {
1598                if is_quarantined(&r.state) {
1599                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1600                    ui.label(
1601                        egui::RichText::new(state_text(r))
1602                            .color(egui::Color32::RED)
1603                            .strong(),
1604                    );
1605                } else {
1606                    ui.label(r.name.clone());
1607                    ui.label(state_text(r));
1608                }
1609                ui.label(r.version.clone());
1610                ui.label(format!("deps:[{}]", r.deps.join(",")));
1611                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1612                ui.horizontal(|ui| {
1613                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1614                        shared.push_cmd(UiCmd::Start {
1615                            factory: r.name.clone(),
1616                        });
1617                    }
1618                    let armed = shared.pending_confirm();
1619                    let armed_here = ui
1620                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1621                        .clicked()
1622                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1623                    if armed_here {
1624                        shared.push_cmd(UiCmd::Remove {
1625                            name: r.name.clone(),
1626                        });
1627                    }
1628                });
1629                ui.end_row();
1630            }
1631        });
1632
1633    if let Some(name) = shared.pending_confirm() {
1634        ui.horizontal(|ui| {
1635            ui.label(
1636                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1637                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1638            );
1639            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1640                shared.confirm_cancel();
1641            }
1642        });
1643    }
1644
1645    ui.label("服务");
1646    let flat: Vec<String> = snap
1647        .services
1648        .iter()
1649        .flat_map(|(n, ids)| {
1650            if ids.is_empty() {
1651                vec![format!("{n} 服务"), "-".to_string()]
1652            } else {
1653                ids.iter()
1654                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1655                    .collect()
1656            }
1657        })
1658        .collect();
1659    if flat.is_empty() {
1660        ui.label("(无服务目录)");
1661    } else {
1662        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1663        egui::Grid::new("svc_grid")
1664            .striped(true)
1665            .num_columns(6)
1666            .show(ui, |ui| {
1667                for r in 0..rows {
1668                    for c in 0..3 {
1669                        let i = c * rows * 2 + r * 2;
1670                        if i < flat.len() {
1671                            ui.label(flat[i].clone());
1672                            ui.label(flat[i + 1].clone());
1673                        } else {
1674                            ui.label("");
1675                            ui.label("");
1676                        }
1677                    }
1678                    ui.end_row();
1679                }
1680            });
1681    }
1682
1683    ui.label("依赖");
1684    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1685        ui.label("(全部无依赖)");
1686    }
1687    for r in &snap.rows {
1688        if !r.deps.is_empty() {
1689            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1690        }
1691    }
1692}
1693
1694/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1695/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1696/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1697fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1698    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1699    let mut form: crate::state::CtpForm = shared.ctp_form();
1700    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1701        ui.label("broker");
1702        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1703        ui.label("td 前置");
1704        ctp_edit(
1705            ui,
1706            shared,
1707            &mut form.td_addr,
1708            "tcp://host:40001",
1709            CtpField::TdAddr,
1710        );
1711        ui.label("td 用户");
1712        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1713        ui.end_row();
1714        ui.label("td 密码");
1715        ctp_edit(
1716            ui,
1717            shared,
1718            &mut form.td_password,
1719            "密码",
1720            CtpField::TdPassword,
1721        );
1722        ui.label("AppID");
1723        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1724        ui.label("AuthCode");
1725        ctp_edit(
1726            ui,
1727            shared,
1728            &mut form.td_auth_code,
1729            "授权码",
1730            CtpField::AuthCode,
1731        );
1732        ui.end_row();
1733        ui.label("md 前置");
1734        ctp_edit(
1735            ui,
1736            shared,
1737            &mut form.md_addr,
1738            "tcp://host:40011",
1739            CtpField::MdAddr,
1740        );
1741        ui.label("md 用户");
1742        ctp_edit(
1743            ui,
1744            shared,
1745            &mut form.md_user,
1746            "空=td 回落",
1747            CtpField::MdUser,
1748        );
1749        ui.label("md 密码");
1750        ctp_edit(
1751            ui,
1752            shared,
1753            &mut form.md_password,
1754            "空=td 回落",
1755            CtpField::MdPassword,
1756        );
1757        ui.end_row();
1758    });
1759    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
1760    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
1761    ui.horizontal(|ui| {
1762        ui.label("收录勾选:");
1763        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
1764        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
1765        if fut || opt {
1766            shared.set_ctp_form(form.clone());
1767        }
1768        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
1769    });
1770    ui.horizontal(|ui| {
1771        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1772            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1773            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1774        }
1775        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1776    });
1777}
1778
1779/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1780/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1781/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1782fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1783    let mut q = shared.contract_query();
1784    ui.horizontal(|ui| {
1785        ui.label("代码或交易所(留空 = 全部):");
1786        if ui
1787            .add(
1788                egui::TextEdit::singleline(&mut q)
1789                    .hint_text("cu 或 SHFE")
1790                    .desired_width(200.0),
1791            )
1792            .changed()
1793        {
1794            shared.set_contract_query(q.clone());
1795        }
1796    });
1797    let rows = shared.contracts(&q);
1798    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
1799    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
1800    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
1801    if rows.is_empty() {
1802        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
1803        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1804            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1805        });
1806        return;
1807    }
1808    let selected = shared.selected_contracts();
1809    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
1810    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
1811    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
1812    // 行溢出才出滚动条。
1813    ui.horizontal(|ui| {
1814        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1815        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1816            if selected.is_empty() {
1817                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1818            } else {
1819                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1820                let symbols: Vec<String> = selected
1821                    .iter()
1822                    .map(|k| {
1823                        k.split_once('.')
1824                            .map(|(_, s)| s.to_string())
1825                            .unwrap_or_else(|| k.clone())
1826                    })
1827                    .collect();
1828                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1829                shared.log_line(format!(
1830                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1831                    selected.len()
1832                ));
1833            }
1834        }
1835    });
1836    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1837        for c in &rows {
1838            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1839            let mut on = selected.contains(&key);
1840            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1841            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
1842            // 行选中(动作归行,不归某一列)。
1843            let row_sel = shared.row_selected("contracts", &key);
1844            let mut cells = Vec::with_capacity(5);
1845            let ck = ui.checkbox(&mut on, key.clone());
1846            if ck.changed() {
1847                shared.toggle_contract_selection(&key);
1848                shared.select_row("contracts", &key);
1849            }
1850            cells.push(ck.rect);
1851            macro_rules! cell {
1852                ($base:expr, $col:expr) => {{
1853                    let base = $base;
1854                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
1855                    if r.clicked() {
1856                        shared.select_row("contracts", &key);
1857                    }
1858                    cells.push(base.rect);
1859                }};
1860            }
1861            cell!(ui.label(c.exchange.as_str().to_string()), 1);
1862            cell!(ui.label(c.name.clone()), 2);
1863            cell!(ui.label(format!("{:.0}", c.size)), 3);
1864            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1865            paint_row_bg(ui, &cells, row_sel);
1866            ui.end_row();
1867        }
1868    });
1869}
1870
1871/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1872enum CtpField {
1873    Broker,
1874    TdAddr,
1875    TdUser,
1876    TdPassword,
1877    AppId,
1878    AuthCode,
1879    MdAddr,
1880    MdUser,
1881    MdPassword,
1882}
1883
1884/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1885fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1886    let is_secret = matches!(
1887        field,
1888        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1889    );
1890    let mut w = egui::TextEdit::singleline(val)
1891        .hint_text(hint)
1892        .desired_width(110.0);
1893    if is_secret {
1894        w = w.password(true);
1895    }
1896    if ui.add(w).changed() {
1897        let mut f = shared.ctp_form();
1898        match field {
1899            CtpField::Broker => f.broker_id = val.clone(),
1900            CtpField::TdAddr => f.td_addr = val.clone(),
1901            CtpField::TdUser => f.td_user = val.clone(),
1902            CtpField::TdPassword => f.td_password = val.clone(),
1903            CtpField::AppId => f.td_app_id = val.clone(),
1904            CtpField::AuthCode => f.td_auth_code = val.clone(),
1905            CtpField::MdAddr => f.md_addr = val.clone(),
1906            CtpField::MdUser => f.md_user = val.clone(),
1907            CtpField::MdPassword => f.md_password = val.clone(),
1908        }
1909        shared.set_ctp_form(f);
1910    }
1911}
1912
1913/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1914///
1915/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1916fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1917    let Some(c) = cell else {
1918        ui.label("(K线无格:等 dm 合成)");
1919        return;
1920    };
1921    if c.bars.is_empty() {
1922        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1923        return;
1924    }
1925    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
1926    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
1927    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
1928    let mut data = egui_charts::model::bar::BarData::new();
1929    for b in &c.bars {
1930        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
1931            .with_timezone(&beijing_tz())
1932            .with_timezone(&chrono::Utc);
1933        data.push(egui_charts::model::bar::Bar::new(
1934            dt,
1935            b.open_price,
1936            b.high_price,
1937            b.low_price,
1938            b.close_price,
1939            b.volume,
1940        ));
1941    }
1942    ui.label(format!(
1943        "K线 {}@{}s({} 根)",
1944        c.symbol,
1945        c.interval,
1946        c.bars.len()
1947    ));
1948    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1949    chart.set_symbol(&c.symbol);
1950    chart.set_timeframe_label(&format!("{}s", c.interval));
1951    chart.enable_tracking_mode();
1952    let _ = chart.show(ui);
1953}
1954
1955#[cfg(test)]
1956mod tests {
1957    use super::*;
1958
1959    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
1960    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
1961    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
1962    #[test]
1963    fn fmt_dt_shows_beijing_wall_clock() {
1964        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
1965            ek_data::time::civil_to_nanos(ek_data::time::Civil {
1966                year: 2026,
1967                month: 4,
1968                day: 16,
1969                hour: 1,
1970                minute: 30,
1971                second: 1,
1972                nanos: 0,
1973            }),
1974        );
1975        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
1976        let beijing = expect.with_timezone(&beijing_tz());
1977        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
1978        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
1979        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
1980        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
1981            year: 2026,
1982            month: 4,
1983            day: 15,
1984            hour: 16,
1985            minute: 0,
1986            second: 30,
1987            nanos: 0,
1988        });
1989        assert_eq!(fmt_dt(Some(ns)), "00:00:30");
1990        // None → 占位
1991        assert_eq!(fmt_dt(None), "-");
1992    }
1993}