1use crate::render::pinned_table_impl_cols;
16use crate::state::{UiCmd, UiShared};
17use ek_strategy::contract::{RowButtons, StratState, StrategyRow};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum Tab {
22 #[default]
24 Active,
25 Done,
27}
28
29impl Tab {
30 #[must_use]
32 pub fn label(self) -> &'static str {
33 match self {
34 Self::Active => "执行中",
35 Self::Done => "已结束",
36 }
37 }
38
39 #[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
51pub const HEADERS: [&str; 10] = [
53 "操作",
54 "状态",
55 "策略",
56 "合约",
57 "方向",
58 "持仓",
59 "目标",
60 "最新价",
61 "时间",
62 "备注",
63];
64
65pub const COL_ACTIONS: usize = 0;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Act {
71 Init,
73 Start,
75 Pause,
77 Resume,
79 Stop,
81 Edit,
83 Remove,
85 Detail,
87}
88
89#[derive(Debug, Clone, Default)]
91pub struct AddForm {
92 pub class_name: String,
94 pub name: String,
96 pub params: std::collections::BTreeMap<String, String>,
98 pub error: Option<String>,
100 pub submitted: Option<String>,
102}
103
104#[derive(Debug, Clone, Default)]
106pub struct DetailForm {
107 pub name: String,
109 pub editing: bool,
111 pub params: std::collections::BTreeMap<String, String>,
113 pub error: Option<String>,
115 pub submitted: bool,
117}
118
119#[derive(Debug, Default)]
121pub struct StrategyWinState {
122 pub tab: Tab,
124 pub selected: Option<String>,
126 pub add_open: bool,
128 pub add_form: AddForm,
130 pub detail: Option<DetailForm>,
132 pub pending_remove: Option<String>,
134 pub last_error: Option<String>,
136 pub sort: Option<(usize, bool)>,
140 pub engine_open: bool,
142 pub engine_trade: String,
144 pub engine_md: String,
146 pub engine_error: Option<String>,
148 pub engine_submitted: bool,
150}
151
152impl StrategyWinState {
153 #[must_use]
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 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 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 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
214pub fn render(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
216 let mut repaint = false;
217
218 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 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 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 if render_dialogs(ui, st, shared) {
296 repaint = true;
297 }
298 return repaint;
299 }
300
301 let selected = st.selected.clone();
303 let pending = st.pending_remove.clone();
304
305 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 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 pr.cell(|ui, _ctx| {
349 ui.colored_label(state_color(ui.ctx(), r.state), r.state.label());
350 });
351 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 pr.cell(|ui, _ctx| {
362 ui.label(symbol_summary(r));
363 });
364 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 pr.cell(|ui, _ctx| {
378 ui.label(crate::tokens::num_text(&r.pos_summary));
379 });
380 pr.cell(|ui, _ctx| {
382 ui.label(crate::tokens::num_text(non_empty(&r.target_summary)));
383 });
384 pr.cell(|ui, _ctx| {
386 ui.label(crate::tokens::num_text(price_text(r.last_price)));
387 });
388 pr.cell(|ui, _ctx| {
390 ui.label(crate::tokens::num_text(non_empty(&r.last_dt)));
391 });
392 pr.cell(|ui, _ctx| {
394 ui.label(non_empty(&r.note));
395 });
396 });
397 }
398 },
399 );
400
401 for (name, act) in acts.into_inner() {
403 st.on_act(shared, &name, act);
404 repaint = true;
405 }
406
407 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
425pub 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
445fn render_engine_dialog(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
453 let mut changed = false;
454 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
541fn render_add_dialog(ui: &mut egui::Ui, st: &mut StrategyWinState, shared: &UiShared) -> bool {
543 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 if let Some(fb) = shared.take_strategy_feedback() {
557 if fb.op == "add" {
558 if fb.ok {
559 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 shared.push_strategy_feedback_front(fb);
572 }
573 }
574
575 if !open {
576 return changed;
577 }
578
579 let mut keep_open = true;
580 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 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 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 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 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 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
700fn 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 st.detail = None;
708 return true;
709 };
710
711 let mut changed = false;
712 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 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 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 ui.add_enabled_ui(d.editing, |ui| {
811 if params_form(ui, m, &mut d.params) {
812 changed = true;
813 }
814 });
815 } else {
816 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 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 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 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
885fn 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
971fn 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
981fn 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 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
1015fn 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
1023fn 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 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 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
1082fn 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#[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
1128fn 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 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
1157fn 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 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 let b: Vec<_> = row_buttons(&r(StratState::Idle))
1273 .into_iter()
1274 .map(|(l, _, _)| l)
1275 .collect();
1276 assert_eq!(b, vec!["初始化", "编辑", "移除"]);
1277 let b: Vec<_> = row_buttons(&r(StratState::Running))
1279 .into_iter()
1280 .map(|(l, _, _)| l)
1281 .collect();
1282 assert_eq!(b, vec!["暂停", "停止"]);
1283 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 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}