Skip to main content

ek_ui/
strategy_win.rs

1//! 策略交易模块窗(「一模块一菜单一窗」)。
2//!
3//! 形制(用户裁定 2026-11-01):
4//! - 主界面是**算法交易那种统一表格**:一行一个策略实例,公共列全在这里,
5//!   个性化部分(参数、变量、逐合约台账)进「详情」弹窗;
6//! - 添加 / 编辑 / 详情是**弹窗**,不占主窗面积;
7//! - 两表:「执行中」(Idle/Inited/Running/Paused)与「已结束」(Stopped/Finished/Failed)。
8//!   已结束表**只读** —— 重启走「移除后重新添加」,不让状态机出现 `Finished→Running`。
9//!
10//! 纪律(与 ek-ui 其它窗同):
11//! - 渲染线程**不 ask**(一发问就把界面卡在插件邮箱上);
12//! - 用 `push_cmd` 投命令,命令线程去问;
13//! - 数据来自 `UiShared` 里的事件缓存,不在这里查引擎。
14
15use crate::render::pinned_table_impl_cols;
16use crate::state::{UiCmd, UiShared};
17use ek_strategy::contract::{RowButtons, StratState, StrategyRow};
18
19/// 窗口内两张表。
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum Tab {
22    /// 执行中(Idle / Inited / Running / Paused)。
23    #[default]
24    Active,
25    /// 已结束(Stopped / Finished / Failed),只读。
26    Done,
27}
28
29impl Tab {
30    /// 标签文字。
31    #[must_use]
32    pub fn label(self) -> &'static str {
33        match self {
34            Self::Active => "执行中",
35            Self::Done => "已结束",
36        }
37    }
38
39    /// 该状态归哪张表。
40    #[must_use]
41    pub fn of(state: StratState) -> Self {
42        match state {
43            StratState::Idle | StratState::Inited | StratState::Running | StratState::Paused => {
44                Self::Active
45            }
46            StratState::Stopped | StratState::Finished | StratState::Failed => Self::Done,
47        }
48    }
49}
50
51/// 表头(顺序即列序;10 列,与总体方案 §2 L6 的 6.2 一致)。
52pub const HEADERS: [&str; 10] = [
53    "操作",
54    "状态",
55    "策略",
56    "合约",
57    "方向",
58    "持仓",
59    "目标",
60    "最新价",
61    "时间",
62    "备注",
63];
64
65/// 「操作」列:
66pub const COL_ACTIONS: usize = 0;
67
68/// 行内动作(点击只投命令,不碰引擎)。
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Act {
71    /// 初始化。
72    Init,
73    /// 启动(暂停后是「恢复」同一条命令)。
74    Start,
75    /// 暂停。
76    Pause,
77    /// 恢复。
78    Resume,
79    /// 停止。
80    Stop,
81    /// 编辑参数(弹窗)。
82    Edit,
83    /// 移除(二次确认)。
84    Remove,
85    /// 详情(弹窗)。
86    Detail,
87}
88
89/// 「添加策略」弹窗的表单态(关窗不销毁:未提交的输入留着)。
90#[derive(Debug, Clone, Default)]
91pub struct AddForm {
92    /// 选中的策略类名(`""` = 还没选)。
93    pub class_name: String,
94    /// 实例名(用户填)。
95    pub name: String,
96    /// 参数(键 → 编码串,与 `ParamSpec` 同一口径)。
97    pub params: std::collections::BTreeMap<String, String>,
98    /// 本地校验/引擎回执的错误(窗内红字,不关窗)。
99    pub error: Option<String>,
100    /// 已提交待回执的实例名(回执 ok 即关窗)。
101    pub submitted: Option<String>,
102}
103
104/// 「详情 / 编辑」弹窗的表单态。
105#[derive(Debug, Clone, Default)]
106pub struct DetailForm {
107    /// 目标实例名。
108    pub name: String,
109    /// 是否处于编辑态(只读 ↔ 可改)。
110    pub editing: bool,
111    /// 编辑态的草稿参数。
112    pub params: std::collections::BTreeMap<String, String>,
113    /// 错误(窗内红字)。
114    pub error: Option<String>,
115    /// 已提交待回执的编辑。
116    pub submitted: bool,
117}
118
119/// 策略窗的持久状态(关窗不销毁,编辑态与选中的 tab 都留在这里)。
120#[derive(Debug, Default)]
121pub struct StrategyWinState {
122    /// 当前 tab。
123    pub tab: Tab,
124    /// 选中的策略名(详情/编辑弹窗的目标)。
125    pub selected: Option<String>,
126    /// 「添加策略」弹窗是否打开(S7 用)。
127    pub add_open: bool,
128    /// 添加弹窗的表单态。
129    pub add_form: AddForm,
130    /// 详情弹窗(打开着 = `Some`)。
131    pub detail: Option<DetailForm>,
132    /// 待确认移除的策略名(行内 [移除] 二次确认)。
133    pub pending_remove: Option<String>,
134    /// 上一次错误提示(展示在工具条下沿;用户改动作即清)。
135    pub last_error: Option<String>,
136    /// 排序键 `(列序, 升序)`;`None` = 按实例名的稳定序。
137    ///
138    /// **只作用于渲染前的副本**(W2 纪律):行表是推送来的单源事实,不在显示层就地排序。
139    pub sort: Option<(usize, bool)>,
140    /// 「网关设置」弹窗是否打开。
141    pub engine_open: bool,
142    /// 交易网关草稿(空 = 自动解析)。
143    pub engine_trade: String,
144    /// 行情网关草稿(空 = 不指定)。
145    pub engine_md: String,
146    /// 网关设置的窗内红字。
147    pub engine_error: Option<String>,
148    /// 已提交、等回执。
149    pub engine_submitted: bool,
150}
151
152impl StrategyWinState {
153    /// 新建(默认「执行中」tab)。
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// 打开详情弹窗(`editing=true` 直接进编辑态)。
160    ///
161    /// 参数回显**只看行表**(事件已带全量)—— 打开弹窗不发任何 ask。
162    pub fn open_detail(&mut self, shared: &UiShared, name: &str, editing: bool) {
163        let params = shared
164            .strategy_row(name)
165            .map(|r| r.params.into_iter().collect())
166            .unwrap_or_default();
167        self.selected = Some(name.to_string());
168        self.last_error = None;
169        self.detail = Some(DetailForm {
170            name: name.to_string(),
171            editing,
172            params,
173            error: None,
174            submitted: false,
175        });
176    }
177
178    /// 吃一条动作(下一帧生效;`repaint` 由调用方置)。
179    fn on_act(&mut self, shared: &UiShared, name: &str, act: Act) {
180        self.last_error = None;
181        match act {
182            Act::Init => shared.push_cmd(UiCmd::StratInit {
183                name: name.to_string(),
184            }),
185            Act::Start => shared.push_cmd(UiCmd::StratStart {
186                name: name.to_string(),
187            }),
188            Act::Pause => shared.push_cmd(UiCmd::StratPause {
189                name: name.to_string(),
190            }),
191            Act::Resume => shared.push_cmd(UiCmd::StratResume {
192                name: name.to_string(),
193            }),
194            Act::Stop => shared.push_cmd(UiCmd::StratStop {
195                name: name.to_string(),
196            }),
197            Act::Edit => self.open_detail(shared, name, true),
198            Act::Detail => self.open_detail(shared, name, false),
199            Act::Remove => {
200                // 二次确认:第一次点只是置位,再点才真的发令。
201                if self.pending_remove.as_deref() == Some(name) {
202                    self.pending_remove = None;
203                    shared.push_cmd(UiCmd::StratRemove {
204                        name: name.to_string(),
205                    });
206                } else {
207                    self.pending_remove = Some(name.to_string());
208                }
209            }
210        }
211    }
212}
213
214/// 渲染整扇窗。返回 `true` = 需要下一帧重绘。
215pub fn render(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
216    let mut repaint = false;
217
218    // 失败提示:命令线程把失败也塞进回执队列(`op = "toast"`),这里取出来挂到工具条下沿。
219    // 不是 toast 的就放回队首,交给弹窗自己吃(顺序不能乱)。
220    loop {
221        let Some(fb) = shared.take_strategy_feedback() else {
222            break;
223        };
224        if fb.op == "toast" {
225            st.last_error = Some(fb.msg);
226            repaint = true;
227        } else {
228            shared.push_strategy_feedback_front(fb);
229            break;
230        }
231    }
232
233    // ---- 工具条 ----
234    ui.horizontal(|ui| {
235        if ui
236            .selectable_label(st.tab == Tab::Active, Tab::Active.label())
237            .clicked()
238        {
239            st.tab = Tab::Active;
240            repaint = true;
241        }
242        if ui
243            .selectable_label(st.tab == Tab::Done, Tab::Done.label())
244            .clicked()
245        {
246            st.tab = Tab::Done;
247            repaint = true;
248        }
249        ui.separator();
250        if ui.button("添加策略…").clicked() {
251            st.add_open = true;
252            repaint = true;
253        }
254        if ui
255            .button("网关…")
256            .on_hover_text("默认交易/行情网关;留空 = 按合约归属或唯一已连接自动解析")
257            .clicked()
258        {
259            st.engine_open = true;
260            st.engine_error = None;
261            st.engine_submitted = false;
262            repaint = true;
263        }
264        ui.separator();
265        if ui.button("全部启动").clicked() {
266            shared.push_cmd(UiCmd::StratStartAll);
267            repaint = true;
268        }
269        if ui.button("全部停止").clicked() {
270            shared.push_cmd(UiCmd::StratStopAll);
271            repaint = true;
272        }
273        if let Some(e) = &st.last_error {
274            ui.separator();
275            ui.colored_label(ui.visuals().error_fg_color, e);
276        }
277    });
278
279    ui.add_space(6.0);
280
281    // ---- 取行(副本;渲染层永不就地改事实源) ----
282    let mut rows: Vec<StrategyRow> = shared
283        .strategy_rows()
284        .into_iter()
285        .filter(|r| Tab::of(r.state) == st.tab)
286        .collect();
287    if let Some((col, asc)) = st.sort {
288        sort_rows(&mut rows, col, asc);
289    }
290
291    if rows.is_empty() {
292        ui.separator();
293        empty_state(ui, st, shared);
294        // 空表也要能弹「添加」窗(否则空态下点了钮没反应)
295        if render_dialogs(ui, st, shared) {
296            repaint = true;
297        }
298        return repaint;
299    }
300
301    // ---- 主表 ----
302    let selected = st.selected.clone();
303    let pending = st.pending_remove.clone();
304
305    // 首帧同一格会跑两遍(FnMut 双 pass),动作按 (名字, 动作) 去重后再发。
306    // 用 `RefCell` 而不是 `&mut Vec`:每格的闭包都要各自持有它。
307    let acts: std::cell::RefCell<Vec<(String, Act)>> = std::cell::RefCell::new(Vec::new());
308
309    let clicked = pinned_table_impl_cols(
310        ui,
311        "strategy_grid",
312        &HEADERS,
313        st.sort,
314        None,
315        |body, row_h| {
316            for r in &rows {
317                let is_sel = selected.as_deref() == Some(r.name.as_str());
318                body.row(row_h, is_sel, |pr| {
319                    // 0 操作
320                    pr.cell(|ui, _ctx| {
321                        ui.horizontal(|ui| {
322                            for (label, act, enabled) in row_buttons(r) {
323                                let text = if act == Act::Remove
324                                    && pending.as_deref() == Some(r.name.as_str())
325                                {
326                                    "确认移除?"
327                                } else {
328                                    label
329                                };
330                                if ui
331                                    .add_enabled(enabled, egui::Button::new(text).small())
332                                    .on_hover_text(hover_of(act))
333                                    .clicked()
334                                {
335                                    push_act(&acts, &r.name, act);
336                                }
337                            }
338                            if ui
339                                .add(egui::Button::new("详情").small())
340                                .on_hover_text("看参数 / 变量 / 逐合约台账")
341                                .clicked()
342                            {
343                                push_act(&acts, &r.name, Act::Detail);
344                            }
345                        });
346                    });
347                    // 1 状态
348                    pr.cell(|ui, _ctx| {
349                        ui.colored_label(state_color(ui.ctx(), r.state), r.state.label());
350                    });
351                    // 2 策略:实例名(行键)+ 类显示名 —— 同一个类可以有多行,只显示类名
352                    // 的话两行看起来一模一样,用户分不清谁是谁。
353                    pr.cell(|ui, _ctx| {
354                        ui.horizontal(|ui| {
355                            ui.label(egui::RichText::new(&r.name).strong());
356                            let weak = crate::tokens::muted(ui.ctx());
357                            ui.label(egui::RichText::new(display_of(r)).small().color(weak));
358                        });
359                    });
360                    // 3 合约(多合约摘要)
361                    pr.cell(|ui, _ctx| {
362                        ui.label(symbol_summary(r));
363                    });
364                    // 4 方向
365                    pr.cell(|ui, _ctx| match r.direction {
366                        Some(ek_strategy::contract::Direction::Long) => {
367                            ui.colored_label(crate::tokens::rise(ui.ctx()), "多");
368                        }
369                        Some(ek_strategy::contract::Direction::Short) => {
370                            ui.colored_label(crate::tokens::fall(ui.ctx()), "空");
371                        }
372                        None => {
373                            ui.weak("—");
374                        }
375                    });
376                    // 5 持仓
377                    pr.cell(|ui, _ctx| {
378                        ui.label(crate::tokens::num_text(&r.pos_summary));
379                    });
380                    // 6 目标
381                    pr.cell(|ui, _ctx| {
382                        ui.label(crate::tokens::num_text(non_empty(&r.target_summary)));
383                    });
384                    // 7 最新价
385                    pr.cell(|ui, _ctx| {
386                        ui.label(crate::tokens::num_text(price_text(r.last_price)));
387                    });
388                    // 8 时间
389                    pr.cell(|ui, _ctx| {
390                        ui.label(crate::tokens::num_text(non_empty(&r.last_dt)));
391                    });
392                    // 9 备注
393                    pr.cell(|ui, _ctx| {
394                        ui.label(non_empty(&r.note));
395                    });
396                });
397            }
398        },
399    );
400
401    // 行内动作(收集完统一发,避免在格闭包里动 st)
402    for (name, act) in acts.into_inner() {
403        st.on_act(shared, &name, act);
404        repaint = true;
405    }
406
407    // 表头点击 = 换排序(只在显示层生效;「操作」列不参与)
408    if let Some(col) = clicked
409        && col != COL_ACTIONS
410    {
411        st.sort = match st.sort {
412            Some((c, asc)) if c == col => Some((col, !asc)),
413            _ => Some((col, true)),
414        };
415        repaint = true;
416    }
417
418    if render_dialogs(ui, st, shared) {
419        repaint = true;
420    }
421
422    repaint
423}
424
425// ============================ 弹窗 ============================
426
427/// 画两个弹窗(添加 / 详情),返回是否有状态变化(要重绘)。
428///
429/// 纪律:这里**一次 ask 都不发**。弹窗要的一切都在 `UiShared`
430/// (类元数据、行表、参数回显)或命令线程回带的回执里。
431pub fn render_dialogs(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
432    let mut repaint = false;
433    if render_add_dialog(ui, st, shared) {
434        repaint = true;
435    }
436    if render_detail_dialog(ui, st, shared) {
437        repaint = true;
438    }
439    if render_engine_dialog(ui, st, shared) {
440        repaint = true;
441    }
442    repaint
443}
444
445/// 「网关设置」弹窗(方案 §5.4 的 `engine.trade_gateway` / `engine.md_gateway`)。
446///
447/// 为什么必须有:多账户时"取第一个"就是**赌单**。引擎的解析顺序是
448/// 「显式值 → 合约归属 → 唯一已连接 → 报错」,两个都连着且合约查不到时它就红灯 ——
449/// 用户得有地方把这个值填进去,而不是被逼着只能单账户。
450///
451/// 留空 = 交回自动解析(这是**推荐**默认:合约归属比人工填更准)。
452fn render_engine_dialog(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
453    let mut changed = false;
454    // 先吃回执(不是给这扇窗的放回去)
455    if let Some(fb) = shared.take_strategy_feedback() {
456        if fb.op == "engine" {
457            st.engine_submitted = false;
458            if fb.ok {
459                st.engine_open = false;
460                st.engine_error = None;
461            } else {
462                st.engine_error = Some(fb.msg);
463            }
464            changed = true;
465        } else {
466            shared.push_strategy_feedback_front(fb);
467        }
468    }
469    if !st.engine_open {
470        return changed;
471    }
472
473    let mut keep_open = true;
474    let mut close_req = false;
475    egui::Window::new("网关设置")
476        .id(egui::Id::new("strategy_engine"))
477        .open(&mut keep_open)
478        .resizable(false)
479        .default_width(420.0)
480        .collapsible(false)
481        .show(ui.ctx(), |ui| {
482            ui.label("默认交易网关");
483            if ui
484                .add(
485                    egui::TextEdit::singleline(&mut st.engine_trade)
486                        .hint_text("留空 = 按合约归属 / 唯一已连接自动解析"),
487                )
488                .changed()
489            {
490                changed = true;
491            }
492            ui.add_space(4.0);
493            ui.label("行情源网关");
494            if ui
495                .add(
496                    egui::TextEdit::singleline(&mut st.engine_md)
497                        .hint_text("留空 = 不指定(数据源按默认)"),
498                )
499                .changed()
500            {
501                changed = true;
502            }
503            ui.add_space(4.0);
504            ui.label(
505                egui::RichText::new(
506                    "提示:多账户同时在线时,留空会让下单依赖「合约归属」;\
507                     合约也查不到就会直接报错(不猜)。",
508                )
509                .small()
510                .weak(),
511            );
512            if let Some(e) = &st.engine_error {
513                ui.colored_label(ui.visuals().error_fg_color, e);
514            }
515            ui.add_space(6.0);
516            ui.horizontal(|ui| {
517                let can = !st.engine_submitted;
518                if ui.add_enabled(can, egui::Button::new("保存")).clicked() {
519                    st.engine_submitted = true;
520                    st.engine_error = None;
521                    shared.push_cmd(UiCmd::StratSetEngine {
522                        trade_gateway: st.engine_trade.trim().to_string(),
523                        md_gateway: st.engine_md.trim().to_string(),
524                    });
525                    changed = true;
526                }
527                if ui.button("取消").clicked() {
528                    close_req = true;
529                }
530            });
531        });
532    if close_req || !keep_open {
533        st.engine_open = false;
534        st.engine_submitted = false;
535        st.engine_error = None;
536        changed = true;
537    }
538    changed
539}
540
541/// 「添加策略」弹窗。
542fn render_add_dialog(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
543    // 类元数据:缓存是主路;空了才让命令线程去补一次(渲染线程不发 ask)。
544    let metas = shared.strategy_metas();
545    if st.add_open && metas.is_empty() {
546        shared.push_cmd(UiCmd::StratMetaSync);
547    }
548    if !st.add_open {
549        return false;
550    }
551
552    let mut changed = false;
553    let mut open = st.add_open;
554
555    // 先把回执吃掉(add 的回执只对"正在提交"的那一单有效)
556    if let Some(fb) = shared.take_strategy_feedback() {
557        if fb.op == "add" {
558            if fb.ok {
559                // 成功:关窗 + 清表单(行由引擎的 Upsert 事件带上来)
560                st.add_open = false;
561                st.add_form = AddForm::default();
562                open = false;
563                changed = true;
564            } else if st.add_form.submitted.as_deref() == Some(fb.name.as_str()) {
565                st.add_form.error = Some(fb.msg);
566                st.add_form.submitted = None;
567                changed = true;
568            }
569        } else {
570            // 不是给我们这扇窗的:放回去(e.k. edit 的回执归详情窗)
571            shared.push_strategy_feedback_front(fb);
572        }
573    }
574
575    if !open {
576        return changed;
577    }
578
579    let mut keep_open = true;
580    // 窗内的「取消」不能直接动 `keep_open`(`.open()` 正借着它),先置请求位。
581    let mut close_req = false;
582    egui::Window::new("添加策略")
583        .id(egui::Id::new("strategy_add"))
584        .open(&mut keep_open)
585        .resizable(true)
586        .default_width(460.0)
587        .collapsible(false)
588        .show(ui.ctx(), |ui| {
589            // ---- 类型下拉 ----
590            ui.horizontal(|ui| {
591                ui.label("策略类型");
592                let cur = metas
593                    .iter()
594                    .find(|m| m.class_name == st.add_form.class_name)
595                    .map_or("(请选择)", |m| m.display_name);
596                egui::ComboBox::from_id_salt("strategy_add_class")
597                    .selected_text(cur)
598                    .show_ui(ui, |ui| {
599                        for m in &metas {
600                            if ui
601                                .selectable_label(
602                                    st.add_form.class_name == m.class_name,
603                                    format!("{}({})", m.display_name, m.class_name),
604                                )
605                                .clicked()
606                            {
607                                // 换类型 = 表单按新类的参数集重建(不留上一类的键)
608                                st.add_form.class_name = m.class_name.to_string();
609                                st.add_form.params = default_params(m);
610                                st.add_form.error = None;
611                                changed = true;
612                            }
613                        }
614                    });
615            });
616            if metas.is_empty() {
617                ui.colored_label(
618                    ui.visuals().warn_fg_color,
619                    "还没拿到策略类(等策略插件上线后自动出现)",
620                );
621            }
622
623            // ---- 实例名 ----
624            ui.horizontal(|ui| {
625                ui.label("实例名");
626                if ui
627                    .add(
628                        egui::TextEdit::singleline(&mut st.add_form.name)
629                            .hint_text("例如 cond_rb_1(字母开头,仅字母数字下划线)"),
630                    )
631                    .changed()
632                {
633                    changed = true;
634                }
635            });
636
637            // ---- 参数区 ----
638            if let Some(m) = metas
639                .iter()
640                .find(|m| m.class_name == st.add_form.class_name)
641            {
642                ui.separator();
643                ui.label(egui::RichText::new("参数").strong());
644                if params_form(ui, m, &mut st.add_form.params) {
645                    changed = true;
646                }
647            }
648
649            // ---- 错误 ----
650            if let Some(e) = &st.add_form.error {
651                ui.add_space(4.0);
652                ui.colored_label(ui.visuals().error_fg_color, e);
653            }
654
655            ui.add_space(6.0);
656            ui.horizontal(|ui| {
657                let ready = !st.add_form.class_name.is_empty()
658                    && !st.add_form.name.trim().is_empty()
659                    && st.add_form.submitted.is_none();
660                if ui.add_enabled(ready, egui::Button::new("添加")).clicked()
661                    && let Some(m) = metas
662                        .iter()
663                        .find(|m| m.class_name == st.add_form.class_name)
664                {
665                    match validate_form(m, &st.add_form.params) {
666                        Err(e) => {
667                            st.add_form.error = Some(e);
668                            changed = true;
669                        }
670                        Ok(params) => {
671                            st.add_form.error = None;
672                            let name = st.add_form.name.trim().to_string();
673                            st.add_form.submitted = Some(name.clone());
674                            shared.push_cmd(UiCmd::StratAdd {
675                                class_name: st.add_form.class_name.clone(),
676                                name,
677                                params,
678                            });
679                            changed = true;
680                        }
681                    }
682                }
683                if ui.button("取消").clicked() {
684                    close_req = true;
685                    changed = true;
686                }
687            });
688        });
689
690    if close_req {
691        keep_open = false;
692    }
693    if !keep_open && st.add_open {
694        st.add_open = false;
695        changed = true;
696    }
697    changed
698}
699
700/// 「详情 / 编辑」弹窗:参数 / 变量 / 逐合约三区(数据全部来自行表)。
701fn render_detail_dialog(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
702    let Some(form) = st.detail.as_ref().map(|d| d.name.clone()) else {
703        return false;
704    };
705    let Some(row) = shared.strategy_row(&form) else {
706        // 行没了(被移除 / 引擎重启):弹窗跟着关,不留一扇挂在空气上的窗。
707        st.detail = None;
708        return true;
709    };
710
711    let mut changed = false;
712    // 编辑回执
713    if let Some(fb) = shared.take_strategy_feedback() {
714        if fb.op == "edit" && fb.name == form {
715            if fb.ok {
716                if let Some(d) = st.detail.as_mut() {
717                    d.editing = false;
718                    d.submitted = false;
719                    d.error = None;
720                }
721                changed = true;
722            } else if let Some(d) = st.detail.as_mut() {
723                d.error = Some(fb.msg);
724                d.submitted = false;
725                changed = true;
726            }
727        } else {
728            shared.push_strategy_feedback_front(fb);
729        }
730    }
731
732    let mut keep_open = true;
733    let meta = shared
734        .strategy_metas()
735        .into_iter()
736        .find(|m| m.class_name == row.class_name);
737
738    egui::Window::new(format!("策略详情 · {}", row.name))
739        .id(egui::Id::new(("strategy_detail", row.name.clone())))
740        .open(&mut keep_open)
741        .resizable(true)
742        .default_width(520.0)
743        .collapsible(false)
744        .show(ui.ctx(), |ui| {
745            // ---- 顶部状态条 ----
746            ui.horizontal(|ui| {
747                ui.colored_label(state_color(ui.ctx(), row.state), row.state.label());
748                ui.separator();
749                ui.label(display_of(&row));
750                ui.separator();
751                ui.label(non_empty(&row.note));
752                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
753                    if row.buttons.stop && ui.button("停止").clicked() {
754                        shared.push_cmd(UiCmd::StratStop {
755                            name: row.name.clone(),
756                        });
757                        changed = true;
758                    }
759                    if row.buttons.pause && ui.button("暂停").clicked() {
760                        shared.push_cmd(UiCmd::StratPause {
761                            name: row.name.clone(),
762                        });
763                        changed = true;
764                    }
765                });
766            });
767            ui.separator();
768
769            // ---- 参数区 ----
770            ui.horizontal(|ui| {
771                ui.label(egui::RichText::new("参数").strong());
772                if let Some(d) = st.detail.as_mut() {
773                    if d.editing {
774                        if ui.small_button("保存").clicked() {
775                            if let Some(m) = &meta {
776                                match validate_form(m, &d.params) {
777                                    Err(e) => d.error = Some(e),
778                                    Ok(params) => {
779                                        d.error = None;
780                                        d.submitted = true;
781                                        shared.push_cmd(UiCmd::StratEdit {
782                                            name: row.name.clone(),
783                                            params,
784                                        });
785                                    }
786                                }
787                                changed = true;
788                            } else {
789                                d.error = Some("缺少该类参数规格,无法校验".to_string());
790                                changed = true;
791                            }
792                        }
793                        if ui.small_button("取消编辑").clicked() {
794                            d.editing = false;
795                            d.error = None;
796                            d.params = row.params.clone().into_iter().collect();
797                            changed = true;
798                        }
799                    } else if row.buttons.edit && ui.small_button("编辑").clicked() {
800                        d.editing = true;
801                        d.error = None;
802                        d.params = row.params.clone().into_iter().collect();
803                        changed = true;
804                    }
805                }
806            });
807            if let Some(m) = &meta {
808                let d = st.detail.as_mut().expect("detail 存在");
809                // 只读态:控件照画但整体禁用(读数是事实,不能就地改)。
810                ui.add_enabled_ui(d.editing, |ui| {
811                    if params_form(ui, m, &mut d.params) {
812                        changed = true;
813                    }
814                });
815            } else {
816                // 没有规格就退化成只读键值表(不隐藏信息)
817                egui::Grid::new("strategy_detail_params")
818                    .num_columns(2)
819                    .show(ui, |ui| {
820                        for (k, v) in &row.params {
821                            ui.label(k);
822                            ui.label(v);
823                            ui.end_row();
824                        }
825                    });
826            }
827            if let Some(e) = st.detail.as_ref().and_then(|d| d.error.clone()) {
828                ui.colored_label(ui.visuals().error_fg_color, e);
829            }
830
831            // ---- 变量区 ----
832            ui.separator();
833            ui.label(egui::RichText::new("变量").strong());
834            if row.variables.is_empty() {
835                ui.weak("(策略未上报变量)");
836            } else {
837                egui::Grid::new("strategy_detail_vars")
838                    .num_columns(2)
839                    .show(ui, |ui| {
840                        for (k, v) in &row.variables {
841                            ui.label(k);
842                            ui.label(v);
843                            ui.end_row();
844                        }
845                    });
846            }
847
848            // ---- 逐合约区(多合约才显示)----
849            if row.symbols.len() > 1 || row.positions.len() > 1 {
850                ui.separator();
851                ui.label(egui::RichText::new("逐合约").strong());
852                egui::Grid::new("strategy_detail_pos")
853                    .num_columns(3)
854                    .show(ui, |ui| {
855                        ui.label("合约");
856                        ui.label("持仓");
857                        ui.label("目标");
858                        ui.end_row();
859                        for (s, p, t) in &row.positions {
860                            if s.starts_with('…') {
861                                // 截断占位行(`…另有 N 个合约未显示`):只渲染这一句,
862                                // 数值位留空 —— 别让人把 0 当成"持仓 0 手"。
863                                ui.label(egui::RichText::new(s).weak());
864                                ui.label("");
865                                ui.label("");
866                                ui.end_row();
867                                continue;
868                            }
869                            ui.label(s);
870                            ui.label(crate::tokens::num_text(p.to_string()));
871                            ui.label(crate::tokens::num_text(t.to_string()));
872                            ui.end_row();
873                        }
874                    });
875            }
876        });
877
878    if !keep_open {
879        st.detail = None;
880        changed = true;
881    }
882    changed
883}
884
885/// 按 `ParamSpec` 铺一张参数表(添加 / 编辑共用同一份控件逻辑)。
886///
887/// 返回 `true` = 用户改过值。分节顺序吃 `ParamSpec.group`(空串归"基本")。
888fn params_form(
889    ui: &mut egui::Ui,
890    meta: &ek_strategy::strategy::StrategyMeta,
891    values: &mut std::collections::BTreeMap<String, String>,
892) -> bool {
893    use ek_strategy::contract::ParamKind;
894    let mut changed = false;
895    let mut last_group: Option<&str> = None;
896    egui::Grid::new(("strategy_params", meta.class_name))
897        .num_columns(2)
898        .spacing([12.0, 6.0])
899        .show(ui, |ui| {
900            for spec in meta.params {
901                let g = spec.group_or_default();
902                if last_group != Some(g) {
903                    ui.label(egui::RichText::new(g).strong());
904                    ui.end_row();
905                    last_group = Some(g);
906                }
907                let entry = values
908                    .entry(spec.key.to_string())
909                    .or_insert_with(|| spec.default.to_string());
910                ui.label(spec.label);
911                let r = match spec.kind {
912                    ParamKind::Bool => {
913                        let mut on = entry == "true";
914                        let resp = ui.checkbox(&mut on, "");
915                        if resp.changed() {
916                            *entry = if on { "true" } else { "false" }.to_string();
917                            changed = true;
918                        }
919                        resp
920                    }
921                    ParamKind::Enum { options } => {
922                        let mut cur = entry.clone();
923                        let resp = egui::ComboBox::from_id_salt((
924                            "strategy_param_enum",
925                            meta.class_name,
926                            spec.key,
927                        ))
928                        .selected_text(cur.clone())
929                        .show_ui(ui, |ui| {
930                            for o in options {
931                                if ui.selectable_label(cur == *o, *o).clicked() {
932                                    cur = (*o).to_string();
933                                }
934                            }
935                        })
936                        .response;
937                        if cur != *entry {
938                            *entry = cur;
939                            changed = true;
940                        }
941                        resp
942                    }
943                    ParamKind::SymbolList => {
944                        let resp = ui.add(
945                            egui::TextEdit::multiline(entry)
946                                .desired_rows(2)
947                                .hint_text("每行一个合约代码"),
948                        );
949                        if resp.changed() {
950                            changed = true;
951                        }
952                        resp
953                    }
954                    _ => {
955                        let resp = ui.add(egui::TextEdit::singleline(entry).desired_width(140.0));
956                        if resp.changed() {
957                            changed = true;
958                        }
959                        resp
960                    }
961                };
962                if !spec.hint.is_empty() {
963                    r.on_hover_text(spec.hint);
964                }
965                ui.end_row();
966            }
967        });
968    changed
969}
970
971/// 一类策略的缺省参数表(键 → 编码串)。
972fn default_params(
973    meta: &ek_strategy::strategy::StrategyMeta,
974) -> std::collections::BTreeMap<String, String> {
975    meta.params
976        .iter()
977        .map(|s| (s.key.to_string(), s.default.to_string()))
978        .collect()
979}
980
981/// 本地校验:把表单的编码串逐项过一遍 `decode_value`(服务端还会再校验一次)。
982fn validate_form(
983    meta: &ek_strategy::strategy::StrategyMeta,
984    values: &std::collections::BTreeMap<String, String>,
985) -> Result<Vec<(String, String)>, String> {
986    use ek_strategy::contract::decode_value;
987    let mut out = Vec::with_capacity(meta.params.len());
988    for spec in meta.params {
989        let raw = values
990            .get(spec.key)
991            .map_or(spec.default, String::as_str)
992            .trim()
993            .to_string();
994        // 符号列表:多行 → 逗号分隔的规范串
995        let raw = if matches!(spec.kind, ek_strategy::contract::ParamKind::SymbolList) {
996            raw.split(['\n', ',', ' ', ';'])
997                .map(str::trim)
998                .filter(|s| !s.is_empty())
999                .collect::<Vec<_>>()
1000                .join(",")
1001        } else {
1002            raw
1003        };
1004        if raw.is_empty() && !matches!(spec.kind, ek_strategy::contract::ParamKind::Str) {
1005            return Err(format!("「{}」不能为空", spec.label));
1006        }
1007        match decode_value(spec.kind, &raw) {
1008            Ok(_) => out.push((spec.key.to_string(), raw)),
1009            Err(e) => return Err(format!("「{}」非法:{e}", spec.label)),
1010        }
1011    }
1012    Ok(out)
1013}
1014
1015/// 收一个行内动作(去重:首帧双 pass 会把同一颗点击看到两次)。
1016fn push_act(acts: &std::cell::RefCell<Vec<(String, Act)>>, name: &str, act: Act) {
1017    let mut a = acts.borrow_mut();
1018    if !a.iter().any(|(n, x)| n == name && *x == act) {
1019        a.push((name.to_string(), act));
1020    }
1021}
1022
1023/// 一行的按钮组 `(文字, 动作, 可用)`。
1024///
1025/// 可用性**只读**内核投影的 [`RowButtons`] —— 界面不自己 `match` 状态。
1026fn row_buttons(r: &StrategyRow) -> Vec<(&'static str, Act, bool)> {
1027    let RowButtons {
1028        init,
1029        start,
1030        pause,
1031        stop,
1032        edit,
1033        remove,
1034    } = r.buttons;
1035    // 只读是**状态**决定的,不是"在哪个 tab":`Stopped` 身在「已结束」页,
1036    // 但它不是终态(Python 的 `inited` 在 stop 之后仍为 True)—— 停过的实例
1037    // 还能 [启动]/[编辑]。真终态(Finished/Failed)才只剩 [移除]。
1038    let terminal = matches!(r.state, StratState::Finished | StratState::Failed);
1039    let mut out = Vec::new();
1040    if !terminal {
1041        if init {
1042            out.push(("初始化", Act::Init, true));
1043        }
1044        if start {
1045            // 暂停后同一个字位换文案,动作仍是 start(resume 是 start 的语义别名)
1046            let label = if r.state == StratState::Paused {
1047                "恢复"
1048            } else {
1049                "启动"
1050            };
1051            out.push((label, Act::Start, true));
1052        }
1053        if pause {
1054            out.push(("暂停", Act::Pause, true));
1055        }
1056        if stop {
1057            out.push(("停止", Act::Stop, true));
1058        }
1059        if edit {
1060            out.push(("编辑", Act::Edit, true));
1061        }
1062    }
1063    if remove {
1064        out.push(("移除", Act::Remove, true));
1065    }
1066    out
1067}
1068
1069fn hover_of(act: Act) -> &'static str {
1070    match act {
1071        Act::Init => "订阅行情、准备参数",
1072        Act::Start => "开始接行情与下单",
1073        Act::Pause => "暂停:不再收行情,持仓与参数都留着",
1074        Act::Resume => "继续",
1075        Act::Stop => "停止:先撤掉本策略全部委托",
1076        Act::Edit => "改参数(运行中不可改)",
1077        Act::Remove => "从列表里删掉(先停止)",
1078        Act::Detail => "看参数 / 变量 / 逐合约台账",
1079    }
1080}
1081
1082/// 状态 → 颜色(只借既有语义色,不新造色板)。
1083fn state_color(ctx: &egui::Context, s: StratState) -> egui::Color32 {
1084    match s {
1085        StratState::Idle | StratState::Stopped => crate::tokens::muted(ctx),
1086        StratState::Inited => crate::tokens::info(ctx),
1087        StratState::Running => crate::tokens::ok(ctx),
1088        StratState::Paused => crate::tokens::warn(ctx),
1089        StratState::Finished => crate::tokens::info(ctx),
1090        StratState::Failed => crate::tokens::error(ctx),
1091    }
1092}
1093
1094fn display_of(r: &StrategyRow) -> String {
1095    if r.display_name.is_empty() {
1096        r.class_name.clone()
1097    } else {
1098        r.display_name.clone()
1099    }
1100}
1101
1102/// 合约列:`>1` 个 → `"多合约(N)"`;单个 → 代码;空 → `"—"`。
1103#[must_use]
1104pub fn symbol_summary(r: &StrategyRow) -> String {
1105    match r.symbols.len() {
1106        0 => "—".to_string(),
1107        1 => r.symbols[0].clone(),
1108        n => format!("多合约({n})"),
1109    }
1110}
1111
1112fn non_empty(s: &str) -> String {
1113    if s.is_empty() {
1114        "—".to_string()
1115    } else {
1116        s.to_string()
1117    }
1118}
1119
1120fn price_text(p: f64) -> String {
1121    if p == 0.0 {
1122        "—".to_string()
1123    } else {
1124        format!("{p:.2}")
1125    }
1126}
1127
1128/// 按列排序(**只排副本**;`COL_ACTIONS` 不参与)。
1129fn sort_rows(rows: &mut [StrategyRow], col: usize, asc: bool) {
1130    use std::cmp::Ordering;
1131    let cmp = |a: &StrategyRow, b: &StrategyRow| -> Ordering {
1132        let o = match col {
1133            1 => a.state.label().cmp(b.state.label()),
1134            2 => display_of(a).cmp(&display_of(b)),
1135            3 => symbol_summary(a).cmp(&symbol_summary(b)),
1136            4 => format!("{:?}", a.direction).cmp(&format!("{:?}", b.direction)),
1137            5 => a.pos_summary.cmp(&b.pos_summary),
1138            6 => a.target_summary.cmp(&b.target_summary),
1139            7 => a
1140                .last_price
1141                .partial_cmp(&b.last_price)
1142                .unwrap_or(Ordering::Equal),
1143            8 => a.last_dt.cmp(&b.last_dt),
1144            9 => a.note.cmp(&b.note),
1145            _ => a.name.cmp(&b.name),
1146        };
1147        // 同值时按实例名兜底,保证排序稳定(每次重排不会来回跳)
1148        o.then_with(|| a.name.cmp(&b.name))
1149    };
1150    if asc {
1151        rows.sort_by(cmp);
1152    } else {
1153        rows.sort_by(|a, b| cmp(b, a));
1154    }
1155}
1156
1157/// 空态引导。
1158fn empty_state(ui: &mut egui::Ui, st: &mut StrategyWinState, _shared: &UiShared) {
1159    ui.vertical_centered(|ui| {
1160        ui.add_space(40.0);
1161        ui.label(
1162            egui::RichText::new("尚未添加策略")
1163                .size(18.0)
1164                .color(ui.visuals().weak_text_color()),
1165        );
1166        ui.add_space(6.0);
1167        ui.label(
1168            egui::RichText::new("点上方「添加策略…」选一个模板,填参数即可开工")
1169                .size(13.0)
1170                .color(ui.visuals().weak_text_color()),
1171        );
1172        ui.add_space(18.0);
1173        if ui.button("添加策略…").clicked() {
1174            st.add_open = true;
1175        }
1176        ui.add_space(28.0);
1177        ui.label(
1178            egui::RichText::new(
1179                "提示:不同策略跑的是同一套东西 —— 参数不同而已。\n\
1180                 数据源尚未接入时,策略可以正常添加、初始化、启动、停止,只是收不到行情。",
1181            )
1182            .size(12.0)
1183            .color(ui.visuals().weak_text_color()),
1184        );
1185    });
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    fn row(name: &str, state: StratState, symbols: Vec<&str>) -> StrategyRow {
1193        StrategyRow {
1194            name: name.to_string(),
1195            state,
1196            symbols: symbols.into_iter().map(str::to_string).collect(),
1197            ..StrategyRow::default()
1198        }
1199    }
1200
1201    #[test]
1202    fn default_tab_is_active() {
1203        let s = StrategyWinState::new();
1204        assert_eq!(s.tab, Tab::Active);
1205        assert!(s.selected.is_none());
1206        assert!(!s.add_open);
1207    }
1208
1209    #[test]
1210    fn tab_labels_are_chinese() {
1211        assert_eq!(Tab::Active.label(), "执行中");
1212        assert_eq!(Tab::Done.label(), "已结束");
1213    }
1214
1215    #[test]
1216    fn tabs_split_by_state() {
1217        for s in [
1218            StratState::Idle,
1219            StratState::Inited,
1220            StratState::Running,
1221            StratState::Paused,
1222        ] {
1223            assert_eq!(Tab::of(s), Tab::Active, "{s:?} 属于执行中");
1224        }
1225        for s in [
1226            StratState::Stopped,
1227            StratState::Finished,
1228            StratState::Failed,
1229        ] {
1230            assert_eq!(Tab::of(s), Tab::Done, "{s:?} 属于已结束");
1231        }
1232    }
1233
1234    #[test]
1235    fn symbol_summary_covers_single_and_multi() {
1236        assert_eq!(symbol_summary(&row("a", StratState::Idle, vec![])), "—");
1237        assert_eq!(
1238            symbol_summary(&row("a", StratState::Idle, vec!["rb2611"])),
1239            "rb2611"
1240        );
1241        assert_eq!(
1242            symbol_summary(&row(
1243                "a",
1244                StratState::Idle,
1245                vec!["rb2611", "ag2612", "cu2612"]
1246            )),
1247            "多合约(3)"
1248        );
1249    }
1250
1251    #[test]
1252    fn sort_keeps_source_order_untouched_and_is_stable() {
1253        let mut rows = vec![
1254            row("b", StratState::Running, vec!["rb2611"]),
1255            row("a", StratState::Idle, vec!["rb2611"]),
1256        ];
1257        let before = rows.iter().map(|r| r.name.clone()).collect::<Vec<_>>();
1258        sort_rows(&mut rows, 2, true);
1259        assert_eq!(rows[0].name, "a", "按策略名升序");
1260        // 副本排序不改源表(Rust 侧天然如此;这里钉的是调用形态:传进来的是副本)
1261        assert_eq!(before, vec!["b".to_string(), "a".to_string()]);
1262    }
1263
1264    #[test]
1265    fn buttons_follow_state_projection() {
1266        let r = |state: StratState| StrategyRow {
1267            buttons: RowButtons::of(state, true),
1268            state,
1269            ..StrategyRow::default()
1270        };
1271        // Idle:只有初始化
1272        let b: Vec<_> = row_buttons(&r(StratState::Idle))
1273            .into_iter()
1274            .map(|(l, _, _)| l)
1275            .collect();
1276        assert_eq!(b, vec!["初始化", "编辑", "移除"]);
1277        // Running:暂停 + 停止,且**没有**编辑/移除
1278        let b: Vec<_> = row_buttons(&r(StratState::Running))
1279            .into_iter()
1280            .map(|(l, _, _)| l)
1281            .collect();
1282        assert_eq!(b, vec!["暂停", "停止"]);
1283        // Finished(已结束表):只剩移除
1284        let b: Vec<_> = row_buttons(&r(StratState::Finished))
1285            .into_iter()
1286            .map(|(l, _, _)| l)
1287            .collect();
1288        assert_eq!(b, vec!["移除"]);
1289    }
1290
1291    #[test]
1292    fn paused_shows_resume_label() {
1293        let r = StrategyRow {
1294            buttons: RowButtons::of(StratState::Paused, true),
1295            state: StratState::Paused,
1296            ..StrategyRow::default()
1297        };
1298        let b: Vec<_> = row_buttons(&r).into_iter().map(|(l, _, _)| l).collect();
1299        assert!(b.contains(&"恢复"), "暂停态该显示恢复:{b:?}");
1300        assert!(!b.contains(&"启动"));
1301    }
1302
1303    #[test]
1304    fn stopped_in_done_tab_stays_startable() {
1305        // 2026-11-03 裁定:只读看**状态**不看 tab。`Stopped` 在「已结束」页,
1306        // 但它不是终态 —— 还能启动/编辑(Python 的 inited 是粘的)。
1307        let r = StrategyRow {
1308            buttons: RowButtons::of(StratState::Stopped, true),
1309            state: StratState::Stopped,
1310            ..StrategyRow::default()
1311        };
1312        let b: Vec<_> = row_buttons(&r).into_iter().map(|(l, _, _)| l).collect();
1313        assert_eq!(b, vec!["启动", "编辑", "移除"], "不该再要一次初始化");
1314    }
1315
1316    #[test]
1317    fn failed_is_terminal_even_outside_the_done_tab() {
1318        let r = StrategyRow {
1319            buttons: RowButtons::of(StratState::Failed, true),
1320            state: StratState::Failed,
1321            ..StrategyRow::default()
1322        };
1323        let b: Vec<_> = row_buttons(&r).into_iter().map(|(l, _, _)| l).collect();
1324        assert_eq!(b, vec!["移除"], "失败终态只剩移除");
1325    }
1326}