1use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39 BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42pub const BTN_CLEAR_LOGS: &str = "清空日志";
44pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46pub const BTN_SEND_ORDER: &str = "委托";
48pub const BTN_CANCEL_ALL: &str = "全撤";
50pub const BTN_CANCEL_ORDER: &str = "撤单";
52pub const BTN_ACCOUNT_ADD: &str = "添加账户";
54pub const MENU_OPS: &str = "运维";
56pub const MENU_ACCOUNTS: &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 subscribe_target(shared: &UiShared) -> Result<String, String> {
81 let md = shared.md_source();
82 if md.is_empty() {
83 return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
84 }
85 Ok(md)
86}
87
88fn state_text(r: &PlugRow) -> String {
89 r.state.clone()
90}
91
92fn fmt_dt(dt: Option<i64>) -> String {
96 dt.map(|ns| {
97 chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
98 .with_timezone(&beijing_tz())
99 .format("%H:%M:%S")
100 .to_string()
101 })
102 .unwrap_or_else(|| "-".to_string())
103}
104
105fn beijing_tz() -> chrono::FixedOffset {
107 chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
108}
109
110fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
116 let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
117 return;
118 };
119 let hovered = ui
120 .input(|i| i.pointer.interact_pos())
121 .is_some_and(|p| union.expand(3.0).contains(p));
122 if selected {
123 ui.painter().rect_filled(
124 union.expand(3.0),
125 3.0,
126 egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
127 );
128 } else if hovered {
129 ui.painter().rect_filled(
130 union.expand(3.0),
131 3.0,
132 egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
133 );
134 }
135}
136
137fn order_active(status: ek_data::constant::Status) -> bool {
139 ek_data::constant::ACTIVE_STATUSES.contains(&status)
140}
141
142fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
145 ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
146}
147
148pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
152 let mut f = shared.order_form();
153 f.symbol = t.symbol.clone();
154 f.exchange = exchange.to_string();
155 match col {
156 8 => {
157 f.direction = Direction::SHORT;
158 f.price = format!("{:.1}", t.bid_price_1);
159 }
160 9 => f.direction = Direction::SHORT,
161 10 => {
162 f.direction = Direction::LONG;
163 f.price = format!("{:.1}", t.ask_price_1);
164 }
165 11 => f.direction = Direction::LONG,
166 _ => {}
167 }
168 shared.set_order_form(f);
169}
170
171fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
174 if symbol.is_empty() {
175 return None;
176 }
177 shared.tick_rows().into_iter().find(|t| {
178 t.symbol == symbol
179 && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
180 })
181}
182
183pub fn apply_ui_style(ctx: &egui::Context) {
187 apply_ui_style_for(ctx, crate::state::Theme::default());
188}
189
190pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
195 ctx.style_mut(|style| {
196 style
197 .text_styles
198 .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
199 style
200 .text_styles
201 .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
202 style
203 .text_styles
204 .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
205 style
206 .text_styles
207 .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
208 style
209 .text_styles
210 .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
211 });
212 ctx.set_visuals(theme_visuals(theme));
213}
214
215fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
218 use egui::Color32;
219 match t {
220 crate::state::Theme::EguiDark => egui::Visuals::dark(),
221 crate::state::Theme::EguiLight => egui::Visuals::light(),
222 crate::state::Theme::QtClassic => {
223 let mut v = egui::Visuals::light();
224 v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
225 v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
226 v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
227 v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
228 v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
229 v
230 }
231 crate::state::Theme::FinanceDark => {
232 let mut v = egui::Visuals::dark();
233 v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
234 v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
235 v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
236 v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
237 v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
238 v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
239 v.widgets.noninteractive.bg_stroke =
240 egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
241 v
242 }
243 }
244}
245
246pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
254 apply_ui_style_for(ctx, shared.theme());
255 egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
257 render_menu_bar(ui, shared);
258 });
259
260 egui::TopBottomPanel::top("market_band")
262 .resizable(true)
263 .default_height(240.0)
264 .show(ctx, |ui| {
265 egui::ScrollArea::both()
269 .id_salt("market_band_scroll")
270 .auto_shrink([false, false])
271 .show(ui, |ui| {
272 render_market_band(ui, shared);
273 });
274 });
275
276 egui::SidePanel::left("order_side")
278 .resizable(true)
279 .default_width(250.0)
280 .show(ctx, |ui| {
281 egui::ScrollArea::vertical()
283 .id_salt("order_side_scroll")
284 .auto_shrink([false, false])
285 .show(ui, |ui| {
286 render_order_panel(ui, shared);
287 ui.separator();
288 render_book_frame(ui, shared);
289 });
290 });
291
292 egui::CentralPanel::default().show(ctx, |ui| {
294 let avail = ui.available_size();
295 let sp = ui.spacing().item_spacing;
296 let gw = ((avail.x - sp.x) / 2.0).max(220.0);
302 let gh = ((avail.y - sp.y) / 2.0).max(120.0);
303 ui.horizontal(|ui| {
304 group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
305 render_topleft_group(ui, shared);
306 });
307 group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
308 render_trades_group(ui, shared);
309 });
310 });
311 ui.horizontal(|ui| {
312 group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
313 render_positions_group(ui, shared);
314 });
315 group_frame(ui, "grp_bottomright", gw, gh, |ui| {
320 render_bottomright_group(ui, shared);
321 });
322 });
323 });
324
325 render_windows(ctx, shared);
327}
328
329fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
337 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
338 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
339 egui::Frame::group(ui.style()).show(&mut child, |ui| {
340 egui::ScrollArea::both()
341 .id_salt(id)
342 .auto_shrink([false, false])
343 .show(ui, |ui| {
344 ui.vertical(content);
345 });
346 });
347}
348
349fn group_frame_plain(
356 ui: &mut egui::Ui,
357 _id: &str,
358 w: f32,
359 h: f32,
360 content: impl FnOnce(&mut egui::Ui),
361) {
362 let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
363 let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
364 egui::Frame::group(ui.style()).show(&mut child, |ui| {
365 ui.vertical(content);
366 });
367}
368
369fn pinned_table(
385 ui: &mut egui::Ui,
386 grid_id: &str,
387 num_columns: usize,
388 headers: &[&str],
389 body: impl FnOnce(&mut egui::Ui),
390) {
391 let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
392 std::cell::RefCell::new(Vec::new());
393 let resp = egui::ScrollArea::both()
394 .id_salt(format!("{grid_id}_sc"))
395 .auto_shrink([false, false])
396 .show(ui, |ui| {
397 egui::Grid::new(grid_id)
398 .striped(true)
399 .num_columns(num_columns)
400 .show(ui, |ui| {
401 for h in headers {
402 let r = ui.strong(*h);
403 heads_cell.borrow_mut().push((r.rect, h.to_string()));
404 }
405 ui.end_row();
406 body(ui);
407 });
408 });
409 let heads = heads_cell.borrow();
410 if heads.is_empty() {
411 return;
412 }
413 let area = resp.inner_rect;
414 let head_h = heads[0].0.height();
415 let m = ui.visuals().clip_rect_margin;
421 let strip = egui::Rect::from_min_max(
422 egui::pos2(area.min.x - m, area.min.y - m),
423 egui::pos2(
424 area.max.x + m,
425 area.min.y + head_h + ui.spacing().item_spacing.y,
426 ),
427 );
428 ui.scope_builder(
429 egui::UiBuilder::new()
430 .id_salt(format!("{grid_id}_hdr_ov"))
431 .max_rect(strip),
432 |ui| {
433 ui.set_clip_rect(strip);
434 let painter = ui.painter();
435 painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
436 let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
437 let color = ui.visuals().strong_text_color();
438 for (rect, text) in heads.iter() {
439 painter.text(
440 egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
441 egui::Align2::LEFT_CENTER,
442 text.as_str(),
443 font.clone(),
444 color,
445 );
446 }
447 },
448 );
449}
450
451fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
454 ui.horizontal(|ui| {
455 if ui.button(MENU_OPS).clicked() {
457 shared.set_ops_window_open(true);
458 }
459 if ui.button(MENU_ACCOUNTS).clicked() {
460 shared.set_accounts_window_open(true);
461 }
462 if ui.button(MENU_CONTRACTS).clicked() {
463 shared.set_contracts_window_open(true);
464 }
465 ui.menu_button(MENU_HELP, |ui| {
466 ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
467 ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
468 });
469 if ui
472 .button(format!("主题:{}", shared.theme().label()))
473 .clicked()
474 {
475 let t = shared.cycle_theme();
476 shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
477 }
478 });
479}
480
481fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
484 let groups = shared.tick_groups();
486 if groups.is_empty() {
487 ui.label("(等待 eTick 订阅)");
488 return;
489 }
490 let sel = shared.selected_exchange();
492 let active = if groups.iter().any(|(e, _)| *e == sel) {
493 sel.clone()
494 } else {
495 groups[0].0.clone()
496 };
497 ui.horizontal(|ui| {
498 for (ex, _) in &groups {
499 if ui.selectable_label(*ex == active, ex.clone()).clicked() {
500 shared.set_selected_exchange(ex.clone());
501 }
502 }
503 });
504 let ticks = groups
505 .iter()
506 .find(|(e, _)| *e == active)
507 .map(|(_, v)| v.clone())
508 .unwrap_or_default();
509 pinned_table(
512 ui,
513 "tick_grid",
514 14,
515 &[
516 "代码",
517 "名称",
518 "最新价",
519 "涨跌幅",
520 "成交量",
521 "今开",
522 "最高",
523 "最低",
524 "买1价",
525 "买1量",
526 "卖1价",
527 "卖1量",
528 "时间",
529 "行情源",
530 ],
531 |ui| {
532 for t in &ticks {
533 let row_sel = shared.order_form().symbol == t.symbol;
538 let mut cells = Vec::with_capacity(14);
539 let mut dbl = false;
540 let resp = ui.button(t.symbol.clone());
541 if resp.clicked() {
542 shared.set_selected_exchange(active.clone());
543 import_tick(shared, t, &active, 0);
544 }
545 dbl |= resp.double_clicked();
546 cells.push(resp.rect);
547 macro_rules! cell {
548 ($base:expr, $col:expr) => {{
549 let base = $base;
550 let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
551 if r.clicked() {
552 shared.set_selected_exchange(active.clone());
553 import_tick(shared, t, &active, $col);
554 }
555 dbl |= r.double_clicked();
556 cells.push(base.rect);
557 }};
558 }
559 cell!(
560 ui.label(if t.name.is_empty() {
561 "-".to_string()
562 } else {
563 t.name.clone()
564 }),
565 1
566 );
567 cell!(ui.label(format!("{:.1}", t.last_price)), 2);
568 cell!(
569 ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
570 if t.pct_change < 0.0 {
571 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
572 } else {
573 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
574 },
575 )),
576 3
577 );
578 cell!(ui.label(format!("{:.0}", t.volume)), 4);
579 cell!(ui.label(format!("{:.1}", t.open_price)), 5);
580 cell!(ui.label(format!("{:.1}", t.high_price)), 6);
581 cell!(ui.label(format!("{:.1}", t.low_price)), 7);
582 cell!(
583 ui.label(
584 egui::RichText::new(format!("{:.1}", t.bid_price_1))
585 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
586 ),
587 8
588 );
589 cell!(
590 ui.label(
591 egui::RichText::new(format!("{:.0}", t.bid_volume_1))
592 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
593 ),
594 9
595 );
596 cell!(
597 ui.label(
598 egui::RichText::new(format!("{:.1}", t.ask_price_1))
599 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
600 ),
601 10
602 );
603 cell!(
604 ui.label(
605 egui::RichText::new(format!("{:.0}", t.ask_volume_1))
606 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
607 ),
608 11
609 );
610 cell!(
611 ui.label(if t.update_time.is_empty() {
612 fmt_dt(Some(t.datetime))
613 } else {
614 t.update_time.clone()
615 }),
616 12
617 );
618 cell!(ui.label(t.gateway_name.clone()), 13);
619 if dbl {
620 shared.open_kline(t.symbol.clone());
621 }
622 paint_row_bg(ui, &cells, row_sel);
623 ui.end_row();
624 }
625 },
626 );
627}
628
629fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
632 let mut f = shared.order_form();
634 let before = f.clone();
635 let mut err: Option<String> = None;
636
637 if f.follow_price {
639 if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
640 if t.last_price > 0.0 {
641 f.price = format!("{:.1}", t.last_price);
642 }
643 }
644 }
645
646 let mut ex_opts: Vec<String> = shared
648 .tick_groups()
649 .iter()
650 .map(|(e, _)| e.clone())
651 .collect();
652 for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
653 if !ex_opts.iter().any(|x| x == e) {
654 ex_opts.push(e.to_string());
655 }
656 }
657 if !ex_opts.contains(&f.exchange) {
658 ex_opts.insert(0, f.exchange.clone());
659 }
660 let ts = shared.trade_snap();
663 let mut gw_opts: Vec<String> = Vec::new();
664 let known_gws = account_gateways(shared);
671 for g in shared
672 .accounts()
673 .iter()
674 .filter(|a| a.connected)
675 .map(|a| a.gateway.clone())
676 .chain(
677 ts.orders
678 .iter()
679 .map(|o| o.gateway_name.clone())
680 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
681 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
682 .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
683 )
684 {
685 if !g.is_empty() && !gw_opts.contains(&g) {
686 gw_opts.push(g);
687 }
688 }
689 if !gw_opts.contains(&f.gateway)
690 && !f.gateway.is_empty()
691 && (known_gws.is_empty() || known_gws.contains(&f.gateway))
692 {
693 gw_opts.insert(0, f.gateway.clone());
694 }
695 if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
698 f.gateway = shared.default_gateway().unwrap_or_default();
699 }
700 if f.gateway.is_empty() {
703 if let Some(def) = shared.default_gateway() {
704 f.gateway = def;
705 }
706 }
707 let no_accounts = gw_opts.is_empty();
708
709 egui::Grid::new("order_form_grid")
712 .num_columns(2)
713 .show(ui, |ui| {
714 ui.label("交易所");
715 egui::ComboBox::from_id_salt("of_exchange")
716 .selected_text(f.exchange.clone())
717 .show_ui(ui, |ui| {
718 for e in &ex_opts {
719 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
720 }
721 });
722 ui.end_row();
723 ui.label("代码");
724 let sym_resp = ui.add(
725 egui::TextEdit::singleline(&mut f.symbol)
726 .hint_text("cu2709")
727 .desired_width(150.0),
728 );
729 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
734 let s = f.symbol.trim().to_string();
735 let bare = match s.split_once('.') {
738 Some((_, b)) => b.trim().to_string(),
739 None => s,
740 };
741 if !bare.is_empty() {
742 f.symbol = bare.clone();
743 match subscribe_target(shared) {
746 Ok(gw) => {
747 shared.push_cmd(UiCmd::CtpSubscribe {
748 gateway: gw.clone(),
749 symbols: vec![bare.clone()],
750 });
751 shared
752 .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
753 }
754 Err(e) => shared.log_line(format!("[订阅] {e}")),
755 }
756 }
757 }
758 ui.end_row();
759 ui.label("名称");
760 ui.label(
761 tick_of(shared, &f.exchange, &f.symbol)
762 .map(|t| {
763 if t.name.is_empty() {
764 "-".to_string()
765 } else {
766 t.name
767 }
768 })
769 .unwrap_or_else(|| "-".to_string()),
770 );
771 ui.end_row();
772 ui.label("方向");
773 egui::ComboBox::from_id_salt("of_direction")
774 .selected_text(f.direction.as_str())
775 .show_ui(ui, |ui| {
776 for d in [Direction::LONG, Direction::SHORT] {
777 ui.selectable_value(&mut f.direction, d, d.as_str());
778 }
779 });
780 ui.end_row();
781 ui.label("开平");
782 ui.horizontal(|ui| {
783 egui::ComboBox::from_id_salt("of_offset")
784 .selected_text(f.offset.as_str())
785 .show_ui(ui, |ui| {
786 for o in [
787 Offset::OPEN,
788 Offset::CLOSE,
789 Offset::CLOSETODAY,
790 Offset::CLOSEYESTERDAY,
791 ] {
792 ui.selectable_value(&mut f.offset, o, o.as_str());
793 }
794 });
795 ui.checkbox(&mut f.auto_split, "自动开平");
797 });
798 ui.end_row();
799 ui.label("类型");
800 egui::ComboBox::from_id_salt("of_order_type")
801 .selected_text(f.order_type.as_str())
802 .show_ui(ui, |ui| {
803 for ot in [OrderType::LIMIT, OrderType::MARKET] {
804 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
805 }
806 });
807 ui.end_row();
808 ui.label("价格");
809 ui.horizontal(|ui| {
810 ui.add(
811 egui::TextEdit::singleline(&mut f.price)
812 .hint_text("107660")
813 .desired_width(90.0),
814 );
815 ui.checkbox(&mut f.follow_price, "随价");
816 });
817 ui.end_row();
818 ui.label("数量");
819 ui.add(
820 egui::TextEdit::singleline(&mut f.volume)
821 .hint_text("1")
822 .desired_width(90.0),
823 );
824 ui.end_row();
825 ui.label("接口");
826 egui::ComboBox::from_id_salt("of_gateway")
827 .selected_text(if f.gateway.is_empty() {
828 "(未选账户)".to_string()
829 } else {
830 f.gateway.clone()
831 })
832 .show_ui(ui, |ui| {
833 for g in &gw_opts {
834 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
835 }
836 });
837 ui.end_row();
838 ui.label("操作");
839 ui.horizontal(|ui| {
840 let send = ui
842 .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
843 .inner
844 .clicked();
845 if send {
846 match f.to_request() {
847 Ok(req) => {
848 let fallback = shared.default_gateway();
849 match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
850 Ok(gateway) => {
851 shared.push_cmd(UiCmd::SendOrder {
852 req,
853 gateway,
854 mode: if f.auto_split {
855 ek_oms::LockMode::Auto
856 } else {
857 ek_oms::LockMode::None
858 },
859 });
860 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
861 }
862 Err(e) => {
863 shared.log_line(format!("[下单] {e}"));
864 err = Some(e);
865 }
866 }
867 }
868 Err(e) => {
869 shared.log_line(format!("[下单] {e}"));
870 err = Some(e);
871 }
872 }
873 }
874 if ui.button(BTN_CANCEL_ALL).clicked() {
875 let mut n = 0usize;
878 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
879 let gw = if !o.gateway_name.is_empty() {
881 o.gateway_name.clone()
882 } else {
883 shared.default_gateway().unwrap_or_default()
884 };
885 if gw.is_empty() {
886 continue;
887 }
888 shared.push_cmd(UiCmd::CancelOrder {
889 orderid: o.orderid.clone(),
890 gateway: gw,
891 });
892 n += 1;
893 }
894 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
895 }
896 });
897 ui.end_row();
898 ui.label("代码回车 = 订阅");
899 ui.label("");
900 ui.end_row();
901 });
902 if let Some(e) = err {
903 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
904 }
905 if no_accounts {
906 ui.label(
908 egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
909 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
910 );
911 }
912 if f != before {
913 shared.set_order_form(f.clone());
914 }
915}
916
917fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
921 let f = shared.order_form();
922 match tick_of(shared, &f.exchange, &f.symbol) {
923 Some(t) => {
924 for i in (1..=5).rev() {
925 let (p, v) = match i {
926 1 => (t.ask_price_1, t.ask_volume_1),
927 2 => (t.ask_price_2, t.ask_volume_2),
928 3 => (t.ask_price_3, t.ask_volume_3),
929 4 => (t.ask_price_4, t.ask_volume_4),
930 _ => (t.ask_price_5, t.ask_volume_5),
931 };
932 ui.label(
933 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
934 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
935 );
936 }
937 for i in 1..=5 {
938 let (p, v) = match i {
939 1 => (t.bid_price_1, t.bid_volume_1),
940 2 => (t.bid_price_2, t.bid_volume_2),
941 3 => (t.bid_price_3, t.bid_volume_3),
942 4 => (t.bid_price_4, t.bid_volume_4),
943 _ => (t.bid_price_5, t.bid_volume_5),
944 };
945 ui.label(
946 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
947 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
948 );
949 }
950 }
951 None => {
952 ui.label("(无 tick)");
953 }
954 }
955}
956
957fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
960 let tab = shared.topleft_tab();
961 ui.horizontal(|ui| {
962 if ui
963 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
964 .clicked()
965 {
966 shared.set_topleft_tab(TopLeftTab::Orders);
967 }
968 if ui
969 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
970 .clicked()
971 {
972 shared.set_topleft_tab(TopLeftTab::Active);
973 }
974 });
975 render_orders_table(ui, shared, tab == TopLeftTab::Active);
976}
977
978fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
982 let ts = shared.trade_snap();
983 let known_gws = account_gateways(shared);
985 let rows: Vec<_> = ts
988 .orders
989 .iter()
990 .filter(|o| !active_only || order_active(o.status))
991 .collect();
992 const ORDER_HEADERS: [&str; 17] = [
994 "代码",
995 "名称",
996 "交易所",
997 "类型",
998 "方向",
999 "开平",
1000 "价格",
1001 "总量",
1002 "已成",
1003 "成交均价",
1004 "状态",
1005 "状态描述",
1006 "时间",
1007 "委托号",
1008 "来源",
1009 "接口",
1010 "操作",
1011 ];
1012 if rows.is_empty() {
1013 pinned_table(
1016 ui,
1017 if active_only {
1018 "active_grid"
1019 } else {
1020 "order_grid"
1021 },
1022 17,
1023 &ORDER_HEADERS,
1024 |ui| {
1025 ui.label(if active_only {
1026 "(无未成交)"
1027 } else {
1028 "(无委托:等网关 send_order)"
1029 });
1030 },
1031 );
1032 return;
1033 }
1034 pinned_table(
1035 ui,
1036 if active_only {
1037 "active_grid"
1038 } else {
1039 "order_grid"
1040 },
1041 17,
1042 &ORDER_HEADERS,
1043 |ui| {
1044 for o in &rows {
1045 let key = format!("{}.{}", o.gateway_name, o.orderid);
1049 let row_sel = shared.row_selected("orders", &key);
1050 let mut cells = Vec::with_capacity(17);
1051 let mut dbl = false;
1052 let sel = ui.selectable_label(row_sel, o.symbol.clone());
1053 if sel.clicked() {
1054 shared.select_row("orders", &key);
1055 }
1056 dbl |= sel.double_clicked();
1057 cells.push(sel.rect);
1058 macro_rules! cell {
1059 ($base:expr, $col:expr) => {{
1060 let base = $base;
1061 let r = click_cell(ui, base.rect, ("ord", &key, $col));
1062 if r.clicked() {
1063 shared.select_row("orders", &key);
1064 }
1065 dbl |= r.double_clicked();
1066 cells.push(base.rect);
1067 }};
1068 }
1069 cell!(
1070 ui.label(if o.name.is_empty() {
1071 "-".to_string()
1072 } else {
1073 o.name.clone()
1074 }),
1075 1
1076 );
1077 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1078 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1079 cell!(
1080 ui.label(
1081 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1082 if o.direction == Some(Direction::SHORT) {
1083 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1084 } else {
1085 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1086 },
1087 ),
1088 ),
1089 4
1090 );
1091 cell!(ui.label(o.offset.as_str().to_string()), 5);
1092 cell!(ui.label(format!("{:.1}", o.price)), 6);
1093 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1094 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1095 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1096 cell!(
1097 ui.label(egui::RichText::new(o.status.as_str()).color(
1098 if o.status == ek_data::constant::Status::REJECTED {
1099 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1100 } else if o.status == ek_data::constant::Status::ALLTRADED {
1101 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1102 } else {
1103 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1104 },
1105 )),
1106 10
1107 );
1108 cell!(ui.label(o.status_msg.clone()), 11);
1109 cell!(ui.label(fmt_dt(o.datetime)), 12);
1110 cell!(ui.label(o.orderid.clone()), 13);
1111 cell!(
1112 ui.label(if o.reference.is_empty() {
1113 "-".to_string()
1114 } else {
1115 o.reference.clone()
1116 }),
1117 14
1118 );
1119 cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1120 if dbl && order_active(o.status) {
1121 let gw = if !o.gateway_name.is_empty() {
1123 o.gateway_name.clone()
1124 } else {
1125 shared.default_gateway().unwrap_or_default()
1126 };
1127 if !gw.is_empty() {
1128 shared.push_cmd(UiCmd::CancelOrder {
1129 orderid: o.orderid.clone(),
1130 gateway: gw,
1131 });
1132 }
1133 }
1134 let hr = ui.horizontal(|ui| {
1135 if order_active(o.status)
1136 && ui
1137 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1138 .clicked()
1139 {
1140 let gw = if !o.gateway_name.is_empty() {
1143 o.gateway_name.clone()
1144 } else {
1145 shared.default_gateway().unwrap_or_default()
1146 };
1147 if gw.is_empty() {
1148 shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1149 } else {
1150 shared.push_cmd(UiCmd::CancelOrder {
1151 orderid: o.orderid.clone(),
1152 gateway: gw,
1153 });
1154 }
1155 }
1156 });
1157 cells.push(hr.response.rect);
1158 paint_row_bg(ui, &cells, row_sel);
1159 ui.end_row();
1160 }
1161 },
1162 );
1163}
1164
1165fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1167 ui.label("成交");
1168 let ts = shared.trade_snap();
1169 let known_gws = account_gateways(shared);
1171 const TRADE_HEADERS: [&str; 12] = [
1173 "成交号",
1174 "代码",
1175 "名称",
1176 "交易所",
1177 "方向",
1178 "开平",
1179 "价格",
1180 "数量",
1181 "成交额",
1182 "时间",
1183 "委托号",
1184 "接口",
1185 ];
1186 if ts.trades.is_empty() {
1187 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1189 ui.label("(无成交)");
1190 });
1191 return;
1192 }
1193 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1194 for t in &ts.trades {
1195 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1198 let row_sel = shared.row_selected("trades", &key);
1199 let mut cells = Vec::with_capacity(12);
1200 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1201 if sel.clicked() {
1202 shared.select_row("trades", &key);
1203 }
1204 cells.push(sel.rect);
1205 macro_rules! cell {
1206 ($base:expr, $col:expr) => {{
1207 let base = $base;
1208 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1209 if r.clicked() {
1210 shared.select_row("trades", &key);
1211 }
1212 cells.push(base.rect);
1213 }};
1214 }
1215 cell!(ui.label(t.symbol.clone()), 1);
1216 cell!(
1217 ui.label(if t.name.is_empty() {
1218 "-".to_string()
1219 } else {
1220 t.name.clone()
1221 }),
1222 2
1223 );
1224 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1225 cell!(
1226 ui.label(
1227 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1228 if t.direction == Some(Direction::SHORT) {
1229 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1230 } else {
1231 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1232 },
1233 ),
1234 ),
1235 4
1236 );
1237 cell!(ui.label(t.offset.as_str().to_string()), 5);
1238 cell!(ui.label(format!("{:.1}", t.price)), 6);
1239 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1240 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1241 cell!(ui.label(fmt_dt(t.datetime)), 9);
1242 cell!(ui.label(t.orderid.clone()), 10);
1243 cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1244 paint_row_bg(ui, &cells, row_sel);
1245 ui.end_row();
1246 }
1247 });
1248}
1249
1250fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1253 ui.label("持仓");
1254 let ts = shared.trade_snap();
1255 let known_gws = account_gateways(shared);
1257 const POS_HEADERS: [&str; 16] = [
1259 "代码",
1260 "名称",
1261 "交易所",
1262 "方向",
1263 "数量",
1264 "昨仓",
1265 "可用",
1266 "冻结",
1267 "在途",
1268 "市值",
1269 "成本",
1270 "均价",
1271 "最新价",
1272 "盈亏",
1273 "盈亏比",
1274 "接口",
1275 ];
1276 if ts.positions.is_empty() {
1277 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1279 ui.label("(无持仓)");
1280 });
1281 return;
1282 }
1283 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1284 for p in &ts.positions {
1285 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1289 let row_sel = shared.row_selected("positions", &key);
1290 let mut cells = Vec::with_capacity(16);
1291 let mut dbl = false;
1292 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1293 if sel.clicked() {
1294 shared.select_row("positions", &key);
1295 }
1296 dbl |= sel.double_clicked();
1297 cells.push(sel.rect);
1298 macro_rules! cell {
1299 ($base:expr, $col:expr) => {{
1300 let base = $base;
1301 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1302 if r.clicked() {
1303 shared.select_row("positions", &key);
1304 }
1305 dbl |= r.double_clicked();
1306 cells.push(base.rect);
1307 }};
1308 }
1309 cell!(
1310 ui.label(if p.name.is_empty() {
1311 "-".to_string()
1312 } else {
1313 p.name.clone()
1314 }),
1315 1
1316 );
1317 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1318 cell!(
1319 ui.label(egui::RichText::new(p.direction.as_str()).color(
1320 if p.direction == ek_data::constant::Direction::SHORT {
1321 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1322 } else {
1323 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1324 },
1325 )),
1326 3
1327 );
1328 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1329 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1330 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1331 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1332 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1333 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1334 cell!(ui.label(format!("{:.2}", p.price)), 10);
1335 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1336 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1337 cell!(
1338 ui.label(
1339 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1340 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1341 } else {
1342 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1343 }),
1344 ),
1345 13
1346 );
1347 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1348 cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1349 if dbl {
1350 let mut f = shared.order_form();
1351 f.symbol = p.symbol.clone();
1352 f.exchange = p.exchange.as_str().to_string();
1353 f.direction = p.direction;
1354 if !p.gateway_name.is_empty() {
1356 f.gateway = p.gateway_name.clone();
1357 }
1358 shared.set_order_form(f);
1359 shared.log_line(format!(
1360 "[ops] 持仓带入下单面板 {}(接口 {})",
1361 p.symbol, p.gateway_name
1362 ));
1363 }
1364 paint_row_bg(ui, &cells, row_sel);
1365 ui.end_row();
1366 }
1367 });
1368}
1369
1370fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1371 let tab = shared.bottomright_tab();
1372 ui.horizontal(|ui| {
1373 if ui
1374 .selectable_label(
1375 tab == BottomRightTab::Account,
1376 BottomRightTab::Account.label(),
1377 )
1378 .clicked()
1379 {
1380 shared.set_bottomright_tab(BottomRightTab::Account);
1381 }
1382 if ui
1383 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1384 .clicked()
1385 {
1386 shared.set_bottomright_tab(BottomRightTab::Logs);
1387 }
1388 });
1389 match tab {
1390 BottomRightTab::Account => {
1391 let ts = shared.trade_snap();
1392 if ts.accounts.is_empty() {
1393 ui.label("(无资金:等网关 eAccount.)");
1394 return;
1395 }
1396 egui::Grid::new("acct_grid")
1397 .striped(true)
1398 .num_columns(5)
1399 .show(ui, |ui| {
1400 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1401 ui.strong(h);
1402 }
1403 ui.end_row();
1404 for a in &ts.accounts {
1405 ui.label(a.accountid.clone());
1406 ui.label(a.gateway_name.clone());
1407 ui.label(format!("{:.2}", a.balance));
1408 ui.label(format!("{:.2}", a.available()));
1409 ui.label(format!("{:.2}", a.frozen));
1410 ui.end_row();
1411 }
1412 });
1413 }
1414 BottomRightTab::Logs => {
1415 render_logs(ui, shared);
1416 }
1417 }
1418}
1419
1420fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1421 ui.horizontal(|ui| {
1422 if ui.button(BTN_CLEAR_LOGS).clicked() {
1423 shared.clear_logs(); }
1425 let (_, spilled) = shared.logs_snapshot();
1426 if spilled > 0 {
1427 ui.label(format!(
1428 "已丢弃 {spilled} 条(环形 {})",
1429 crate::state::RING_CAP
1430 ));
1431 }
1432 });
1433 let lines = shared.logs_snapshot().0;
1434 egui::ScrollArea::vertical()
1435 .id_salt("log_scroll")
1436 .max_height(180.0)
1437 .stick_to_bottom(true)
1438 .show(ui, |ui| {
1439 for line in lines {
1440 ui.monospace(line);
1441 }
1442 });
1443}
1444
1445struct WinGeom {
1449 inner: [f32; 2],
1450 min_inner: [f32; 2],
1451 maximized: bool,
1452}
1453
1454fn show_independent_window(
1469 ctx: &egui::Context,
1470 id_salt: impl std::hash::Hash,
1471 title: &str,
1472 geom: &WinGeom,
1473 embedded: impl Fn(&egui::Context) -> bool,
1474 body: impl Fn(&mut egui::Ui) -> bool,
1475) -> bool {
1476 let closed = std::cell::Cell::new(false);
1477 let mut builder = egui::ViewportBuilder::default()
1478 .with_title(title)
1479 .with_inner_size(geom.inner)
1480 .with_min_inner_size(geom.min_inner)
1481 .with_minimize_button(true)
1482 .with_maximize_button(true)
1483 .with_close_button(true);
1484 if geom.maximized {
1485 builder = builder.with_maximized(true);
1486 }
1487 ctx.show_viewport_immediate(
1488 egui::ViewportId::from_hash_of(id_salt),
1489 builder,
1490 |ctx2, class| {
1491 if class == egui::ViewportClass::Embedded {
1492 closed.set(embedded(ctx2));
1493 return;
1494 }
1495 if ctx2.input(|i| i.viewport().close_requested()) {
1496 closed.set(true);
1497 return;
1498 }
1499 egui::CentralPanel::default().show(ctx2, |ui| {
1500 if body(ui) {
1501 closed.set(true);
1502 }
1503 });
1504 },
1505 );
1506 closed.get()
1507}
1508
1509fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1510 if shared.ops_window_open() {
1512 let closed = show_independent_window(
1513 ctx,
1514 "ops_win",
1515 "运维窗",
1516 &WinGeom {
1517 inner: [680.0, 420.0],
1518 min_inner: [420.0, 260.0],
1519 maximized: false,
1520 },
1521 |ctx2| {
1522 let mut open = shared.ops_window_open();
1523 egui::Window::new("运维窗")
1524 .id(egui::Id::new("ops_win"))
1525 .default_pos([80.0, 320.0])
1526 .default_size([640.0, 380.0])
1527 .open(&mut open)
1528 .show(ctx2, |ui| {
1529 render_ops_window(ui, shared);
1530 });
1531 shared.set_ops_window_open(open);
1532 !open
1533 },
1534 |ui| {
1535 render_ops_window(ui, shared);
1536 false
1537 },
1538 );
1539 if closed {
1540 shared.set_ops_window_open(false);
1541 }
1542 }
1543
1544 if shared.accounts_window_open() {
1547 let closed = show_independent_window(
1548 ctx,
1549 "account_win",
1550 "账户面板",
1551 &WinGeom {
1552 inner: [760.0, 520.0],
1553 min_inner: [520.0, 320.0],
1554 maximized: false,
1555 },
1556 |ctx2| {
1557 let mut open = shared.accounts_window_open();
1558 egui::Window::new("账户面板")
1559 .id(egui::Id::new("account_win"))
1560 .default_pos([640.0, 320.0])
1561 .default_size([720.0, 470.0])
1562 .open(&mut open)
1563 .show(ctx2, |ui| {
1564 render_accounts_window(ui, shared);
1565 });
1566 shared.set_accounts_window_open(open);
1567 !open
1568 },
1569 |ui| {
1570 render_accounts_window(ui, shared);
1571 false
1572 },
1573 );
1574 if closed {
1575 shared.set_accounts_window_open(false);
1576 }
1577 }
1578
1579 if shared.account_edit_open() {
1583 let closed = show_independent_window(
1584 ctx,
1585 "account_edit_win",
1586 "账户配置",
1587 &WinGeom {
1588 inner: [680.0, 340.0],
1589 min_inner: [480.0, 240.0],
1590 maximized: false,
1591 },
1592 |ctx2| {
1593 let mut open = shared.account_edit_open();
1594 let title = match shared.editing_account() {
1595 Some(gw) => format!("账户配置 — 编辑 {gw}"),
1596 None => "账户配置 — 添加".to_string(),
1597 };
1598 egui::Window::new(title)
1599 .id(egui::Id::new("account_edit_win"))
1600 .default_pos([700.0, 380.0])
1601 .default_size([660.0, 320.0])
1602 .open(&mut open)
1603 .show(ctx2, |ui| {
1604 render_account_edit_window(ui, shared);
1605 });
1606 if !open {
1607 shared.end_account_edit();
1608 }
1609 !open
1610 },
1611 |ui| {
1612 render_account_edit_window(ui, shared);
1613 false
1614 },
1615 );
1616 if closed && shared.account_edit_open() {
1617 shared.end_account_edit();
1618 }
1619 }
1620
1621 if shared.contracts_window_open() {
1623 let closed = show_independent_window(
1624 ctx,
1625 "contracts_win",
1626 "查询合约窗",
1627 &WinGeom {
1628 inner: [620.0, 460.0],
1629 min_inner: [420.0, 260.0],
1630 maximized: false,
1631 },
1632 |ctx2| {
1633 let mut open = shared.contracts_window_open();
1634 egui::Window::new("查询合约窗")
1635 .id(egui::Id::new("contracts_win"))
1636 .default_pos([420.0, 300.0])
1637 .default_size([560.0, 420.0])
1638 .open(&mut open)
1639 .show(ctx2, |ui| {
1640 render_contracts_window(ui, shared);
1641 });
1642 shared.set_contracts_window_open(open);
1643 !open
1644 },
1645 |ui| {
1646 render_contracts_window(ui, shared);
1647 false
1648 },
1649 );
1650 if closed {
1651 shared.set_contracts_window_open(false);
1652 }
1653 }
1654
1655 let mut closed: Vec<String> = Vec::new();
1659 for sym in shared.kline_symbols() {
1660 let is_closed = show_independent_window(
1661 ctx,
1662 format!("kline_win:{sym}"),
1663 &format!("K线 — {sym}"),
1664 &WinGeom {
1665 inner: [560.0, 360.0],
1666 min_inner: [360.0, 220.0],
1667 maximized: true,
1668 },
1669 |ctx2| {
1670 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1671 let resp = egui::Window::new(format!("K线 — {sym}"))
1672 .id(egui::Id::new(("kline_win", sym.clone())))
1673 .default_pos([300.0, 340.0])
1674 .default_size([540.0, 330.0])
1675 .show(ctx2, |ui| {
1676 let cell = shared
1677 .market()
1678 .cells()
1679 .into_iter()
1680 .find(|c| c.symbol == sym);
1681 render_kline_cell(ui, cell.as_ref());
1682 });
1683 esc || resp.is_none()
1684 },
1685 |ui| {
1686 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1687 return true;
1688 }
1689 let cell = shared
1690 .market()
1691 .cells()
1692 .into_iter()
1693 .find(|c| c.symbol == sym);
1694 render_kline_cell(ui, cell.as_ref());
1695 false
1696 },
1697 );
1698 if is_closed {
1699 closed.push(sym);
1700 }
1701 }
1702 for s in closed {
1703 shared.close_kline(&s);
1704 }
1705}
1706
1707fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1710 ui.label("插件");
1711 let snap = shared.snapshot();
1712 egui::Grid::new("plug_grid")
1713 .striped(true)
1714 .num_columns(6)
1715 .show(ui, |ui| {
1716 ui.strong("名称");
1717 ui.strong("状态");
1718 ui.strong("版本");
1719 ui.strong("依赖");
1720 ui.strong("统计");
1721 ui.strong("操作");
1722 ui.end_row();
1723 if snap.rows.is_empty() {
1724 ui.label("(快照空:等 eTimer 首拉)");
1725 ui.end_row();
1726 }
1727 for r in &snap.rows {
1728 if is_quarantined(&r.state) {
1729 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1730 ui.label(
1731 egui::RichText::new(state_text(r))
1732 .color(egui::Color32::RED)
1733 .strong(),
1734 );
1735 } else {
1736 ui.label(r.name.clone());
1737 ui.label(state_text(r));
1738 }
1739 ui.label(r.version.clone());
1740 ui.label(format!("deps:[{}]", r.deps.join(",")));
1741 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1742 ui.horizontal(|ui| {
1743 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1744 shared.push_cmd(UiCmd::Start {
1748 factory: r.name.clone(),
1749 name: String::new(),
1750 });
1751 }
1752 let armed = shared.pending_confirm();
1753 let armed_here = ui
1754 .small_button(remove_button_label(&r.name, armed.as_deref()))
1755 .clicked()
1756 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1757 if armed_here {
1758 shared.push_cmd(UiCmd::Remove {
1759 name: r.name.clone(),
1760 });
1761 }
1762 });
1763 ui.end_row();
1764 }
1765 });
1766
1767 if let Some(name) = shared.pending_confirm() {
1768 ui.horizontal(|ui| {
1769 ui.label(
1770 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1771 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1772 );
1773 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1774 shared.confirm_cancel();
1775 }
1776 });
1777 }
1778
1779 ui.label("服务");
1780 let flat: Vec<String> = snap
1781 .services
1782 .iter()
1783 .flat_map(|(n, ids)| {
1784 if ids.is_empty() {
1785 vec![format!("{n} 服务"), "-".to_string()]
1786 } else {
1787 ids.iter()
1788 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1789 .collect()
1790 }
1791 })
1792 .collect();
1793 if flat.is_empty() {
1794 ui.label("(无服务目录)");
1795 } else {
1796 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1798 .striped(true)
1799 .num_columns(6)
1800 .show(ui, |ui| {
1801 for r in 0..rows {
1802 for c in 0..3 {
1803 let i = c * rows * 2 + r * 2;
1804 if i < flat.len() {
1805 ui.label(flat[i].clone());
1806 ui.label(flat[i + 1].clone());
1807 } else {
1808 ui.label("");
1809 ui.label("");
1810 }
1811 }
1812 ui.end_row();
1813 }
1814 });
1815 }
1816
1817 ui.label("依赖");
1818 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1819 ui.label("(全部无依赖)");
1820 }
1821 for r in &snap.rows {
1822 if !r.deps.is_empty() {
1823 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1824 }
1825 }
1826}
1827
1828fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
1834 let accounts = shared.accounts();
1835 let locked = shared.accounts_locked();
1836 let md = shared.md_source();
1837 let snap = shared.trade_snap();
1838
1839 let row_label: std::collections::BTreeMap<String, String> = accounts
1843 .iter()
1844 .map(|a| {
1845 let d = a.display_name();
1846 let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
1847 (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
1848 })
1849 .collect();
1850
1851 ui.horizontal(|ui| {
1853 ui.label(
1854 egui::RichText::new(format!(
1855 "当前行情源 = {}",
1856 if md.is_empty() {
1857 "(未指定)".to_string()
1858 } else {
1859 md.clone()
1860 }
1861 ))
1862 .strong(),
1863 );
1864 ui.label(format!(
1865 "账户 {}/{}",
1866 accounts.len(),
1867 crate::state::MAX_ACCOUNTS
1868 ));
1869 if shared.can_add_account() {
1870 if ui.button(BTN_ACCOUNT_ADD).clicked() {
1871 shared.begin_account_edit(None);
1872 }
1873 } else {
1874 ui.label(
1875 egui::RichText::new(format!(
1876 "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
1877 crate::state::MAX_ACCOUNTS
1878 ))
1879 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1880 );
1881 }
1882 });
1883
1884 const ACCOUNT_HEADERS: [&str; 7] = [
1885 "网关(派生名)",
1886 "别名",
1887 "账号",
1888 "状态",
1889 "行情源",
1890 "收录(勾→实)",
1891 "操作",
1892 ];
1893 egui::Grid::new("account_grid")
1898 .striped(true)
1899 .num_columns(7)
1900 .show(ui, |ui| {
1901 for h in &ACCOUNT_HEADERS {
1902 ui.strong(*h);
1903 }
1904 ui.end_row();
1905 if accounts.is_empty() {
1906 ui.label("(无账户:上方『添加账户』开配置弹窗)");
1907 for _ in 0..6 {
1908 ui.label("");
1909 }
1910 ui.end_row();
1911 }
1912 for a in &accounts {
1913 let is_md = a.gateway == md;
1914 let name = row_label
1915 .get(&a.gateway)
1916 .cloned()
1917 .unwrap_or_else(|| a.gateway.clone());
1918 ui.label(a.gateway.clone());
1919 ui.label(if a.alias.trim().is_empty() {
1921 "—".to_string()
1922 } else {
1923 a.alias.clone()
1924 });
1925 ui.label(a.account.clone());
1926 match a.stage {
1932 crate::state::AccountStage::AssembleFailed => {
1933 ui.add(egui::Label::new(
1934 egui::RichText::new(format!(
1935 "装配失败:{}",
1936 a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
1937 ))
1938 .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
1939 )
1940 .wrap_mode(egui::TextWrapMode::Extend));
1941 }
1942 stage => {
1943 ui.add(
1944 egui::Label::new(egui::RichText::new(stage.label()))
1945 .wrap_mode(egui::TextWrapMode::Extend),
1946 );
1947 }
1948 }
1949 ui.horizontal(|ui| {
1951 if is_md {
1952 ui.label(egui::RichText::new("● 行情源").strong());
1953 }
1954 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
1955 ui.small_button(if is_md {
1956 "已是行情源"
1957 } else {
1958 "设为行情源"
1959 })
1960 });
1961 if resp.inner.clicked() {
1962 match shared.set_md_source(&a.gateway) {
1963 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
1964 Err(e) => shared.log_line(format!("[账户] {e}")),
1965 }
1966 }
1967 });
1968 ui.vertical(|ui| {
1971 ui.add(
1972 egui::Label::new(egui::RichText::new(format!(
1973 "勾:{}",
1974 classes_label(&a.form.product_classes())
1975 )))
1976 .wrap_mode(egui::TextWrapMode::Extend),
1977 );
1978 ui.add(
1979 egui::Label::new(egui::RichText::new(format!(
1980 "实:{}",
1981 if a.effective_classes.is_empty() {
1982 "—".to_string()
1983 } else {
1984 classes_label(&a.effective_classes)
1985 }
1986 )))
1987 .wrap_mode(egui::TextWrapMode::Extend),
1988 );
1989 });
1990 ui.horizontal(|ui| {
1992 let failed = a.stage == crate::state::AccountStage::AssembleFailed;
1993 let idle = a.stage == crate::state::AccountStage::NotAssembled;
1994 if failed || idle {
1995 let label = if failed {
1998 format!("重试 {name}")
1999 } else {
2000 format!("连接 {name}")
2001 };
2002 if ui.small_button(label).clicked() {
2003 push_connect(shared, &a.gateway, &a.form);
2004 }
2005 }
2006 if ui.small_button(format!("编辑 {name}")).clicked() {
2007 shared.begin_account_edit(Some(&a.gateway));
2010 }
2011 let open = snap
2012 .orders
2013 .iter()
2014 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2015 .count();
2016 let has_pos = snap
2017 .positions
2018 .iter()
2019 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2020 if open > 0 {
2021 ui.label(
2023 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2024 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2025 );
2026 if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2030 for o in snap
2031 .orders
2032 .iter()
2033 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2034 {
2035 shared.push_cmd(UiCmd::CancelOrder {
2036 orderid: o.orderid.clone(),
2037 gateway: a.gateway.clone(),
2038 });
2039 }
2040 shared.log_line(format!(
2041 "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2042 a.gateway, open
2043 ));
2044 }
2045 } else {
2046 let armed = shared.pending_confirm();
2049 let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2050 let fired = ui
2051 .small_button(remove_button_label(
2052 &name,
2053 if armed_here { Some(name.as_str()) } else { None },
2054 ))
2055 .clicked()
2056 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2057 if fired {
2058 shared.push_cmd(UiCmd::Remove {
2059 name: a.gateway.clone(),
2060 });
2061 shared.remove_account(&a.gateway);
2062 shared.end_account_edit_if_editing(&a.gateway);
2065 if has_pos {
2066 shared.log_line(format!(
2067 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2068 a.gateway
2069 ));
2070 }
2071 }
2072 }
2073 });
2074 ui.end_row();
2075 }
2076 });
2077
2078 if let Some(gw) = shared.pending_confirm() {
2079 let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2081 ui.horizontal(|ui| {
2082 ui.label(
2083 egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2084 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2085 );
2086 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2087 shared.confirm_cancel();
2088 }
2089 });
2090 }
2091
2092 ui.label(if locked {
2093 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2094 } else {
2095 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2096 });
2097 ui.label(
2099 egui::RichText::new(
2100 "状态列只说实例装配(svctl 在场位);登录成败 / 掉线 / 查询断供界面不可见 —— 开放项 ctp.status",
2101 )
2102 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2103 );
2104 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2106 if src.form.md_addr.trim().is_empty() {
2107 ui.label(
2108 egui::RichText::new(format!(
2109 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2110 src.gateway,
2111 row_label
2112 .get(&src.gateway)
2113 .cloned()
2114 .unwrap_or_else(|| src.account.clone())
2115 ))
2116 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2117 );
2118 }
2119 }
2120}
2121
2122fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2128 let editing: Option<crate::state::AccountRow> =
2129 shared.editing_account().and_then(|g| shared.account(&g));
2130 let editing_on = editing.is_some();
2131 let md = shared.md_source();
2132 let locked = shared.accounts_locked();
2133 let mut form: crate::state::CtpForm = shared.ctp_form();
2134 let mut alias = shared.edit_alias();
2135 if let Some(e) = shared.edit_error() {
2137 ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2138 }
2139 ui.horizontal(|ui| {
2145 ui.label("别名");
2146 if ui
2147 .add(
2148 egui::TextEdit::singleline(&mut alias)
2149 .hint_text("显示用,可空")
2150 .desired_width(120.0),
2151 )
2152 .changed()
2153 {
2154 shared.set_edit_alias(alias.clone());
2155 }
2156 ui.label("broker");
2157 ui.add_enabled_ui(!editing_on, |ui| {
2158 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2159 });
2160 ui.label("td 前置");
2161 ctp_edit(
2162 ui,
2163 shared,
2164 &mut form.td_addr,
2165 "tcp://host:40001",
2166 CtpField::TdAddr,
2167 );
2168 ui.label("td 用户");
2169 ui.add_enabled_ui(!editing_on, |ui| {
2170 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2171 });
2172 });
2173 if let Some(r) = &editing {
2174 ui.label(
2177 egui::RichText::new(format!(
2178 "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2179 r.gateway
2180 ))
2181 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2182 );
2183 }
2184 ui.horizontal(|ui| {
2185 ui.label("td 密码");
2186 ctp_edit(
2187 ui,
2188 shared,
2189 &mut form.td_password,
2190 "密码",
2191 CtpField::TdPassword,
2192 );
2193 ui.label("AppID");
2194 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2195 ui.label("AuthCode");
2196 ctp_edit(
2197 ui,
2198 shared,
2199 &mut form.td_auth_code,
2200 "授权码",
2201 CtpField::AuthCode,
2202 );
2203 });
2204 let md_editable = match &editing {
2207 None => true,
2208 Some(r) => !locked || r.gateway == md,
2209 };
2210 ui.horizontal(|ui| {
2211 ui.label("md 前置");
2212 ui.add_enabled_ui(md_editable, |ui| {
2213 ctp_edit(
2214 ui,
2215 shared,
2216 &mut form.md_addr,
2217 "tcp://host:40011",
2218 CtpField::MdAddr,
2219 );
2220 ui.label("md 用户");
2221 ctp_edit(
2222 ui,
2223 shared,
2224 &mut form.md_user,
2225 "空=td 回落",
2226 CtpField::MdUser,
2227 );
2228 ui.label("md 密码");
2229 ctp_edit(
2230 ui,
2231 shared,
2232 &mut form.md_password,
2233 "空=td 回落",
2234 CtpField::MdPassword,
2235 );
2236 });
2237 if !md_editable {
2238 ui.label(
2239 egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2240 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2241 );
2242 }
2243 });
2244 ui.horizontal(|ui| {
2247 ui.label("收录勾选:");
2248 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2249 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2250 if fut || opt {
2251 shared.set_ctp_form(form.clone());
2252 }
2253 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2254 });
2255 if let Some(r) = &editing {
2258 ui.horizontal(|ui| {
2259 ui.label("网关实际收录(只读回带):");
2260 ui.label(if r.effective_classes.is_empty() {
2261 "—(尚未装配或无回带)".to_string()
2262 } else {
2263 classes_label(&r.effective_classes)
2264 });
2265 ui.label("装配状态(只读):");
2266 ui.label(format!(
2267 "{}{}",
2268 r.stage.label(),
2269 r.fail_note
2270 .as_ref()
2271 .map(|n| format!(" —— {n}"))
2272 .unwrap_or_default()
2273 ));
2274 });
2275 }
2276 if shared.account_edit_dirty() {
2279 ui.label(
2280 egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2281 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2282 );
2283 }
2284 ui.horizontal(|ui| {
2285 if ui.button("保存").clicked() {
2286 match save_account_draft(shared, &form, &alias, false) {
2287 Ok(()) => shared.end_account_edit(),
2288 Err(e) => {
2289 shared.log_line(format!("[账户] 保存被拦:{e}"));
2290 shared.set_edit_error(Some(e));
2291 }
2292 }
2293 }
2294 if ui.button("保存并连接").clicked() {
2295 match save_account_draft(shared, &form, &alias, true) {
2296 Ok(()) => shared.end_account_edit(),
2297 Err(e) => {
2298 shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2299 shared.set_edit_error(Some(e));
2300 }
2301 }
2302 }
2303 if ui.button("取消").clicked() {
2304 shared.end_account_edit();
2305 }
2306 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2307 });
2308}
2309
2310fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2312 shared
2313 .accounts()
2314 .iter()
2315 .map(|a| a.gateway.clone())
2316 .collect()
2317}
2318
2319fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2327 if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2328 gateway.to_string()
2329 } else {
2330 format!("{gateway}(已摘除)")
2331 }
2332}
2333
2334fn classes_label(classes: &[String]) -> String {
2336 if classes.is_empty() {
2337 return "—".to_string();
2338 }
2339 classes
2340 .iter()
2341 .map(|c| match c.as_str() {
2342 "1" => "期货",
2343 "2" => "期权",
2344 other => other,
2345 })
2346 .collect::<Vec<_>>()
2347 .join("+")
2348}
2349
2350fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2355 shared.push_cmd(UiCmd::Connect {
2356 gateway: gateway.to_string(),
2357 form: form.clone(),
2358 });
2359 shared.note_connect_sent(gateway);
2360 shared.log_line(format!(
2361 "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2362 gateway
2363 ));
2364}
2365
2366fn save_account_draft(
2373 shared: &UiShared,
2374 form: &crate::state::CtpForm,
2375 alias: &str,
2376 connect: bool,
2377) -> Result<(), String> {
2378 form.validate()?;
2379 if let Some(gw) = shared.editing_account() {
2380 if shared.account(&gw).is_none() {
2382 return Err(format!(
2383 "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2384 ));
2385 }
2386 shared.update_account_form(&gw, form.clone());
2387 if !shared.set_account_alias(&gw, alias) {
2388 return Err(format!(
2389 "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2390 ));
2391 }
2392 shared.log_line(format!(
2393 "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2394 ));
2395 if connect {
2396 push_connect(shared, &gw, form);
2397 }
2398 return Ok(());
2399 }
2400 let mut row = crate::state::AccountRow::derive(form)?;
2401 row.alias = alias.trim().to_string();
2402 let gateway = row.gateway.clone();
2403 let account = row.account.clone();
2404 shared.add_account(row.clone())?;
2405 let md = shared.md_source();
2409 let mut f = row.form.clone();
2410 if md.is_empty() && !shared.accounts_locked() {
2411 match shared.set_md_source(&gateway) {
2412 Ok(()) => {
2413 if f.md_addr.trim().is_empty() {
2414 shared.log_line(format!(
2415 "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2416 ));
2417 } else {
2418 shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2419 }
2420 }
2421 Err(e) => {
2422 shared.log_line(format!("[账户] {e}"));
2423 f.md_addr.clear();
2424 f.md_user.clear();
2425 f.md_password.clear();
2426 }
2427 }
2428 } else {
2429 f.md_addr.clear();
2430 f.md_user.clear();
2431 f.md_password.clear();
2432 if md.is_empty() {
2433 shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2434 }
2435 }
2436 shared.update_account_form(&gateway, f.clone());
2437 shared.log_line(format!(
2439 "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2440 ));
2441 if connect {
2442 push_connect(shared, &gateway, &f);
2443 }
2444 Ok(())
2445}
2446
2447fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2451 let mut q = shared.contract_query();
2452 ui.horizontal(|ui| {
2453 ui.label("代码或交易所(留空 = 全部):");
2454 if ui
2455 .add(
2456 egui::TextEdit::singleline(&mut q)
2457 .hint_text("cu 或 SHFE")
2458 .desired_width(200.0),
2459 )
2460 .changed()
2461 {
2462 shared.set_contract_query(q.clone());
2463 }
2464 });
2465 let rows = shared.contracts(&q);
2466 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2469 if rows.is_empty() {
2470 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2472 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2473 });
2474 return;
2475 }
2476 let selected = shared.selected_contracts();
2477 ui.horizontal(|ui| {
2482 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2483 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2484 if selected.is_empty() {
2485 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2486 } else {
2487 let symbols: Vec<String> = selected
2489 .iter()
2490 .map(|k| {
2491 k.split_once('.')
2492 .map(|(_, s)| s.to_string())
2493 .unwrap_or_else(|| k.clone())
2494 })
2495 .collect();
2496 match subscribe_target(shared) {
2497 Ok(gw) => {
2498 shared.push_cmd(UiCmd::CtpSubscribe {
2499 gateway: gw.clone(),
2500 symbols,
2501 });
2502 shared.log_line(format!(
2503 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2504 selected.len(),
2505 gw
2506 ));
2507 }
2508 Err(e) => shared.log_line(format!("[订阅] {e}")),
2509 }
2510 }
2511 }
2512 });
2513 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2514 for c in &rows {
2515 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2516 let mut on = selected.contains(&key);
2517 let row_sel = shared.row_selected("contracts", &key);
2521 let mut cells = Vec::with_capacity(5);
2522 let ck = ui.checkbox(&mut on, key.clone());
2523 if ck.changed() {
2524 shared.toggle_contract_selection(&key);
2525 shared.select_row("contracts", &key);
2526 }
2527 cells.push(ck.rect);
2528 macro_rules! cell {
2529 ($base:expr, $col:expr) => {{
2530 let base = $base;
2531 let r = click_cell(ui, base.rect, ("crt", &key, $col));
2532 if r.clicked() {
2533 shared.select_row("contracts", &key);
2534 }
2535 cells.push(base.rect);
2536 }};
2537 }
2538 cell!(ui.label(c.exchange.as_str().to_string()), 1);
2539 cell!(ui.label(c.name.clone()), 2);
2540 cell!(ui.label(format!("{:.0}", c.size)), 3);
2541 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2542 paint_row_bg(ui, &cells, row_sel);
2543 ui.end_row();
2544 }
2545 });
2546}
2547
2548enum CtpField {
2550 Broker,
2551 TdAddr,
2552 TdUser,
2553 TdPassword,
2554 AppId,
2555 AuthCode,
2556 MdAddr,
2557 MdUser,
2558 MdPassword,
2559}
2560
2561fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2563 let is_secret = matches!(
2564 field,
2565 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2566 );
2567 let mut w = egui::TextEdit::singleline(val)
2568 .hint_text(hint)
2569 .desired_width(210.0);
2572 if is_secret {
2573 w = w.password(true);
2574 }
2575 if ui.add(w).changed() {
2576 let mut f = shared.ctp_form();
2577 match field {
2578 CtpField::Broker => f.broker_id = val.clone(),
2579 CtpField::TdAddr => f.td_addr = val.clone(),
2580 CtpField::TdUser => f.td_user = val.clone(),
2581 CtpField::TdPassword => f.td_password = val.clone(),
2582 CtpField::AppId => f.td_app_id = val.clone(),
2583 CtpField::AuthCode => f.td_auth_code = val.clone(),
2584 CtpField::MdAddr => f.md_addr = val.clone(),
2585 CtpField::MdUser => f.md_user = val.clone(),
2586 CtpField::MdPassword => f.md_password = val.clone(),
2587 }
2588 shared.set_ctp_form(f);
2589 }
2590}
2591
2592fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2596 let Some(c) = cell else {
2597 ui.label("(K线无格:等 dm 合成)");
2598 return;
2599 };
2600 if c.bars.is_empty() {
2601 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2602 return;
2603 }
2604 let mut data = egui_charts::model::bar::BarData::new();
2608 for b in &c.bars {
2609 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2610 .with_timezone(&beijing_tz())
2611 .with_timezone(&chrono::Utc);
2612 data.push(egui_charts::model::bar::Bar::new(
2613 dt,
2614 b.open_price,
2615 b.high_price,
2616 b.low_price,
2617 b.close_price,
2618 b.volume,
2619 ));
2620 }
2621 ui.label(format!(
2622 "K线 {}@{}s({} 根)",
2623 c.symbol,
2624 c.interval,
2625 c.bars.len()
2626 ));
2627 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2628 chart.set_symbol(&c.symbol);
2629 chart.set_timeframe_label(&format!("{}s", c.interval));
2630 chart.enable_tracking_mode();
2631 let _ = chart.show(ui);
2632}
2633
2634#[cfg(test)]
2635mod tests {
2636 use super::*;
2637
2638 #[test]
2642 fn fmt_dt_shows_beijing_wall_clock() {
2643 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2644 ek_data::time::civil_to_nanos(ek_data::time::Civil {
2645 year: 2026,
2646 month: 4,
2647 day: 16,
2648 hour: 1,
2649 minute: 30,
2650 second: 1,
2651 nanos: 0,
2652 }),
2653 );
2654 let beijing = expect.with_timezone(&beijing_tz());
2656 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2657 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2660 year: 2026,
2661 month: 4,
2662 day: 15,
2663 hour: 16,
2664 minute: 0,
2665 second: 30,
2666 nanos: 0,
2667 });
2668 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2669 assert_eq!(fmt_dt(None), "-");
2671 }
2672}