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