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