1use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, 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 paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
119 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
120 return;
121 };
122 let hovered = ui
123 .input(|i| i.pointer.interact_pos())
124 .is_some_and(|p| union.expand(3.0).contains(p));
125 if selected {
126 ui.painter().rect_filled(
127 union.expand(3.0),
128 3.0,
129 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
130 );
131 } else if hovered {
132 ui.painter().rect_filled(
133 union.expand(3.0),
134 3.0,
135 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
136 );
137 }
138}
139
140fn order_active(status: ek_data::constant::Status) -> bool {
142 ek_data::constant::ACTIVE_STATUSES.contains(&status)
143}
144
145fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
148 ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
149}
150
151pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
155 let mut f = shared.order_form();
156 f.symbol = t.symbol.clone();
157 f.exchange = exchange.to_string();
158 match col {
159 8 => {
160 f.direction = Direction::SHORT;
161 f.price = format!("{:.1}", t.bid_price_1);
162 }
163 9 => f.direction = Direction::SHORT,
164 10 => {
165 f.direction = Direction::LONG;
166 f.price = format!("{:.1}", t.ask_price_1);
167 }
168 11 => f.direction = Direction::LONG,
169 _ => {}
170 }
171 shared.set_order_form(f);
172}
173
174fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
177 if symbol.is_empty() {
178 return None;
179 }
180 shared.tick_rows().into_iter().find(|t| {
181 t.symbol == symbol
182 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
183 })
184}
185
186pub fn apply_ui_style(ctx: &egui::Context) {
190 apply_ui_style_for(ctx, crate::state::Theme::default());
191}
192
193pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
198 ctx.style_mut(|style| {
199 style
200 .text_styles
201 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
202 style
203 .text_styles
204 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
205 style
206 .text_styles
207 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
208 style
209 .text_styles
210 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
211 style
212 .text_styles
213 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
214 });
215 ctx.set_visuals(theme_visuals(theme));
216}
217
218fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
222 use egui::Color32;
223 match t {
224 crate::state::Theme::EguiDark => egui::Visuals::dark(),
225 crate::state::Theme::EguiLight => egui::Visuals::light(),
226 crate::state::Theme::QtClassic => {
227 let mut v = egui::Visuals::light();
228 v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
229 v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
230 v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
231 v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
232 v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
233 v
234 }
235 crate::state::Theme::FinanceDark => {
236 let mut v = egui::Visuals::dark();
237 v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
238 v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
239 v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
240 v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
241 v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
242 v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
243 v.widgets.noninteractive.bg_stroke =
244 egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
245 v
246 }
247 crate::state::Theme::QDarkStyle => {
267 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();
280 v.dark_mode = true;
281 v.window_fill = bg1;
282 v.panel_fill = bg1;
283 v.extreme_bg_color = bg1; v.faint_bg_color = bg2;
285 v.hyperlink_color = accent4;
286 v.weak_text_color = Some(text3);
287 v.window_stroke = egui::Stroke::new(1.0, bg4);
288 v.window_corner_radius = r4;
289 v.menu_corner_radius = r4;
290 let stroke1 = egui::Stroke::new(1.0, text1);
296 v.widgets.noninteractive = egui::style::WidgetVisuals {
299 bg_fill: bg1,
300 weak_bg_fill: bg1,
301 bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
303 fg_stroke: stroke1,
304 expansion: 0.0,
305 };
306 v.widgets.inactive = egui::style::WidgetVisuals {
307 bg_fill: bg4,
308 weak_bg_fill: bg4,
309 bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
311 fg_stroke: stroke1,
312 expansion: 0.0,
313 };
314 v.widgets.hovered = egui::style::WidgetVisuals {
315 bg_fill: bg5,
316 weak_bg_fill: bg5,
317 bg_stroke: egui::Stroke::new(1.0, bg5),
318 corner_radius: r4,
319 fg_stroke: stroke1,
320 expansion: 0.0,
321 };
322 v.widgets.active = egui::style::WidgetVisuals {
323 bg_fill: bg6,
324 weak_bg_fill: bg6,
325 bg_stroke: egui::Stroke::new(1.0, bg6),
326 corner_radius: r4,
327 fg_stroke: stroke1,
328 expansion: 0.0,
329 };
330 v.widgets.open = egui::style::WidgetVisuals {
331 bg_fill: bg6,
332 weak_bg_fill: bg6,
333 bg_stroke: egui::Stroke::new(1.0, bg4),
334 corner_radius: r4,
335 fg_stroke: stroke1,
336 expansion: 0.0,
337 };
338 v.selection = egui::style::Selection {
339 bg_fill: accent2,
340 stroke: egui::Stroke::new(1.0, text1), };
342 v.interact_cursor = Some(egui::CursorIcon::PointingHand);
344 v
345 }
346 }
347}
348
349#[derive(Clone, Copy)]
363struct Chrome {
364 line: egui::Color32,
366 header_text: egui::Color32,
368 tab_head_fill: egui::Color32,
370 tab_fill: egui::Color32,
372 tab_text: egui::Color32,
374 tab_underline: egui::Color32,
376}
377
378impl Chrome {
379 fn of(theme: crate::state::Theme, v: &egui::Visuals) -> Self {
381 let line = v.widgets.noninteractive.bg_stroke.color;
382 let tab_fill = match theme {
386 crate::state::Theme::QDarkStyle => v.widgets.hovered.bg_fill,
387 _ => v.selection.bg_fill,
388 };
389 Self {
390 line,
391 header_text: v.widgets.noninteractive.fg_stroke.color,
392 tab_head_fill: line,
393 tab_fill,
394 tab_text: v.widgets.hovered.fg_stroke.color,
395 tab_underline: v.hyperlink_color,
396 }
397 }
398}
399
400fn tab_label(
414 ui: &mut egui::Ui,
415 shared: &UiShared,
416 selected: bool,
417 text: impl Into<String>,
418) -> egui::Response {
419 let ch = Chrome::of(shared.theme(), ui.visuals());
420 let text: String = text.into();
421 let resp = ui.selectable_label(selected, text.clone());
422 let r = resp.rect;
423 let radius = ui.visuals().widgets.inactive.corner_radius;
424 let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
425 let p = ui.painter();
426 let (fill, ink, underline) = if selected {
427 (
428 ch.tab_fill,
429 ch.tab_text,
430 egui::Stroke::new(3.0, ch.tab_underline), )
432 } else {
433 (
434 ch.tab_head_fill,
435 ui.visuals().widgets.inactive.fg_stroke.color,
436 egui::Stroke::new(2.0, ch.line), )
438 };
439 p.rect_filled(r, radius, fill);
440 p.hline(r.x_range(), r.max.y, underline);
441 p.text(
442 r.center(),
443 egui::Align2::CENTER_CENTER,
444 text.as_str(),
445 font,
446 ink,
447 );
448 resp
449}
450
451fn header_band(ui: &mut egui::Ui, shared: &UiShared, heads: &[(egui::Rect, String)]) {
463 if heads.is_empty() {
464 return;
465 }
466 let ch = Chrome::of(shared.theme(), ui.visuals());
467 let left = ui.max_rect().min.x;
477 let right = ui.max_rect().max.x;
478 let top = heads[0].0.min.y;
479 let bottom = heads[0].0.max.y + ui.spacing().item_spacing.y;
480 let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
481 let p = ui.painter();
482 p.rect_filled(strip, 0.0, ch.line);
483 p.hline(
484 strip.x_range(),
485 strip.max.y,
486 egui::Stroke::new(1.0, ch.line),
487 );
488 let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
489 let mid_y = (top + heads[0].0.max.y) * 0.5;
490 for (rect, text) in heads {
491 p.text(
492 egui::pos2(rect.min.x, mid_y),
493 egui::Align2::LEFT_CENTER,
494 text.as_str(),
495 font.clone(),
496 ch.header_text,
497 );
498 }
499}
500
501pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
509 apply_ui_style_for(ctx, shared.theme());
510 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
512 render_menu_bar(ui, shared);
513 });
514
515 egui::TopBottomPanel::top("market_band")
517 .resizable(true)
518 .default_height(240.0)
519 .show(ctx, |ui| {
520 egui::ScrollArea::both()
524 .id_salt("market_band_scroll")
525 .auto_shrink([false, false])
526 .show(ui, |ui| {
527 render_market_band(ui, shared);
528 });
529 });
530
531 egui::SidePanel::left("order_side")
533 .resizable(true)
534 .default_width(250.0)
535 .show(ctx, |ui| {
536 egui::ScrollArea::vertical()
538 .id_salt("order_side_scroll")
539 .auto_shrink([false, false])
540 .show(ui, |ui| {
541 render_order_panel(ui, shared);
542 ui.separator();
543 render_book_frame(ui, shared);
544 });
545 });
546
547 egui::CentralPanel::default().show(ctx, |ui| {
549 let avail = ui.available_size();
550 let sp = ui.spacing().item_spacing;
551 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
557 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
558 ui.horizontal(|ui| {
559 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
560 render_topleft_group(ui, shared);
561 });
562 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
563 render_trades_group(ui, shared);
564 });
565 });
566 ui.horizontal(|ui| {
567 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
568 render_positions_group(ui, shared);
569 });
570 group_frame_plain(ui, "grp_bottomright", gw, gh, |ui| {
576 render_bottomright_group(ui, shared);
577 });
578 });
579 });
580
581 render_windows(ctx, shared);
583}
584
585fn group_frame_plain(
595 ui: &mut egui::Ui,
596 _id: &str,
597 w: f32,
598 h: f32,
599 content: impl FnOnce(&mut egui::Ui),
600) {
601 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
602 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
603 egui::Frame::group(ui.style()).show(&mut child, |ui| {
604 ui.vertical(content);
605 });
606}
607
608enum GridLines {
636 Plain,
637 Grid { rows: usize },
638}
639
640fn pinned_table(
641 ui: &mut egui::Ui,
642 shared: &UiShared,
643 grid_id: &str,
644 num_columns: usize,
645 headers: &[&str],
646 body: impl FnOnce(&mut egui::Ui),
647) {
648 pinned_table_impl(
649 ui,
650 shared,
651 grid_id,
652 num_columns,
653 headers,
654 GridLines::Plain,
655 body,
656 );
657}
658
659fn pinned_table_grid(
666 ui: &mut egui::Ui,
667 shared: &UiShared,
668 grid_id: &str,
669 num_columns: usize,
670 headers: &[&str],
671 rows: usize,
672 body: impl FnOnce(&mut egui::Ui),
673) {
674 pinned_table_impl(
675 ui,
676 shared,
677 grid_id,
678 num_columns,
679 headers,
680 GridLines::Grid { rows },
681 body,
682 );
683}
684
685fn pinned_table_impl(
686 ui: &mut egui::Ui,
687 shared: &UiShared,
688 grid_id: &str,
689 num_columns: usize,
690 headers: &[&str],
691 grid_lines: GridLines,
692 body: impl FnOnce(&mut egui::Ui),
693) {
694 let ch = Chrome::of(shared.theme(), ui.visuals());
695 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
696 std::cell::RefCell::new(Vec::new());
697 let grid_rect_cell: std::cell::Cell<Option<egui::Rect>> = std::cell::Cell::new(None);
699 let resp = egui::ScrollArea::both()
700 .id_salt(format!("{grid_id}_sc"))
701 .auto_shrink([false, false])
702 .show(ui, |ui| {
703 let gr = egui::Grid::new(grid_id)
704 .num_columns(num_columns)
711 .show(ui, |ui| {
712 for h in headers {
713 let r = ui.label(egui::RichText::new(*h).strong().small());
715 heads_cell.borrow_mut().push((r.rect, h.to_string()));
716 }
717 ui.end_row();
718 body(ui);
719 });
720 grid_rect_cell.set(Some(gr.response.rect));
721 });
722 let heads = heads_cell.borrow();
723 if heads.is_empty() {
724 return;
725 }
726 let area = resp.inner_rect;
727 let head_h = heads[0].0.height();
728 let sp = ui.spacing().item_spacing;
729 let m = ui.visuals().clip_rect_margin;
735 let strip = egui::Rect::from_min_max(
736 egui::pos2(area.min.x - m, area.min.y - m),
737 egui::pos2(area.max.x + m, area.min.y + head_h + sp.y),
738 );
739 if let GridLines::Grid { rows } = grid_lines {
741 if let Some(grect) = grid_rect_cell.get() {
742 let line = egui::Stroke::new(1.0, ch.line);
743 ui.scope_builder(
744 egui::UiBuilder::new()
745 .id_salt(format!("{grid_id}_gridlines"))
746 .max_rect(area),
747 |ui| {
748 ui.set_clip_rect(area);
749 let p = ui.painter();
750 for next in heads.iter().skip(1).take(num_columns.saturating_sub(1)) {
758 let x = next.0.min.x - sp.x * 0.5;
759 p.vline(x, grect.min.y..=grect.max.y, line);
760 }
761 let body_top = grect.min.y + head_h + sp.y;
763 if rows > 0 && grect.max.y > body_top {
764 let row_h = (grect.max.y - body_top) / rows as f32;
765 for k in 0..rows {
766 let y = body_top + row_h * (k as f32 + 0.5);
767 p.hline(grect.min.x..=grect.max.x, y, line);
768 }
769 }
770 },
771 );
772 }
773 }
774 ui.scope_builder(
775 egui::UiBuilder::new()
776 .id_salt(format!("{grid_id}_hdr_ov"))
777 .max_rect(strip),
778 |ui| {
779 ui.set_clip_rect(strip);
780 let painter = ui.painter();
781 painter.rect_filled(strip, 0.0, ch.line);
782 painter.hline(
785 strip.x_range(),
786 strip.max.y,
787 egui::Stroke::new(1.0, ch.line),
788 );
789 let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
790 for (rect, text) in heads.iter() {
791 painter.text(
792 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
793 egui::Align2::LEFT_CENTER,
794 text.as_str(),
795 font.clone(),
796 ch.header_text,
797 );
798 }
799 },
800 );
801}
802
803fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
806 ui.horizontal(|ui| {
807 if ui.button(MENU_OPS).clicked() {
809 shared.set_ops_window_open(true);
810 }
811 if ui.button(MENU_ACCOUNTS).clicked() {
812 shared.set_accounts_window_open(true);
813 }
814 if ui.button(MENU_CONTRACTS).clicked() {
815 shared.set_contracts_window_open(true);
816 }
817 ui.menu_button(MENU_HELP, |ui| {
818 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
819 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
820 });
821 if ui
824 .button(format!("主题:{}", shared.theme().label()))
825 .clicked()
826 {
827 let t = shared.cycle_theme();
828 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
829 }
830 });
831}
832
833fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
836 let groups = shared.tick_groups();
838 if groups.is_empty() {
839 ui.label("(等待 eTick 订阅)");
840 return;
841 }
842 let sel = shared.selected_exchange();
844 let active = if groups.iter().any(|(e, _)| *e == sel) {
845 sel.clone()
846 } else {
847 groups[0].0.clone()
848 };
849 ui.horizontal(|ui| {
850 for (ex, _) in &groups {
851 if tab_label(ui, shared, *ex == active, ex.clone()).clicked() {
852 shared.set_selected_exchange(ex.clone());
853 }
854 }
855 });
856 let ticks = groups
857 .iter()
858 .find(|(e, _)| *e == active)
859 .map(|(_, v)| v.clone())
860 .unwrap_or_default();
861 pinned_table(
864 ui,
865 shared,
866 "tick_grid",
867 14,
868 &[
869 "代码",
870 "名称",
871 "最新价",
872 "涨跌幅",
873 "成交量",
874 "今开",
875 "最高",
876 "最低",
877 "买1价",
878 "买1量",
879 "卖1价",
880 "卖1量",
881 "时间",
882 "行情源",
883 ],
884 |ui| {
885 for t in &ticks {
886 let row_sel = shared.order_form().symbol == t.symbol;
891 let mut cells = Vec::with_capacity(14);
892 let mut dbl = false;
893 let resp = ui.button(t.symbol.clone());
894 if resp.clicked() {
895 shared.set_selected_exchange(active.clone());
896 import_tick(shared, t, &active, 0);
897 }
898 dbl |= resp.double_clicked();
899 cells.push(resp.rect);
900 macro_rules! cell {
901 ($base:expr, $col:expr) => {{
902 let base = $base;
903 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
904 if r.clicked() {
905 shared.set_selected_exchange(active.clone());
906 import_tick(shared, t, &active, $col);
907 }
908 dbl |= r.double_clicked();
909 cells.push(base.rect);
910 }};
911 }
912 cell!(
913 ui.label(if t.name.is_empty() {
914 "-".to_string()
915 } else {
916 t.name.clone()
917 }),
918 1
919 );
920 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
921 cell!(
922 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
923 if t.pct_change < 0.0 {
924 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
925 } else {
926 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
927 },
928 )),
929 3
930 );
931 cell!(ui.label(format!("{:.0}", t.volume)), 4);
932 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
933 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
934 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
935 cell!(
936 ui.label(
937 egui::RichText::new(format!("{:.1}", t.bid_price_1))
938 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
939 ),
940 8
941 );
942 cell!(
943 ui.label(
944 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
945 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
946 ),
947 9
948 );
949 cell!(
950 ui.label(
951 egui::RichText::new(format!("{:.1}", t.ask_price_1))
952 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
953 ),
954 10
955 );
956 cell!(
957 ui.label(
958 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
959 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
960 ),
961 11
962 );
963 cell!(
964 ui.label(if t.update_time.is_empty() {
965 fmt_dt(Some(t.datetime))
966 } else {
967 t.update_time.clone()
968 }),
969 12
970 );
971 cell!(ui.label(t.gateway_name.clone()), 13);
972 if dbl {
973 shared.open_kline(t.symbol.clone());
974 }
975 paint_row_bg(ui, &cells, row_sel);
976 ui.end_row();
977 }
978 },
979 );
980}
981
982fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
985 let mut f = shared.order_form();
987 let before = f.clone();
988 let mut err: Option<String> = None;
989
990 if f.follow_price {
992 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
993 if t.last_price > 0.0 {
994 f.price = format!("{:.1}", t.last_price);
995 }
996 }
997 }
998
999 let mut ex_opts: Vec<String> = shared
1001 .tick_groups()
1002 .iter()
1003 .map(|(e, _)| e.clone())
1004 .collect();
1005 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
1006 if !ex_opts.iter().any(|x| x == e) {
1007 ex_opts.push(e.to_string());
1008 }
1009 }
1010 if !ex_opts.contains(&f.exchange) {
1011 ex_opts.insert(0, f.exchange.clone());
1012 }
1013 let ts = shared.trade_snap();
1016 let mut gw_opts: Vec<String> = Vec::new();
1017 let known_gws = account_gateways(shared);
1024 for g in shared
1025 .accounts()
1026 .iter()
1027 .filter(|a| a.connected)
1028 .map(|a| a.gateway.clone())
1029 .chain(
1030 ts.orders
1031 .iter()
1032 .map(|o| o.gateway_name.clone())
1033 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
1034 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
1035 .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
1036 )
1037 {
1038 if !g.is_empty() && !gw_opts.contains(&g) {
1039 gw_opts.push(g);
1040 }
1041 }
1042 if !gw_opts.contains(&f.gateway)
1043 && !f.gateway.is_empty()
1044 && (known_gws.is_empty() || known_gws.contains(&f.gateway))
1045 {
1046 gw_opts.insert(0, f.gateway.clone());
1047 }
1048 if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
1051 f.gateway = shared.default_gateway().unwrap_or_default();
1052 }
1053 if f.gateway.is_empty() {
1056 if let Some(def) = shared.default_gateway() {
1057 f.gateway = def;
1058 }
1059 }
1060 let no_accounts = gw_opts.is_empty();
1061
1062 egui::Grid::new("order_form_grid")
1065 .num_columns(2)
1066 .show(ui, |ui| {
1067 ui.label("交易所");
1068 egui::ComboBox::from_id_salt("of_exchange")
1069 .selected_text(f.exchange.clone())
1070 .show_ui(ui, |ui| {
1071 for e in &ex_opts {
1072 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
1073 }
1074 });
1075 ui.end_row();
1076 ui.label("代码");
1077 let sym_resp = ui.add(
1078 egui::TextEdit::singleline(&mut f.symbol)
1079 .hint_text("cu2709")
1080 .desired_width(150.0),
1081 );
1082 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1087 let s = f.symbol.trim().to_string();
1088 let bare = match s.split_once('.') {
1091 Some((_, b)) => b.trim().to_string(),
1092 None => s,
1093 };
1094 if !bare.is_empty() {
1095 f.symbol = bare.clone();
1096 match subscribe_target(shared) {
1099 Ok(gw) => {
1100 shared.push_cmd(UiCmd::CtpSubscribe {
1101 gateway: gw.clone(),
1102 symbols: vec![bare.clone()],
1103 });
1104 shared
1105 .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
1106 }
1107 Err(e) => shared.log_line(format!("[订阅] {e}")),
1108 }
1109 }
1110 }
1111 ui.end_row();
1112 ui.label("名称");
1113 ui.label(
1114 tick_of(shared, &f.exchange, &f.symbol)
1115 .map(|t| {
1116 if t.name.is_empty() {
1117 "-".to_string()
1118 } else {
1119 t.name
1120 }
1121 })
1122 .unwrap_or_else(|| "-".to_string()),
1123 );
1124 ui.end_row();
1125 ui.label("方向");
1126 egui::ComboBox::from_id_salt("of_direction")
1127 .selected_text(f.direction.as_str())
1128 .show_ui(ui, |ui| {
1129 for d in [Direction::LONG, Direction::SHORT] {
1130 ui.selectable_value(&mut f.direction, d, d.as_str());
1131 }
1132 });
1133 ui.end_row();
1134 ui.label("开平");
1135 ui.horizontal(|ui| {
1136 egui::ComboBox::from_id_salt("of_offset")
1137 .selected_text(f.offset.as_str())
1138 .show_ui(ui, |ui| {
1139 for o in [
1140 Offset::OPEN,
1141 Offset::CLOSE,
1142 Offset::CLOSETODAY,
1143 Offset::CLOSEYESTERDAY,
1144 ] {
1145 ui.selectable_value(&mut f.offset, o, o.as_str());
1146 }
1147 });
1148 ui.checkbox(&mut f.auto_split, "自动开平");
1150 });
1151 ui.end_row();
1152 ui.label("类型");
1153 egui::ComboBox::from_id_salt("of_order_type")
1154 .selected_text(f.order_type.as_str())
1155 .show_ui(ui, |ui| {
1156 for ot in [OrderType::LIMIT, OrderType::MARKET] {
1157 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
1158 }
1159 });
1160 ui.end_row();
1161 ui.label("价格");
1162 ui.horizontal(|ui| {
1163 ui.add(
1164 egui::TextEdit::singleline(&mut f.price)
1165 .hint_text("107660")
1166 .desired_width(90.0),
1167 );
1168 ui.checkbox(&mut f.follow_price, "随价");
1169 });
1170 ui.end_row();
1171 ui.label("数量");
1172 ui.add(
1173 egui::TextEdit::singleline(&mut f.volume)
1174 .hint_text("1")
1175 .desired_width(90.0),
1176 );
1177 ui.end_row();
1178 ui.label("接口");
1179 egui::ComboBox::from_id_salt("of_gateway")
1180 .selected_text(if f.gateway.is_empty() {
1181 "(未选账户)".to_string()
1182 } else {
1183 f.gateway.clone()
1184 })
1185 .show_ui(ui, |ui| {
1186 for g in &gw_opts {
1187 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
1188 }
1189 });
1190 ui.end_row();
1191 ui.label("操作");
1192 ui.horizontal(|ui| {
1193 let send = ui
1195 .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
1196 .inner
1197 .clicked();
1198 if send {
1199 match f.to_request() {
1200 Ok(req) => {
1201 let fallback = shared.default_gateway();
1202 match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
1203 Ok(gateway) => {
1204 shared.push_cmd(UiCmd::SendOrder {
1205 req,
1206 gateway,
1207 mode: if f.auto_split {
1208 ek_oms::LockMode::Auto
1209 } else {
1210 ek_oms::LockMode::None
1211 },
1212 });
1213 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
1214 }
1215 Err(e) => {
1216 shared.log_line(format!("[下单] {e}"));
1217 err = Some(e);
1218 }
1219 }
1220 }
1221 Err(e) => {
1222 shared.log_line(format!("[下单] {e}"));
1223 err = Some(e);
1224 }
1225 }
1226 }
1227 if ui.button(BTN_CANCEL_ALL).clicked() {
1228 let mut n = 0usize;
1231 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
1232 let gw = if !o.gateway_name.is_empty() {
1234 o.gateway_name.clone()
1235 } else {
1236 shared.default_gateway().unwrap_or_default()
1237 };
1238 if gw.is_empty() {
1239 continue;
1240 }
1241 shared.push_cmd(UiCmd::CancelOrder {
1242 orderid: o.orderid.clone(),
1243 gateway: gw,
1244 });
1245 n += 1;
1246 }
1247 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
1248 }
1249 });
1250 ui.end_row();
1251 ui.label("代码回车 = 订阅");
1252 ui.label("");
1253 ui.end_row();
1254 });
1255 if let Some(e) = err {
1256 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
1257 }
1258 if no_accounts {
1259 ui.label(
1261 egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1262 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1263 );
1264 }
1265 if f != before {
1266 shared.set_order_form(f.clone());
1267 }
1268}
1269
1270fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1274 let f = shared.order_form();
1275 match tick_of(shared, &f.exchange, &f.symbol) {
1276 Some(t) => {
1277 for i in (1..=5).rev() {
1278 let (p, v) = match i {
1279 1 => (t.ask_price_1, t.ask_volume_1),
1280 2 => (t.ask_price_2, t.ask_volume_2),
1281 3 => (t.ask_price_3, t.ask_volume_3),
1282 4 => (t.ask_price_4, t.ask_volume_4),
1283 _ => (t.ask_price_5, t.ask_volume_5),
1284 };
1285 ui.label(
1286 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
1287 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
1288 );
1289 }
1290 for i in 1..=5 {
1291 let (p, v) = match i {
1292 1 => (t.bid_price_1, t.bid_volume_1),
1293 2 => (t.bid_price_2, t.bid_volume_2),
1294 3 => (t.bid_price_3, t.bid_volume_3),
1295 4 => (t.bid_price_4, t.bid_volume_4),
1296 _ => (t.bid_price_5, t.bid_volume_5),
1297 };
1298 ui.label(
1299 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
1300 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
1301 );
1302 }
1303 }
1304 None => {
1305 ui.label("(无 tick)");
1306 }
1307 }
1308}
1309
1310fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
1313 let tab = shared.topleft_tab();
1314 ui.horizontal(|ui| {
1315 if tab_label(
1316 ui,
1317 shared,
1318 tab == TopLeftTab::Orders,
1319 TopLeftTab::Orders.label(),
1320 )
1321 .clicked()
1322 {
1323 shared.set_topleft_tab(TopLeftTab::Orders);
1324 }
1325 if tab_label(
1326 ui,
1327 shared,
1328 tab == TopLeftTab::Active,
1329 TopLeftTab::Active.label(),
1330 )
1331 .clicked()
1332 {
1333 shared.set_topleft_tab(TopLeftTab::Active);
1334 }
1335 });
1336 render_orders_table(ui, shared, tab == TopLeftTab::Active);
1337}
1338
1339fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1343 let ts = shared.trade_snap();
1344 let known_gws = account_gateways(shared);
1346 let rows: Vec<_> = ts
1349 .orders
1350 .iter()
1351 .filter(|o| !active_only || order_active(o.status))
1352 .collect();
1353 const ORDER_HEADERS: [&str; 17] = [
1355 "代码",
1356 "名称",
1357 "交易所",
1358 "类型",
1359 "方向",
1360 "开平",
1361 "价格",
1362 "总量",
1363 "已成",
1364 "成交均价",
1365 "状态",
1366 "状态描述",
1367 "时间",
1368 "委托号",
1369 "来源",
1370 "接口",
1371 "操作",
1372 ];
1373 if rows.is_empty() {
1374 pinned_table(
1377 ui,
1378 shared,
1379 if active_only {
1380 "active_grid"
1381 } else {
1382 "order_grid"
1383 },
1384 17,
1385 &ORDER_HEADERS,
1386 |ui| {
1387 ui.label(if active_only {
1388 "(无未成交)"
1389 } else {
1390 "(无委托:等网关 send_order)"
1391 });
1392 },
1393 );
1394 return;
1395 }
1396 pinned_table(
1397 ui,
1398 shared,
1399 if active_only {
1400 "active_grid"
1401 } else {
1402 "order_grid"
1403 },
1404 17,
1405 &ORDER_HEADERS,
1406 |ui| {
1407 for o in &rows {
1408 let key = format!("{}.{}", o.gateway_name, o.orderid);
1412 let row_sel = shared.row_selected("orders", &key);
1413 let mut cells = Vec::with_capacity(17);
1414 let mut dbl = false;
1415 let sel = ui.selectable_label(row_sel, o.symbol.clone());
1416 if sel.clicked() {
1417 shared.select_row("orders", &key);
1418 }
1419 dbl |= sel.double_clicked();
1420 cells.push(sel.rect);
1421 macro_rules! cell {
1422 ($base:expr, $col:expr) => {{
1423 let base = $base;
1424 let r = click_cell(ui, base.rect, ("ord", &key, $col));
1425 if r.clicked() {
1426 shared.select_row("orders", &key);
1427 }
1428 dbl |= r.double_clicked();
1429 cells.push(base.rect);
1430 }};
1431 }
1432 cell!(
1433 ui.label(if o.name.is_empty() {
1434 "-".to_string()
1435 } else {
1436 o.name.clone()
1437 }),
1438 1
1439 );
1440 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1441 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1442 cell!(
1443 ui.label(
1444 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1445 if o.direction == Some(Direction::SHORT) {
1446 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1447 } else {
1448 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1449 },
1450 ),
1451 ),
1452 4
1453 );
1454 cell!(ui.label(o.offset.as_str().to_string()), 5);
1455 cell!(ui.label(format!("{:.1}", o.price)), 6);
1456 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1457 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1458 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1459 cell!(
1460 ui.label(egui::RichText::new(o.status.as_str()).color(
1461 if o.status == ek_data::constant::Status::REJECTED {
1462 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1463 } else if o.status == ek_data::constant::Status::ALLTRADED {
1464 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1465 } else {
1466 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1467 },
1468 )),
1469 10
1470 );
1471 cell!(ui.label(o.status_msg.clone()), 11);
1472 cell!(ui.label(fmt_dt(o.datetime)), 12);
1473 cell!(ui.label(o.orderid.clone()), 13);
1474 cell!(
1475 ui.label(if o.reference.is_empty() {
1476 "-".to_string()
1477 } else {
1478 o.reference.clone()
1479 }),
1480 14
1481 );
1482 cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1483 if dbl && order_active(o.status) {
1484 let gw = if !o.gateway_name.is_empty() {
1486 o.gateway_name.clone()
1487 } else {
1488 shared.default_gateway().unwrap_or_default()
1489 };
1490 if !gw.is_empty() {
1491 shared.push_cmd(UiCmd::CancelOrder {
1492 orderid: o.orderid.clone(),
1493 gateway: gw,
1494 });
1495 }
1496 }
1497 let hr = ui.horizontal(|ui| {
1498 if order_active(o.status)
1499 && ui
1500 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1501 .clicked()
1502 {
1503 let gw = if !o.gateway_name.is_empty() {
1506 o.gateway_name.clone()
1507 } else {
1508 shared.default_gateway().unwrap_or_default()
1509 };
1510 if gw.is_empty() {
1511 shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1512 } else {
1513 shared.push_cmd(UiCmd::CancelOrder {
1514 orderid: o.orderid.clone(),
1515 gateway: gw,
1516 });
1517 }
1518 }
1519 });
1520 cells.push(hr.response.rect);
1521 paint_row_bg(ui, &cells, row_sel);
1522 ui.end_row();
1523 }
1524 },
1525 );
1526}
1527
1528fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1530 tab_label(ui, shared, true, "成交");
1533 let ts = shared.trade_snap();
1534 let known_gws = account_gateways(shared);
1536 const TRADE_HEADERS: [&str; 12] = [
1538 "成交号",
1539 "代码",
1540 "名称",
1541 "交易所",
1542 "方向",
1543 "开平",
1544 "价格",
1545 "数量",
1546 "成交额",
1547 "时间",
1548 "委托号",
1549 "接口",
1550 ];
1551 if ts.trades.is_empty() {
1552 pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1554 ui.label("(无成交)");
1555 });
1556 return;
1557 }
1558 pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1559 for t in &ts.trades {
1560 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1563 let row_sel = shared.row_selected("trades", &key);
1564 let mut cells = Vec::with_capacity(12);
1565 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1566 if sel.clicked() {
1567 shared.select_row("trades", &key);
1568 }
1569 cells.push(sel.rect);
1570 macro_rules! cell {
1571 ($base:expr, $col:expr) => {{
1572 let base = $base;
1573 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1574 if r.clicked() {
1575 shared.select_row("trades", &key);
1576 }
1577 cells.push(base.rect);
1578 }};
1579 }
1580 cell!(ui.label(t.symbol.clone()), 1);
1581 cell!(
1582 ui.label(if t.name.is_empty() {
1583 "-".to_string()
1584 } else {
1585 t.name.clone()
1586 }),
1587 2
1588 );
1589 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1590 cell!(
1591 ui.label(
1592 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1593 if t.direction == Some(Direction::SHORT) {
1594 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1595 } else {
1596 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1597 },
1598 ),
1599 ),
1600 4
1601 );
1602 cell!(ui.label(t.offset.as_str().to_string()), 5);
1603 cell!(ui.label(format!("{:.1}", t.price)), 6);
1604 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1605 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1606 cell!(ui.label(fmt_dt(t.datetime)), 9);
1607 cell!(ui.label(t.orderid.clone()), 10);
1608 cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1609 paint_row_bg(ui, &cells, row_sel);
1610 ui.end_row();
1611 }
1612 });
1613}
1614
1615fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1618 tab_label(ui, shared, true, "持仓");
1620 let ts = shared.trade_snap();
1621 let known_gws = account_gateways(shared);
1623 const POS_HEADERS: [&str; 16] = [
1625 "代码",
1626 "名称",
1627 "交易所",
1628 "方向",
1629 "数量",
1630 "昨仓",
1631 "可用",
1632 "冻结",
1633 "在途",
1634 "市值",
1635 "成本",
1636 "均价",
1637 "最新价",
1638 "盈亏",
1639 "盈亏比",
1640 "接口",
1641 ];
1642 if ts.positions.is_empty() {
1643 pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
1645 ui.label("(无持仓)");
1646 });
1647 return;
1648 }
1649 pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
1650 for p in &ts.positions {
1651 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1655 let row_sel = shared.row_selected("positions", &key);
1656 let mut cells = Vec::with_capacity(16);
1657 let mut dbl = false;
1658 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1659 if sel.clicked() {
1660 shared.select_row("positions", &key);
1661 }
1662 dbl |= sel.double_clicked();
1663 cells.push(sel.rect);
1664 macro_rules! cell {
1665 ($base:expr, $col:expr) => {{
1666 let base = $base;
1667 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1668 if r.clicked() {
1669 shared.select_row("positions", &key);
1670 }
1671 dbl |= r.double_clicked();
1672 cells.push(base.rect);
1673 }};
1674 }
1675 cell!(
1676 ui.label(if p.name.is_empty() {
1677 "-".to_string()
1678 } else {
1679 p.name.clone()
1680 }),
1681 1
1682 );
1683 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1684 cell!(
1685 ui.label(egui::RichText::new(p.direction.as_str()).color(
1686 if p.direction == ek_data::constant::Direction::SHORT {
1687 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1688 } else {
1689 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1690 },
1691 )),
1692 3
1693 );
1694 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1695 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1696 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1697 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1698 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1699 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1700 cell!(ui.label(format!("{:.2}", p.price)), 10);
1701 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1702 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1703 cell!(
1704 ui.label(
1705 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1706 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1707 } else {
1708 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1709 }),
1710 ),
1711 13
1712 );
1713 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1714 cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1715 if dbl {
1716 let mut f = shared.order_form();
1717 f.symbol = p.symbol.clone();
1718 f.exchange = p.exchange.as_str().to_string();
1719 f.direction = p.direction;
1720 if !p.gateway_name.is_empty() {
1722 f.gateway = p.gateway_name.clone();
1723 }
1724 shared.set_order_form(f);
1725 shared.log_line(format!(
1726 "[ops] 持仓带入下单面板 {}(接口 {})",
1727 p.symbol, p.gateway_name
1728 ));
1729 }
1730 paint_row_bg(ui, &cells, row_sel);
1731 ui.end_row();
1732 }
1733 });
1734}
1735
1736fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1737 let tab = shared.bottomright_tab();
1738 ui.horizontal(|ui| {
1739 if tab_label(
1740 ui,
1741 shared,
1742 tab == BottomRightTab::Account,
1743 BottomRightTab::Account.label(),
1744 )
1745 .clicked()
1746 {
1747 shared.set_bottomright_tab(BottomRightTab::Account);
1748 }
1749 if tab_label(
1750 ui,
1751 shared,
1752 tab == BottomRightTab::Logs,
1753 BottomRightTab::Logs.label(),
1754 )
1755 .clicked()
1756 {
1757 shared.set_bottomright_tab(BottomRightTab::Logs);
1758 }
1759 });
1760 match tab {
1761 BottomRightTab::Account => {
1762 let ts = shared.trade_snap();
1763 if ts.accounts.is_empty() {
1764 ui.label("(无资金:等网关 eAccount.)");
1765 return;
1766 }
1767 let mut heads: Vec<(egui::Rect, String)> = Vec::new();
1768 egui::Grid::new("acct_grid").num_columns(5).show(ui, |ui| {
1769 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1770 let r = ui.label(egui::RichText::new(h).strong().small());
1771 heads.push((r.rect, h.to_string()));
1772 }
1773 ui.end_row();
1774 for a in &ts.accounts {
1775 ui.label(a.accountid.clone());
1776 ui.label(a.gateway_name.clone());
1777 ui.label(format!("{:.2}", a.balance));
1778 ui.label(format!("{:.2}", a.available()));
1779 ui.label(format!("{:.2}", a.frozen));
1780 ui.end_row();
1781 }
1782 });
1783 header_band(ui, shared, &heads);
1784 }
1785 BottomRightTab::Logs => {
1786 render_logs(ui, shared);
1787 }
1788 }
1789}
1790
1791fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1806 ui.horizontal(|ui| {
1807 if ui.button(BTN_CLEAR_LOGS).clicked() {
1808 shared.clear_logs(); }
1810 });
1811 let (entries, spilled) = shared.logs_entries();
1812 if spilled > 0 {
1813 ui.label(format!(
1818 "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
1819 crate::state::RING_CAP
1820 ));
1821 }
1822 pinned_table(ui, shared, "log_grid", 2, &["时间", "信息"], |ui| {
1823 if entries.is_empty() {
1824 ui.label("(无日志)");
1825 ui.label("");
1826 ui.end_row();
1827 return;
1828 }
1829 for e in &entries {
1830 ui.monospace(e.at.clone());
1831 ui.monospace(e.text.clone());
1832 ui.end_row();
1833 }
1834 });
1835}
1836
1837struct WinGeom {
1841 inner: [f32; 2],
1842 min_inner: [f32; 2],
1843 maximized: bool,
1844}
1845
1846fn show_independent_window(
1861 ctx: &egui::Context,
1862 id_salt: impl std::hash::Hash,
1863 title: &str,
1864 geom: &WinGeom,
1865 embedded: impl Fn(&egui::Context) -> bool,
1866 body: impl Fn(&mut egui::Ui) -> bool,
1867) -> bool {
1868 let closed = std::cell::Cell::new(false);
1869 let mut builder = egui::ViewportBuilder::default()
1870 .with_title(title)
1871 .with_inner_size(geom.inner)
1872 .with_min_inner_size(geom.min_inner)
1873 .with_minimize_button(true)
1874 .with_maximize_button(true)
1875 .with_close_button(true);
1876 if geom.maximized {
1877 builder = builder.with_maximized(true);
1878 }
1879 ctx.show_viewport_immediate(
1880 egui::ViewportId::from_hash_of(id_salt),
1881 builder,
1882 |ctx2, class| {
1883 if class == egui::ViewportClass::Embedded {
1884 closed.set(embedded(ctx2));
1885 return;
1886 }
1887 if ctx2.input(|i| i.viewport().close_requested()) {
1888 closed.set(true);
1889 return;
1890 }
1891 egui::CentralPanel::default().show(ctx2, |ui| {
1892 if body(ui) {
1893 closed.set(true);
1894 }
1895 });
1896 },
1897 );
1898 closed.get()
1899}
1900
1901fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1902 if shared.ops_window_open() {
1904 let closed = show_independent_window(
1905 ctx,
1906 "ops_win",
1907 "运维窗",
1908 &WinGeom {
1909 inner: [680.0, 420.0],
1910 min_inner: [420.0, 260.0],
1911 maximized: false,
1912 },
1913 |ctx2| {
1914 let mut open = shared.ops_window_open();
1915 egui::Window::new("运维窗")
1916 .id(egui::Id::new("ops_win"))
1917 .default_pos([80.0, 320.0])
1918 .default_size([640.0, 380.0])
1919 .open(&mut open)
1920 .show(ctx2, |ui| {
1921 render_ops_window(ui, shared);
1922 });
1923 shared.set_ops_window_open(open);
1924 !open
1925 },
1926 |ui| {
1927 render_ops_window(ui, shared);
1928 false
1929 },
1930 );
1931 if closed {
1932 shared.set_ops_window_open(false);
1933 }
1934 }
1935
1936 if shared.accounts_window_open() {
1939 let closed = show_independent_window(
1940 ctx,
1941 "account_win",
1942 "账户面板",
1943 &WinGeom {
1944 inner: [760.0, 520.0],
1945 min_inner: [520.0, 320.0],
1946 maximized: false,
1947 },
1948 |ctx2| {
1949 let mut open = shared.accounts_window_open();
1950 egui::Window::new("账户面板")
1951 .id(egui::Id::new("account_win"))
1952 .default_pos([640.0, 320.0])
1953 .default_size([720.0, 470.0])
1954 .open(&mut open)
1955 .show(ctx2, |ui| {
1956 render_accounts_window(ui, shared);
1957 });
1958 shared.set_accounts_window_open(open);
1959 !open
1960 },
1961 |ui| {
1962 render_accounts_window(ui, shared);
1963 false
1964 },
1965 );
1966 if closed {
1967 shared.set_accounts_window_open(false);
1968 }
1969 }
1970
1971 if shared.account_edit_open() {
1975 let closed = show_independent_window(
1976 ctx,
1977 "account_edit_win",
1978 "账户配置",
1979 &WinGeom {
1980 inner: [680.0, 340.0],
1981 min_inner: [480.0, 240.0],
1982 maximized: false,
1983 },
1984 |ctx2| {
1985 let mut open = shared.account_edit_open();
1986 let title = match shared.editing_account() {
1987 Some(gw) => format!("账户配置 — 编辑 {gw}"),
1988 None => "账户配置 — 添加".to_string(),
1989 };
1990 egui::Window::new(title)
1991 .id(egui::Id::new("account_edit_win"))
1992 .default_pos([700.0, 380.0])
1993 .default_size([660.0, 320.0])
1994 .open(&mut open)
1995 .show(ctx2, |ui| {
1996 render_account_edit_window(ui, shared);
1997 });
1998 if !open {
1999 shared.end_account_edit();
2000 }
2001 !open
2002 },
2003 |ui| {
2004 render_account_edit_window(ui, shared);
2005 false
2006 },
2007 );
2008 if closed && shared.account_edit_open() {
2009 shared.end_account_edit();
2010 }
2011 }
2012
2013 if shared.contracts_window_open() {
2015 let closed = show_independent_window(
2016 ctx,
2017 "contracts_win",
2018 "查询合约窗",
2019 &WinGeom {
2020 inner: [620.0, 460.0],
2021 min_inner: [420.0, 260.0],
2022 maximized: false,
2023 },
2024 |ctx2| {
2025 let mut open = shared.contracts_window_open();
2026 egui::Window::new("查询合约窗")
2027 .id(egui::Id::new("contracts_win"))
2028 .default_pos([420.0, 300.0])
2029 .default_size([560.0, 420.0])
2030 .open(&mut open)
2031 .show(ctx2, |ui| {
2032 render_contracts_window(ui, shared);
2033 });
2034 shared.set_contracts_window_open(open);
2035 !open
2036 },
2037 |ui| {
2038 render_contracts_window(ui, shared);
2039 false
2040 },
2041 );
2042 if closed {
2043 shared.set_contracts_window_open(false);
2044 }
2045 }
2046
2047 let mut closed: Vec<String> = Vec::new();
2051 for sym in shared.kline_symbols() {
2052 let is_closed = show_independent_window(
2053 ctx,
2054 format!("kline_win:{sym}"),
2055 &format!("K线 — {sym}"),
2056 &WinGeom {
2057 inner: [560.0, 360.0],
2058 min_inner: [360.0, 220.0],
2059 maximized: true,
2060 },
2061 |ctx2| {
2062 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
2063 let resp = egui::Window::new(format!("K线 — {sym}"))
2064 .id(egui::Id::new(("kline_win", sym.clone())))
2065 .default_pos([300.0, 340.0])
2066 .default_size([540.0, 330.0])
2067 .show(ctx2, |ui| {
2068 let cell = shared
2069 .market()
2070 .cells()
2071 .into_iter()
2072 .find(|c| c.symbol == sym);
2073 render_kline_cell(ui, cell.as_ref());
2074 });
2075 esc || resp.is_none()
2076 },
2077 |ui| {
2078 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
2079 return true;
2080 }
2081 let cell = shared
2082 .market()
2083 .cells()
2084 .into_iter()
2085 .find(|c| c.symbol == sym);
2086 render_kline_cell(ui, cell.as_ref());
2087 false
2088 },
2089 );
2090 if is_closed {
2091 closed.push(sym);
2092 }
2093 }
2094 for s in closed {
2095 shared.close_kline(&s);
2096 }
2097}
2098
2099fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
2102 ui.label("插件");
2103 let snap = shared.snapshot();
2104 let account_gateways: std::collections::HashSet<String> = shared
2106 .accounts()
2107 .iter()
2108 .map(|a| a.gateway.clone())
2109 .collect();
2110 let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
2111 egui::Grid::new("plug_grid").num_columns(6).show(ui, |ui| {
2112 for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
2113 let r = ui.label(egui::RichText::new(h).strong().small());
2114 plug_heads.push((r.rect, h.to_string()));
2115 }
2116 ui.end_row();
2117 if snap.rows.is_empty() {
2118 ui.label("(快照空:等 eTimer 首拉)");
2119 ui.end_row();
2120 }
2121 for r in &snap.rows {
2122 if is_quarantined(&r.state) {
2123 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
2124 ui.label(
2125 egui::RichText::new(state_text(r))
2126 .color(egui::Color32::RED)
2127 .strong(),
2128 );
2129 } else {
2130 ui.label(r.name.clone());
2131 ui.label(state_text(r));
2132 }
2133 ui.label(r.version.clone());
2134 ui.label(format!("deps:[{}]", r.deps.join(",")));
2135 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
2136 ui.horizontal(|ui| {
2137 if account_gateways.contains(&r.name) {
2143 ui.label(
2144 egui::RichText::new("起停归『账户』面板(带撤单守卫)")
2145 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2146 );
2147 } else {
2148 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
2149 shared.push_cmd(UiCmd::Start {
2153 factory: r.name.clone(),
2154 name: String::new(),
2155 });
2156 }
2157 let armed = shared.pending_confirm();
2158 let armed_here = ui
2159 .small_button(remove_button_label(&r.name, armed.as_deref()))
2160 .clicked()
2161 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
2162 if armed_here {
2163 shared.push_cmd(UiCmd::Remove {
2164 name: r.name.clone(),
2165 });
2166 }
2167 }
2168 });
2169 ui.end_row();
2170 }
2171 });
2172 header_band(ui, shared, &plug_heads);
2173
2174 if let Some(name) = shared.pending_confirm() {
2175 ui.horizontal(|ui| {
2176 ui.label(
2177 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
2178 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2179 );
2180 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2181 shared.confirm_cancel();
2182 }
2183 });
2184 }
2185
2186 ui.label("服务");
2187 let flat: Vec<String> = snap
2188 .services
2189 .iter()
2190 .flat_map(|(n, ids)| {
2191 if ids.is_empty() {
2192 vec![format!("{n} 服务"), "-".to_string()]
2193 } else {
2194 ids.iter()
2195 .flat_map(|id| [format!("{n} 服务"), id.clone()])
2196 .collect()
2197 }
2198 })
2199 .collect();
2200 if flat.is_empty() {
2201 ui.label("(无服务目录)");
2202 } else {
2203 let rows = flat.len().div_ceil(6); let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
2207 egui::Grid::new("svc_grid").num_columns(6).show(ui, |ui| {
2208 for _ in 0..3 {
2209 for h in ["服务", "实例"] {
2210 let r = ui.label(egui::RichText::new(h).strong().small());
2211 svc_heads.push((r.rect, h.to_string()));
2212 }
2213 }
2214 ui.end_row();
2215 for r in 0..rows {
2216 for c in 0..3 {
2217 let i = c * rows * 2 + r * 2;
2218 if i < flat.len() {
2219 ui.label(flat[i].clone());
2220 ui.label(flat[i + 1].clone());
2221 } else {
2222 ui.label("");
2223 ui.label("");
2224 }
2225 }
2226 ui.end_row();
2227 }
2228 });
2229 header_band(ui, shared, &svc_heads);
2230 }
2231
2232 ui.label("依赖");
2233 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
2234 ui.label("(全部无依赖)");
2235 }
2236 for r in &snap.rows {
2237 if !r.deps.is_empty() {
2238 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
2239 }
2240 }
2241}
2242
2243fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
2249 let accounts = shared.accounts();
2250 let locked = shared.accounts_locked();
2251 let md = shared.md_source();
2252 let snap = shared.trade_snap();
2253
2254 let row_label: std::collections::BTreeMap<String, String> = accounts
2258 .iter()
2259 .map(|a| {
2260 let d = a.display_name();
2261 let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
2262 (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
2263 })
2264 .collect();
2265
2266 ui.horizontal(|ui| {
2268 ui.label(
2269 egui::RichText::new(format!(
2270 "当前行情源 = {}",
2271 if md.is_empty() {
2272 "(未指定)".to_string()
2273 } else {
2274 md.clone()
2275 }
2276 ))
2277 .strong(),
2278 );
2279 ui.label(format!(
2280 "账户 {}/{}",
2281 accounts.len(),
2282 crate::state::MAX_ACCOUNTS
2283 ));
2284 if shared.can_add_account() {
2285 if ui.button(BTN_ACCOUNT_ADD).clicked() {
2286 shared.begin_account_edit(None);
2287 }
2288 } else {
2289 ui.label(
2290 egui::RichText::new(format!(
2291 "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
2292 crate::state::MAX_ACCOUNTS
2293 ))
2294 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2295 );
2296 }
2297 });
2298
2299 const ACCOUNT_HEADERS: [&str; 7] = [
2300 "网关(派生名)",
2301 "别名",
2302 "账号",
2303 "状态",
2304 "行情源",
2305 "收录(勾→实)",
2306 "操作",
2307 ];
2308 let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
2314 egui::Grid::new("account_grid")
2315 .num_columns(7)
2316 .show(ui, |ui| {
2317 for h in &ACCOUNT_HEADERS {
2318 let r = ui.label(egui::RichText::new(*h).strong().small());
2319 acct_heads.push((r.rect, h.to_string()));
2320 }
2321 ui.end_row();
2322 if accounts.is_empty() {
2323 ui.label("(无账户:上方『添加账户』开配置弹窗)");
2324 for _ in 0..6 {
2325 ui.label("");
2326 }
2327 ui.end_row();
2328 }
2329 for a in &accounts {
2330 let is_md = a.gateway == md;
2331 let name = row_label
2332 .get(&a.gateway)
2333 .cloned()
2334 .unwrap_or_else(|| a.gateway.clone());
2335 ui.label(a.gateway.clone());
2336 ui.label(if a.alias.trim().is_empty() {
2338 "—".to_string()
2339 } else {
2340 a.alias.clone()
2341 });
2342 ui.label(a.account.clone());
2343 match a.stage {
2349 crate::state::AccountStage::AssembleFailed => {
2350 ui.add(
2351 egui::Label::new(
2352 egui::RichText::new(format!(
2353 "装配失败:{}",
2354 a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
2355 ))
2356 .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
2357 )
2358 .wrap_mode(egui::TextWrapMode::Extend),
2359 );
2360 }
2361 _ => {
2362 let color = match a.login {
2363 crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
2364 crate::state::LoginState::LoginFailed
2365 | crate::state::LoginState::FrontDown => {
2366 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2367 }
2368 crate::state::LoginState::QueryStarved => {
2369 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
2370 }
2371 _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
2373 };
2374 ui.add(
2375 egui::Label::new(
2376 egui::RichText::new(a.status_label()).color(color),
2377 )
2378 .wrap_mode(egui::TextWrapMode::Extend),
2379 );
2380 }
2381 }
2382 ui.horizontal(|ui| {
2384 if is_md {
2385 ui.label(egui::RichText::new("● 行情源").strong());
2386 }
2387 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
2388 ui.small_button(if is_md {
2389 "已是行情源"
2390 } else {
2391 "设为行情源"
2392 })
2393 });
2394 if resp.inner.clicked() {
2395 match shared.set_md_source(&a.gateway) {
2396 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
2397 Err(e) => shared.log_line(format!("[账户] {e}")),
2398 }
2399 }
2400 });
2401 ui.vertical(|ui| {
2404 ui.add(
2405 egui::Label::new(egui::RichText::new(format!(
2406 "勾:{}",
2407 classes_label(&a.form.product_classes())
2408 )))
2409 .wrap_mode(egui::TextWrapMode::Extend),
2410 );
2411 ui.add(
2412 egui::Label::new(egui::RichText::new(format!(
2413 "实:{}",
2414 if a.effective_classes.is_empty() {
2415 "—".to_string()
2416 } else {
2417 classes_label(&a.effective_classes)
2418 }
2419 )))
2420 .wrap_mode(egui::TextWrapMode::Extend),
2421 );
2422 });
2423 ui.horizontal(|ui| {
2425 let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2426 let idle = a.stage == crate::state::AccountStage::NotAssembled;
2427 let stopped = a.login == crate::state::LoginState::Stopped;
2430 if failed || idle || stopped {
2431 let label = if failed {
2434 format!("重试 {name}")
2435 } else {
2436 format!("连接 {name}")
2437 };
2438 if ui.small_button(label).clicked() {
2439 push_connect(shared, &a.gateway, &a.form);
2440 }
2441 }
2442 let open = snap
2444 .orders
2445 .iter()
2446 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2447 .count();
2448 if a.stage == crate::state::AccountStage::Assembled
2455 && a.login != crate::state::LoginState::Stopped
2456 && open == 0
2457 && ui.small_button(format!("断开 {name}")).clicked()
2458 {
2459 shared.push_cmd(UiCmd::Disconnect {
2460 gateway: a.gateway.clone(),
2461 });
2462 shared.log_line(format!(
2463 "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2464 gateway = a.gateway
2465 ));
2466 }
2467 if ui.small_button(format!("编辑 {name}")).clicked() {
2468 shared.begin_account_edit(Some(&a.gateway));
2471 }
2472 let has_pos = snap
2473 .positions
2474 .iter()
2475 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2476 if open > 0 {
2477 ui.label(
2479 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2480 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2481 );
2482 if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2486 for o in snap
2487 .orders
2488 .iter()
2489 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2490 {
2491 shared.push_cmd(UiCmd::CancelOrder {
2492 orderid: o.orderid.clone(),
2493 gateway: a.gateway.clone(),
2494 });
2495 }
2496 shared.log_line(format!(
2497 "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2498 a.gateway, open
2499 ));
2500 }
2501 } else {
2502 let armed = shared.pending_confirm();
2505 let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2506 let fired = ui
2507 .small_button(remove_button_label(
2508 &name,
2509 if armed_here { Some(name.as_str()) } else { None },
2510 ))
2511 .clicked()
2512 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2513 if fired {
2514 shared.push_cmd(UiCmd::Remove {
2515 name: a.gateway.clone(),
2516 });
2517 shared.remove_account(&a.gateway);
2518 shared.end_account_edit_if_editing(&a.gateway);
2521 if has_pos {
2522 shared.log_line(format!(
2523 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2524 a.gateway
2525 ));
2526 }
2527 }
2528 }
2529 });
2530 ui.end_row();
2531 }
2532 });
2533 header_band(ui, shared, &acct_heads);
2534
2535 if let Some(gw) = shared.pending_confirm() {
2536 let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2538 ui.horizontal(|ui| {
2539 ui.label(
2540 egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2541 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2542 );
2543 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2544 shared.confirm_cancel();
2545 }
2546 });
2547 }
2548
2549 ui.label(if locked {
2550 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2551 } else {
2552 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2553 });
2554 ui.label(
2556 egui::RichText::new(format!(
2557 "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2558 crate::state::STATUS_POLL_TICKS
2559 ))
2560 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2561 );
2562 ui.label(
2567 egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
2568 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2569 );
2570 for a in accounts
2579 .iter()
2580 .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2581 {
2582 let note = a.status_note.clone().unwrap_or_default();
2583 let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2584 let color = if alarm {
2585 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2586 } else {
2587 egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
2588 };
2589 ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2590 }
2591 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2593 if src.form.md_addr.trim().is_empty() {
2594 ui.label(
2595 egui::RichText::new(format!(
2596 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2597 src.gateway,
2598 row_label
2599 .get(&src.gateway)
2600 .cloned()
2601 .unwrap_or_else(|| src.account.clone())
2602 ))
2603 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2604 );
2605 }
2606 }
2607}
2608
2609fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2615 let editing: Option<crate::state::AccountRow> =
2616 shared.editing_account().and_then(|g| shared.account(&g));
2617 let editing_on = editing.is_some();
2618 let md = shared.md_source();
2619 let locked = shared.accounts_locked();
2620 let mut form: crate::state::CtpForm = shared.ctp_form();
2621 let mut alias = shared.edit_alias();
2622 if let Some(e) = shared.edit_error() {
2624 ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2625 }
2626 ui.horizontal(|ui| {
2632 ui.label("别名");
2633 if ui
2634 .add(
2635 egui::TextEdit::singleline(&mut alias)
2636 .hint_text("显示用,可空")
2637 .desired_width(120.0),
2638 )
2639 .changed()
2640 {
2641 shared.set_edit_alias(alias.clone());
2642 }
2643 ui.label("broker");
2644 ui.add_enabled_ui(!editing_on, |ui| {
2645 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2646 });
2647 ui.label("td 前置");
2648 ctp_edit(
2649 ui,
2650 shared,
2651 &mut form.td_addr,
2652 "tcp://host:40001",
2653 CtpField::TdAddr,
2654 );
2655 ui.label("td 用户");
2656 ui.add_enabled_ui(!editing_on, |ui| {
2657 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2658 });
2659 });
2660 if let Some(r) = &editing {
2661 ui.label(
2664 egui::RichText::new(format!(
2665 "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2666 r.gateway
2667 ))
2668 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2669 );
2670 }
2671 ui.horizontal(|ui| {
2672 ui.label("td 密码");
2673 ctp_edit(
2674 ui,
2675 shared,
2676 &mut form.td_password,
2677 "密码",
2678 CtpField::TdPassword,
2679 );
2680 ui.label("AppID");
2681 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2682 ui.label("AuthCode");
2683 ctp_edit(
2684 ui,
2685 shared,
2686 &mut form.td_auth_code,
2687 "授权码",
2688 CtpField::AuthCode,
2689 );
2690 });
2691 let md_editable = match &editing {
2694 None => true,
2695 Some(r) => !locked || r.gateway == md,
2696 };
2697 ui.horizontal(|ui| {
2698 ui.label("md 前置");
2699 ui.add_enabled_ui(md_editable, |ui| {
2700 ctp_edit(
2701 ui,
2702 shared,
2703 &mut form.md_addr,
2704 "tcp://host:40011",
2705 CtpField::MdAddr,
2706 );
2707 ui.label("md 用户");
2708 ctp_edit(
2709 ui,
2710 shared,
2711 &mut form.md_user,
2712 "空=td 回落",
2713 CtpField::MdUser,
2714 );
2715 ui.label("md 密码");
2716 ctp_edit(
2717 ui,
2718 shared,
2719 &mut form.md_password,
2720 "空=td 回落",
2721 CtpField::MdPassword,
2722 );
2723 });
2724 if !md_editable {
2725 ui.label(
2726 egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2727 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2728 );
2729 }
2730 });
2731 ui.horizontal(|ui| {
2734 ui.label("收录勾选:");
2735 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2736 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2737 if fut || opt {
2738 shared.set_ctp_form(form.clone());
2739 }
2740 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2741 });
2742 ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
2745 if let Some(r) = &editing {
2748 ui.horizontal(|ui| {
2749 ui.label("网关实际收录(只读回带):");
2750 ui.label(if r.effective_classes.is_empty() {
2751 "—(尚未装配或无回带)".to_string()
2752 } else {
2753 classes_label(&r.effective_classes)
2754 });
2755 ui.label("装配/登录状态(只读):");
2756 ui.label(format!(
2757 "{}{}",
2758 r.status_label(),
2759 r.fail_note
2760 .as_ref()
2761 .map(|n| format!(" —— {n}"))
2762 .unwrap_or_default()
2763 ));
2764 });
2765 }
2766 if shared.account_edit_dirty() {
2769 ui.label(
2770 egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2771 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2772 );
2773 }
2774 ui.horizontal(|ui| {
2775 if ui.button("保存").clicked() {
2776 match save_account_draft(shared, &form, &alias, false) {
2777 Ok(()) => shared.end_account_edit(),
2778 Err(e) => {
2779 shared.log_line(format!("[账户] 保存被拦:{e}"));
2780 shared.set_edit_error(Some(e));
2781 }
2782 }
2783 }
2784 if ui.button("保存并连接").clicked() {
2785 match save_account_draft(shared, &form, &alias, true) {
2786 Ok(()) => shared.end_account_edit(),
2787 Err(e) => {
2788 shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2789 shared.set_edit_error(Some(e));
2790 }
2791 }
2792 }
2793 if ui.button("取消").clicked() {
2794 shared.end_account_edit();
2795 }
2796 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2797 });
2798}
2799
2800fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2802 shared
2803 .accounts()
2804 .iter()
2805 .map(|a| a.gateway.clone())
2806 .collect()
2807}
2808
2809fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2817 if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2818 gateway.to_string()
2819 } else {
2820 format!("{gateway}(已摘除)")
2821 }
2822}
2823
2824fn classes_label(classes: &[String]) -> String {
2826 if classes.is_empty() {
2827 return "—".to_string();
2828 }
2829 classes
2830 .iter()
2831 .map(|c| match c.as_str() {
2832 "1" => "期货",
2833 "2" => "期权",
2834 other => other,
2835 })
2836 .collect::<Vec<_>>()
2837 .join("+")
2838}
2839
2840fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2845 shared.push_cmd(UiCmd::Connect {
2846 gateway: gateway.to_string(),
2847 form: form.clone(),
2848 });
2849 shared.note_connect_sent(gateway);
2850 shared.log_line(format!(
2851 "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2852 gateway
2853 ));
2854}
2855
2856fn save_account_draft(
2863 shared: &UiShared,
2864 form: &crate::state::CtpForm,
2865 alias: &str,
2866 connect: bool,
2867) -> Result<(), String> {
2868 form.validate()?;
2869 if let Some(gw) = shared.editing_account() {
2870 if shared.account(&gw).is_none() {
2872 return Err(format!(
2873 "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2874 ));
2875 }
2876 shared.update_account_form(&gw, form.clone());
2877 if !shared.set_account_alias(&gw, alias) {
2878 return Err(format!(
2879 "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2880 ));
2881 }
2882 shared.log_line(format!(
2883 "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2884 ));
2885 if connect {
2886 push_connect(shared, &gw, form);
2887 }
2888 return Ok(());
2889 }
2890 let mut row = crate::state::AccountRow::derive(form)?;
2891 row.alias = alias.trim().to_string();
2892 let gateway = row.gateway.clone();
2893 let account = row.account.clone();
2894 shared.add_account(row.clone())?;
2895 let md = shared.md_source();
2899 let mut f = row.form.clone();
2900 if md.is_empty() && !shared.accounts_locked() {
2901 match shared.set_md_source(&gateway) {
2902 Ok(()) => {
2903 if f.md_addr.trim().is_empty() {
2904 shared.log_line(format!(
2905 "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2906 ));
2907 } else {
2908 shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2909 }
2910 }
2911 Err(e) => {
2912 shared.log_line(format!("[账户] {e}"));
2913 f.md_addr.clear();
2914 f.md_user.clear();
2915 f.md_password.clear();
2916 }
2917 }
2918 } else {
2919 f.md_addr.clear();
2920 f.md_user.clear();
2921 f.md_password.clear();
2922 if md.is_empty() {
2923 shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2924 }
2925 }
2926 shared.update_account_form(&gateway, f.clone());
2927 shared.log_line(format!(
2929 "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2930 ));
2931 if connect {
2932 push_connect(shared, &gateway, &f);
2933 }
2934 Ok(())
2935}
2936
2937fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2941 let mut q = shared.contract_query();
2942 ui.horizontal(|ui| {
2943 ui.label("代码或交易所(留空 = 全部):");
2944 if ui
2945 .add(
2946 egui::TextEdit::singleline(&mut q)
2947 .hint_text("cu 或 SHFE")
2948 .desired_width(200.0),
2949 )
2950 .changed()
2951 {
2952 shared.set_contract_query(q.clone());
2953 }
2954 });
2955 let rows = shared.contracts(&q);
2956 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2959 if rows.is_empty() {
2960 pinned_table(ui, shared, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2962 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2963 });
2964 return;
2965 }
2966 let selected = shared.selected_contracts();
2967 ui.horizontal(|ui| {
2972 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2973 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2974 if selected.is_empty() {
2975 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2976 } else {
2977 let symbols: Vec<String> = selected
2979 .iter()
2980 .map(|k| {
2981 k.split_once('.')
2982 .map(|(_, s)| s.to_string())
2983 .unwrap_or_else(|| k.clone())
2984 })
2985 .collect();
2986 match subscribe_target(shared) {
2987 Ok(gw) => {
2988 shared.push_cmd(UiCmd::CtpSubscribe {
2989 gateway: gw.clone(),
2990 symbols,
2991 });
2992 shared.log_line(format!(
2993 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2994 selected.len(),
2995 gw
2996 ));
2997 }
2998 Err(e) => shared.log_line(format!("[订阅] {e}")),
2999 }
3000 }
3001 }
3002 });
3003 pinned_table_grid(
3006 ui,
3007 shared,
3008 "contract_grid",
3009 5,
3010 &CONTRACT_HEADERS,
3011 rows.len(),
3012 |ui| {
3013 for c in &rows {
3014 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
3015 let mut on = selected.contains(&key);
3016 let row_sel = shared.row_selected("contracts", &key);
3020 let mut cells = Vec::with_capacity(5);
3021 let ck = ui.checkbox(&mut on, key.clone());
3022 if ck.changed() {
3023 shared.toggle_contract_selection(&key);
3024 shared.select_row("contracts", &key);
3025 }
3026 cells.push(ck.rect);
3027 macro_rules! cell {
3028 ($base:expr, $col:expr) => {{
3029 let base = $base;
3030 let r = click_cell(ui, base.rect, ("crt", &key, $col));
3031 if r.clicked() {
3032 shared.select_row("contracts", &key);
3033 }
3034 cells.push(base.rect);
3035 }};
3036 }
3037 cell!(ui.label(c.exchange.as_str().to_string()), 1);
3038 cell!(ui.label(c.name.clone()), 2);
3039 cell!(ui.label(format!("{:.0}", c.size)), 3);
3040 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
3041 paint_row_bg(ui, &cells, row_sel);
3042 ui.end_row();
3043 }
3044 },
3045 );
3046}
3047
3048enum CtpField {
3050 Broker,
3051 TdAddr,
3052 TdUser,
3053 TdPassword,
3054 AppId,
3055 AuthCode,
3056 MdAddr,
3057 MdUser,
3058 MdPassword,
3059}
3060
3061fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
3063 let is_secret = matches!(
3064 field,
3065 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
3066 );
3067 let mut w = egui::TextEdit::singleline(val)
3068 .hint_text(hint)
3069 .desired_width(210.0);
3072 if is_secret {
3073 w = w.password(true);
3074 }
3075 if ui.add(w).changed() {
3076 let mut f = shared.ctp_form();
3077 match field {
3078 CtpField::Broker => f.broker_id = val.clone(),
3079 CtpField::TdAddr => f.td_addr = val.clone(),
3080 CtpField::TdUser => f.td_user = val.clone(),
3081 CtpField::TdPassword => f.td_password = val.clone(),
3082 CtpField::AppId => f.td_app_id = val.clone(),
3083 CtpField::AuthCode => f.td_auth_code = val.clone(),
3084 CtpField::MdAddr => f.md_addr = val.clone(),
3085 CtpField::MdUser => f.md_user = val.clone(),
3086 CtpField::MdPassword => f.md_password = val.clone(),
3087 }
3088 shared.set_ctp_form(f);
3089 }
3090}
3091
3092fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
3096 let Some(c) = cell else {
3097 ui.label("(K线无格:等 dm 合成)");
3098 return;
3099 };
3100 if c.bars.is_empty() {
3101 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
3102 return;
3103 }
3104 let mut data = egui_charts::model::bar::BarData::new();
3108 for b in &c.bars {
3109 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
3110 .with_timezone(&beijing_tz())
3111 .with_timezone(&chrono::Utc);
3112 data.push(egui_charts::model::bar::Bar::new(
3113 dt,
3114 b.open_price,
3115 b.high_price,
3116 b.low_price,
3117 b.close_price,
3118 b.volume,
3119 ));
3120 }
3121 ui.label(format!(
3122 "K线 {}@{}s({} 根)",
3123 c.symbol,
3124 c.interval,
3125 c.bars.len()
3126 ));
3127 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
3128 chart.set_symbol(&c.symbol);
3129 chart.set_timeframe_label(&format!("{}s", c.interval));
3130 chart.enable_tracking_mode();
3131 let _ = chart.show(ui);
3132}
3133
3134#[cfg(test)]
3135mod tests {
3136 use super::*;
3137
3138 #[test]
3142 fn chrome_qdark_matches_qdarkstyle_palette() {
3143 let v = theme_visuals(crate::state::Theme::QDarkStyle);
3144 let ch = Chrome::of(crate::state::Theme::QDarkStyle, &v);
3145 assert_eq!(ch.line, egui::Color32::from_rgb(0x45, 0x53, 0x64));
3147 assert_eq!(ch.header_text, egui::Color32::from_rgb(0xDF, 0xE1, 0xE2));
3148 assert_eq!(ch.tab_head_fill, egui::Color32::from_rgb(0x45, 0x53, 0x64));
3150 assert_eq!(ch.tab_fill, egui::Color32::from_rgb(0x54, 0x68, 0x7A));
3153 assert_eq!(ch.tab_underline, egui::Color32::from_rgb(0x25, 0x9A, 0xE9));
3154 let fv = theme_visuals(crate::state::Theme::FinanceDark);
3156 let fch = Chrome::of(crate::state::Theme::FinanceDark, &fv);
3157 assert_eq!(fch.line, egui::Color32::from_rgb(0x3A, 0x46, 0x58));
3158 assert_eq!(fch.tab_fill, egui::Color32::from_rgb(0x2A, 0x44, 0x68));
3159 assert_eq!(fch.tab_underline, egui::Color32::from_rgb(0x6A, 0x9C, 0xD8));
3160 assert_ne!(fch.line, ch.line);
3161 assert_ne!(fch.tab_underline, ch.tab_underline);
3162 }
3163
3164 #[test]
3168 fn log_entries_stamp_beijing_wall_clock() {
3169 let mut ring = crate::state::LogRing::new(4);
3170 ring.push("[ops] 一行");
3171 let e = &ring.entries()[0];
3172 assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
3173 assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
3174 for seg in e.at.split(':') {
3175 assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
3176 }
3177 let want = chrono::Utc::now()
3178 .with_timezone(&beijing_tz())
3179 .format("%H:%M:%S")
3180 .to_string();
3181 let secs = |s: &str| -> i64 {
3182 s.split(':')
3183 .map(|x| x.parse::<i64>().unwrap())
3184 .fold(0, |acc, v| acc * 60 + v)
3185 };
3186 let (a, b) = (secs(&e.at), secs(&want));
3187 let d = (a - b).abs();
3188 assert!(
3190 d <= 2 || d > 86_398,
3191 "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
3192 );
3193 assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
3195 }
3196
3197 #[test]
3201 fn fmt_dt_shows_beijing_wall_clock() {
3202 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
3203 ek_data::time::civil_to_nanos(ek_data::time::Civil {
3204 year: 2026,
3205 month: 4,
3206 day: 16,
3207 hour: 1,
3208 minute: 30,
3209 second: 1,
3210 nanos: 0,
3211 }),
3212 );
3213 let beijing = expect.with_timezone(&beijing_tz());
3215 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
3216 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
3219 year: 2026,
3220 month: 4,
3221 day: 15,
3222 hour: 16,
3223 minute: 0,
3224 second: 30,
3225 nanos: 0,
3226 });
3227 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
3228 assert_eq!(fmt_dt(None), "-");
3230 }
3231}