1use ek_data::constant::{Direction, Offset, OrderType};
32use ek_station::wire::PlugRow;
33
34use crate::state::{
35 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
36};
37
38pub const BTN_CLEAR_LOGS: &str = "清空日志";
40pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
42pub const BTN_SEND_ORDER: &str = "委托";
44pub const BTN_CANCEL_ALL: &str = "全撤";
46pub const BTN_QUERY_RATE: &str = "流控查询";
48pub const BTN_CANCEL_ORDER: &str = "撤单";
50pub const BTN_CTP_CONNECT: &str = "连接 CTP";
52pub const MENU_OPS: &str = "运维";
54pub const MENU_CTP: &str = "网关";
56pub const MENU_HELP: &str = "帮助";
58pub const MENU_CONTRACTS: &str = "查询合约";
60pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
64
65fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
66 if armed_on == Some(name) {
67 format!("确认移除 {name} ?")
68 } else {
69 format!("移除 {name}")
70 }
71}
72
73fn state_text(r: &PlugRow) -> String {
74 r.state.clone()
75}
76
77fn fmt_dt(dt: Option<i64>) -> String {
79 dt.map(|ns| {
80 chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
81 .format("%H:%M:%S")
82 .to_string()
83 })
84 .unwrap_or_else(|| "-".to_string())
85}
86
87fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
93 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
94 return;
95 };
96 let hovered = ui
97 .input(|i| i.pointer.interact_pos())
98 .is_some_and(|p| union.expand(3.0).contains(p));
99 if selected {
100 ui.painter().rect_filled(
101 union.expand(3.0),
102 3.0,
103 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
104 );
105 } else if hovered {
106 ui.painter().rect_filled(
107 union.expand(3.0),
108 3.0,
109 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
110 );
111 }
112}
113
114fn order_active(status: ek_data::constant::Status) -> bool {
116 ek_data::constant::ACTIVE_STATUSES.contains(&status)
117}
118
119fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
122 if symbol.is_empty() {
123 return None;
124 }
125 shared.tick_rows().into_iter().find(|t| {
126 t.symbol == symbol
127 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
128 })
129}
130
131pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
139 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
141 render_menu_bar(ui, shared);
142 });
143
144 egui::TopBottomPanel::top("market_band")
146 .resizable(true)
147 .default_height(240.0)
148 .show(ctx, |ui| {
149 egui::ScrollArea::both()
151 .id_salt("market_band_scroll")
152 .auto_shrink([false, false])
153 .show(ui, |ui| {
154 render_market_band(ui, shared);
155 });
156 });
157
158 egui::SidePanel::left("order_side")
160 .resizable(true)
161 .default_width(250.0)
162 .show(ctx, |ui| {
163 egui::ScrollArea::vertical()
165 .id_salt("order_side_scroll")
166 .auto_shrink([false, false])
167 .show(ui, |ui| {
168 render_order_panel(ui, shared);
169 ui.separator();
170 render_book_frame(ui, shared);
171 });
172 });
173
174 egui::CentralPanel::default().show(ctx, |ui| {
176 let avail = ui.available_size();
177 let sp = ui.spacing().item_spacing;
178 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
184 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
185 ui.horizontal(|ui| {
186 group_frame(ui, "grp_topleft", gw, gh, |ui| {
187 render_topleft_group(ui, shared);
188 });
189 group_frame(ui, "grp_trades", gw, gh, |ui| {
190 render_trades_group(ui, shared);
191 });
192 });
193 ui.horizontal(|ui| {
194 group_frame(ui, "grp_positions", gw, gh, |ui| {
195 render_positions_group(ui, shared);
196 });
197 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
198 render_bottomright_group(ui, shared);
199 });
200 });
201 });
202
203 render_windows(ctx, shared);
205}
206
207fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
215 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
216 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
217 egui::Frame::group(ui.style()).show(&mut child, |ui| {
218 egui::ScrollArea::both()
219 .id_salt(id)
220 .auto_shrink([false, false])
221 .show(ui, |ui| {
222 ui.vertical(content);
223 });
224 });
225}
226
227fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
230 ui.horizontal(|ui| {
231 if ui.button(MENU_OPS).clicked() {
233 shared.set_ops_window_open(true);
234 }
235 if ui.button(MENU_CTP).clicked() {
236 shared.set_ctp_window_open(true);
237 }
238 if ui.button(MENU_CONTRACTS).clicked() {
239 shared.set_contracts_window_open(true);
240 }
241 ui.menu_button(MENU_HELP, |ui| {
242 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
243 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
244 });
245 });
246}
247
248fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
251 let groups = shared.tick_groups();
253 if groups.is_empty() {
254 ui.label("(等待 eTick 订阅)");
255 return;
256 }
257 let sel = shared.selected_exchange();
259 let active = if groups.iter().any(|(e, _)| *e == sel) {
260 sel.clone()
261 } else {
262 groups[0].0.clone()
263 };
264 ui.horizontal(|ui| {
265 for (ex, _) in &groups {
266 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
267 shared.set_selected_exchange(ex.clone());
268 }
269 }
270 });
271 let ticks = groups
272 .iter()
273 .find(|(e, _)| *e == active)
274 .map(|(_, v)| v.clone())
275 .unwrap_or_default();
276 let last_time = ticks
277 .iter()
278 .map(|t| {
279 if t.update_time.is_empty() {
280 fmt_dt(Some(t.datetime))
281 } else {
282 t.update_time.clone()
283 }
284 })
285 .max()
286 .unwrap_or_else(|| "-".to_string());
287 ui.label(format!(
288 "tick {} 格 / 最后更新 {last_time}",
289 shared.tick_count()
290 ));
291 egui::Grid::new("tick_grid")
292 .striped(true)
293 .num_columns(14)
294 .show(ui, |ui| {
295 for h in [
296 "代码",
297 "名称",
298 "最新价",
299 "涨跌幅",
300 "成交量",
301 "今开",
302 "最高",
303 "最低",
304 "买1价",
305 "买1量",
306 "卖1价",
307 "卖1量",
308 "时间",
309 "接口",
310 ] {
311 ui.strong(h);
312 }
313 ui.end_row();
314 for t in &ticks {
315 let row_sel = shared.order_form().symbol == t.symbol;
319 let mut cells = Vec::with_capacity(14);
320 let resp = ui.button(t.symbol.clone());
321 if resp.clicked() {
322 shared.set_selected_exchange(active.clone());
323 let mut f = shared.order_form();
324 f.symbol = t.symbol.clone();
325 f.exchange = active.clone();
326 shared.set_order_form(f);
327 }
328 if resp.double_clicked() {
329 shared.open_kline(t.symbol.clone());
330 }
331 cells.push(resp.rect);
332 cells.push(
333 ui.label(if t.name.is_empty() {
334 "-".to_string()
335 } else {
336 t.name.clone()
337 })
338 .rect,
339 );
340 cells.push(ui.label(format!("{:.1}", t.last_price)).rect);
341 cells.push(
342 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
343 if t.pct_change < 0.0 {
344 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
345 } else {
346 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
347 },
348 ))
349 .rect,
350 );
351 cells.push(ui.label(format!("{:.0}", t.volume)).rect);
352 cells.push(ui.label(format!("{:.1}", t.open_price)).rect);
353 cells.push(ui.label(format!("{:.1}", t.high_price)).rect);
354 cells.push(ui.label(format!("{:.1}", t.low_price)).rect);
355 cells.push(
356 ui.label(
357 egui::RichText::new(format!("{:.1}", t.bid_price_1))
358 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
359 )
360 .rect,
361 );
362 cells.push(
363 ui.label(
364 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
365 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
366 )
367 .rect,
368 );
369 cells.push(
370 ui.label(
371 egui::RichText::new(format!("{:.1}", t.ask_price_1))
372 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
373 )
374 .rect,
375 );
376 cells.push(
377 ui.label(
378 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
379 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
380 )
381 .rect,
382 );
383 cells.push(
384 ui.label(if t.update_time.is_empty() {
385 fmt_dt(Some(t.datetime))
386 } else {
387 t.update_time.clone()
388 })
389 .rect,
390 );
391 cells.push(ui.label(t.gateway_name.clone()).rect);
392 paint_row_bg(ui, &cells, row_sel);
393 ui.end_row();
394 }
395 });
396}
397
398fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
401 let mut f = shared.order_form();
403 let before = f.clone();
404 let mut err: Option<String> = None;
405
406 if f.follow_price {
408 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
409 if t.last_price > 0.0 {
410 f.price = format!("{:.1}", t.last_price);
411 }
412 }
413 }
414
415 let mut ex_opts: Vec<String> = shared
417 .tick_groups()
418 .iter()
419 .map(|(e, _)| e.clone())
420 .collect();
421 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
422 if !ex_opts.iter().any(|x| x == e) {
423 ex_opts.push(e.to_string());
424 }
425 }
426 if !ex_opts.contains(&f.exchange) {
427 ex_opts.insert(0, f.exchange.clone());
428 }
429 let ts = shared.trade_snap();
431 let mut gw_opts: Vec<String> = Vec::new();
432 for g in ts
433 .orders
434 .iter()
435 .map(|o| o.gateway_name.clone())
436 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
437 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
438 {
439 if !g.is_empty() && !gw_opts.contains(&g) {
440 gw_opts.push(g);
441 }
442 }
443 for g in ["sim", "ctp"] {
444 if !gw_opts.iter().any(|x| x == g) {
445 gw_opts.push(g.to_string());
446 }
447 }
448 if !gw_opts.contains(&f.gateway) {
449 gw_opts.insert(0, f.gateway.clone());
450 }
451
452 egui::Grid::new("order_form_grid")
455 .num_columns(2)
456 .show(ui, |ui| {
457 ui.label("交易所");
458 egui::ComboBox::from_id_salt("of_exchange")
459 .selected_text(f.exchange.clone())
460 .show_ui(ui, |ui| {
461 for e in &ex_opts {
462 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
463 }
464 });
465 ui.end_row();
466 ui.label("代码");
467 let sym_resp = ui.add(
468 egui::TextEdit::singleline(&mut f.symbol)
469 .hint_text("cu2709")
470 .desired_width(150.0),
471 );
472 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
477 let s = f.symbol.trim().to_string();
478 let bare = match s.split_once('.') {
481 Some((_, b)) => b.trim().to_string(),
482 None => s,
483 };
484 if !bare.is_empty() {
485 f.symbol = bare.clone();
486 shared.push_cmd(UiCmd::CtpSubscribe {
487 symbols: vec![bare.clone()],
488 });
489 shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
490 }
491 }
492 ui.end_row();
493 ui.label("名称");
494 ui.label(
495 tick_of(shared, &f.exchange, &f.symbol)
496 .map(|t| {
497 if t.name.is_empty() {
498 "-".to_string()
499 } else {
500 t.name
501 }
502 })
503 .unwrap_or_else(|| "-".to_string()),
504 );
505 ui.end_row();
506 ui.label("方向");
507 egui::ComboBox::from_id_salt("of_direction")
508 .selected_text(f.direction.as_str())
509 .show_ui(ui, |ui| {
510 for d in [Direction::LONG, Direction::SHORT] {
511 ui.selectable_value(&mut f.direction, d, d.as_str());
512 }
513 });
514 ui.end_row();
515 ui.label("开平");
516 ui.horizontal(|ui| {
517 egui::ComboBox::from_id_salt("of_offset")
518 .selected_text(f.offset.as_str())
519 .show_ui(ui, |ui| {
520 for o in [
521 Offset::OPEN,
522 Offset::CLOSE,
523 Offset::CLOSETODAY,
524 Offset::CLOSEYESTERDAY,
525 ] {
526 ui.selectable_value(&mut f.offset, o, o.as_str());
527 }
528 });
529 ui.checkbox(&mut f.auto_split, "自动开平");
531 });
532 ui.end_row();
533 ui.label("类型");
534 egui::ComboBox::from_id_salt("of_order_type")
535 .selected_text(f.order_type.as_str())
536 .show_ui(ui, |ui| {
537 for ot in [OrderType::LIMIT, OrderType::MARKET] {
538 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
539 }
540 });
541 ui.end_row();
542 ui.label("价格");
543 ui.horizontal(|ui| {
544 ui.add(
545 egui::TextEdit::singleline(&mut f.price)
546 .hint_text("107660")
547 .desired_width(90.0),
548 );
549 ui.checkbox(&mut f.follow_price, "随价");
550 });
551 ui.end_row();
552 ui.label("数量");
553 ui.add(
554 egui::TextEdit::singleline(&mut f.volume)
555 .hint_text("1")
556 .desired_width(90.0),
557 );
558 ui.end_row();
559 ui.label("接口");
560 egui::ComboBox::from_id_salt("of_gateway")
561 .selected_text(if f.gateway.is_empty() {
562 "sim".to_string()
563 } else {
564 f.gateway.clone()
565 })
566 .show_ui(ui, |ui| {
567 for g in &gw_opts {
568 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
569 }
570 });
571 ui.end_row();
572 ui.label("操作");
573 ui.horizontal(|ui| {
574 if ui.button(BTN_SEND_ORDER).clicked() {
575 match f.to_request() {
576 Ok(req) => match crate::state::parse_gateway(&f.gateway) {
577 Ok(gateway) => {
578 shared.push_cmd(UiCmd::SendOrder {
579 req,
580 gateway,
581 mode: if f.auto_split {
582 ek_oms::LockMode::Auto
583 } else {
584 ek_oms::LockMode::None
585 },
586 });
587 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
588 }
589 Err(e) => {
590 shared.log_line(format!("[下单] {e}"));
591 err = Some(e);
592 }
593 },
594 Err(e) => {
595 shared.log_line(format!("[下单] {e}"));
596 err = Some(e);
597 }
598 }
599 }
600 if ui.button(BTN_CANCEL_ALL).clicked() {
601 let mut n = 0usize;
604 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
605 let gw = if o.gateway_name.is_empty() {
606 "sim".to_string()
607 } else {
608 o.gateway_name.clone()
609 };
610 shared.push_cmd(UiCmd::CancelOrder {
611 orderid: o.orderid.clone(),
612 gateway: gw,
613 });
614 n += 1;
615 }
616 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
617 }
618 });
619 ui.end_row();
620 ui.label("代码回车 = 订阅");
621 ui.label("");
622 ui.end_row();
623 });
624 if let Some(e) = err {
625 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
626 }
627 if f != before {
628 shared.set_order_form(f.clone());
629 }
630}
631
632fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
636 let f = shared.order_form();
637 match tick_of(shared, &f.exchange, &f.symbol) {
638 Some(t) => {
639 for i in (1..=5).rev() {
640 let (p, v) = match i {
641 1 => (t.ask_price_1, t.ask_volume_1),
642 2 => (t.ask_price_2, t.ask_volume_2),
643 3 => (t.ask_price_3, t.ask_volume_3),
644 4 => (t.ask_price_4, t.ask_volume_4),
645 _ => (t.ask_price_5, t.ask_volume_5),
646 };
647 ui.label(
648 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
649 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
650 );
651 }
652 for i in 1..=5 {
653 let (p, v) = match i {
654 1 => (t.bid_price_1, t.bid_volume_1),
655 2 => (t.bid_price_2, t.bid_volume_2),
656 3 => (t.bid_price_3, t.bid_volume_3),
657 4 => (t.bid_price_4, t.bid_volume_4),
658 _ => (t.bid_price_5, t.bid_volume_5),
659 };
660 ui.label(
661 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
662 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
663 );
664 }
665 }
666 None => {
667 ui.label("(无 tick)");
668 }
669 }
670}
671
672fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
675 let tab = shared.topleft_tab();
676 ui.horizontal(|ui| {
677 if ui
678 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
679 .clicked()
680 {
681 shared.set_topleft_tab(TopLeftTab::Orders);
682 }
683 if ui
684 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
685 .clicked()
686 {
687 shared.set_topleft_tab(TopLeftTab::Active);
688 }
689 });
690 render_orders_table(ui, shared, tab == TopLeftTab::Active);
691}
692
693fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
697 let ts = shared.trade_snap();
698 let rows: Vec<_> = ts
701 .orders
702 .iter()
703 .filter(|o| !active_only || order_active(o.status))
704 .collect();
705 if rows.is_empty() {
706 ui.label(if active_only {
707 "(无未成交)"
708 } else {
709 "(无委托:等网关 send_order)"
710 });
711 return;
712 }
713 egui::Grid::new(if active_only {
714 "active_grid"
715 } else {
716 "order_grid"
717 })
718 .striped(true)
719 .num_columns(17)
720 .show(ui, |ui| {
721 for h in [
722 "代码",
723 "名称",
724 "交易所",
725 "类型",
726 "方向",
727 "开平",
728 "价格",
729 "总量",
730 "已成",
731 "成交均价",
732 "状态",
733 "状态描述",
734 "时间",
735 "委托号",
736 "来源",
737 "接口",
738 "操作",
739 ] {
740 ui.strong(h);
741 }
742 ui.end_row();
743 for o in &rows {
744 let key = format!("{}.{}", o.gateway_name, o.orderid);
748 let row_sel = shared.row_selected("orders", &key);
749 let mut cells = Vec::with_capacity(17);
750 let sel = ui.selectable_label(row_sel, o.symbol.clone());
751 if sel.clicked() {
752 shared.select_row("orders", &key);
753 }
754 if sel.double_clicked() && order_active(o.status) {
755 let gw = if o.gateway_name.is_empty() {
756 "sim".to_string()
757 } else {
758 o.gateway_name.clone()
759 };
760 shared.push_cmd(UiCmd::CancelOrder {
761 orderid: o.orderid.clone(),
762 gateway: gw,
763 });
764 }
765 cells.push(sel.rect);
766 cells.push(
767 ui.label(if o.name.is_empty() {
768 "-".to_string()
769 } else {
770 o.name.clone()
771 })
772 .rect,
773 );
774 cells.push(ui.label(o.exchange.as_str().to_string()).rect);
775 cells.push(ui.label(o.order_type.as_str().to_string()).rect);
776 cells.push(
777 ui.label(
778 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
779 if o.direction == Some(Direction::SHORT) {
780 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
781 } else {
782 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
783 },
784 ),
785 )
786 .rect,
787 );
788 cells.push(ui.label(o.offset.as_str().to_string()).rect);
789 cells.push(ui.label(format!("{:.1}", o.price)).rect);
790 cells.push(ui.label(format!("{:.0}", o.volume)).rect);
791 cells.push(ui.label(format!("{:.0}", o.traded)).rect);
792 cells.push(ui.label(format!("{:.1}", o.traded_price)).rect);
793 cells.push(
794 ui.label(egui::RichText::new(o.status.as_str()).color(
795 if o.status == ek_data::constant::Status::REJECTED {
796 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
797 } else if o.status == ek_data::constant::Status::ALLTRADED {
798 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
799 } else {
800 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
801 },
802 ))
803 .rect,
804 );
805 cells.push(ui.label(o.status_msg.clone()).rect);
806 cells.push(ui.label(fmt_dt(o.datetime)).rect);
807 cells.push(ui.label(o.orderid.clone()).rect);
808 cells.push(
809 ui.label(if o.reference.is_empty() {
810 "-".to_string()
811 } else {
812 o.reference.clone()
813 })
814 .rect,
815 );
816 cells.push(ui.label(o.gateway_name.clone()).rect);
817 let hr = ui.horizontal(|ui| {
818 if order_active(o.status)
819 && ui
820 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
821 .clicked()
822 {
823 let gw = if o.gateway_name.is_empty() {
825 "sim".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 }
834 });
835 cells.push(hr.response.rect);
836 paint_row_bg(ui, &cells, row_sel);
837 ui.end_row();
838 }
839 });
840}
841
842fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
844 ui.label("成交");
845 let ts = shared.trade_snap();
846 if ts.trades.is_empty() {
847 ui.label("(无成交)");
848 return;
849 }
850 egui::Grid::new("trade_grid")
851 .striped(true)
852 .num_columns(12)
853 .show(ui, |ui| {
854 for h in [
855 "成交号",
856 "代码",
857 "名称",
858 "交易所",
859 "方向",
860 "开平",
861 "价格",
862 "数量",
863 "成交额",
864 "时间",
865 "委托号",
866 "接口",
867 ] {
868 ui.strong(h);
869 }
870 ui.end_row();
871 for t in &ts.trades {
872 let key = format!("{}.{}", t.gateway_name, t.tradeid);
875 let row_sel = shared.row_selected("trades", &key);
876 let mut cells = Vec::with_capacity(12);
877 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
878 if sel.clicked() {
879 shared.select_row("trades", &key);
880 }
881 cells.push(sel.rect);
882 cells.push(ui.label(t.symbol.clone()).rect);
883 cells.push(
884 ui.label(if t.name.is_empty() {
885 "-".to_string()
886 } else {
887 t.name.clone()
888 })
889 .rect,
890 );
891 cells.push(ui.label(t.exchange.as_str().to_string()).rect);
892 cells.push(
893 ui.label(
894 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
895 if t.direction == Some(Direction::SHORT) {
896 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
897 } else {
898 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
899 },
900 ),
901 )
902 .rect,
903 );
904 cells.push(ui.label(t.offset.as_str().to_string()).rect);
905 cells.push(ui.label(format!("{:.1}", t.price)).rect);
906 cells.push(ui.label(format!("{:.0}", t.volume)).rect);
907 cells.push(ui.label(format!("{:.1}", t.traded_amount)).rect);
908 cells.push(ui.label(fmt_dt(t.datetime)).rect);
909 cells.push(ui.label(t.orderid.clone()).rect);
910 cells.push(ui.label(t.gateway_name.clone()).rect);
911 paint_row_bg(ui, &cells, row_sel);
912 ui.end_row();
913 }
914 });
915}
916
917fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
920 ui.label("持仓");
921 let ts = shared.trade_snap();
922 if ts.positions.is_empty() {
923 ui.label("(无持仓)");
924 return;
925 }
926 egui::Grid::new("pos_grid")
927 .striped(true)
928 .num_columns(16)
929 .show(ui, |ui| {
930 for h in [
931 "代码",
932 "名称",
933 "交易所",
934 "方向",
935 "数量",
936 "昨仓",
937 "可用",
938 "冻结",
939 "在途",
940 "市值",
941 "成本",
942 "均价",
943 "最新价",
944 "盈亏",
945 "盈亏比",
946 "接口",
947 ] {
948 ui.strong(h);
949 }
950 ui.end_row();
951 for p in &ts.positions {
952 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
955 let row_sel = shared.row_selected("positions", &key);
956 let mut cells = Vec::with_capacity(16);
957 let sel = ui.selectable_label(row_sel, p.symbol.clone());
958 if sel.clicked() {
959 shared.select_row("positions", &key);
960 }
961 if sel.double_clicked() {
962 let mut f = shared.order_form();
963 f.symbol = p.symbol.clone();
964 f.exchange = p.exchange.as_str().to_string();
965 f.direction = p.direction;
966 shared.set_order_form(f);
967 shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
968 }
969 cells.push(sel.rect);
970 cells.push(
971 ui.label(if p.name.is_empty() {
972 "-".to_string()
973 } else {
974 p.name.clone()
975 })
976 .rect,
977 );
978 cells.push(ui.label(p.exchange.as_str().to_string()).rect);
979 cells.push(
980 ui.label(egui::RichText::new(p.direction.as_str()).color(
981 if p.direction == ek_data::constant::Direction::SHORT {
982 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
983 } else {
984 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
985 },
986 ))
987 .rect,
988 );
989 cells.push(ui.label(format!("{:.0}", p.volume)).rect);
990 cells.push(ui.label(format!("{:.0}", p.yd_volume)).rect);
991 cells.push(ui.label(format!("{:.0}", p.can_use_volume)).rect);
992 cells.push(ui.label(format!("{:.0}", p.frozen)).rect);
993 cells.push(ui.label(format!("{:.0}", p.on_road_volume)).rect);
994 cells.push(ui.label(format!("{:.1}", p.market_value)).rect);
995 cells.push(ui.label(format!("{:.2}", p.price)).rect);
996 cells.push(ui.label(format!("{:.2}", p.avg_price)).rect);
997 cells.push(ui.label(format!("{:.2}", p.last_price)).rect);
998 cells.push(
999 ui.label(
1000 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1001 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1002 } else {
1003 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1004 }),
1005 )
1006 .rect,
1007 );
1008 cells.push(ui.label(format!("{:+.4}", p.profit_rate)).rect);
1009 cells.push(ui.label(p.gateway_name.clone()).rect);
1010 paint_row_bg(ui, &cells, row_sel);
1011 ui.end_row();
1012 }
1013 });
1014}
1015
1016fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1017 let tab = shared.bottomright_tab();
1018 ui.horizontal(|ui| {
1019 if ui
1020 .selectable_label(
1021 tab == BottomRightTab::Account,
1022 BottomRightTab::Account.label(),
1023 )
1024 .clicked()
1025 {
1026 shared.set_bottomright_tab(BottomRightTab::Account);
1027 }
1028 if ui
1029 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1030 .clicked()
1031 {
1032 shared.set_bottomright_tab(BottomRightTab::Logs);
1033 }
1034 });
1035 match tab {
1036 BottomRightTab::Account => {
1037 let ts = shared.trade_snap();
1038 if ts.accounts.is_empty() {
1039 ui.label("(无资金:等网关 eAccount.)");
1040 return;
1041 }
1042 egui::Grid::new("acct_grid")
1043 .striped(true)
1044 .num_columns(5)
1045 .show(ui, |ui| {
1046 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1047 ui.strong(h);
1048 }
1049 ui.end_row();
1050 for a in &ts.accounts {
1051 ui.label(a.accountid.clone());
1052 ui.label(a.gateway_name.clone());
1053 ui.label(format!("{:.2}", a.balance));
1054 ui.label(format!("{:.2}", a.available()));
1055 ui.label(format!("{:.2}", a.frozen));
1056 ui.end_row();
1057 }
1058 });
1059 }
1060 BottomRightTab::Logs => {
1061 render_logs(ui, shared);
1062 }
1063 }
1064}
1065
1066fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1067 ui.horizontal(|ui| {
1068 if ui.button(BTN_CLEAR_LOGS).clicked() {
1069 shared.clear_logs(); }
1071 let (_, spilled) = shared.logs_snapshot();
1072 if spilled > 0 {
1073 ui.label(format!(
1074 "已丢弃 {spilled} 条(环形 {})",
1075 crate::state::RING_CAP
1076 ));
1077 }
1078 });
1079 let lines = shared.logs_snapshot().0;
1080 egui::ScrollArea::vertical()
1081 .id_salt("log_scroll")
1082 .max_height(180.0)
1083 .stick_to_bottom(true)
1084 .show(ui, |ui| {
1085 for line in lines {
1086 ui.monospace(line);
1087 }
1088 });
1089}
1090
1091fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1094 let mut ops_open = shared.ops_window_open();
1096 egui::Window::new("运维窗")
1097 .id(egui::Id::new("ops_win"))
1098 .default_pos([80.0, 320.0])
1099 .default_size([640.0, 380.0])
1100 .open(&mut ops_open)
1101 .show(ctx, |ui| {
1102 render_ops_window(ui, shared);
1103 });
1104 shared.set_ops_window_open(ops_open);
1105
1106 let mut ctp_open = shared.ctp_window_open();
1108 egui::Window::new("CTP 网关配置窗")
1109 .id(egui::Id::new("ctp_win"))
1110 .default_pos([640.0, 320.0])
1111 .default_size([560.0, 330.0])
1112 .open(&mut ctp_open)
1113 .show(ctx, |ui| {
1114 render_ctp_window(ui, shared);
1115 });
1116 shared.set_ctp_window_open(ctp_open);
1117
1118 let mut contracts_open = shared.contracts_window_open();
1120 egui::Window::new("查询合约窗")
1121 .id(egui::Id::new("contracts_win"))
1122 .default_pos([420.0, 300.0])
1123 .default_size([560.0, 420.0])
1124 .open(&mut contracts_open)
1125 .show(ctx, |ui| {
1126 render_contracts_window(ui, shared);
1127 });
1128 shared.set_contracts_window_open(contracts_open);
1129
1130 let mut closed: Vec<String> = Vec::new();
1132 for sym in shared.kline_symbols() {
1133 let resp = egui::Window::new(format!("K线 — {sym}"))
1134 .id(egui::Id::new(("kline_win", sym.clone())))
1135 .default_pos([300.0, 340.0])
1136 .default_size([540.0, 330.0])
1137 .show(ctx, |ui| {
1138 let cell = shared
1139 .market()
1140 .cells()
1141 .into_iter()
1142 .find(|c| c.symbol == sym);
1143 render_kline_cell(ui, cell.as_ref());
1144 });
1145 if resp.is_none() {
1146 closed.push(sym);
1147 }
1148 }
1149 for s in closed {
1150 shared.close_kline(&s);
1151 }
1152}
1153
1154fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1157 ui.label("插件");
1158 let snap = shared.snapshot();
1159 egui::Grid::new("plug_grid")
1160 .striped(true)
1161 .num_columns(6)
1162 .show(ui, |ui| {
1163 ui.strong("名称");
1164 ui.strong("状态");
1165 ui.strong("版本");
1166 ui.strong("依赖");
1167 ui.strong("统计");
1168 ui.strong("操作");
1169 ui.end_row();
1170 if snap.rows.is_empty() {
1171 ui.label("(快照空:等 eTimer 首拉)");
1172 ui.end_row();
1173 }
1174 for r in &snap.rows {
1175 if is_quarantined(&r.state) {
1176 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1177 ui.label(
1178 egui::RichText::new(state_text(r))
1179 .color(egui::Color32::RED)
1180 .strong(),
1181 );
1182 } else {
1183 ui.label(r.name.clone());
1184 ui.label(state_text(r));
1185 }
1186 ui.label(r.version.clone());
1187 ui.label(format!("deps:[{}]", r.deps.join(",")));
1188 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1189 ui.horizontal(|ui| {
1190 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1191 shared.push_cmd(UiCmd::Start {
1192 factory: r.name.clone(),
1193 });
1194 }
1195 let armed = shared.pending_confirm();
1196 let armed_here = ui
1197 .small_button(remove_button_label(&r.name, armed.as_deref()))
1198 .clicked()
1199 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1200 if armed_here {
1201 shared.push_cmd(UiCmd::Remove {
1202 name: r.name.clone(),
1203 });
1204 }
1205 });
1206 ui.end_row();
1207 }
1208 });
1209
1210 if let Some(name) = shared.pending_confirm() {
1211 ui.horizontal(|ui| {
1212 ui.label(
1213 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1214 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1215 );
1216 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1217 shared.confirm_cancel();
1218 }
1219 });
1220 }
1221
1222 ui.label("服务");
1223 let flat: Vec<String> = snap
1224 .services
1225 .iter()
1226 .flat_map(|(n, ids)| {
1227 if ids.is_empty() {
1228 vec![format!("{n} 服务"), "-".to_string()]
1229 } else {
1230 ids.iter()
1231 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1232 .collect()
1233 }
1234 })
1235 .collect();
1236 if flat.is_empty() {
1237 ui.label("(无服务目录)");
1238 } else {
1239 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1241 .striped(true)
1242 .num_columns(6)
1243 .show(ui, |ui| {
1244 for r in 0..rows {
1245 for c in 0..3 {
1246 let i = c * rows * 2 + r * 2;
1247 if i < flat.len() {
1248 ui.label(flat[i].clone());
1249 ui.label(flat[i + 1].clone());
1250 } else {
1251 ui.label("");
1252 ui.label("");
1253 }
1254 }
1255 ui.end_row();
1256 }
1257 });
1258 }
1259
1260 ui.label("依赖");
1261 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1262 ui.label("(全部无依赖)");
1263 }
1264 for r in &snap.rows {
1265 if !r.deps.is_empty() {
1266 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1267 }
1268 }
1269
1270 ui.horizontal(|ui| {
1271 if ui.small_button(BTN_QUERY_RATE).clicked() {
1272 shared.push_cmd(UiCmd::QueryRate);
1273 }
1274 });
1275}
1276
1277fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1281 ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1282 let mut form: crate::state::CtpForm = shared.ctp_form();
1283 egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1284 ui.label("broker");
1285 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1286 ui.label("td 前置");
1287 ctp_edit(
1288 ui,
1289 shared,
1290 &mut form.td_addr,
1291 "tcp://host:40001",
1292 CtpField::TdAddr,
1293 );
1294 ui.label("td 用户");
1295 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1296 ui.end_row();
1297 ui.label("td 密码");
1298 ctp_edit(
1299 ui,
1300 shared,
1301 &mut form.td_password,
1302 "密码",
1303 CtpField::TdPassword,
1304 );
1305 ui.label("AppID");
1306 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1307 ui.label("AuthCode");
1308 ctp_edit(
1309 ui,
1310 shared,
1311 &mut form.td_auth_code,
1312 "授权码",
1313 CtpField::AuthCode,
1314 );
1315 ui.end_row();
1316 ui.label("md 前置");
1317 ctp_edit(
1318 ui,
1319 shared,
1320 &mut form.md_addr,
1321 "tcp://host:40011",
1322 CtpField::MdAddr,
1323 );
1324 ui.label("md 用户");
1325 ctp_edit(
1326 ui,
1327 shared,
1328 &mut form.md_user,
1329 "空=td 回落",
1330 CtpField::MdUser,
1331 );
1332 ui.label("md 密码");
1333 ctp_edit(
1334 ui,
1335 shared,
1336 &mut form.md_password,
1337 "空=td 回落",
1338 CtpField::MdPassword,
1339 );
1340 ui.end_row();
1341 });
1342 ui.horizontal(|ui| {
1343 if ui.small_button(BTN_CTP_CONNECT).clicked() {
1344 shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1345 shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1346 }
1347 ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1348 });
1349}
1350
1351fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1355 let mut q = shared.contract_query();
1356 ui.horizontal(|ui| {
1357 ui.label("代码或交易所(留空 = 全部):");
1358 if ui
1359 .add(
1360 egui::TextEdit::singleline(&mut q)
1361 .hint_text("cu 或 SHFE")
1362 .desired_width(200.0),
1363 )
1364 .changed()
1365 {
1366 shared.set_contract_query(q.clone());
1367 }
1368 });
1369 let rows = shared.contracts(&q);
1370 if rows.is_empty() {
1371 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1372 return;
1373 }
1374 let selected = shared.selected_contracts();
1375 egui::ScrollArea::vertical()
1376 .id_salt("contract_scroll")
1377 .max_height(260.0)
1378 .auto_shrink([false, false])
1379 .show(ui, |ui| {
1380 egui::Grid::new("contract_grid")
1381 .striped(true)
1382 .num_columns(5)
1383 .show(ui, |ui| {
1384 for h in ["选(代码)", "交易所", "名称", "乘数", "最小变动"] {
1385 ui.strong(h);
1386 }
1387 ui.end_row();
1388 for c in &rows {
1389 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1390 let mut on = selected.contains(&key);
1391 let row_sel = shared.row_selected("contracts", &key);
1395 let mut cells = Vec::with_capacity(5);
1396 let ck = ui.checkbox(&mut on, key.clone());
1397 if ck.changed() {
1398 shared.toggle_contract_selection(&key);
1399 shared.select_row("contracts", &key);
1400 }
1401 cells.push(ck.rect);
1402 cells.push(ui.label(c.exchange.as_str().to_string()).rect);
1403 cells.push(ui.label(c.name.clone()).rect);
1404 cells.push(ui.label(format!("{:.0}", c.size)).rect);
1405 cells.push(ui.label(format!("{:.4}", c.price_tick)).rect);
1406 paint_row_bg(ui, &cells, row_sel);
1407 ui.end_row();
1408 }
1409 });
1410 });
1411 ui.horizontal(|ui| {
1412 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1413 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1414 if selected.is_empty() {
1415 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1416 } else {
1417 let symbols: Vec<String> = selected
1419 .iter()
1420 .map(|k| {
1421 k.split_once('.')
1422 .map(|(_, s)| s.to_string())
1423 .unwrap_or_else(|| k.clone())
1424 })
1425 .collect();
1426 shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1427 shared.log_line(format!(
1428 "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1429 selected.len()
1430 ));
1431 }
1432 }
1433 });
1434}
1435
1436enum CtpField {
1438 Broker,
1439 TdAddr,
1440 TdUser,
1441 TdPassword,
1442 AppId,
1443 AuthCode,
1444 MdAddr,
1445 MdUser,
1446 MdPassword,
1447}
1448
1449fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1451 let is_secret = matches!(
1452 field,
1453 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1454 );
1455 let mut w = egui::TextEdit::singleline(val)
1456 .hint_text(hint)
1457 .desired_width(110.0);
1458 if is_secret {
1459 w = w.password(true);
1460 }
1461 if ui.add(w).changed() {
1462 let mut f = shared.ctp_form();
1463 match field {
1464 CtpField::Broker => f.broker_id = val.clone(),
1465 CtpField::TdAddr => f.td_addr = val.clone(),
1466 CtpField::TdUser => f.td_user = val.clone(),
1467 CtpField::TdPassword => f.td_password = val.clone(),
1468 CtpField::AppId => f.td_app_id = val.clone(),
1469 CtpField::AuthCode => f.td_auth_code = val.clone(),
1470 CtpField::MdAddr => f.md_addr = val.clone(),
1471 CtpField::MdUser => f.md_user = val.clone(),
1472 CtpField::MdPassword => f.md_password = val.clone(),
1473 }
1474 shared.set_ctp_form(f);
1475 }
1476}
1477
1478fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1482 let Some(c) = cell else {
1483 ui.label("(K线无格:等 dm 合成)");
1484 return;
1485 };
1486 if c.bars.is_empty() {
1487 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1488 return;
1489 }
1490 let mut data = egui_charts::model::bar::BarData::new();
1491 for b in &c.bars {
1492 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1493 data.push(egui_charts::model::bar::Bar::new(
1494 dt,
1495 b.open_price,
1496 b.high_price,
1497 b.low_price,
1498 b.close_price,
1499 b.volume,
1500 ));
1501 }
1502 ui.label(format!(
1503 "K线 {}@{}s({} 根)",
1504 c.symbol,
1505 c.interval,
1506 c.bars.len()
1507 ));
1508 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1509 chart.set_symbol(&c.symbol);
1510 chart.set_timeframe_label(&format!("{}s", c.interval));
1511 chart.enable_tracking_mode();
1512 let _ = chart.show(ui);
1513}