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