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