1use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42pub const BTN_CLEAR_LOGS: &str = "清空日志";
44pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46pub const BTN_SEND_ORDER: &str = "委托";
48pub const BTN_CANCEL_ALL: &str = "全撤";
50pub const BTN_CANCEL_ORDER: &str = "撤单";
52pub const BTN_ACCOUNT_ADD: &str = "添加账户";
54pub const MENU_OPS: &str = "运维";
56pub const MENU_ACCOUNTS: &str = "账户";
59pub const MENU_HELP: &str = "帮助";
61pub const MENU_CONTRACTS: &str = "查询合约";
63pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
67
68pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
71
72fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
73 if armed_on == Some(name) {
74 format!("确认移除 {name} ?")
75 } else {
76 format!("移除 {name}")
77 }
78}
79
80fn subscribe_target(shared: &UiShared) -> Result<String, String> {
85 let md = shared.md_source();
86 if md.is_empty() {
87 return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
88 }
89 Ok(md)
90}
91
92fn state_text(r: &PlugRow) -> String {
93 r.state.clone()
94}
95
96fn fmt_dt(dt: Option<i64>) -> String {
100 dt.map(|ns| {
101 chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
102 .with_timezone(&beijing_tz())
103 .format("%H:%M:%S")
104 .to_string()
105 })
106 .unwrap_or_else(|| "-".to_string())
107}
108
109fn beijing_tz() -> chrono::FixedOffset {
111 chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
112}
113
114fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
120 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
121 return;
122 };
123 let hovered = ui
124 .input(|i| i.pointer.interact_pos())
125 .is_some_and(|p| union.expand(3.0).contains(p));
126 if selected {
127 ui.painter().rect_filled(
128 union.expand(3.0),
129 3.0,
130 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
131 );
132 } else if hovered {
133 ui.painter().rect_filled(
134 union.expand(3.0),
135 3.0,
136 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
137 );
138 }
139}
140
141fn order_active(status: ek_data::constant::Status) -> bool {
143 ek_data::constant::ACTIVE_STATUSES.contains(&status)
144}
145
146fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
149 ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
150}
151
152pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
156 let mut f = shared.order_form();
157 f.symbol = t.symbol.clone();
158 f.exchange = exchange.to_string();
159 match col {
160 8 => {
161 f.direction = Direction::SHORT;
162 f.price = format!("{:.1}", t.bid_price_1);
163 }
164 9 => f.direction = Direction::SHORT,
165 10 => {
166 f.direction = Direction::LONG;
167 f.price = format!("{:.1}", t.ask_price_1);
168 }
169 11 => f.direction = Direction::LONG,
170 _ => {}
171 }
172 shared.set_order_form(f);
173}
174
175fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
178 if symbol.is_empty() {
179 return None;
180 }
181 shared.tick_rows().into_iter().find(|t| {
182 t.symbol == symbol
183 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
184 })
185}
186
187pub fn apply_ui_style(ctx: &egui::Context) {
191 apply_ui_style_for(ctx, crate::state::Theme::default());
192}
193
194pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
199 ctx.style_mut(|style| {
200 style
201 .text_styles
202 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
203 style
204 .text_styles
205 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
206 style
207 .text_styles
208 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
209 style
210 .text_styles
211 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
212 style
213 .text_styles
214 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
215 });
216 ctx.set_visuals(theme_visuals(theme));
217}
218
219fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
223 use egui::Color32;
224 match t {
225 crate::state::Theme::EguiDark => egui::Visuals::dark(),
226 crate::state::Theme::EguiLight => egui::Visuals::light(),
227 crate::state::Theme::QtClassic => {
228 let mut v = egui::Visuals::light();
229 v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
230 v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
231 v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
232 v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
233 v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
234 v
235 }
236 crate::state::Theme::FinanceDark => {
237 let mut v = egui::Visuals::dark();
238 v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
239 v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
240 v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
241 v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
242 v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
243 v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
244 v.widgets.noninteractive.bg_stroke =
245 egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
246 v
247 }
248 crate::state::Theme::QDarkStyle => {
268 let bg1 = Color32::from_rgb(0x19, 0x23, 0x2D); let bg2 = Color32::from_rgb(0x29, 0x35, 0x44); let bg4 = Color32::from_rgb(0x45, 0x53, 0x64); let bg5 = Color32::from_rgb(0x54, 0x68, 0x7A); let bg6 = Color32::from_rgb(0x60, 0x79, 0x8B); let text1 = Color32::from_rgb(0xDF, 0xE1, 0xE2); let text3 = Color32::from_rgb(0xAC, 0xB1, 0xB6); let accent2 = Color32::from_rgb(0x34, 0x67, 0x92); let accent4 = Color32::from_rgb(0x25, 0x9A, 0xE9); let r4 = egui::CornerRadius::same(4); let mut v = egui::Visuals::dark();
281 v.dark_mode = true;
282 v.window_fill = bg1;
283 v.panel_fill = bg1;
284 v.extreme_bg_color = bg1; v.faint_bg_color = bg2;
286 v.hyperlink_color = accent4;
287 v.weak_text_color = Some(text3);
288 v.window_stroke = egui::Stroke::new(1.0, bg4);
289 v.window_corner_radius = r4;
290 v.menu_corner_radius = r4;
291 let stroke1 = egui::Stroke::new(1.0, text1);
297 v.widgets.noninteractive = egui::style::WidgetVisuals {
300 bg_fill: bg1,
301 weak_bg_fill: bg1,
302 bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
304 fg_stroke: stroke1,
305 expansion: 0.0,
306 };
307 v.widgets.inactive = egui::style::WidgetVisuals {
308 bg_fill: bg4,
309 weak_bg_fill: bg4,
310 bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
312 fg_stroke: stroke1,
313 expansion: 0.0,
314 };
315 v.widgets.hovered = egui::style::WidgetVisuals {
316 bg_fill: bg5,
317 weak_bg_fill: bg5,
318 bg_stroke: egui::Stroke::new(1.0, bg5),
319 corner_radius: r4,
320 fg_stroke: stroke1,
321 expansion: 0.0,
322 };
323 v.widgets.active = egui::style::WidgetVisuals {
324 bg_fill: bg6,
325 weak_bg_fill: bg6,
326 bg_stroke: egui::Stroke::new(1.0, bg6),
327 corner_radius: r4,
328 fg_stroke: stroke1,
329 expansion: 0.0,
330 };
331 v.widgets.open = egui::style::WidgetVisuals {
332 bg_fill: bg6,
333 weak_bg_fill: bg6,
334 bg_stroke: egui::Stroke::new(1.0, bg4),
335 corner_radius: r4,
336 fg_stroke: stroke1,
337 expansion: 0.0,
338 };
339 v.selection = egui::style::Selection {
340 bg_fill: accent2,
341 stroke: egui::Stroke::new(1.0, text1), };
343 v.interact_cursor = Some(egui::CursorIcon::PointingHand);
345 v
346 }
347 }
348}
349
350pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
358 apply_ui_style_for(ctx, shared.theme());
359 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
361 render_menu_bar(ui, shared);
362 });
363
364 egui::TopBottomPanel::top("market_band")
366 .resizable(true)
367 .default_height(240.0)
368 .show(ctx, |ui| {
369 egui::ScrollArea::both()
373 .id_salt("market_band_scroll")
374 .auto_shrink([false, false])
375 .show(ui, |ui| {
376 render_market_band(ui, shared);
377 });
378 });
379
380 egui::SidePanel::left("order_side")
382 .resizable(true)
383 .default_width(250.0)
384 .show(ctx, |ui| {
385 egui::ScrollArea::vertical()
387 .id_salt("order_side_scroll")
388 .auto_shrink([false, false])
389 .show(ui, |ui| {
390 render_order_panel(ui, shared);
391 ui.separator();
392 render_book_frame(ui, shared);
393 });
394 });
395
396 egui::CentralPanel::default().show(ctx, |ui| {
398 let avail = ui.available_size();
399 let sp = ui.spacing().item_spacing;
400 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
406 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
407 ui.horizontal(|ui| {
408 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
409 render_topleft_group(ui, shared);
410 });
411 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
412 render_trades_group(ui, shared);
413 });
414 });
415 ui.horizontal(|ui| {
416 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
417 render_positions_group(ui, shared);
418 });
419 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
424 render_bottomright_group(ui, shared);
425 });
426 });
427 });
428
429 render_windows(ctx, shared);
431}
432
433fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
441 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
442 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
443 egui::Frame::group(ui.style()).show(&mut child, |ui| {
444 egui::ScrollArea::both()
445 .id_salt(id)
446 .auto_shrink([false, false])
447 .show(ui, |ui| {
448 ui.vertical(content);
449 });
450 });
451}
452
453fn group_frame_plain(
460 ui: &mut egui::Ui,
461 _id: &str,
462 w: f32,
463 h: f32,
464 content: impl FnOnce(&mut egui::Ui),
465) {
466 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
467 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
468 egui::Frame::group(ui.style()).show(&mut child, |ui| {
469 ui.vertical(content);
470 });
471}
472
473fn pinned_table(
489 ui: &mut egui::Ui,
490 grid_id: &str,
491 num_columns: usize,
492 headers: &[&str],
493 body: impl FnOnce(&mut egui::Ui),
494) {
495 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
496 std::cell::RefCell::new(Vec::new());
497 let resp = egui::ScrollArea::both()
498 .id_salt(format!("{grid_id}_sc"))
499 .auto_shrink([false, false])
500 .show(ui, |ui| {
501 egui::Grid::new(grid_id)
502 .striped(true)
503 .num_columns(num_columns)
504 .show(ui, |ui| {
505 for h in headers {
506 let r = ui.strong(*h);
507 heads_cell.borrow_mut().push((r.rect, h.to_string()));
508 }
509 ui.end_row();
510 body(ui);
511 });
512 });
513 let heads = heads_cell.borrow();
514 if heads.is_empty() {
515 return;
516 }
517 let area = resp.inner_rect;
518 let head_h = heads[0].0.height();
519 let m = ui.visuals().clip_rect_margin;
525 let strip = egui::Rect::from_min_max(
526 egui::pos2(area.min.x - m, area.min.y - m),
527 egui::pos2(
528 area.max.x + m,
529 area.min.y + head_h + ui.spacing().item_spacing.y,
530 ),
531 );
532 ui.scope_builder(
533 egui::UiBuilder::new()
534 .id_salt(format!("{grid_id}_hdr_ov"))
535 .max_rect(strip),
536 |ui| {
537 ui.set_clip_rect(strip);
538 let painter = ui.painter();
539 painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
540 let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
541 let color = ui.visuals().strong_text_color();
542 for (rect, text) in heads.iter() {
543 painter.text(
544 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
545 egui::Align2::LEFT_CENTER,
546 text.as_str(),
547 font.clone(),
548 color,
549 );
550 }
551 },
552 );
553}
554
555fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
558 ui.horizontal(|ui| {
559 if ui.button(MENU_OPS).clicked() {
561 shared.set_ops_window_open(true);
562 }
563 if ui.button(MENU_ACCOUNTS).clicked() {
564 shared.set_accounts_window_open(true);
565 }
566 if ui.button(MENU_CONTRACTS).clicked() {
567 shared.set_contracts_window_open(true);
568 }
569 ui.menu_button(MENU_HELP, |ui| {
570 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
571 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
572 });
573 if ui
576 .button(format!("主题:{}", shared.theme().label()))
577 .clicked()
578 {
579 let t = shared.cycle_theme();
580 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
581 }
582 });
583}
584
585fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
588 let groups = shared.tick_groups();
590 if groups.is_empty() {
591 ui.label("(等待 eTick 订阅)");
592 return;
593 }
594 let sel = shared.selected_exchange();
596 let active = if groups.iter().any(|(e, _)| *e == sel) {
597 sel.clone()
598 } else {
599 groups[0].0.clone()
600 };
601 ui.horizontal(|ui| {
602 for (ex, _) in &groups {
603 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
604 shared.set_selected_exchange(ex.clone());
605 }
606 }
607 });
608 let ticks = groups
609 .iter()
610 .find(|(e, _)| *e == active)
611 .map(|(_, v)| v.clone())
612 .unwrap_or_default();
613 pinned_table(
616 ui,
617 "tick_grid",
618 14,
619 &[
620 "代码",
621 "名称",
622 "最新价",
623 "涨跌幅",
624 "成交量",
625 "今开",
626 "最高",
627 "最低",
628 "买1价",
629 "买1量",
630 "卖1价",
631 "卖1量",
632 "时间",
633 "行情源",
634 ],
635 |ui| {
636 for t in &ticks {
637 let row_sel = shared.order_form().symbol == t.symbol;
642 let mut cells = Vec::with_capacity(14);
643 let mut dbl = false;
644 let resp = ui.button(t.symbol.clone());
645 if resp.clicked() {
646 shared.set_selected_exchange(active.clone());
647 import_tick(shared, t, &active, 0);
648 }
649 dbl |= resp.double_clicked();
650 cells.push(resp.rect);
651 macro_rules! cell {
652 ($base:expr, $col:expr) => {{
653 let base = $base;
654 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
655 if r.clicked() {
656 shared.set_selected_exchange(active.clone());
657 import_tick(shared, t, &active, $col);
658 }
659 dbl |= r.double_clicked();
660 cells.push(base.rect);
661 }};
662 }
663 cell!(
664 ui.label(if t.name.is_empty() {
665 "-".to_string()
666 } else {
667 t.name.clone()
668 }),
669 1
670 );
671 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
672 cell!(
673 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
674 if t.pct_change < 0.0 {
675 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
676 } else {
677 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
678 },
679 )),
680 3
681 );
682 cell!(ui.label(format!("{:.0}", t.volume)), 4);
683 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
684 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
685 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
686 cell!(
687 ui.label(
688 egui::RichText::new(format!("{:.1}", t.bid_price_1))
689 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
690 ),
691 8
692 );
693 cell!(
694 ui.label(
695 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
696 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
697 ),
698 9
699 );
700 cell!(
701 ui.label(
702 egui::RichText::new(format!("{:.1}", t.ask_price_1))
703 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
704 ),
705 10
706 );
707 cell!(
708 ui.label(
709 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
710 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
711 ),
712 11
713 );
714 cell!(
715 ui.label(if t.update_time.is_empty() {
716 fmt_dt(Some(t.datetime))
717 } else {
718 t.update_time.clone()
719 }),
720 12
721 );
722 cell!(ui.label(t.gateway_name.clone()), 13);
723 if dbl {
724 shared.open_kline(t.symbol.clone());
725 }
726 paint_row_bg(ui, &cells, row_sel);
727 ui.end_row();
728 }
729 },
730 );
731}
732
733fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
736 let mut f = shared.order_form();
738 let before = f.clone();
739 let mut err: Option<String> = None;
740
741 if f.follow_price {
743 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
744 if t.last_price > 0.0 {
745 f.price = format!("{:.1}", t.last_price);
746 }
747 }
748 }
749
750 let mut ex_opts: Vec<String> = shared
752 .tick_groups()
753 .iter()
754 .map(|(e, _)| e.clone())
755 .collect();
756 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
757 if !ex_opts.iter().any(|x| x == e) {
758 ex_opts.push(e.to_string());
759 }
760 }
761 if !ex_opts.contains(&f.exchange) {
762 ex_opts.insert(0, f.exchange.clone());
763 }
764 let ts = shared.trade_snap();
767 let mut gw_opts: Vec<String> = Vec::new();
768 let known_gws = account_gateways(shared);
775 for g in shared
776 .accounts()
777 .iter()
778 .filter(|a| a.connected)
779 .map(|a| a.gateway.clone())
780 .chain(
781 ts.orders
782 .iter()
783 .map(|o| o.gateway_name.clone())
784 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
785 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
786 .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
787 )
788 {
789 if !g.is_empty() && !gw_opts.contains(&g) {
790 gw_opts.push(g);
791 }
792 }
793 if !gw_opts.contains(&f.gateway)
794 && !f.gateway.is_empty()
795 && (known_gws.is_empty() || known_gws.contains(&f.gateway))
796 {
797 gw_opts.insert(0, f.gateway.clone());
798 }
799 if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
802 f.gateway = shared.default_gateway().unwrap_or_default();
803 }
804 if f.gateway.is_empty() {
807 if let Some(def) = shared.default_gateway() {
808 f.gateway = def;
809 }
810 }
811 let no_accounts = gw_opts.is_empty();
812
813 egui::Grid::new("order_form_grid")
816 .num_columns(2)
817 .show(ui, |ui| {
818 ui.label("交易所");
819 egui::ComboBox::from_id_salt("of_exchange")
820 .selected_text(f.exchange.clone())
821 .show_ui(ui, |ui| {
822 for e in &ex_opts {
823 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
824 }
825 });
826 ui.end_row();
827 ui.label("代码");
828 let sym_resp = ui.add(
829 egui::TextEdit::singleline(&mut f.symbol)
830 .hint_text("cu2709")
831 .desired_width(150.0),
832 );
833 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
838 let s = f.symbol.trim().to_string();
839 let bare = match s.split_once('.') {
842 Some((_, b)) => b.trim().to_string(),
843 None => s,
844 };
845 if !bare.is_empty() {
846 f.symbol = bare.clone();
847 match subscribe_target(shared) {
850 Ok(gw) => {
851 shared.push_cmd(UiCmd::CtpSubscribe {
852 gateway: gw.clone(),
853 symbols: vec![bare.clone()],
854 });
855 shared
856 .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
857 }
858 Err(e) => shared.log_line(format!("[订阅] {e}")),
859 }
860 }
861 }
862 ui.end_row();
863 ui.label("名称");
864 ui.label(
865 tick_of(shared, &f.exchange, &f.symbol)
866 .map(|t| {
867 if t.name.is_empty() {
868 "-".to_string()
869 } else {
870 t.name
871 }
872 })
873 .unwrap_or_else(|| "-".to_string()),
874 );
875 ui.end_row();
876 ui.label("方向");
877 egui::ComboBox::from_id_salt("of_direction")
878 .selected_text(f.direction.as_str())
879 .show_ui(ui, |ui| {
880 for d in [Direction::LONG, Direction::SHORT] {
881 ui.selectable_value(&mut f.direction, d, d.as_str());
882 }
883 });
884 ui.end_row();
885 ui.label("开平");
886 ui.horizontal(|ui| {
887 egui::ComboBox::from_id_salt("of_offset")
888 .selected_text(f.offset.as_str())
889 .show_ui(ui, |ui| {
890 for o in [
891 Offset::OPEN,
892 Offset::CLOSE,
893 Offset::CLOSETODAY,
894 Offset::CLOSEYESTERDAY,
895 ] {
896 ui.selectable_value(&mut f.offset, o, o.as_str());
897 }
898 });
899 ui.checkbox(&mut f.auto_split, "自动开平");
901 });
902 ui.end_row();
903 ui.label("类型");
904 egui::ComboBox::from_id_salt("of_order_type")
905 .selected_text(f.order_type.as_str())
906 .show_ui(ui, |ui| {
907 for ot in [OrderType::LIMIT, OrderType::MARKET] {
908 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
909 }
910 });
911 ui.end_row();
912 ui.label("价格");
913 ui.horizontal(|ui| {
914 ui.add(
915 egui::TextEdit::singleline(&mut f.price)
916 .hint_text("107660")
917 .desired_width(90.0),
918 );
919 ui.checkbox(&mut f.follow_price, "随价");
920 });
921 ui.end_row();
922 ui.label("数量");
923 ui.add(
924 egui::TextEdit::singleline(&mut f.volume)
925 .hint_text("1")
926 .desired_width(90.0),
927 );
928 ui.end_row();
929 ui.label("接口");
930 egui::ComboBox::from_id_salt("of_gateway")
931 .selected_text(if f.gateway.is_empty() {
932 "(未选账户)".to_string()
933 } else {
934 f.gateway.clone()
935 })
936 .show_ui(ui, |ui| {
937 for g in &gw_opts {
938 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
939 }
940 });
941 ui.end_row();
942 ui.label("操作");
943 ui.horizontal(|ui| {
944 let send = ui
946 .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
947 .inner
948 .clicked();
949 if send {
950 match f.to_request() {
951 Ok(req) => {
952 let fallback = shared.default_gateway();
953 match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
954 Ok(gateway) => {
955 shared.push_cmd(UiCmd::SendOrder {
956 req,
957 gateway,
958 mode: if f.auto_split {
959 ek_oms::LockMode::Auto
960 } else {
961 ek_oms::LockMode::None
962 },
963 });
964 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
965 }
966 Err(e) => {
967 shared.log_line(format!("[下单] {e}"));
968 err = Some(e);
969 }
970 }
971 }
972 Err(e) => {
973 shared.log_line(format!("[下单] {e}"));
974 err = Some(e);
975 }
976 }
977 }
978 if ui.button(BTN_CANCEL_ALL).clicked() {
979 let mut n = 0usize;
982 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
983 let gw = if !o.gateway_name.is_empty() {
985 o.gateway_name.clone()
986 } else {
987 shared.default_gateway().unwrap_or_default()
988 };
989 if gw.is_empty() {
990 continue;
991 }
992 shared.push_cmd(UiCmd::CancelOrder {
993 orderid: o.orderid.clone(),
994 gateway: gw,
995 });
996 n += 1;
997 }
998 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
999 }
1000 });
1001 ui.end_row();
1002 ui.label("代码回车 = 订阅");
1003 ui.label("");
1004 ui.end_row();
1005 });
1006 if let Some(e) = err {
1007 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
1008 }
1009 if no_accounts {
1010 ui.label(
1012 egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1013 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1014 );
1015 }
1016 if f != before {
1017 shared.set_order_form(f.clone());
1018 }
1019}
1020
1021fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1025 let f = shared.order_form();
1026 match tick_of(shared, &f.exchange, &f.symbol) {
1027 Some(t) => {
1028 for i in (1..=5).rev() {
1029 let (p, v) = match i {
1030 1 => (t.ask_price_1, t.ask_volume_1),
1031 2 => (t.ask_price_2, t.ask_volume_2),
1032 3 => (t.ask_price_3, t.ask_volume_3),
1033 4 => (t.ask_price_4, t.ask_volume_4),
1034 _ => (t.ask_price_5, t.ask_volume_5),
1035 };
1036 ui.label(
1037 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
1038 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
1039 );
1040 }
1041 for i in 1..=5 {
1042 let (p, v) = match i {
1043 1 => (t.bid_price_1, t.bid_volume_1),
1044 2 => (t.bid_price_2, t.bid_volume_2),
1045 3 => (t.bid_price_3, t.bid_volume_3),
1046 4 => (t.bid_price_4, t.bid_volume_4),
1047 _ => (t.bid_price_5, t.bid_volume_5),
1048 };
1049 ui.label(
1050 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
1051 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
1052 );
1053 }
1054 }
1055 None => {
1056 ui.label("(无 tick)");
1057 }
1058 }
1059}
1060
1061fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
1064 let tab = shared.topleft_tab();
1065 ui.horizontal(|ui| {
1066 if ui
1067 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
1068 .clicked()
1069 {
1070 shared.set_topleft_tab(TopLeftTab::Orders);
1071 }
1072 if ui
1073 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
1074 .clicked()
1075 {
1076 shared.set_topleft_tab(TopLeftTab::Active);
1077 }
1078 });
1079 render_orders_table(ui, shared, tab == TopLeftTab::Active);
1080}
1081
1082fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1086 let ts = shared.trade_snap();
1087 let known_gws = account_gateways(shared);
1089 let rows: Vec<_> = ts
1092 .orders
1093 .iter()
1094 .filter(|o| !active_only || order_active(o.status))
1095 .collect();
1096 const ORDER_HEADERS: [&str; 17] = [
1098 "代码",
1099 "名称",
1100 "交易所",
1101 "类型",
1102 "方向",
1103 "开平",
1104 "价格",
1105 "总量",
1106 "已成",
1107 "成交均价",
1108 "状态",
1109 "状态描述",
1110 "时间",
1111 "委托号",
1112 "来源",
1113 "接口",
1114 "操作",
1115 ];
1116 if rows.is_empty() {
1117 pinned_table(
1120 ui,
1121 if active_only {
1122 "active_grid"
1123 } else {
1124 "order_grid"
1125 },
1126 17,
1127 &ORDER_HEADERS,
1128 |ui| {
1129 ui.label(if active_only {
1130 "(无未成交)"
1131 } else {
1132 "(无委托:等网关 send_order)"
1133 });
1134 },
1135 );
1136 return;
1137 }
1138 pinned_table(
1139 ui,
1140 if active_only {
1141 "active_grid"
1142 } else {
1143 "order_grid"
1144 },
1145 17,
1146 &ORDER_HEADERS,
1147 |ui| {
1148 for o in &rows {
1149 let key = format!("{}.{}", o.gateway_name, o.orderid);
1153 let row_sel = shared.row_selected("orders", &key);
1154 let mut cells = Vec::with_capacity(17);
1155 let mut dbl = false;
1156 let sel = ui.selectable_label(row_sel, o.symbol.clone());
1157 if sel.clicked() {
1158 shared.select_row("orders", &key);
1159 }
1160 dbl |= sel.double_clicked();
1161 cells.push(sel.rect);
1162 macro_rules! cell {
1163 ($base:expr, $col:expr) => {{
1164 let base = $base;
1165 let r = click_cell(ui, base.rect, ("ord", &key, $col));
1166 if r.clicked() {
1167 shared.select_row("orders", &key);
1168 }
1169 dbl |= r.double_clicked();
1170 cells.push(base.rect);
1171 }};
1172 }
1173 cell!(
1174 ui.label(if o.name.is_empty() {
1175 "-".to_string()
1176 } else {
1177 o.name.clone()
1178 }),
1179 1
1180 );
1181 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1182 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1183 cell!(
1184 ui.label(
1185 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1186 if o.direction == Some(Direction::SHORT) {
1187 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1188 } else {
1189 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1190 },
1191 ),
1192 ),
1193 4
1194 );
1195 cell!(ui.label(o.offset.as_str().to_string()), 5);
1196 cell!(ui.label(format!("{:.1}", o.price)), 6);
1197 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1198 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1199 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1200 cell!(
1201 ui.label(egui::RichText::new(o.status.as_str()).color(
1202 if o.status == ek_data::constant::Status::REJECTED {
1203 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1204 } else if o.status == ek_data::constant::Status::ALLTRADED {
1205 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1206 } else {
1207 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1208 },
1209 )),
1210 10
1211 );
1212 cell!(ui.label(o.status_msg.clone()), 11);
1213 cell!(ui.label(fmt_dt(o.datetime)), 12);
1214 cell!(ui.label(o.orderid.clone()), 13);
1215 cell!(
1216 ui.label(if o.reference.is_empty() {
1217 "-".to_string()
1218 } else {
1219 o.reference.clone()
1220 }),
1221 14
1222 );
1223 cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1224 if dbl && order_active(o.status) {
1225 let gw = if !o.gateway_name.is_empty() {
1227 o.gateway_name.clone()
1228 } else {
1229 shared.default_gateway().unwrap_or_default()
1230 };
1231 if !gw.is_empty() {
1232 shared.push_cmd(UiCmd::CancelOrder {
1233 orderid: o.orderid.clone(),
1234 gateway: gw,
1235 });
1236 }
1237 }
1238 let hr = ui.horizontal(|ui| {
1239 if order_active(o.status)
1240 && ui
1241 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1242 .clicked()
1243 {
1244 let gw = if !o.gateway_name.is_empty() {
1247 o.gateway_name.clone()
1248 } else {
1249 shared.default_gateway().unwrap_or_default()
1250 };
1251 if gw.is_empty() {
1252 shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1253 } else {
1254 shared.push_cmd(UiCmd::CancelOrder {
1255 orderid: o.orderid.clone(),
1256 gateway: gw,
1257 });
1258 }
1259 }
1260 });
1261 cells.push(hr.response.rect);
1262 paint_row_bg(ui, &cells, row_sel);
1263 ui.end_row();
1264 }
1265 },
1266 );
1267}
1268
1269fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1271 ui.label("成交");
1272 let ts = shared.trade_snap();
1273 let known_gws = account_gateways(shared);
1275 const TRADE_HEADERS: [&str; 12] = [
1277 "成交号",
1278 "代码",
1279 "名称",
1280 "交易所",
1281 "方向",
1282 "开平",
1283 "价格",
1284 "数量",
1285 "成交额",
1286 "时间",
1287 "委托号",
1288 "接口",
1289 ];
1290 if ts.trades.is_empty() {
1291 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1293 ui.label("(无成交)");
1294 });
1295 return;
1296 }
1297 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1298 for t in &ts.trades {
1299 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1302 let row_sel = shared.row_selected("trades", &key);
1303 let mut cells = Vec::with_capacity(12);
1304 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1305 if sel.clicked() {
1306 shared.select_row("trades", &key);
1307 }
1308 cells.push(sel.rect);
1309 macro_rules! cell {
1310 ($base:expr, $col:expr) => {{
1311 let base = $base;
1312 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1313 if r.clicked() {
1314 shared.select_row("trades", &key);
1315 }
1316 cells.push(base.rect);
1317 }};
1318 }
1319 cell!(ui.label(t.symbol.clone()), 1);
1320 cell!(
1321 ui.label(if t.name.is_empty() {
1322 "-".to_string()
1323 } else {
1324 t.name.clone()
1325 }),
1326 2
1327 );
1328 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1329 cell!(
1330 ui.label(
1331 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1332 if t.direction == Some(Direction::SHORT) {
1333 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1334 } else {
1335 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1336 },
1337 ),
1338 ),
1339 4
1340 );
1341 cell!(ui.label(t.offset.as_str().to_string()), 5);
1342 cell!(ui.label(format!("{:.1}", t.price)), 6);
1343 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1344 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1345 cell!(ui.label(fmt_dt(t.datetime)), 9);
1346 cell!(ui.label(t.orderid.clone()), 10);
1347 cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1348 paint_row_bg(ui, &cells, row_sel);
1349 ui.end_row();
1350 }
1351 });
1352}
1353
1354fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1357 ui.label("持仓");
1358 let ts = shared.trade_snap();
1359 let known_gws = account_gateways(shared);
1361 const POS_HEADERS: [&str; 16] = [
1363 "代码",
1364 "名称",
1365 "交易所",
1366 "方向",
1367 "数量",
1368 "昨仓",
1369 "可用",
1370 "冻结",
1371 "在途",
1372 "市值",
1373 "成本",
1374 "均价",
1375 "最新价",
1376 "盈亏",
1377 "盈亏比",
1378 "接口",
1379 ];
1380 if ts.positions.is_empty() {
1381 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1383 ui.label("(无持仓)");
1384 });
1385 return;
1386 }
1387 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1388 for p in &ts.positions {
1389 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1393 let row_sel = shared.row_selected("positions", &key);
1394 let mut cells = Vec::with_capacity(16);
1395 let mut dbl = false;
1396 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1397 if sel.clicked() {
1398 shared.select_row("positions", &key);
1399 }
1400 dbl |= sel.double_clicked();
1401 cells.push(sel.rect);
1402 macro_rules! cell {
1403 ($base:expr, $col:expr) => {{
1404 let base = $base;
1405 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1406 if r.clicked() {
1407 shared.select_row("positions", &key);
1408 }
1409 dbl |= r.double_clicked();
1410 cells.push(base.rect);
1411 }};
1412 }
1413 cell!(
1414 ui.label(if p.name.is_empty() {
1415 "-".to_string()
1416 } else {
1417 p.name.clone()
1418 }),
1419 1
1420 );
1421 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1422 cell!(
1423 ui.label(egui::RichText::new(p.direction.as_str()).color(
1424 if p.direction == ek_data::constant::Direction::SHORT {
1425 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1426 } else {
1427 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1428 },
1429 )),
1430 3
1431 );
1432 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1433 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1434 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1435 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1436 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1437 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1438 cell!(ui.label(format!("{:.2}", p.price)), 10);
1439 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1440 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1441 cell!(
1442 ui.label(
1443 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1444 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1445 } else {
1446 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1447 }),
1448 ),
1449 13
1450 );
1451 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1452 cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1453 if dbl {
1454 let mut f = shared.order_form();
1455 f.symbol = p.symbol.clone();
1456 f.exchange = p.exchange.as_str().to_string();
1457 f.direction = p.direction;
1458 if !p.gateway_name.is_empty() {
1460 f.gateway = p.gateway_name.clone();
1461 }
1462 shared.set_order_form(f);
1463 shared.log_line(format!(
1464 "[ops] 持仓带入下单面板 {}(接口 {})",
1465 p.symbol, p.gateway_name
1466 ));
1467 }
1468 paint_row_bg(ui, &cells, row_sel);
1469 ui.end_row();
1470 }
1471 });
1472}
1473
1474fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1475 let tab = shared.bottomright_tab();
1476 ui.horizontal(|ui| {
1477 if ui
1478 .selectable_label(
1479 tab == BottomRightTab::Account,
1480 BottomRightTab::Account.label(),
1481 )
1482 .clicked()
1483 {
1484 shared.set_bottomright_tab(BottomRightTab::Account);
1485 }
1486 if ui
1487 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1488 .clicked()
1489 {
1490 shared.set_bottomright_tab(BottomRightTab::Logs);
1491 }
1492 });
1493 match tab {
1494 BottomRightTab::Account => {
1495 let ts = shared.trade_snap();
1496 if ts.accounts.is_empty() {
1497 ui.label("(无资金:等网关 eAccount.)");
1498 return;
1499 }
1500 egui::Grid::new("acct_grid")
1501 .striped(true)
1502 .num_columns(5)
1503 .show(ui, |ui| {
1504 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1505 ui.strong(h);
1506 }
1507 ui.end_row();
1508 for a in &ts.accounts {
1509 ui.label(a.accountid.clone());
1510 ui.label(a.gateway_name.clone());
1511 ui.label(format!("{:.2}", a.balance));
1512 ui.label(format!("{:.2}", a.available()));
1513 ui.label(format!("{:.2}", a.frozen));
1514 ui.end_row();
1515 }
1516 });
1517 }
1518 BottomRightTab::Logs => {
1519 render_logs(ui, shared);
1520 }
1521 }
1522}
1523
1524fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1525 ui.horizontal(|ui| {
1526 if ui.button(BTN_CLEAR_LOGS).clicked() {
1527 shared.clear_logs(); }
1529 });
1530 let (_, spilled) = shared.logs_snapshot();
1531 if spilled > 0 {
1532 ui.label(format!(
1537 "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
1538 crate::state::RING_CAP
1539 ));
1540 }
1541 let lines = shared.logs_snapshot().0;
1542 egui::ScrollArea::vertical()
1543 .id_salt("log_scroll")
1544 .max_height(180.0)
1545 .stick_to_bottom(true)
1546 .show(ui, |ui| {
1547 for line in lines {
1548 ui.monospace(line);
1549 }
1550 });
1551}
1552
1553struct WinGeom {
1557 inner: [f32; 2],
1558 min_inner: [f32; 2],
1559 maximized: bool,
1560}
1561
1562fn show_independent_window(
1577 ctx: &egui::Context,
1578 id_salt: impl std::hash::Hash,
1579 title: &str,
1580 geom: &WinGeom,
1581 embedded: impl Fn(&egui::Context) -> bool,
1582 body: impl Fn(&mut egui::Ui) -> bool,
1583) -> bool {
1584 let closed = std::cell::Cell::new(false);
1585 let mut builder = egui::ViewportBuilder::default()
1586 .with_title(title)
1587 .with_inner_size(geom.inner)
1588 .with_min_inner_size(geom.min_inner)
1589 .with_minimize_button(true)
1590 .with_maximize_button(true)
1591 .with_close_button(true);
1592 if geom.maximized {
1593 builder = builder.with_maximized(true);
1594 }
1595 ctx.show_viewport_immediate(
1596 egui::ViewportId::from_hash_of(id_salt),
1597 builder,
1598 |ctx2, class| {
1599 if class == egui::ViewportClass::Embedded {
1600 closed.set(embedded(ctx2));
1601 return;
1602 }
1603 if ctx2.input(|i| i.viewport().close_requested()) {
1604 closed.set(true);
1605 return;
1606 }
1607 egui::CentralPanel::default().show(ctx2, |ui| {
1608 if body(ui) {
1609 closed.set(true);
1610 }
1611 });
1612 },
1613 );
1614 closed.get()
1615}
1616
1617fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1618 if shared.ops_window_open() {
1620 let closed = show_independent_window(
1621 ctx,
1622 "ops_win",
1623 "运维窗",
1624 &WinGeom {
1625 inner: [680.0, 420.0],
1626 min_inner: [420.0, 260.0],
1627 maximized: false,
1628 },
1629 |ctx2| {
1630 let mut open = shared.ops_window_open();
1631 egui::Window::new("运维窗")
1632 .id(egui::Id::new("ops_win"))
1633 .default_pos([80.0, 320.0])
1634 .default_size([640.0, 380.0])
1635 .open(&mut open)
1636 .show(ctx2, |ui| {
1637 render_ops_window(ui, shared);
1638 });
1639 shared.set_ops_window_open(open);
1640 !open
1641 },
1642 |ui| {
1643 render_ops_window(ui, shared);
1644 false
1645 },
1646 );
1647 if closed {
1648 shared.set_ops_window_open(false);
1649 }
1650 }
1651
1652 if shared.accounts_window_open() {
1655 let closed = show_independent_window(
1656 ctx,
1657 "account_win",
1658 "账户面板",
1659 &WinGeom {
1660 inner: [760.0, 520.0],
1661 min_inner: [520.0, 320.0],
1662 maximized: false,
1663 },
1664 |ctx2| {
1665 let mut open = shared.accounts_window_open();
1666 egui::Window::new("账户面板")
1667 .id(egui::Id::new("account_win"))
1668 .default_pos([640.0, 320.0])
1669 .default_size([720.0, 470.0])
1670 .open(&mut open)
1671 .show(ctx2, |ui| {
1672 render_accounts_window(ui, shared);
1673 });
1674 shared.set_accounts_window_open(open);
1675 !open
1676 },
1677 |ui| {
1678 render_accounts_window(ui, shared);
1679 false
1680 },
1681 );
1682 if closed {
1683 shared.set_accounts_window_open(false);
1684 }
1685 }
1686
1687 if shared.account_edit_open() {
1691 let closed = show_independent_window(
1692 ctx,
1693 "account_edit_win",
1694 "账户配置",
1695 &WinGeom {
1696 inner: [680.0, 340.0],
1697 min_inner: [480.0, 240.0],
1698 maximized: false,
1699 },
1700 |ctx2| {
1701 let mut open = shared.account_edit_open();
1702 let title = match shared.editing_account() {
1703 Some(gw) => format!("账户配置 — 编辑 {gw}"),
1704 None => "账户配置 — 添加".to_string(),
1705 };
1706 egui::Window::new(title)
1707 .id(egui::Id::new("account_edit_win"))
1708 .default_pos([700.0, 380.0])
1709 .default_size([660.0, 320.0])
1710 .open(&mut open)
1711 .show(ctx2, |ui| {
1712 render_account_edit_window(ui, shared);
1713 });
1714 if !open {
1715 shared.end_account_edit();
1716 }
1717 !open
1718 },
1719 |ui| {
1720 render_account_edit_window(ui, shared);
1721 false
1722 },
1723 );
1724 if closed && shared.account_edit_open() {
1725 shared.end_account_edit();
1726 }
1727 }
1728
1729 if shared.contracts_window_open() {
1731 let closed = show_independent_window(
1732 ctx,
1733 "contracts_win",
1734 "查询合约窗",
1735 &WinGeom {
1736 inner: [620.0, 460.0],
1737 min_inner: [420.0, 260.0],
1738 maximized: false,
1739 },
1740 |ctx2| {
1741 let mut open = shared.contracts_window_open();
1742 egui::Window::new("查询合约窗")
1743 .id(egui::Id::new("contracts_win"))
1744 .default_pos([420.0, 300.0])
1745 .default_size([560.0, 420.0])
1746 .open(&mut open)
1747 .show(ctx2, |ui| {
1748 render_contracts_window(ui, shared);
1749 });
1750 shared.set_contracts_window_open(open);
1751 !open
1752 },
1753 |ui| {
1754 render_contracts_window(ui, shared);
1755 false
1756 },
1757 );
1758 if closed {
1759 shared.set_contracts_window_open(false);
1760 }
1761 }
1762
1763 let mut closed: Vec<String> = Vec::new();
1767 for sym in shared.kline_symbols() {
1768 let is_closed = show_independent_window(
1769 ctx,
1770 format!("kline_win:{sym}"),
1771 &format!("K线 — {sym}"),
1772 &WinGeom {
1773 inner: [560.0, 360.0],
1774 min_inner: [360.0, 220.0],
1775 maximized: true,
1776 },
1777 |ctx2| {
1778 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1779 let resp = egui::Window::new(format!("K线 — {sym}"))
1780 .id(egui::Id::new(("kline_win", sym.clone())))
1781 .default_pos([300.0, 340.0])
1782 .default_size([540.0, 330.0])
1783 .show(ctx2, |ui| {
1784 let cell = shared
1785 .market()
1786 .cells()
1787 .into_iter()
1788 .find(|c| c.symbol == sym);
1789 render_kline_cell(ui, cell.as_ref());
1790 });
1791 esc || resp.is_none()
1792 },
1793 |ui| {
1794 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1795 return true;
1796 }
1797 let cell = shared
1798 .market()
1799 .cells()
1800 .into_iter()
1801 .find(|c| c.symbol == sym);
1802 render_kline_cell(ui, cell.as_ref());
1803 false
1804 },
1805 );
1806 if is_closed {
1807 closed.push(sym);
1808 }
1809 }
1810 for s in closed {
1811 shared.close_kline(&s);
1812 }
1813}
1814
1815fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1818 ui.label("插件");
1819 let snap = shared.snapshot();
1820 let account_gateways: std::collections::HashSet<String> = shared
1822 .accounts()
1823 .iter()
1824 .map(|a| a.gateway.clone())
1825 .collect();
1826 egui::Grid::new("plug_grid")
1827 .striped(true)
1828 .num_columns(6)
1829 .show(ui, |ui| {
1830 ui.strong("名称");
1831 ui.strong("状态");
1832 ui.strong("版本");
1833 ui.strong("依赖");
1834 ui.strong("统计");
1835 ui.strong("操作");
1836 ui.end_row();
1837 if snap.rows.is_empty() {
1838 ui.label("(快照空:等 eTimer 首拉)");
1839 ui.end_row();
1840 }
1841 for r in &snap.rows {
1842 if is_quarantined(&r.state) {
1843 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1844 ui.label(
1845 egui::RichText::new(state_text(r))
1846 .color(egui::Color32::RED)
1847 .strong(),
1848 );
1849 } else {
1850 ui.label(r.name.clone());
1851 ui.label(state_text(r));
1852 }
1853 ui.label(r.version.clone());
1854 ui.label(format!("deps:[{}]", r.deps.join(",")));
1855 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1856 ui.horizontal(|ui| {
1857 if account_gateways.contains(&r.name) {
1863 ui.label(
1864 egui::RichText::new("起停归『账户』面板(带撤单守卫)")
1865 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
1866 );
1867 } else {
1868 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1869 shared.push_cmd(UiCmd::Start {
1873 factory: r.name.clone(),
1874 name: String::new(),
1875 });
1876 }
1877 let armed = shared.pending_confirm();
1878 let armed_here = ui
1879 .small_button(remove_button_label(&r.name, armed.as_deref()))
1880 .clicked()
1881 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1882 if armed_here {
1883 shared.push_cmd(UiCmd::Remove {
1884 name: r.name.clone(),
1885 });
1886 }
1887 }
1888 });
1889 ui.end_row();
1890 }
1891 });
1892
1893 if let Some(name) = shared.pending_confirm() {
1894 ui.horizontal(|ui| {
1895 ui.label(
1896 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1897 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1898 );
1899 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1900 shared.confirm_cancel();
1901 }
1902 });
1903 }
1904
1905 ui.label("服务");
1906 let flat: Vec<String> = snap
1907 .services
1908 .iter()
1909 .flat_map(|(n, ids)| {
1910 if ids.is_empty() {
1911 vec![format!("{n} 服务"), "-".to_string()]
1912 } else {
1913 ids.iter()
1914 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1915 .collect()
1916 }
1917 })
1918 .collect();
1919 if flat.is_empty() {
1920 ui.label("(无服务目录)");
1921 } else {
1922 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1924 .striped(true)
1925 .num_columns(6)
1926 .show(ui, |ui| {
1927 for r in 0..rows {
1928 for c in 0..3 {
1929 let i = c * rows * 2 + r * 2;
1930 if i < flat.len() {
1931 ui.label(flat[i].clone());
1932 ui.label(flat[i + 1].clone());
1933 } else {
1934 ui.label("");
1935 ui.label("");
1936 }
1937 }
1938 ui.end_row();
1939 }
1940 });
1941 }
1942
1943 ui.label("依赖");
1944 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1945 ui.label("(全部无依赖)");
1946 }
1947 for r in &snap.rows {
1948 if !r.deps.is_empty() {
1949 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1950 }
1951 }
1952}
1953
1954fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
1960 let accounts = shared.accounts();
1961 let locked = shared.accounts_locked();
1962 let md = shared.md_source();
1963 let snap = shared.trade_snap();
1964
1965 let row_label: std::collections::BTreeMap<String, String> = accounts
1969 .iter()
1970 .map(|a| {
1971 let d = a.display_name();
1972 let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
1973 (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
1974 })
1975 .collect();
1976
1977 ui.horizontal(|ui| {
1979 ui.label(
1980 egui::RichText::new(format!(
1981 "当前行情源 = {}",
1982 if md.is_empty() {
1983 "(未指定)".to_string()
1984 } else {
1985 md.clone()
1986 }
1987 ))
1988 .strong(),
1989 );
1990 ui.label(format!(
1991 "账户 {}/{}",
1992 accounts.len(),
1993 crate::state::MAX_ACCOUNTS
1994 ));
1995 if shared.can_add_account() {
1996 if ui.button(BTN_ACCOUNT_ADD).clicked() {
1997 shared.begin_account_edit(None);
1998 }
1999 } else {
2000 ui.label(
2001 egui::RichText::new(format!(
2002 "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
2003 crate::state::MAX_ACCOUNTS
2004 ))
2005 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2006 );
2007 }
2008 });
2009
2010 const ACCOUNT_HEADERS: [&str; 7] = [
2011 "网关(派生名)",
2012 "别名",
2013 "账号",
2014 "状态",
2015 "行情源",
2016 "收录(勾→实)",
2017 "操作",
2018 ];
2019 egui::Grid::new("account_grid")
2024 .striped(true)
2025 .num_columns(7)
2026 .show(ui, |ui| {
2027 for h in &ACCOUNT_HEADERS {
2028 ui.strong(*h);
2029 }
2030 ui.end_row();
2031 if accounts.is_empty() {
2032 ui.label("(无账户:上方『添加账户』开配置弹窗)");
2033 for _ in 0..6 {
2034 ui.label("");
2035 }
2036 ui.end_row();
2037 }
2038 for a in &accounts {
2039 let is_md = a.gateway == md;
2040 let name = row_label
2041 .get(&a.gateway)
2042 .cloned()
2043 .unwrap_or_else(|| a.gateway.clone());
2044 ui.label(a.gateway.clone());
2045 ui.label(if a.alias.trim().is_empty() {
2047 "—".to_string()
2048 } else {
2049 a.alias.clone()
2050 });
2051 ui.label(a.account.clone());
2052 match a.stage {
2058 crate::state::AccountStage::AssembleFailed => {
2059 ui.add(
2060 egui::Label::new(
2061 egui::RichText::new(format!(
2062 "装配失败:{}",
2063 a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
2064 ))
2065 .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
2066 )
2067 .wrap_mode(egui::TextWrapMode::Extend),
2068 );
2069 }
2070 _ => {
2071 let color = match a.login {
2072 crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
2073 crate::state::LoginState::LoginFailed
2074 | crate::state::LoginState::FrontDown => {
2075 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2076 }
2077 crate::state::LoginState::QueryStarved => {
2078 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
2079 }
2080 _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
2082 };
2083 ui.add(
2084 egui::Label::new(
2085 egui::RichText::new(a.status_label()).color(color),
2086 )
2087 .wrap_mode(egui::TextWrapMode::Extend),
2088 );
2089 }
2090 }
2091 ui.horizontal(|ui| {
2093 if is_md {
2094 ui.label(egui::RichText::new("● 行情源").strong());
2095 }
2096 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
2097 ui.small_button(if is_md {
2098 "已是行情源"
2099 } else {
2100 "设为行情源"
2101 })
2102 });
2103 if resp.inner.clicked() {
2104 match shared.set_md_source(&a.gateway) {
2105 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
2106 Err(e) => shared.log_line(format!("[账户] {e}")),
2107 }
2108 }
2109 });
2110 ui.vertical(|ui| {
2113 ui.add(
2114 egui::Label::new(egui::RichText::new(format!(
2115 "勾:{}",
2116 classes_label(&a.form.product_classes())
2117 )))
2118 .wrap_mode(egui::TextWrapMode::Extend),
2119 );
2120 ui.add(
2121 egui::Label::new(egui::RichText::new(format!(
2122 "实:{}",
2123 if a.effective_classes.is_empty() {
2124 "—".to_string()
2125 } else {
2126 classes_label(&a.effective_classes)
2127 }
2128 )))
2129 .wrap_mode(egui::TextWrapMode::Extend),
2130 );
2131 });
2132 ui.horizontal(|ui| {
2134 let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2135 let idle = a.stage == crate::state::AccountStage::NotAssembled;
2136 let stopped = a.login == crate::state::LoginState::Stopped;
2139 if failed || idle || stopped {
2140 let label = if failed {
2143 format!("重试 {name}")
2144 } else {
2145 format!("连接 {name}")
2146 };
2147 if ui.small_button(label).clicked() {
2148 push_connect(shared, &a.gateway, &a.form);
2149 }
2150 }
2151 let open = snap
2153 .orders
2154 .iter()
2155 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2156 .count();
2157 if a.stage == crate::state::AccountStage::Assembled
2164 && a.login != crate::state::LoginState::Stopped
2165 && open == 0
2166 && ui.small_button(format!("断开 {name}")).clicked()
2167 {
2168 shared.push_cmd(UiCmd::Disconnect {
2169 gateway: a.gateway.clone(),
2170 });
2171 shared.log_line(format!(
2172 "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2173 gateway = a.gateway
2174 ));
2175 }
2176 if ui.small_button(format!("编辑 {name}")).clicked() {
2177 shared.begin_account_edit(Some(&a.gateway));
2180 }
2181 let has_pos = snap
2182 .positions
2183 .iter()
2184 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2185 if open > 0 {
2186 ui.label(
2188 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2189 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2190 );
2191 if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2195 for o in snap
2196 .orders
2197 .iter()
2198 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2199 {
2200 shared.push_cmd(UiCmd::CancelOrder {
2201 orderid: o.orderid.clone(),
2202 gateway: a.gateway.clone(),
2203 });
2204 }
2205 shared.log_line(format!(
2206 "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2207 a.gateway, open
2208 ));
2209 }
2210 } else {
2211 let armed = shared.pending_confirm();
2214 let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2215 let fired = ui
2216 .small_button(remove_button_label(
2217 &name,
2218 if armed_here { Some(name.as_str()) } else { None },
2219 ))
2220 .clicked()
2221 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2222 if fired {
2223 shared.push_cmd(UiCmd::Remove {
2224 name: a.gateway.clone(),
2225 });
2226 shared.remove_account(&a.gateway);
2227 shared.end_account_edit_if_editing(&a.gateway);
2230 if has_pos {
2231 shared.log_line(format!(
2232 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2233 a.gateway
2234 ));
2235 }
2236 }
2237 }
2238 });
2239 ui.end_row();
2240 }
2241 });
2242
2243 if let Some(gw) = shared.pending_confirm() {
2244 let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2246 ui.horizontal(|ui| {
2247 ui.label(
2248 egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2249 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2250 );
2251 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2252 shared.confirm_cancel();
2253 }
2254 });
2255 }
2256
2257 ui.label(if locked {
2258 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2259 } else {
2260 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2261 });
2262 ui.label(
2264 egui::RichText::new(format!(
2265 "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2266 crate::state::STATUS_POLL_TICKS
2267 ))
2268 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2269 );
2270 ui.label(
2275 egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
2276 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2277 );
2278 for a in accounts
2287 .iter()
2288 .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2289 {
2290 let note = a.status_note.clone().unwrap_or_default();
2291 let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2292 let color = if alarm {
2293 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2294 } else {
2295 egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
2296 };
2297 ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2298 }
2299 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2301 if src.form.md_addr.trim().is_empty() {
2302 ui.label(
2303 egui::RichText::new(format!(
2304 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2305 src.gateway,
2306 row_label
2307 .get(&src.gateway)
2308 .cloned()
2309 .unwrap_or_else(|| src.account.clone())
2310 ))
2311 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2312 );
2313 }
2314 }
2315}
2316
2317fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2323 let editing: Option<crate::state::AccountRow> =
2324 shared.editing_account().and_then(|g| shared.account(&g));
2325 let editing_on = editing.is_some();
2326 let md = shared.md_source();
2327 let locked = shared.accounts_locked();
2328 let mut form: crate::state::CtpForm = shared.ctp_form();
2329 let mut alias = shared.edit_alias();
2330 if let Some(e) = shared.edit_error() {
2332 ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2333 }
2334 ui.horizontal(|ui| {
2340 ui.label("别名");
2341 if ui
2342 .add(
2343 egui::TextEdit::singleline(&mut alias)
2344 .hint_text("显示用,可空")
2345 .desired_width(120.0),
2346 )
2347 .changed()
2348 {
2349 shared.set_edit_alias(alias.clone());
2350 }
2351 ui.label("broker");
2352 ui.add_enabled_ui(!editing_on, |ui| {
2353 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2354 });
2355 ui.label("td 前置");
2356 ctp_edit(
2357 ui,
2358 shared,
2359 &mut form.td_addr,
2360 "tcp://host:40001",
2361 CtpField::TdAddr,
2362 );
2363 ui.label("td 用户");
2364 ui.add_enabled_ui(!editing_on, |ui| {
2365 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2366 });
2367 });
2368 if let Some(r) = &editing {
2369 ui.label(
2372 egui::RichText::new(format!(
2373 "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2374 r.gateway
2375 ))
2376 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2377 );
2378 }
2379 ui.horizontal(|ui| {
2380 ui.label("td 密码");
2381 ctp_edit(
2382 ui,
2383 shared,
2384 &mut form.td_password,
2385 "密码",
2386 CtpField::TdPassword,
2387 );
2388 ui.label("AppID");
2389 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2390 ui.label("AuthCode");
2391 ctp_edit(
2392 ui,
2393 shared,
2394 &mut form.td_auth_code,
2395 "授权码",
2396 CtpField::AuthCode,
2397 );
2398 });
2399 let md_editable = match &editing {
2402 None => true,
2403 Some(r) => !locked || r.gateway == md,
2404 };
2405 ui.horizontal(|ui| {
2406 ui.label("md 前置");
2407 ui.add_enabled_ui(md_editable, |ui| {
2408 ctp_edit(
2409 ui,
2410 shared,
2411 &mut form.md_addr,
2412 "tcp://host:40011",
2413 CtpField::MdAddr,
2414 );
2415 ui.label("md 用户");
2416 ctp_edit(
2417 ui,
2418 shared,
2419 &mut form.md_user,
2420 "空=td 回落",
2421 CtpField::MdUser,
2422 );
2423 ui.label("md 密码");
2424 ctp_edit(
2425 ui,
2426 shared,
2427 &mut form.md_password,
2428 "空=td 回落",
2429 CtpField::MdPassword,
2430 );
2431 });
2432 if !md_editable {
2433 ui.label(
2434 egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2435 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2436 );
2437 }
2438 });
2439 ui.horizontal(|ui| {
2442 ui.label("收录勾选:");
2443 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2444 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2445 if fut || opt {
2446 shared.set_ctp_form(form.clone());
2447 }
2448 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2449 });
2450 ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
2453 if let Some(r) = &editing {
2456 ui.horizontal(|ui| {
2457 ui.label("网关实际收录(只读回带):");
2458 ui.label(if r.effective_classes.is_empty() {
2459 "—(尚未装配或无回带)".to_string()
2460 } else {
2461 classes_label(&r.effective_classes)
2462 });
2463 ui.label("装配/登录状态(只读):");
2464 ui.label(format!(
2465 "{}{}",
2466 r.status_label(),
2467 r.fail_note
2468 .as_ref()
2469 .map(|n| format!(" —— {n}"))
2470 .unwrap_or_default()
2471 ));
2472 });
2473 }
2474 if shared.account_edit_dirty() {
2477 ui.label(
2478 egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2479 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2480 );
2481 }
2482 ui.horizontal(|ui| {
2483 if ui.button("保存").clicked() {
2484 match save_account_draft(shared, &form, &alias, false) {
2485 Ok(()) => shared.end_account_edit(),
2486 Err(e) => {
2487 shared.log_line(format!("[账户] 保存被拦:{e}"));
2488 shared.set_edit_error(Some(e));
2489 }
2490 }
2491 }
2492 if ui.button("保存并连接").clicked() {
2493 match save_account_draft(shared, &form, &alias, true) {
2494 Ok(()) => shared.end_account_edit(),
2495 Err(e) => {
2496 shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2497 shared.set_edit_error(Some(e));
2498 }
2499 }
2500 }
2501 if ui.button("取消").clicked() {
2502 shared.end_account_edit();
2503 }
2504 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2505 });
2506}
2507
2508fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2510 shared
2511 .accounts()
2512 .iter()
2513 .map(|a| a.gateway.clone())
2514 .collect()
2515}
2516
2517fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2525 if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2526 gateway.to_string()
2527 } else {
2528 format!("{gateway}(已摘除)")
2529 }
2530}
2531
2532fn classes_label(classes: &[String]) -> String {
2534 if classes.is_empty() {
2535 return "—".to_string();
2536 }
2537 classes
2538 .iter()
2539 .map(|c| match c.as_str() {
2540 "1" => "期货",
2541 "2" => "期权",
2542 other => other,
2543 })
2544 .collect::<Vec<_>>()
2545 .join("+")
2546}
2547
2548fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2553 shared.push_cmd(UiCmd::Connect {
2554 gateway: gateway.to_string(),
2555 form: form.clone(),
2556 });
2557 shared.note_connect_sent(gateway);
2558 shared.log_line(format!(
2559 "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2560 gateway
2561 ));
2562}
2563
2564fn save_account_draft(
2571 shared: &UiShared,
2572 form: &crate::state::CtpForm,
2573 alias: &str,
2574 connect: bool,
2575) -> Result<(), String> {
2576 form.validate()?;
2577 if let Some(gw) = shared.editing_account() {
2578 if shared.account(&gw).is_none() {
2580 return Err(format!(
2581 "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2582 ));
2583 }
2584 shared.update_account_form(&gw, form.clone());
2585 if !shared.set_account_alias(&gw, alias) {
2586 return Err(format!(
2587 "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2588 ));
2589 }
2590 shared.log_line(format!(
2591 "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2592 ));
2593 if connect {
2594 push_connect(shared, &gw, form);
2595 }
2596 return Ok(());
2597 }
2598 let mut row = crate::state::AccountRow::derive(form)?;
2599 row.alias = alias.trim().to_string();
2600 let gateway = row.gateway.clone();
2601 let account = row.account.clone();
2602 shared.add_account(row.clone())?;
2603 let md = shared.md_source();
2607 let mut f = row.form.clone();
2608 if md.is_empty() && !shared.accounts_locked() {
2609 match shared.set_md_source(&gateway) {
2610 Ok(()) => {
2611 if f.md_addr.trim().is_empty() {
2612 shared.log_line(format!(
2613 "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2614 ));
2615 } else {
2616 shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2617 }
2618 }
2619 Err(e) => {
2620 shared.log_line(format!("[账户] {e}"));
2621 f.md_addr.clear();
2622 f.md_user.clear();
2623 f.md_password.clear();
2624 }
2625 }
2626 } else {
2627 f.md_addr.clear();
2628 f.md_user.clear();
2629 f.md_password.clear();
2630 if md.is_empty() {
2631 shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2632 }
2633 }
2634 shared.update_account_form(&gateway, f.clone());
2635 shared.log_line(format!(
2637 "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2638 ));
2639 if connect {
2640 push_connect(shared, &gateway, &f);
2641 }
2642 Ok(())
2643}
2644
2645fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2649 let mut q = shared.contract_query();
2650 ui.horizontal(|ui| {
2651 ui.label("代码或交易所(留空 = 全部):");
2652 if ui
2653 .add(
2654 egui::TextEdit::singleline(&mut q)
2655 .hint_text("cu 或 SHFE")
2656 .desired_width(200.0),
2657 )
2658 .changed()
2659 {
2660 shared.set_contract_query(q.clone());
2661 }
2662 });
2663 let rows = shared.contracts(&q);
2664 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2667 if rows.is_empty() {
2668 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2670 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2671 });
2672 return;
2673 }
2674 let selected = shared.selected_contracts();
2675 ui.horizontal(|ui| {
2680 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2681 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2682 if selected.is_empty() {
2683 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2684 } else {
2685 let symbols: Vec<String> = selected
2687 .iter()
2688 .map(|k| {
2689 k.split_once('.')
2690 .map(|(_, s)| s.to_string())
2691 .unwrap_or_else(|| k.clone())
2692 })
2693 .collect();
2694 match subscribe_target(shared) {
2695 Ok(gw) => {
2696 shared.push_cmd(UiCmd::CtpSubscribe {
2697 gateway: gw.clone(),
2698 symbols,
2699 });
2700 shared.log_line(format!(
2701 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2702 selected.len(),
2703 gw
2704 ));
2705 }
2706 Err(e) => shared.log_line(format!("[订阅] {e}")),
2707 }
2708 }
2709 }
2710 });
2711 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2712 for c in &rows {
2713 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2714 let mut on = selected.contains(&key);
2715 let row_sel = shared.row_selected("contracts", &key);
2719 let mut cells = Vec::with_capacity(5);
2720 let ck = ui.checkbox(&mut on, key.clone());
2721 if ck.changed() {
2722 shared.toggle_contract_selection(&key);
2723 shared.select_row("contracts", &key);
2724 }
2725 cells.push(ck.rect);
2726 macro_rules! cell {
2727 ($base:expr, $col:expr) => {{
2728 let base = $base;
2729 let r = click_cell(ui, base.rect, ("crt", &key, $col));
2730 if r.clicked() {
2731 shared.select_row("contracts", &key);
2732 }
2733 cells.push(base.rect);
2734 }};
2735 }
2736 cell!(ui.label(c.exchange.as_str().to_string()), 1);
2737 cell!(ui.label(c.name.clone()), 2);
2738 cell!(ui.label(format!("{:.0}", c.size)), 3);
2739 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2740 paint_row_bg(ui, &cells, row_sel);
2741 ui.end_row();
2742 }
2743 });
2744}
2745
2746enum CtpField {
2748 Broker,
2749 TdAddr,
2750 TdUser,
2751 TdPassword,
2752 AppId,
2753 AuthCode,
2754 MdAddr,
2755 MdUser,
2756 MdPassword,
2757}
2758
2759fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2761 let is_secret = matches!(
2762 field,
2763 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2764 );
2765 let mut w = egui::TextEdit::singleline(val)
2766 .hint_text(hint)
2767 .desired_width(210.0);
2770 if is_secret {
2771 w = w.password(true);
2772 }
2773 if ui.add(w).changed() {
2774 let mut f = shared.ctp_form();
2775 match field {
2776 CtpField::Broker => f.broker_id = val.clone(),
2777 CtpField::TdAddr => f.td_addr = val.clone(),
2778 CtpField::TdUser => f.td_user = val.clone(),
2779 CtpField::TdPassword => f.td_password = val.clone(),
2780 CtpField::AppId => f.td_app_id = val.clone(),
2781 CtpField::AuthCode => f.td_auth_code = val.clone(),
2782 CtpField::MdAddr => f.md_addr = val.clone(),
2783 CtpField::MdUser => f.md_user = val.clone(),
2784 CtpField::MdPassword => f.md_password = val.clone(),
2785 }
2786 shared.set_ctp_form(f);
2787 }
2788}
2789
2790fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2794 let Some(c) = cell else {
2795 ui.label("(K线无格:等 dm 合成)");
2796 return;
2797 };
2798 if c.bars.is_empty() {
2799 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2800 return;
2801 }
2802 let mut data = egui_charts::model::bar::BarData::new();
2806 for b in &c.bars {
2807 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2808 .with_timezone(&beijing_tz())
2809 .with_timezone(&chrono::Utc);
2810 data.push(egui_charts::model::bar::Bar::new(
2811 dt,
2812 b.open_price,
2813 b.high_price,
2814 b.low_price,
2815 b.close_price,
2816 b.volume,
2817 ));
2818 }
2819 ui.label(format!(
2820 "K线 {}@{}s({} 根)",
2821 c.symbol,
2822 c.interval,
2823 c.bars.len()
2824 ));
2825 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2826 chart.set_symbol(&c.symbol);
2827 chart.set_timeframe_label(&format!("{}s", c.interval));
2828 chart.enable_tracking_mode();
2829 let _ = chart.show(ui);
2830}
2831
2832#[cfg(test)]
2833mod tests {
2834 use super::*;
2835
2836 #[test]
2840 fn fmt_dt_shows_beijing_wall_clock() {
2841 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2842 ek_data::time::civil_to_nanos(ek_data::time::Civil {
2843 year: 2026,
2844 month: 4,
2845 day: 16,
2846 hour: 1,
2847 minute: 30,
2848 second: 1,
2849 nanos: 0,
2850 }),
2851 );
2852 let beijing = expect.with_timezone(&beijing_tz());
2854 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2855 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2858 year: 2026,
2859 month: 4,
2860 day: 15,
2861 hour: 16,
2862 minute: 0,
2863 second: 30,
2864 nanos: 0,
2865 });
2866 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2867 assert_eq!(fmt_dt(None), "-");
2869 }
2870}