use ek_data::constant::{Direction, Offset, OrderType};
use ek_station::wire::PlugRow;
use crate::state::{
BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, 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_CTP_CONNECT: &str = "连接 CTP";
pub const MENU_OPS: &str = "运维";
pub const MENU_CTP: &str = "网关";
pub const MENU_HELP: &str = "帮助";
pub const MENU_CONTRACTS: &str = "查询合约";
pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
if armed_on == Some(name) {
format!("确认移除 {name} ?")
} else {
format!("移除 {name}")
}
}
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 beijing_tz() -> chrono::FixedOffset {
chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
}
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
}
}
}
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(ui, "grp_bottomright", gw, gh, |ui| {
render_bottomright_group(ui, shared);
});
});
});
render_windows(ctx, shared);
}
fn group_frame(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| {
egui::ScrollArea::both()
.id_salt(id)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.vertical(content);
});
});
}
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);
});
}
fn pinned_table(
ui: &mut egui::Ui,
grid_id: &str,
num_columns: usize,
headers: &[&str],
body: impl FnOnce(&mut egui::Ui),
) {
let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
std::cell::RefCell::new(Vec::new());
let resp = egui::ScrollArea::both()
.id_salt(format!("{grid_id}_sc"))
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new(grid_id)
.striped(true)
.num_columns(num_columns)
.show(ui, |ui| {
for h in headers {
let r = ui.strong(*h);
heads_cell.borrow_mut().push((r.rect, h.to_string()));
}
ui.end_row();
body(ui);
});
});
let heads = heads_cell.borrow();
if heads.is_empty() {
return;
}
let area = resp.inner_rect;
let head_h = heads[0].0.height();
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 + ui.spacing().item_spacing.y,
),
);
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, ui.visuals().panel_fill);
let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
let color = ui.visuals().strong_text_color();
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(),
color,
);
}
},
);
}
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_CTP).clicked() {
shared.set_ctp_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 ui.selectable_label(*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,
"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();
for g in 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()))
{
if !g.is_empty() && !gw_opts.contains(&g) {
gw_opts.push(g);
}
}
for g in ["ctp"] {
if !gw_opts.iter().any(|x| x == g) {
gw_opts.push(g.to_string());
}
}
if !gw_opts.contains(&f.gateway) {
gw_opts.insert(0, f.gateway.clone());
}
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();
shared.push_cmd(UiCmd::CtpSubscribe {
symbols: vec![bare.clone()],
});
shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
}
}
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() {
"ctp".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| {
if ui.button(BTN_SEND_ORDER).clicked() {
match f.to_request() {
Ok(req) => match crate::state::parse_gateway(&f.gateway) {
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() {
"ctp".to_string()
} else {
o.gateway_name.clone()
};
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 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 ui
.selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
.clicked()
{
shared.set_topleft_tab(TopLeftTab::Orders);
}
if ui
.selectable_label(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 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,
if active_only {
"active_grid"
} else {
"order_grid"
},
17,
&ORDER_HEADERS,
|ui| {
ui.label(if active_only {
"(无未成交)"
} else {
"(无委托:等网关 send_order)"
});
},
);
return;
}
pinned_table(
ui,
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(o.gateway_name.clone()), 15);
if dbl && order_active(o.status) {
let gw = if o.gateway_name.is_empty() {
"ctp".to_string()
} else {
o.gateway_name.clone()
};
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() {
"ctp".to_string()
} else {
o.gateway_name.clone()
};
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) {
ui.label("成交");
let ts = shared.trade_snap();
const TRADE_HEADERS: [&str; 12] = [
"成交号",
"代码",
"名称",
"交易所",
"方向",
"开平",
"价格",
"数量",
"成交额",
"时间",
"委托号",
"接口",
];
if ts.trades.is_empty() {
pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
ui.label("(无成交)");
});
return;
}
pinned_table(ui, "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(t.gateway_name.clone()), 11);
paint_row_bg(ui, &cells, row_sel);
ui.end_row();
}
});
}
fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
ui.label("持仓");
let ts = shared.trade_snap();
const POS_HEADERS: [&str; 16] = [
"代码",
"名称",
"交易所",
"方向",
"数量",
"昨仓",
"可用",
"冻结",
"在途",
"市值",
"成本",
"均价",
"最新价",
"盈亏",
"盈亏比",
"接口",
];
if ts.positions.is_empty() {
pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
ui.label("(无持仓)");
});
return;
}
pinned_table(ui, "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(p.gateway_name.clone()), 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;
shared.set_order_form(f);
shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
}
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 ui
.selectable_label(
tab == BottomRightTab::Account,
BottomRightTab::Account.label(),
)
.clicked()
{
shared.set_bottomright_tab(BottomRightTab::Account);
}
if ui
.selectable_label(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;
}
egui::Grid::new("acct_grid")
.striped(true)
.num_columns(5)
.show(ui, |ui| {
for h in ["账号", "接口", "余额", "可用", "冻结"] {
ui.strong(h);
}
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();
}
});
}
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 (_, spilled) = shared.logs_snapshot();
if spilled > 0 {
ui.label(format!(
"已丢弃 {spilled} 条(环形 {})",
crate::state::RING_CAP
));
}
});
let lines = shared.logs_snapshot().0;
egui::ScrollArea::vertical()
.id_salt("log_scroll")
.max_height(180.0)
.stick_to_bottom(true)
.show(ui, |ui| {
for line in lines {
ui.monospace(line);
}
});
}
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.ctp_window_open() {
let closed = show_independent_window(
ctx,
"ctp_win",
"CTP 网关配置窗",
&WinGeom {
inner: [600.0, 380.0],
min_inner: [420.0, 260.0],
maximized: false,
},
|ctx2| {
let mut open = shared.ctp_window_open();
egui::Window::new("CTP 网关配置窗")
.id(egui::Id::new("ctp_win"))
.default_pos([640.0, 320.0])
.default_size([560.0, 330.0])
.open(&mut open)
.show(ctx2, |ui| {
render_ctp_window(ui, shared);
});
shared.set_ctp_window_open(open);
!open
},
|ui| {
render_ctp_window(ui, shared);
false
},
);
if closed {
shared.set_ctp_window_open(false);
}
}
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();
egui::Grid::new("plug_grid")
.striped(true)
.num_columns(6)
.show(ui, |ui| {
ui.strong("名称");
ui.strong("状态");
ui.strong("版本");
ui.strong("依赖");
ui.strong("统计");
ui.strong("操作");
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 ui.small_button(format!("重注入 {}", r.name)).clicked() {
shared.push_cmd(UiCmd::Start {
factory: r.name.clone(),
});
}
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();
}
});
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); egui::Grid::new("svc_grid")
.striped(true)
.num_columns(6)
.show(ui, |ui| {
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();
}
});
}
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_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
let mut form: crate::state::CtpForm = shared.ctp_form();
egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
ui.label("broker");
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 用户");
ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
ui.end_row();
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,
);
ui.end_row();
ui.label("md 前置");
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,
);
ui.end_row();
});
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.horizontal(|ui| {
if ui.small_button(BTN_CTP_CONNECT).clicked() {
shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
}
ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
});
}
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, "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();
shared.push_cmd(UiCmd::CtpSubscribe { symbols });
shared.log_line(format!(
"[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
selected.len()
));
}
}
});
pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |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(110.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 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), "-");
}
}