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