Skip to main content

ek_ui/
render.rs

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