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