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