1use ek_data::constant::{Direction, Offset, OrderType};
35use ek_station::wire::PlugRow;
36
37use crate::state::{
38 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
39};
40
41pub const BTN_CLEAR_LOGS: &str = "清空日志";
43pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
45pub const BTN_SEND_ORDER: &str = "委托";
47pub const BTN_CANCEL_ALL: &str = "全撤";
49pub const BTN_QUERY_RATE: &str = "流控查询";
51pub const BTN_CANCEL_ORDER: &str = "撤单";
53pub const BTN_CTP_CONNECT: &str = "连接 CTP";
55pub const MENU_OPS: &str = "运维";
57pub const MENU_CTP: &str = "网关";
59pub const MENU_HELP: &str = "帮助";
61pub const MENU_CONTRACTS: &str = "查询合约";
63pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
67
68fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
69 if armed_on == Some(name) {
70 format!("确认移除 {name} ?")
71 } else {
72 format!("移除 {name}")
73 }
74}
75
76fn state_text(r: &PlugRow) -> String {
77 r.state.clone()
78}
79
80fn fmt_dt(dt: Option<i64>) -> String {
82 dt.map(|ns| {
83 chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
84 .format("%H:%M:%S")
85 .to_string()
86 })
87 .unwrap_or_else(|| "-".to_string())
88}
89
90fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
96 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
97 return;
98 };
99 let hovered = ui
100 .input(|i| i.pointer.interact_pos())
101 .is_some_and(|p| union.expand(3.0).contains(p));
102 if selected {
103 ui.painter().rect_filled(
104 union.expand(3.0),
105 3.0,
106 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
107 );
108 } else if hovered {
109 ui.painter().rect_filled(
110 union.expand(3.0),
111 3.0,
112 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
113 );
114 }
115}
116
117fn order_active(status: ek_data::constant::Status) -> bool {
119 ek_data::constant::ACTIVE_STATUSES.contains(&status)
120}
121
122fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
125 ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
126}
127
128pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
132 let mut f = shared.order_form();
133 f.symbol = t.symbol.clone();
134 f.exchange = exchange.to_string();
135 match col {
136 8 => {
137 f.direction = Direction::SHORT;
138 f.price = format!("{:.1}", t.bid_price_1);
139 }
140 9 => f.direction = Direction::SHORT,
141 10 => {
142 f.direction = Direction::LONG;
143 f.price = format!("{:.1}", t.ask_price_1);
144 }
145 11 => f.direction = Direction::LONG,
146 _ => {}
147 }
148 shared.set_order_form(f);
149}
150
151fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
154 if symbol.is_empty() {
155 return None;
156 }
157 shared.tick_rows().into_iter().find(|t| {
158 t.symbol == symbol
159 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
160 })
161}
162
163pub fn apply_ui_style(ctx: &egui::Context) {
167 apply_ui_style_for(ctx, crate::state::Theme::default());
168}
169
170pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
175 ctx.style_mut(|style| {
176 style
177 .text_styles
178 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
179 style
180 .text_styles
181 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
182 style
183 .text_styles
184 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
185 style
186 .text_styles
187 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
188 style
189 .text_styles
190 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
191 });
192 ctx.set_visuals(theme_visuals(theme));
193}
194
195fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
198 use egui::Color32;
199 match t {
200 crate::state::Theme::EguiDark => egui::Visuals::dark(),
201 crate::state::Theme::EguiLight => egui::Visuals::light(),
202 crate::state::Theme::QtClassic => {
203 let mut v = egui::Visuals::light();
204 v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
205 v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
206 v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
207 v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
208 v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
209 v
210 }
211 crate::state::Theme::FinanceDark => {
212 let mut v = egui::Visuals::dark();
213 v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
214 v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
215 v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
216 v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
217 v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
218 v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
219 v.widgets.noninteractive.bg_stroke =
220 egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
221 v
222 }
223 }
224}
225
226pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
234 apply_ui_style_for(ctx, shared.theme());
235 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
237 render_menu_bar(ui, shared);
238 });
239
240 egui::TopBottomPanel::top("market_band")
242 .resizable(true)
243 .default_height(240.0)
244 .show(ctx, |ui| {
245 egui::ScrollArea::both()
249 .id_salt("market_band_scroll")
250 .auto_shrink([false, false])
251 .show(ui, |ui| {
252 render_market_band(ui, shared);
253 });
254 });
255
256 egui::SidePanel::left("order_side")
258 .resizable(true)
259 .default_width(250.0)
260 .show(ctx, |ui| {
261 egui::ScrollArea::vertical()
263 .id_salt("order_side_scroll")
264 .auto_shrink([false, false])
265 .show(ui, |ui| {
266 render_order_panel(ui, shared);
267 ui.separator();
268 render_book_frame(ui, shared);
269 });
270 });
271
272 egui::CentralPanel::default().show(ctx, |ui| {
274 let avail = ui.available_size();
275 let sp = ui.spacing().item_spacing;
276 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
282 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
283 ui.horizontal(|ui| {
284 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
285 render_topleft_group(ui, shared);
286 });
287 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
288 render_trades_group(ui, shared);
289 });
290 });
291 ui.horizontal(|ui| {
292 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
293 render_positions_group(ui, shared);
294 });
295 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
300 render_bottomright_group(ui, shared);
301 });
302 });
303 });
304
305 render_windows(ctx, shared);
307}
308
309fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
317 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
318 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
319 egui::Frame::group(ui.style()).show(&mut child, |ui| {
320 egui::ScrollArea::both()
321 .id_salt(id)
322 .auto_shrink([false, false])
323 .show(ui, |ui| {
324 ui.vertical(content);
325 });
326 });
327}
328
329fn group_frame_plain(
336 ui: &mut egui::Ui,
337 _id: &str,
338 w: f32,
339 h: f32,
340 content: impl FnOnce(&mut egui::Ui),
341) {
342 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
343 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
344 egui::Frame::group(ui.style()).show(&mut child, |ui| {
345 ui.vertical(content);
346 });
347}
348
349fn pinned_table(
365 ui: &mut egui::Ui,
366 grid_id: &str,
367 num_columns: usize,
368 headers: &[&str],
369 body: impl FnOnce(&mut egui::Ui),
370) {
371 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
372 std::cell::RefCell::new(Vec::new());
373 let resp = egui::ScrollArea::both()
374 .id_salt(format!("{grid_id}_sc"))
375 .auto_shrink([false, false])
376 .show(ui, |ui| {
377 egui::Grid::new(grid_id)
378 .striped(true)
379 .num_columns(num_columns)
380 .show(ui, |ui| {
381 for h in headers {
382 let r = ui.strong(*h);
383 heads_cell.borrow_mut().push((r.rect, h.to_string()));
384 }
385 ui.end_row();
386 body(ui);
387 });
388 });
389 let heads = heads_cell.borrow();
390 if heads.is_empty() {
391 return;
392 }
393 let area = resp.inner_rect;
394 let head_h = heads[0].0.height();
395 let m = ui.visuals().clip_rect_margin;
401 let strip = egui::Rect::from_min_max(
402 egui::pos2(area.min.x - m, area.min.y - m),
403 egui::pos2(
404 area.max.x + m,
405 area.min.y + head_h + ui.spacing().item_spacing.y,
406 ),
407 );
408 ui.scope_builder(
409 egui::UiBuilder::new()
410 .id_salt(format!("{grid_id}_hdr_ov"))
411 .max_rect(strip),
412 |ui| {
413 ui.set_clip_rect(strip);
414 let painter = ui.painter();
415 painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
416 let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
417 let color = ui.visuals().strong_text_color();
418 for (rect, text) in heads.iter() {
419 painter.text(
420 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
421 egui::Align2::LEFT_CENTER,
422 text.as_str(),
423 font.clone(),
424 color,
425 );
426 }
427 },
428 );
429}
430
431fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
434 ui.horizontal(|ui| {
435 if ui.button(MENU_OPS).clicked() {
437 shared.set_ops_window_open(true);
438 }
439 if ui.button(MENU_CTP).clicked() {
440 shared.set_ctp_window_open(true);
441 }
442 if ui.button(MENU_CONTRACTS).clicked() {
443 shared.set_contracts_window_open(true);
444 }
445 ui.menu_button(MENU_HELP, |ui| {
446 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
447 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
448 });
449 if ui
452 .button(format!("主题:{}", shared.theme().label()))
453 .clicked()
454 {
455 let t = shared.cycle_theme();
456 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
457 }
458 });
459}
460
461fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
464 let groups = shared.tick_groups();
466 if groups.is_empty() {
467 ui.label("(等待 eTick 订阅)");
468 return;
469 }
470 let sel = shared.selected_exchange();
472 let active = if groups.iter().any(|(e, _)| *e == sel) {
473 sel.clone()
474 } else {
475 groups[0].0.clone()
476 };
477 ui.horizontal(|ui| {
478 for (ex, _) in &groups {
479 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
480 shared.set_selected_exchange(ex.clone());
481 }
482 }
483 });
484 let ticks = groups
485 .iter()
486 .find(|(e, _)| *e == active)
487 .map(|(_, v)| v.clone())
488 .unwrap_or_default();
489 pinned_table(
492 ui,
493 "tick_grid",
494 14,
495 &[
496 "代码",
497 "名称",
498 "最新价",
499 "涨跌幅",
500 "成交量",
501 "今开",
502 "最高",
503 "最低",
504 "买1价",
505 "买1量",
506 "卖1价",
507 "卖1量",
508 "时间",
509 "接口",
510 ],
511 |ui| {
512 for t in &ticks {
513 let row_sel = shared.order_form().symbol == t.symbol;
518 let mut cells = Vec::with_capacity(14);
519 let mut dbl = false;
520 let resp = ui.button(t.symbol.clone());
521 if resp.clicked() {
522 shared.set_selected_exchange(active.clone());
523 import_tick(shared, t, &active, 0);
524 }
525 dbl |= resp.double_clicked();
526 cells.push(resp.rect);
527 macro_rules! cell {
528 ($base:expr, $col:expr) => {{
529 let base = $base;
530 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
531 if r.clicked() {
532 shared.set_selected_exchange(active.clone());
533 import_tick(shared, t, &active, $col);
534 }
535 dbl |= r.double_clicked();
536 cells.push(base.rect);
537 }};
538 }
539 cell!(
540 ui.label(if t.name.is_empty() {
541 "-".to_string()
542 } else {
543 t.name.clone()
544 }),
545 1
546 );
547 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
548 cell!(
549 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
550 if t.pct_change < 0.0 {
551 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
552 } else {
553 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
554 },
555 )),
556 3
557 );
558 cell!(ui.label(format!("{:.0}", t.volume)), 4);
559 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
560 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
561 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
562 cell!(
563 ui.label(
564 egui::RichText::new(format!("{:.1}", t.bid_price_1))
565 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
566 ),
567 8
568 );
569 cell!(
570 ui.label(
571 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
572 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
573 ),
574 9
575 );
576 cell!(
577 ui.label(
578 egui::RichText::new(format!("{:.1}", t.ask_price_1))
579 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
580 ),
581 10
582 );
583 cell!(
584 ui.label(
585 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
586 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
587 ),
588 11
589 );
590 cell!(
591 ui.label(if t.update_time.is_empty() {
592 fmt_dt(Some(t.datetime))
593 } else {
594 t.update_time.clone()
595 }),
596 12
597 );
598 cell!(ui.label(t.gateway_name.clone()), 13);
599 if dbl {
600 shared.open_kline(t.symbol.clone());
601 }
602 paint_row_bg(ui, &cells, row_sel);
603 ui.end_row();
604 }
605 },
606 );
607}
608
609fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
612 let mut f = shared.order_form();
614 let before = f.clone();
615 let mut err: Option<String> = None;
616
617 if f.follow_price {
619 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
620 if t.last_price > 0.0 {
621 f.price = format!("{:.1}", t.last_price);
622 }
623 }
624 }
625
626 let mut ex_opts: Vec<String> = shared
628 .tick_groups()
629 .iter()
630 .map(|(e, _)| e.clone())
631 .collect();
632 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
633 if !ex_opts.iter().any(|x| x == e) {
634 ex_opts.push(e.to_string());
635 }
636 }
637 if !ex_opts.contains(&f.exchange) {
638 ex_opts.insert(0, f.exchange.clone());
639 }
640 let ts = shared.trade_snap();
642 let mut gw_opts: Vec<String> = Vec::new();
643 for g in ts
644 .orders
645 .iter()
646 .map(|o| o.gateway_name.clone())
647 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
648 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
649 {
650 if !g.is_empty() && !gw_opts.contains(&g) {
651 gw_opts.push(g);
652 }
653 }
654 for g in ["sim", "ctp"] {
655 if !gw_opts.iter().any(|x| x == g) {
656 gw_opts.push(g.to_string());
657 }
658 }
659 if !gw_opts.contains(&f.gateway) {
660 gw_opts.insert(0, f.gateway.clone());
661 }
662
663 egui::Grid::new("order_form_grid")
666 .num_columns(2)
667 .show(ui, |ui| {
668 ui.label("交易所");
669 egui::ComboBox::from_id_salt("of_exchange")
670 .selected_text(f.exchange.clone())
671 .show_ui(ui, |ui| {
672 for e in &ex_opts {
673 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
674 }
675 });
676 ui.end_row();
677 ui.label("代码");
678 let sym_resp = ui.add(
679 egui::TextEdit::singleline(&mut f.symbol)
680 .hint_text("cu2709")
681 .desired_width(150.0),
682 );
683 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
688 let s = f.symbol.trim().to_string();
689 let bare = match s.split_once('.') {
692 Some((_, b)) => b.trim().to_string(),
693 None => s,
694 };
695 if !bare.is_empty() {
696 f.symbol = bare.clone();
697 shared.push_cmd(UiCmd::CtpSubscribe {
698 symbols: vec![bare.clone()],
699 });
700 shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
701 }
702 }
703 ui.end_row();
704 ui.label("名称");
705 ui.label(
706 tick_of(shared, &f.exchange, &f.symbol)
707 .map(|t| {
708 if t.name.is_empty() {
709 "-".to_string()
710 } else {
711 t.name
712 }
713 })
714 .unwrap_or_else(|| "-".to_string()),
715 );
716 ui.end_row();
717 ui.label("方向");
718 egui::ComboBox::from_id_salt("of_direction")
719 .selected_text(f.direction.as_str())
720 .show_ui(ui, |ui| {
721 for d in [Direction::LONG, Direction::SHORT] {
722 ui.selectable_value(&mut f.direction, d, d.as_str());
723 }
724 });
725 ui.end_row();
726 ui.label("开平");
727 ui.horizontal(|ui| {
728 egui::ComboBox::from_id_salt("of_offset")
729 .selected_text(f.offset.as_str())
730 .show_ui(ui, |ui| {
731 for o in [
732 Offset::OPEN,
733 Offset::CLOSE,
734 Offset::CLOSETODAY,
735 Offset::CLOSEYESTERDAY,
736 ] {
737 ui.selectable_value(&mut f.offset, o, o.as_str());
738 }
739 });
740 ui.checkbox(&mut f.auto_split, "自动开平");
742 });
743 ui.end_row();
744 ui.label("类型");
745 egui::ComboBox::from_id_salt("of_order_type")
746 .selected_text(f.order_type.as_str())
747 .show_ui(ui, |ui| {
748 for ot in [OrderType::LIMIT, OrderType::MARKET] {
749 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
750 }
751 });
752 ui.end_row();
753 ui.label("价格");
754 ui.horizontal(|ui| {
755 ui.add(
756 egui::TextEdit::singleline(&mut f.price)
757 .hint_text("107660")
758 .desired_width(90.0),
759 );
760 ui.checkbox(&mut f.follow_price, "随价");
761 });
762 ui.end_row();
763 ui.label("数量");
764 ui.add(
765 egui::TextEdit::singleline(&mut f.volume)
766 .hint_text("1")
767 .desired_width(90.0),
768 );
769 ui.end_row();
770 ui.label("接口");
771 egui::ComboBox::from_id_salt("of_gateway")
772 .selected_text(if f.gateway.is_empty() {
773 "sim".to_string()
774 } else {
775 f.gateway.clone()
776 })
777 .show_ui(ui, |ui| {
778 for g in &gw_opts {
779 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
780 }
781 });
782 ui.end_row();
783 ui.label("操作");
784 ui.horizontal(|ui| {
785 if ui.button(BTN_SEND_ORDER).clicked() {
786 match f.to_request() {
787 Ok(req) => match crate::state::parse_gateway(&f.gateway) {
788 Ok(gateway) => {
789 shared.push_cmd(UiCmd::SendOrder {
790 req,
791 gateway,
792 mode: if f.auto_split {
793 ek_oms::LockMode::Auto
794 } else {
795 ek_oms::LockMode::None
796 },
797 });
798 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
799 }
800 Err(e) => {
801 shared.log_line(format!("[下单] {e}"));
802 err = Some(e);
803 }
804 },
805 Err(e) => {
806 shared.log_line(format!("[下单] {e}"));
807 err = Some(e);
808 }
809 }
810 }
811 if ui.button(BTN_CANCEL_ALL).clicked() {
812 let mut n = 0usize;
815 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
816 let gw = if o.gateway_name.is_empty() {
817 "sim".to_string()
818 } else {
819 o.gateway_name.clone()
820 };
821 shared.push_cmd(UiCmd::CancelOrder {
822 orderid: o.orderid.clone(),
823 gateway: gw,
824 });
825 n += 1;
826 }
827 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
828 }
829 });
830 ui.end_row();
831 ui.label("代码回车 = 订阅");
832 ui.label("");
833 ui.end_row();
834 });
835 if let Some(e) = err {
836 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
837 }
838 if f != before {
839 shared.set_order_form(f.clone());
840 }
841}
842
843fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
847 let f = shared.order_form();
848 match tick_of(shared, &f.exchange, &f.symbol) {
849 Some(t) => {
850 for i in (1..=5).rev() {
851 let (p, v) = match i {
852 1 => (t.ask_price_1, t.ask_volume_1),
853 2 => (t.ask_price_2, t.ask_volume_2),
854 3 => (t.ask_price_3, t.ask_volume_3),
855 4 => (t.ask_price_4, t.ask_volume_4),
856 _ => (t.ask_price_5, t.ask_volume_5),
857 };
858 ui.label(
859 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
860 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
861 );
862 }
863 for i in 1..=5 {
864 let (p, v) = match i {
865 1 => (t.bid_price_1, t.bid_volume_1),
866 2 => (t.bid_price_2, t.bid_volume_2),
867 3 => (t.bid_price_3, t.bid_volume_3),
868 4 => (t.bid_price_4, t.bid_volume_4),
869 _ => (t.bid_price_5, t.bid_volume_5),
870 };
871 ui.label(
872 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
873 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
874 );
875 }
876 }
877 None => {
878 ui.label("(无 tick)");
879 }
880 }
881}
882
883fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
886 let tab = shared.topleft_tab();
887 ui.horizontal(|ui| {
888 if ui
889 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
890 .clicked()
891 {
892 shared.set_topleft_tab(TopLeftTab::Orders);
893 }
894 if ui
895 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
896 .clicked()
897 {
898 shared.set_topleft_tab(TopLeftTab::Active);
899 }
900 });
901 render_orders_table(ui, shared, tab == TopLeftTab::Active);
902}
903
904fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
908 let ts = shared.trade_snap();
909 let rows: Vec<_> = ts
912 .orders
913 .iter()
914 .filter(|o| !active_only || order_active(o.status))
915 .collect();
916 const ORDER_HEADERS: [&str; 17] = [
918 "代码",
919 "名称",
920 "交易所",
921 "类型",
922 "方向",
923 "开平",
924 "价格",
925 "总量",
926 "已成",
927 "成交均价",
928 "状态",
929 "状态描述",
930 "时间",
931 "委托号",
932 "来源",
933 "接口",
934 "操作",
935 ];
936 if rows.is_empty() {
937 pinned_table(
940 ui,
941 if active_only {
942 "active_grid"
943 } else {
944 "order_grid"
945 },
946 17,
947 &ORDER_HEADERS,
948 |ui| {
949 ui.label(if active_only {
950 "(无未成交)"
951 } else {
952 "(无委托:等网关 send_order)"
953 });
954 },
955 );
956 return;
957 }
958 pinned_table(
959 ui,
960 if active_only {
961 "active_grid"
962 } else {
963 "order_grid"
964 },
965 17,
966 &ORDER_HEADERS,
967 |ui| {
968 for o in &rows {
969 let key = format!("{}.{}", o.gateway_name, o.orderid);
973 let row_sel = shared.row_selected("orders", &key);
974 let mut cells = Vec::with_capacity(17);
975 let mut dbl = false;
976 let sel = ui.selectable_label(row_sel, o.symbol.clone());
977 if sel.clicked() {
978 shared.select_row("orders", &key);
979 }
980 dbl |= sel.double_clicked();
981 cells.push(sel.rect);
982 macro_rules! cell {
983 ($base:expr, $col:expr) => {{
984 let base = $base;
985 let r = click_cell(ui, base.rect, ("ord", &key, $col));
986 if r.clicked() {
987 shared.select_row("orders", &key);
988 }
989 dbl |= r.double_clicked();
990 cells.push(base.rect);
991 }};
992 }
993 cell!(
994 ui.label(if o.name.is_empty() {
995 "-".to_string()
996 } else {
997 o.name.clone()
998 }),
999 1
1000 );
1001 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1002 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1003 cell!(
1004 ui.label(
1005 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1006 if o.direction == Some(Direction::SHORT) {
1007 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1008 } else {
1009 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1010 },
1011 ),
1012 ),
1013 4
1014 );
1015 cell!(ui.label(o.offset.as_str().to_string()), 5);
1016 cell!(ui.label(format!("{:.1}", o.price)), 6);
1017 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1018 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1019 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1020 cell!(
1021 ui.label(egui::RichText::new(o.status.as_str()).color(
1022 if o.status == ek_data::constant::Status::REJECTED {
1023 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1024 } else if o.status == ek_data::constant::Status::ALLTRADED {
1025 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1026 } else {
1027 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1028 },
1029 )),
1030 10
1031 );
1032 cell!(ui.label(o.status_msg.clone()), 11);
1033 cell!(ui.label(fmt_dt(o.datetime)), 12);
1034 cell!(ui.label(o.orderid.clone()), 13);
1035 cell!(
1036 ui.label(if o.reference.is_empty() {
1037 "-".to_string()
1038 } else {
1039 o.reference.clone()
1040 }),
1041 14
1042 );
1043 cell!(ui.label(o.gateway_name.clone()), 15);
1044 if dbl && order_active(o.status) {
1045 let gw = if o.gateway_name.is_empty() {
1046 "sim".to_string()
1047 } else {
1048 o.gateway_name.clone()
1049 };
1050 shared.push_cmd(UiCmd::CancelOrder {
1051 orderid: o.orderid.clone(),
1052 gateway: gw,
1053 });
1054 }
1055 let hr = ui.horizontal(|ui| {
1056 if order_active(o.status)
1057 && ui
1058 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1059 .clicked()
1060 {
1061 let gw = if o.gateway_name.is_empty() {
1063 "sim".to_string()
1064 } else {
1065 o.gateway_name.clone()
1066 };
1067 shared.push_cmd(UiCmd::CancelOrder {
1068 orderid: o.orderid.clone(),
1069 gateway: gw,
1070 });
1071 }
1072 });
1073 cells.push(hr.response.rect);
1074 paint_row_bg(ui, &cells, row_sel);
1075 ui.end_row();
1076 }
1077 },
1078 );
1079}
1080
1081fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1083 ui.label("成交");
1084 let ts = shared.trade_snap();
1085 const TRADE_HEADERS: [&str; 12] = [
1087 "成交号",
1088 "代码",
1089 "名称",
1090 "交易所",
1091 "方向",
1092 "开平",
1093 "价格",
1094 "数量",
1095 "成交额",
1096 "时间",
1097 "委托号",
1098 "接口",
1099 ];
1100 if ts.trades.is_empty() {
1101 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1103 ui.label("(无成交)");
1104 });
1105 return;
1106 }
1107 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1108 for t in &ts.trades {
1109 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1112 let row_sel = shared.row_selected("trades", &key);
1113 let mut cells = Vec::with_capacity(12);
1114 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1115 if sel.clicked() {
1116 shared.select_row("trades", &key);
1117 }
1118 cells.push(sel.rect);
1119 macro_rules! cell {
1120 ($base:expr, $col:expr) => {{
1121 let base = $base;
1122 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1123 if r.clicked() {
1124 shared.select_row("trades", &key);
1125 }
1126 cells.push(base.rect);
1127 }};
1128 }
1129 cell!(ui.label(t.symbol.clone()), 1);
1130 cell!(
1131 ui.label(if t.name.is_empty() {
1132 "-".to_string()
1133 } else {
1134 t.name.clone()
1135 }),
1136 2
1137 );
1138 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1139 cell!(
1140 ui.label(
1141 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1142 if t.direction == Some(Direction::SHORT) {
1143 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1144 } else {
1145 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1146 },
1147 ),
1148 ),
1149 4
1150 );
1151 cell!(ui.label(t.offset.as_str().to_string()), 5);
1152 cell!(ui.label(format!("{:.1}", t.price)), 6);
1153 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1154 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1155 cell!(ui.label(fmt_dt(t.datetime)), 9);
1156 cell!(ui.label(t.orderid.clone()), 10);
1157 cell!(ui.label(t.gateway_name.clone()), 11);
1158 paint_row_bg(ui, &cells, row_sel);
1159 ui.end_row();
1160 }
1161 });
1162}
1163
1164fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1167 ui.label("持仓");
1168 let ts = shared.trade_snap();
1169 const POS_HEADERS: [&str; 16] = [
1171 "代码",
1172 "名称",
1173 "交易所",
1174 "方向",
1175 "数量",
1176 "昨仓",
1177 "可用",
1178 "冻结",
1179 "在途",
1180 "市值",
1181 "成本",
1182 "均价",
1183 "最新价",
1184 "盈亏",
1185 "盈亏比",
1186 "接口",
1187 ];
1188 if ts.positions.is_empty() {
1189 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1191 ui.label("(无持仓)");
1192 });
1193 return;
1194 }
1195 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1196 for p in &ts.positions {
1197 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1201 let row_sel = shared.row_selected("positions", &key);
1202 let mut cells = Vec::with_capacity(16);
1203 let mut dbl = false;
1204 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1205 if sel.clicked() {
1206 shared.select_row("positions", &key);
1207 }
1208 dbl |= sel.double_clicked();
1209 cells.push(sel.rect);
1210 macro_rules! cell {
1211 ($base:expr, $col:expr) => {{
1212 let base = $base;
1213 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1214 if r.clicked() {
1215 shared.select_row("positions", &key);
1216 }
1217 dbl |= r.double_clicked();
1218 cells.push(base.rect);
1219 }};
1220 }
1221 cell!(
1222 ui.label(if p.name.is_empty() {
1223 "-".to_string()
1224 } else {
1225 p.name.clone()
1226 }),
1227 1
1228 );
1229 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1230 cell!(
1231 ui.label(egui::RichText::new(p.direction.as_str()).color(
1232 if p.direction == ek_data::constant::Direction::SHORT {
1233 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1234 } else {
1235 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1236 },
1237 )),
1238 3
1239 );
1240 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1241 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1242 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1243 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1244 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1245 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1246 cell!(ui.label(format!("{:.2}", p.price)), 10);
1247 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1248 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1249 cell!(
1250 ui.label(
1251 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1252 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1253 } else {
1254 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1255 }),
1256 ),
1257 13
1258 );
1259 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1260 cell!(ui.label(p.gateway_name.clone()), 15);
1261 if dbl {
1262 let mut f = shared.order_form();
1263 f.symbol = p.symbol.clone();
1264 f.exchange = p.exchange.as_str().to_string();
1265 f.direction = p.direction;
1266 shared.set_order_form(f);
1267 shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1268 }
1269 paint_row_bg(ui, &cells, row_sel);
1270 ui.end_row();
1271 }
1272 });
1273}
1274
1275fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1276 let tab = shared.bottomright_tab();
1277 ui.horizontal(|ui| {
1278 if ui
1279 .selectable_label(
1280 tab == BottomRightTab::Account,
1281 BottomRightTab::Account.label(),
1282 )
1283 .clicked()
1284 {
1285 shared.set_bottomright_tab(BottomRightTab::Account);
1286 }
1287 if ui
1288 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1289 .clicked()
1290 {
1291 shared.set_bottomright_tab(BottomRightTab::Logs);
1292 }
1293 });
1294 match tab {
1295 BottomRightTab::Account => {
1296 let ts = shared.trade_snap();
1297 if ts.accounts.is_empty() {
1298 ui.label("(无资金:等网关 eAccount.)");
1299 return;
1300 }
1301 egui::Grid::new("acct_grid")
1302 .striped(true)
1303 .num_columns(5)
1304 .show(ui, |ui| {
1305 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1306 ui.strong(h);
1307 }
1308 ui.end_row();
1309 for a in &ts.accounts {
1310 ui.label(a.accountid.clone());
1311 ui.label(a.gateway_name.clone());
1312 ui.label(format!("{:.2}", a.balance));
1313 ui.label(format!("{:.2}", a.available()));
1314 ui.label(format!("{:.2}", a.frozen));
1315 ui.end_row();
1316 }
1317 });
1318 }
1319 BottomRightTab::Logs => {
1320 render_logs(ui, shared);
1321 }
1322 }
1323}
1324
1325fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1326 ui.horizontal(|ui| {
1327 if ui.button(BTN_CLEAR_LOGS).clicked() {
1328 shared.clear_logs(); }
1330 let (_, spilled) = shared.logs_snapshot();
1331 if spilled > 0 {
1332 ui.label(format!(
1333 "已丢弃 {spilled} 条(环形 {})",
1334 crate::state::RING_CAP
1335 ));
1336 }
1337 });
1338 let lines = shared.logs_snapshot().0;
1339 egui::ScrollArea::vertical()
1340 .id_salt("log_scroll")
1341 .max_height(180.0)
1342 .stick_to_bottom(true)
1343 .show(ui, |ui| {
1344 for line in lines {
1345 ui.monospace(line);
1346 }
1347 });
1348}
1349
1350struct WinGeom {
1354 inner: [f32; 2],
1355 min_inner: [f32; 2],
1356 maximized: bool,
1357}
1358
1359fn show_independent_window(
1374 ctx: &egui::Context,
1375 id_salt: impl std::hash::Hash,
1376 title: &str,
1377 geom: &WinGeom,
1378 embedded: impl Fn(&egui::Context) -> bool,
1379 body: impl Fn(&mut egui::Ui) -> bool,
1380) -> bool {
1381 let closed = std::cell::Cell::new(false);
1382 let mut builder = egui::ViewportBuilder::default()
1383 .with_title(title)
1384 .with_inner_size(geom.inner)
1385 .with_min_inner_size(geom.min_inner)
1386 .with_minimize_button(true)
1387 .with_maximize_button(true)
1388 .with_close_button(true);
1389 if geom.maximized {
1390 builder = builder.with_maximized(true);
1391 }
1392 ctx.show_viewport_immediate(
1393 egui::ViewportId::from_hash_of(id_salt),
1394 builder,
1395 |ctx2, class| {
1396 if class == egui::ViewportClass::Embedded {
1397 closed.set(embedded(ctx2));
1398 return;
1399 }
1400 if ctx2.input(|i| i.viewport().close_requested()) {
1401 closed.set(true);
1402 return;
1403 }
1404 egui::CentralPanel::default().show(ctx2, |ui| {
1405 if body(ui) {
1406 closed.set(true);
1407 }
1408 });
1409 },
1410 );
1411 closed.get()
1412}
1413
1414fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1415 if shared.ops_window_open() {
1417 let closed = show_independent_window(
1418 ctx,
1419 "ops_win",
1420 "运维窗",
1421 &WinGeom {
1422 inner: [680.0, 420.0],
1423 min_inner: [420.0, 260.0],
1424 maximized: false,
1425 },
1426 |ctx2| {
1427 let mut open = shared.ops_window_open();
1428 egui::Window::new("运维窗")
1429 .id(egui::Id::new("ops_win"))
1430 .default_pos([80.0, 320.0])
1431 .default_size([640.0, 380.0])
1432 .open(&mut open)
1433 .show(ctx2, |ui| {
1434 render_ops_window(ui, shared);
1435 });
1436 shared.set_ops_window_open(open);
1437 !open
1438 },
1439 |ui| {
1440 render_ops_window(ui, shared);
1441 false
1442 },
1443 );
1444 if closed {
1445 shared.set_ops_window_open(false);
1446 }
1447 }
1448
1449 if shared.ctp_window_open() {
1451 let closed = show_independent_window(
1452 ctx,
1453 "ctp_win",
1454 "CTP 网关配置窗",
1455 &WinGeom {
1456 inner: [600.0, 380.0],
1457 min_inner: [420.0, 260.0],
1458 maximized: false,
1459 },
1460 |ctx2| {
1461 let mut open = shared.ctp_window_open();
1462 egui::Window::new("CTP 网关配置窗")
1463 .id(egui::Id::new("ctp_win"))
1464 .default_pos([640.0, 320.0])
1465 .default_size([560.0, 330.0])
1466 .open(&mut open)
1467 .show(ctx2, |ui| {
1468 render_ctp_window(ui, shared);
1469 });
1470 shared.set_ctp_window_open(open);
1471 !open
1472 },
1473 |ui| {
1474 render_ctp_window(ui, shared);
1475 false
1476 },
1477 );
1478 if closed {
1479 shared.set_ctp_window_open(false);
1480 }
1481 }
1482
1483 if shared.contracts_window_open() {
1485 let closed = show_independent_window(
1486 ctx,
1487 "contracts_win",
1488 "查询合约窗",
1489 &WinGeom {
1490 inner: [620.0, 460.0],
1491 min_inner: [420.0, 260.0],
1492 maximized: false,
1493 },
1494 |ctx2| {
1495 let mut open = shared.contracts_window_open();
1496 egui::Window::new("查询合约窗")
1497 .id(egui::Id::new("contracts_win"))
1498 .default_pos([420.0, 300.0])
1499 .default_size([560.0, 420.0])
1500 .open(&mut open)
1501 .show(ctx2, |ui| {
1502 render_contracts_window(ui, shared);
1503 });
1504 shared.set_contracts_window_open(open);
1505 !open
1506 },
1507 |ui| {
1508 render_contracts_window(ui, shared);
1509 false
1510 },
1511 );
1512 if closed {
1513 shared.set_contracts_window_open(false);
1514 }
1515 }
1516
1517 let mut closed: Vec<String> = Vec::new();
1521 for sym in shared.kline_symbols() {
1522 let is_closed = show_independent_window(
1523 ctx,
1524 format!("kline_win:{sym}"),
1525 &format!("K线 — {sym}"),
1526 &WinGeom {
1527 inner: [560.0, 360.0],
1528 min_inner: [360.0, 220.0],
1529 maximized: true,
1530 },
1531 |ctx2| {
1532 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1533 let resp = egui::Window::new(format!("K线 — {sym}"))
1534 .id(egui::Id::new(("kline_win", sym.clone())))
1535 .default_pos([300.0, 340.0])
1536 .default_size([540.0, 330.0])
1537 .show(ctx2, |ui| {
1538 let cell = shared
1539 .market()
1540 .cells()
1541 .into_iter()
1542 .find(|c| c.symbol == sym);
1543 render_kline_cell(ui, cell.as_ref());
1544 });
1545 esc || resp.is_none()
1546 },
1547 |ui| {
1548 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1549 return true;
1550 }
1551 let cell = shared
1552 .market()
1553 .cells()
1554 .into_iter()
1555 .find(|c| c.symbol == sym);
1556 render_kline_cell(ui, cell.as_ref());
1557 false
1558 },
1559 );
1560 if is_closed {
1561 closed.push(sym);
1562 }
1563 }
1564 for s in closed {
1565 shared.close_kline(&s);
1566 }
1567}
1568
1569fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1572 ui.label("插件");
1573 let snap = shared.snapshot();
1574 egui::Grid::new("plug_grid")
1575 .striped(true)
1576 .num_columns(6)
1577 .show(ui, |ui| {
1578 ui.strong("名称");
1579 ui.strong("状态");
1580 ui.strong("版本");
1581 ui.strong("依赖");
1582 ui.strong("统计");
1583 ui.strong("操作");
1584 ui.end_row();
1585 if snap.rows.is_empty() {
1586 ui.label("(快照空:等 eTimer 首拉)");
1587 ui.end_row();
1588 }
1589 for r in &snap.rows {
1590 if is_quarantined(&r.state) {
1591 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1592 ui.label(
1593 egui::RichText::new(state_text(r))
1594 .color(egui::Color32::RED)
1595 .strong(),
1596 );
1597 } else {
1598 ui.label(r.name.clone());
1599 ui.label(state_text(r));
1600 }
1601 ui.label(r.version.clone());
1602 ui.label(format!("deps:[{}]", r.deps.join(",")));
1603 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1604 ui.horizontal(|ui| {
1605 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1606 shared.push_cmd(UiCmd::Start {
1607 factory: r.name.clone(),
1608 });
1609 }
1610 let armed = shared.pending_confirm();
1611 let armed_here = ui
1612 .small_button(remove_button_label(&r.name, armed.as_deref()))
1613 .clicked()
1614 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1615 if armed_here {
1616 shared.push_cmd(UiCmd::Remove {
1617 name: r.name.clone(),
1618 });
1619 }
1620 });
1621 ui.end_row();
1622 }
1623 });
1624
1625 if let Some(name) = shared.pending_confirm() {
1626 ui.horizontal(|ui| {
1627 ui.label(
1628 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1629 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1630 );
1631 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1632 shared.confirm_cancel();
1633 }
1634 });
1635 }
1636
1637 ui.label("服务");
1638 let flat: Vec<String> = snap
1639 .services
1640 .iter()
1641 .flat_map(|(n, ids)| {
1642 if ids.is_empty() {
1643 vec![format!("{n} 服务"), "-".to_string()]
1644 } else {
1645 ids.iter()
1646 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1647 .collect()
1648 }
1649 })
1650 .collect();
1651 if flat.is_empty() {
1652 ui.label("(无服务目录)");
1653 } else {
1654 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1656 .striped(true)
1657 .num_columns(6)
1658 .show(ui, |ui| {
1659 for r in 0..rows {
1660 for c in 0..3 {
1661 let i = c * rows * 2 + r * 2;
1662 if i < flat.len() {
1663 ui.label(flat[i].clone());
1664 ui.label(flat[i + 1].clone());
1665 } else {
1666 ui.label("");
1667 ui.label("");
1668 }
1669 }
1670 ui.end_row();
1671 }
1672 });
1673 }
1674
1675 ui.label("依赖");
1676 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1677 ui.label("(全部无依赖)");
1678 }
1679 for r in &snap.rows {
1680 if !r.deps.is_empty() {
1681 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1682 }
1683 }
1684
1685 ui.horizontal(|ui| {
1686 if ui.small_button(BTN_QUERY_RATE).clicked() {
1687 shared.push_cmd(UiCmd::QueryRate);
1688 }
1689 });
1690}
1691
1692fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1696 ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1697 let mut form: crate::state::CtpForm = shared.ctp_form();
1698 egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1699 ui.label("broker");
1700 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1701 ui.label("td 前置");
1702 ctp_edit(
1703 ui,
1704 shared,
1705 &mut form.td_addr,
1706 "tcp://host:40001",
1707 CtpField::TdAddr,
1708 );
1709 ui.label("td 用户");
1710 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1711 ui.end_row();
1712 ui.label("td 密码");
1713 ctp_edit(
1714 ui,
1715 shared,
1716 &mut form.td_password,
1717 "密码",
1718 CtpField::TdPassword,
1719 );
1720 ui.label("AppID");
1721 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1722 ui.label("AuthCode");
1723 ctp_edit(
1724 ui,
1725 shared,
1726 &mut form.td_auth_code,
1727 "授权码",
1728 CtpField::AuthCode,
1729 );
1730 ui.end_row();
1731 ui.label("md 前置");
1732 ctp_edit(
1733 ui,
1734 shared,
1735 &mut form.md_addr,
1736 "tcp://host:40011",
1737 CtpField::MdAddr,
1738 );
1739 ui.label("md 用户");
1740 ctp_edit(
1741 ui,
1742 shared,
1743 &mut form.md_user,
1744 "空=td 回落",
1745 CtpField::MdUser,
1746 );
1747 ui.label("md 密码");
1748 ctp_edit(
1749 ui,
1750 shared,
1751 &mut form.md_password,
1752 "空=td 回落",
1753 CtpField::MdPassword,
1754 );
1755 ui.end_row();
1756 });
1757 ui.horizontal(|ui| {
1758 if ui.small_button(BTN_CTP_CONNECT).clicked() {
1759 shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1760 shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1761 }
1762 ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1763 });
1764}
1765
1766fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1770 let mut q = shared.contract_query();
1771 ui.horizontal(|ui| {
1772 ui.label("代码或交易所(留空 = 全部):");
1773 if ui
1774 .add(
1775 egui::TextEdit::singleline(&mut q)
1776 .hint_text("cu 或 SHFE")
1777 .desired_width(200.0),
1778 )
1779 .changed()
1780 {
1781 shared.set_contract_query(q.clone());
1782 }
1783 });
1784 let rows = shared.contracts(&q);
1785 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
1788 if rows.is_empty() {
1789 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1791 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1792 });
1793 return;
1794 }
1795 let selected = shared.selected_contracts();
1796 ui.horizontal(|ui| {
1801 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1802 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1803 if selected.is_empty() {
1804 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1805 } else {
1806 let symbols: Vec<String> = selected
1808 .iter()
1809 .map(|k| {
1810 k.split_once('.')
1811 .map(|(_, s)| s.to_string())
1812 .unwrap_or_else(|| k.clone())
1813 })
1814 .collect();
1815 shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1816 shared.log_line(format!(
1817 "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1818 selected.len()
1819 ));
1820 }
1821 }
1822 });
1823 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1824 for c in &rows {
1825 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1826 let mut on = selected.contains(&key);
1827 let row_sel = shared.row_selected("contracts", &key);
1831 let mut cells = Vec::with_capacity(5);
1832 let ck = ui.checkbox(&mut on, key.clone());
1833 if ck.changed() {
1834 shared.toggle_contract_selection(&key);
1835 shared.select_row("contracts", &key);
1836 }
1837 cells.push(ck.rect);
1838 macro_rules! cell {
1839 ($base:expr, $col:expr) => {{
1840 let base = $base;
1841 let r = click_cell(ui, base.rect, ("crt", &key, $col));
1842 if r.clicked() {
1843 shared.select_row("contracts", &key);
1844 }
1845 cells.push(base.rect);
1846 }};
1847 }
1848 cell!(ui.label(c.exchange.as_str().to_string()), 1);
1849 cell!(ui.label(c.name.clone()), 2);
1850 cell!(ui.label(format!("{:.0}", c.size)), 3);
1851 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1852 paint_row_bg(ui, &cells, row_sel);
1853 ui.end_row();
1854 }
1855 });
1856}
1857
1858enum CtpField {
1860 Broker,
1861 TdAddr,
1862 TdUser,
1863 TdPassword,
1864 AppId,
1865 AuthCode,
1866 MdAddr,
1867 MdUser,
1868 MdPassword,
1869}
1870
1871fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1873 let is_secret = matches!(
1874 field,
1875 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1876 );
1877 let mut w = egui::TextEdit::singleline(val)
1878 .hint_text(hint)
1879 .desired_width(110.0);
1880 if is_secret {
1881 w = w.password(true);
1882 }
1883 if ui.add(w).changed() {
1884 let mut f = shared.ctp_form();
1885 match field {
1886 CtpField::Broker => f.broker_id = val.clone(),
1887 CtpField::TdAddr => f.td_addr = val.clone(),
1888 CtpField::TdUser => f.td_user = val.clone(),
1889 CtpField::TdPassword => f.td_password = val.clone(),
1890 CtpField::AppId => f.td_app_id = val.clone(),
1891 CtpField::AuthCode => f.td_auth_code = val.clone(),
1892 CtpField::MdAddr => f.md_addr = val.clone(),
1893 CtpField::MdUser => f.md_user = val.clone(),
1894 CtpField::MdPassword => f.md_password = val.clone(),
1895 }
1896 shared.set_ctp_form(f);
1897 }
1898}
1899
1900fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1904 let Some(c) = cell else {
1905 ui.label("(K线无格:等 dm 合成)");
1906 return;
1907 };
1908 if c.bars.is_empty() {
1909 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1910 return;
1911 }
1912 let mut data = egui_charts::model::bar::BarData::new();
1913 for b in &c.bars {
1914 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1915 data.push(egui_charts::model::bar::Bar::new(
1916 dt,
1917 b.open_price,
1918 b.high_price,
1919 b.low_price,
1920 b.close_price,
1921 b.volume,
1922 ));
1923 }
1924 ui.label(format!(
1925 "K线 {}@{}s({} 根)",
1926 c.symbol,
1927 c.interval,
1928 c.bars.len()
1929 ));
1930 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1931 chart.set_symbol(&c.symbol);
1932 chart.set_timeframe_label(&format!("{}s", c.interval));
1933 chart.enable_tracking_mode();
1934 let _ = chart.show(ui);
1935}