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 for g in shared.accounts().iter().map(|a| a.gateway.clone()).chain(
665 ts.orders
666 .iter()
667 .map(|o| o.gateway_name.clone())
668 .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
669 .chain(ts.accounts.iter().map(|a| a.gateway_name.clone())),
670 ) {
671 if !g.is_empty() && !gw_opts.contains(&g) {
672 gw_opts.push(g);
673 }
674 }
675 if !gw_opts.contains(&f.gateway) && !f.gateway.is_empty() {
676 gw_opts.insert(0, f.gateway.clone());
677 }
678 if f.gateway.is_empty() {
681 if let Some(def) = shared.default_gateway() {
682 f.gateway = def;
683 }
684 }
685 let no_accounts = gw_opts.is_empty();
686
687 egui::Grid::new("order_form_grid")
690 .num_columns(2)
691 .show(ui, |ui| {
692 ui.label("交易所");
693 egui::ComboBox::from_id_salt("of_exchange")
694 .selected_text(f.exchange.clone())
695 .show_ui(ui, |ui| {
696 for e in &ex_opts {
697 ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
698 }
699 });
700 ui.end_row();
701 ui.label("代码");
702 let sym_resp = ui.add(
703 egui::TextEdit::singleline(&mut f.symbol)
704 .hint_text("cu2709")
705 .desired_width(150.0),
706 );
707 if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
712 let s = f.symbol.trim().to_string();
713 let bare = match s.split_once('.') {
716 Some((_, b)) => b.trim().to_string(),
717 None => s,
718 };
719 if !bare.is_empty() {
720 f.symbol = bare.clone();
721 match subscribe_target(shared) {
724 Ok(gw) => {
725 shared.push_cmd(UiCmd::CtpSubscribe {
726 gateway: gw.clone(),
727 symbols: vec![bare.clone()],
728 });
729 shared
730 .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
731 }
732 Err(e) => shared.log_line(format!("[订阅] {e}")),
733 }
734 }
735 }
736 ui.end_row();
737 ui.label("名称");
738 ui.label(
739 tick_of(shared, &f.exchange, &f.symbol)
740 .map(|t| {
741 if t.name.is_empty() {
742 "-".to_string()
743 } else {
744 t.name
745 }
746 })
747 .unwrap_or_else(|| "-".to_string()),
748 );
749 ui.end_row();
750 ui.label("方向");
751 egui::ComboBox::from_id_salt("of_direction")
752 .selected_text(f.direction.as_str())
753 .show_ui(ui, |ui| {
754 for d in [Direction::LONG, Direction::SHORT] {
755 ui.selectable_value(&mut f.direction, d, d.as_str());
756 }
757 });
758 ui.end_row();
759 ui.label("开平");
760 ui.horizontal(|ui| {
761 egui::ComboBox::from_id_salt("of_offset")
762 .selected_text(f.offset.as_str())
763 .show_ui(ui, |ui| {
764 for o in [
765 Offset::OPEN,
766 Offset::CLOSE,
767 Offset::CLOSETODAY,
768 Offset::CLOSEYESTERDAY,
769 ] {
770 ui.selectable_value(&mut f.offset, o, o.as_str());
771 }
772 });
773 ui.checkbox(&mut f.auto_split, "自动开平");
775 });
776 ui.end_row();
777 ui.label("类型");
778 egui::ComboBox::from_id_salt("of_order_type")
779 .selected_text(f.order_type.as_str())
780 .show_ui(ui, |ui| {
781 for ot in [OrderType::LIMIT, OrderType::MARKET] {
782 ui.selectable_value(&mut f.order_type, ot, ot.as_str());
783 }
784 });
785 ui.end_row();
786 ui.label("价格");
787 ui.horizontal(|ui| {
788 ui.add(
789 egui::TextEdit::singleline(&mut f.price)
790 .hint_text("107660")
791 .desired_width(90.0),
792 );
793 ui.checkbox(&mut f.follow_price, "随价");
794 });
795 ui.end_row();
796 ui.label("数量");
797 ui.add(
798 egui::TextEdit::singleline(&mut f.volume)
799 .hint_text("1")
800 .desired_width(90.0),
801 );
802 ui.end_row();
803 ui.label("接口");
804 egui::ComboBox::from_id_salt("of_gateway")
805 .selected_text(if f.gateway.is_empty() {
806 "(未选账户)".to_string()
807 } else {
808 f.gateway.clone()
809 })
810 .show_ui(ui, |ui| {
811 for g in &gw_opts {
812 ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
813 }
814 });
815 ui.end_row();
816 ui.label("操作");
817 ui.horizontal(|ui| {
818 let send = ui
820 .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
821 .inner
822 .clicked();
823 if send {
824 match f.to_request() {
825 Ok(req) => {
826 let fallback = shared.default_gateway();
827 match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
828 Ok(gateway) => {
829 shared.push_cmd(UiCmd::SendOrder {
830 req,
831 gateway,
832 mode: if f.auto_split {
833 ek_oms::LockMode::Auto
834 } else {
835 ek_oms::LockMode::None
836 },
837 });
838 shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
839 }
840 Err(e) => {
841 shared.log_line(format!("[下单] {e}"));
842 err = Some(e);
843 }
844 }
845 }
846 Err(e) => {
847 shared.log_line(format!("[下单] {e}"));
848 err = Some(e);
849 }
850 }
851 }
852 if ui.button(BTN_CANCEL_ALL).clicked() {
853 let mut n = 0usize;
856 for o in ts.orders.iter().filter(|o| order_active(o.status)) {
857 let gw = if !o.gateway_name.is_empty() {
859 o.gateway_name.clone()
860 } else {
861 shared.default_gateway().unwrap_or_default()
862 };
863 if gw.is_empty() {
864 continue;
865 }
866 shared.push_cmd(UiCmd::CancelOrder {
867 orderid: o.orderid.clone(),
868 gateway: gw,
869 });
870 n += 1;
871 }
872 shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
873 }
874 });
875 ui.end_row();
876 ui.label("代码回车 = 订阅");
877 ui.label("");
878 ui.end_row();
879 });
880 if let Some(e) = err {
881 ui.label(egui::RichText::new(e).color(egui::Color32::RED));
882 }
883 if no_accounts {
884 ui.label(
886 egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
887 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
888 );
889 }
890 if f != before {
891 shared.set_order_form(f.clone());
892 }
893}
894
895fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
899 let f = shared.order_form();
900 match tick_of(shared, &f.exchange, &f.symbol) {
901 Some(t) => {
902 for i in (1..=5).rev() {
903 let (p, v) = match i {
904 1 => (t.ask_price_1, t.ask_volume_1),
905 2 => (t.ask_price_2, t.ask_volume_2),
906 3 => (t.ask_price_3, t.ask_volume_3),
907 4 => (t.ask_price_4, t.ask_volume_4),
908 _ => (t.ask_price_5, t.ask_volume_5),
909 };
910 ui.label(
911 egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
912 .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
913 );
914 }
915 for i in 1..=5 {
916 let (p, v) = match i {
917 1 => (t.bid_price_1, t.bid_volume_1),
918 2 => (t.bid_price_2, t.bid_volume_2),
919 3 => (t.bid_price_3, t.bid_volume_3),
920 4 => (t.bid_price_4, t.bid_volume_4),
921 _ => (t.bid_price_5, t.bid_volume_5),
922 };
923 ui.label(
924 egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
925 .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
926 );
927 }
928 }
929 None => {
930 ui.label("(无 tick)");
931 }
932 }
933}
934
935fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
938 let tab = shared.topleft_tab();
939 ui.horizontal(|ui| {
940 if ui
941 .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
942 .clicked()
943 {
944 shared.set_topleft_tab(TopLeftTab::Orders);
945 }
946 if ui
947 .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
948 .clicked()
949 {
950 shared.set_topleft_tab(TopLeftTab::Active);
951 }
952 });
953 render_orders_table(ui, shared, tab == TopLeftTab::Active);
954}
955
956fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
960 let ts = shared.trade_snap();
961 let rows: Vec<_> = ts
964 .orders
965 .iter()
966 .filter(|o| !active_only || order_active(o.status))
967 .collect();
968 const ORDER_HEADERS: [&str; 17] = [
970 "代码",
971 "名称",
972 "交易所",
973 "类型",
974 "方向",
975 "开平",
976 "价格",
977 "总量",
978 "已成",
979 "成交均价",
980 "状态",
981 "状态描述",
982 "时间",
983 "委托号",
984 "来源",
985 "接口",
986 "操作",
987 ];
988 if rows.is_empty() {
989 pinned_table(
992 ui,
993 if active_only {
994 "active_grid"
995 } else {
996 "order_grid"
997 },
998 17,
999 &ORDER_HEADERS,
1000 |ui| {
1001 ui.label(if active_only {
1002 "(无未成交)"
1003 } else {
1004 "(无委托:等网关 send_order)"
1005 });
1006 },
1007 );
1008 return;
1009 }
1010 pinned_table(
1011 ui,
1012 if active_only {
1013 "active_grid"
1014 } else {
1015 "order_grid"
1016 },
1017 17,
1018 &ORDER_HEADERS,
1019 |ui| {
1020 for o in &rows {
1021 let key = format!("{}.{}", o.gateway_name, o.orderid);
1025 let row_sel = shared.row_selected("orders", &key);
1026 let mut cells = Vec::with_capacity(17);
1027 let mut dbl = false;
1028 let sel = ui.selectable_label(row_sel, o.symbol.clone());
1029 if sel.clicked() {
1030 shared.select_row("orders", &key);
1031 }
1032 dbl |= sel.double_clicked();
1033 cells.push(sel.rect);
1034 macro_rules! cell {
1035 ($base:expr, $col:expr) => {{
1036 let base = $base;
1037 let r = click_cell(ui, base.rect, ("ord", &key, $col));
1038 if r.clicked() {
1039 shared.select_row("orders", &key);
1040 }
1041 dbl |= r.double_clicked();
1042 cells.push(base.rect);
1043 }};
1044 }
1045 cell!(
1046 ui.label(if o.name.is_empty() {
1047 "-".to_string()
1048 } else {
1049 o.name.clone()
1050 }),
1051 1
1052 );
1053 cell!(ui.label(o.exchange.as_str().to_string()), 2);
1054 cell!(ui.label(o.order_type.as_str().to_string()), 3);
1055 cell!(
1056 ui.label(
1057 egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1058 if o.direction == Some(Direction::SHORT) {
1059 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1060 } else {
1061 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1062 },
1063 ),
1064 ),
1065 4
1066 );
1067 cell!(ui.label(o.offset.as_str().to_string()), 5);
1068 cell!(ui.label(format!("{:.1}", o.price)), 6);
1069 cell!(ui.label(format!("{:.0}", o.volume)), 7);
1070 cell!(ui.label(format!("{:.0}", o.traded)), 8);
1071 cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1072 cell!(
1073 ui.label(egui::RichText::new(o.status.as_str()).color(
1074 if o.status == ek_data::constant::Status::REJECTED {
1075 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1076 } else if o.status == ek_data::constant::Status::ALLTRADED {
1077 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1078 } else {
1079 egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1080 },
1081 )),
1082 10
1083 );
1084 cell!(ui.label(o.status_msg.clone()), 11);
1085 cell!(ui.label(fmt_dt(o.datetime)), 12);
1086 cell!(ui.label(o.orderid.clone()), 13);
1087 cell!(
1088 ui.label(if o.reference.is_empty() {
1089 "-".to_string()
1090 } else {
1091 o.reference.clone()
1092 }),
1093 14
1094 );
1095 cell!(ui.label(o.gateway_name.clone()), 15);
1096 if dbl && order_active(o.status) {
1097 let gw = if !o.gateway_name.is_empty() {
1099 o.gateway_name.clone()
1100 } else {
1101 shared.default_gateway().unwrap_or_default()
1102 };
1103 if !gw.is_empty() {
1104 shared.push_cmd(UiCmd::CancelOrder {
1105 orderid: o.orderid.clone(),
1106 gateway: gw,
1107 });
1108 }
1109 }
1110 let hr = ui.horizontal(|ui| {
1111 if order_active(o.status)
1112 && ui
1113 .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1114 .clicked()
1115 {
1116 let gw = if !o.gateway_name.is_empty() {
1119 o.gateway_name.clone()
1120 } else {
1121 shared.default_gateway().unwrap_or_default()
1122 };
1123 if gw.is_empty() {
1124 shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1125 } else {
1126 shared.push_cmd(UiCmd::CancelOrder {
1127 orderid: o.orderid.clone(),
1128 gateway: gw,
1129 });
1130 }
1131 }
1132 });
1133 cells.push(hr.response.rect);
1134 paint_row_bg(ui, &cells, row_sel);
1135 ui.end_row();
1136 }
1137 },
1138 );
1139}
1140
1141fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1143 ui.label("成交");
1144 let ts = shared.trade_snap();
1145 const TRADE_HEADERS: [&str; 12] = [
1147 "成交号",
1148 "代码",
1149 "名称",
1150 "交易所",
1151 "方向",
1152 "开平",
1153 "价格",
1154 "数量",
1155 "成交额",
1156 "时间",
1157 "委托号",
1158 "接口",
1159 ];
1160 if ts.trades.is_empty() {
1161 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1163 ui.label("(无成交)");
1164 });
1165 return;
1166 }
1167 pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1168 for t in &ts.trades {
1169 let key = format!("{}.{}", t.gateway_name, t.tradeid);
1172 let row_sel = shared.row_selected("trades", &key);
1173 let mut cells = Vec::with_capacity(12);
1174 let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1175 if sel.clicked() {
1176 shared.select_row("trades", &key);
1177 }
1178 cells.push(sel.rect);
1179 macro_rules! cell {
1180 ($base:expr, $col:expr) => {{
1181 let base = $base;
1182 let r = click_cell(ui, base.rect, ("trd", &key, $col));
1183 if r.clicked() {
1184 shared.select_row("trades", &key);
1185 }
1186 cells.push(base.rect);
1187 }};
1188 }
1189 cell!(ui.label(t.symbol.clone()), 1);
1190 cell!(
1191 ui.label(if t.name.is_empty() {
1192 "-".to_string()
1193 } else {
1194 t.name.clone()
1195 }),
1196 2
1197 );
1198 cell!(ui.label(t.exchange.as_str().to_string()), 3);
1199 cell!(
1200 ui.label(
1201 egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1202 if t.direction == Some(Direction::SHORT) {
1203 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1204 } else {
1205 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1206 },
1207 ),
1208 ),
1209 4
1210 );
1211 cell!(ui.label(t.offset.as_str().to_string()), 5);
1212 cell!(ui.label(format!("{:.1}", t.price)), 6);
1213 cell!(ui.label(format!("{:.0}", t.volume)), 7);
1214 cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1215 cell!(ui.label(fmt_dt(t.datetime)), 9);
1216 cell!(ui.label(t.orderid.clone()), 10);
1217 cell!(ui.label(t.gateway_name.clone()), 11);
1218 paint_row_bg(ui, &cells, row_sel);
1219 ui.end_row();
1220 }
1221 });
1222}
1223
1224fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1227 ui.label("持仓");
1228 let ts = shared.trade_snap();
1229 const POS_HEADERS: [&str; 16] = [
1231 "代码",
1232 "名称",
1233 "交易所",
1234 "方向",
1235 "数量",
1236 "昨仓",
1237 "可用",
1238 "冻结",
1239 "在途",
1240 "市值",
1241 "成本",
1242 "均价",
1243 "最新价",
1244 "盈亏",
1245 "盈亏比",
1246 "接口",
1247 ];
1248 if ts.positions.is_empty() {
1249 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1251 ui.label("(无持仓)");
1252 });
1253 return;
1254 }
1255 pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1256 for p in &ts.positions {
1257 let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1261 let row_sel = shared.row_selected("positions", &key);
1262 let mut cells = Vec::with_capacity(16);
1263 let mut dbl = false;
1264 let sel = ui.selectable_label(row_sel, p.symbol.clone());
1265 if sel.clicked() {
1266 shared.select_row("positions", &key);
1267 }
1268 dbl |= sel.double_clicked();
1269 cells.push(sel.rect);
1270 macro_rules! cell {
1271 ($base:expr, $col:expr) => {{
1272 let base = $base;
1273 let r = click_cell(ui, base.rect, ("pos", &key, $col));
1274 if r.clicked() {
1275 shared.select_row("positions", &key);
1276 }
1277 dbl |= r.double_clicked();
1278 cells.push(base.rect);
1279 }};
1280 }
1281 cell!(
1282 ui.label(if p.name.is_empty() {
1283 "-".to_string()
1284 } else {
1285 p.name.clone()
1286 }),
1287 1
1288 );
1289 cell!(ui.label(p.exchange.as_str().to_string()), 2);
1290 cell!(
1291 ui.label(egui::RichText::new(p.direction.as_str()).color(
1292 if p.direction == ek_data::constant::Direction::SHORT {
1293 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1294 } else {
1295 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1296 },
1297 )),
1298 3
1299 );
1300 cell!(ui.label(format!("{:.0}", p.volume)), 4);
1301 cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1302 cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1303 cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1304 cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1305 cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1306 cell!(ui.label(format!("{:.2}", p.price)), 10);
1307 cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1308 cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1309 cell!(
1310 ui.label(
1311 egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1312 egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1313 } else {
1314 egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1315 }),
1316 ),
1317 13
1318 );
1319 cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1320 cell!(ui.label(p.gateway_name.clone()), 15);
1321 if dbl {
1322 let mut f = shared.order_form();
1323 f.symbol = p.symbol.clone();
1324 f.exchange = p.exchange.as_str().to_string();
1325 f.direction = p.direction;
1326 if !p.gateway_name.is_empty() {
1328 f.gateway = p.gateway_name.clone();
1329 }
1330 shared.set_order_form(f);
1331 shared.log_line(format!(
1332 "[ops] 持仓带入下单面板 {}(接口 {})",
1333 p.symbol, p.gateway_name
1334 ));
1335 }
1336 paint_row_bg(ui, &cells, row_sel);
1337 ui.end_row();
1338 }
1339 });
1340}
1341
1342fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1343 let tab = shared.bottomright_tab();
1344 ui.horizontal(|ui| {
1345 if ui
1346 .selectable_label(
1347 tab == BottomRightTab::Account,
1348 BottomRightTab::Account.label(),
1349 )
1350 .clicked()
1351 {
1352 shared.set_bottomright_tab(BottomRightTab::Account);
1353 }
1354 if ui
1355 .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1356 .clicked()
1357 {
1358 shared.set_bottomright_tab(BottomRightTab::Logs);
1359 }
1360 });
1361 match tab {
1362 BottomRightTab::Account => {
1363 let ts = shared.trade_snap();
1364 if ts.accounts.is_empty() {
1365 ui.label("(无资金:等网关 eAccount.)");
1366 return;
1367 }
1368 egui::Grid::new("acct_grid")
1369 .striped(true)
1370 .num_columns(5)
1371 .show(ui, |ui| {
1372 for h in ["账号", "接口", "余额", "可用", "冻结"] {
1373 ui.strong(h);
1374 }
1375 ui.end_row();
1376 for a in &ts.accounts {
1377 ui.label(a.accountid.clone());
1378 ui.label(a.gateway_name.clone());
1379 ui.label(format!("{:.2}", a.balance));
1380 ui.label(format!("{:.2}", a.available()));
1381 ui.label(format!("{:.2}", a.frozen));
1382 ui.end_row();
1383 }
1384 });
1385 }
1386 BottomRightTab::Logs => {
1387 render_logs(ui, shared);
1388 }
1389 }
1390}
1391
1392fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1393 ui.horizontal(|ui| {
1394 if ui.button(BTN_CLEAR_LOGS).clicked() {
1395 shared.clear_logs(); }
1397 let (_, spilled) = shared.logs_snapshot();
1398 if spilled > 0 {
1399 ui.label(format!(
1400 "已丢弃 {spilled} 条(环形 {})",
1401 crate::state::RING_CAP
1402 ));
1403 }
1404 });
1405 let lines = shared.logs_snapshot().0;
1406 egui::ScrollArea::vertical()
1407 .id_salt("log_scroll")
1408 .max_height(180.0)
1409 .stick_to_bottom(true)
1410 .show(ui, |ui| {
1411 for line in lines {
1412 ui.monospace(line);
1413 }
1414 });
1415}
1416
1417struct WinGeom {
1421 inner: [f32; 2],
1422 min_inner: [f32; 2],
1423 maximized: bool,
1424}
1425
1426fn show_independent_window(
1441 ctx: &egui::Context,
1442 id_salt: impl std::hash::Hash,
1443 title: &str,
1444 geom: &WinGeom,
1445 embedded: impl Fn(&egui::Context) -> bool,
1446 body: impl Fn(&mut egui::Ui) -> bool,
1447) -> bool {
1448 let closed = std::cell::Cell::new(false);
1449 let mut builder = egui::ViewportBuilder::default()
1450 .with_title(title)
1451 .with_inner_size(geom.inner)
1452 .with_min_inner_size(geom.min_inner)
1453 .with_minimize_button(true)
1454 .with_maximize_button(true)
1455 .with_close_button(true);
1456 if geom.maximized {
1457 builder = builder.with_maximized(true);
1458 }
1459 ctx.show_viewport_immediate(
1460 egui::ViewportId::from_hash_of(id_salt),
1461 builder,
1462 |ctx2, class| {
1463 if class == egui::ViewportClass::Embedded {
1464 closed.set(embedded(ctx2));
1465 return;
1466 }
1467 if ctx2.input(|i| i.viewport().close_requested()) {
1468 closed.set(true);
1469 return;
1470 }
1471 egui::CentralPanel::default().show(ctx2, |ui| {
1472 if body(ui) {
1473 closed.set(true);
1474 }
1475 });
1476 },
1477 );
1478 closed.get()
1479}
1480
1481fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1482 if shared.ops_window_open() {
1484 let closed = show_independent_window(
1485 ctx,
1486 "ops_win",
1487 "运维窗",
1488 &WinGeom {
1489 inner: [680.0, 420.0],
1490 min_inner: [420.0, 260.0],
1491 maximized: false,
1492 },
1493 |ctx2| {
1494 let mut open = shared.ops_window_open();
1495 egui::Window::new("运维窗")
1496 .id(egui::Id::new("ops_win"))
1497 .default_pos([80.0, 320.0])
1498 .default_size([640.0, 380.0])
1499 .open(&mut open)
1500 .show(ctx2, |ui| {
1501 render_ops_window(ui, shared);
1502 });
1503 shared.set_ops_window_open(open);
1504 !open
1505 },
1506 |ui| {
1507 render_ops_window(ui, shared);
1508 false
1509 },
1510 );
1511 if closed {
1512 shared.set_ops_window_open(false);
1513 }
1514 }
1515
1516 if shared.accounts_window_open() {
1519 let closed = show_independent_window(
1520 ctx,
1521 "account_win",
1522 "账户面板",
1523 &WinGeom {
1524 inner: [760.0, 520.0],
1525 min_inner: [520.0, 320.0],
1526 maximized: false,
1527 },
1528 |ctx2| {
1529 let mut open = shared.accounts_window_open();
1530 egui::Window::new("账户面板")
1531 .id(egui::Id::new("account_win"))
1532 .default_pos([640.0, 320.0])
1533 .default_size([720.0, 470.0])
1534 .open(&mut open)
1535 .show(ctx2, |ui| {
1536 render_accounts_window(ui, shared);
1537 });
1538 shared.set_accounts_window_open(open);
1539 !open
1540 },
1541 |ui| {
1542 render_accounts_window(ui, shared);
1543 false
1544 },
1545 );
1546 if closed {
1547 shared.set_accounts_window_open(false);
1548 }
1549 }
1550
1551 if shared.contracts_window_open() {
1553 let closed = show_independent_window(
1554 ctx,
1555 "contracts_win",
1556 "查询合约窗",
1557 &WinGeom {
1558 inner: [620.0, 460.0],
1559 min_inner: [420.0, 260.0],
1560 maximized: false,
1561 },
1562 |ctx2| {
1563 let mut open = shared.contracts_window_open();
1564 egui::Window::new("查询合约窗")
1565 .id(egui::Id::new("contracts_win"))
1566 .default_pos([420.0, 300.0])
1567 .default_size([560.0, 420.0])
1568 .open(&mut open)
1569 .show(ctx2, |ui| {
1570 render_contracts_window(ui, shared);
1571 });
1572 shared.set_contracts_window_open(open);
1573 !open
1574 },
1575 |ui| {
1576 render_contracts_window(ui, shared);
1577 false
1578 },
1579 );
1580 if closed {
1581 shared.set_contracts_window_open(false);
1582 }
1583 }
1584
1585 let mut closed: Vec<String> = Vec::new();
1589 for sym in shared.kline_symbols() {
1590 let is_closed = show_independent_window(
1591 ctx,
1592 format!("kline_win:{sym}"),
1593 &format!("K线 — {sym}"),
1594 &WinGeom {
1595 inner: [560.0, 360.0],
1596 min_inner: [360.0, 220.0],
1597 maximized: true,
1598 },
1599 |ctx2| {
1600 let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1601 let resp = egui::Window::new(format!("K线 — {sym}"))
1602 .id(egui::Id::new(("kline_win", sym.clone())))
1603 .default_pos([300.0, 340.0])
1604 .default_size([540.0, 330.0])
1605 .show(ctx2, |ui| {
1606 let cell = shared
1607 .market()
1608 .cells()
1609 .into_iter()
1610 .find(|c| c.symbol == sym);
1611 render_kline_cell(ui, cell.as_ref());
1612 });
1613 esc || resp.is_none()
1614 },
1615 |ui| {
1616 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1617 return true;
1618 }
1619 let cell = shared
1620 .market()
1621 .cells()
1622 .into_iter()
1623 .find(|c| c.symbol == sym);
1624 render_kline_cell(ui, cell.as_ref());
1625 false
1626 },
1627 );
1628 if is_closed {
1629 closed.push(sym);
1630 }
1631 }
1632 for s in closed {
1633 shared.close_kline(&s);
1634 }
1635}
1636
1637fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1640 ui.label("插件");
1641 let snap = shared.snapshot();
1642 egui::Grid::new("plug_grid")
1643 .striped(true)
1644 .num_columns(6)
1645 .show(ui, |ui| {
1646 ui.strong("名称");
1647 ui.strong("状态");
1648 ui.strong("版本");
1649 ui.strong("依赖");
1650 ui.strong("统计");
1651 ui.strong("操作");
1652 ui.end_row();
1653 if snap.rows.is_empty() {
1654 ui.label("(快照空:等 eTimer 首拉)");
1655 ui.end_row();
1656 }
1657 for r in &snap.rows {
1658 if is_quarantined(&r.state) {
1659 ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1660 ui.label(
1661 egui::RichText::new(state_text(r))
1662 .color(egui::Color32::RED)
1663 .strong(),
1664 );
1665 } else {
1666 ui.label(r.name.clone());
1667 ui.label(state_text(r));
1668 }
1669 ui.label(r.version.clone());
1670 ui.label(format!("deps:[{}]", r.deps.join(",")));
1671 ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1672 ui.horizontal(|ui| {
1673 if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1674 shared.push_cmd(UiCmd::Start {
1678 factory: r.name.clone(),
1679 name: String::new(),
1680 });
1681 }
1682 let armed = shared.pending_confirm();
1683 let armed_here = ui
1684 .small_button(remove_button_label(&r.name, armed.as_deref()))
1685 .clicked()
1686 && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1687 if armed_here {
1688 shared.push_cmd(UiCmd::Remove {
1689 name: r.name.clone(),
1690 });
1691 }
1692 });
1693 ui.end_row();
1694 }
1695 });
1696
1697 if let Some(name) = shared.pending_confirm() {
1698 ui.horizontal(|ui| {
1699 ui.label(
1700 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1701 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1702 );
1703 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1704 shared.confirm_cancel();
1705 }
1706 });
1707 }
1708
1709 ui.label("服务");
1710 let flat: Vec<String> = snap
1711 .services
1712 .iter()
1713 .flat_map(|(n, ids)| {
1714 if ids.is_empty() {
1715 vec![format!("{n} 服务"), "-".to_string()]
1716 } else {
1717 ids.iter()
1718 .flat_map(|id| [format!("{n} 服务"), id.clone()])
1719 .collect()
1720 }
1721 })
1722 .collect();
1723 if flat.is_empty() {
1724 ui.label("(无服务目录)");
1725 } else {
1726 let rows = flat.len().div_ceil(6); egui::Grid::new("svc_grid")
1728 .striped(true)
1729 .num_columns(6)
1730 .show(ui, |ui| {
1731 for r in 0..rows {
1732 for c in 0..3 {
1733 let i = c * rows * 2 + r * 2;
1734 if i < flat.len() {
1735 ui.label(flat[i].clone());
1736 ui.label(flat[i + 1].clone());
1737 } else {
1738 ui.label("");
1739 ui.label("");
1740 }
1741 }
1742 ui.end_row();
1743 }
1744 });
1745 }
1746
1747 ui.label("依赖");
1748 if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1749 ui.label("(全部无依赖)");
1750 }
1751 for r in &snap.rows {
1752 if !r.deps.is_empty() {
1753 ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1754 }
1755 }
1756}
1757
1758fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
1764 let accounts = shared.accounts();
1765 let locked = shared.accounts_locked();
1766 let md = shared.md_source();
1767 let snap = shared.trade_snap();
1768
1769 const ACCOUNT_HEADERS: [&str; 6] = ["网关(派生名)", "broker", "账号", "状态", "行情源", "操作"];
1770 egui::Grid::new("account_grid")
1775 .striped(true)
1776 .num_columns(6)
1777 .show(ui, |ui| {
1778 for h in &ACCOUNT_HEADERS {
1779 ui.strong(*h);
1780 }
1781 ui.end_row();
1782 if accounts.is_empty() {
1783 ui.label("(无账户:下方表单填好后点『添加账户』)");
1784 for _ in 0..5 {
1785 ui.label("");
1786 }
1787 ui.end_row();
1788 }
1789 for a in &accounts {
1790 let is_md = a.gateway == md;
1791 ui.label(a.gateway.clone());
1792 ui.label(a.broker_id.clone());
1793 ui.label(a.account.clone());
1794 ui.label(if a.connected {
1797 "已装配"
1798 } else {
1799 "未装配"
1800 });
1801 ui.horizontal(|ui| {
1803 if is_md {
1804 ui.label(egui::RichText::new("● 行情源").strong());
1805 }
1806 let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
1807 ui.small_button(if is_md {
1808 "已是行情源"
1809 } else {
1810 "设为行情源"
1811 })
1812 });
1813 if resp.inner.clicked() {
1814 match shared.set_md_source(&a.gateway) {
1815 Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
1816 Err(e) => shared.log_line(format!("[账户] {e}")),
1817 }
1818 }
1819 });
1820 ui.horizontal(|ui| {
1822 if ui.small_button(format!("编辑 {}", a.account)).clicked() {
1823 shared.select_row("account_grid", &a.gateway);
1824 shared.set_ctp_form(a.form.clone());
1825 }
1826 let open = snap
1827 .orders
1828 .iter()
1829 .filter(|o| o.gateway_name == a.gateway && o.is_active())
1830 .count();
1831 let has_pos = snap
1832 .positions
1833 .iter()
1834 .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
1835 if open > 0 {
1836 ui.label(
1838 egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
1839 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1840 );
1841 } else {
1842 let armed = shared.pending_confirm();
1843 let fired = ui
1844 .small_button(remove_button_label(&a.gateway, armed.as_deref()))
1845 .clicked()
1846 && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
1847 if fired {
1848 shared.push_cmd(UiCmd::Remove {
1849 name: a.gateway.clone(),
1850 });
1851 shared.remove_account(&a.gateway);
1852 if has_pos {
1853 shared.log_line(format!(
1854 "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
1855 a.gateway
1856 ));
1857 }
1858 }
1859 }
1860 });
1861 ui.end_row();
1862 }
1863 });
1864
1865 if let Some(name) = shared.pending_confirm() {
1866 ui.horizontal(|ui| {
1867 ui.label(
1868 egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1869 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1870 );
1871 if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1872 shared.confirm_cancel();
1873 }
1874 });
1875 }
1876
1877 ui.label(if locked {
1878 "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
1879 } else {
1880 "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
1881 });
1882 if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
1884 if src.form.md_addr.trim().is_empty() {
1885 ui.label(
1886 egui::RichText::new(format!(
1887 "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
1888 src.gateway, src.account
1889 ))
1890 .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1891 );
1892 }
1893 }
1894
1895 render_account_form(ui, shared);
1896}
1897
1898fn render_account_form(ui: &mut egui::Ui, shared: &UiShared) {
1902 let editing: Option<crate::state::AccountRow> = shared
1903 .accounts()
1904 .into_iter()
1905 .find(|a| shared.row_selected("account_grid", &a.gateway));
1906 let mut form: crate::state::CtpForm = shared.ctp_form();
1907 ui.horizontal(|ui| {
1913 ui.label("broker");
1914 ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1915 ui.label("td 前置");
1916 ctp_edit(
1917 ui,
1918 shared,
1919 &mut form.td_addr,
1920 "tcp://host:40001",
1921 CtpField::TdAddr,
1922 );
1923 ui.label("td 用户");
1924 ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1925 });
1926 ui.horizontal(|ui| {
1927 ui.label("td 密码");
1928 ctp_edit(
1929 ui,
1930 shared,
1931 &mut form.td_password,
1932 "密码",
1933 CtpField::TdPassword,
1934 );
1935 ui.label("AppID");
1936 ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1937 ui.label("AuthCode");
1938 ctp_edit(
1939 ui,
1940 shared,
1941 &mut form.td_auth_code,
1942 "授权码",
1943 CtpField::AuthCode,
1944 );
1945 });
1946 ui.horizontal(|ui| {
1947 ui.label("md 前置");
1948 ctp_edit(
1949 ui,
1950 shared,
1951 &mut form.md_addr,
1952 "tcp://host:40011",
1953 CtpField::MdAddr,
1954 );
1955 ui.label("md 用户");
1956 ctp_edit(
1957 ui,
1958 shared,
1959 &mut form.md_user,
1960 "空=td 回落",
1961 CtpField::MdUser,
1962 );
1963 ui.label("md 密码");
1964 ctp_edit(
1965 ui,
1966 shared,
1967 &mut form.md_password,
1968 "空=td 回落",
1969 CtpField::MdPassword,
1970 );
1971 });
1972 ui.horizontal(|ui| {
1975 ui.label("收录勾选:");
1976 let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
1977 let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
1978 if fut || opt {
1979 shared.set_ctp_form(form.clone());
1980 }
1981 ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
1982 });
1983 ui.horizontal(|ui| {
1984 let md = shared.md_source();
1985 if let Some(acc) = &editing {
1986 if ui.small_button(format!("连接/重连 {}", acc.gateway)).clicked() {
1987 let mut f = form.clone();
1988 if md != acc.gateway {
1989 f.md_addr.clear();
1991 }
1992 shared.update_account_form(&acc.gateway, f.clone());
1993 shared.push_cmd(UiCmd::CtpConfigure {
1994 gateway: acc.gateway.clone(),
1995 form: f,
1996 });
1997 shared.log_line(format!(
1998 "[ops] {} 配置已入列(等 eTimer 请辞 → {}.configure)",
1999 acc.gateway, acc.gateway
2000 ));
2001 }
2002 if ui.small_button("取消编辑").clicked() {
2003 shared.select_row("account_grid", "");
2004 }
2005 } else if ui.small_button(BTN_ACCOUNT_ADD).clicked() {
2006 match crate::state::AccountRow::derive(&form) {
2007 Ok(row) => match shared.add_account(row.clone()) {
2008 Ok(()) => {
2009 let becomes_source = md.is_empty() && !shared.accounts_locked();
2014 let mut f = form.clone();
2015 if becomes_source {
2016 match shared.set_md_source(&row.gateway) {
2017 Ok(()) => {
2018 if f.md_addr.trim().is_empty() {
2019 shared.log_line(format!(
2020 "[账户] {} 已设为行情源但 md 前置为空:点『编辑 {}』补填 md 前置才出行情",
2021 row.gateway, row.account
2022 ));
2023 } else {
2024 shared.log_line(format!(
2025 "[账户] {} = 行情源(首个账户自动指定,§5.C)",
2026 row.gateway
2027 ));
2028 }
2029 }
2030 Err(e) => {
2031 shared.log_line(format!("[账户] {e}"));
2032 f.md_addr.clear();
2033 }
2034 }
2035 } else {
2036 f.md_addr.clear();
2037 if md.is_empty() {
2038 shared.log_line(
2039 "[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户",
2040 );
2041 }
2042 }
2043 shared.push_cmd(UiCmd::Start {
2045 factory: "ctp".to_string(),
2046 name: row.gateway.clone(),
2047 });
2048 shared.push_cmd(UiCmd::CtpConfigure {
2049 gateway: row.gateway.clone(),
2050 form: f,
2051 });
2052 shared.log_line(format!(
2053 "[账户] 添加 {}(装配 + 凭据注入已入列)",
2054 row.gateway
2055 ));
2056 }
2057 Err(e) => shared.log_line(format!("[账户] {e}")),
2058 },
2059 Err(e) => shared.log_line(format!("[账户] {e}")),
2060 }
2061 }
2062 ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2063 });
2064}
2065
2066fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2070 let mut q = shared.contract_query();
2071 ui.horizontal(|ui| {
2072 ui.label("代码或交易所(留空 = 全部):");
2073 if ui
2074 .add(
2075 egui::TextEdit::singleline(&mut q)
2076 .hint_text("cu 或 SHFE")
2077 .desired_width(200.0),
2078 )
2079 .changed()
2080 {
2081 shared.set_contract_query(q.clone());
2082 }
2083 });
2084 let rows = shared.contracts(&q);
2085 const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2088 if rows.is_empty() {
2089 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2091 ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2092 });
2093 return;
2094 }
2095 let selected = shared.selected_contracts();
2096 ui.horizontal(|ui| {
2101 ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2102 if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2103 if selected.is_empty() {
2104 shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2105 } else {
2106 let symbols: Vec<String> = selected
2108 .iter()
2109 .map(|k| {
2110 k.split_once('.')
2111 .map(|(_, s)| s.to_string())
2112 .unwrap_or_else(|| k.clone())
2113 })
2114 .collect();
2115 match subscribe_target(shared) {
2116 Ok(gw) => {
2117 shared.push_cmd(UiCmd::CtpSubscribe {
2118 gateway: gw.clone(),
2119 symbols,
2120 });
2121 shared.log_line(format!(
2122 "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2123 selected.len(),
2124 gw
2125 ));
2126 }
2127 Err(e) => shared.log_line(format!("[订阅] {e}")),
2128 }
2129 }
2130 }
2131 });
2132 pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2133 for c in &rows {
2134 let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2135 let mut on = selected.contains(&key);
2136 let row_sel = shared.row_selected("contracts", &key);
2140 let mut cells = Vec::with_capacity(5);
2141 let ck = ui.checkbox(&mut on, key.clone());
2142 if ck.changed() {
2143 shared.toggle_contract_selection(&key);
2144 shared.select_row("contracts", &key);
2145 }
2146 cells.push(ck.rect);
2147 macro_rules! cell {
2148 ($base:expr, $col:expr) => {{
2149 let base = $base;
2150 let r = click_cell(ui, base.rect, ("crt", &key, $col));
2151 if r.clicked() {
2152 shared.select_row("contracts", &key);
2153 }
2154 cells.push(base.rect);
2155 }};
2156 }
2157 cell!(ui.label(c.exchange.as_str().to_string()), 1);
2158 cell!(ui.label(c.name.clone()), 2);
2159 cell!(ui.label(format!("{:.0}", c.size)), 3);
2160 cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2161 paint_row_bg(ui, &cells, row_sel);
2162 ui.end_row();
2163 }
2164 });
2165}
2166
2167enum CtpField {
2169 Broker,
2170 TdAddr,
2171 TdUser,
2172 TdPassword,
2173 AppId,
2174 AuthCode,
2175 MdAddr,
2176 MdUser,
2177 MdPassword,
2178}
2179
2180fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2182 let is_secret = matches!(
2183 field,
2184 CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2185 );
2186 let mut w = egui::TextEdit::singleline(val)
2187 .hint_text(hint)
2188 .desired_width(210.0);
2191 if is_secret {
2192 w = w.password(true);
2193 }
2194 if ui.add(w).changed() {
2195 let mut f = shared.ctp_form();
2196 match field {
2197 CtpField::Broker => f.broker_id = val.clone(),
2198 CtpField::TdAddr => f.td_addr = val.clone(),
2199 CtpField::TdUser => f.td_user = val.clone(),
2200 CtpField::TdPassword => f.td_password = val.clone(),
2201 CtpField::AppId => f.td_app_id = val.clone(),
2202 CtpField::AuthCode => f.td_auth_code = val.clone(),
2203 CtpField::MdAddr => f.md_addr = val.clone(),
2204 CtpField::MdUser => f.md_user = val.clone(),
2205 CtpField::MdPassword => f.md_password = val.clone(),
2206 }
2207 shared.set_ctp_form(f);
2208 }
2209}
2210
2211fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2215 let Some(c) = cell else {
2216 ui.label("(K线无格:等 dm 合成)");
2217 return;
2218 };
2219 if c.bars.is_empty() {
2220 ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2221 return;
2222 }
2223 let mut data = egui_charts::model::bar::BarData::new();
2227 for b in &c.bars {
2228 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2229 .with_timezone(&beijing_tz())
2230 .with_timezone(&chrono::Utc);
2231 data.push(egui_charts::model::bar::Bar::new(
2232 dt,
2233 b.open_price,
2234 b.high_price,
2235 b.low_price,
2236 b.close_price,
2237 b.volume,
2238 ));
2239 }
2240 ui.label(format!(
2241 "K线 {}@{}s({} 根)",
2242 c.symbol,
2243 c.interval,
2244 c.bars.len()
2245 ));
2246 let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2247 chart.set_symbol(&c.symbol);
2248 chart.set_timeframe_label(&format!("{}s", c.interval));
2249 chart.enable_tracking_mode();
2250 let _ = chart.show(ui);
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255 use super::*;
2256
2257 #[test]
2261 fn fmt_dt_shows_beijing_wall_clock() {
2262 let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2263 ek_data::time::civil_to_nanos(ek_data::time::Civil {
2264 year: 2026,
2265 month: 4,
2266 day: 16,
2267 hour: 1,
2268 minute: 30,
2269 second: 1,
2270 nanos: 0,
2271 }),
2272 );
2273 let beijing = expect.with_timezone(&beijing_tz());
2275 assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2276 let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2279 year: 2026,
2280 month: 4,
2281 day: 15,
2282 hour: 16,
2283 minute: 0,
2284 second: 30,
2285 nanos: 0,
2286 });
2287 assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2288 assert_eq!(fmt_dt(None), "-");
2290 }
2291}