use ek_data::constant::{Direction, Offset, OrderType};
use ek_station::SVCTL_NAME;
use ek_station::wire::PlugRow;
use crate::plugin::UI_NAME;
use crate::state::{
BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, is_quarantined,
};
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()
}
pub fn stat_color(spilled: u64, panics: u64) -> egui::Color32 {
if panics > 0 || spilled >= ek_core::plugin::MAILBOX_CAP as u64 {
crate::tokens::semantic::ERROR
} else if spilled > 0 {
crate::tokens::semantic::WARN
} else {
crate::tokens::semantic::MUTED
}
}
fn order_active(status: ek_data::constant::Status) -> bool {
ek_data::constant::ACTIVE_STATUSES.contains(&status)
}
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 = crate::format::price(t.bid_price_1);
}
9 => f.direction = Direction::SHORT,
10 => {
f.direction = Direction::LONG;
f.price = crate::format::price(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) {
ctx.style_mut(|style| {
crate::tokens::apply(style);
});
ctx.set_visuals(crate::tokens::visuals());
}
#[derive(Clone, Copy)]
struct Chrome {
line: egui::Color32,
band_fill: egui::Color32,
header_text: egui::Color32,
tab_fill: egui::Color32,
tab_text: egui::Color32,
tab_underline: egui::Color32,
}
impl Chrome {
fn of(v: &egui::Visuals) -> Self {
Self {
line: v.widgets.noninteractive.bg_stroke.color,
band_fill: v.widgets.inactive.bg_fill,
header_text: v.widgets.noninteractive.fg_stroke.color,
tab_fill: v.widgets.hovered.bg_fill,
tab_text: v.widgets.hovered.fg_stroke.color,
tab_underline: v.hyperlink_color,
}
}
}
fn tab_label(ui: &mut egui::Ui, selected: bool, text: impl Into<String>) -> egui::Response {
let ch = Chrome::of(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.band_fill,
ch.header_text,
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,
heads: &[(egui::Rect, String)],
content_right: f32,
) -> egui::Rect {
if heads.is_empty() {
return egui::Rect::NOTHING;
}
let ch = Chrome::of(ui.visuals());
let left = ui.max_rect().min.x;
let right = ui.max_rect().max.x.max(content_right);
let head_h = table_heights(ui).1;
let band_h = head_h + ui.visuals().clip_rect_margin;
let mid = heads[0].0.center().y;
let top = mid - band_h * 0.5;
let bottom = mid + band_h * 0.5;
let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
let p = ui.painter();
p.rect_filled(strip, 0.0, ch.band_fill);
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 = (strip.min.y + strip.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,
);
}
strip
}
pub fn table_fill_probe(ctx: &egui::Context, table_id: &str) -> (f32, f32) {
let fill_id = egui::Id::new(("table_fill", table_id));
ctx.data(|d| {
(
d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
d.get_temp::<f32>(fill_id).unwrap_or(0.0),
)
})
}
fn grid_chrome(
ui: &egui::Ui,
band: egui::Rect,
heads: &[(egui::Rect, String)],
row_bottoms: &[f32],
) {
if !band.is_positive() || heads.is_empty() {
return;
}
let ch = Chrome::of(ui.visuals());
let sp = ui.spacing().item_spacing;
let p = ui.painter();
for &rb in row_bottoms {
p.hline(
band.x_range(),
rb + sp.y * 0.5,
egui::Stroke::new(1.0, ch.line),
);
}
}
pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
apply_ui_style(ctx);
let close_requested = ctx.input(|i| i.viewport().close_requested());
if close_requested && !shared.exit_confirmed() && !shared.exit_confirm_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
shared.open_exit_confirm();
}
crate::toast::drain_and_show(ctx, shared);
if shared.exit_confirm_open() {
egui::Window::new("退出确认")
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label("确认退出 ek-ui?");
ui.horizontal(|ui| {
if ui.button("取消").clicked() {
shared.close_exit_confirm();
}
if ui.button("确认退出").clicked() {
shared.confirm_exit();
shared.close_exit_confirm();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
}
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
render_menu_bar(ui, shared);
});
egui::TopBottomPanel::top("market_band")
.resizable(true)
.default_height(crate::tokens::MARKET_BAND_HEIGHT)
.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(crate::tokens::ORDER_SIDE_WIDTH)
.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| {
render_data_area(ui, shared);
});
render_windows(ctx, shared);
}
pub fn exchange_tab_name(ex: ek_data::constant::Exchange) -> &'static str {
use ek_data::constant::Exchange as E;
match ex {
E::SHFE => "上期所",
E::DCE => "大商所",
E::CZCE => "郑商所",
E::CFFEX => "中金所",
E::GFEX => "广期所",
E::INE => "能源中心",
other => other.as_str(),
}
}
fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
let active = shared.selected_exchange();
let active_ex = crate::state::MARKET_ORDER
.iter()
.copied()
.find(|e| e.as_str() == active)
.unwrap_or(crate::state::MARKET_ORDER[0]);
let tab_h =
ui.text_style_height(&egui::TextStyle::Button) + ui.spacing().button_padding.y * 2.0 + 4.0;
let avail = ui.available_size();
let table_rect = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(avail.x, (avail.y - tab_h).max(40.0)),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(table_rect), |ui| {
ui.add_space(ui.visuals().clip_rect_margin);
render_market_pane(ui, shared, active_ex);
});
ui.horizontal(|ui| {
for ex in crate::state::MARKET_ORDER {
if tab_label(ui, ex == active_ex, exchange_tab_name(ex)).clicked() {
shared.set_selected_exchange(ex.as_str());
}
}
});
}
fn render_data_area(ui: &mut egui::Ui, shared: &UiShared) {
let split = shared.data_split();
crate::splitters::quad(
ui,
egui::Id::new("ek_data_quad"),
split.row,
split.col,
|row| {
shared.set_data_split(crate::state::DataSplit {
col: split.col,
row,
});
},
|col| {
shared.set_data_split(crate::state::DataSplit {
col,
row: split.row,
});
},
|ui| render_topleft_group(ui, shared),
|ui| render_trades_group(ui, shared),
|ui| render_positions_group(ui, shared),
|ui| render_bottomright_group(ui, shared),
);
}
fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
let tab = shared.topleft_tab();
ui.horizontal(|ui| {
if tab_label(ui, tab == TopLeftTab::Orders, TopLeftTab::Orders.label()).clicked() {
shared.set_topleft_tab(TopLeftTab::Orders);
}
if tab_label(ui, tab == TopLeftTab::Active, TopLeftTab::Active.label()).clicked() {
shared.set_topleft_tab(TopLeftTab::Active);
}
});
render_orders_table(ui, shared, tab == TopLeftTab::Active);
}
fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
let tab = shared.bottomright_tab();
ui.horizontal(|ui| {
if tab_label(
ui,
tab == BottomRightTab::Account,
BottomRightTab::Account.label(),
)
.clicked()
{
shared.set_bottomright_tab(BottomRightTab::Account);
}
if tab_label(
ui,
tab == BottomRightTab::Logs,
BottomRightTab::Logs.label(),
)
.clicked()
{
shared.set_bottomright_tab(BottomRightTab::Logs);
}
});
match tab {
BottomRightTab::Account => render_funds_pane(ui, shared),
BottomRightTab::Logs => render_logs(ui, shared),
}
}
fn table_heights(ui: &egui::Ui) -> (f32, f32) {
let st = ui.style();
let floor_h = crate::tokens::INTERACT_HEIGHT;
(
(st.text_styles[&egui::TextStyle::Body].size + 6.0).max(floor_h),
(st.text_styles[&egui::TextStyle::Small].size + 6.0).max(floor_h),
)
}
pub(crate) type PinCell<'b> = Box<dyn FnMut(&mut egui::Ui, &PinCellCtx) + 'b>;
pub(crate) struct PinCellCtx {
pub hit: egui::Response,
}
struct PinRow<'b> {
selected: bool,
cells: Vec<PinCell<'b>>,
}
#[derive(Default)]
pub(crate) struct PinBody<'b> {
rows: Vec<PinRow<'b>>,
}
impl<'b> PinBody<'b> {
pub fn row(&mut self, _h: f32, selected: bool, add: impl FnOnce(&mut PinRowAdd<'b>)) {
let mut r = PinRowAdd {
selected,
cells: Vec::new(),
};
add(&mut r);
self.rows.push(r.finish());
}
}
pub(crate) struct PinRowAdd<'b> {
selected: bool,
cells: Vec<PinCell<'b>>,
}
impl<'b> PinRowAdd<'b> {
pub fn cell(&mut self, make: impl FnMut(&mut egui::Ui, &PinCellCtx) + 'b) {
self.cells.push(Box::new(make));
}
fn finish(self) -> PinRow<'b> {
PinRow {
selected: self.selected,
cells: self.cells,
}
}
}
pub(crate) fn pin_cell<R: CellOutcome>(
ui: &mut egui::Ui,
ctx: &PinCellCtx,
right_align: bool,
make: impl FnOnce(&mut egui::Ui) -> R,
) -> (egui::Response, Option<egui::Response>) {
let inner = if right_align {
crate::ui_ext::num_cell(ui, make)
} else {
make(ui)
};
(ctx.hit.clone(), inner.response())
}
fn quadrant_is_degenerate(ui: &egui::Ui) -> bool {
let c = ui.clip_rect();
let m = ui.max_rect();
(c.width() == 0.0 && m.width() > 0.0) || (c.height() == 0.0 && m.height() > 0.0)
}
impl egui_table::TableDelegate for PinDelegate<'_, '_> {
fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
let i = cell.col_range.start;
let Some(seen) = self.header_drawn.get(i) else {
return;
};
let seen = *seen;
if !seen {
self.header_drawn[i] = true;
return;
}
let i = cell.col_range.start;
let mark = match self.sort {
Some((c, asc)) if c == i => {
if asc {
" ▲"
} else {
" ▼"
}
}
_ => "",
};
let cell = ui.max_rect();
let gh = ui.text_style_height(&egui::TextStyle::Small);
let target = egui::Rect::from_min_size(
egui::pos2(cell.min.x, self.band_mid - gh * 0.5),
egui::vec2(cell.width(), gh),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(target), |ui| {
ui.label(
egui::RichText::new(format!("{}{mark}", self.headers[i]))
.strong()
.small(),
);
});
let id = ui.id().with(("hdr_click", i));
if ui
.interact(ui.max_rect(), id, egui::Sense::click())
.clicked()
{
self.clicked.get_or_insert(i);
}
}
fn row_ui(&mut self, ui: &mut egui::Ui, row_nr: u64) {
if quadrant_is_degenerate(ui) {
return;
}
let r = ui.max_rect();
let band_w = self.band_x.max - self.band_x.min;
let fill = egui::Rect::from_min_max(
egui::pos2(r.min.x, r.min.y),
egui::pos2(r.max.x.max(r.min.x + band_w), r.max.y),
);
if let Some(row) = self.rows.get(row_nr as usize) {
if row.selected {
ui.painter()
.rect_filled(fill, 0.0, ui.visuals().selection.bg_fill);
} else if ui.rect_contains_pointer(fill) {
ui.painter()
.rect_filled(fill, 0.0, ui.visuals().widgets.hovered.bg_fill);
}
ui.painter()
.hline(self.band_x, r.max.y, egui::Stroke::new(1.0, self.line));
}
}
fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
let Some(row) = self.rows.get_mut(cell.row_nr as usize) else {
return;
};
let Some(slot) = row.cells.get_mut(cell.col_nr) else {
return;
};
let hit = ui.interact(
ui.max_rect(),
ui.id().with("pin_cell"),
egui::Sense::click(),
);
let ctx = PinCellCtx { hit };
egui::Frame::new()
.inner_margin(egui::Margin {
left: 6,
right: 6,
top: 0,
bottom: 0,
})
.show(ui, |ui| slot(ui, &ctx));
}
fn default_row_height(&self) -> f32 {
self.row_h
}
}
struct PinDelegate<'d, 'b> {
headers: &'d [&'d str],
sort: Option<(usize, bool)>,
rows: &'d mut [PinRow<'b>],
row_h: f32,
clicked: Option<usize>,
header_drawn: Vec<bool>,
line: egui::Color32,
band_x: egui::Rangef,
band_mid: f32,
}
fn pinned_table<'p>(
ui: &mut egui::Ui,
table_id: &str,
headers: &[&str],
body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
pinned_table_impl(ui, table_id, headers, None, body)
}
fn pinned_table_sorted<'p>(
ui: &mut egui::Ui,
table_id: &str,
headers: &[&str],
sort: Option<(usize, bool)>,
body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
pinned_table_impl(ui, table_id, headers, sort, body)
}
fn pinned_table_impl<'p>(
ui: &mut egui::Ui,
table_id: &str,
headers: &[&str],
sort: Option<(usize, bool)>,
add_body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
pinned_table_impl_cols(ui, table_id, headers, sort, None, add_body)
}
fn header_proportional_widths(ui: &egui::Ui, headers: &[&str], available: f32) -> Vec<f32> {
let head_font = ui.style().text_styles[&egui::TextStyle::Small].clone();
let head_w = |s: &str| -> f32 {
let g = ui.painter().layout_no_wrap(
s.to_string(),
head_font.clone(),
egui::Color32::TRANSPARENT,
);
(g.size().x + 12.0).max(24.0)
};
fill_proportional(headers.iter().map(|h| head_w(h)).collect(), available)
}
fn fill_proportional(mut base: Vec<f32>, available: f32) -> Vec<f32> {
let sum: f32 = base.iter().sum();
if sum > 0.0 && sum < available {
let k = available / sum;
for w in base.iter_mut() {
*w *= k;
}
}
base
}
pub(crate) fn pinned_table_impl_cols<'p>(
ui: &mut egui::Ui,
table_id: &str,
headers: &[&str],
sort: Option<(usize, bool)>,
col_current: Option<Vec<f32>>,
add_body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
let ch = Chrome::of(ui.visuals());
let (row_h, head_h) = table_heights(ui);
let sp = ui.spacing().item_spacing;
let m = ui.visuals().clip_rect_margin;
let left = ui.cursor().min.x - sp.x * 0.5;
let top = ui.cursor().top();
let band = egui::Rect::from_min_max(
egui::pos2(left, top - m),
egui::pos2(left + ui.available_width() + sp.x, top + head_h),
);
ui.painter().rect_filled(band, 0.0, ch.band_fill);
ui.painter()
.hline(band.x_range(), band.max.y, egui::Stroke::new(1.0, ch.line));
let mut body = PinBody::default();
add_body(&mut body, row_h);
let n_rows = body.rows.len() as u64;
let mut delegate = PinDelegate {
headers,
sort,
rows: &mut body.rows,
row_h,
clicked: None,
header_drawn: vec![false; headers.len()],
band_x: band.x_range(),
line: ch.line,
band_mid: band.center().y,
};
let _ret = ui.scope_builder(
egui::UiBuilder::new()
.id_salt(("pinned_table", table_id))
.max_rect(egui::Rect::from_min_max(ui.cursor().min, ui.max_rect().max)),
|ui| {
let state_id = egui_table::TableState::id(ui, egui::Id::new(table_id));
let sc = &ui.style().spacing.scroll;
let reserve = if sc.floating {
0.0
} else {
sc.bar_width + sc.bar_inner_margin + sc.bar_outer_margin
};
let target = (ui.available_width() - reserve).max(0.0);
let fill_id = egui::Id::new(("table_fill", table_id));
let (last_target, last_stored, filled) = ui.ctx().data(|d| {
(
d.get_temp::<f32>(fill_id).unwrap_or(0.0),
d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
d.get_temp::<bool>(fill_id.with("filled")).unwrap_or(false),
)
});
let mut st = egui_table::TableState::load(ui.ctx(), state_id).unwrap_or_default();
let stored: f32 = st.col_widths.values().sum();
let may_fill = !filled
&& ((target - last_target).abs() > 8.0 || (stored - last_stored).abs() > 0.5);
let mut did_fill = false;
let mut new_stored = stored;
if col_current.is_none() && may_fill && stored < target {
if stored <= 0.0 {
if n_rows == 0 {
for (i, w) in header_proportional_widths(ui, headers, target)
.into_iter()
.enumerate()
{
st.col_widths.insert(egui::Id::new(i), w);
}
did_fill = true;
}
} else {
let last = egui::Id::new(headers.len() - 1);
let cur = st.col_widths.get(&last).copied().unwrap_or(24.0);
let add = (target - stored).min((target * 0.5 - cur).max(0.0));
if add > 0.5 {
st.col_widths.insert(last, cur + add);
did_fill = true;
}
}
if did_fill {
new_stored = st.col_widths.values().sum::<f32>();
st.store(ui.ctx(), state_id);
}
}
ui.ctx().data_mut(|d| {
d.insert_temp(fill_id, target);
d.insert_temp(fill_id.with("stored"), new_stored);
d.insert_temp(fill_id.with("filled"), did_fill || filled);
});
let seed: Option<Vec<f32>> = col_current;
ui.style_mut().visuals.widgets.noninteractive.bg_stroke =
egui::Stroke::new(1.0, egui::Color32::TRANSPARENT);
let columns: Vec<egui_table::Column> = match &seed {
Some(ws) => headers
.iter()
.zip(ws.iter().copied())
.enumerate()
.map(|(i, (_, w))| {
egui_table::Column::new(w)
.range(24.0..=f32::INFINITY)
.id(egui::Id::new(i))
.resizable(true)
})
.collect(),
None => headers
.iter()
.enumerate()
.map(|(i, _)| {
egui_table::Column::new(100.0)
.range(24.0..=f32::INFINITY)
.id(egui::Id::new(i))
.resizable(true)
})
.collect(),
};
egui_table::Table::new()
.id_salt(table_id)
.num_rows(n_rows)
.columns(columns)
.headers([egui_table::HeaderRow::new(head_h)])
.auto_size_mode(egui_table::AutoSizeMode::Never)
.show(ui, &mut delegate);
},
);
delegate.clicked
}
pub(crate) trait CellOutcome {
fn response(&self) -> Option<egui::Response>;
}
impl CellOutcome for egui::Response {
fn response(&self) -> Option<egui::Response> {
Some(self.clone())
}
}
impl CellOutcome for () {
fn response(&self) -> Option<egui::Response> {
None
}
}
impl<T: CellOutcome> CellOutcome for egui::InnerResponse<T> {
fn response(&self) -> Option<egui::Response> {
self.inner
.response()
.or_else(|| Some(self.response.clone()))
}
}
fn sorted_rows<'a, T>(
rows: &'a [T],
sort: Option<(usize, bool)>,
key: impl Fn(&T, usize) -> String,
) -> Vec<&'a T> {
let mut out: Vec<&'a T> = rows.iter().collect();
if let Some((col, asc)) = sort {
out.sort_by(|a, b| {
let (ka, kb) = (key(a, col), key(b, col));
if asc { ka.cmp(&kb) } else { kb.cmp(&ka) }
});
}
out
}
fn trade_cells(t: &ek_data::trade::TradeData) -> Vec<String> {
vec![
t.symbol.clone(),
if t.name.is_empty() {
"-".to_string()
} else {
t.name.clone()
},
t.exchange.as_str().to_string(),
t.direction
.map(|d| d.as_str().to_string())
.unwrap_or_else(|| "-".to_string()),
t.offset.as_str().to_string(),
crate::format::price(t.price),
crate::format::volume(t.volume),
crate::format::price(t.traded_amount),
crate::format::time_hms_millis(t.datetime),
t.tradeid.clone(),
t.orderid.clone(),
t.gateway_name.clone(),
]
}
fn trade_sort_key(t: &ek_data::trade::TradeData, col: usize) -> String {
match col {
5 => format!("{:020.4}", t.price),
6 => format!("{:020.4}", t.volume),
7 => format!("{:020.4}", t.traded_amount),
8 => t.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
c => trade_cells(t).get(c).cloned().unwrap_or_default(),
}
}
fn save_table_csv<T>(
table: &str,
headers: &[&str],
rows: &[&T],
cells: impl Fn(&T) -> Vec<String>,
) -> std::io::Result<()> {
let mut s = headers.join(",");
s.push('\n');
for r in rows {
s.push_str(&cells(r).join(","));
s.push('\n');
}
let dir = std::env::current_dir()?.join("target");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(format!("ek-ui-{table}.csv")), s)
}
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);
}
if ui.button("恢复布局").clicked() {
shared.reset_layout();
ui.ctx().data_mut(|d| {
d.remove::<egui::containers::panel::PanelState>(egui::Id::new("market_band"));
d.remove::<egui::containers::panel::PanelState>(egui::Id::new("order_side"));
});
}
ui.menu_button(MENU_HELP, |ui| {
ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
});
});
}
const TICK_HEADERS: [&str; 14] = [
"代码",
"名称",
"最新价",
"涨跌幅",
"成交量",
"今开",
"最高",
"最低",
"买1价",
"买1量",
"卖1价",
"卖1量",
"时间",
"行情源",
];
pub(crate) fn render_market_pane(
ui: &mut egui::Ui,
shared: &UiShared,
ex: ek_data::constant::Exchange,
) {
let table_id = format!("tick_{}", ex.as_str());
let groups = shared.tick_groups();
let ticks: Vec<ek_data::tick::TickData> = groups
.iter()
.find(|(e, _)| e == ex.as_str())
.map(|(_, v)| v.clone())
.unwrap_or_default();
let sort = shared.table_sort(&table_id);
let ticks = sorted_rows(&ticks, sort, tick_sort_key);
let clicked = pinned_table_sorted(ui, &table_id, &TICK_HEADERS, sort, |body, row_h| {
const RIGHT_COLS: [usize; 10] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
for t in &ticks {
let row_sel = shared.order_form().symbol == t.symbol;
body.row(row_h, row_sel, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
r.cell(|ui, ctx| {
let (cell_r, inner_r) =
pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
let hit =
cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
if hit {
import_tick(shared, t, ex.as_str(), $col);
}
let dbl = cell_r.double_clicked()
|| inner_r.as_ref().is_some_and(|r| r.double_clicked());
if dbl {
shared.open_kline(t.symbol.clone());
}
});
}};
}
cell!(|ui| ui.button(t.symbol.clone()), 0);
cell!(
|ui| ui.label(if t.name.is_empty() {
"-".to_string()
} else {
t.name.clone()
}),
1
);
cell!(|ui| ui.label(crate::format::price(t.last_price)), 2);
cell!(
|ui| ui.label(
egui::RichText::new(crate::format::pct(t.pct_change))
.color(crate::tokens::rise_fall(t.pct_change)),
),
3
);
cell!(|ui| ui.label(crate::format::volume(t.volume)), 4);
cell!(|ui| ui.label(crate::format::price(t.open_price)), 5);
cell!(|ui| ui.label(crate::format::price(t.high_price)), 6);
cell!(|ui| ui.label(crate::format::price(t.low_price)), 7);
cell!(
|ui| ui.label(
egui::RichText::new(crate::format::price(t.bid_price_1))
.color(crate::tokens::semantic::RISE),
),
8
);
cell!(
|ui| ui.label(
egui::RichText::new(crate::format::volume(t.bid_volume_1))
.color(crate::tokens::semantic::RISE),
),
9
);
cell!(
|ui| ui.label(
egui::RichText::new(crate::format::price(t.ask_price_1))
.color(crate::tokens::semantic::FALL),
),
10
);
cell!(
|ui| ui.label(
egui::RichText::new(crate::format::volume(t.ask_volume_1))
.color(crate::tokens::semantic::FALL),
),
11
);
cell!(
|ui| ui.label(if t.update_time.is_empty() {
crate::format::time_hms_millis(Some(t.datetime))
} else {
t.update_time.clone()
}),
12
);
cell!(|ui| ui.label(t.gateway_name.clone()), 13);
});
}
});
if let Some(col) = clicked {
shared.toggle_table_sort(&table_id, col);
}
}
fn tick_sort_key(t: &ek_data::tick::TickData, col: usize) -> String {
match col {
0 => t.symbol.clone(),
1 => t.name.clone(),
2 => format!("{:020.4}", t.last_price),
3 => format!("{:020.4}", t.pct_change),
4 => format!("{:020.4}", t.volume),
5 => format!("{:020.4}", t.open_price),
6 => format!("{:020.4}", t.high_price),
7 => format!("{:020.4}", t.low_price),
8 => format!("{:020.4}", t.bid_price_1),
9 => format!("{:020.4}", t.bid_volume_1),
10 => format!("{:020.4}", t.ask_price_1),
11 => format!("{:020.4}", t.ask_volume_1),
12 => {
if t.update_time.is_empty() {
format!("{:025}", t.datetime)
} else {
t.update_time.clone()
}
}
13 => t.gateway_name.clone(),
_ => String::new(),
}
}
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
&& let Some(t) = tick_of(shared, &f.exchange, &f.symbol)
&& t.last_price > 0.0
{
f.price = crate::format::price(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()
&& 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| {
crate::ui_ext::primary_button(ui, 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();
crate::ui_ext::muted_label(ui, "代码回车 = 订阅");
ui.label("");
ui.end_row();
});
if let Some(e) = err {
ui.label(egui::RichText::new(e).color(crate::tokens::semantic::ERROR));
}
if no_accounts {
ui.label(
egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
.color(crate::tokens::semantic::WARN),
);
}
if f != before {
shared.set_order_form(f.clone());
}
}
const BOOK_ASK_COLOR: egui::Color32 = crate::tokens::semantic::FALL;
const BOOK_BID_COLOR: egui::Color32 = crate::tokens::semantic::RISE;
fn book_row(row: &mut egui::Ui, tag: String, color: egui::Color32, price: f64, volume: f64) {
row.label(egui::RichText::new(tag).color(color));
let _ = crate::ui_ext::num_cell(row, |ui| {
ui.label(egui::RichText::new(crate::format::price(price)).color(color))
});
let _ = crate::ui_ext::num_cell(row, |ui| {
ui.label(egui::RichText::new(crate::format::volume(volume)).color(color))
});
}
fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
let f = shared.order_form();
let Some(t) = tick_of(shared, &f.exchange, &f.symbol) else {
crate::ui_ext::muted_label(ui, "(无 tick)");
return;
};
let (_, _head_h) = table_heights(ui);
let asks: [(f64, f64); 5] = [
(t.ask_price_5, t.ask_volume_5),
(t.ask_price_4, t.ask_volume_4),
(t.ask_price_3, t.ask_volume_3),
(t.ask_price_2, t.ask_volume_2),
(t.ask_price_1, t.ask_volume_1),
];
let bids: [(f64, f64); 5] = [
(t.bid_price_1, t.bid_volume_1),
(t.bid_price_2, t.bid_volume_2),
(t.bid_price_3, t.bid_volume_3),
(t.bid_price_4, t.bid_volume_4),
(t.bid_price_5, t.bid_volume_5),
];
egui::Grid::new("book_grid")
.spacing([4.0, 0.0])
.show(ui, |grid| {
for (k, (p, v)) in asks.iter().enumerate() {
book_row(grid, format!("卖{}", 5 - k), BOOK_ASK_COLOR, *p, *v);
grid.end_row();
}
for (k, (p, v)) in bids.iter().enumerate() {
book_row(grid, format!("买{}", k + 1), BOOK_BID_COLOR, *p, *v);
grid.end_row();
}
});
}
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] = [
"代码",
"名称",
"交易所",
"类型",
"方向",
"开平",
"价格",
"总量",
"已成",
"成交均价",
"状态",
"状态描述",
"时间",
"委托号",
"来源",
"接口",
"操作",
];
let table_id = if active_only {
"active_grid"
} else {
"order_grid"
};
let sort = shared.table_sort(table_id);
let rows = sorted_rows(&rows, sort, |o, col| order_sort_key(o, col));
let clicked = pinned_table_sorted(ui, table_id, &ORDER_HEADERS, sort, |body, row_h| {
const RIGHT_COLS: [usize; 4] = [6, 7, 8, 9];
let known_gws = &known_gws;
for o in &rows {
let key = format!("{}.{}", o.gateway_name, o.orderid);
let row_sel = shared.row_selected("orders", &key);
body.row(row_h, row_sel, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
let key = key.clone();
r.cell(move |ui, ctx| {
let (cell_r, inner_r) =
pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
let hit =
cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
if hit {
shared.select_row("orders", &key);
}
let dbl = cell_r.double_clicked()
|| inner_r.as_ref().is_some_and(|r| r.double_clicked());
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,
});
}
}
});
}};
}
cell!(|ui| ui.label(o.symbol.clone()), 0);
cell!(
|ui| ui.label(if o.name.is_empty() {
"-".to_string()
} else {
o.name.clone()
}),
1
);
cell!(|ui| ui.label(o.exchange.as_str().to_string()), 2);
cell!(|ui| ui.label(o.order_type.as_str().to_string()), 3);
cell!(
|ui| ui.label(
egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
if o.direction == Some(Direction::SHORT) {
crate::tokens::semantic::FALL
} else {
crate::tokens::semantic::RISE
},
),
),
4
);
cell!(|ui| ui.label(o.offset.as_str().to_string()), 5);
cell!(|ui| ui.label(crate::format::price(o.price)), 6);
cell!(|ui| ui.label(crate::format::volume(o.volume)), 7);
cell!(|ui| ui.label(crate::format::volume(o.traded)), 8);
cell!(|ui| ui.label(crate::format::price(o.traded_price)), 9);
cell!(
|ui| ui.label(egui::RichText::new(o.status.as_str()).color(
if o.status == ek_data::constant::Status::REJECTED {
crate::tokens::semantic::RISE
} else if o.status == ek_data::constant::Status::ALLTRADED {
crate::tokens::semantic::FALL
} else {
crate::tokens::semantic::WARN
},
)),
10
);
cell!(|ui| ui.label(o.status_msg.clone()), 11);
cell!(
|ui| ui.label(crate::format::time_hms_millis(o.datetime)),
12
);
cell!(|ui| ui.label(o.orderid.clone()), 13);
cell!(
|ui| ui.label(if o.reference.is_empty() {
"-".to_string()
} else {
o.reference.clone()
}),
14
);
cell!(
|ui| ui.label(ledger_gw_label(&o.gateway_name, known_gws)),
15
);
r.cell(|ui, _ctx| {
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,
});
}
}
});
});
});
}
});
if let Some(col) = clicked {
shared.toggle_table_sort(table_id, col);
}
}
fn order_sort_key(o: &ek_data::order::OrderData, col: usize) -> String {
match col {
0 => o.symbol.clone(),
1 => o.name.clone(),
2 => o.exchange.as_str().to_string(),
3 => o.order_type.as_str().to_string(),
4 => o
.direction
.map(|d| d.as_str().to_string())
.unwrap_or_default(),
5 => o.offset.as_str().to_string(),
6 => format!("{:020.4}", o.price),
7 => format!("{:020.4}", o.volume),
8 => format!("{:020.4}", o.traded),
9 => format!("{:020.4}", o.traded_price),
10 => o.status.as_str().to_string(),
11 => o.status_msg.clone(),
12 => o.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
13 => o.orderid.clone(),
14 => o.reference.clone(),
15 => o.gateway_name.clone(),
_ => String::new(),
}
}
pub(crate) fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
tab_label(ui, true, "成交");
let ts = shared.trade_snap();
let known_gws = account_gateways(shared);
const TRADE_HEADERS: [&str; 12] = [
"代码",
"名称",
"交易所",
"方向",
"开平",
"价格",
"数量",
"成交金额",
"时间",
"成交号",
"委托号",
"接口",
];
let sort = shared.table_sort("trade_grid");
let rows = sorted_rows(&ts.trades, sort, trade_sort_key);
let clicked = pinned_table_sorted(ui, "trade_grid", &TRADE_HEADERS, sort, |body, row_h| {
const RIGHT_COLS: [usize; 3] = [5, 6, 7];
let rows = &rows;
let known_gws = &known_gws;
for t in rows.iter().copied() {
let key = format!("{}.{}", t.gateway_name, t.tradeid);
let row_sel = shared.row_selected("trades", &key);
body.row(row_h, row_sel, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
let key = key.clone();
r.cell(move |ui, ctx| {
let (cell_r, inner_r) =
pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
let hit =
cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
if hit {
shared.select_row("trades", &key);
}
let dbl = cell_r.double_clicked()
|| inner_r.as_ref().is_some_and(|r| r.double_clicked());
if dbl {
let mut f = shared.order_form();
f.symbol = t.symbol.clone();
if let Some(d) = t.direction {
f.direction = d;
}
shared.set_order_form(f);
}
});
}};
}
cell!(
|ui| {
let r = ui
.add(egui::Label::new(t.symbol.clone()).sense(egui::Sense::click()))
.on_hover_text("右键:保存数据");
r.context_menu(|ui| {
if ui.button("保存数据").clicked() {
let _ =
save_table_csv("trade_grid", &TRADE_HEADERS, rows, trade_cells);
ui.close();
}
});
r
},
0
);
cell!(
|ui| ui.label(if t.name.is_empty() {
"-".to_string()
} else {
t.name.clone()
}),
1
);
cell!(|ui| ui.label(t.exchange.as_str().to_string()), 2);
cell!(
|ui| ui.label(
egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
if t.direction == Some(Direction::SHORT) {
crate::tokens::semantic::FALL
} else {
crate::tokens::semantic::RISE
},
),
),
3
);
cell!(|ui| ui.label(t.offset.as_str().to_string()), 4);
cell!(|ui| ui.label(crate::format::price(t.price)), 5);
cell!(|ui| ui.label(crate::format::volume(t.volume)), 6);
cell!(|ui| ui.label(crate::format::price(t.traded_amount)), 7);
cell!(|ui| ui.label(crate::format::time_hms_millis(t.datetime)), 8);
cell!(|ui| ui.label(t.tradeid.clone()), 9);
cell!(|ui| ui.label(t.orderid.clone()), 10);
cell!(
|ui| ui.label(ledger_gw_label(&t.gateway_name, known_gws)),
11
);
});
}
});
if let Some(col) = clicked {
shared.toggle_table_sort("trade_grid", col);
}
}
pub(crate) fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
tab_label(ui, true, "持仓");
let ts = shared.trade_snap();
let known_gws = account_gateways(shared);
const POS_HEADERS: [&str; 16] = [
"代码",
"名称",
"交易所",
"方向",
"数量",
"昨仓",
"可用",
"冻结",
"在途",
"市值",
"成本",
"均价",
"最新价",
"盈亏",
"盈亏比",
"接口",
];
let sort = shared.table_sort("pos_grid");
let rows = sorted_rows(&ts.positions, sort, pos_sort_key);
let clicked = pinned_table_sorted(ui, "pos_grid", &POS_HEADERS, sort, |body, row_h| {
const RIGHT_COLS: [usize; 11] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
let known_gws = &known_gws;
for p in &rows {
let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
let row_sel = shared.row_selected("positions", &key);
body.row(row_h, row_sel, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
let key = key.clone();
r.cell(move |ui, ctx| {
let (cell_r, inner_r) =
pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
let hit =
cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
if hit {
shared.select_row("positions", &key);
}
let dbl = cell_r.double_clicked()
|| inner_r.as_ref().is_some_and(|r| r.double_clicked());
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
));
}
});
}};
}
cell!(|ui| ui.label(p.symbol.clone()), 0);
cell!(
|ui| ui.label(if p.name.is_empty() {
"-".to_string()
} else {
p.name.clone()
}),
1
);
cell!(|ui| ui.label(p.exchange.as_str().to_string()), 2);
cell!(
|ui| ui.label(egui::RichText::new(p.direction.as_str()).color(
if p.direction == ek_data::constant::Direction::SHORT {
crate::tokens::semantic::FALL
} else {
crate::tokens::semantic::RISE
},
)),
3
);
cell!(|ui| ui.label(crate::format::volume(p.volume)), 4);
cell!(|ui| ui.label(crate::format::volume(p.yd_volume)), 5);
cell!(|ui| ui.label(crate::format::volume(p.can_use_volume)), 6);
cell!(|ui| ui.label(crate::format::volume(p.frozen)), 7);
cell!(|ui| ui.label(crate::format::volume(p.on_road_volume)), 8);
cell!(|ui| ui.label(crate::format::price(p.market_value)), 9);
cell!(|ui| ui.label(crate::format::price(p.avg_price)), 10);
cell!(|ui| ui.label(crate::format::price(p.price)), 11);
cell!(|ui| ui.label(crate::format::price(p.last_price)), 12);
cell!(
|ui| ui.label(egui::RichText::new(crate::format::price(p.pnl)).color(
if p.pnl < 0.0 {
crate::tokens::semantic::FALL
} else {
crate::tokens::semantic::RISE
}
),),
13
);
cell!(|ui| ui.label(crate::format::price(p.profit_rate)), 14);
cell!(
|ui| ui.label(ledger_gw_label(&p.gateway_name, known_gws)),
15
);
});
}
});
if let Some(col) = clicked {
shared.toggle_table_sort("pos_grid", col);
}
}
fn pos_sort_key(p: &ek_data::position::PositionData, col: usize) -> String {
match col {
0 => p.symbol.clone(),
1 => p.name.clone(),
2 => p.exchange.as_str().to_string(),
3 => p.direction.as_str().to_string(),
4 => format!("{:020.4}", p.volume),
5 => format!("{:020.4}", p.yd_volume),
6 => format!("{:020.4}", p.can_use_volume),
7 => format!("{:020.4}", p.frozen),
8 => format!("{:020.4}", p.on_road_volume),
9 => format!("{:020.4}", p.market_value),
10 => format!("{:020.4}", p.avg_price),
11 => format!("{:020.4}", p.price),
12 => format!("{:020.4}", p.last_price),
13 => format!("{:020.4}", p.pnl),
14 => format!("{:020.4}", p.profit_rate),
15 => p.gateway_name.clone(),
_ => String::new(),
}
}
pub(crate) fn render_funds_pane(ui: &mut egui::Ui, shared: &UiShared) {
let ts = shared.trade_snap();
pinned_table_impl(
ui,
"acct_grid",
&["账号", "接口", "余额", "可用", "冻结"],
None,
|body, row_h| {
const RIGHT_COLS: [usize; 3] = [2, 3, 4];
for a in &ts.accounts {
body.row(row_h, false, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
r.cell(|ui, ctx| {
let _ = pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
});
}};
}
cell!(|ui| ui.label(a.accountid.clone()), 0);
cell!(|ui| ui.label(a.gateway_name.clone()), 1);
cell!(|ui| ui.label(crate::format::price(a.balance)), 2);
cell!(|ui| ui.label(crate::format::price(a.available())), 3);
cell!(|ui| ui.label(crate::format::price(a.frozen)), 4);
});
}
},
);
}
fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
let (entries, spilled) = shared.logs_entries();
if spilled > 0 {
ui.label(format!(
"已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
crate::state::RING_CAP
));
}
let mono_font = egui::TextStyle::Monospace.resolve(ui.style());
let widths: Option<Vec<f32>> = if entries.is_empty() {
None
} else {
Some(vec![
{
let g = ui.painter().layout_no_wrap(
"18:00:00.000".to_string(),
mono_font.clone(),
egui::Color32::TRANSPARENT,
);
g.size().x + 12.0
},
{
let mut w: f32 = 120.0;
for e in &entries {
let g = ui.painter().layout_no_wrap(
e.text.clone(),
mono_font.clone(),
egui::Color32::TRANSPARENT,
);
w = w.max(g.size().x + 12.0);
}
w
},
])
};
pinned_table_impl_cols(
ui,
"log_grid",
&["时间", "信息"],
None,
widths,
|body, row_h| {
for e in &entries {
body.row(row_h, false, |r| {
r.cell(|ui, _ctx| {
ui.monospace(e.at.clone());
});
r.cell(|ui, _ctx| {
ui.monospace(e.text.clone());
});
});
}
},
);
}
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) {
crate::ui_ext::section_title(ui, "插件");
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();
let mut plug_rows: Vec<f32> = Vec::new();
let plug_col_w = crate::ui_ext::grid_col_width(ui, "plug_grid", 6);
let plug_row_h = table_heights(ui).0;
egui::Grid::new("plug_grid")
.num_columns(6)
.min_col_width(plug_col_w)
.min_row_height(plug_row_h)
.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();
for r in &snap.rows {
if is_quarantined(&r.state) {
ui.label(
egui::RichText::new(r.name.clone()).color(crate::tokens::semantic::ERROR),
);
ui.label(
egui::RichText::new(state_text(r))
.color(crate::tokens::semantic::ERROR)
.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(
egui::RichText::new(format!("spilled:{} panics:{}", r.spilled, r.panics))
.color(stat_color(r.spilled, r.panics)),
);
ui.horizontal(|ui| {
if account_gateways.contains(&r.name) {
ui.label(
egui::RichText::new("起停归『账户』面板(带撤单守卫)")
.color(crate::tokens::semantic::INFO),
);
} else if r.name == UI_NAME || r.name == SVCTL_NAME {
ui.label(
egui::RichText::new("管制面/观测面自身:不在运维窗摘除")
.color(crate::tokens::semantic::INFO),
);
if ui.small_button(format!("重注入 {}", r.name)).clicked() {
shared.push_cmd(UiCmd::Start {
factory: r.name.clone(),
name: String::new(),
});
}
} 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(),
});
}
}
});
plug_rows.push(ui.min_rect().max.y);
ui.end_row();
}
});
let plug_band = header_band(ui, &plug_heads, ui.min_rect().max.x);
grid_chrome(ui, plug_band, &plug_heads, &plug_rows);
if let Some(name) = shared.pending_confirm() {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
.color(crate::tokens::semantic::WARN),
);
if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
shared.confirm_cancel();
}
});
}
let pipeline = shared.pipeline_lines();
if !pipeline.is_empty() {
crate::ui_ext::space_group(ui);
crate::ui_ext::section_title(ui, "oms 管线");
for line in &pipeline {
ui.label(egui::RichText::new(line).monospace());
}
}
crate::ui_ext::space_group(ui);
crate::ui_ext::section_title(ui, "服务");
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();
let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
let mut svc_rows: Vec<f32> = Vec::new();
let svc_col_w = crate::ui_ext::grid_col_width(ui, "svc_grid", 6);
let svc_row_h = table_heights(ui).0;
egui::Grid::new("svc_grid")
.num_columns(6)
.min_col_width(svc_col_w)
.min_row_height(svc_row_h)
.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();
let rows = flat.len().div_ceil(6); 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("");
}
}
svc_rows.push(ui.min_rect().max.y);
ui.end_row();
}
});
let svc_band = header_band(ui, &svc_heads, ui.min_rect().max.x);
grid_chrome(ui, svc_band, &svc_heads, &svc_rows);
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(crate::tokens::semantic::WARN),
);
}
});
const ACCOUNT_HEADERS: [&str; 7] = [
"网关(派生名)",
"别名",
"账号",
"状态",
"行情源",
"收录(勾→实)",
"操作",
];
let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
let mut acct_rows: Vec<f32> = Vec::new();
let acct_col_w = crate::ui_ext::grid_col_width(ui, "account_grid", 7);
let acct_row_h = table_heights(ui).0;
egui::Grid::new("account_grid")
.num_columns(7)
.min_col_width(acct_col_w)
.min_row_height(acct_row_h)
.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();
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(crate::tokens::semantic::ERROR),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
}
_ => {
let color = match a.login {
crate::state::LoginState::Online => crate::tokens::semantic::OK,
crate::state::LoginState::LoginFailed
| crate::state::LoginState::FrontDown => {
crate::tokens::semantic::ERROR
}
crate::state::LoginState::QueryStarved => {
crate::tokens::semantic::WARN
}
_ => crate::tokens::semantic::MUTED,
};
let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
(true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
_ => a.status_label(),
};
ui.add(
egui::Label::new(egui::RichText::new(text).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(crate::tokens::semantic::WARN),
);
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
));
}
}
}
});
acct_rows.push(ui.min_rect().max.y);
ui.end_row();
}
});
let acct_band = header_band(ui, &acct_heads, ui.min_rect().max.x);
grid_chrome(ui, acct_band, &acct_heads, &acct_rows);
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(crate::tokens::semantic::WARN),
);
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(crate::tokens::semantic::INFO),
);
ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).color(crate::tokens::semantic::INFO));
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 {
crate::tokens::semantic::ERROR
} else {
crate::tokens::semantic::INFO
};
ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
}
if let Some(src) = accounts.iter().find(|a| a.gateway == md)
&& 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(crate::tokens::semantic::WARN),
);
}
}
const ACCT_FORM_LABEL_W: f32 = 86.0;
fn acct_form_label(ui: &mut egui::Ui, text: &str) {
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(
ACCT_FORM_LABEL_W,
ui.text_style_height(&egui::TextStyle::Body),
),
)),
|ui| {
ui.label(text);
},
);
}
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(crate::tokens::semantic::ERROR));
}
ui.horizontal(|ui| {
acct_form_label(ui, "别名");
if ui
.add(
egui::TextEdit::singleline(&mut alias)
.hint_text("显示用,可空")
.desired_width(120.0),
)
.changed()
{
shared.set_edit_alias(alias.clone());
}
});
ui.horizontal(|ui| {
acct_form_label(ui, "broker");
ui.add_enabled_ui(!editing_on, |ui| {
ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
});
});
ui.horizontal(|ui| {
acct_form_label(ui, "td 前置");
ctp_edit(
ui,
shared,
&mut form.td_addr,
"tcp://host:40001",
CtpField::TdAddr,
);
});
ui.horizontal(|ui| {
acct_form_label(ui, "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(crate::tokens::semantic::WARN),
);
}
ui.horizontal(|ui| {
acct_form_label(ui, "td 密码");
ctp_edit(
ui,
shared,
&mut form.td_password,
"密码",
CtpField::TdPassword,
);
});
ui.horizontal(|ui| {
acct_form_label(ui, "AppID");
ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
});
ui.horizontal(|ui| {
acct_form_label(ui, "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| {
acct_form_label(ui, "md 前置");
ui.add_enabled_ui(md_editable, |ui| {
ctp_edit(
ui,
shared,
&mut form.md_addr,
"tcp://host:40011",
CtpField::MdAddr,
);
});
});
if !md_editable {
ui.horizontal(|ui| {
acct_form_label(ui, "");
ui.label(
egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
.color(crate::tokens::semantic::WARN),
);
});
}
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(crate::tokens::semantic::WARN),
);
}
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] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
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(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
const RIGHT_COLS: [usize; 2] = [3, 4];
for c in &rows {
let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
let on = selected.contains(&key);
let row_sel = shared.row_selected("contracts", &key);
let key_owned = key.clone();
let on_owned = on;
body.row(row_h, row_sel, |r| {
macro_rules! cell {
($make:expr, $col:expr) => {{
let key = key_owned.clone();
r.cell(move |ui, ctx| {
let (cell_r, inner_r) =
pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
let hit =
cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
if hit {
shared.select_row("contracts", &key.clone());
}
});
}};
}
{
let key = key_owned.clone();
let mut on = on_owned;
r.cell(move |ui, _ctx| {
if ui.checkbox(&mut on, key.clone()).changed() {
shared.toggle_contract_selection(&key);
shared.select_row("contracts", &key);
}
});
}
cell!(|ui| ui.label(c.exchange.as_str().to_string()), 1);
cell!(|ui| ui.label(c.name.clone()), 2);
cell!(|ui| ui.label(crate::format::volume(c.size)), 3);
cell!(|ui| ui.label(crate::format::price_tick(c.price_tick)), 4);
});
}
});
}
enum CtpField {
Broker,
TdAddr,
TdUser,
TdPassword,
AppId,
AuthCode,
MdAddr,
}
fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
let is_secret = matches!(field, CtpField::TdPassword | 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(),
}
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 cfg = egui_charts::config::ChartConfig {
bullish_color: crate::tokens::semantic::RISE,
bearish_color: crate::tokens::semantic::FALL,
background_color: crate::tokens::shadcn::BG1,
grid_color: crate::tokens::shadcn::BORDER,
text_color: crate::tokens::shadcn::TEXT1,
..egui_charts::config::ChartConfig::default()
};
let mut chart = egui_charts::widget::Chart::with_config(data, cfg).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_derives_from_token_palette() {
let v = crate::tokens::visuals();
let ch = Chrome::of(&v);
assert_eq!(ch.line, crate::tokens::shadcn::BORDER);
assert_eq!(ch.band_fill, crate::tokens::shadcn::BG4);
assert_eq!(ch.header_text, crate::tokens::shadcn::TEXT1);
}
#[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!(crate::format::time_hms_millis(Some(ns)), "00:00:30.000");
assert_eq!(crate::format::time_hms_millis(None), "-");
}
}