use ek_data::constant::{Direction, Offset, OrderType};
use ek_station::wire::PlugRow;
use crate::state::{
BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, is_quarantined,
};
pub const BTN_CLEAR_LOGS: &str = "清空日志";
pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
pub const BTN_SEND_ORDER: &str = "委托";
pub const BTN_CANCEL_ALL: &str = "全撤";
pub const BTN_CANCEL_ORDER: &str = "撤单";
pub const BTN_ACCOUNT_ADD: &str = "添加账户";
pub const MENU_OPS: &str = "运维";
pub const MENU_ACCOUNTS: &str = "账户";
pub const MENU_HELP: &str = "帮助";
pub const MENU_CONTRACTS: &str = "查询合约";
pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
if armed_on == Some(name) {
format!("确认移除 {name} ?")
} else {
format!("移除 {name}")
}
}
fn subscribe_target(shared: &UiShared) -> Result<String, String> {
let md = shared.md_source();
if md.is_empty() {
return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
}
Ok(md)
}
fn state_text(r: &PlugRow) -> String {
r.state.clone()
}
fn fmt_dt(dt: Option<i64>) -> String {
dt.map(|ns| {
chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
.with_timezone(&beijing_tz())
.format("%H:%M:%S")
.to_string()
})
.unwrap_or_else(|| "-".to_string())
}
fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
return;
};
let hovered = ui
.input(|i| i.pointer.interact_pos())
.is_some_and(|p| union.expand(3.0).contains(p));
if selected {
ui.painter().rect_filled(
union.expand(3.0),
3.0,
egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
);
} else if hovered {
ui.painter().rect_filled(
union.expand(3.0),
3.0,
egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
);
}
}
fn order_active(status: ek_data::constant::Status) -> bool {
ek_data::constant::ACTIVE_STATUSES.contains(&status)
}
fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
}
pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
let mut f = shared.order_form();
f.symbol = t.symbol.clone();
f.exchange = exchange.to_string();
match col {
8 => {
f.direction = Direction::SHORT;
f.price = format!("{:.1}", t.bid_price_1);
}
9 => f.direction = Direction::SHORT,
10 => {
f.direction = Direction::LONG;
f.price = format!("{:.1}", t.ask_price_1);
}
11 => f.direction = Direction::LONG,
_ => {}
}
shared.set_order_form(f);
}
fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
if symbol.is_empty() {
return None;
}
shared.tick_rows().into_iter().find(|t| {
t.symbol == symbol
&& (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
})
}
pub fn apply_ui_style(ctx: &egui::Context) {
apply_ui_style_for(ctx, crate::state::Theme::default());
}
pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
ctx.style_mut(|style| {
style
.text_styles
.insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
style
.text_styles
.insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
style
.text_styles
.insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
style
.text_styles
.insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
style
.text_styles
.insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
});
ctx.set_visuals(theme_visuals(theme));
}
fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
use egui::Color32;
match t {
crate::state::Theme::EguiDark => egui::Visuals::dark(),
crate::state::Theme::EguiLight => egui::Visuals::light(),
crate::state::Theme::QtClassic => {
let mut v = egui::Visuals::light();
v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
v
}
crate::state::Theme::FinanceDark => {
let mut v = egui::Visuals::dark();
v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
v.widgets.noninteractive.bg_stroke =
egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
v
}
crate::state::Theme::QDarkStyle => {
let bg1 = Color32::from_rgb(0x19, 0x23, 0x2D); let bg2 = Color32::from_rgb(0x29, 0x35, 0x44); let bg4 = Color32::from_rgb(0x45, 0x53, 0x64); let bg5 = Color32::from_rgb(0x54, 0x68, 0x7A); let bg6 = Color32::from_rgb(0x60, 0x79, 0x8B); let text1 = Color32::from_rgb(0xDF, 0xE1, 0xE2); let text3 = Color32::from_rgb(0xAC, 0xB1, 0xB6); let accent2 = Color32::from_rgb(0x34, 0x67, 0x92); let accent4 = Color32::from_rgb(0x25, 0x9A, 0xE9); let r4 = egui::CornerRadius::same(4);
let mut v = egui::Visuals::dark();
v.dark_mode = true;
v.window_fill = bg1;
v.panel_fill = bg1;
v.extreme_bg_color = bg1; v.faint_bg_color = bg2;
v.hyperlink_color = accent4;
v.weak_text_color = Some(text3);
v.window_stroke = egui::Stroke::new(1.0, bg4);
v.window_corner_radius = r4;
v.menu_corner_radius = r4;
let stroke1 = egui::Stroke::new(1.0, text1);
v.widgets.noninteractive = egui::style::WidgetVisuals {
bg_fill: bg1,
weak_bg_fill: bg1,
bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
fg_stroke: stroke1,
expansion: 0.0,
};
v.widgets.inactive = egui::style::WidgetVisuals {
bg_fill: bg4,
weak_bg_fill: bg4,
bg_stroke: egui::Stroke::new(1.0, bg4), corner_radius: r4,
fg_stroke: stroke1,
expansion: 0.0,
};
v.widgets.hovered = egui::style::WidgetVisuals {
bg_fill: bg5,
weak_bg_fill: bg5,
bg_stroke: egui::Stroke::new(1.0, bg5),
corner_radius: r4,
fg_stroke: stroke1,
expansion: 0.0,
};
v.widgets.active = egui::style::WidgetVisuals {
bg_fill: bg6,
weak_bg_fill: bg6,
bg_stroke: egui::Stroke::new(1.0, bg6),
corner_radius: r4,
fg_stroke: stroke1,
expansion: 0.0,
};
v.widgets.open = egui::style::WidgetVisuals {
bg_fill: bg6,
weak_bg_fill: bg6,
bg_stroke: egui::Stroke::new(1.0, bg4),
corner_radius: r4,
fg_stroke: stroke1,
expansion: 0.0,
};
v.selection = egui::style::Selection {
bg_fill: accent2,
stroke: egui::Stroke::new(1.0, text1), };
v.interact_cursor = Some(egui::CursorIcon::PointingHand);
v
}
}
}
#[derive(Clone, Copy)]
struct Chrome {
line: egui::Color32,
header_text: egui::Color32,
tab_head_fill: egui::Color32,
tab_fill: egui::Color32,
tab_text: egui::Color32,
tab_underline: egui::Color32,
}
impl Chrome {
fn of(theme: crate::state::Theme, v: &egui::Visuals) -> Self {
let line = v.widgets.noninteractive.bg_stroke.color;
let tab_fill = match theme {
crate::state::Theme::QDarkStyle => v.widgets.hovered.bg_fill,
_ => v.selection.bg_fill,
};
Self {
line,
header_text: v.widgets.noninteractive.fg_stroke.color,
tab_head_fill: line,
tab_fill,
tab_text: v.widgets.hovered.fg_stroke.color,
tab_underline: v.hyperlink_color,
}
}
}
fn tab_label(
ui: &mut egui::Ui,
shared: &UiShared,
selected: bool,
text: impl Into<String>,
) -> egui::Response {
let ch = Chrome::of(shared.theme(), ui.visuals());
let text: String = text.into();
let resp = ui.selectable_label(selected, text.clone());
let r = resp.rect;
let radius = ui.visuals().widgets.inactive.corner_radius;
let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
let p = ui.painter();
let (fill, ink, underline) = if selected {
(
ch.tab_fill,
ch.tab_text,
egui::Stroke::new(3.0, ch.tab_underline), )
} else {
(
ch.tab_head_fill,
ui.visuals().widgets.inactive.fg_stroke.color,
egui::Stroke::new(2.0, ch.line), )
};
p.rect_filled(r, radius, fill);
p.hline(r.x_range(), r.max.y, underline);
p.text(
r.center(),
egui::Align2::CENTER_CENTER,
text.as_str(),
font,
ink,
);
resp
}
fn header_band(ui: &mut egui::Ui, shared: &UiShared, heads: &[(egui::Rect, String)]) {
if heads.is_empty() {
return;
}
let ch = Chrome::of(shared.theme(), ui.visuals());
let min_x = heads
.iter()
.map(|h| h.0.min.x)
.fold(f32::INFINITY, f32::min);
let max_x = heads
.iter()
.map(|h| h.0.max.x)
.fold(f32::NEG_INFINITY, f32::max);
let top = heads[0].0.min.y;
let bottom = heads[0].0.max.y + ui.spacing().item_spacing.y;
let strip = egui::Rect::from_min_max(egui::pos2(min_x, top), egui::pos2(max_x, bottom));
let p = ui.painter();
p.rect_filled(strip, 0.0, ch.line);
p.hline(
strip.x_range(),
strip.max.y,
egui::Stroke::new(1.0, ch.line),
);
let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
let mid_y = (top + heads[0].0.max.y) * 0.5;
for (rect, text) in heads {
p.text(
egui::pos2(rect.min.x, mid_y),
egui::Align2::LEFT_CENTER,
text.as_str(),
font.clone(),
ch.header_text,
);
}
}
pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
apply_ui_style_for(ctx, shared.theme());
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
render_menu_bar(ui, shared);
});
egui::TopBottomPanel::top("market_band")
.resizable(true)
.default_height(240.0)
.show(ctx, |ui| {
egui::ScrollArea::both()
.id_salt("market_band_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
render_market_band(ui, shared);
});
});
egui::SidePanel::left("order_side")
.resizable(true)
.default_width(250.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.id_salt("order_side_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
render_order_panel(ui, shared);
ui.separator();
render_book_frame(ui, shared);
});
});
egui::CentralPanel::default().show(ctx, |ui| {
let avail = ui.available_size();
let sp = ui.spacing().item_spacing;
let gw = ((avail.x - sp.x) / 2.0).max(220.0);
let gh = ((avail.y - sp.y) / 2.0).max(120.0);
ui.horizontal(|ui| {
group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
render_topleft_group(ui, shared);
});
group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
render_trades_group(ui, shared);
});
});
ui.horizontal(|ui| {
group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
render_positions_group(ui, shared);
});
group_frame_plain(ui, "grp_bottomright", gw, gh, |ui| {
render_bottomright_group(ui, shared);
});
});
});
render_windows(ctx, shared);
}
fn group_frame_plain(
ui: &mut egui::Ui,
_id: &str,
w: f32,
h: f32,
content: impl FnOnce(&mut egui::Ui),
) {
let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
egui::Frame::group(ui.style()).show(&mut child, |ui| {
ui.vertical(content);
});
}
enum GridLines {
Plain,
Grid { rows: usize },
}
fn pinned_table(
ui: &mut egui::Ui,
shared: &UiShared,
grid_id: &str,
num_columns: usize,
headers: &[&str],
body: impl FnOnce(&mut egui::Ui),
) {
pinned_table_impl(
ui,
shared,
grid_id,
num_columns,
headers,
GridLines::Plain,
body,
);
}
fn pinned_table_grid(
ui: &mut egui::Ui,
shared: &UiShared,
grid_id: &str,
num_columns: usize,
headers: &[&str],
rows: usize,
body: impl FnOnce(&mut egui::Ui),
) {
pinned_table_impl(
ui,
shared,
grid_id,
num_columns,
headers,
GridLines::Grid { rows },
body,
);
}
fn pinned_table_impl(
ui: &mut egui::Ui,
shared: &UiShared,
grid_id: &str,
num_columns: usize,
headers: &[&str],
grid_lines: GridLines,
body: impl FnOnce(&mut egui::Ui),
) {
let ch = Chrome::of(shared.theme(), ui.visuals());
let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
std::cell::RefCell::new(Vec::new());
let grid_rect_cell: std::cell::Cell<Option<egui::Rect>> = std::cell::Cell::new(None);
let resp = egui::ScrollArea::both()
.id_salt(format!("{grid_id}_sc"))
.auto_shrink([false, false])
.show(ui, |ui| {
let gr = egui::Grid::new(grid_id)
.num_columns(num_columns)
.show(ui, |ui| {
for h in headers {
let r = ui.label(egui::RichText::new(*h).strong().small());
heads_cell.borrow_mut().push((r.rect, h.to_string()));
}
ui.end_row();
body(ui);
});
grid_rect_cell.set(Some(gr.response.rect));
});
let heads = heads_cell.borrow();
if heads.is_empty() {
return;
}
let area = resp.inner_rect;
let head_h = heads[0].0.height();
let sp = ui.spacing().item_spacing;
let m = ui.visuals().clip_rect_margin;
let strip = egui::Rect::from_min_max(
egui::pos2(area.min.x - m, area.min.y - m),
egui::pos2(area.max.x + m, area.min.y + head_h + sp.y),
);
if let GridLines::Grid { rows } = grid_lines {
if let Some(grect) = grid_rect_cell.get() {
let line = egui::Stroke::new(1.0, ch.line);
ui.scope_builder(
egui::UiBuilder::new()
.id_salt(format!("{grid_id}_gridlines"))
.max_rect(area),
|ui| {
ui.set_clip_rect(area);
let p = ui.painter();
for h in heads.iter().take(num_columns.saturating_sub(1)) {
let x = h.0.max.x + sp.x * 0.5;
p.vline(x, grect.min.y..=grect.max.y, line);
}
let body_top = grect.min.y + head_h + sp.y;
if rows > 0 && grect.max.y > body_top {
let row_h = (grect.max.y - body_top) / rows as f32;
for k in 0..rows {
let y = body_top + row_h * (k as f32 + 0.5);
p.hline(grect.min.x..=grect.max.x, y, line);
}
}
},
);
}
}
ui.scope_builder(
egui::UiBuilder::new()
.id_salt(format!("{grid_id}_hdr_ov"))
.max_rect(strip),
|ui| {
ui.set_clip_rect(strip);
let painter = ui.painter();
painter.rect_filled(strip, 0.0, ch.line);
painter.hline(
strip.x_range(),
strip.max.y,
egui::Stroke::new(1.0, ch.line),
);
let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
for (rect, text) in heads.iter() {
painter.text(
egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
egui::Align2::LEFT_CENTER,
text.as_str(),
font.clone(),
ch.header_text,
);
}
},
);
}
fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
ui.horizontal(|ui| {
if ui.button(MENU_OPS).clicked() {
shared.set_ops_window_open(true);
}
if ui.button(MENU_ACCOUNTS).clicked() {
shared.set_accounts_window_open(true);
}
if ui.button(MENU_CONTRACTS).clicked() {
shared.set_contracts_window_open(true);
}
ui.menu_button(MENU_HELP, |ui| {
ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
});
if ui
.button(format!("主题:{}", shared.theme().label()))
.clicked()
{
let t = shared.cycle_theme();
shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
}
});
}
fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
let groups = shared.tick_groups();
if groups.is_empty() {
ui.label("(等待 eTick 订阅)");
return;
}
let sel = shared.selected_exchange();
let active = if groups.iter().any(|(e, _)| *e == sel) {
sel.clone()
} else {
groups[0].0.clone()
};
ui.horizontal(|ui| {
for (ex, _) in &groups {
if tab_label(ui, shared, *ex == active, ex.clone()).clicked() {
shared.set_selected_exchange(ex.clone());
}
}
});
let ticks = groups
.iter()
.find(|(e, _)| *e == active)
.map(|(_, v)| v.clone())
.unwrap_or_default();
pinned_table(
ui,
shared,
"tick_grid",
14,
&[
"代码",
"名称",
"最新价",
"涨跌幅",
"成交量",
"今开",
"最高",
"最低",
"买1价",
"买1量",
"卖1价",
"卖1量",
"时间",
"行情源",
],
|ui| {
for t in &ticks {
let row_sel = shared.order_form().symbol == t.symbol;
let mut cells = Vec::with_capacity(14);
let mut dbl = false;
let resp = ui.button(t.symbol.clone());
if resp.clicked() {
shared.set_selected_exchange(active.clone());
import_tick(shared, t, &active, 0);
}
dbl |= resp.double_clicked();
cells.push(resp.rect);
macro_rules! cell {
($base:expr, $col:expr) => {{
let base = $base;
let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
if r.clicked() {
shared.set_selected_exchange(active.clone());
import_tick(shared, t, &active, $col);
}
dbl |= r.double_clicked();
cells.push(base.rect);
}};
}
cell!(
ui.label(if t.name.is_empty() {
"-".to_string()
} else {
t.name.clone()
}),
1
);
cell!(ui.label(format!("{:.1}", t.last_price)), 2);
cell!(
ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
if t.pct_change < 0.0 {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
},
)),
3
);
cell!(ui.label(format!("{:.0}", t.volume)), 4);
cell!(ui.label(format!("{:.1}", t.open_price)), 5);
cell!(ui.label(format!("{:.1}", t.high_price)), 6);
cell!(ui.label(format!("{:.1}", t.low_price)), 7);
cell!(
ui.label(
egui::RichText::new(format!("{:.1}", t.bid_price_1))
.color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
),
8
);
cell!(
ui.label(
egui::RichText::new(format!("{:.0}", t.bid_volume_1))
.color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
),
9
);
cell!(
ui.label(
egui::RichText::new(format!("{:.1}", t.ask_price_1))
.color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
),
10
);
cell!(
ui.label(
egui::RichText::new(format!("{:.0}", t.ask_volume_1))
.color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
),
11
);
cell!(
ui.label(if t.update_time.is_empty() {
fmt_dt(Some(t.datetime))
} else {
t.update_time.clone()
}),
12
);
cell!(ui.label(t.gateway_name.clone()), 13);
if dbl {
shared.open_kline(t.symbol.clone());
}
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
},
);
}
fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
let mut f = shared.order_form();
let before = f.clone();
let mut err: Option<String> = None;
if f.follow_price {
if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
if t.last_price > 0.0 {
f.price = format!("{:.1}", t.last_price);
}
}
}
let mut ex_opts: Vec<String> = shared
.tick_groups()
.iter()
.map(|(e, _)| e.clone())
.collect();
for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
if !ex_opts.iter().any(|x| x == e) {
ex_opts.push(e.to_string());
}
}
if !ex_opts.contains(&f.exchange) {
ex_opts.insert(0, f.exchange.clone());
}
let ts = shared.trade_snap();
let mut gw_opts: Vec<String> = Vec::new();
let known_gws = account_gateways(shared);
for g in shared
.accounts()
.iter()
.filter(|a| a.connected)
.map(|a| a.gateway.clone())
.chain(
ts.orders
.iter()
.map(|o| o.gateway_name.clone())
.chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
.chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
.filter(|g| known_gws.is_empty() || known_gws.contains(g)),
)
{
if !g.is_empty() && !gw_opts.contains(&g) {
gw_opts.push(g);
}
}
if !gw_opts.contains(&f.gateway)
&& !f.gateway.is_empty()
&& (known_gws.is_empty() || known_gws.contains(&f.gateway))
{
gw_opts.insert(0, f.gateway.clone());
}
if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
f.gateway = shared.default_gateway().unwrap_or_default();
}
if f.gateway.is_empty() {
if let Some(def) = shared.default_gateway() {
f.gateway = def;
}
}
let no_accounts = gw_opts.is_empty();
egui::Grid::new("order_form_grid")
.num_columns(2)
.show(ui, |ui| {
ui.label("交易所");
egui::ComboBox::from_id_salt("of_exchange")
.selected_text(f.exchange.clone())
.show_ui(ui, |ui| {
for e in &ex_opts {
ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
}
});
ui.end_row();
ui.label("代码");
let sym_resp = ui.add(
egui::TextEdit::singleline(&mut f.symbol)
.hint_text("cu2709")
.desired_width(150.0),
);
if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let s = f.symbol.trim().to_string();
let bare = match s.split_once('.') {
Some((_, b)) => b.trim().to_string(),
None => s,
};
if !bare.is_empty() {
f.symbol = bare.clone();
match subscribe_target(shared) {
Ok(gw) => {
shared.push_cmd(UiCmd::CtpSubscribe {
gateway: gw.clone(),
symbols: vec![bare.clone()],
});
shared
.log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
}
Err(e) => shared.log_line(format!("[订阅] {e}")),
}
}
}
ui.end_row();
ui.label("名称");
ui.label(
tick_of(shared, &f.exchange, &f.symbol)
.map(|t| {
if t.name.is_empty() {
"-".to_string()
} else {
t.name
}
})
.unwrap_or_else(|| "-".to_string()),
);
ui.end_row();
ui.label("方向");
egui::ComboBox::from_id_salt("of_direction")
.selected_text(f.direction.as_str())
.show_ui(ui, |ui| {
for d in [Direction::LONG, Direction::SHORT] {
ui.selectable_value(&mut f.direction, d, d.as_str());
}
});
ui.end_row();
ui.label("开平");
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("of_offset")
.selected_text(f.offset.as_str())
.show_ui(ui, |ui| {
for o in [
Offset::OPEN,
Offset::CLOSE,
Offset::CLOSETODAY,
Offset::CLOSEYESTERDAY,
] {
ui.selectable_value(&mut f.offset, o, o.as_str());
}
});
ui.checkbox(&mut f.auto_split, "自动开平");
});
ui.end_row();
ui.label("类型");
egui::ComboBox::from_id_salt("of_order_type")
.selected_text(f.order_type.as_str())
.show_ui(ui, |ui| {
for ot in [OrderType::LIMIT, OrderType::MARKET] {
ui.selectable_value(&mut f.order_type, ot, ot.as_str());
}
});
ui.end_row();
ui.label("价格");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut f.price)
.hint_text("107660")
.desired_width(90.0),
);
ui.checkbox(&mut f.follow_price, "随价");
});
ui.end_row();
ui.label("数量");
ui.add(
egui::TextEdit::singleline(&mut f.volume)
.hint_text("1")
.desired_width(90.0),
);
ui.end_row();
ui.label("接口");
egui::ComboBox::from_id_salt("of_gateway")
.selected_text(if f.gateway.is_empty() {
"(未选账户)".to_string()
} else {
f.gateway.clone()
})
.show_ui(ui, |ui| {
for g in &gw_opts {
ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
}
});
ui.end_row();
ui.label("操作");
ui.horizontal(|ui| {
let send = ui
.add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
.inner
.clicked();
if send {
match f.to_request() {
Ok(req) => {
let fallback = shared.default_gateway();
match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
Ok(gateway) => {
shared.push_cmd(UiCmd::SendOrder {
req,
gateway,
mode: if f.auto_split {
ek_oms::LockMode::Auto
} else {
ek_oms::LockMode::None
},
});
shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
}
Err(e) => {
shared.log_line(format!("[下单] {e}"));
err = Some(e);
}
}
}
Err(e) => {
shared.log_line(format!("[下单] {e}"));
err = Some(e);
}
}
}
if ui.button(BTN_CANCEL_ALL).clicked() {
let mut n = 0usize;
for o in ts.orders.iter().filter(|o| order_active(o.status)) {
let gw = if !o.gateway_name.is_empty() {
o.gateway_name.clone()
} else {
shared.default_gateway().unwrap_or_default()
};
if gw.is_empty() {
continue;
}
shared.push_cmd(UiCmd::CancelOrder {
orderid: o.orderid.clone(),
gateway: gw,
});
n += 1;
}
shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
}
});
ui.end_row();
ui.label("代码回车 = 订阅");
ui.label("");
ui.end_row();
});
if let Some(e) = err {
ui.label(egui::RichText::new(e).color(egui::Color32::RED));
}
if no_accounts {
ui.label(
egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
if f != before {
shared.set_order_form(f.clone());
}
}
fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
let f = shared.order_form();
match tick_of(shared, &f.exchange, &f.symbol) {
Some(t) => {
for i in (1..=5).rev() {
let (p, v) = match i {
1 => (t.ask_price_1, t.ask_volume_1),
2 => (t.ask_price_2, t.ask_volume_2),
3 => (t.ask_price_3, t.ask_volume_3),
4 => (t.ask_price_4, t.ask_volume_4),
_ => (t.ask_price_5, t.ask_volume_5),
};
ui.label(
egui::RichText::new(format!("卖{i} {:.1} {:.0}", p, v))
.color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
);
}
for i in 1..=5 {
let (p, v) = match i {
1 => (t.bid_price_1, t.bid_volume_1),
2 => (t.bid_price_2, t.bid_volume_2),
3 => (t.bid_price_3, t.bid_volume_3),
4 => (t.bid_price_4, t.bid_volume_4),
_ => (t.bid_price_5, t.bid_volume_5),
};
ui.label(
egui::RichText::new(format!("买{i} {:.1} {:.0}", p, v))
.color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
);
}
}
None => {
ui.label("(无 tick)");
}
}
}
fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
let tab = shared.topleft_tab();
ui.horizontal(|ui| {
if tab_label(
ui,
shared,
tab == TopLeftTab::Orders,
TopLeftTab::Orders.label(),
)
.clicked()
{
shared.set_topleft_tab(TopLeftTab::Orders);
}
if tab_label(
ui,
shared,
tab == TopLeftTab::Active,
TopLeftTab::Active.label(),
)
.clicked()
{
shared.set_topleft_tab(TopLeftTab::Active);
}
});
render_orders_table(ui, shared, tab == TopLeftTab::Active);
}
fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
let ts = shared.trade_snap();
let known_gws = account_gateways(shared);
let rows: Vec<_> = ts
.orders
.iter()
.filter(|o| !active_only || order_active(o.status))
.collect();
const ORDER_HEADERS: [&str; 17] = [
"代码",
"名称",
"交易所",
"类型",
"方向",
"开平",
"价格",
"总量",
"已成",
"成交均价",
"状态",
"状态描述",
"时间",
"委托号",
"来源",
"接口",
"操作",
];
if rows.is_empty() {
pinned_table(
ui,
shared,
if active_only {
"active_grid"
} else {
"order_grid"
},
17,
&ORDER_HEADERS,
|ui| {
ui.label(if active_only {
"(无未成交)"
} else {
"(无委托:等网关 send_order)"
});
},
);
return;
}
pinned_table(
ui,
shared,
if active_only {
"active_grid"
} else {
"order_grid"
},
17,
&ORDER_HEADERS,
|ui| {
for o in &rows {
let key = format!("{}.{}", o.gateway_name, o.orderid);
let row_sel = shared.row_selected("orders", &key);
let mut cells = Vec::with_capacity(17);
let mut dbl = false;
let sel = ui.selectable_label(row_sel, o.symbol.clone());
if sel.clicked() {
shared.select_row("orders", &key);
}
dbl |= sel.double_clicked();
cells.push(sel.rect);
macro_rules! cell {
($base:expr, $col:expr) => {{
let base = $base;
let r = click_cell(ui, base.rect, ("ord", &key, $col));
if r.clicked() {
shared.select_row("orders", &key);
}
dbl |= r.double_clicked();
cells.push(base.rect);
}};
}
cell!(
ui.label(if o.name.is_empty() {
"-".to_string()
} else {
o.name.clone()
}),
1
);
cell!(ui.label(o.exchange.as_str().to_string()), 2);
cell!(ui.label(o.order_type.as_str().to_string()), 3);
cell!(
ui.label(
egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
if o.direction == Some(Direction::SHORT) {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
},
),
),
4
);
cell!(ui.label(o.offset.as_str().to_string()), 5);
cell!(ui.label(format!("{:.1}", o.price)), 6);
cell!(ui.label(format!("{:.0}", o.volume)), 7);
cell!(ui.label(format!("{:.0}", o.traded)), 8);
cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
cell!(
ui.label(egui::RichText::new(o.status.as_str()).color(
if o.status == ek_data::constant::Status::REJECTED {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
} else if o.status == ek_data::constant::Status::ALLTRADED {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x88, 0x10)
},
)),
10
);
cell!(ui.label(o.status_msg.clone()), 11);
cell!(ui.label(fmt_dt(o.datetime)), 12);
cell!(ui.label(o.orderid.clone()), 13);
cell!(
ui.label(if o.reference.is_empty() {
"-".to_string()
} else {
o.reference.clone()
}),
14
);
cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
if dbl && order_active(o.status) {
let gw = if !o.gateway_name.is_empty() {
o.gateway_name.clone()
} else {
shared.default_gateway().unwrap_or_default()
};
if !gw.is_empty() {
shared.push_cmd(UiCmd::CancelOrder {
orderid: o.orderid.clone(),
gateway: gw,
});
}
}
let hr = ui.horizontal(|ui| {
if order_active(o.status)
&& ui
.small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
.clicked()
{
let gw = if !o.gateway_name.is_empty() {
o.gateway_name.clone()
} else {
shared.default_gateway().unwrap_or_default()
};
if gw.is_empty() {
shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
} else {
shared.push_cmd(UiCmd::CancelOrder {
orderid: o.orderid.clone(),
gateway: gw,
});
}
}
});
cells.push(hr.response.rect);
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
},
);
}
fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
tab_label(ui, shared, true, "成交");
let ts = shared.trade_snap();
let known_gws = account_gateways(shared);
const TRADE_HEADERS: [&str; 12] = [
"成交号",
"代码",
"名称",
"交易所",
"方向",
"开平",
"价格",
"数量",
"成交额",
"时间",
"委托号",
"接口",
];
if ts.trades.is_empty() {
pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
ui.label("(无成交)");
});
return;
}
pinned_table(ui, shared, "trade_grid", 12, &TRADE_HEADERS, |ui| {
for t in &ts.trades {
let key = format!("{}.{}", t.gateway_name, t.tradeid);
let row_sel = shared.row_selected("trades", &key);
let mut cells = Vec::with_capacity(12);
let sel = ui.selectable_label(row_sel, t.tradeid.clone());
if sel.clicked() {
shared.select_row("trades", &key);
}
cells.push(sel.rect);
macro_rules! cell {
($base:expr, $col:expr) => {{
let base = $base;
let r = click_cell(ui, base.rect, ("trd", &key, $col));
if r.clicked() {
shared.select_row("trades", &key);
}
cells.push(base.rect);
}};
}
cell!(ui.label(t.symbol.clone()), 1);
cell!(
ui.label(if t.name.is_empty() {
"-".to_string()
} else {
t.name.clone()
}),
2
);
cell!(ui.label(t.exchange.as_str().to_string()), 3);
cell!(
ui.label(
egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
if t.direction == Some(Direction::SHORT) {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
},
),
),
4
);
cell!(ui.label(t.offset.as_str().to_string()), 5);
cell!(ui.label(format!("{:.1}", t.price)), 6);
cell!(ui.label(format!("{:.0}", t.volume)), 7);
cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
cell!(ui.label(fmt_dt(t.datetime)), 9);
cell!(ui.label(t.orderid.clone()), 10);
cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
});
}
fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
tab_label(ui, shared, true, "持仓");
let ts = shared.trade_snap();
let known_gws = account_gateways(shared);
const POS_HEADERS: [&str; 16] = [
"代码",
"名称",
"交易所",
"方向",
"数量",
"昨仓",
"可用",
"冻结",
"在途",
"市值",
"成本",
"均价",
"最新价",
"盈亏",
"盈亏比",
"接口",
];
if ts.positions.is_empty() {
pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
ui.label("(无持仓)");
});
return;
}
pinned_table(ui, shared, "pos_grid", 16, &POS_HEADERS, |ui| {
for p in &ts.positions {
let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
let row_sel = shared.row_selected("positions", &key);
let mut cells = Vec::with_capacity(16);
let mut dbl = false;
let sel = ui.selectable_label(row_sel, p.symbol.clone());
if sel.clicked() {
shared.select_row("positions", &key);
}
dbl |= sel.double_clicked();
cells.push(sel.rect);
macro_rules! cell {
($base:expr, $col:expr) => {{
let base = $base;
let r = click_cell(ui, base.rect, ("pos", &key, $col));
if r.clicked() {
shared.select_row("positions", &key);
}
dbl |= r.double_clicked();
cells.push(base.rect);
}};
}
cell!(
ui.label(if p.name.is_empty() {
"-".to_string()
} else {
p.name.clone()
}),
1
);
cell!(ui.label(p.exchange.as_str().to_string()), 2);
cell!(
ui.label(egui::RichText::new(p.direction.as_str()).color(
if p.direction == ek_data::constant::Direction::SHORT {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
},
)),
3
);
cell!(ui.label(format!("{:.0}", p.volume)), 4);
cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
cell!(ui.label(format!("{:.0}", p.frozen)), 7);
cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
cell!(ui.label(format!("{:.1}", p.market_value)), 9);
cell!(ui.label(format!("{:.2}", p.price)), 10);
cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
cell!(ui.label(format!("{:.2}", p.last_price)), 12);
cell!(
ui.label(
egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
} else {
egui::Color32::from_rgb(0xE0, 0x50, 0x50)
}),
),
13
);
cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
if dbl {
let mut f = shared.order_form();
f.symbol = p.symbol.clone();
f.exchange = p.exchange.as_str().to_string();
f.direction = p.direction;
if !p.gateway_name.is_empty() {
f.gateway = p.gateway_name.clone();
}
shared.set_order_form(f);
shared.log_line(format!(
"[ops] 持仓带入下单面板 {}(接口 {})",
p.symbol, p.gateway_name
));
}
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
});
}
fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
let tab = shared.bottomright_tab();
ui.horizontal(|ui| {
if tab_label(
ui,
shared,
tab == BottomRightTab::Account,
BottomRightTab::Account.label(),
)
.clicked()
{
shared.set_bottomright_tab(BottomRightTab::Account);
}
if tab_label(
ui,
shared,
tab == BottomRightTab::Logs,
BottomRightTab::Logs.label(),
)
.clicked()
{
shared.set_bottomright_tab(BottomRightTab::Logs);
}
});
match tab {
BottomRightTab::Account => {
let ts = shared.trade_snap();
if ts.accounts.is_empty() {
ui.label("(无资金:等网关 eAccount.)");
return;
}
let mut heads: Vec<(egui::Rect, String)> = Vec::new();
egui::Grid::new("acct_grid").num_columns(5).show(ui, |ui| {
for h in ["账号", "接口", "余额", "可用", "冻结"] {
let r = ui.label(egui::RichText::new(h).strong().small());
heads.push((r.rect, h.to_string()));
}
ui.end_row();
for a in &ts.accounts {
ui.label(a.accountid.clone());
ui.label(a.gateway_name.clone());
ui.label(format!("{:.2}", a.balance));
ui.label(format!("{:.2}", a.available()));
ui.label(format!("{:.2}", a.frozen));
ui.end_row();
}
});
header_band(ui, shared, &heads);
}
BottomRightTab::Logs => {
render_logs(ui, shared);
}
}
}
fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
ui.horizontal(|ui| {
if ui.button(BTN_CLEAR_LOGS).clicked() {
shared.clear_logs(); }
});
let (entries, spilled) = shared.logs_entries();
if spilled > 0 {
ui.label(format!(
"已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
crate::state::RING_CAP
));
}
pinned_table(ui, shared, "log_grid", 2, &["时间", "信息"], |ui| {
if entries.is_empty() {
ui.label("(无日志)");
ui.label("");
ui.end_row();
return;
}
for e in &entries {
ui.monospace(e.at.clone());
ui.monospace(e.text.clone());
ui.end_row();
}
});
}
struct WinGeom {
inner: [f32; 2],
min_inner: [f32; 2],
maximized: bool,
}
fn show_independent_window(
ctx: &egui::Context,
id_salt: impl std::hash::Hash,
title: &str,
geom: &WinGeom,
embedded: impl Fn(&egui::Context) -> bool,
body: impl Fn(&mut egui::Ui) -> bool,
) -> bool {
let closed = std::cell::Cell::new(false);
let mut builder = egui::ViewportBuilder::default()
.with_title(title)
.with_inner_size(geom.inner)
.with_min_inner_size(geom.min_inner)
.with_minimize_button(true)
.with_maximize_button(true)
.with_close_button(true);
if geom.maximized {
builder = builder.with_maximized(true);
}
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of(id_salt),
builder,
|ctx2, class| {
if class == egui::ViewportClass::Embedded {
closed.set(embedded(ctx2));
return;
}
if ctx2.input(|i| i.viewport().close_requested()) {
closed.set(true);
return;
}
egui::CentralPanel::default().show(ctx2, |ui| {
if body(ui) {
closed.set(true);
}
});
},
);
closed.get()
}
fn render_windows(ctx: &egui::Context, shared: &UiShared) {
if shared.ops_window_open() {
let closed = show_independent_window(
ctx,
"ops_win",
"运维窗",
&WinGeom {
inner: [680.0, 420.0],
min_inner: [420.0, 260.0],
maximized: false,
},
|ctx2| {
let mut open = shared.ops_window_open();
egui::Window::new("运维窗")
.id(egui::Id::new("ops_win"))
.default_pos([80.0, 320.0])
.default_size([640.0, 380.0])
.open(&mut open)
.show(ctx2, |ui| {
render_ops_window(ui, shared);
});
shared.set_ops_window_open(open);
!open
},
|ui| {
render_ops_window(ui, shared);
false
},
);
if closed {
shared.set_ops_window_open(false);
}
}
if shared.accounts_window_open() {
let closed = show_independent_window(
ctx,
"account_win",
"账户面板",
&WinGeom {
inner: [760.0, 520.0],
min_inner: [520.0, 320.0],
maximized: false,
},
|ctx2| {
let mut open = shared.accounts_window_open();
egui::Window::new("账户面板")
.id(egui::Id::new("account_win"))
.default_pos([640.0, 320.0])
.default_size([720.0, 470.0])
.open(&mut open)
.show(ctx2, |ui| {
render_accounts_window(ui, shared);
});
shared.set_accounts_window_open(open);
!open
},
|ui| {
render_accounts_window(ui, shared);
false
},
);
if closed {
shared.set_accounts_window_open(false);
}
}
if shared.account_edit_open() {
let closed = show_independent_window(
ctx,
"account_edit_win",
"账户配置",
&WinGeom {
inner: [680.0, 340.0],
min_inner: [480.0, 240.0],
maximized: false,
},
|ctx2| {
let mut open = shared.account_edit_open();
let title = match shared.editing_account() {
Some(gw) => format!("账户配置 — 编辑 {gw}"),
None => "账户配置 — 添加".to_string(),
};
egui::Window::new(title)
.id(egui::Id::new("account_edit_win"))
.default_pos([700.0, 380.0])
.default_size([660.0, 320.0])
.open(&mut open)
.show(ctx2, |ui| {
render_account_edit_window(ui, shared);
});
if !open {
shared.end_account_edit();
}
!open
},
|ui| {
render_account_edit_window(ui, shared);
false
},
);
if closed && shared.account_edit_open() {
shared.end_account_edit();
}
}
if shared.contracts_window_open() {
let closed = show_independent_window(
ctx,
"contracts_win",
"查询合约窗",
&WinGeom {
inner: [620.0, 460.0],
min_inner: [420.0, 260.0],
maximized: false,
},
|ctx2| {
let mut open = shared.contracts_window_open();
egui::Window::new("查询合约窗")
.id(egui::Id::new("contracts_win"))
.default_pos([420.0, 300.0])
.default_size([560.0, 420.0])
.open(&mut open)
.show(ctx2, |ui| {
render_contracts_window(ui, shared);
});
shared.set_contracts_window_open(open);
!open
},
|ui| {
render_contracts_window(ui, shared);
false
},
);
if closed {
shared.set_contracts_window_open(false);
}
}
let mut closed: Vec<String> = Vec::new();
for sym in shared.kline_symbols() {
let is_closed = show_independent_window(
ctx,
format!("kline_win:{sym}"),
&format!("K线 — {sym}"),
&WinGeom {
inner: [560.0, 360.0],
min_inner: [360.0, 220.0],
maximized: true,
},
|ctx2| {
let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
let resp = egui::Window::new(format!("K线 — {sym}"))
.id(egui::Id::new(("kline_win", sym.clone())))
.default_pos([300.0, 340.0])
.default_size([540.0, 330.0])
.show(ctx2, |ui| {
let cell = shared
.market()
.cells()
.into_iter()
.find(|c| c.symbol == sym);
render_kline_cell(ui, cell.as_ref());
});
esc || resp.is_none()
},
|ui| {
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
return true;
}
let cell = shared
.market()
.cells()
.into_iter()
.find(|c| c.symbol == sym);
render_kline_cell(ui, cell.as_ref());
false
},
);
if is_closed {
closed.push(sym);
}
}
for s in closed {
shared.close_kline(&s);
}
}
fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
ui.label("插件");
let snap = shared.snapshot();
let account_gateways: std::collections::HashSet<String> = shared
.accounts()
.iter()
.map(|a| a.gateway.clone())
.collect();
let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
egui::Grid::new("plug_grid").num_columns(6).show(ui, |ui| {
for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
let r = ui.label(egui::RichText::new(h).strong().small());
plug_heads.push((r.rect, h.to_string()));
}
ui.end_row();
if snap.rows.is_empty() {
ui.label("(快照空:等 eTimer 首拉)");
ui.end_row();
}
for r in &snap.rows {
if is_quarantined(&r.state) {
ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
ui.label(
egui::RichText::new(state_text(r))
.color(egui::Color32::RED)
.strong(),
);
} else {
ui.label(r.name.clone());
ui.label(state_text(r));
}
ui.label(r.version.clone());
ui.label(format!("deps:[{}]", r.deps.join(",")));
ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
ui.horizontal(|ui| {
if account_gateways.contains(&r.name) {
ui.label(
egui::RichText::new("起停归『账户』面板(带撤单守卫)")
.color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
);
} else {
if ui.small_button(format!("重注入 {}", r.name)).clicked() {
shared.push_cmd(UiCmd::Start {
factory: r.name.clone(),
name: String::new(),
});
}
let armed = shared.pending_confirm();
let armed_here = ui
.small_button(remove_button_label(&r.name, armed.as_deref()))
.clicked()
&& shared.confirm_step(&r.name) == ConfirmStep::Fired;
if armed_here {
shared.push_cmd(UiCmd::Remove {
name: r.name.clone(),
});
}
}
});
ui.end_row();
}
});
header_band(ui, shared, &plug_heads);
if let Some(name) = shared.pending_confirm() {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
shared.confirm_cancel();
}
});
}
ui.label("服务");
let flat: Vec<String> = snap
.services
.iter()
.flat_map(|(n, ids)| {
if ids.is_empty() {
vec![format!("{n} 服务"), "-".to_string()]
} else {
ids.iter()
.flat_map(|id| [format!("{n} 服务"), id.clone()])
.collect()
}
})
.collect();
if flat.is_empty() {
ui.label("(无服务目录)");
} else {
let rows = flat.len().div_ceil(6); let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
egui::Grid::new("svc_grid").num_columns(6).show(ui, |ui| {
for _ in 0..3 {
for h in ["服务", "实例"] {
let r = ui.label(egui::RichText::new(h).strong().small());
svc_heads.push((r.rect, h.to_string()));
}
}
ui.end_row();
for r in 0..rows {
for c in 0..3 {
let i = c * rows * 2 + r * 2;
if i < flat.len() {
ui.label(flat[i].clone());
ui.label(flat[i + 1].clone());
} else {
ui.label("");
ui.label("");
}
}
ui.end_row();
}
});
header_band(ui, shared, &svc_heads);
}
ui.label("依赖");
if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
ui.label("(全部无依赖)");
}
for r in &snap.rows {
if !r.deps.is_empty() {
ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
}
}
}
fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
let accounts = shared.accounts();
let locked = shared.accounts_locked();
let md = shared.md_source();
let snap = shared.trade_snap();
let row_label: std::collections::BTreeMap<String, String> = accounts
.iter()
.map(|a| {
let d = a.display_name();
let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
(a.gateway.clone(), if dup { a.gateway.clone() } else { d })
})
.collect();
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(
"当前行情源 = {}",
if md.is_empty() {
"(未指定)".to_string()
} else {
md.clone()
}
))
.strong(),
);
ui.label(format!(
"账户 {}/{}",
accounts.len(),
crate::state::MAX_ACCOUNTS
));
if shared.can_add_account() {
if ui.button(BTN_ACCOUNT_ADD).clicked() {
shared.begin_account_edit(None);
}
} else {
ui.label(
egui::RichText::new(format!(
"账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
crate::state::MAX_ACCOUNTS
))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
});
const ACCOUNT_HEADERS: [&str; 7] = [
"网关(派生名)",
"别名",
"账号",
"状态",
"行情源",
"收录(勾→实)",
"操作",
];
let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
egui::Grid::new("account_grid")
.num_columns(7)
.show(ui, |ui| {
for h in &ACCOUNT_HEADERS {
let r = ui.label(egui::RichText::new(*h).strong().small());
acct_heads.push((r.rect, h.to_string()));
}
ui.end_row();
if accounts.is_empty() {
ui.label("(无账户:上方『添加账户』开配置弹窗)");
for _ in 0..6 {
ui.label("");
}
ui.end_row();
}
for a in &accounts {
let is_md = a.gateway == md;
let name = row_label
.get(&a.gateway)
.cloned()
.unwrap_or_else(|| a.gateway.clone());
ui.label(a.gateway.clone());
ui.label(if a.alias.trim().is_empty() {
"—".to_string()
} else {
a.alias.clone()
});
ui.label(a.account.clone());
match a.stage {
crate::state::AccountStage::AssembleFailed => {
ui.add(
egui::Label::new(
egui::RichText::new(format!(
"装配失败:{}",
a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
))
.color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
}
_ => {
let color = match a.login {
crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
crate::state::LoginState::LoginFailed
| crate::state::LoginState::FrontDown => {
egui::Color32::from_rgb(0xC0, 0x30, 0x30)
}
crate::state::LoginState::QueryStarved => {
egui::Color32::from_rgb(0xE0, 0x88, 0x10)
}
_ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
};
ui.add(
egui::Label::new(
egui::RichText::new(a.status_label()).color(color),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
}
}
ui.horizontal(|ui| {
if is_md {
ui.label(egui::RichText::new("● 行情源").strong());
}
let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
ui.small_button(if is_md {
"已是行情源"
} else {
"设为行情源"
})
});
if resp.inner.clicked() {
match shared.set_md_source(&a.gateway) {
Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
Err(e) => shared.log_line(format!("[账户] {e}")),
}
}
});
ui.vertical(|ui| {
ui.add(
egui::Label::new(egui::RichText::new(format!(
"勾:{}",
classes_label(&a.form.product_classes())
)))
.wrap_mode(egui::TextWrapMode::Extend),
);
ui.add(
egui::Label::new(egui::RichText::new(format!(
"实:{}",
if a.effective_classes.is_empty() {
"—".to_string()
} else {
classes_label(&a.effective_classes)
}
)))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.horizontal(|ui| {
let failed = a.stage == crate::state::AccountStage::AssembleFailed;
let idle = a.stage == crate::state::AccountStage::NotAssembled;
let stopped = a.login == crate::state::LoginState::Stopped;
if failed || idle || stopped {
let label = if failed {
format!("重试 {name}")
} else {
format!("连接 {name}")
};
if ui.small_button(label).clicked() {
push_connect(shared, &a.gateway, &a.form);
}
}
let open = snap
.orders
.iter()
.filter(|o| o.gateway_name == a.gateway && o.is_active())
.count();
if a.stage == crate::state::AccountStage::Assembled
&& a.login != crate::state::LoginState::Stopped
&& open == 0
&& ui.small_button(format!("断开 {name}")).clicked()
{
shared.push_cmd(UiCmd::Disconnect {
gateway: a.gateway.clone(),
});
shared.log_line(format!(
"[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
gateway = a.gateway
));
}
if ui.small_button(format!("编辑 {name}")).clicked() {
shared.begin_account_edit(Some(&a.gateway));
}
let has_pos = snap
.positions
.iter()
.any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
if open > 0 {
ui.label(
egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
if ui.small_button(format!("先撤 {open} 笔")).clicked() {
for o in snap
.orders
.iter()
.filter(|o| o.gateway_name == a.gateway && o.is_active())
{
shared.push_cmd(UiCmd::CancelOrder {
orderid: o.orderid.clone(),
gateway: a.gateway.clone(),
});
}
shared.log_line(format!(
"[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
a.gateway, open
));
}
} else {
let armed = shared.pending_confirm();
let armed_here = armed.as_deref() == Some(a.gateway.as_str());
let fired = ui
.small_button(remove_button_label(
&name,
if armed_here { Some(name.as_str()) } else { None },
))
.clicked()
&& shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
if fired {
shared.push_cmd(UiCmd::Remove {
name: a.gateway.clone(),
});
shared.remove_account(&a.gateway);
shared.end_account_edit_if_editing(&a.gateway);
if has_pos {
shared.log_line(format!(
"[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
a.gateway
));
}
}
}
});
ui.end_row();
}
});
header_band(ui, shared, &acct_heads);
if let Some(gw) = shared.pending_confirm() {
let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
shared.confirm_cancel();
}
});
}
ui.label(if locked {
"行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
} else {
"行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
});
ui.label(
egui::RichText::new(format!(
"状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
crate::state::STATUS_POLL_TICKS
))
.color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
);
ui.label(
egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
.color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
);
for a in accounts
.iter()
.filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
{
let note = a.status_note.clone().unwrap_or_default();
let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
let color = if alarm {
egui::Color32::from_rgb(0xC0, 0x30, 0x30)
} else {
egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
};
ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
}
if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
if src.form.md_addr.trim().is_empty() {
ui.label(
egui::RichText::new(format!(
"元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
src.gateway,
row_label
.get(&src.gateway)
.cloned()
.unwrap_or_else(|| src.account.clone())
))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
}
}
fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
let editing: Option<crate::state::AccountRow> =
shared.editing_account().and_then(|g| shared.account(&g));
let editing_on = editing.is_some();
let md = shared.md_source();
let locked = shared.accounts_locked();
let mut form: crate::state::CtpForm = shared.ctp_form();
let mut alias = shared.edit_alias();
if let Some(e) = shared.edit_error() {
ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
}
ui.horizontal(|ui| {
ui.label("别名");
if ui
.add(
egui::TextEdit::singleline(&mut alias)
.hint_text("显示用,可空")
.desired_width(120.0),
)
.changed()
{
shared.set_edit_alias(alias.clone());
}
ui.label("broker");
ui.add_enabled_ui(!editing_on, |ui| {
ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
});
ui.label("td 前置");
ctp_edit(
ui,
shared,
&mut form.td_addr,
"tcp://host:40001",
CtpField::TdAddr,
);
ui.label("td 用户");
ui.add_enabled_ui(!editing_on, |ui| {
ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
});
});
if let Some(r) = &editing {
ui.label(
egui::RichText::new(format!(
"派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
r.gateway
))
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
ui.horizontal(|ui| {
ui.label("td 密码");
ctp_edit(
ui,
shared,
&mut form.td_password,
"密码",
CtpField::TdPassword,
);
ui.label("AppID");
ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
ui.label("AuthCode");
ctp_edit(
ui,
shared,
&mut form.td_auth_code,
"授权码",
CtpField::AuthCode,
);
});
let md_editable = match &editing {
None => true,
Some(r) => !locked || r.gateway == md,
};
ui.horizontal(|ui| {
ui.label("md 前置");
ui.add_enabled_ui(md_editable, |ui| {
ctp_edit(
ui,
shared,
&mut form.md_addr,
"tcp://host:40011",
CtpField::MdAddr,
);
ui.label("md 用户");
ctp_edit(
ui,
shared,
&mut form.md_user,
"空=td 回落",
CtpField::MdUser,
);
ui.label("md 密码");
ctp_edit(
ui,
shared,
&mut form.md_password,
"空=td 回落",
CtpField::MdPassword,
);
});
if !md_editable {
ui.label(
egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
});
ui.horizontal(|ui| {
ui.label("收录勾选:");
let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
if fut || opt {
shared.set_ctp_form(form.clone());
}
ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
});
ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
if let Some(r) = &editing {
ui.horizontal(|ui| {
ui.label("网关实际收录(只读回带):");
ui.label(if r.effective_classes.is_empty() {
"—(尚未装配或无回带)".to_string()
} else {
classes_label(&r.effective_classes)
});
ui.label("装配/登录状态(只读):");
ui.label(format!(
"{}{}",
r.status_label(),
r.fail_note
.as_ref()
.map(|n| format!(" —— {n}"))
.unwrap_or_default()
));
});
}
if shared.account_edit_dirty() {
ui.label(
egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
.color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
);
}
ui.horizontal(|ui| {
if ui.button("保存").clicked() {
match save_account_draft(shared, &form, &alias, false) {
Ok(()) => shared.end_account_edit(),
Err(e) => {
shared.log_line(format!("[账户] 保存被拦:{e}"));
shared.set_edit_error(Some(e));
}
}
}
if ui.button("保存并连接").clicked() {
match save_account_draft(shared, &form, &alias, true) {
Ok(()) => shared.end_account_edit(),
Err(e) => {
shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
shared.set_edit_error(Some(e));
}
}
}
if ui.button("取消").clicked() {
shared.end_account_edit();
}
ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
});
}
fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
shared
.accounts()
.iter()
.map(|a| a.gateway.clone())
.collect()
}
fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
gateway.to_string()
} else {
format!("{gateway}(已摘除)")
}
}
fn classes_label(classes: &[String]) -> String {
if classes.is_empty() {
return "—".to_string();
}
classes
.iter()
.map(|c| match c.as_str() {
"1" => "期货",
"2" => "期权",
other => other,
})
.collect::<Vec<_>>()
.join("+")
}
fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
shared.push_cmd(UiCmd::Connect {
gateway: gateway.to_string(),
form: form.clone(),
});
shared.note_connect_sent(gateway);
shared.log_line(format!(
"[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
gateway
));
}
fn save_account_draft(
shared: &UiShared,
form: &crate::state::CtpForm,
alias: &str,
connect: bool,
) -> Result<(), String> {
form.validate()?;
if let Some(gw) = shared.editing_account() {
if shared.account(&gw).is_none() {
return Err(format!(
"编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
));
}
shared.update_account_form(&gw, form.clone());
if !shared.set_account_alias(&gw, alias) {
return Err(format!(
"别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
));
}
shared.log_line(format!(
"[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
));
if connect {
push_connect(shared, &gw, form);
}
return Ok(());
}
let mut row = crate::state::AccountRow::derive(form)?;
row.alias = alias.trim().to_string();
let gateway = row.gateway.clone();
let account = row.account.clone();
shared.add_account(row.clone())?;
let md = shared.md_source();
let mut f = row.form.clone();
if md.is_empty() && !shared.accounts_locked() {
match shared.set_md_source(&gateway) {
Ok(()) => {
if f.md_addr.trim().is_empty() {
shared.log_line(format!(
"[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
));
} else {
shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
}
}
Err(e) => {
shared.log_line(format!("[账户] {e}"));
f.md_addr.clear();
f.md_user.clear();
f.md_password.clear();
}
}
} else {
f.md_addr.clear();
f.md_user.clear();
f.md_password.clear();
if md.is_empty() {
shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
}
}
shared.update_account_form(&gateway, f.clone());
shared.log_line(format!(
"[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
));
if connect {
push_connect(shared, &gateway, &f);
}
Ok(())
}
fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
let mut q = shared.contract_query();
ui.horizontal(|ui| {
ui.label("代码或交易所(留空 = 全部):");
if ui
.add(
egui::TextEdit::singleline(&mut q)
.hint_text("cu 或 SHFE")
.desired_width(200.0),
)
.changed()
{
shared.set_contract_query(q.clone());
}
});
let rows = shared.contracts(&q);
const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
if rows.is_empty() {
pinned_table(ui, shared, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
});
return;
}
let selected = shared.selected_contracts();
ui.horizontal(|ui| {
ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
if selected.is_empty() {
shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
} else {
let symbols: Vec<String> = selected
.iter()
.map(|k| {
k.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_else(|| k.clone())
})
.collect();
match subscribe_target(shared) {
Ok(gw) => {
shared.push_cmd(UiCmd::CtpSubscribe {
gateway: gw.clone(),
symbols,
});
shared.log_line(format!(
"[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
selected.len(),
gw
));
}
Err(e) => shared.log_line(format!("[订阅] {e}")),
}
}
}
});
pinned_table_grid(
ui,
shared,
"contract_grid",
5,
&CONTRACT_HEADERS,
rows.len(),
|ui| {
for c in &rows {
let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
let mut on = selected.contains(&key);
let row_sel = shared.row_selected("contracts", &key);
let mut cells = Vec::with_capacity(5);
let ck = ui.checkbox(&mut on, key.clone());
if ck.changed() {
shared.toggle_contract_selection(&key);
shared.select_row("contracts", &key);
}
cells.push(ck.rect);
macro_rules! cell {
($base:expr, $col:expr) => {{
let base = $base;
let r = click_cell(ui, base.rect, ("crt", &key, $col));
if r.clicked() {
shared.select_row("contracts", &key);
}
cells.push(base.rect);
}};
}
cell!(ui.label(c.exchange.as_str().to_string()), 1);
cell!(ui.label(c.name.clone()), 2);
cell!(ui.label(format!("{:.0}", c.size)), 3);
cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
},
);
}
enum CtpField {
Broker,
TdAddr,
TdUser,
TdPassword,
AppId,
AuthCode,
MdAddr,
MdUser,
MdPassword,
}
fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
let is_secret = matches!(
field,
CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
);
let mut w = egui::TextEdit::singleline(val)
.hint_text(hint)
.desired_width(210.0);
if is_secret {
w = w.password(true);
}
if ui.add(w).changed() {
let mut f = shared.ctp_form();
match field {
CtpField::Broker => f.broker_id = val.clone(),
CtpField::TdAddr => f.td_addr = val.clone(),
CtpField::TdUser => f.td_user = val.clone(),
CtpField::TdPassword => f.td_password = val.clone(),
CtpField::AppId => f.td_app_id = val.clone(),
CtpField::AuthCode => f.td_auth_code = val.clone(),
CtpField::MdAddr => f.md_addr = val.clone(),
CtpField::MdUser => f.md_user = val.clone(),
CtpField::MdPassword => f.md_password = val.clone(),
}
shared.set_ctp_form(f);
}
}
fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
let Some(c) = cell else {
ui.label("(K线无格:等 dm 合成)");
return;
};
if c.bars.is_empty() {
ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
return;
}
let mut data = egui_charts::model::bar::BarData::new();
for b in &c.bars {
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
.with_timezone(&beijing_tz())
.with_timezone(&chrono::Utc);
data.push(egui_charts::model::bar::Bar::new(
dt,
b.open_price,
b.high_price,
b.low_price,
b.close_price,
b.volume,
));
}
ui.label(format!(
"K线 {}@{}s({} 根)",
c.symbol,
c.interval,
c.bars.len()
));
let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
chart.set_symbol(&c.symbol);
chart.set_timeframe_label(&format!("{}s", c.interval));
chart.enable_tracking_mode();
let _ = chart.show(ui);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chrome_qdark_matches_qdarkstyle_palette() {
let v = theme_visuals(crate::state::Theme::QDarkStyle);
let ch = Chrome::of(crate::state::Theme::QDarkStyle, &v);
assert_eq!(ch.line, egui::Color32::from_rgb(0x45, 0x53, 0x64));
assert_eq!(ch.header_text, egui::Color32::from_rgb(0xDF, 0xE1, 0xE2));
assert_eq!(ch.tab_head_fill, egui::Color32::from_rgb(0x45, 0x53, 0x64));
assert_eq!(ch.tab_fill, egui::Color32::from_rgb(0x54, 0x68, 0x7A));
assert_eq!(ch.tab_underline, egui::Color32::from_rgb(0x25, 0x9A, 0xE9));
let fv = theme_visuals(crate::state::Theme::FinanceDark);
let fch = Chrome::of(crate::state::Theme::FinanceDark, &fv);
assert_eq!(fch.line, egui::Color32::from_rgb(0x3A, 0x46, 0x58));
assert_eq!(fch.tab_fill, egui::Color32::from_rgb(0x2A, 0x44, 0x68));
assert_eq!(fch.tab_underline, egui::Color32::from_rgb(0x6A, 0x9C, 0xD8));
assert_ne!(fch.line, ch.line);
assert_ne!(fch.tab_underline, ch.tab_underline);
}
#[test]
fn log_entries_stamp_beijing_wall_clock() {
let mut ring = crate::state::LogRing::new(4);
ring.push("[ops] 一行");
let e = &ring.entries()[0];
assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
for seg in e.at.split(':') {
assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
}
let want = chrono::Utc::now()
.with_timezone(&beijing_tz())
.format("%H:%M:%S")
.to_string();
let secs = |s: &str| -> i64 {
s.split(':')
.map(|x| x.parse::<i64>().unwrap())
.fold(0, |acc, v| acc * 60 + v)
};
let (a, b) = (secs(&e.at), secs(&want));
let d = (a - b).abs();
assert!(
d <= 2 || d > 86_398,
"日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
);
assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
}
#[test]
fn fmt_dt_shows_beijing_wall_clock() {
let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
ek_data::time::civil_to_nanos(ek_data::time::Civil {
year: 2026,
month: 4,
day: 16,
hour: 1,
minute: 30,
second: 1,
nanos: 0,
}),
);
let beijing = expect.with_timezone(&beijing_tz());
assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
year: 2026,
month: 4,
day: 15,
hour: 16,
minute: 0,
second: 30,
nanos: 0,
});
assert_eq!(fmt_dt(Some(ns)), "00:00:30");
assert_eq!(fmt_dt(None), "-");
}
}