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(
1934 egui::Label::new(
1935 egui::RichText::new(format!(
1936 "装配失败:{}",
1937 a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
1938 ))
1939 .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
1940 )
1941 .wrap_mode(egui::TextWrapMode::Extend),
1942 );
1943 }
1944 _ => {
1945 let color = match a.login {
1946 crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
1947 crate::state::LoginState::LoginFailed
1948 | crate::state::LoginState::FrontDown => {
1949 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
1950 }
1951 crate::state::LoginState::QueryStarved => {
1952 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1953 }
1954 _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
1956 };
1957 ui.add(
1958 egui::Label::new(
1959 egui::RichText::new(a.status_label()).color(color),
1960 )
1961 .wrap_mode(egui::TextWrapMode::Extend),
1962 );
1963 }
1964 }
1965 ui.horizontal(|ui| {
1967 if is_md {
1968 ui.label(egui::RichText::new("● 行情源").strong());
1969 }
1970 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
1971 ui.small_button(if is_md {
1972 "已是行情源"
1973 } else {
1974 "设为行情源"
1975 })
1976 });
1977 if resp.inner.clicked() {
1978 match shared.set_md_source(&a.gateway) {
1979 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
1980 Err(e) => shared.log_line(format!("[账户] {e}")),
1981 }
1982 }
1983 });
1984 ui.vertical(|ui| {
1987 ui.add(
1988 egui::Label::new(egui::RichText::new(format!(
1989 "勾:{}",
1990 classes_label(&a.form.product_classes())
1991 )))
1992 .wrap_mode(egui::TextWrapMode::Extend),
1993 );
1994 ui.add(
1995 egui::Label::new(egui::RichText::new(format!(
1996 "实:{}",
1997 if a.effective_classes.is_empty() {
1998 "—".to_string()
1999 } else {
2000 classes_label(&a.effective_classes)
2001 }
2002 )))
2003 .wrap_mode(egui::TextWrapMode::Extend),
2004 );
2005 });
2006 ui.horizontal(|ui| {
2008 let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2009 let idle = a.stage == crate::state::AccountStage::NotAssembled;
2010 let stopped = a.login == crate::state::LoginState::Stopped;
2013 if failed || idle || stopped {
2014 let label = if failed {
2017 format!("重试 {name}")
2018 } else {
2019 format!("连接 {name}")
2020 };
2021 if ui.small_button(label).clicked() {
2022 push_connect(shared, &a.gateway, &a.form);
2023 }
2024 }
2025 let open = snap
2027 .orders
2028 .iter()
2029 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2030 .count();
2031 if a.stage == crate::state::AccountStage::Assembled
2038 && a.login != crate::state::LoginState::Stopped
2039 && open == 0
2040 && ui.small_button(format!("断开 {name}")).clicked()
2041 {
2042 shared.push_cmd(UiCmd::Disconnect {
2043 gateway: a.gateway.clone(),
2044 });
2045 shared.log_line(format!(
2046 "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2047 gateway = a.gateway
2048 ));
2049 }
2050 if ui.small_button(format!("编辑 {name}")).clicked() {
2051 shared.begin_account_edit(Some(&a.gateway));
2054 }
2055 let has_pos = snap
2056 .positions
2057 .iter()
2058 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2059 if open > 0 {
2060 ui.label(
2062 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2063 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2064 );
2065 if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2069 for o in snap
2070 .orders
2071 .iter()
2072 .filter(|o| o.gateway_name == a.gateway && o.is_active())
2073 {
2074 shared.push_cmd(UiCmd::CancelOrder {
2075 orderid: o.orderid.clone(),
2076 gateway: a.gateway.clone(),
2077 });
2078 }
2079 shared.log_line(format!(
2080 "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2081 a.gateway, open
2082 ));
2083 }
2084 } else {
2085 let armed = shared.pending_confirm();
2088 let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2089 let fired = ui
2090 .small_button(remove_button_label(
2091 &name,
2092 if armed_here { Some(name.as_str()) } else { None },
2093 ))
2094 .clicked()
2095 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2096 if fired {
2097 shared.push_cmd(UiCmd::Remove {
2098 name: a.gateway.clone(),
2099 });
2100 shared.remove_account(&a.gateway);
2101 shared.end_account_edit_if_editing(&a.gateway);
2104 if has_pos {
2105 shared.log_line(format!(
2106 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2107 a.gateway
2108 ));
2109 }
2110 }
2111 }
2112 });
2113 ui.end_row();
2114 }
2115 });
2116
2117 if let Some(gw) = shared.pending_confirm() {
2118 let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2120 ui.horizontal(|ui| {
2121 ui.label(
2122 egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2123 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2124 );
2125 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2126 shared.confirm_cancel();
2127 }
2128 });
2129 }
2130
2131 ui.label(if locked {
2132 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2133 } else {
2134 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2135 });
2136 ui.label(
2138 egui::RichText::new(format!(
2139 "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2140 crate::state::STATUS_POLL_TICKS
2141 ))
2142 .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2143 );
2144 for a in accounts
2153 .iter()
2154 .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2155 {
2156 let note = a.status_note.clone().unwrap_or_default();
2157 let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2158 let color = if alarm {
2159 egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2160 } else {
2161 egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
2162 };
2163 ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2164 }
2165 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2167 if src.form.md_addr.trim().is_empty() {
2168 ui.label(
2169 egui::RichText::new(format!(
2170 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2171 src.gateway,
2172 row_label
2173 .get(&src.gateway)
2174 .cloned()
2175 .unwrap_or_else(|| src.account.clone())
2176 ))
2177 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2178 );
2179 }
2180 }
2181}
2182
2183fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2189 let editing: Option<crate::state::AccountRow> =
2190 shared.editing_account().and_then(|g| shared.account(&g));
2191 let editing_on = editing.is_some();
2192 let md = shared.md_source();
2193 let locked = shared.accounts_locked();
2194 let mut form: crate::state::CtpForm = shared.ctp_form();
2195 let mut alias = shared.edit_alias();
2196 if let Some(e) = shared.edit_error() {
2198 ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2199 }
2200 ui.horizontal(|ui| {
2206 ui.label("别名");
2207 if ui
2208 .add(
2209 egui::TextEdit::singleline(&mut alias)
2210 .hint_text("显示用,可空")
2211 .desired_width(120.0),
2212 )
2213 .changed()
2214 {
2215 shared.set_edit_alias(alias.clone());
2216 }
2217 ui.label("broker");
2218 ui.add_enabled_ui(!editing_on, |ui| {
2219 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2220 });
2221 ui.label("td 前置");
2222 ctp_edit(
2223 ui,
2224 shared,
2225 &mut form.td_addr,
2226 "tcp://host:40001",
2227 CtpField::TdAddr,
2228 );
2229 ui.label("td 用户");
2230 ui.add_enabled_ui(!editing_on, |ui| {
2231 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2232 });
2233 });
2234 if let Some(r) = &editing {
2235 ui.label(
2238 egui::RichText::new(format!(
2239 "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2240 r.gateway
2241 ))
2242 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2243 );
2244 }
2245 ui.horizontal(|ui| {
2246 ui.label("td 密码");
2247 ctp_edit(
2248 ui,
2249 shared,
2250 &mut form.td_password,
2251 "密码",
2252 CtpField::TdPassword,
2253 );
2254 ui.label("AppID");
2255 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2256 ui.label("AuthCode");
2257 ctp_edit(
2258 ui,
2259 shared,
2260 &mut form.td_auth_code,
2261 "授权码",
2262 CtpField::AuthCode,
2263 );
2264 });
2265 let md_editable = match &editing {
2268 None => true,
2269 Some(r) => !locked || r.gateway == md,
2270 };
2271 ui.horizontal(|ui| {
2272 ui.label("md 前置");
2273 ui.add_enabled_ui(md_editable, |ui| {
2274 ctp_edit(
2275 ui,
2276 shared,
2277 &mut form.md_addr,
2278 "tcp://host:40011",
2279 CtpField::MdAddr,
2280 );
2281 ui.label("md 用户");
2282 ctp_edit(
2283 ui,
2284 shared,
2285 &mut form.md_user,
2286 "空=td 回落",
2287 CtpField::MdUser,
2288 );
2289 ui.label("md 密码");
2290 ctp_edit(
2291 ui,
2292 shared,
2293 &mut form.md_password,
2294 "空=td 回落",
2295 CtpField::MdPassword,
2296 );
2297 });
2298 if !md_editable {
2299 ui.label(
2300 egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2301 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2302 );
2303 }
2304 });
2305 ui.horizontal(|ui| {
2308 ui.label("收录勾选:");
2309 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2310 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2311 if fut || opt {
2312 shared.set_ctp_form(form.clone());
2313 }
2314 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2315 });
2316 if let Some(r) = &editing {
2319 ui.horizontal(|ui| {
2320 ui.label("网关实际收录(只读回带):");
2321 ui.label(if r.effective_classes.is_empty() {
2322 "—(尚未装配或无回带)".to_string()
2323 } else {
2324 classes_label(&r.effective_classes)
2325 });
2326 ui.label("装配/登录状态(只读):");
2327 ui.label(format!(
2328 "{}{}",
2329 r.status_label(),
2330 r.fail_note
2331 .as_ref()
2332 .map(|n| format!(" —— {n}"))
2333 .unwrap_or_default()
2334 ));
2335 });
2336 }
2337 if shared.account_edit_dirty() {
2340 ui.label(
2341 egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2342 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2343 );
2344 }
2345 ui.horizontal(|ui| {
2346 if ui.button("保存").clicked() {
2347 match save_account_draft(shared, &form, &alias, false) {
2348 Ok(()) => shared.end_account_edit(),
2349 Err(e) => {
2350 shared.log_line(format!("[账户] 保存被拦:{e}"));
2351 shared.set_edit_error(Some(e));
2352 }
2353 }
2354 }
2355 if ui.button("保存并连接").clicked() {
2356 match save_account_draft(shared, &form, &alias, true) {
2357 Ok(()) => shared.end_account_edit(),
2358 Err(e) => {
2359 shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2360 shared.set_edit_error(Some(e));
2361 }
2362 }
2363 }
2364 if ui.button("取消").clicked() {
2365 shared.end_account_edit();
2366 }
2367 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2368 });
2369}
2370
2371fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2373 shared
2374 .accounts()
2375 .iter()
2376 .map(|a| a.gateway.clone())
2377 .collect()
2378}
2379
2380fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2388 if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2389 gateway.to_string()
2390 } else {
2391 format!("{gateway}(已摘除)")
2392 }
2393}
2394
2395fn classes_label(classes: &[String]) -> String {
2397 if classes.is_empty() {
2398 return "—".to_string();
2399 }
2400 classes
2401 .iter()
2402 .map(|c| match c.as_str() {
2403 "1" => "期货",
2404 "2" => "期权",
2405 other => other,
2406 })
2407 .collect::<Vec<_>>()
2408 .join("+")
2409}
2410
2411fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2416 shared.push_cmd(UiCmd::Connect {
2417 gateway: gateway.to_string(),
2418 form: form.clone(),
2419 });
2420 shared.note_connect_sent(gateway);
2421 shared.log_line(format!(
2422 "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2423 gateway
2424 ));
2425}
2426
2427fn save_account_draft(
2434 shared: &UiShared,
2435 form: &crate::state::CtpForm,
2436 alias: &str,
2437 connect: bool,
2438) -> Result<(), String> {
2439 form.validate()?;
2440 if let Some(gw) = shared.editing_account() {
2441 if shared.account(&gw).is_none() {
2443 return Err(format!(
2444 "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2445 ));
2446 }
2447 shared.update_account_form(&gw, form.clone());
2448 if !shared.set_account_alias(&gw, alias) {
2449 return Err(format!(
2450 "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2451 ));
2452 }
2453 shared.log_line(format!(
2454 "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2455 ));
2456 if connect {
2457 push_connect(shared, &gw, form);
2458 }
2459 return Ok(());
2460 }
2461 let mut row = crate::state::AccountRow::derive(form)?;
2462 row.alias = alias.trim().to_string();
2463 let gateway = row.gateway.clone();
2464 let account = row.account.clone();
2465 shared.add_account(row.clone())?;
2466 let md = shared.md_source();
2470 let mut f = row.form.clone();
2471 if md.is_empty() && !shared.accounts_locked() {
2472 match shared.set_md_source(&gateway) {
2473 Ok(()) => {
2474 if f.md_addr.trim().is_empty() {
2475 shared.log_line(format!(
2476 "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2477 ));
2478 } else {
2479 shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2480 }
2481 }
2482 Err(e) => {
2483 shared.log_line(format!("[账户] {e}"));
2484 f.md_addr.clear();
2485 f.md_user.clear();
2486 f.md_password.clear();
2487 }
2488 }
2489 } else {
2490 f.md_addr.clear();
2491 f.md_user.clear();
2492 f.md_password.clear();
2493 if md.is_empty() {
2494 shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2495 }
2496 }
2497 shared.update_account_form(&gateway, f.clone());
2498 shared.log_line(format!(
2500 "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2501 ));
2502 if connect {
2503 push_connect(shared, &gateway, &f);
2504 }
2505 Ok(())
2506}
2507
2508fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2512 let mut q = shared.contract_query();
2513 ui.horizontal(|ui| {
2514 ui.label("代码或交易所(留空 = 全部):");
2515 if ui
2516 .add(
2517 egui::TextEdit::singleline(&mut q)
2518 .hint_text("cu 或 SHFE")
2519 .desired_width(200.0),
2520 )
2521 .changed()
2522 {
2523 shared.set_contract_query(q.clone());
2524 }
2525 });
2526 let rows = shared.contracts(&q);
2527 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2530 if rows.is_empty() {
2531 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2533 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2534 });
2535 return;
2536 }
2537 let selected = shared.selected_contracts();
2538 ui.horizontal(|ui| {
2543 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2544 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2545 if selected.is_empty() {
2546 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2547 } else {
2548 let symbols: Vec<String> = selected
2550 .iter()
2551 .map(|k| {
2552 k.split_once('.')
2553 .map(|(_, s)| s.to_string())
2554 .unwrap_or_else(|| k.clone())
2555 })
2556 .collect();
2557 match subscribe_target(shared) {
2558 Ok(gw) => {
2559 shared.push_cmd(UiCmd::CtpSubscribe {
2560 gateway: gw.clone(),
2561 symbols,
2562 });
2563 shared.log_line(format!(
2564 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2565 selected.len(),
2566 gw
2567 ));
2568 }
2569 Err(e) => shared.log_line(format!("[订阅] {e}")),
2570 }
2571 }
2572 }
2573 });
2574 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2575 for c in &rows {
2576 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2577 let mut on = selected.contains(&key);
2578 let row_sel = shared.row_selected("contracts", &key);
2582 let mut cells = Vec::with_capacity(5);
2583 let ck = ui.checkbox(&mut on, key.clone());
2584 if ck.changed() {
2585 shared.toggle_contract_selection(&key);
2586 shared.select_row("contracts", &key);
2587 }
2588 cells.push(ck.rect);
2589 macro_rules! cell {
2590 ($base:expr, $col:expr) => {{
2591 let base = $base;
2592 let r = click_cell(ui, base.rect, ("crt", &key, $col));
2593 if r.clicked() {
2594 shared.select_row("contracts", &key);
2595 }
2596 cells.push(base.rect);
2597 }};
2598 }
2599 cell!(ui.label(c.exchange.as_str().to_string()), 1);
2600 cell!(ui.label(c.name.clone()), 2);
2601 cell!(ui.label(format!("{:.0}", c.size)), 3);
2602 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2603 paint_row_bg(ui, &cells, row_sel);
2604 ui.end_row();
2605 }
2606 });
2607}
2608
2609enum CtpField {
2611 Broker,
2612 TdAddr,
2613 TdUser,
2614 TdPassword,
2615 AppId,
2616 AuthCode,
2617 MdAddr,
2618 MdUser,
2619 MdPassword,
2620}
2621
2622fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2624 let is_secret = matches!(
2625 field,
2626 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2627 );
2628 let mut w = egui::TextEdit::singleline(val)
2629 .hint_text(hint)
2630 .desired_width(210.0);
2633 if is_secret {
2634 w = w.password(true);
2635 }
2636 if ui.add(w).changed() {
2637 let mut f = shared.ctp_form();
2638 match field {
2639 CtpField::Broker => f.broker_id = val.clone(),
2640 CtpField::TdAddr => f.td_addr = val.clone(),
2641 CtpField::TdUser => f.td_user = val.clone(),
2642 CtpField::TdPassword => f.td_password = val.clone(),
2643 CtpField::AppId => f.td_app_id = val.clone(),
2644 CtpField::AuthCode => f.td_auth_code = val.clone(),
2645 CtpField::MdAddr => f.md_addr = val.clone(),
2646 CtpField::MdUser => f.md_user = val.clone(),
2647 CtpField::MdPassword => f.md_password = val.clone(),
2648 }
2649 shared.set_ctp_form(f);
2650 }
2651}
2652
2653fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2657 let Some(c) = cell else {
2658 ui.label("(K线无格:等 dm 合成)");
2659 return;
2660 };
2661 if c.bars.is_empty() {
2662 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2663 return;
2664 }
2665 let mut data = egui_charts::model::bar::BarData::new();
2669 for b in &c.bars {
2670 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2671 .with_timezone(&beijing_tz())
2672 .with_timezone(&chrono::Utc);
2673 data.push(egui_charts::model::bar::Bar::new(
2674 dt,
2675 b.open_price,
2676 b.high_price,
2677 b.low_price,
2678 b.close_price,
2679 b.volume,
2680 ));
2681 }
2682 ui.label(format!(
2683 "K线 {}@{}s({} 根)",
2684 c.symbol,
2685 c.interval,
2686 c.bars.len()
2687 ));
2688 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2689 chart.set_symbol(&c.symbol);
2690 chart.set_timeframe_label(&format!("{}s", c.interval));
2691 chart.enable_tracking_mode();
2692 let _ = chart.show(ui);
2693}
2694
2695#[cfg(test)]
2696mod tests {
2697 use super::*;
2698
2699 #[test]
2703 fn fmt_dt_shows_beijing_wall_clock() {
2704 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2705 ek_data::time::civil_to_nanos(ek_data::time::Civil {
2706 year: 2026,
2707 month: 4,
2708 day: 16,
2709 hour: 1,
2710 minute: 30,
2711 second: 1,
2712 nanos: 0,
2713 }),
2714 );
2715 let beijing = expect.with_timezone(&beijing_tz());
2717 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2718 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2721 year: 2026,
2722 month: 4,
2723 day: 15,
2724 hour: 16,
2725 minute: 0,
2726 second: 30,
2727 nanos: 0,
2728 });
2729 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2730 assert_eq!(fmt_dt(None), "-");
2732 }
2733}