1use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42pub const BTN_CLEAR_LOGS: &str = "清空日志";
44pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46pub const BTN_SEND_ORDER: &str = "委托";
48pub const BTN_CANCEL_ALL: &str = "全撤";
50pub const BTN_CANCEL_ORDER: &str = "撤单";
52pub const BTN_CTP_CONNECT: &str = "连接 CTP";
54pub const MENU_OPS: &str = "运维";
56pub const MENU_CTP: &str = "网关";
58pub const MENU_HELP: &str = "帮助";
60pub const MENU_CONTRACTS: &str = "查询合约";
62pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
66
67fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
68 if armed_on == Some(name) {
69 format!("确认移除 {name} ?")
70 } else {
71 format!("移除 {name}")
72 }
73}
74
75fn state_text(r: &PlugRow) -> String {
76 r.state.clone()
77}
78
79fn fmt_dt(dt: Option<i64>) -> String {
81 dt.map(|ns| {
82 chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
83 .format("%H:%M:%S")
84 .to_string()
85 })
86 .unwrap_or_else(|| "-".to_string())
87}
88
89fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
95 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
96 return;
97 };
98 let hovered = ui
99 .input(|i| i.pointer.interact_pos())
100 .is_some_and(|p| union.expand(3.0).contains(p));
101 if selected {
102 ui.painter().rect_filled(
103 union.expand(3.0),
104 3.0,
105 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
106 );
107 } else if hovered {
108 ui.painter().rect_filled(
109 union.expand(3.0),
110 3.0,
111 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
112 );
113 }
114}
115
116fn order_active(status: ek_data::constant::Status) -> bool {
118 ek_data::constant::ACTIVE_STATUSES.contains(&status)
119}
120
121fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
124 ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
125}
126
127pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
131 let mut f = shared.order_form();
132 f.symbol = t.symbol.clone();
133 f.exchange = exchange.to_string();
134 match col {
135 8 => {
136 f.direction = Direction::SHORT;
137 f.price = format!("{:.1}", t.bid_price_1);
138 }
139 9 => f.direction = Direction::SHORT,
140 10 => {
141 f.direction = Direction::LONG;
142 f.price = format!("{:.1}", t.ask_price_1);
143 }
144 11 => f.direction = Direction::LONG,
145 _ => {}
146 }
147 shared.set_order_form(f);
148}
149
150fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
153 if symbol.is_empty() {
154 return None;
155 }
156 shared.tick_rows().into_iter().find(|t| {
157 t.symbol == symbol
158 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
159 })
160}
161
162pub fn apply_ui_style(ctx: &egui::Context) {
166 apply_ui_style_for(ctx, crate::state::Theme::default());
167}
168
169pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
174 ctx.style_mut(|style| {
175 style
176 .text_styles
177 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
178 style
179 .text_styles
180 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
181 style
182 .text_styles
183 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
184 style
185 .text_styles
186 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
187 style
188 .text_styles
189 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
190 });
191 ctx.set_visuals(theme_visuals(theme));
192}
193
194fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
197 use egui::Color32;
198 match t {
199 crate::state::Theme::EguiDark => egui::Visuals::dark(),
200 crate::state::Theme::EguiLight => egui::Visuals::light(),
201 crate::state::Theme::QtClassic => {
202 let mut v = egui::Visuals::light();
203 v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
204 v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
205 v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
206 v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
207 v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
208 v
209 }
210 crate::state::Theme::FinanceDark => {
211 let mut v = egui::Visuals::dark();
212 v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
213 v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
214 v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
215 v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
216 v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
217 v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
218 v.widgets.noninteractive.bg_stroke =
219 egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
220 v
221 }
222 }
223}
224
225pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
233 apply_ui_style_for(ctx, shared.theme());
234 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
236 render_menu_bar(ui, shared);
237 });
238
239 egui::TopBottomPanel::top("market_band")
241 .resizable(true)
242 .default_height(240.0)
243 .show(ctx, |ui| {
244 egui::ScrollArea::both()
248 .id_salt("market_band_scroll")
249 .auto_shrink([false, false])
250 .show(ui, |ui| {
251 render_market_band(ui, shared);
252 });
253 });
254
255 egui::SidePanel::left("order_side")
257 .resizable(true)
258 .default_width(250.0)
259 .show(ctx, |ui| {
260 egui::ScrollArea::vertical()
262 .id_salt("order_side_scroll")
263 .auto_shrink([false, false])
264 .show(ui, |ui| {
265 render_order_panel(ui, shared);
266 ui.separator();
267 render_book_frame(ui, shared);
268 });
269 });
270
271 egui::CentralPanel::default().show(ctx, |ui| {
273 let avail = ui.available_size();
274 let sp = ui.spacing().item_spacing;
275 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
281 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
282 ui.horizontal(|ui| {
283 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
284 render_topleft_group(ui, shared);
285 });
286 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
287 render_trades_group(ui, shared);
288 });
289 });
290 ui.horizontal(|ui| {
291 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
292 render_positions_group(ui, shared);
293 });
294 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
299 render_bottomright_group(ui, shared);
300 });
301 });
302 });
303
304 render_windows(ctx, shared);
306}
307
308fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
316 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
317 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
318 egui::Frame::group(ui.style()).show(&mut child, |ui| {
319 egui::ScrollArea::both()
320 .id_salt(id)
321 .auto_shrink([false, false])
322 .show(ui, |ui| {
323 ui.vertical(content);
324 });
325 });
326}
327
328fn group_frame_plain(
335 ui: &mut egui::Ui,
336 _id: &str,
337 w: f32,
338 h: f32,
339 content: impl FnOnce(&mut egui::Ui),
340) {
341 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
342 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
343 egui::Frame::group(ui.style()).show(&mut child, |ui| {
344 ui.vertical(content);
345 });
346}
347
348fn pinned_table(
364 ui: &mut egui::Ui,
365 grid_id: &str,
366 num_columns: usize,
367 headers: &[&str],
368 body: impl FnOnce(&mut egui::Ui),
369) {
370 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
371 std::cell::RefCell::new(Vec::new());
372 let resp = egui::ScrollArea::both()
373 .id_salt(format!("{grid_id}_sc"))
374 .auto_shrink([false, false])
375 .show(ui, |ui| {
376 egui::Grid::new(grid_id)
377 .striped(true)
378 .num_columns(num_columns)
379 .show(ui, |ui| {
380 for h in headers {
381 let r = ui.strong(*h);
382 heads_cell.borrow_mut().push((r.rect, h.to_string()));
383 }
384 ui.end_row();
385 body(ui);
386 });
387 });
388 let heads = heads_cell.borrow();
389 if heads.is_empty() {
390 return;
391 }
392 let area = resp.inner_rect;
393 let head_h = heads[0].0.height();
394 let m = ui.visuals().clip_rect_margin;
400 let strip = egui::Rect::from_min_max(
401 egui::pos2(area.min.x - m, area.min.y - m),
402 egui::pos2(
403 area.max.x + m,
404 area.min.y + head_h + ui.spacing().item_spacing.y,
405 ),
406 );
407 ui.scope_builder(
408 egui::UiBuilder::new()
409 .id_salt(format!("{grid_id}_hdr_ov"))
410 .max_rect(strip),
411 |ui| {
412 ui.set_clip_rect(strip);
413 let painter = ui.painter();
414 painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
415 let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
416 let color = ui.visuals().strong_text_color();
417 for (rect, text) in heads.iter() {
418 painter.text(
419 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
420 egui::Align2::LEFT_CENTER,
421 text.as_str(),
422 font.clone(),
423 color,
424 );
425 }
426 },
427 );
428}
429
430fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
433 ui.horizontal(|ui| {
434 if ui.button(MENU_OPS).clicked() {
436 shared.set_ops_window_open(true);
437 }
438 if ui.button(MENU_CTP).clicked() {
439 shared.set_ctp_window_open(true);
440 }
441 if ui.button(MENU_CONTRACTS).clicked() {
442 shared.set_contracts_window_open(true);
443 }
444 ui.menu_button(MENU_HELP, |ui| {
445 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
446 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
447 });
448 if ui
451 .button(format!("主题:{}", shared.theme().label()))
452 .clicked()
453 {
454 let t = shared.cycle_theme();
455 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
456 }
457 });
458}
459
460fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
463 let groups = shared.tick_groups();
465 if groups.is_empty() {
466 ui.label("(等待 eTick 订阅)");
467 return;
468 }
469 let sel = shared.selected_exchange();
471 let active = if groups.iter().any(|(e, _)| *e == sel) {
472 sel.clone()
473 } else {
474 groups[0].0.clone()
475 };
476 ui.horizontal(|ui| {
477 for (ex, _) in &groups {
478 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
479 shared.set_selected_exchange(ex.clone());
480 }
481 }
482 });
483 let ticks = groups
484 .iter()
485 .find(|(e, _)| *e == active)
486 .map(|(_, v)| v.clone())
487 .unwrap_or_default();
488 pinned_table(
491 ui,
492 "tick_grid",
493 14,
494 &[
495 "代码",
496 "名称",
497 "最新价",
498 "涨跌幅",
499 "成交量",
500 "今开",
501 "最高",
502 "最低",
503 "买1价",
504 "买1量",
505 "卖1价",
506 "卖1量",
507 "时间",
508 "接口",
509 ],
510 |ui| {
511 for t in &ticks {
512 let row_sel = shared.order_form().symbol == t.symbol;
517 let mut cells = Vec::with_capacity(14);
518 let mut dbl = false;
519 let resp = ui.button(t.symbol.clone());
520 if resp.clicked() {
521 shared.set_selected_exchange(active.clone());
522 import_tick(shared, t, &active, 0);
523 }
524 dbl |= resp.double_clicked();
525 cells.push(resp.rect);
526 macro_rules! cell {
527 ($base:expr, $col:expr) => {{
528 let base = $base;
529 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
530 if r.clicked() {
531 shared.set_selected_exchange(active.clone());
532 import_tick(shared, t, &active, $col);
533 }
534 dbl |= r.double_clicked();
535 cells.push(base.rect);
536 }};
537 }
538 cell!(
539 ui.label(if t.name.is_empty() {
540 "-".to_string()
541 } else {
542 t.name.clone()
543 }),
544 1
545 );
546 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
547 cell!(
548 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
549 if t.pct_change < 0.0 {
550 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
551 } else {
552 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
553 },
554 )),
555 3
556 );
557 cell!(ui.label(format!("{:.0}", t.volume)), 4);
558 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
559 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
560 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
561 cell!(
562 ui.label(
563 egui::RichText::new(format!("{:.1}", t.bid_price_1))
564 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
565 ),
566 8
567 );
568 cell!(
569 ui.label(
570 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
571 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
572 ),
573 9
574 );
575 cell!(
576 ui.label(
577 egui::RichText::new(format!("{:.1}", t.ask_price_1))
578 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
579 ),
580 10
581 );
582 cell!(
583 ui.label(
584 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
585 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
586 ),
587 11
588 );
589 cell!(
590 ui.label(if t.update_time.is_empty() {
591 fmt_dt(Some(t.datetime))
592 } else {
593 t.update_time.clone()
594 }),
595 12
596 );
597 cell!(ui.label(t.gateway_name.clone()), 13);
598 if dbl {
599 shared.open_kline(t.symbol.clone());
600 }
601 paint_row_bg(ui, &cells, row_sel);
602 ui.end_row();
603 }
604 },
605 );
606}
607
608fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
611 let mut f = shared.order_form();
613 let before = f.clone();
614 let mut err: Option<String> = None;
615
616 if f.follow_price {
618 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
619 if t.last_price > 0.0 {
620 f.price = format!("{:.1}", t.last_price);
621 }
622 }
623 }
624
625 let mut ex_opts: Vec<String> = shared
627 .tick_groups()
628 .iter()
629 .map(|(e, _)| e.clone())
630 .collect();
631 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
632 if !ex_opts.iter().any(|x| x == e) {
633 ex_opts.push(e.to_string());
634 }
635 }
636 if !ex_opts.contains(&f.exchange) {
637 ex_opts.insert(0, f.exchange.clone());
638 }
639 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 ["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 "ctp".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 "ctp".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 "ctp".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 "ctp".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
1686fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1690 ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1691 let mut form: crate::state::CtpForm = shared.ctp_form();
1692 egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1693 ui.label("broker");
1694 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1695 ui.label("td 前置");
1696 ctp_edit(
1697 ui,
1698 shared,
1699 &mut form.td_addr,
1700 "tcp://host:40001",
1701 CtpField::TdAddr,
1702 );
1703 ui.label("td 用户");
1704 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1705 ui.end_row();
1706 ui.label("td 密码");
1707 ctp_edit(
1708 ui,
1709 shared,
1710 &mut form.td_password,
1711 "密码",
1712 CtpField::TdPassword,
1713 );
1714 ui.label("AppID");
1715 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1716 ui.label("AuthCode");
1717 ctp_edit(
1718 ui,
1719 shared,
1720 &mut form.td_auth_code,
1721 "授权码",
1722 CtpField::AuthCode,
1723 );
1724 ui.end_row();
1725 ui.label("md 前置");
1726 ctp_edit(
1727 ui,
1728 shared,
1729 &mut form.md_addr,
1730 "tcp://host:40011",
1731 CtpField::MdAddr,
1732 );
1733 ui.label("md 用户");
1734 ctp_edit(
1735 ui,
1736 shared,
1737 &mut form.md_user,
1738 "空=td 回落",
1739 CtpField::MdUser,
1740 );
1741 ui.label("md 密码");
1742 ctp_edit(
1743 ui,
1744 shared,
1745 &mut form.md_password,
1746 "空=td 回落",
1747 CtpField::MdPassword,
1748 );
1749 ui.end_row();
1750 });
1751 ui.horizontal(|ui| {
1752 if ui.small_button(BTN_CTP_CONNECT).clicked() {
1753 shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1754 shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1755 }
1756 ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1757 });
1758}
1759
1760fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1764 let mut q = shared.contract_query();
1765 ui.horizontal(|ui| {
1766 ui.label("代码或交易所(留空 = 全部):");
1767 if ui
1768 .add(
1769 egui::TextEdit::singleline(&mut q)
1770 .hint_text("cu 或 SHFE")
1771 .desired_width(200.0),
1772 )
1773 .changed()
1774 {
1775 shared.set_contract_query(q.clone());
1776 }
1777 });
1778 let rows = shared.contracts(&q);
1779 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
1782 if rows.is_empty() {
1783 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1785 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1786 });
1787 return;
1788 }
1789 let selected = shared.selected_contracts();
1790 ui.horizontal(|ui| {
1795 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1796 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1797 if selected.is_empty() {
1798 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1799 } else {
1800 let symbols: Vec<String> = selected
1802 .iter()
1803 .map(|k| {
1804 k.split_once('.')
1805 .map(|(_, s)| s.to_string())
1806 .unwrap_or_else(|| k.clone())
1807 })
1808 .collect();
1809 shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1810 shared.log_line(format!(
1811 "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1812 selected.len()
1813 ));
1814 }
1815 }
1816 });
1817 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1818 for c in &rows {
1819 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1820 let mut on = selected.contains(&key);
1821 let row_sel = shared.row_selected("contracts", &key);
1825 let mut cells = Vec::with_capacity(5);
1826 let ck = ui.checkbox(&mut on, key.clone());
1827 if ck.changed() {
1828 shared.toggle_contract_selection(&key);
1829 shared.select_row("contracts", &key);
1830 }
1831 cells.push(ck.rect);
1832 macro_rules! cell {
1833 ($base:expr, $col:expr) => {{
1834 let base = $base;
1835 let r = click_cell(ui, base.rect, ("crt", &key, $col));
1836 if r.clicked() {
1837 shared.select_row("contracts", &key);
1838 }
1839 cells.push(base.rect);
1840 }};
1841 }
1842 cell!(ui.label(c.exchange.as_str().to_string()), 1);
1843 cell!(ui.label(c.name.clone()), 2);
1844 cell!(ui.label(format!("{:.0}", c.size)), 3);
1845 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1846 paint_row_bg(ui, &cells, row_sel);
1847 ui.end_row();
1848 }
1849 });
1850}
1851
1852enum CtpField {
1854 Broker,
1855 TdAddr,
1856 TdUser,
1857 TdPassword,
1858 AppId,
1859 AuthCode,
1860 MdAddr,
1861 MdUser,
1862 MdPassword,
1863}
1864
1865fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1867 let is_secret = matches!(
1868 field,
1869 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1870 );
1871 let mut w = egui::TextEdit::singleline(val)
1872 .hint_text(hint)
1873 .desired_width(110.0);
1874 if is_secret {
1875 w = w.password(true);
1876 }
1877 if ui.add(w).changed() {
1878 let mut f = shared.ctp_form();
1879 match field {
1880 CtpField::Broker => f.broker_id = val.clone(),
1881 CtpField::TdAddr => f.td_addr = val.clone(),
1882 CtpField::TdUser => f.td_user = val.clone(),
1883 CtpField::TdPassword => f.td_password = val.clone(),
1884 CtpField::AppId => f.td_app_id = val.clone(),
1885 CtpField::AuthCode => f.td_auth_code = val.clone(),
1886 CtpField::MdAddr => f.md_addr = val.clone(),
1887 CtpField::MdUser => f.md_user = val.clone(),
1888 CtpField::MdPassword => f.md_password = val.clone(),
1889 }
1890 shared.set_ctp_form(f);
1891 }
1892}
1893
1894fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1898 let Some(c) = cell else {
1899 ui.label("(K线无格:等 dm 合成)");
1900 return;
1901 };
1902 if c.bars.is_empty() {
1903 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1904 return;
1905 }
1906 let mut data = egui_charts::model::bar::BarData::new();
1907 for b in &c.bars {
1908 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1909 data.push(egui_charts::model::bar::Bar::new(
1910 dt,
1911 b.open_price,
1912 b.high_price,
1913 b.low_price,
1914 b.close_price,
1915 b.volume,
1916 ));
1917 }
1918 ui.label(format!(
1919 "K线 {}@{}s({} 根)",
1920 c.symbol,
1921 c.interval,
1922 c.bars.len()
1923 ));
1924 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1925 chart.set_symbol(&c.symbol);
1926 chart.set_timeframe_label(&format!("{}s", c.interval));
1927 chart.enable_tracking_mode();
1928 let _ = chart.show(ui);
1929}