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