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 ctx.style_mut(|style| {
168 style
169 .text_styles
170 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
171 style
172 .text_styles
173 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
174 style
175 .text_styles
176 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
177 style
178 .text_styles
179 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
180 style
181 .text_styles
182 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
183 });
184}
185
186pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
194 apply_ui_style(ctx);
195 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
197 render_menu_bar(ui, shared);
198 });
199
200 egui::TopBottomPanel::top("market_band")
202 .resizable(true)
203 .default_height(240.0)
204 .show(ctx, |ui| {
205 egui::ScrollArea::both()
207 .id_salt("market_band_scroll")
208 .auto_shrink([false, false])
209 .show(ui, |ui| {
210 render_market_band(ui, shared);
211 });
212 });
213
214 egui::SidePanel::left("order_side")
216 .resizable(true)
217 .default_width(250.0)
218 .show(ctx, |ui| {
219 egui::ScrollArea::vertical()
221 .id_salt("order_side_scroll")
222 .auto_shrink([false, false])
223 .show(ui, |ui| {
224 render_order_panel(ui, shared);
225 ui.separator();
226 render_book_frame(ui, shared);
227 });
228 });
229
230 egui::CentralPanel::default().show(ctx, |ui| {
232 let avail = ui.available_size();
233 let sp = ui.spacing().item_spacing;
234 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
240 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
241 ui.horizontal(|ui| {
242 group_frame(ui, "grp_topleft", gw, gh, |ui| {
243 render_topleft_group(ui, shared);
244 });
245 group_frame(ui, "grp_trades", gw, gh, |ui| {
246 render_trades_group(ui, shared);
247 });
248 });
249 ui.horizontal(|ui| {
250 group_frame(ui, "grp_positions", gw, gh, |ui| {
251 render_positions_group(ui, shared);
252 });
253 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
254 render_bottomright_group(ui, shared);
255 });
256 });
257 });
258
259 render_windows(ctx, shared);
261}
262
263fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
271 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
272 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
273 egui::Frame::group(ui.style()).show(&mut child, |ui| {
274 egui::ScrollArea::both()
275 .id_salt(id)
276 .auto_shrink([false, false])
277 .show(ui, |ui| {
278 ui.vertical(content);
279 });
280 });
281}
282
283fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
286 ui.horizontal(|ui| {
287 if ui.button(MENU_OPS).clicked() {
289 shared.set_ops_window_open(true);
290 }
291 if ui.button(MENU_CTP).clicked() {
292 shared.set_ctp_window_open(true);
293 }
294 if ui.button(MENU_CONTRACTS).clicked() {
295 shared.set_contracts_window_open(true);
296 }
297 ui.menu_button(MENU_HELP, |ui| {
298 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
299 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
300 });
301 });
302}
303
304fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
307 let groups = shared.tick_groups();
309 if groups.is_empty() {
310 ui.label("(等待 eTick 订阅)");
311 return;
312 }
313 let sel = shared.selected_exchange();
315 let active = if groups.iter().any(|(e, _)| *e == sel) {
316 sel.clone()
317 } else {
318 groups[0].0.clone()
319 };
320 ui.horizontal(|ui| {
321 for (ex, _) in &groups {
322 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
323 shared.set_selected_exchange(ex.clone());
324 }
325 }
326 });
327 let ticks = groups
328 .iter()
329 .find(|(e, _)| *e == active)
330 .map(|(_, v)| v.clone())
331 .unwrap_or_default();
332 let last_time = ticks
333 .iter()
334 .map(|t| {
335 if t.update_time.is_empty() {
336 fmt_dt(Some(t.datetime))
337 } else {
338 t.update_time.clone()
339 }
340 })
341 .max()
342 .unwrap_or_else(|| "-".to_string());
343 ui.label(format!(
344 "tick {} 格 / 最后更新 {last_time}",
345 shared.tick_count()
346 ));
347 egui::Grid::new("tick_grid")
348 .striped(true)
349 .num_columns(14)
350 .show(ui, |ui| {
351 for h in [
352 "代码",
353 "名称",
354 "最新价",
355 "涨跌幅",
356 "成交量",
357 "今开",
358 "最高",
359 "最低",
360 "买1价",
361 "买1量",
362 "卖1价",
363 "卖1量",
364 "时间",
365 "接口",
366 ] {
367 ui.strong(h);
368 }
369 ui.end_row();
370 for t in &ticks {
371 let row_sel = shared.order_form().symbol == t.symbol;
376 let mut cells = Vec::with_capacity(14);
377 let mut dbl = false;
378 let resp = ui.button(t.symbol.clone());
379 if resp.clicked() {
380 shared.set_selected_exchange(active.clone());
381 import_tick(shared, t, &active, 0);
382 }
383 dbl |= resp.double_clicked();
384 cells.push(resp.rect);
385 macro_rules! cell {
386 ($base:expr, $col:expr) => {{
387 let base = $base;
388 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
389 if r.clicked() {
390 shared.set_selected_exchange(active.clone());
391 import_tick(shared, t, &active, $col);
392 }
393 dbl |= r.double_clicked();
394 cells.push(base.rect);
395 }};
396 }
397 cell!(
398 ui.label(if t.name.is_empty() {
399 "-".to_string()
400 } else {
401 t.name.clone()
402 }),
403 1
404 );
405 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
406 cell!(
407 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
408 if t.pct_change < 0.0 {
409 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
410 } else {
411 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
412 },
413 )),
414 3
415 );
416 cell!(ui.label(format!("{:.0}", t.volume)), 4);
417 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
418 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
419 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
420 cell!(
421 ui.label(
422 egui::RichText::new(format!("{:.1}", t.bid_price_1))
423 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
424 ),
425 8
426 );
427 cell!(
428 ui.label(
429 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
430 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
431 ),
432 9
433 );
434 cell!(
435 ui.label(
436 egui::RichText::new(format!("{:.1}", t.ask_price_1))
437 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
438 ),
439 10
440 );
441 cell!(
442 ui.label(
443 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
444 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
445 ),
446 11
447 );
448 cell!(
449 ui.label(if t.update_time.is_empty() {
450 fmt_dt(Some(t.datetime))
451 } else {
452 t.update_time.clone()
453 }),
454 12
455 );
456 cell!(ui.label(t.gateway_name.clone()), 13);
457 if dbl {
458 shared.open_kline(t.symbol.clone());
459 }
460 paint_row_bg(ui, &cells, row_sel);
461 ui.end_row();
462 }
463 });
464}
465
466fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
469 let mut f = shared.order_form();
471 let before = f.clone();
472 let mut err: Option<String> = None;
473
474 if f.follow_price {
476 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
477 if t.last_price > 0.0 {
478 f.price = format!("{:.1}", t.last_price);
479 }
480 }
481 }
482
483 let mut ex_opts: Vec<String> = shared
485 .tick_groups()
486 .iter()
487 .map(|(e, _)| e.clone())
488 .collect();
489 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
490 if !ex_opts.iter().any(|x| x == e) {
491 ex_opts.push(e.to_string());
492 }
493 }
494 if !ex_opts.contains(&f.exchange) {
495 ex_opts.insert(0, f.exchange.clone());
496 }
497 let ts = shared.trade_snap();
499 let mut gw_opts: Vec<String> = Vec::new();
500 for g in ts
501 .orders
502 .iter()
503 .map(|o| o.gateway_name.clone())
504 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
505 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
506 {
507 if !g.is_empty() && !gw_opts.contains(&g) {
508 gw_opts.push(g);
509 }
510 }
511 for g in ["sim", "ctp"] {
512 if !gw_opts.iter().any(|x| x == g) {
513 gw_opts.push(g.to_string());
514 }
515 }
516 if !gw_opts.contains(&f.gateway) {
517 gw_opts.insert(0, f.gateway.clone());
518 }
519
520 egui::Grid::new("order_form_grid")
523 .num_columns(2)
524 .show(ui, |ui| {
525 ui.label("交易所");
526 egui::ComboBox::from_id_salt("of_exchange")
527 .selected_text(f.exchange.clone())
528 .show_ui(ui, |ui| {
529 for e in &ex_opts {
530 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
531 }
532 });
533 ui.end_row();
534 ui.label("代码");
535 let sym_resp = ui.add(
536 egui::TextEdit::singleline(&mut f.symbol)
537 .hint_text("cu2709")
538 .desired_width(150.0),
539 );
540 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
545 let s = f.symbol.trim().to_string();
546 let bare = match s.split_once('.') {
549 Some((_, b)) => b.trim().to_string(),
550 None => s,
551 };
552 if !bare.is_empty() {
553 f.symbol = bare.clone();
554 shared.push_cmd(UiCmd::CtpSubscribe {
555 symbols: vec![bare.clone()],
556 });
557 shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
558 }
559 }
560 ui.end_row();
561 ui.label("名称");
562 ui.label(
563 tick_of(shared, &f.exchange, &f.symbol)
564 .map(|t| {
565 if t.name.is_empty() {
566 "-".to_string()
567 } else {
568 t.name
569 }
570 })
571 .unwrap_or_else(|| "-".to_string()),
572 );
573 ui.end_row();
574 ui.label("方向");
575 egui::ComboBox::from_id_salt("of_direction")
576 .selected_text(f.direction.as_str())
577 .show_ui(ui, |ui| {
578 for d in [Direction::LONG, Direction::SHORT] {
579 ui.selectable_value(&mut f.direction, d, d.as_str());
580 }
581 });
582 ui.end_row();
583 ui.label("开平");
584 ui.horizontal(|ui| {
585 egui::ComboBox::from_id_salt("of_offset")
586 .selected_text(f.offset.as_str())
587 .show_ui(ui, |ui| {
588 for o in [
589 Offset::OPEN,
590 Offset::CLOSE,
591 Offset::CLOSETODAY,
592 Offset::CLOSEYESTERDAY,
593 ] {
594 ui.selectable_value(&mut f.offset, o, o.as_str());
595 }
596 });
597 ui.checkbox(&mut f.auto_split, "自动开平");
599 });
600 ui.end_row();
601 ui.label("类型");
602 egui::ComboBox::from_id_salt("of_order_type")
603 .selected_text(f.order_type.as_str())
604 .show_ui(ui, |ui| {
605 for ot in [OrderType::LIMIT, OrderType::MARKET] {
606 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
607 }
608 });
609 ui.end_row();
610 ui.label("价格");
611 ui.horizontal(|ui| {
612 ui.add(
613 egui::TextEdit::singleline(&mut f.price)
614 .hint_text("107660")
615 .desired_width(90.0),
616 );
617 ui.checkbox(&mut f.follow_price, "随价");
618 });
619 ui.end_row();
620 ui.label("数量");
621 ui.add(
622 egui::TextEdit::singleline(&mut f.volume)
623 .hint_text("1")
624 .desired_width(90.0),
625 );
626 ui.end_row();
627 ui.label("接口");
628 egui::ComboBox::from_id_salt("of_gateway")
629 .selected_text(if f.gateway.is_empty() {
630 "sim".to_string()
631 } else {
632 f.gateway.clone()
633 })
634 .show_ui(ui, |ui| {
635 for g in &gw_opts {
636 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
637 }
638 });
639 ui.end_row();
640 ui.label("操作");
641 ui.horizontal(|ui| {
642 if ui.button(BTN_SEND_ORDER).clicked() {
643 match f.to_request() {
644 Ok(req) => match crate::state::parse_gateway(&f.gateway) {
645 Ok(gateway) => {
646 shared.push_cmd(UiCmd::SendOrder {
647 req,
648 gateway,
649 mode: if f.auto_split {
650 ek_oms::LockMode::Auto
651 } else {
652 ek_oms::LockMode::None
653 },
654 });
655 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
656 }
657 Err(e) => {
658 shared.log_line(format!("[下单] {e}"));
659 err = Some(e);
660 }
661 },
662 Err(e) => {
663 shared.log_line(format!("[下单] {e}"));
664 err = Some(e);
665 }
666 }
667 }
668 if ui.button(BTN_CANCEL_ALL).clicked() {
669 let mut n = 0usize;
672 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
673 let gw = if o.gateway_name.is_empty() {
674 "sim".to_string()
675 } else {
676 o.gateway_name.clone()
677 };
678 shared.push_cmd(UiCmd::CancelOrder {
679 orderid: o.orderid.clone(),
680 gateway: gw,
681 });
682 n += 1;
683 }
684 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
685 }
686 });
687 ui.end_row();
688 ui.label("代码回车 = 订阅");
689 ui.label("");
690 ui.end_row();
691 });
692 if let Some(e) = err {
693 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
694 }
695 if f != before {
696 shared.set_order_form(f.clone());
697 }
698}
699
700fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
704 let f = shared.order_form();
705 match tick_of(shared, &f.exchange, &f.symbol) {
706 Some(t) => {
707 for i in (1..=5).rev() {
708 let (p, v) = match i {
709 1 => (t.ask_price_1, t.ask_volume_1),
710 2 => (t.ask_price_2, t.ask_volume_2),
711 3 => (t.ask_price_3, t.ask_volume_3),
712 4 => (t.ask_price_4, t.ask_volume_4),
713 _ => (t.ask_price_5, t.ask_volume_5),
714 };
715 ui.label(
716 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
717 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
718 );
719 }
720 for i in 1..=5 {
721 let (p, v) = match i {
722 1 => (t.bid_price_1, t.bid_volume_1),
723 2 => (t.bid_price_2, t.bid_volume_2),
724 3 => (t.bid_price_3, t.bid_volume_3),
725 4 => (t.bid_price_4, t.bid_volume_4),
726 _ => (t.bid_price_5, t.bid_volume_5),
727 };
728 ui.label(
729 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
730 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
731 );
732 }
733 }
734 None => {
735 ui.label("(无 tick)");
736 }
737 }
738}
739
740fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
743 let tab = shared.topleft_tab();
744 ui.horizontal(|ui| {
745 if ui
746 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
747 .clicked()
748 {
749 shared.set_topleft_tab(TopLeftTab::Orders);
750 }
751 if ui
752 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
753 .clicked()
754 {
755 shared.set_topleft_tab(TopLeftTab::Active);
756 }
757 });
758 render_orders_table(ui, shared, tab == TopLeftTab::Active);
759}
760
761fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
765 let ts = shared.trade_snap();
766 let rows: Vec<_> = ts
769 .orders
770 .iter()
771 .filter(|o| !active_only || order_active(o.status))
772 .collect();
773 if rows.is_empty() {
774 ui.label(if active_only {
775 "(无未成交)"
776 } else {
777 "(无委托:等网关 send_order)"
778 });
779 return;
780 }
781 egui::Grid::new(if active_only {
782 "active_grid"
783 } else {
784 "order_grid"
785 })
786 .striped(true)
787 .num_columns(17)
788 .show(ui, |ui| {
789 for h in [
790 "代码",
791 "名称",
792 "交易所",
793 "类型",
794 "方向",
795 "开平",
796 "价格",
797 "总量",
798 "已成",
799 "成交均价",
800 "状态",
801 "状态描述",
802 "时间",
803 "委托号",
804 "来源",
805 "接口",
806 "操作",
807 ] {
808 ui.strong(h);
809 }
810 ui.end_row();
811 for o in &rows {
812 let key = format!("{}.{}", o.gateway_name, o.orderid);
816 let row_sel = shared.row_selected("orders", &key);
817 let mut cells = Vec::with_capacity(17);
818 let mut dbl = false;
819 let sel = ui.selectable_label(row_sel, o.symbol.clone());
820 if sel.clicked() {
821 shared.select_row("orders", &key);
822 }
823 dbl |= sel.double_clicked();
824 cells.push(sel.rect);
825 macro_rules! cell {
826 ($base:expr, $col:expr) => {{
827 let base = $base;
828 let r = click_cell(ui, base.rect, ("ord", &key, $col));
829 if r.clicked() {
830 shared.select_row("orders", &key);
831 }
832 dbl |= r.double_clicked();
833 cells.push(base.rect);
834 }};
835 }
836 cell!(
837 ui.label(if o.name.is_empty() {
838 "-".to_string()
839 } else {
840 o.name.clone()
841 }),
842 1
843 );
844 cell!(ui.label(o.exchange.as_str().to_string()), 2);
845 cell!(ui.label(o.order_type.as_str().to_string()), 3);
846 cell!(
847 ui.label(
848 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
849 if o.direction == Some(Direction::SHORT) {
850 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
851 } else {
852 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
853 },
854 ),
855 ),
856 4
857 );
858 cell!(ui.label(o.offset.as_str().to_string()), 5);
859 cell!(ui.label(format!("{:.1}", o.price)), 6);
860 cell!(ui.label(format!("{:.0}", o.volume)), 7);
861 cell!(ui.label(format!("{:.0}", o.traded)), 8);
862 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
863 cell!(
864 ui.label(egui::RichText::new(o.status.as_str()).color(
865 if o.status == ek_data::constant::Status::REJECTED {
866 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
867 } else if o.status == ek_data::constant::Status::ALLTRADED {
868 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
869 } else {
870 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
871 },
872 )),
873 10
874 );
875 cell!(ui.label(o.status_msg.clone()), 11);
876 cell!(ui.label(fmt_dt(o.datetime)), 12);
877 cell!(ui.label(o.orderid.clone()), 13);
878 cell!(
879 ui.label(if o.reference.is_empty() {
880 "-".to_string()
881 } else {
882 o.reference.clone()
883 }),
884 14
885 );
886 cell!(ui.label(o.gateway_name.clone()), 15);
887 if dbl && order_active(o.status) {
888 let gw = if o.gateway_name.is_empty() {
889 "sim".to_string()
890 } else {
891 o.gateway_name.clone()
892 };
893 shared.push_cmd(UiCmd::CancelOrder {
894 orderid: o.orderid.clone(),
895 gateway: gw,
896 });
897 }
898 let hr = ui.horizontal(|ui| {
899 if order_active(o.status)
900 && ui
901 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
902 .clicked()
903 {
904 let gw = if o.gateway_name.is_empty() {
906 "sim".to_string()
907 } else {
908 o.gateway_name.clone()
909 };
910 shared.push_cmd(UiCmd::CancelOrder {
911 orderid: o.orderid.clone(),
912 gateway: gw,
913 });
914 }
915 });
916 cells.push(hr.response.rect);
917 paint_row_bg(ui, &cells, row_sel);
918 ui.end_row();
919 }
920 });
921}
922
923fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
925 ui.label("成交");
926 let ts = shared.trade_snap();
927 if ts.trades.is_empty() {
928 ui.label("(无成交)");
929 return;
930 }
931 egui::Grid::new("trade_grid")
932 .striped(true)
933 .num_columns(12)
934 .show(ui, |ui| {
935 for h in [
936 "成交号",
937 "代码",
938 "名称",
939 "交易所",
940 "方向",
941 "开平",
942 "价格",
943 "数量",
944 "成交额",
945 "时间",
946 "委托号",
947 "接口",
948 ] {
949 ui.strong(h);
950 }
951 ui.end_row();
952 for t in &ts.trades {
953 let key = format!("{}.{}", t.gateway_name, t.tradeid);
956 let row_sel = shared.row_selected("trades", &key);
957 let mut cells = Vec::with_capacity(12);
958 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
959 if sel.clicked() {
960 shared.select_row("trades", &key);
961 }
962 cells.push(sel.rect);
963 macro_rules! cell {
964 ($base:expr, $col:expr) => {{
965 let base = $base;
966 let r = click_cell(ui, base.rect, ("trd", &key, $col));
967 if r.clicked() {
968 shared.select_row("trades", &key);
969 }
970 cells.push(base.rect);
971 }};
972 }
973 cell!(ui.label(t.symbol.clone()), 1);
974 cell!(
975 ui.label(if t.name.is_empty() {
976 "-".to_string()
977 } else {
978 t.name.clone()
979 }),
980 2
981 );
982 cell!(ui.label(t.exchange.as_str().to_string()), 3);
983 cell!(
984 ui.label(
985 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
986 if t.direction == Some(Direction::SHORT) {
987 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
988 } else {
989 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
990 },
991 ),
992 ),
993 4
994 );
995 cell!(ui.label(t.offset.as_str().to_string()), 5);
996 cell!(ui.label(format!("{:.1}", t.price)), 6);
997 cell!(ui.label(format!("{:.0}", t.volume)), 7);
998 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
999 cell!(ui.label(fmt_dt(t.datetime)), 9);
1000 cell!(ui.label(t.orderid.clone()), 10);
1001 cell!(ui.label(t.gateway_name.clone()), 11);
1002 paint_row_bg(ui, &cells, row_sel);
1003 ui.end_row();
1004 }
1005 });
1006}
1007
1008fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1011 ui.label("持仓");
1012 let ts = shared.trade_snap();
1013 if ts.positions.is_empty() {
1014 ui.label("(无持仓)");
1015 return;
1016 }
1017 egui::Grid::new("pos_grid")
1018 .striped(true)
1019 .num_columns(16)
1020 .show(ui, |ui| {
1021 for h in [
1022 "代码",
1023 "名称",
1024 "交易所",
1025 "方向",
1026 "数量",
1027 "昨仓",
1028 "可用",
1029 "冻结",
1030 "在途",
1031 "市值",
1032 "成本",
1033 "均价",
1034 "最新价",
1035 "盈亏",
1036 "盈亏比",
1037 "接口",
1038 ] {
1039 ui.strong(h);
1040 }
1041 ui.end_row();
1042 for p in &ts.positions {
1043 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1047 let row_sel = shared.row_selected("positions", &key);
1048 let mut cells = Vec::with_capacity(16);
1049 let mut dbl = false;
1050 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1051 if sel.clicked() {
1052 shared.select_row("positions", &key);
1053 }
1054 dbl |= sel.double_clicked();
1055 cells.push(sel.rect);
1056 macro_rules! cell {
1057 ($base:expr, $col:expr) => {{
1058 let base = $base;
1059 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1060 if r.clicked() {
1061 shared.select_row("positions", &key);
1062 }
1063 dbl |= r.double_clicked();
1064 cells.push(base.rect);
1065 }};
1066 }
1067 cell!(
1068 ui.label(if p.name.is_empty() {
1069 "-".to_string()
1070 } else {
1071 p.name.clone()
1072 }),
1073 1
1074 );
1075 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1076 cell!(
1077 ui.label(egui::RichText::new(p.direction.as_str()).color(
1078 if p.direction == ek_data::constant::Direction::SHORT {
1079 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1080 } else {
1081 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1082 },
1083 )),
1084 3
1085 );
1086 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1087 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1088 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1089 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1090 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1091 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1092 cell!(ui.label(format!("{:.2}", p.price)), 10);
1093 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1094 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1095 cell!(
1096 ui.label(
1097 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1098 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1099 } else {
1100 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1101 }),
1102 ),
1103 13
1104 );
1105 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1106 cell!(ui.label(p.gateway_name.clone()), 15);
1107 if dbl {
1108 let mut f = shared.order_form();
1109 f.symbol = p.symbol.clone();
1110 f.exchange = p.exchange.as_str().to_string();
1111 f.direction = p.direction;
1112 shared.set_order_form(f);
1113 shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1114 }
1115 paint_row_bg(ui, &cells, row_sel);
1116 ui.end_row();
1117 }
1118 });
1119}
1120
1121fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1122 let tab = shared.bottomright_tab();
1123 ui.horizontal(|ui| {
1124 if ui
1125 .selectable_label(
1126 tab == BottomRightTab::Account,
1127 BottomRightTab::Account.label(),
1128 )
1129 .clicked()
1130 {
1131 shared.set_bottomright_tab(BottomRightTab::Account);
1132 }
1133 if ui
1134 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1135 .clicked()
1136 {
1137 shared.set_bottomright_tab(BottomRightTab::Logs);
1138 }
1139 });
1140 match tab {
1141 BottomRightTab::Account => {
1142 let ts = shared.trade_snap();
1143 if ts.accounts.is_empty() {
1144 ui.label("(无资金:等网关 eAccount.)");
1145 return;
1146 }
1147 egui::Grid::new("acct_grid")
1148 .striped(true)
1149 .num_columns(5)
1150 .show(ui, |ui| {
1151 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1152 ui.strong(h);
1153 }
1154 ui.end_row();
1155 for a in &ts.accounts {
1156 ui.label(a.accountid.clone());
1157 ui.label(a.gateway_name.clone());
1158 ui.label(format!("{:.2}", a.balance));
1159 ui.label(format!("{:.2}", a.available()));
1160 ui.label(format!("{:.2}", a.frozen));
1161 ui.end_row();
1162 }
1163 });
1164 }
1165 BottomRightTab::Logs => {
1166 render_logs(ui, shared);
1167 }
1168 }
1169}
1170
1171fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1172 ui.horizontal(|ui| {
1173 if ui.button(BTN_CLEAR_LOGS).clicked() {
1174 shared.clear_logs(); }
1176 let (_, spilled) = shared.logs_snapshot();
1177 if spilled > 0 {
1178 ui.label(format!(
1179 "已丢弃 {spilled} 条(环形 {})",
1180 crate::state::RING_CAP
1181 ));
1182 }
1183 });
1184 let lines = shared.logs_snapshot().0;
1185 egui::ScrollArea::vertical()
1186 .id_salt("log_scroll")
1187 .max_height(180.0)
1188 .stick_to_bottom(true)
1189 .show(ui, |ui| {
1190 for line in lines {
1191 ui.monospace(line);
1192 }
1193 });
1194}
1195
1196fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1199 let mut ops_open = shared.ops_window_open();
1201 egui::Window::new("运维窗")
1202 .id(egui::Id::new("ops_win"))
1203 .default_pos([80.0, 320.0])
1204 .default_size([640.0, 380.0])
1205 .open(&mut ops_open)
1206 .show(ctx, |ui| {
1207 render_ops_window(ui, shared);
1208 });
1209 shared.set_ops_window_open(ops_open);
1210
1211 let mut ctp_open = shared.ctp_window_open();
1213 egui::Window::new("CTP 网关配置窗")
1214 .id(egui::Id::new("ctp_win"))
1215 .default_pos([640.0, 320.0])
1216 .default_size([560.0, 330.0])
1217 .open(&mut ctp_open)
1218 .show(ctx, |ui| {
1219 render_ctp_window(ui, shared);
1220 });
1221 shared.set_ctp_window_open(ctp_open);
1222
1223 if shared.contracts_window_open() {
1234 let contracts_closed = std::cell::Cell::new(false);
1235 ctx.show_viewport_immediate(
1236 egui::ViewportId::from_hash_of("contracts_win"),
1237 egui::ViewportBuilder::default()
1238 .with_title("查询合约窗")
1239 .with_inner_size([620.0, 460.0])
1240 .with_min_inner_size([420.0, 260.0])
1241 .with_minimize_button(true)
1242 .with_maximize_button(true)
1243 .with_close_button(true),
1244 |ctx2, class| {
1245 if class == egui::ViewportClass::Embedded {
1246 let mut open = shared.contracts_window_open();
1247 egui::Window::new("查询合约窗")
1248 .id(egui::Id::new("contracts_win"))
1249 .default_pos([420.0, 300.0])
1250 .default_size([560.0, 420.0])
1251 .open(&mut open)
1252 .show(ctx2, |ui| {
1253 render_contracts_window(ui, shared);
1254 });
1255 shared.set_contracts_window_open(open);
1256 return;
1257 }
1258 if ctx2.input(|i| i.viewport().close_requested()) {
1259 contracts_closed.set(true);
1260 return;
1261 }
1262 egui::CentralPanel::default().show(ctx2, |ui| {
1263 render_contracts_window(ui, shared);
1264 });
1265 },
1266 );
1267 if contracts_closed.get() {
1268 shared.set_contracts_window_open(false);
1269 }
1270 }
1271
1272 let mut closed: Vec<String> = Vec::new();
1274 for sym in shared.kline_symbols() {
1275 let closed_flag = std::cell::Cell::new(false);
1276 ctx.show_viewport_immediate(
1277 egui::ViewportId::from_hash_of(("kline_win", sym.clone())),
1278 egui::ViewportBuilder::default()
1279 .with_title(format!("K线 — {sym}"))
1280 .with_inner_size([560.0, 360.0])
1281 .with_min_inner_size([360.0, 220.0])
1282 .with_minimize_button(true)
1283 .with_maximize_button(true)
1284 .with_close_button(true),
1285 |ctx2, class| {
1286 if class == egui::ViewportClass::Embedded {
1287 let resp = egui::Window::new(format!("K线 — {sym}"))
1288 .id(egui::Id::new(("kline_win", sym.clone())))
1289 .default_pos([300.0, 340.0])
1290 .default_size([540.0, 330.0])
1291 .show(ctx2, |ui| {
1292 let cell = shared
1293 .market()
1294 .cells()
1295 .into_iter()
1296 .find(|c| c.symbol == sym);
1297 render_kline_cell(ui, cell.as_ref());
1298 });
1299 if resp.is_none() {
1300 closed_flag.set(true);
1301 }
1302 return;
1303 }
1304 if ctx2.input(|i| i.viewport().close_requested()) {
1305 closed_flag.set(true);
1306 return;
1307 }
1308 egui::CentralPanel::default().show(ctx2, |ui| {
1309 let cell = shared
1310 .market()
1311 .cells()
1312 .into_iter()
1313 .find(|c| c.symbol == sym);
1314 render_kline_cell(ui, cell.as_ref());
1315 });
1316 },
1317 );
1318 if closed_flag.get() {
1319 closed.push(sym);
1320 }
1321 }
1322 for s in closed {
1323 shared.close_kline(&s);
1324 }
1325}
1326
1327fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1330 ui.label("插件");
1331 let snap = shared.snapshot();
1332 egui::Grid::new("plug_grid")
1333 .striped(true)
1334 .num_columns(6)
1335 .show(ui, |ui| {
1336 ui.strong("名称");
1337 ui.strong("状态");
1338 ui.strong("版本");
1339 ui.strong("依赖");
1340 ui.strong("统计");
1341 ui.strong("操作");
1342 ui.end_row();
1343 if snap.rows.is_empty() {
1344 ui.label("(快照空:等 eTimer 首拉)");
1345 ui.end_row();
1346 }
1347 for r in &snap.rows {
1348 if is_quarantined(&r.state) {
1349 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1350 ui.label(
1351 egui::RichText::new(state_text(r))
1352 .color(egui::Color32::RED)
1353 .strong(),
1354 );
1355 } else {
1356 ui.label(r.name.clone());
1357 ui.label(state_text(r));
1358 }
1359 ui.label(r.version.clone());
1360 ui.label(format!("deps:[{}]", r.deps.join(",")));
1361 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1362 ui.horizontal(|ui| {
1363 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1364 shared.push_cmd(UiCmd::Start {
1365 factory: r.name.clone(),
1366 });
1367 }
1368 let armed = shared.pending_confirm();
1369 let armed_here = ui
1370 .small_button(remove_button_label(&r.name, armed.as_deref()))
1371 .clicked()
1372 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1373 if armed_here {
1374 shared.push_cmd(UiCmd::Remove {
1375 name: r.name.clone(),
1376 });
1377 }
1378 });
1379 ui.end_row();
1380 }
1381 });
1382
1383 if let Some(name) = shared.pending_confirm() {
1384 ui.horizontal(|ui| {
1385 ui.label(
1386 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1387 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1388 );
1389 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1390 shared.confirm_cancel();
1391 }
1392 });
1393 }
1394
1395 ui.label("服务");
1396 let flat: Vec<String> = snap
1397 .services
1398 .iter()
1399 .flat_map(|(n, ids)| {
1400 if ids.is_empty() {
1401 vec![format!("{n} 服务"), "-".to_string()]
1402 } else {
1403 ids.iter()
1404 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1405 .collect()
1406 }
1407 })
1408 .collect();
1409 if flat.is_empty() {
1410 ui.label("(无服务目录)");
1411 } else {
1412 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1414 .striped(true)
1415 .num_columns(6)
1416 .show(ui, |ui| {
1417 for r in 0..rows {
1418 for c in 0..3 {
1419 let i = c * rows * 2 + r * 2;
1420 if i < flat.len() {
1421 ui.label(flat[i].clone());
1422 ui.label(flat[i + 1].clone());
1423 } else {
1424 ui.label("");
1425 ui.label("");
1426 }
1427 }
1428 ui.end_row();
1429 }
1430 });
1431 }
1432
1433 ui.label("依赖");
1434 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1435 ui.label("(全部无依赖)");
1436 }
1437 for r in &snap.rows {
1438 if !r.deps.is_empty() {
1439 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1440 }
1441 }
1442
1443 ui.horizontal(|ui| {
1444 if ui.small_button(BTN_QUERY_RATE).clicked() {
1445 shared.push_cmd(UiCmd::QueryRate);
1446 }
1447 });
1448}
1449
1450fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1454 ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1455 let mut form: crate::state::CtpForm = shared.ctp_form();
1456 egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1457 ui.label("broker");
1458 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1459 ui.label("td 前置");
1460 ctp_edit(
1461 ui,
1462 shared,
1463 &mut form.td_addr,
1464 "tcp://host:40001",
1465 CtpField::TdAddr,
1466 );
1467 ui.label("td 用户");
1468 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1469 ui.end_row();
1470 ui.label("td 密码");
1471 ctp_edit(
1472 ui,
1473 shared,
1474 &mut form.td_password,
1475 "密码",
1476 CtpField::TdPassword,
1477 );
1478 ui.label("AppID");
1479 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1480 ui.label("AuthCode");
1481 ctp_edit(
1482 ui,
1483 shared,
1484 &mut form.td_auth_code,
1485 "授权码",
1486 CtpField::AuthCode,
1487 );
1488 ui.end_row();
1489 ui.label("md 前置");
1490 ctp_edit(
1491 ui,
1492 shared,
1493 &mut form.md_addr,
1494 "tcp://host:40011",
1495 CtpField::MdAddr,
1496 );
1497 ui.label("md 用户");
1498 ctp_edit(
1499 ui,
1500 shared,
1501 &mut form.md_user,
1502 "空=td 回落",
1503 CtpField::MdUser,
1504 );
1505 ui.label("md 密码");
1506 ctp_edit(
1507 ui,
1508 shared,
1509 &mut form.md_password,
1510 "空=td 回落",
1511 CtpField::MdPassword,
1512 );
1513 ui.end_row();
1514 });
1515 ui.horizontal(|ui| {
1516 if ui.small_button(BTN_CTP_CONNECT).clicked() {
1517 shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1518 shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1519 }
1520 ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1521 });
1522}
1523
1524fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1528 let mut q = shared.contract_query();
1529 ui.horizontal(|ui| {
1530 ui.label("代码或交易所(留空 = 全部):");
1531 if ui
1532 .add(
1533 egui::TextEdit::singleline(&mut q)
1534 .hint_text("cu 或 SHFE")
1535 .desired_width(200.0),
1536 )
1537 .changed()
1538 {
1539 shared.set_contract_query(q.clone());
1540 }
1541 });
1542 let rows = shared.contracts(&q);
1543 if rows.is_empty() {
1544 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1545 return;
1546 }
1547 let selected = shared.selected_contracts();
1548 egui::ScrollArea::vertical()
1549 .id_salt("contract_scroll")
1550 .max_height(260.0)
1551 .auto_shrink([false, false])
1552 .show(ui, |ui| {
1553 egui::Grid::new("contract_grid")
1554 .striped(true)
1555 .num_columns(5)
1556 .show(ui, |ui| {
1557 for h in ["选(代码)", "交易所", "名称", "乘数", "最小变动"] {
1558 ui.strong(h);
1559 }
1560 ui.end_row();
1561 for c in &rows {
1562 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1563 let mut on = selected.contains(&key);
1564 let row_sel = shared.row_selected("contracts", &key);
1568 let mut cells = Vec::with_capacity(5);
1569 let ck = ui.checkbox(&mut on, key.clone());
1570 if ck.changed() {
1571 shared.toggle_contract_selection(&key);
1572 shared.select_row("contracts", &key);
1573 }
1574 cells.push(ck.rect);
1575 macro_rules! cell {
1576 ($base:expr, $col:expr) => {{
1577 let base = $base;
1578 let r = click_cell(ui, base.rect, ("crt", &key, $col));
1579 if r.clicked() {
1580 shared.select_row("contracts", &key);
1581 }
1582 cells.push(base.rect);
1583 }};
1584 }
1585 cell!(ui.label(c.exchange.as_str().to_string()), 1);
1586 cell!(ui.label(c.name.clone()), 2);
1587 cell!(ui.label(format!("{:.0}", c.size)), 3);
1588 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1589 paint_row_bg(ui, &cells, row_sel);
1590 ui.end_row();
1591 }
1592 });
1593 });
1594 ui.horizontal(|ui| {
1595 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1596 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1597 if selected.is_empty() {
1598 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1599 } else {
1600 let symbols: Vec<String> = selected
1602 .iter()
1603 .map(|k| {
1604 k.split_once('.')
1605 .map(|(_, s)| s.to_string())
1606 .unwrap_or_else(|| k.clone())
1607 })
1608 .collect();
1609 shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1610 shared.log_line(format!(
1611 "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1612 selected.len()
1613 ));
1614 }
1615 }
1616 });
1617}
1618
1619enum CtpField {
1621 Broker,
1622 TdAddr,
1623 TdUser,
1624 TdPassword,
1625 AppId,
1626 AuthCode,
1627 MdAddr,
1628 MdUser,
1629 MdPassword,
1630}
1631
1632fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1634 let is_secret = matches!(
1635 field,
1636 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1637 );
1638 let mut w = egui::TextEdit::singleline(val)
1639 .hint_text(hint)
1640 .desired_width(110.0);
1641 if is_secret {
1642 w = w.password(true);
1643 }
1644 if ui.add(w).changed() {
1645 let mut f = shared.ctp_form();
1646 match field {
1647 CtpField::Broker => f.broker_id = val.clone(),
1648 CtpField::TdAddr => f.td_addr = val.clone(),
1649 CtpField::TdUser => f.td_user = val.clone(),
1650 CtpField::TdPassword => f.td_password = val.clone(),
1651 CtpField::AppId => f.td_app_id = val.clone(),
1652 CtpField::AuthCode => f.td_auth_code = val.clone(),
1653 CtpField::MdAddr => f.md_addr = val.clone(),
1654 CtpField::MdUser => f.md_user = val.clone(),
1655 CtpField::MdPassword => f.md_password = val.clone(),
1656 }
1657 shared.set_ctp_form(f);
1658 }
1659}
1660
1661fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1665 let Some(c) = cell else {
1666 ui.label("(K线无格:等 dm 合成)");
1667 return;
1668 };
1669 if c.bars.is_empty() {
1670 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1671 return;
1672 }
1673 let mut data = egui_charts::model::bar::BarData::new();
1674 for b in &c.bars {
1675 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1676 data.push(egui_charts::model::bar::Bar::new(
1677 dt,
1678 b.open_price,
1679 b.high_price,
1680 b.low_price,
1681 b.close_price,
1682 b.volume,
1683 ));
1684 }
1685 ui.label(format!(
1686 "K线 {}@{}s({} 根)",
1687 c.symbol,
1688 c.interval,
1689 c.bars.len()
1690 ));
1691 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1692 chart.set_symbol(&c.symbol);
1693 chart.set_timeframe_label(&format!("{}s", c.interval));
1694 chart.enable_tracking_mode();
1695 let _ = chart.show(ui);
1696}