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 min_x = heads
468 .iter()
469 .map(|h| h.0.min.x)
470 .fold(f32::INFINITY, f32::min);
471 let max_x = heads
472 .iter()
473 .map(|h| h.0.max.x)
474 .fold(f32::NEG_INFINITY, f32::max);
475 let top = heads[0].0.min.y;
476 let bottom = heads[0].0.max.y + ui.spacing().item_spacing.y;
477 let strip = egui::Rect::from_min_max(egui::pos2(min_x, top), egui::pos2(max_x, bottom));
478 let p = ui.painter();
479 p.rect_filled(strip, 0.0, ch.line);
480 p.hline(
481 strip.x_range(),
482 strip.max.y,
483 egui::Stroke::new(1.0, ch.line),
484 );
485 let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
486 let mid_y = (top + heads[0].0.max.y) * 0.5;
487 for (rect, text) in heads {
488 p.text(
489 egui::pos2(rect.min.x, mid_y),
490 egui::Align2::LEFT_CENTER,
491 text.as_str(),
492 font.clone(),
493 ch.header_text,
494 );
495 }
496}
497
498pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
506 apply_ui_style_for(ctx, shared.theme());
507 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
509 render_menu_bar(ui, shared);
510 });
511
512 egui::TopBottomPanel::top("market_band")
514 .resizable(true)
515 .default_height(240.0)
516 .show(ctx, |ui| {
517 egui::ScrollArea::both()
521 .id_salt("market_band_scroll")
522 .auto_shrink([false, false])
523 .show(ui, |ui| {
524 render_market_band(ui, shared);
525 });
526 });
527
528 egui::SidePanel::left("order_side")
530 .resizable(true)
531 .default_width(250.0)
532 .show(ctx, |ui| {
533 egui::ScrollArea::vertical()
535 .id_salt("order_side_scroll")
536 .auto_shrink([false, false])
537 .show(ui, |ui| {
538 render_order_panel(ui, shared);
539 ui.separator();
540 render_book_frame(ui, shared);
541 });
542 });
543
544 egui::CentralPanel::default().show(ctx, |ui| {
546 let avail = ui.available_size();
547 let sp = ui.spacing().item_spacing;
548 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
554 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
555 ui.horizontal(|ui| {
556 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
557 render_topleft_group(ui, shared);
558 });
559 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
560 render_trades_group(ui, shared);
561 });
562 });
563 ui.horizontal(|ui| {
564 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
565 render_positions_group(ui, shared);
566 });
567 group_frame_plain(ui, "grp_bottomright", gw, gh, |ui| {
573 render_bottomright_group(ui, shared);
574 });
575 });
576 });
577
578 render_windows(ctx, shared);
580}
581
582fn group_frame_plain(
592 ui: &mut egui::Ui,
593 _id: &str,
594 w: f32,
595 h: f32,
596 content: impl FnOnce(&mut egui::Ui),
597) {
598 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
599 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
600 egui::Frame::group(ui.style()).show(&mut child, |ui| {
601 ui.vertical(content);
602 });
603}
604
605enum GridLines {
633 Plain,
634 Grid { rows: usize },
635}
636
637fn pinned_table(
638 ui: &mut egui::Ui,
639 shared: &UiShared,
640 grid_id: &str,
641 num_columns: usize,
642 headers: &[&str],
643 body: impl FnOnce(&mut egui::Ui),
644) {
645 pinned_table_impl(
646 ui,
647 shared,
648 grid_id,
649 num_columns,
650 headers,
651 GridLines::Plain,
652 body,
653 );
654}
655
656fn pinned_table_grid(
663 ui: &mut egui::Ui,
664 shared: &UiShared,
665 grid_id: &str,
666 num_columns: usize,
667 headers: &[&str],
668 rows: usize,
669 body: impl FnOnce(&mut egui::Ui),
670) {
671 pinned_table_impl(
672 ui,
673 shared,
674 grid_id,
675 num_columns,
676 headers,
677 GridLines::Grid { rows },
678 body,
679 );
680}
681
682fn pinned_table_impl(
683 ui: &mut egui::Ui,
684 shared: &UiShared,
685 grid_id: &str,
686 num_columns: usize,
687 headers: &[&str],
688 grid_lines: GridLines,
689 body: impl FnOnce(&mut egui::Ui),
690) {
691 let ch = Chrome::of(shared.theme(), ui.visuals());
692 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
693 std::cell::RefCell::new(Vec::new());
694 let grid_rect_cell: std::cell::Cell<Option<egui::Rect>> = std::cell::Cell::new(None);
696 let resp = egui::ScrollArea::both()
697 .id_salt(format!("{grid_id}_sc"))
698 .auto_shrink([false, false])
699 .show(ui, |ui| {
700 let gr = egui::Grid::new(grid_id)
701 .num_columns(num_columns)
708 .show(ui, |ui| {
709 for h in headers {
710 let r = ui.label(egui::RichText::new(*h).strong().small());
712 heads_cell.borrow_mut().push((r.rect, h.to_string()));
713 }
714 ui.end_row();
715 body(ui);
716 });
717 grid_rect_cell.set(Some(gr.response.rect));
718 });
719 let heads = heads_cell.borrow();
720 if heads.is_empty() {
721 return;
722 }
723 let area = resp.inner_rect;
724 let head_h = heads[0].0.height();
725 let sp = ui.spacing().item_spacing;
726 let m = ui.visuals().clip_rect_margin;
732 let strip = egui::Rect::from_min_max(
733 egui::pos2(area.min.x - m, area.min.y - m),
734 egui::pos2(area.max.x + m, area.min.y + head_h + sp.y),
735 );
736 if let GridLines::Grid { rows } = grid_lines {
738 if let Some(grect) = grid_rect_cell.get() {
739 let line = egui::Stroke::new(1.0, ch.line);
740 ui.scope_builder(
741 egui::UiBuilder::new()
742 .id_salt(format!("{grid_id}_gridlines"))
743 .max_rect(area),
744 |ui| {
745 ui.set_clip_rect(area);
746 let p = ui.painter();
747 for h in heads.iter().take(num_columns.saturating_sub(1)) {
749 let x = h.0.max.x + sp.x * 0.5;
750 p.vline(x, grect.min.y..=grect.max.y, line);
751 }
752 let body_top = grect.min.y + head_h + sp.y;
754 if rows > 0 && grect.max.y > body_top {
755 let row_h = (grect.max.y - body_top) / rows as f32;
756 for k in 0..rows {
757 let y = body_top + row_h * (k as f32 + 0.5);
758 p.hline(grect.min.x..=grect.max.x, y, line);
759 }
760 }
761 },
762 );
763 }
764 }
765 ui.scope_builder(
766 egui::UiBuilder::new()
767 .id_salt(format!("{grid_id}_hdr_ov"))
768 .max_rect(strip),
769 |ui| {
770 ui.set_clip_rect(strip);
771 let painter = ui.painter();
772 painter.rect_filled(strip, 0.0, ch.line);
773 painter.hline(
776 strip.x_range(),
777 strip.max.y,
778 egui::Stroke::new(1.0, ch.line),
779 );
780 let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
781 for (rect, text) in heads.iter() {
782 painter.text(
783 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
784 egui::Align2::LEFT_CENTER,
785 text.as_str(),
786 font.clone(),
787 ch.header_text,
788 );
789 }
790 },
791 );
792}
793
794fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
797 ui.horizontal(|ui| {
798 if ui.button(MENU_OPS).clicked() {
800 shared.set_ops_window_open(true);
801 }
802 if ui.button(MENU_ACCOUNTS).clicked() {
803 shared.set_accounts_window_open(true);
804 }
805 if ui.button(MENU_CONTRACTS).clicked() {
806 shared.set_contracts_window_open(true);
807 }
808 ui.menu_button(MENU_HELP, |ui| {
809 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
810 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
811 });
812 if ui
815 .button(format!("主题:{}", shared.theme().label()))
816 .clicked()
817 {
818 let t = shared.cycle_theme();
819 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
820 }
821 });
822}
823
824fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
827 let groups = shared.tick_groups();
829 if groups.is_empty() {
830 ui.label("(等待 eTick 订阅)");
831 return;
832 }
833 let sel = shared.selected_exchange();
835 let active = if groups.iter().any(|(e, _)| *e == sel) {
836 sel.clone()
837 } else {
838 groups[0].0.clone()
839 };
840 ui.horizontal(|ui| {
841 for (ex, _) in &groups {
842 if tab_label(ui, shared, *ex == active, ex.clone()).clicked() {
843 shared.set_selected_exchange(ex.clone());
844 }
845 }
846 });
847 let ticks = groups
848 .iter()
849 .find(|(e, _)| *e == active)
850 .map(|(_, v)| v.clone())
851 .unwrap_or_default();
852 pinned_table(
855 ui,
856 shared,
857 "tick_grid",
858 14,
859 &[
860 "代码",
861 "名称",
862 "最新价",
863 "涨跌幅",
864 "成交量",
865 "今开",
866 "最高",
867 "最低",
868 "买1价",
869 "买1量",
870 "卖1价",
871 "卖1量",
872 "时间",
873 "行情源",
874 ],
875 |ui| {
876 for t in &ticks {
877 let row_sel = shared.order_form().symbol == t.symbol;
882 let mut cells = Vec::with_capacity(14);
883 let mut dbl = false;
884 let resp = ui.button(t.symbol.clone());
885 if resp.clicked() {
886 shared.set_selected_exchange(active.clone());
887 import_tick(shared, t, &active, 0);
888 }
889 dbl |= resp.double_clicked();
890 cells.push(resp.rect);
891 macro_rules! cell {
892 ($base:expr, $col:expr) => {{
893 let base = $base;
894 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
895 if r.clicked() {
896 shared.set_selected_exchange(active.clone());
897 import_tick(shared, t, &active, $col);
898 }
899 dbl |= r.double_clicked();
900 cells.push(base.rect);
901 }};
902 }
903 cell!(
904 ui.label(if t.name.is_empty() {
905 "-".to_string()
906 } else {
907 t.name.clone()
908 }),
909 1
910 );
911 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
912 cell!(
913 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
914 if t.pct_change < 0.0 {
915 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
916 } else {
917 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
918 },
919 )),
920 3
921 );
922 cell!(ui.label(format!("{:.0}", t.volume)), 4);
923 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
924 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
925 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
926 cell!(
927 ui.label(
928 egui::RichText::new(format!("{:.1}", t.bid_price_1))
929 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
930 ),
931 8
932 );
933 cell!(
934 ui.label(
935 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
936 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
937 ),
938 9
939 );
940 cell!(
941 ui.label(
942 egui::RichText::new(format!("{:.1}", t.ask_price_1))
943 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
944 ),
945 10
946 );
947 cell!(
948 ui.label(
949 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
950 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
951 ),
952 11
953 );
954 cell!(
955 ui.label(if t.update_time.is_empty() {
956 fmt_dt(Some(t.datetime))
957 } else {
958 t.update_time.clone()
959 }),
960 12
961 );
962 cell!(ui.label(t.gateway_name.clone()), 13);
963 if dbl {
964 shared.open_kline(t.symbol.clone());
965 }
966 paint_row_bg(ui, &cells, row_sel);
967 ui.end_row();
968 }
969 },
970 );
971}
972
973fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
976 let mut f = shared.order_form();
978 let before = f.clone();
979 let mut err: Option<String> = None;
980
981 if f.follow_price {
983 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
984 if t.last_price > 0.0 {
985 f.price = format!("{:.1}", t.last_price);
986 }
987 }
988 }
989
990 let mut ex_opts: Vec<String> = shared
992 .tick_groups()
993 .iter()
994 .map(|(e, _)| e.clone())
995 .collect();
996 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
997 if !ex_opts.iter().any(|x| x == e) {
998 ex_opts.push(e.to_string());
999 }
1000 }
1001 if !ex_opts.contains(&f.exchange) {
1002 ex_opts.insert(0, f.exchange.clone());
1003 }
1004 let ts = shared.trade_snap();
1007 let mut gw_opts: Vec<String> = Vec::new();
1008 let known_gws = account_gateways(shared);
1015 for g in shared
1016 .accounts()
1017 .iter()
1018 .filter(|a| a.connected)
1019 .map(|a| a.gateway.clone())
1020 .chain(
1021 ts.orders
1022 .iter()
1023 .map(|o| o.gateway_name.clone())
1024 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
1025 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
1026 .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
1027 )
1028 {
1029 if !g.is_empty() && !gw_opts.contains(&g) {
1030 gw_opts.push(g);
1031 }
1032 }
1033 if !gw_opts.contains(&f.gateway)
1034 && !f.gateway.is_empty()
1035 && (known_gws.is_empty() || known_gws.contains(&f.gateway))
1036 {
1037 gw_opts.insert(0, f.gateway.clone());
1038 }
1039 if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
1042 f.gateway = shared.default_gateway().unwrap_or_default();
1043 }
1044 if f.gateway.is_empty() {
1047 if let Some(def) = shared.default_gateway() {
1048 f.gateway = def;
1049 }
1050 }
1051 let no_accounts = gw_opts.is_empty();
1052
1053 egui::Grid::new("order_form_grid")
1056 .num_columns(2)
1057 .show(ui, |ui| {
1058 ui.label("交易所");
1059 egui::ComboBox::from_id_salt("of_exchange")
1060 .selected_text(f.exchange.clone())
1061 .show_ui(ui, |ui| {
1062 for e in &ex_opts {
1063 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
1064 }
1065 });
1066 ui.end_row();
1067 ui.label("代码");
1068 let sym_resp = ui.add(
1069 egui::TextEdit::singleline(&mut f.symbol)
1070 .hint_text("cu2709")
1071 .desired_width(150.0),
1072 );
1073 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1078 let s = f.symbol.trim().to_string();
1079 let bare = match s.split_once('.') {
1082 Some((_, b)) => b.trim().to_string(),
1083 None => s,
1084 };
1085 if !bare.is_empty() {
1086 f.symbol = bare.clone();
1087 match subscribe_target(shared) {
1090 Ok(gw) => {
1091 shared.push_cmd(UiCmd::CtpSubscribe {
1092 gateway: gw.clone(),
1093 symbols: vec![bare.clone()],
1094 });
1095 shared
1096 .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
1097 }
1098 Err(e) => shared.log_line(format!("[订阅] {e}")),
1099 }
1100 }
1101 }
1102 ui.end_row();
1103 ui.label("名称");
1104 ui.label(
1105 tick_of(shared, &f.exchange, &f.symbol)
1106 .map(|t| {
1107 if t.name.is_empty() {
1108 "-".to_string()
1109 } else {
1110 t.name
1111 }
1112 })
1113 .unwrap_or_else(|| "-".to_string()),
1114 );
1115 ui.end_row();
1116 ui.label("方向");
1117 egui::ComboBox::from_id_salt("of_direction")
1118 .selected_text(f.direction.as_str())
1119 .show_ui(ui, |ui| {
1120 for d in [Direction::LONG, Direction::SHORT] {
1121 ui.selectable_value(&mut f.direction, d, d.as_str());
1122 }
1123 });
1124 ui.end_row();
1125 ui.label("开平");
1126 ui.horizontal(|ui| {
1127 egui::ComboBox::from_id_salt("of_offset")
1128 .selected_text(f.offset.as_str())
1129 .show_ui(ui, |ui| {
1130 for o in [
1131 Offset::OPEN,
1132 Offset::CLOSE,
1133 Offset::CLOSETODAY,
1134 Offset::CLOSEYESTERDAY,
1135 ] {
1136 ui.selectable_value(&mut f.offset, o, o.as_str());
1137 }
1138 });
1139 ui.checkbox(&mut f.auto_split, "自动开平");
1141 });
1142 ui.end_row();
1143 ui.label("类型");
1144 egui::ComboBox::from_id_salt("of_order_type")
1145 .selected_text(f.order_type.as_str())
1146 .show_ui(ui, |ui| {
1147 for ot in [OrderType::LIMIT, OrderType::MARKET] {
1148 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
1149 }
1150 });
1151 ui.end_row();
1152 ui.label("价格");
1153 ui.horizontal(|ui| {
1154 ui.add(
1155 egui::TextEdit::singleline(&mut f.price)
1156 .hint_text("107660")
1157 .desired_width(90.0),
1158 );
1159 ui.checkbox(&mut f.follow_price, "随价");
1160 });
1161 ui.end_row();
1162 ui.label("数量");
1163 ui.add(
1164 egui::TextEdit::singleline(&mut f.volume)
1165 .hint_text("1")
1166 .desired_width(90.0),
1167 );
1168 ui.end_row();
1169 ui.label("接口");
1170 egui::ComboBox::from_id_salt("of_gateway")
1171 .selected_text(if f.gateway.is_empty() {
1172 "(未选账户)".to_string()
1173 } else {
1174 f.gateway.clone()
1175 })
1176 .show_ui(ui, |ui| {
1177 for g in &gw_opts {
1178 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
1179 }
1180 });
1181 ui.end_row();
1182 ui.label("操作");
1183 ui.horizontal(|ui| {
1184 let send = ui
1186 .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
1187 .inner
1188 .clicked();
1189 if send {
1190 match f.to_request() {
1191 Ok(req) => {
1192 let fallback = shared.default_gateway();
1193 match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
1194 Ok(gateway) => {
1195 shared.push_cmd(UiCmd::SendOrder {
1196 req,
1197 gateway,
1198 mode: if f.auto_split {
1199 ek_oms::LockMode::Auto
1200 } else {
1201 ek_oms::LockMode::None
1202 },
1203 });
1204 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
1205 }
1206 Err(e) => {
1207 shared.log_line(format!("[下单] {e}"));
1208 err = Some(e);
1209 }
1210 }
1211 }
1212 Err(e) => {
1213 shared.log_line(format!("[下单] {e}"));
1214 err = Some(e);
1215 }
1216 }
1217 }
1218 if ui.button(BTN_CANCEL_ALL).clicked() {
1219 let mut n = 0usize;
1222 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
1223 let gw = if !o.gateway_name.is_empty() {
1225 o.gateway_name.clone()
1226 } else {
1227 shared.default_gateway().unwrap_or_default()
1228 };
1229 if gw.is_empty() {
1230 continue;
1231 }
1232 shared.push_cmd(UiCmd::CancelOrder {
1233 orderid: o.orderid.clone(),
1234 gateway: gw,
1235 });
1236 n += 1;
1237 }
1238 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
1239 }
1240 });
1241 ui.end_row();
1242 ui.label("代码回车 = 订阅");
1243 ui.label("");
1244 ui.end_row();
1245 });
1246 if let Some(e) = err {
1247 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
1248 }
1249 if no_accounts {
1250 ui.label(
1252 egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1253 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1254 );
1255 }
1256 if f != before {
1257 shared.set_order_form(f.clone());
1258 }
1259}
1260
1261fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1265 let f = shared.order_form();
1266 match tick_of(shared, &f.exchange, &f.symbol) {
1267 Some(t) => {
1268 for i in (1..=5).rev() {
1269 let (p, v) = match i {
1270 1 => (t.ask_price_1, t.ask_volume_1),
1271 2 => (t.ask_price_2, t.ask_volume_2),
1272 3 => (t.ask_price_3, t.ask_volume_3),
1273 4 => (t.ask_price_4, t.ask_volume_4),
1274 _ => (t.ask_price_5, t.ask_volume_5),
1275 };
1276 ui.label(
1277 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
1278 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
1279 );
1280 }
1281 for i in 1..=5 {
1282 let (p, v) = match i {
1283 1 => (t.bid_price_1, t.bid_volume_1),
1284 2 => (t.bid_price_2, t.bid_volume_2),
1285 3 => (t.bid_price_3, t.bid_volume_3),
1286 4 => (t.bid_price_4, t.bid_volume_4),
1287 _ => (t.bid_price_5, t.bid_volume_5),
1288 };
1289 ui.label(
1290 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
1291 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
1292 );
1293 }
1294 }
1295 None => {
1296 ui.label("(无 tick)");
1297 }
1298 }
1299}
1300
1301fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
1304 let tab = shared.topleft_tab();
1305 ui.horizontal(|ui| {
1306 if tab_label(
1307 ui,
1308 shared,
1309 tab == TopLeftTab::Orders,
1310 TopLeftTab::Orders.label(),
1311 )
1312 .clicked()
1313 {
1314 shared.set_topleft_tab(TopLeftTab::Orders);
1315 }
1316 if tab_label(
1317 ui,
1318 shared,
1319 tab == TopLeftTab::Active,
1320 TopLeftTab::Active.label(),
1321 )
1322 .clicked()
1323 {
1324 shared.set_topleft_tab(TopLeftTab::Active);
1325 }
1326 });
1327 render_orders_table(ui, shared, tab == TopLeftTab::Active);
1328}
1329
1330fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1334 let ts = shared.trade_snap();
1335 let known_gws = account_gateways(shared);
1337 let rows: Vec<_> = ts
1340 .orders
1341 .iter()
1342 .filter(|o| !active_only || order_active(o.status))
1343 .collect();
1344 const ORDER_HEADERS: [&str; 17] = [
1346 "代码",
1347 "名称",
1348 "交易所",
1349 "类型",
1350 "方向",
1351 "开平",
1352 "价格",
1353 "总量",
1354 "已成",
1355 "成交均价",
1356 "状态",
1357 "状态描述",
1358 "时间",
1359 "委托号",
1360 "来源",
1361 "接口",
1362 "操作",
1363 ];
1364 if rows.is_empty() {
1365 pinned_table(
1368 ui,
1369 shared,
1370 if active_only {
1371 "active_grid"
1372 } else {
1373 "order_grid"
1374 },
1375 17,
1376 &ORDER_HEADERS,
1377 |ui| {
1378 ui.label(if active_only {
1379 "(无未成交)"
1380 } else {
1381 "(无委托:等网关 send_order)"
1382 });
1383 },
1384 );
1385 return;
1386 }
1387 pinned_table(
1388 ui,
1389 shared,
1390 if active_only {
1391 "active_grid"
1392 } else {
1393 "order_grid"
1394 },
1395 17,
1396 &ORDER_HEADERS,
1397 |ui| {
1398 for o in &rows {
1399 let key = format!("{}.{}", o.gateway_name, o.orderid);
1403 let row_sel = shared.row_selected("orders", &key);
1404 let mut cells = Vec::with_capacity(17);
1405 let mut dbl = false;
1406 let sel = ui.selectable_label(row_sel, o.symbol.clone());
1407 if sel.clicked() {
1408 shared.select_row("orders", &key);
1409 }
1410 dbl |= sel.double_clicked();
1411 cells.push(sel.rect);
1412 macro_rules! cell {
1413 ($base:expr, $col:expr) => {{
1414 let base = $base;
1415 let r = click_cell(ui, base.rect, ("ord", &key, $col));
1416 if r.clicked() {
1417 shared.select_row("orders", &key);
1418 }
1419 dbl |= r.double_clicked();
1420 cells.push(base.rect);
1421 }};
1422 }
1423 cell!(
1424 ui.label(if o.name.is_empty() {
1425 "-".to_string()
1426 } else {
1427 o.name.clone()
1428 }),
1429 1
1430 );
1431 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1432 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1433 cell!(
1434 ui.label(
1435 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1436 if o.direction == Some(Direction::SHORT) {
1437 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1438 } else {
1439 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1440 },
1441 ),
1442 ),
1443 4
1444 );
1445 cell!(ui.label(o.offset.as_str().to_string()), 5);
1446 cell!(ui.label(format!("{:.1}", o.price)), 6);
1447 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1448 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1449 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1450 cell!(
1451 ui.label(egui::RichText::new(o.status.as_str()).color(
1452 if o.status == ek_data::constant::Status::REJECTED {
1453 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1454 } else if o.status == ek_data::constant::Status::ALLTRADED {
1455 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1456 } else {
1457 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1458 },
1459 )),
1460 10
1461 );
1462 cell!(ui.label(o.status_msg.clone()), 11);
1463 cell!(ui.label(fmt_dt(o.datetime)), 12);
1464 cell!(ui.label(o.orderid.clone()), 13);
1465 cell!(
1466 ui.label(if o.reference.is_empty() {
1467 "-".to_string()
1468 } else {
1469 o.reference.clone()
1470 }),
1471 14
1472 );
1473 cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1474 if dbl && order_active(o.status) {
1475 let gw = if !o.gateway_name.is_empty() {
1477 o.gateway_name.clone()
1478 } else {
1479 shared.default_gateway().unwrap_or_default()
1480 };
1481 if !gw.is_empty() {
1482 shared.push_cmd(UiCmd::CancelOrder {
1483 orderid: o.orderid.clone(),
1484 gateway: gw,
1485 });
1486 }
1487 }
1488 let hr = ui.horizontal(|ui| {
1489 if order_active(o.status)
1490 && ui
1491 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1492 .clicked()
1493 {
1494 let gw = if !o.gateway_name.is_empty() {
1497 o.gateway_name.clone()
1498 } else {
1499 shared.default_gateway().unwrap_or_default()
1500 };
1501 if gw.is_empty() {
1502 shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1503 } else {
1504 shared.push_cmd(UiCmd::CancelOrder {
1505 orderid: o.orderid.clone(),
1506 gateway: gw,
1507 });
1508 }
1509 }
1510 });
1511 cells.push(hr.response.rect);
1512 paint_row_bg(ui, &cells, row_sel);
1513 ui.end_row();
1514 }
1515 },
1516 );
1517}
1518
1519fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1521 tab_label(ui, shared, true, "成交");
1524 let ts = shared.trade_snap();
1525 let known_gws = account_gateways(shared);
1527 const TRADE_HEADERS: [&str; 12] = [
1529 "成交号",
1530 "代码",
1531 "名称",
1532 "交易所",
1533 "方向",
1534 "开平",
1535 "价格",
1536 "数量",
1537 "成交额",
1538 "时间",
1539 "委托号",
1540 "接口",
1541 ];
1542 if ts.trades.is_empty() {
1543 pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1545 ui.label("(无成交)");
1546 });
1547 return;
1548 }
1549 pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1550 for t in &ts.trades {
1551 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1554 let row_sel = shared.row_selected("trades", &key);
1555 let mut cells = Vec::with_capacity(12);
1556 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1557 if sel.clicked() {
1558 shared.select_row("trades", &key);
1559 }
1560 cells.push(sel.rect);
1561 macro_rules! cell {
1562 ($base:expr, $col:expr) => {{
1563 let base = $base;
1564 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1565 if r.clicked() {
1566 shared.select_row("trades", &key);
1567 }
1568 cells.push(base.rect);
1569 }};
1570 }
1571 cell!(ui.label(t.symbol.clone()), 1);
1572 cell!(
1573 ui.label(if t.name.is_empty() {
1574 "-".to_string()
1575 } else {
1576 t.name.clone()
1577 }),
1578 2
1579 );
1580 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1581 cell!(
1582 ui.label(
1583 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1584 if t.direction == Some(Direction::SHORT) {
1585 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1586 } else {
1587 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1588 },
1589 ),
1590 ),
1591 4
1592 );
1593 cell!(ui.label(t.offset.as_str().to_string()), 5);
1594 cell!(ui.label(format!("{:.1}", t.price)), 6);
1595 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1596 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1597 cell!(ui.label(fmt_dt(t.datetime)), 9);
1598 cell!(ui.label(t.orderid.clone()), 10);
1599 cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1600 paint_row_bg(ui, &cells, row_sel);
1601 ui.end_row();
1602 }
1603 });
1604}
1605
1606fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1609 tab_label(ui, shared, true, "持仓");
1611 let ts = shared.trade_snap();
1612 let known_gws = account_gateways(shared);
1614 const POS_HEADERS: [&str; 16] = [
1616 "代码",
1617 "名称",
1618 "交易所",
1619 "方向",
1620 "数量",
1621 "昨仓",
1622 "可用",
1623 "冻结",
1624 "在途",
1625 "市值",
1626 "成本",
1627 "均价",
1628 "最新价",
1629 "盈亏",
1630 "盈亏比",
1631 "接口",
1632 ];
1633 if ts.positions.is_empty() {
1634 pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
1636 ui.label("(无持仓)");
1637 });
1638 return;
1639 }
1640 pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
1641 for p in &ts.positions {
1642 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1646 let row_sel = shared.row_selected("positions", &key);
1647 let mut cells = Vec::with_capacity(16);
1648 let mut dbl = false;
1649 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1650 if sel.clicked() {
1651 shared.select_row("positions", &key);
1652 }
1653 dbl |= sel.double_clicked();
1654 cells.push(sel.rect);
1655 macro_rules! cell {
1656 ($base:expr, $col:expr) => {{
1657 let base = $base;
1658 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1659 if r.clicked() {
1660 shared.select_row("positions", &key);
1661 }
1662 dbl |= r.double_clicked();
1663 cells.push(base.rect);
1664 }};
1665 }
1666 cell!(
1667 ui.label(if p.name.is_empty() {
1668 "-".to_string()
1669 } else {
1670 p.name.clone()
1671 }),
1672 1
1673 );
1674 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1675 cell!(
1676 ui.label(egui::RichText::new(p.direction.as_str()).color(
1677 if p.direction == ek_data::constant::Direction::SHORT {
1678 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1679 } else {
1680 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1681 },
1682 )),
1683 3
1684 );
1685 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1686 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1687 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1688 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1689 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1690 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1691 cell!(ui.label(format!("{:.2}", p.price)), 10);
1692 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1693 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1694 cell!(
1695 ui.label(
1696 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1697 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1698 } else {
1699 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1700 }),
1701 ),
1702 13
1703 );
1704 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1705 cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1706 if dbl {
1707 let mut f = shared.order_form();
1708 f.symbol = p.symbol.clone();
1709 f.exchange = p.exchange.as_str().to_string();
1710 f.direction = p.direction;
1711 if !p.gateway_name.is_empty() {
1713 f.gateway = p.gateway_name.clone();
1714 }
1715 shared.set_order_form(f);
1716 shared.log_line(format!(
1717 "[ops] 持仓带入下单面板 {}(接口 {})",
1718 p.symbol, p.gateway_name
1719 ));
1720 }
1721 paint_row_bg(ui, &cells, row_sel);
1722 ui.end_row();
1723 }
1724 });
1725}
1726
1727fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1728 let tab = shared.bottomright_tab();
1729 ui.horizontal(|ui| {
1730 if tab_label(
1731 ui,
1732 shared,
1733 tab == BottomRightTab::Account,
1734 BottomRightTab::Account.label(),
1735 )
1736 .clicked()
1737 {
1738 shared.set_bottomright_tab(BottomRightTab::Account);
1739 }
1740 if tab_label(
1741 ui,
1742 shared,
1743 tab == BottomRightTab::Logs,
1744 BottomRightTab::Logs.label(),
1745 )
1746 .clicked()
1747 {
1748 shared.set_bottomright_tab(BottomRightTab::Logs);
1749 }
1750 });
1751 match tab {
1752 BottomRightTab::Account => {
1753 let ts = shared.trade_snap();
1754 if ts.accounts.is_empty() {
1755 ui.label("(无资金:等网关 eAccount.)");
1756 return;
1757 }
1758 let mut heads: Vec<(egui::Rect, String)> = Vec::new();
1759 egui::Grid::new("acct_grid").num_columns(5).show(ui, |ui| {
1760 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1761 let r = ui.label(egui::RichText::new(h).strong().small());
1762 heads.push((r.rect, h.to_string()));
1763 }
1764 ui.end_row();
1765 for a in &ts.accounts {
1766 ui.label(a.accountid.clone());
1767 ui.label(a.gateway_name.clone());
1768 ui.label(format!("{:.2}", a.balance));
1769 ui.label(format!("{:.2}", a.available()));
1770 ui.label(format!("{:.2}", a.frozen));
1771 ui.end_row();
1772 }
1773 });
1774 header_band(ui, shared, &heads);
1775 }
1776 BottomRightTab::Logs => {
1777 render_logs(ui, shared);
1778 }
1779 }
1780}
1781
1782fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1797 ui.horizontal(|ui| {
1798 if ui.button(BTN_CLEAR_LOGS).clicked() {
1799 shared.clear_logs(); }
1801 });
1802 let (entries, spilled) = shared.logs_entries();
1803 if spilled > 0 {
1804 ui.label(format!(
1809 "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
1810 crate::state::RING_CAP
1811 ));
1812 }
1813 pinned_table(ui, shared, "log_grid", 2, &["时间", "信息"], |ui| {
1814 if entries.is_empty() {
1815 ui.label("(无日志)");
1816 ui.label("");
1817 ui.end_row();
1818 return;
1819 }
1820 for e in &entries {
1821 ui.monospace(e.at.clone());
1822 ui.monospace(e.text.clone());
1823 ui.end_row();
1824 }
1825 });
1826}
1827
1828struct WinGeom {
1832 inner: [f32; 2],
1833 min_inner: [f32; 2],
1834 maximized: bool,
1835}
1836
1837fn show_independent_window(
1852 ctx: &egui::Context,
1853 id_salt: impl std::hash::Hash,
1854 title: &str,
1855 geom: &WinGeom,
1856 embedded: impl Fn(&egui::Context) -> bool,
1857 body: impl Fn(&mut egui::Ui) -> bool,
1858) -> bool {
1859 let closed = std::cell::Cell::new(false);
1860 let mut builder = egui::ViewportBuilder::default()
1861 .with_title(title)
1862 .with_inner_size(geom.inner)
1863 .with_min_inner_size(geom.min_inner)
1864 .with_minimize_button(true)
1865 .with_maximize_button(true)
1866 .with_close_button(true);
1867 if geom.maximized {
1868 builder = builder.with_maximized(true);
1869 }
1870 ctx.show_viewport_immediate(
1871 egui::ViewportId::from_hash_of(id_salt),
1872 builder,
1873 |ctx2, class| {
1874 if class == egui::ViewportClass::Embedded {
1875 closed.set(embedded(ctx2));
1876 return;
1877 }
1878 if ctx2.input(|i| i.viewport().close_requested()) {
1879 closed.set(true);
1880 return;
1881 }
1882 egui::CentralPanel::default().show(ctx2, |ui| {
1883 if body(ui) {
1884 closed.set(true);
1885 }
1886 });
1887 },
1888 );
1889 closed.get()
1890}
1891
1892fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1893 if shared.ops_window_open() {
1895 let closed = show_independent_window(
1896 ctx,
1897 "ops_win",
1898 "运维窗",
1899 &WinGeom {
1900 inner: [680.0, 420.0],
1901 min_inner: [420.0, 260.0],
1902 maximized: false,
1903 },
1904 |ctx2| {
1905 let mut open = shared.ops_window_open();
1906 egui::Window::new("运维窗")
1907 .id(egui::Id::new("ops_win"))
1908 .default_pos([80.0, 320.0])
1909 .default_size([640.0, 380.0])
1910 .open(&mut open)
1911 .show(ctx2, |ui| {
1912 render_ops_window(ui, shared);
1913 });
1914 shared.set_ops_window_open(open);
1915 !open
1916 },
1917 |ui| {
1918 render_ops_window(ui, shared);
1919 false
1920 },
1921 );
1922 if closed {
1923 shared.set_ops_window_open(false);
1924 }
1925 }
1926
1927 if shared.accounts_window_open() {
1930 let closed = show_independent_window(
1931 ctx,
1932 "account_win",
1933 "账户面板",
1934 &WinGeom {
1935 inner: [760.0, 520.0],
1936 min_inner: [520.0, 320.0],
1937 maximized: false,
1938 },
1939 |ctx2| {
1940 let mut open = shared.accounts_window_open();
1941 egui::Window::new("账户面板")
1942 .id(egui::Id::new("account_win"))
1943 .default_pos([640.0, 320.0])
1944 .default_size([720.0, 470.0])
1945 .open(&mut open)
1946 .show(ctx2, |ui| {
1947 render_accounts_window(ui, shared);
1948 });
1949 shared.set_accounts_window_open(open);
1950 !open
1951 },
1952 |ui| {
1953 render_accounts_window(ui, shared);
1954 false
1955 },
1956 );
1957 if closed {
1958 shared.set_accounts_window_open(false);
1959 }
1960 }
1961
1962 if shared.account_edit_open() {
1966 let closed = show_independent_window(
1967 ctx,
1968 "account_edit_win",
1969 "账户配置",
1970 &WinGeom {
1971 inner: [680.0, 340.0],
1972 min_inner: [480.0, 240.0],
1973 maximized: false,
1974 },
1975 |ctx2| {
1976 let mut open = shared.account_edit_open();
1977 let title = match shared.editing_account() {
1978 Some(gw) => format!("账户配置 — 编辑 {gw}"),
1979 None => "账户配置 — 添加".to_string(),
1980 };
1981 egui::Window::new(title)
1982 .id(egui::Id::new("account_edit_win"))
1983 .default_pos([700.0, 380.0])
1984 .default_size([660.0, 320.0])
1985 .open(&mut open)
1986 .show(ctx2, |ui| {
1987 render_account_edit_window(ui, shared);
1988 });
1989 if !open {
1990 shared.end_account_edit();
1991 }
1992 !open
1993 },
1994 |ui| {
1995 render_account_edit_window(ui, shared);
1996 false
1997 },
1998 );
1999 if closed && shared.account_edit_open() {
2000 shared.end_account_edit();
2001 }
2002 }
2003
2004 if shared.contracts_window_open() {
2006 let closed = show_independent_window(
2007 ctx,
2008 "contracts_win",
2009 "查询合约窗",
2010 &WinGeom {
2011 inner: [620.0, 460.0],
2012 min_inner: [420.0, 260.0],
2013 maximized: false,
2014 },
2015 |ctx2| {
2016 let mut open = shared.contracts_window_open();
2017 egui::Window::new("查询合约窗")
2018 .id(egui::Id::new("contracts_win"))
2019 .default_pos([420.0, 300.0])
2020 .default_size([560.0, 420.0])
2021 .open(&mut open)
2022 .show(ctx2, |ui| {
2023 render_contracts_window(ui, shared);
2024 });
2025 shared.set_contracts_window_open(open);
2026 !open
2027 },
2028 |ui| {
2029 render_contracts_window(ui, shared);
2030 false
2031 },
2032 );
2033 if closed {
2034 shared.set_contracts_window_open(false);
2035 }
2036 }
2037
2038 let mut closed: Vec<String> = Vec::new();
2042 for sym in shared.kline_symbols() {
2043 let is_closed = show_independent_window(
2044 ctx,
2045 format!("kline_win:{sym}"),
2046 &format!("K线 — {sym}"),
2047 &WinGeom {
2048 inner: [560.0, 360.0],
2049 min_inner: [360.0, 220.0],
2050 maximized: true,
2051 },
2052 |ctx2| {
2053 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
2054 let resp = egui::Window::new(format!("K线 — {sym}"))
2055 .id(egui::Id::new(("kline_win", sym.clone())))
2056 .default_pos([300.0, 340.0])
2057 .default_size([540.0, 330.0])
2058 .show(ctx2, |ui| {
2059 let cell = shared
2060 .market()
2061 .cells()
2062 .into_iter()
2063 .find(|c| c.symbol == sym);
2064 render_kline_cell(ui, cell.as_ref());
2065 });
2066 esc || resp.is_none()
2067 },
2068 |ui| {
2069 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
2070 return true;
2071 }
2072 let cell = shared
2073 .market()
2074 .cells()
2075 .into_iter()
2076 .find(|c| c.symbol == sym);
2077 render_kline_cell(ui, cell.as_ref());
2078 false
2079 },
2080 );
2081 if is_closed {
2082 closed.push(sym);
2083 }
2084 }
2085 for s in closed {
2086 shared.close_kline(&s);
2087 }
2088}
2089
2090fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
2093 ui.label("插件");
2094 let snap = shared.snapshot();
2095 let account_gateways: std::collections::HashSet<String> = shared
2097 .accounts()
2098 .iter()
2099 .map(|a| a.gateway.clone())
2100 .collect();
2101 let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
2102 egui::Grid::new("plug_grid").num_columns(6).show(ui, |ui| {
2103 for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
2104 let r = ui.label(egui::RichText::new(h).strong().small());
2105 plug_heads.push((r.rect, h.to_string()));
2106 }
2107 ui.end_row();
2108 if snap.rows.is_empty() {
2109 ui.label("(快照空:等 eTimer 首拉)");
2110 ui.end_row();
2111 }
2112 for r in &snap.rows {
2113 if is_quarantined(&r.state) {
2114 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
2115 ui.label(
2116 egui::RichText::new(state_text(r))
2117 .color(egui::Color32::RED)
2118 .strong(),
2119 );
2120 } else {
2121 ui.label(r.name.clone());
2122 ui.label(state_text(r));
2123 }
2124 ui.label(r.version.clone());
2125 ui.label(format!("deps:[{}]", r.deps.join(",")));
2126 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
2127 ui.horizontal(|ui| {
2128 if account_gateways.contains(&r.name) {
2134 ui.label(
2135 egui::RichText::new("起停归『账户』面板(带撤单守卫)")
2136 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2137 );
2138 } else {
2139 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
2140 shared.push_cmd(UiCmd::Start {
2144 factory: r.name.clone(),
2145 name: String::new(),
2146 });
2147 }
2148 let armed = shared.pending_confirm();
2149 let armed_here = ui
2150 .small_button(remove_button_label(&r.name, armed.as_deref()))
2151 .clicked()
2152 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
2153 if armed_here {
2154 shared.push_cmd(UiCmd::Remove {
2155 name: r.name.clone(),
2156 });
2157 }
2158 }
2159 });
2160 ui.end_row();
2161 }
2162 });
2163 header_band(ui, shared, &plug_heads);
2164
2165 if let Some(name) = shared.pending_confirm() {
2166 ui.horizontal(|ui| {
2167 ui.label(
2168 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
2169 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2170 );
2171 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2172 shared.confirm_cancel();
2173 }
2174 });
2175 }
2176
2177 ui.label("服务");
2178 let flat: Vec<String> = snap
2179 .services
2180 .iter()
2181 .flat_map(|(n, ids)| {
2182 if ids.is_empty() {
2183 vec![format!("{n} 服务"), "-".to_string()]
2184 } else {
2185 ids.iter()
2186 .flat_map(|id| [format!("{n} 服务"), id.clone()])
2187 .collect()
2188 }
2189 })
2190 .collect();
2191 if flat.is_empty() {
2192 ui.label("(无服务目录)");
2193 } else {
2194 let rows = flat.len().div_ceil(6); let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
2198 egui::Grid::new("svc_grid").num_columns(6).show(ui, |ui| {
2199 for _ in 0..3 {
2200 for h in ["服务", "实例"] {
2201 let r = ui.label(egui::RichText::new(h).strong().small());
2202 svc_heads.push((r.rect, h.to_string()));
2203 }
2204 }
2205 ui.end_row();
2206 for r in 0..rows {
2207 for c in 0..3 {
2208 let i = c * rows * 2 + r * 2;
2209 if i < flat.len() {
2210 ui.label(flat[i].clone());
2211 ui.label(flat[i + 1].clone());
2212 } else {
2213 ui.label("");
2214 ui.label("");
2215 }
2216 }
2217 ui.end_row();
2218 }
2219 });
2220 header_band(ui, shared, &svc_heads);
2221 }
2222
2223 ui.label("依赖");
2224 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
2225 ui.label("(全部无依赖)");
2226 }
2227 for r in &snap.rows {
2228 if !r.deps.is_empty() {
2229 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
2230 }
2231 }
2232}
2233
2234fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
2240 let accounts = shared.accounts();
2241 let locked = shared.accounts_locked();
2242 let md = shared.md_source();
2243 let snap = shared.trade_snap();
2244
2245 let row_label: std::collections::BTreeMap<String, String> = accounts
2249 .iter()
2250 .map(|a| {
2251 let d = a.display_name();
2252 let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
2253 (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
2254 })
2255 .collect();
2256
2257 ui.horizontal(|ui| {
2259 ui.label(
2260 egui::RichText::new(format!(
2261 "当前行情源 = {}",
2262 if md.is_empty() {
2263 "(未指定)".to_string()
2264 } else {
2265 md.clone()
2266 }
2267 ))
2268 .strong(),
2269 );
2270 ui.label(format!(
2271 "账户 {}/{}",
2272 accounts.len(),
2273 crate::state::MAX_ACCOUNTS
2274 ));
2275 if shared.can_add_account() {
2276 if ui.button(BTN_ACCOUNT_ADD).clicked() {
2277 shared.begin_account_edit(None);
2278 }
2279 } else {
2280 ui.label(
2281 egui::RichText::new(format!(
2282 "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
2283 crate::state::MAX_ACCOUNTS
2284 ))
2285 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2286 );
2287 }
2288 });
2289
2290 const ACCOUNT_HEADERS: [&str; 7] = [
2291 "网关(派生名)",
2292 "别名",
2293 "账号",
2294 "状态",
2295 "行情源",
2296 "收录(勾→实)",
2297 "操作",
2298 ];
2299 let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
2305 egui::Grid::new("account_grid")
2306 .num_columns(7)
2307 .show(ui, |ui| {
2308 for h in &ACCOUNT_HEADERS {
2309 let r = ui.label(egui::RichText::new(*h).strong().small());
2310 acct_heads.push((r.rect, h.to_string()));
2311 }
2312 ui.end_row();
2313 if accounts.is_empty() {
2314 ui.label("(无账户:上方『添加账户』开配置弹窗)");
2315 for _ in 0..6 {
2316 ui.label("");
2317 }
2318 ui.end_row();
2319 }
2320 for a in &accounts {
2321 let is_md = a.gateway == md;
2322 let name = row_label
2323 .get(&a.gateway)
2324 .cloned()
2325 .unwrap_or_else(|| a.gateway.clone());
2326 ui.label(a.gateway.clone());
2327 ui.label(if a.alias.trim().is_empty() {
2329 "—".to_string()
2330 } else {
2331 a.alias.clone()
2332 });
2333 ui.label(a.account.clone());
2334 match a.stage {
2340 crate::state::AccountStage::AssembleFailed => {
2341 ui.add(
2342 egui::Label::new(
2343 egui::RichText::new(format!(
2344 "装配失败:{}",
2345 a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
2346 ))
2347 .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
2348 )
2349 .wrap_mode(egui::TextWrapMode::Extend),
2350 );
2351 }
2352 _ => {
2353 let color = match a.login {
2354 crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
2355 crate::state::LoginState::LoginFailed
2356 | crate::state::LoginState::FrontDown => {
2357 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2358 }
2359 crate::state::LoginState::QueryStarved => {
2360 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
2361 }
2362 _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
2364 };
2365 ui.add(
2366 egui::Label::new(
2367 egui::RichText::new(a.status_label()).color(color),
2368 )
2369 .wrap_mode(egui::TextWrapMode::Extend),
2370 );
2371 }
2372 }
2373 ui.horizontal(|ui| {
2375 if is_md {
2376 ui.label(egui::RichText::new("● 行情源").strong());
2377 }
2378 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
2379 ui.small_button(if is_md {
2380 "已是行情源"
2381 } else {
2382 "设为行情源"
2383 })
2384 });
2385 if resp.inner.clicked() {
2386 match shared.set_md_source(&a.gateway) {
2387 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
2388 Err(e) => shared.log_line(format!("[账户] {e}")),
2389 }
2390 }
2391 });
2392 ui.vertical(|ui| {
2395 ui.add(
2396 egui::Label::new(egui::RichText::new(format!(
2397 "勾:{}",
2398 classes_label(&a.form.product_classes())
2399 )))
2400 .wrap_mode(egui::TextWrapMode::Extend),
2401 );
2402 ui.add(
2403 egui::Label::new(egui::RichText::new(format!(
2404 "实:{}",
2405 if a.effective_classes.is_empty() {
2406 "—".to_string()
2407 } else {
2408 classes_label(&a.effective_classes)
2409 }
2410 )))
2411 .wrap_mode(egui::TextWrapMode::Extend),
2412 );
2413 });
2414 ui.horizontal(|ui| {
2416 let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2417 let idle = a.stage == crate::state::AccountStage::NotAssembled;
2418 let stopped = a.login == crate::state::LoginState::Stopped;
2421 if failed || idle || stopped {
2422 let label = if failed {
2425 format!("重试 {name}")
2426 } else {
2427 format!("连接 {name}")
2428 };
2429 if ui.small_button(label).clicked() {
2430 push_connect(shared, &a.gateway, &a.form);
2431 }
2432 }
2433 let open = snap
2435 .orders
2436 .iter()
2437 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2438 .count();
2439 if a.stage == crate::state::AccountStage::Assembled
2446 && a.login != crate::state::LoginState::Stopped
2447 && open == 0
2448 && ui.small_button(format!("断开 {name}")).clicked()
2449 {
2450 shared.push_cmd(UiCmd::Disconnect {
2451 gateway: a.gateway.clone(),
2452 });
2453 shared.log_line(format!(
2454 "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2455 gateway = a.gateway
2456 ));
2457 }
2458 if ui.small_button(format!("编辑 {name}")).clicked() {
2459 shared.begin_account_edit(Some(&a.gateway));
2462 }
2463 let has_pos = snap
2464 .positions
2465 .iter()
2466 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2467 if open > 0 {
2468 ui.label(
2470 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2471 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2472 );
2473 if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2477 for o in snap
2478 .orders
2479 .iter()
2480 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2481 {
2482 shared.push_cmd(UiCmd::CancelOrder {
2483 orderid: o.orderid.clone(),
2484 gateway: a.gateway.clone(),
2485 });
2486 }
2487 shared.log_line(format!(
2488 "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2489 a.gateway, open
2490 ));
2491 }
2492 } else {
2493 let armed = shared.pending_confirm();
2496 let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2497 let fired = ui
2498 .small_button(remove_button_label(
2499 &name,
2500 if armed_here { Some(name.as_str()) } else { None },
2501 ))
2502 .clicked()
2503 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2504 if fired {
2505 shared.push_cmd(UiCmd::Remove {
2506 name: a.gateway.clone(),
2507 });
2508 shared.remove_account(&a.gateway);
2509 shared.end_account_edit_if_editing(&a.gateway);
2512 if has_pos {
2513 shared.log_line(format!(
2514 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2515 a.gateway
2516 ));
2517 }
2518 }
2519 }
2520 });
2521 ui.end_row();
2522 }
2523 });
2524 header_band(ui, shared, &acct_heads);
2525
2526 if let Some(gw) = shared.pending_confirm() {
2527 let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2529 ui.horizontal(|ui| {
2530 ui.label(
2531 egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2532 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2533 );
2534 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2535 shared.confirm_cancel();
2536 }
2537 });
2538 }
2539
2540 ui.label(if locked {
2541 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2542 } else {
2543 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2544 });
2545 ui.label(
2547 egui::RichText::new(format!(
2548 "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2549 crate::state::STATUS_POLL_TICKS
2550 ))
2551 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2552 );
2553 ui.label(
2558 egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
2559 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2560 );
2561 for a in accounts
2570 .iter()
2571 .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2572 {
2573 let note = a.status_note.clone().unwrap_or_default();
2574 let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2575 let color = if alarm {
2576 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2577 } else {
2578 egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
2579 };
2580 ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2581 }
2582 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2584 if src.form.md_addr.trim().is_empty() {
2585 ui.label(
2586 egui::RichText::new(format!(
2587 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2588 src.gateway,
2589 row_label
2590 .get(&src.gateway)
2591 .cloned()
2592 .unwrap_or_else(|| src.account.clone())
2593 ))
2594 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2595 );
2596 }
2597 }
2598}
2599
2600fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2606 let editing: Option<crate::state::AccountRow> =
2607 shared.editing_account().and_then(|g| shared.account(&g));
2608 let editing_on = editing.is_some();
2609 let md = shared.md_source();
2610 let locked = shared.accounts_locked();
2611 let mut form: crate::state::CtpForm = shared.ctp_form();
2612 let mut alias = shared.edit_alias();
2613 if let Some(e) = shared.edit_error() {
2615 ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2616 }
2617 ui.horizontal(|ui| {
2623 ui.label("别名");
2624 if ui
2625 .add(
2626 egui::TextEdit::singleline(&mut alias)
2627 .hint_text("显示用,可空")
2628 .desired_width(120.0),
2629 )
2630 .changed()
2631 {
2632 shared.set_edit_alias(alias.clone());
2633 }
2634 ui.label("broker");
2635 ui.add_enabled_ui(!editing_on, |ui| {
2636 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2637 });
2638 ui.label("td 前置");
2639 ctp_edit(
2640 ui,
2641 shared,
2642 &mut form.td_addr,
2643 "tcp://host:40001",
2644 CtpField::TdAddr,
2645 );
2646 ui.label("td 用户");
2647 ui.add_enabled_ui(!editing_on, |ui| {
2648 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2649 });
2650 });
2651 if let Some(r) = &editing {
2652 ui.label(
2655 egui::RichText::new(format!(
2656 "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2657 r.gateway
2658 ))
2659 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2660 );
2661 }
2662 ui.horizontal(|ui| {
2663 ui.label("td 密码");
2664 ctp_edit(
2665 ui,
2666 shared,
2667 &mut form.td_password,
2668 "密码",
2669 CtpField::TdPassword,
2670 );
2671 ui.label("AppID");
2672 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2673 ui.label("AuthCode");
2674 ctp_edit(
2675 ui,
2676 shared,
2677 &mut form.td_auth_code,
2678 "授权码",
2679 CtpField::AuthCode,
2680 );
2681 });
2682 let md_editable = match &editing {
2685 None => true,
2686 Some(r) => !locked || r.gateway == md,
2687 };
2688 ui.horizontal(|ui| {
2689 ui.label("md 前置");
2690 ui.add_enabled_ui(md_editable, |ui| {
2691 ctp_edit(
2692 ui,
2693 shared,
2694 &mut form.md_addr,
2695 "tcp://host:40011",
2696 CtpField::MdAddr,
2697 );
2698 ui.label("md 用户");
2699 ctp_edit(
2700 ui,
2701 shared,
2702 &mut form.md_user,
2703 "空=td 回落",
2704 CtpField::MdUser,
2705 );
2706 ui.label("md 密码");
2707 ctp_edit(
2708 ui,
2709 shared,
2710 &mut form.md_password,
2711 "空=td 回落",
2712 CtpField::MdPassword,
2713 );
2714 });
2715 if !md_editable {
2716 ui.label(
2717 egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2718 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2719 );
2720 }
2721 });
2722 ui.horizontal(|ui| {
2725 ui.label("收录勾选:");
2726 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2727 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2728 if fut || opt {
2729 shared.set_ctp_form(form.clone());
2730 }
2731 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2732 });
2733 ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
2736 if let Some(r) = &editing {
2739 ui.horizontal(|ui| {
2740 ui.label("网关实际收录(只读回带):");
2741 ui.label(if r.effective_classes.is_empty() {
2742 "—(尚未装配或无回带)".to_string()
2743 } else {
2744 classes_label(&r.effective_classes)
2745 });
2746 ui.label("装配/登录状态(只读):");
2747 ui.label(format!(
2748 "{}{}",
2749 r.status_label(),
2750 r.fail_note
2751 .as_ref()
2752 .map(|n| format!(" —— {n}"))
2753 .unwrap_or_default()
2754 ));
2755 });
2756 }
2757 if shared.account_edit_dirty() {
2760 ui.label(
2761 egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2762 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2763 );
2764 }
2765 ui.horizontal(|ui| {
2766 if ui.button("保存").clicked() {
2767 match save_account_draft(shared, &form, &alias, false) {
2768 Ok(()) => shared.end_account_edit(),
2769 Err(e) => {
2770 shared.log_line(format!("[账户] 保存被拦:{e}"));
2771 shared.set_edit_error(Some(e));
2772 }
2773 }
2774 }
2775 if ui.button("保存并连接").clicked() {
2776 match save_account_draft(shared, &form, &alias, true) {
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 shared.end_account_edit();
2786 }
2787 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2788 });
2789}
2790
2791fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2793 shared
2794 .accounts()
2795 .iter()
2796 .map(|a| a.gateway.clone())
2797 .collect()
2798}
2799
2800fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2808 if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2809 gateway.to_string()
2810 } else {
2811 format!("{gateway}(已摘除)")
2812 }
2813}
2814
2815fn classes_label(classes: &[String]) -> String {
2817 if classes.is_empty() {
2818 return "—".to_string();
2819 }
2820 classes
2821 .iter()
2822 .map(|c| match c.as_str() {
2823 "1" => "期货",
2824 "2" => "期权",
2825 other => other,
2826 })
2827 .collect::<Vec<_>>()
2828 .join("+")
2829}
2830
2831fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2836 shared.push_cmd(UiCmd::Connect {
2837 gateway: gateway.to_string(),
2838 form: form.clone(),
2839 });
2840 shared.note_connect_sent(gateway);
2841 shared.log_line(format!(
2842 "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2843 gateway
2844 ));
2845}
2846
2847fn save_account_draft(
2854 shared: &UiShared,
2855 form: &crate::state::CtpForm,
2856 alias: &str,
2857 connect: bool,
2858) -> Result<(), String> {
2859 form.validate()?;
2860 if let Some(gw) = shared.editing_account() {
2861 if shared.account(&gw).is_none() {
2863 return Err(format!(
2864 "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2865 ));
2866 }
2867 shared.update_account_form(&gw, form.clone());
2868 if !shared.set_account_alias(&gw, alias) {
2869 return Err(format!(
2870 "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2871 ));
2872 }
2873 shared.log_line(format!(
2874 "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2875 ));
2876 if connect {
2877 push_connect(shared, &gw, form);
2878 }
2879 return Ok(());
2880 }
2881 let mut row = crate::state::AccountRow::derive(form)?;
2882 row.alias = alias.trim().to_string();
2883 let gateway = row.gateway.clone();
2884 let account = row.account.clone();
2885 shared.add_account(row.clone())?;
2886 let md = shared.md_source();
2890 let mut f = row.form.clone();
2891 if md.is_empty() && !shared.accounts_locked() {
2892 match shared.set_md_source(&gateway) {
2893 Ok(()) => {
2894 if f.md_addr.trim().is_empty() {
2895 shared.log_line(format!(
2896 "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2897 ));
2898 } else {
2899 shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2900 }
2901 }
2902 Err(e) => {
2903 shared.log_line(format!("[账户] {e}"));
2904 f.md_addr.clear();
2905 f.md_user.clear();
2906 f.md_password.clear();
2907 }
2908 }
2909 } else {
2910 f.md_addr.clear();
2911 f.md_user.clear();
2912 f.md_password.clear();
2913 if md.is_empty() {
2914 shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2915 }
2916 }
2917 shared.update_account_form(&gateway, f.clone());
2918 shared.log_line(format!(
2920 "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2921 ));
2922 if connect {
2923 push_connect(shared, &gateway, &f);
2924 }
2925 Ok(())
2926}
2927
2928fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2932 let mut q = shared.contract_query();
2933 ui.horizontal(|ui| {
2934 ui.label("代码或交易所(留空 = 全部):");
2935 if ui
2936 .add(
2937 egui::TextEdit::singleline(&mut q)
2938 .hint_text("cu 或 SHFE")
2939 .desired_width(200.0),
2940 )
2941 .changed()
2942 {
2943 shared.set_contract_query(q.clone());
2944 }
2945 });
2946 let rows = shared.contracts(&q);
2947 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2950 if rows.is_empty() {
2951 pinned_table(ui, shared, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2953 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2954 });
2955 return;
2956 }
2957 let selected = shared.selected_contracts();
2958 ui.horizontal(|ui| {
2963 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2964 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2965 if selected.is_empty() {
2966 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2967 } else {
2968 let symbols: Vec<String> = selected
2970 .iter()
2971 .map(|k| {
2972 k.split_once('.')
2973 .map(|(_, s)| s.to_string())
2974 .unwrap_or_else(|| k.clone())
2975 })
2976 .collect();
2977 match subscribe_target(shared) {
2978 Ok(gw) => {
2979 shared.push_cmd(UiCmd::CtpSubscribe {
2980 gateway: gw.clone(),
2981 symbols,
2982 });
2983 shared.log_line(format!(
2984 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2985 selected.len(),
2986 gw
2987 ));
2988 }
2989 Err(e) => shared.log_line(format!("[订阅] {e}")),
2990 }
2991 }
2992 }
2993 });
2994 pinned_table_grid(
2997 ui,
2998 shared,
2999 "contract_grid",
3000 5,
3001 &CONTRACT_HEADERS,
3002 rows.len(),
3003 |ui| {
3004 for c in &rows {
3005 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
3006 let mut on = selected.contains(&key);
3007 let row_sel = shared.row_selected("contracts", &key);
3011 let mut cells = Vec::with_capacity(5);
3012 let ck = ui.checkbox(&mut on, key.clone());
3013 if ck.changed() {
3014 shared.toggle_contract_selection(&key);
3015 shared.select_row("contracts", &key);
3016 }
3017 cells.push(ck.rect);
3018 macro_rules! cell {
3019 ($base:expr, $col:expr) => {{
3020 let base = $base;
3021 let r = click_cell(ui, base.rect, ("crt", &key, $col));
3022 if r.clicked() {
3023 shared.select_row("contracts", &key);
3024 }
3025 cells.push(base.rect);
3026 }};
3027 }
3028 cell!(ui.label(c.exchange.as_str().to_string()), 1);
3029 cell!(ui.label(c.name.clone()), 2);
3030 cell!(ui.label(format!("{:.0}", c.size)), 3);
3031 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
3032 paint_row_bg(ui, &cells, row_sel);
3033 ui.end_row();
3034 }
3035 },
3036 );
3037}
3038
3039enum CtpField {
3041 Broker,
3042 TdAddr,
3043 TdUser,
3044 TdPassword,
3045 AppId,
3046 AuthCode,
3047 MdAddr,
3048 MdUser,
3049 MdPassword,
3050}
3051
3052fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
3054 let is_secret = matches!(
3055 field,
3056 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
3057 );
3058 let mut w = egui::TextEdit::singleline(val)
3059 .hint_text(hint)
3060 .desired_width(210.0);
3063 if is_secret {
3064 w = w.password(true);
3065 }
3066 if ui.add(w).changed() {
3067 let mut f = shared.ctp_form();
3068 match field {
3069 CtpField::Broker => f.broker_id = val.clone(),
3070 CtpField::TdAddr => f.td_addr = val.clone(),
3071 CtpField::TdUser => f.td_user = val.clone(),
3072 CtpField::TdPassword => f.td_password = val.clone(),
3073 CtpField::AppId => f.td_app_id = val.clone(),
3074 CtpField::AuthCode => f.td_auth_code = val.clone(),
3075 CtpField::MdAddr => f.md_addr = val.clone(),
3076 CtpField::MdUser => f.md_user = val.clone(),
3077 CtpField::MdPassword => f.md_password = val.clone(),
3078 }
3079 shared.set_ctp_form(f);
3080 }
3081}
3082
3083fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
3087 let Some(c) = cell else {
3088 ui.label("(K线无格:等 dm 合成)");
3089 return;
3090 };
3091 if c.bars.is_empty() {
3092 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
3093 return;
3094 }
3095 let mut data = egui_charts::model::bar::BarData::new();
3099 for b in &c.bars {
3100 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
3101 .with_timezone(&beijing_tz())
3102 .with_timezone(&chrono::Utc);
3103 data.push(egui_charts::model::bar::Bar::new(
3104 dt,
3105 b.open_price,
3106 b.high_price,
3107 b.low_price,
3108 b.close_price,
3109 b.volume,
3110 ));
3111 }
3112 ui.label(format!(
3113 "K线 {}@{}s({} 根)",
3114 c.symbol,
3115 c.interval,
3116 c.bars.len()
3117 ));
3118 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
3119 chart.set_symbol(&c.symbol);
3120 chart.set_timeframe_label(&format!("{}s", c.interval));
3121 chart.enable_tracking_mode();
3122 let _ = chart.show(ui);
3123}
3124
3125#[cfg(test)]
3126mod tests {
3127 use super::*;
3128
3129 #[test]
3133 fn chrome_qdark_matches_qdarkstyle_palette() {
3134 let v = theme_visuals(crate::state::Theme::QDarkStyle);
3135 let ch = Chrome::of(crate::state::Theme::QDarkStyle, &v);
3136 assert_eq!(ch.line, egui::Color32::from_rgb(0x45, 0x53, 0x64));
3138 assert_eq!(ch.header_text, egui::Color32::from_rgb(0xDF, 0xE1, 0xE2));
3139 assert_eq!(ch.tab_head_fill, egui::Color32::from_rgb(0x45, 0x53, 0x64));
3141 assert_eq!(ch.tab_fill, egui::Color32::from_rgb(0x54, 0x68, 0x7A));
3144 assert_eq!(ch.tab_underline, egui::Color32::from_rgb(0x25, 0x9A, 0xE9));
3145 let fv = theme_visuals(crate::state::Theme::FinanceDark);
3147 let fch = Chrome::of(crate::state::Theme::FinanceDark, &fv);
3148 assert_eq!(fch.line, egui::Color32::from_rgb(0x3A, 0x46, 0x58));
3149 assert_eq!(fch.tab_fill, egui::Color32::from_rgb(0x2A, 0x44, 0x68));
3150 assert_eq!(fch.tab_underline, egui::Color32::from_rgb(0x6A, 0x9C, 0xD8));
3151 assert_ne!(fch.line, ch.line);
3152 assert_ne!(fch.tab_underline, ch.tab_underline);
3153 }
3154
3155 #[test]
3159 fn log_entries_stamp_beijing_wall_clock() {
3160 let mut ring = crate::state::LogRing::new(4);
3161 ring.push("[ops] 一行");
3162 let e = &ring.entries()[0];
3163 assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
3164 assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
3165 for seg in e.at.split(':') {
3166 assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
3167 }
3168 let want = chrono::Utc::now()
3169 .with_timezone(&beijing_tz())
3170 .format("%H:%M:%S")
3171 .to_string();
3172 let secs = |s: &str| -> i64 {
3173 s.split(':')
3174 .map(|x| x.parse::<i64>().unwrap())
3175 .fold(0, |acc, v| acc * 60 + v)
3176 };
3177 let (a, b) = (secs(&e.at), secs(&want));
3178 let d = (a - b).abs();
3179 assert!(
3181 d <= 2 || d > 86_398,
3182 "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
3183 );
3184 assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
3186 }
3187
3188 #[test]
3192 fn fmt_dt_shows_beijing_wall_clock() {
3193 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
3194 ek_data::time::civil_to_nanos(ek_data::time::Civil {
3195 year: 2026,
3196 month: 4,
3197 day: 16,
3198 hour: 1,
3199 minute: 30,
3200 second: 1,
3201 nanos: 0,
3202 }),
3203 );
3204 let beijing = expect.with_timezone(&beijing_tz());
3206 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
3207 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
3210 year: 2026,
3211 month: 4,
3212 day: 15,
3213 hour: 16,
3214 minute: 0,
3215 second: 30,
3216 nanos: 0,
3217 });
3218 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
3219 assert_eq!(fmt_dt(None), "-");
3221 }
3222}