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