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 strip_h = head_h + ui.spacing().item_spacing.y;
396 let strip = egui::Rect::from_min_size(area.min, egui::vec2(area.width(), strip_h));
397 ui.scope_builder(
398 egui::UiBuilder::new()
399 .id_salt(format!("{grid_id}_hdr_ov"))
400 .max_rect(strip),
401 |ui| {
402 ui.set_clip_rect(area);
403 let painter = ui.painter();
404 painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
405 let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
406 let color = ui.visuals().strong_text_color();
407 for (rect, text) in heads.iter() {
408 painter.text(
409 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
410 egui::Align2::LEFT_CENTER,
411 text.as_str(),
412 font.clone(),
413 color,
414 );
415 }
416 },
417 );
418}
419
420fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
423 ui.horizontal(|ui| {
424 if ui.button(MENU_OPS).clicked() {
426 shared.set_ops_window_open(true);
427 }
428 if ui.button(MENU_CTP).clicked() {
429 shared.set_ctp_window_open(true);
430 }
431 if ui.button(MENU_CONTRACTS).clicked() {
432 shared.set_contracts_window_open(true);
433 }
434 ui.menu_button(MENU_HELP, |ui| {
435 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
436 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
437 });
438 if ui
441 .button(format!("主题:{}", shared.theme().label()))
442 .clicked()
443 {
444 let t = shared.cycle_theme();
445 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
446 }
447 });
448}
449
450fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
453 let groups = shared.tick_groups();
455 if groups.is_empty() {
456 ui.label("(等待 eTick 订阅)");
457 return;
458 }
459 let sel = shared.selected_exchange();
461 let active = if groups.iter().any(|(e, _)| *e == sel) {
462 sel.clone()
463 } else {
464 groups[0].0.clone()
465 };
466 ui.horizontal(|ui| {
467 for (ex, _) in &groups {
468 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
469 shared.set_selected_exchange(ex.clone());
470 }
471 }
472 });
473 let ticks = groups
474 .iter()
475 .find(|(e, _)| *e == active)
476 .map(|(_, v)| v.clone())
477 .unwrap_or_default();
478 let last_time = ticks
479 .iter()
480 .map(|t| {
481 if t.update_time.is_empty() {
482 fmt_dt(Some(t.datetime))
483 } else {
484 t.update_time.clone()
485 }
486 })
487 .max()
488 .unwrap_or_else(|| "-".to_string());
489 ui.label(format!(
490 "tick {} 格 / 最后更新 {last_time}",
491 shared.tick_count()
492 ));
493 pinned_table(
494 ui,
495 "tick_grid",
496 14,
497 &[
498 "代码",
499 "名称",
500 "最新价",
501 "涨跌幅",
502 "成交量",
503 "今开",
504 "最高",
505 "最低",
506 "买1价",
507 "买1量",
508 "卖1价",
509 "卖1量",
510 "时间",
511 "接口",
512 ],
513 |ui| {
514 for t in &ticks {
515 let row_sel = shared.order_form().symbol == t.symbol;
520 let mut cells = Vec::with_capacity(14);
521 let mut dbl = false;
522 let resp = ui.button(t.symbol.clone());
523 if resp.clicked() {
524 shared.set_selected_exchange(active.clone());
525 import_tick(shared, t, &active, 0);
526 }
527 dbl |= resp.double_clicked();
528 cells.push(resp.rect);
529 macro_rules! cell {
530 ($base:expr, $col:expr) => {{
531 let base = $base;
532 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
533 if r.clicked() {
534 shared.set_selected_exchange(active.clone());
535 import_tick(shared, t, &active, $col);
536 }
537 dbl |= r.double_clicked();
538 cells.push(base.rect);
539 }};
540 }
541 cell!(
542 ui.label(if t.name.is_empty() {
543 "-".to_string()
544 } else {
545 t.name.clone()
546 }),
547 1
548 );
549 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
550 cell!(
551 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
552 if t.pct_change < 0.0 {
553 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
554 } else {
555 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
556 },
557 )),
558 3
559 );
560 cell!(ui.label(format!("{:.0}", t.volume)), 4);
561 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
562 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
563 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
564 cell!(
565 ui.label(
566 egui::RichText::new(format!("{:.1}", t.bid_price_1))
567 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
568 ),
569 8
570 );
571 cell!(
572 ui.label(
573 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
574 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
575 ),
576 9
577 );
578 cell!(
579 ui.label(
580 egui::RichText::new(format!("{:.1}", t.ask_price_1))
581 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
582 ),
583 10
584 );
585 cell!(
586 ui.label(
587 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
588 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
589 ),
590 11
591 );
592 cell!(
593 ui.label(if t.update_time.is_empty() {
594 fmt_dt(Some(t.datetime))
595 } else {
596 t.update_time.clone()
597 }),
598 12
599 );
600 cell!(ui.label(t.gateway_name.clone()), 13);
601 if dbl {
602 shared.open_kline(t.symbol.clone());
603 }
604 paint_row_bg(ui, &cells, row_sel);
605 ui.end_row();
606 }
607 },
608 );
609}
610
611fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
614 let mut f = shared.order_form();
616 let before = f.clone();
617 let mut err: Option<String> = None;
618
619 if f.follow_price {
621 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
622 if t.last_price > 0.0 {
623 f.price = format!("{:.1}", t.last_price);
624 }
625 }
626 }
627
628 let mut ex_opts: Vec<String> = shared
630 .tick_groups()
631 .iter()
632 .map(|(e, _)| e.clone())
633 .collect();
634 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
635 if !ex_opts.iter().any(|x| x == e) {
636 ex_opts.push(e.to_string());
637 }
638 }
639 if !ex_opts.contains(&f.exchange) {
640 ex_opts.insert(0, f.exchange.clone());
641 }
642 let ts = shared.trade_snap();
644 let mut gw_opts: Vec<String> = Vec::new();
645 for g in ts
646 .orders
647 .iter()
648 .map(|o| o.gateway_name.clone())
649 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
650 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
651 {
652 if !g.is_empty() && !gw_opts.contains(&g) {
653 gw_opts.push(g);
654 }
655 }
656 for g in ["sim", "ctp"] {
657 if !gw_opts.iter().any(|x| x == g) {
658 gw_opts.push(g.to_string());
659 }
660 }
661 if !gw_opts.contains(&f.gateway) {
662 gw_opts.insert(0, f.gateway.clone());
663 }
664
665 egui::Grid::new("order_form_grid")
668 .num_columns(2)
669 .show(ui, |ui| {
670 ui.label("交易所");
671 egui::ComboBox::from_id_salt("of_exchange")
672 .selected_text(f.exchange.clone())
673 .show_ui(ui, |ui| {
674 for e in &ex_opts {
675 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
676 }
677 });
678 ui.end_row();
679 ui.label("代码");
680 let sym_resp = ui.add(
681 egui::TextEdit::singleline(&mut f.symbol)
682 .hint_text("cu2709")
683 .desired_width(150.0),
684 );
685 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
690 let s = f.symbol.trim().to_string();
691 let bare = match s.split_once('.') {
694 Some((_, b)) => b.trim().to_string(),
695 None => s,
696 };
697 if !bare.is_empty() {
698 f.symbol = bare.clone();
699 shared.push_cmd(UiCmd::CtpSubscribe {
700 symbols: vec![bare.clone()],
701 });
702 shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
703 }
704 }
705 ui.end_row();
706 ui.label("名称");
707 ui.label(
708 tick_of(shared, &f.exchange, &f.symbol)
709 .map(|t| {
710 if t.name.is_empty() {
711 "-".to_string()
712 } else {
713 t.name
714 }
715 })
716 .unwrap_or_else(|| "-".to_string()),
717 );
718 ui.end_row();
719 ui.label("方向");
720 egui::ComboBox::from_id_salt("of_direction")
721 .selected_text(f.direction.as_str())
722 .show_ui(ui, |ui| {
723 for d in [Direction::LONG, Direction::SHORT] {
724 ui.selectable_value(&mut f.direction, d, d.as_str());
725 }
726 });
727 ui.end_row();
728 ui.label("开平");
729 ui.horizontal(|ui| {
730 egui::ComboBox::from_id_salt("of_offset")
731 .selected_text(f.offset.as_str())
732 .show_ui(ui, |ui| {
733 for o in [
734 Offset::OPEN,
735 Offset::CLOSE,
736 Offset::CLOSETODAY,
737 Offset::CLOSEYESTERDAY,
738 ] {
739 ui.selectable_value(&mut f.offset, o, o.as_str());
740 }
741 });
742 ui.checkbox(&mut f.auto_split, "自动开平");
744 });
745 ui.end_row();
746 ui.label("类型");
747 egui::ComboBox::from_id_salt("of_order_type")
748 .selected_text(f.order_type.as_str())
749 .show_ui(ui, |ui| {
750 for ot in [OrderType::LIMIT, OrderType::MARKET] {
751 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
752 }
753 });
754 ui.end_row();
755 ui.label("价格");
756 ui.horizontal(|ui| {
757 ui.add(
758 egui::TextEdit::singleline(&mut f.price)
759 .hint_text("107660")
760 .desired_width(90.0),
761 );
762 ui.checkbox(&mut f.follow_price, "随价");
763 });
764 ui.end_row();
765 ui.label("数量");
766 ui.add(
767 egui::TextEdit::singleline(&mut f.volume)
768 .hint_text("1")
769 .desired_width(90.0),
770 );
771 ui.end_row();
772 ui.label("接口");
773 egui::ComboBox::from_id_salt("of_gateway")
774 .selected_text(if f.gateway.is_empty() {
775 "sim".to_string()
776 } else {
777 f.gateway.clone()
778 })
779 .show_ui(ui, |ui| {
780 for g in &gw_opts {
781 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
782 }
783 });
784 ui.end_row();
785 ui.label("操作");
786 ui.horizontal(|ui| {
787 if ui.button(BTN_SEND_ORDER).clicked() {
788 match f.to_request() {
789 Ok(req) => match crate::state::parse_gateway(&f.gateway) {
790 Ok(gateway) => {
791 shared.push_cmd(UiCmd::SendOrder {
792 req,
793 gateway,
794 mode: if f.auto_split {
795 ek_oms::LockMode::Auto
796 } else {
797 ek_oms::LockMode::None
798 },
799 });
800 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
801 }
802 Err(e) => {
803 shared.log_line(format!("[下单] {e}"));
804 err = Some(e);
805 }
806 },
807 Err(e) => {
808 shared.log_line(format!("[下单] {e}"));
809 err = Some(e);
810 }
811 }
812 }
813 if ui.button(BTN_CANCEL_ALL).clicked() {
814 let mut n = 0usize;
817 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
818 let gw = if o.gateway_name.is_empty() {
819 "sim".to_string()
820 } else {
821 o.gateway_name.clone()
822 };
823 shared.push_cmd(UiCmd::CancelOrder {
824 orderid: o.orderid.clone(),
825 gateway: gw,
826 });
827 n += 1;
828 }
829 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
830 }
831 });
832 ui.end_row();
833 ui.label("代码回车 = 订阅");
834 ui.label("");
835 ui.end_row();
836 });
837 if let Some(e) = err {
838 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
839 }
840 if f != before {
841 shared.set_order_form(f.clone());
842 }
843}
844
845fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
849 let f = shared.order_form();
850 match tick_of(shared, &f.exchange, &f.symbol) {
851 Some(t) => {
852 for i in (1..=5).rev() {
853 let (p, v) = match i {
854 1 => (t.ask_price_1, t.ask_volume_1),
855 2 => (t.ask_price_2, t.ask_volume_2),
856 3 => (t.ask_price_3, t.ask_volume_3),
857 4 => (t.ask_price_4, t.ask_volume_4),
858 _ => (t.ask_price_5, t.ask_volume_5),
859 };
860 ui.label(
861 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
862 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
863 );
864 }
865 for i in 1..=5 {
866 let (p, v) = match i {
867 1 => (t.bid_price_1, t.bid_volume_1),
868 2 => (t.bid_price_2, t.bid_volume_2),
869 3 => (t.bid_price_3, t.bid_volume_3),
870 4 => (t.bid_price_4, t.bid_volume_4),
871 _ => (t.bid_price_5, t.bid_volume_5),
872 };
873 ui.label(
874 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
875 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
876 );
877 }
878 }
879 None => {
880 ui.label("(无 tick)");
881 }
882 }
883}
884
885fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
888 let tab = shared.topleft_tab();
889 ui.horizontal(|ui| {
890 if ui
891 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
892 .clicked()
893 {
894 shared.set_topleft_tab(TopLeftTab::Orders);
895 }
896 if ui
897 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
898 .clicked()
899 {
900 shared.set_topleft_tab(TopLeftTab::Active);
901 }
902 });
903 render_orders_table(ui, shared, tab == TopLeftTab::Active);
904}
905
906fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
910 let ts = shared.trade_snap();
911 let rows: Vec<_> = ts
914 .orders
915 .iter()
916 .filter(|o| !active_only || order_active(o.status))
917 .collect();
918 if rows.is_empty() {
919 ui.label(if active_only {
922 "(无未成交)"
923 } else {
924 "(无委托:等网关 send_order)"
925 });
926 }
927 pinned_table(
928 ui,
929 if active_only {
930 "active_grid"
931 } else {
932 "order_grid"
933 },
934 17,
935 &[
936 "代码",
937 "名称",
938 "交易所",
939 "类型",
940 "方向",
941 "开平",
942 "价格",
943 "总量",
944 "已成",
945 "成交均价",
946 "状态",
947 "状态描述",
948 "时间",
949 "委托号",
950 "来源",
951 "接口",
952 "操作",
953 ],
954 |ui| {
955 for o in &rows {
956 let key = format!("{}.{}", o.gateway_name, o.orderid);
960 let row_sel = shared.row_selected("orders", &key);
961 let mut cells = Vec::with_capacity(17);
962 let mut dbl = false;
963 let sel = ui.selectable_label(row_sel, o.symbol.clone());
964 if sel.clicked() {
965 shared.select_row("orders", &key);
966 }
967 dbl |= sel.double_clicked();
968 cells.push(sel.rect);
969 macro_rules! cell {
970 ($base:expr, $col:expr) => {{
971 let base = $base;
972 let r = click_cell(ui, base.rect, ("ord", &key, $col));
973 if r.clicked() {
974 shared.select_row("orders", &key);
975 }
976 dbl |= r.double_clicked();
977 cells.push(base.rect);
978 }};
979 }
980 cell!(
981 ui.label(if o.name.is_empty() {
982 "-".to_string()
983 } else {
984 o.name.clone()
985 }),
986 1
987 );
988 cell!(ui.label(o.exchange.as_str().to_string()), 2);
989 cell!(ui.label(o.order_type.as_str().to_string()), 3);
990 cell!(
991 ui.label(
992 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
993 if o.direction == Some(Direction::SHORT) {
994 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
995 } else {
996 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
997 },
998 ),
999 ),
1000 4
1001 );
1002 cell!(ui.label(o.offset.as_str().to_string()), 5);
1003 cell!(ui.label(format!("{:.1}", o.price)), 6);
1004 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1005 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1006 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1007 cell!(
1008 ui.label(egui::RichText::new(o.status.as_str()).color(
1009 if o.status == ek_data::constant::Status::REJECTED {
1010 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1011 } else if o.status == ek_data::constant::Status::ALLTRADED {
1012 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1013 } else {
1014 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1015 },
1016 )),
1017 10
1018 );
1019 cell!(ui.label(o.status_msg.clone()), 11);
1020 cell!(ui.label(fmt_dt(o.datetime)), 12);
1021 cell!(ui.label(o.orderid.clone()), 13);
1022 cell!(
1023 ui.label(if o.reference.is_empty() {
1024 "-".to_string()
1025 } else {
1026 o.reference.clone()
1027 }),
1028 14
1029 );
1030 cell!(ui.label(o.gateway_name.clone()), 15);
1031 if dbl && order_active(o.status) {
1032 let gw = if o.gateway_name.is_empty() {
1033 "sim".to_string()
1034 } else {
1035 o.gateway_name.clone()
1036 };
1037 shared.push_cmd(UiCmd::CancelOrder {
1038 orderid: o.orderid.clone(),
1039 gateway: gw,
1040 });
1041 }
1042 let hr = ui.horizontal(|ui| {
1043 if order_active(o.status)
1044 && ui
1045 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1046 .clicked()
1047 {
1048 let gw = if o.gateway_name.is_empty() {
1050 "sim".to_string()
1051 } else {
1052 o.gateway_name.clone()
1053 };
1054 shared.push_cmd(UiCmd::CancelOrder {
1055 orderid: o.orderid.clone(),
1056 gateway: gw,
1057 });
1058 }
1059 });
1060 cells.push(hr.response.rect);
1061 paint_row_bg(ui, &cells, row_sel);
1062 ui.end_row();
1063 }
1064 },
1065 );
1066}
1067
1068fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1070 ui.label("成交");
1071 let ts = shared.trade_snap();
1072 if ts.trades.is_empty() {
1073 ui.label("(无成交)"); }
1075 pinned_table(
1076 ui,
1077 "trade_grid",
1078 12,
1079 &[
1080 "成交号",
1081 "代码",
1082 "名称",
1083 "交易所",
1084 "方向",
1085 "开平",
1086 "价格",
1087 "数量",
1088 "成交额",
1089 "时间",
1090 "委托号",
1091 "接口",
1092 ],
1093 |ui| {
1094 for t in &ts.trades {
1095 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1098 let row_sel = shared.row_selected("trades", &key);
1099 let mut cells = Vec::with_capacity(12);
1100 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1101 if sel.clicked() {
1102 shared.select_row("trades", &key);
1103 }
1104 cells.push(sel.rect);
1105 macro_rules! cell {
1106 ($base:expr, $col:expr) => {{
1107 let base = $base;
1108 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1109 if r.clicked() {
1110 shared.select_row("trades", &key);
1111 }
1112 cells.push(base.rect);
1113 }};
1114 }
1115 cell!(ui.label(t.symbol.clone()), 1);
1116 cell!(
1117 ui.label(if t.name.is_empty() {
1118 "-".to_string()
1119 } else {
1120 t.name.clone()
1121 }),
1122 2
1123 );
1124 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1125 cell!(
1126 ui.label(
1127 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1128 if t.direction == Some(Direction::SHORT) {
1129 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1130 } else {
1131 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1132 },
1133 ),
1134 ),
1135 4
1136 );
1137 cell!(ui.label(t.offset.as_str().to_string()), 5);
1138 cell!(ui.label(format!("{:.1}", t.price)), 6);
1139 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1140 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1141 cell!(ui.label(fmt_dt(t.datetime)), 9);
1142 cell!(ui.label(t.orderid.clone()), 10);
1143 cell!(ui.label(t.gateway_name.clone()), 11);
1144 paint_row_bg(ui, &cells, row_sel);
1145 ui.end_row();
1146 }
1147 },
1148 );
1149}
1150
1151fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1154 ui.label("持仓");
1155 let ts = shared.trade_snap();
1156 if ts.positions.is_empty() {
1157 ui.label("(无持仓)"); }
1159 pinned_table(
1160 ui,
1161 "pos_grid",
1162 16,
1163 &[
1164 "代码",
1165 "名称",
1166 "交易所",
1167 "方向",
1168 "数量",
1169 "昨仓",
1170 "可用",
1171 "冻结",
1172 "在途",
1173 "市值",
1174 "成本",
1175 "均价",
1176 "最新价",
1177 "盈亏",
1178 "盈亏比",
1179 "接口",
1180 ],
1181 |ui| {
1182 for p in &ts.positions {
1183 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1187 let row_sel = shared.row_selected("positions", &key);
1188 let mut cells = Vec::with_capacity(16);
1189 let mut dbl = false;
1190 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1191 if sel.clicked() {
1192 shared.select_row("positions", &key);
1193 }
1194 dbl |= sel.double_clicked();
1195 cells.push(sel.rect);
1196 macro_rules! cell {
1197 ($base:expr, $col:expr) => {{
1198 let base = $base;
1199 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1200 if r.clicked() {
1201 shared.select_row("positions", &key);
1202 }
1203 dbl |= r.double_clicked();
1204 cells.push(base.rect);
1205 }};
1206 }
1207 cell!(
1208 ui.label(if p.name.is_empty() {
1209 "-".to_string()
1210 } else {
1211 p.name.clone()
1212 }),
1213 1
1214 );
1215 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1216 cell!(
1217 ui.label(egui::RichText::new(p.direction.as_str()).color(
1218 if p.direction == ek_data::constant::Direction::SHORT {
1219 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1220 } else {
1221 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1222 },
1223 )),
1224 3
1225 );
1226 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1227 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1228 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1229 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1230 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1231 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1232 cell!(ui.label(format!("{:.2}", p.price)), 10);
1233 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1234 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1235 cell!(
1236 ui.label(
1237 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1238 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1239 } else {
1240 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1241 }),
1242 ),
1243 13
1244 );
1245 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1246 cell!(ui.label(p.gateway_name.clone()), 15);
1247 if dbl {
1248 let mut f = shared.order_form();
1249 f.symbol = p.symbol.clone();
1250 f.exchange = p.exchange.as_str().to_string();
1251 f.direction = p.direction;
1252 shared.set_order_form(f);
1253 shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1254 }
1255 paint_row_bg(ui, &cells, row_sel);
1256 ui.end_row();
1257 }
1258 },
1259 );
1260}
1261
1262fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1263 let tab = shared.bottomright_tab();
1264 ui.horizontal(|ui| {
1265 if ui
1266 .selectable_label(
1267 tab == BottomRightTab::Account,
1268 BottomRightTab::Account.label(),
1269 )
1270 .clicked()
1271 {
1272 shared.set_bottomright_tab(BottomRightTab::Account);
1273 }
1274 if ui
1275 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1276 .clicked()
1277 {
1278 shared.set_bottomright_tab(BottomRightTab::Logs);
1279 }
1280 });
1281 match tab {
1282 BottomRightTab::Account => {
1283 let ts = shared.trade_snap();
1284 if ts.accounts.is_empty() {
1285 ui.label("(无资金:等网关 eAccount.)");
1286 return;
1287 }
1288 egui::Grid::new("acct_grid")
1289 .striped(true)
1290 .num_columns(5)
1291 .show(ui, |ui| {
1292 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1293 ui.strong(h);
1294 }
1295 ui.end_row();
1296 for a in &ts.accounts {
1297 ui.label(a.accountid.clone());
1298 ui.label(a.gateway_name.clone());
1299 ui.label(format!("{:.2}", a.balance));
1300 ui.label(format!("{:.2}", a.available()));
1301 ui.label(format!("{:.2}", a.frozen));
1302 ui.end_row();
1303 }
1304 });
1305 }
1306 BottomRightTab::Logs => {
1307 render_logs(ui, shared);
1308 }
1309 }
1310}
1311
1312fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1313 ui.horizontal(|ui| {
1314 if ui.button(BTN_CLEAR_LOGS).clicked() {
1315 shared.clear_logs(); }
1317 let (_, spilled) = shared.logs_snapshot();
1318 if spilled > 0 {
1319 ui.label(format!(
1320 "已丢弃 {spilled} 条(环形 {})",
1321 crate::state::RING_CAP
1322 ));
1323 }
1324 });
1325 let lines = shared.logs_snapshot().0;
1326 egui::ScrollArea::vertical()
1327 .id_salt("log_scroll")
1328 .max_height(180.0)
1329 .stick_to_bottom(true)
1330 .show(ui, |ui| {
1331 for line in lines {
1332 ui.monospace(line);
1333 }
1334 });
1335}
1336
1337struct WinGeom {
1341 inner: [f32; 2],
1342 min_inner: [f32; 2],
1343 maximized: bool,
1344}
1345
1346fn show_independent_window(
1361 ctx: &egui::Context,
1362 id_salt: impl std::hash::Hash,
1363 title: &str,
1364 geom: &WinGeom,
1365 embedded: impl Fn(&egui::Context) -> bool,
1366 body: impl Fn(&mut egui::Ui) -> bool,
1367) -> bool {
1368 let closed = std::cell::Cell::new(false);
1369 let mut builder = egui::ViewportBuilder::default()
1370 .with_title(title)
1371 .with_inner_size(geom.inner)
1372 .with_min_inner_size(geom.min_inner)
1373 .with_minimize_button(true)
1374 .with_maximize_button(true)
1375 .with_close_button(true);
1376 if geom.maximized {
1377 builder = builder.with_maximized(true);
1378 }
1379 ctx.show_viewport_immediate(
1380 egui::ViewportId::from_hash_of(id_salt),
1381 builder,
1382 |ctx2, class| {
1383 if class == egui::ViewportClass::Embedded {
1384 closed.set(embedded(ctx2));
1385 return;
1386 }
1387 if ctx2.input(|i| i.viewport().close_requested()) {
1388 closed.set(true);
1389 return;
1390 }
1391 egui::CentralPanel::default().show(ctx2, |ui| {
1392 if body(ui) {
1393 closed.set(true);
1394 }
1395 });
1396 },
1397 );
1398 closed.get()
1399}
1400
1401fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1402 if shared.ops_window_open() {
1404 let closed = show_independent_window(
1405 ctx,
1406 "ops_win",
1407 "运维窗",
1408 &WinGeom {
1409 inner: [680.0, 420.0],
1410 min_inner: [420.0, 260.0],
1411 maximized: false,
1412 },
1413 |ctx2| {
1414 let mut open = shared.ops_window_open();
1415 egui::Window::new("运维窗")
1416 .id(egui::Id::new("ops_win"))
1417 .default_pos([80.0, 320.0])
1418 .default_size([640.0, 380.0])
1419 .open(&mut open)
1420 .show(ctx2, |ui| {
1421 render_ops_window(ui, shared);
1422 });
1423 shared.set_ops_window_open(open);
1424 !open
1425 },
1426 |ui| {
1427 render_ops_window(ui, shared);
1428 false
1429 },
1430 );
1431 if closed {
1432 shared.set_ops_window_open(false);
1433 }
1434 }
1435
1436 if shared.ctp_window_open() {
1438 let closed = show_independent_window(
1439 ctx,
1440 "ctp_win",
1441 "CTP 网关配置窗",
1442 &WinGeom {
1443 inner: [600.0, 380.0],
1444 min_inner: [420.0, 260.0],
1445 maximized: false,
1446 },
1447 |ctx2| {
1448 let mut open = shared.ctp_window_open();
1449 egui::Window::new("CTP 网关配置窗")
1450 .id(egui::Id::new("ctp_win"))
1451 .default_pos([640.0, 320.0])
1452 .default_size([560.0, 330.0])
1453 .open(&mut open)
1454 .show(ctx2, |ui| {
1455 render_ctp_window(ui, shared);
1456 });
1457 shared.set_ctp_window_open(open);
1458 !open
1459 },
1460 |ui| {
1461 render_ctp_window(ui, shared);
1462 false
1463 },
1464 );
1465 if closed {
1466 shared.set_ctp_window_open(false);
1467 }
1468 }
1469
1470 if shared.contracts_window_open() {
1472 let closed = show_independent_window(
1473 ctx,
1474 "contracts_win",
1475 "查询合约窗",
1476 &WinGeom {
1477 inner: [620.0, 460.0],
1478 min_inner: [420.0, 260.0],
1479 maximized: false,
1480 },
1481 |ctx2| {
1482 let mut open = shared.contracts_window_open();
1483 egui::Window::new("查询合约窗")
1484 .id(egui::Id::new("contracts_win"))
1485 .default_pos([420.0, 300.0])
1486 .default_size([560.0, 420.0])
1487 .open(&mut open)
1488 .show(ctx2, |ui| {
1489 render_contracts_window(ui, shared);
1490 });
1491 shared.set_contracts_window_open(open);
1492 !open
1493 },
1494 |ui| {
1495 render_contracts_window(ui, shared);
1496 false
1497 },
1498 );
1499 if closed {
1500 shared.set_contracts_window_open(false);
1501 }
1502 }
1503
1504 let mut closed: Vec<String> = Vec::new();
1508 for sym in shared.kline_symbols() {
1509 let is_closed = show_independent_window(
1510 ctx,
1511 format!("kline_win:{sym}"),
1512 &format!("K线 — {sym}"),
1513 &WinGeom {
1514 inner: [560.0, 360.0],
1515 min_inner: [360.0, 220.0],
1516 maximized: true,
1517 },
1518 |ctx2| {
1519 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1520 let resp = egui::Window::new(format!("K线 — {sym}"))
1521 .id(egui::Id::new(("kline_win", sym.clone())))
1522 .default_pos([300.0, 340.0])
1523 .default_size([540.0, 330.0])
1524 .show(ctx2, |ui| {
1525 let cell = shared
1526 .market()
1527 .cells()
1528 .into_iter()
1529 .find(|c| c.symbol == sym);
1530 render_kline_cell(ui, cell.as_ref());
1531 });
1532 esc || resp.is_none()
1533 },
1534 |ui| {
1535 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1536 return true;
1537 }
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 false
1545 },
1546 );
1547 if is_closed {
1548 closed.push(sym);
1549 }
1550 }
1551 for s in closed {
1552 shared.close_kline(&s);
1553 }
1554}
1555
1556fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1559 ui.label("插件");
1560 let snap = shared.snapshot();
1561 egui::Grid::new("plug_grid")
1562 .striped(true)
1563 .num_columns(6)
1564 .show(ui, |ui| {
1565 ui.strong("名称");
1566 ui.strong("状态");
1567 ui.strong("版本");
1568 ui.strong("依赖");
1569 ui.strong("统计");
1570 ui.strong("操作");
1571 ui.end_row();
1572 if snap.rows.is_empty() {
1573 ui.label("(快照空:等 eTimer 首拉)");
1574 ui.end_row();
1575 }
1576 for r in &snap.rows {
1577 if is_quarantined(&r.state) {
1578 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1579 ui.label(
1580 egui::RichText::new(state_text(r))
1581 .color(egui::Color32::RED)
1582 .strong(),
1583 );
1584 } else {
1585 ui.label(r.name.clone());
1586 ui.label(state_text(r));
1587 }
1588 ui.label(r.version.clone());
1589 ui.label(format!("deps:[{}]", r.deps.join(",")));
1590 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1591 ui.horizontal(|ui| {
1592 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1593 shared.push_cmd(UiCmd::Start {
1594 factory: r.name.clone(),
1595 });
1596 }
1597 let armed = shared.pending_confirm();
1598 let armed_here = ui
1599 .small_button(remove_button_label(&r.name, armed.as_deref()))
1600 .clicked()
1601 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1602 if armed_here {
1603 shared.push_cmd(UiCmd::Remove {
1604 name: r.name.clone(),
1605 });
1606 }
1607 });
1608 ui.end_row();
1609 }
1610 });
1611
1612 if let Some(name) = shared.pending_confirm() {
1613 ui.horizontal(|ui| {
1614 ui.label(
1615 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1616 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1617 );
1618 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1619 shared.confirm_cancel();
1620 }
1621 });
1622 }
1623
1624 ui.label("服务");
1625 let flat: Vec<String> = snap
1626 .services
1627 .iter()
1628 .flat_map(|(n, ids)| {
1629 if ids.is_empty() {
1630 vec![format!("{n} 服务"), "-".to_string()]
1631 } else {
1632 ids.iter()
1633 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1634 .collect()
1635 }
1636 })
1637 .collect();
1638 if flat.is_empty() {
1639 ui.label("(无服务目录)");
1640 } else {
1641 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1643 .striped(true)
1644 .num_columns(6)
1645 .show(ui, |ui| {
1646 for r in 0..rows {
1647 for c in 0..3 {
1648 let i = c * rows * 2 + r * 2;
1649 if i < flat.len() {
1650 ui.label(flat[i].clone());
1651 ui.label(flat[i + 1].clone());
1652 } else {
1653 ui.label("");
1654 ui.label("");
1655 }
1656 }
1657 ui.end_row();
1658 }
1659 });
1660 }
1661
1662 ui.label("依赖");
1663 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1664 ui.label("(全部无依赖)");
1665 }
1666 for r in &snap.rows {
1667 if !r.deps.is_empty() {
1668 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1669 }
1670 }
1671
1672 ui.horizontal(|ui| {
1673 if ui.small_button(BTN_QUERY_RATE).clicked() {
1674 shared.push_cmd(UiCmd::QueryRate);
1675 }
1676 });
1677}
1678
1679fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1683 ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1684 let mut form: crate::state::CtpForm = shared.ctp_form();
1685 egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1686 ui.label("broker");
1687 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1688 ui.label("td 前置");
1689 ctp_edit(
1690 ui,
1691 shared,
1692 &mut form.td_addr,
1693 "tcp://host:40001",
1694 CtpField::TdAddr,
1695 );
1696 ui.label("td 用户");
1697 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1698 ui.end_row();
1699 ui.label("td 密码");
1700 ctp_edit(
1701 ui,
1702 shared,
1703 &mut form.td_password,
1704 "密码",
1705 CtpField::TdPassword,
1706 );
1707 ui.label("AppID");
1708 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1709 ui.label("AuthCode");
1710 ctp_edit(
1711 ui,
1712 shared,
1713 &mut form.td_auth_code,
1714 "授权码",
1715 CtpField::AuthCode,
1716 );
1717 ui.end_row();
1718 ui.label("md 前置");
1719 ctp_edit(
1720 ui,
1721 shared,
1722 &mut form.md_addr,
1723 "tcp://host:40011",
1724 CtpField::MdAddr,
1725 );
1726 ui.label("md 用户");
1727 ctp_edit(
1728 ui,
1729 shared,
1730 &mut form.md_user,
1731 "空=td 回落",
1732 CtpField::MdUser,
1733 );
1734 ui.label("md 密码");
1735 ctp_edit(
1736 ui,
1737 shared,
1738 &mut form.md_password,
1739 "空=td 回落",
1740 CtpField::MdPassword,
1741 );
1742 ui.end_row();
1743 });
1744 ui.horizontal(|ui| {
1745 if ui.small_button(BTN_CTP_CONNECT).clicked() {
1746 shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1747 shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1748 }
1749 ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1750 });
1751}
1752
1753fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1757 let mut q = shared.contract_query();
1758 ui.horizontal(|ui| {
1759 ui.label("代码或交易所(留空 = 全部):");
1760 if ui
1761 .add(
1762 egui::TextEdit::singleline(&mut q)
1763 .hint_text("cu 或 SHFE")
1764 .desired_width(200.0),
1765 )
1766 .changed()
1767 {
1768 shared.set_contract_query(q.clone());
1769 }
1770 });
1771 let rows = shared.contracts(&q);
1772 if rows.is_empty() {
1773 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1775 pinned_table(
1776 ui,
1777 "contract_grid",
1778 5,
1779 &["代码", "名称", "交易所", "乘数", "最后价"],
1780 |_ui| {},
1781 );
1782 return;
1783 }
1784 let selected = shared.selected_contracts();
1785 ui.horizontal(|ui| {
1790 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1791 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1792 if selected.is_empty() {
1793 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1794 } else {
1795 let symbols: Vec<String> = selected
1797 .iter()
1798 .map(|k| {
1799 k.split_once('.')
1800 .map(|(_, s)| s.to_string())
1801 .unwrap_or_else(|| k.clone())
1802 })
1803 .collect();
1804 shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1805 shared.log_line(format!(
1806 "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1807 selected.len()
1808 ));
1809 }
1810 }
1811 });
1812 pinned_table(
1813 ui,
1814 "contract_grid",
1815 5,
1816 &["选(代码)", "交易所", "名称", "乘数", "最小变动"],
1817 |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}
1852
1853enum CtpField {
1855 Broker,
1856 TdAddr,
1857 TdUser,
1858 TdPassword,
1859 AppId,
1860 AuthCode,
1861 MdAddr,
1862 MdUser,
1863 MdPassword,
1864}
1865
1866fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1868 let is_secret = matches!(
1869 field,
1870 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1871 );
1872 let mut w = egui::TextEdit::singleline(val)
1873 .hint_text(hint)
1874 .desired_width(110.0);
1875 if is_secret {
1876 w = w.password(true);
1877 }
1878 if ui.add(w).changed() {
1879 let mut f = shared.ctp_form();
1880 match field {
1881 CtpField::Broker => f.broker_id = val.clone(),
1882 CtpField::TdAddr => f.td_addr = val.clone(),
1883 CtpField::TdUser => f.td_user = val.clone(),
1884 CtpField::TdPassword => f.td_password = val.clone(),
1885 CtpField::AppId => f.td_app_id = val.clone(),
1886 CtpField::AuthCode => f.td_auth_code = val.clone(),
1887 CtpField::MdAddr => f.md_addr = val.clone(),
1888 CtpField::MdUser => f.md_user = val.clone(),
1889 CtpField::MdPassword => f.md_password = val.clone(),
1890 }
1891 shared.set_ctp_form(f);
1892 }
1893}
1894
1895fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1899 let Some(c) = cell else {
1900 ui.label("(K线无格:等 dm 合成)");
1901 return;
1902 };
1903 if c.bars.is_empty() {
1904 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1905 return;
1906 }
1907 let mut data = egui_charts::model::bar::BarData::new();
1908 for b in &c.bars {
1909 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1910 data.push(egui_charts::model::bar::Bar::new(
1911 dt,
1912 b.open_price,
1913 b.high_price,
1914 b.low_price,
1915 b.close_price,
1916 b.volume,
1917 ));
1918 }
1919 ui.label(format!(
1920 "K线 {}@{}s({} 根)",
1921 c.symbol,
1922 c.interval,
1923 c.bars.len()
1924 ));
1925 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1926 chart.set_symbol(&c.symbol);
1927 chart.set_timeframe_label(&format!("{}s", c.interval));
1928 chart.enable_tracking_mode();
1929 let _ = chart.show(ui);
1930}