use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use ek_core::main_engine::MainEngine;
use ek_data::constant::{Direction, Offset, OrderType, Status};
use ek_data::order_request::OrderRequest;
use ek_oms::LockMode;
use ek_station::StationConfig;
use ek_ui::state::{AccountRow, CtpForm, OrderForm, UiCmd, UiShared};
use ek_ui::{apply_cjk_fonts, render_body, spawn_host_watcher, ui_kinds};
struct TourApp {
shared: Arc<UiShared>,
}
impl eframe::App for TourApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
render_body(ctx, &self.shared);
ctx.request_repaint_after(Duration::from_millis(500));
}
}
fn env_or(key: &str, fallback: &str) -> String {
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}
fn banner(shared: &UiShared, step: &str) {
println!("【开盘演示】{step}");
shared.log_line(format!("【开盘演示】{step}"));
}
fn wait_for(secs: u64, mut f: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + Duration::from_secs(secs);
while std::time::Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
fn status_label(code: &str) -> &str {
match code {
"0" => "开盘前",
"1" => "非交易",
"2" => "连续交易",
"3" => "竞价报单",
"4" => "竞价平衡",
"5" => "竞价撮合",
"6" => "收盘",
"7" => "业务处理",
_ => "未知",
}
}
fn harvest_status(shared: &UiShared) -> Vec<(String, String, String, String)> {
let (lines, _) = shared.logs_snapshot();
let mut seen = std::collections::HashSet::new();
let mut out: Vec<(String, String, String, String)> = Vec::new();
for l in lines.iter() {
let Some(at) = l.find("交易状态:") else {
continue;
};
let rest = &l[at + "交易状态:".len()..];
let Some(sp) = rest.find(' ') else { continue };
let inst = rest[..sp].to_string(); let tail = &rest[sp + 1..];
let status = tail
.find("status=")
.map(|i| {
let s = &tail[i + 7..];
s.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
})
.unwrap_or_default();
let time = tail
.find('@')
.map(|i| {
let s = &tail[i + 1..];
s.chars()
.take_while(|c| c.is_ascii_digit() || *c == ':')
.collect::<String>()
})
.unwrap_or_default();
let reason = tail
.find("(原因 ")
.map(|i| {
let s = &tail[i + "(原因 ".len()..];
s.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
})
.unwrap_or_default();
if status.is_empty() {
continue;
}
if seen.insert((inst.clone(), status.clone(), time.clone())) {
out.push((inst, status, time, reason));
}
}
out
}
struct StatusWatch {
seen: std::collections::HashSet<(String, String, String)>,
by_inst: BTreeMap<String, Vec<(String, String)>>,
by_status: BTreeMap<String, usize>,
total: usize,
}
impl StatusWatch {
fn absorb(&mut self, shared: &UiShared) -> usize {
let rows = harvest_status(shared);
let mut fresh = 0usize;
for (inst, status, time, _reason) in rows {
if self
.seen
.insert((inst.clone(), status.clone(), time.clone()))
{
*self.by_status.entry(status.clone()).or_default() += 1;
self.by_inst.entry(inst).or_default().push((status, time));
fresh += 1;
}
}
self.total += fresh;
fresh
}
fn report(&self, shared: &UiShared, tag: &str) {
let (lines, _) = shared.logs_snapshot();
println!(
"── 交易状态取证[{tag}] 品种 {} 个 / 状态行 {} 条(本次新增见上;日志环 {} 条)──",
self.by_inst.len(),
self.total,
lines.len()
);
for (st, n) in &self.by_status {
println!(" 状态码 {st} {} : {} 条", status_label(st), n);
}
}
fn print_sequences(&self, limit: usize) {
println!(
"── 品种迁移序列(共 {} 个,列 {} 个)──",
self.by_inst.len(),
limit
);
for (inst, seq) in self.by_inst.iter().take(limit) {
let seq_txt: Vec<String> = seq
.iter()
.map(|(s, t)| format!("{}/{}@{t}", s, status_label(s)))
.collect();
println!(" {inst}: {}", seq_txt.join(" → "));
}
}
}
fn dump_book(shared: &UiShared, tag: &str, gateways: &[String]) {
let snap = shared.trade_snap();
println!(
"── 账本取证[{tag}] 委托 {} / 成交 {} / 持仓 {} / 资金 {} ──",
snap.orders.len(),
snap.trades.len(),
snap.positions.len(),
snap.accounts.len()
);
for gw in gateways {
let o: Vec<_> = snap
.orders
.iter()
.filter(|o| &o.gateway_name == gw)
.collect();
let t: Vec<_> = snap
.trades
.iter()
.filter(|t| &t.gateway_name == gw)
.collect();
let p: Vec<_> = snap
.positions
.iter()
.filter(|p| &p.gateway_name == gw)
.collect();
let a: Vec<_> = snap
.accounts
.iter()
.filter(|a| &a.gateway_name == gw)
.collect();
println!(
" 账户 {gw}: 委托 {} / 成交 {} / 持仓 {} / 资金 {}",
o.len(),
t.len(),
p.len(),
a.len()
);
for x in o.iter().take(6) {
println!(
" 委托 {} {} {:?}/{:?} @{} 量{} 已成{} {:?} msg={}",
x.en_orderid(),
x.symbol,
x.direction,
x.offset,
x.price,
x.volume,
x.traded,
x.status,
x.status_msg
);
}
for x in t.iter().take(4) {
println!(
" 成交 tradeid={} orderid={} {} @{} 量{}",
x.tradeid, x.orderid, x.symbol, x.price, x.volume
);
}
for x in a.iter().take(2) {
println!(
" 资金 {} balance={:.2} frozen={:.2}",
x.accountid, x.balance, x.frozen
);
}
for x in p.iter().take(4) {
println!(
" 持仓 {} {:?} 量{} 昨{} 均价{:.2}",
x.symbol, x.direction, x.volume, x.yd_volume, x.avg_price
);
}
}
}
fn main() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
ek_station::boot(
&engine,
&ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["ui".to_string()],
},
)
.expect("boot");
engine.start().expect("start");
let watcher = spawn_host_watcher(&engine, &shared);
let user1 = std::env::var("EK_CTP_USER").expect("缺 EK_CTP_USER");
let pass1 = std::env::var("EK_CTP_PASSWORD").expect("缺 EK_CTP_PASSWORD");
let user2 = std::env::var("EK_CTP_2_USER").expect("缺 EK_CTP_2_USER");
let pass2 = std::env::var("EK_CTP_2_PASSWORD").expect("缺 EK_CTP_2_PASSWORD");
let broker = env_or("EK_CTP_BROKER_ID", "9999");
let app_id = env_or("EK_CTP_APP_ID", "simnow_client_test");
let auth_code = env_or("EK_CTP_AUTH_CODE", "0000000000000000");
let td_addr = env_or("EK_CTP_TD", "tcp://182.254.243.31:30001");
let md_addr = env_or("EK_CTP_MD", "tcp://182.254.243.31:30011");
{
let shared = Arc::clone(&shared);
std::thread::spawn(move || {
let nap = |secs: u64| std::thread::sleep(Duration::from_secs(secs));
nap(2);
banner(&shared, "步1 运维窗开(初始在场:supervisor + ui)");
shared.set_ops_window_open(true);
nap(2);
for f in ["dm", "oms"] {
banner(&shared, &format!("步2 svctl.start {f}"));
shared.push_cmd(UiCmd::Start {
factory: f.to_string(),
name: String::new(),
});
nap(2);
}
shared.set_ops_window_open(false);
banner(&shared, "步3 账户面板开 + 元账号表单填值(密码掩码)");
shared.set_accounts_window_open(true);
let form1 = CtpForm {
broker_id: broker.clone(),
td_addr: td_addr.clone(),
td_user: user1.clone(),
td_password: pass1.clone(),
td_auth_code: auth_code.clone(),
td_app_id: app_id.clone(),
md_addr: md_addr.clone(),
md_user: String::new(),
md_password: String::new(),
symbols: String::new(),
accept_futures: true,
accept_options: false,
};
shared.set_ctp_form(form1.clone());
nap(3);
let row1 = AccountRow::derive(&form1).unwrap_or_else(|e| panic!("派生名 FAIL: {e}"));
let gw1 = row1.gateway.clone();
shared
.add_account(row1)
.unwrap_or_else(|e| panic!("添加账户 FAIL: {e}"));
shared
.set_md_source(&gw1)
.unwrap_or_else(|e| panic!("定源 FAIL: {e}"));
banner(
&shared,
&format!("步3 添加元账号 {gw1}(TD+MD;已指定为行情源)"),
);
shared.push_cmd(UiCmd::Connect {
gateway: gw1.clone(),
form: form1.clone(),
});
shared.note_connect_sent(&gw1);
nap(10);
banner(&shared, "步3 纯 TD 账号表单填值(只 TD 前置,无 MD)");
let form2 = CtpForm {
broker_id: broker.clone(),
td_addr: td_addr.clone(),
td_user: user2.clone(),
td_password: pass2.clone(),
td_auth_code: auth_code.clone(),
td_app_id: app_id.clone(),
md_addr: String::new(),
md_user: String::new(),
md_password: String::new(),
symbols: String::new(),
accept_futures: true,
accept_options: false,
};
shared.set_ctp_form(form2.clone());
nap(3);
let row2 = AccountRow::derive(&form2).unwrap_or_else(|e| panic!("派生名 FAIL: {e}"));
let gw2 = row2.gateway.clone();
shared
.add_account(row2)
.unwrap_or_else(|e| panic!("添加账户 FAIL: {e}"));
banner(
&shared,
&format!("步3 添加纯 TD 账号 {gw2}(行情源仍 = {gw1},已锁定)"),
);
shared.push_cmd(UiCmd::Connect {
gateway: gw2.clone(),
form: form2.clone(),
});
shared.note_connect_sent(&gw2);
nap(12);
let logged = |gw: &str| {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains(gw) && l.contains("交易服务器登录成功"))
};
banner(
&shared,
&format!(
"步3 两账号登录面:{} = {} / {} = {}",
gw1,
logged(&gw1),
gw2,
logged(&gw2)
),
);
shared.set_accounts_window_open(false);
let mut watch = StatusWatch {
seen: std::collections::HashSet::new(),
by_inst: BTreeMap::new(),
by_status: BTreeMap::new(),
total: 0,
};
banner(&shared, "步4 开盘观察(交易状态推送流,采样 60s)");
for i in 0..6 {
nap(10);
let fresh = watch.absorb(&shared);
banner(
&shared,
&format!(
"步4 采样 {}/6:新增状态行 {} 条(累计 {})",
i + 1,
fresh,
watch.total
),
);
}
watch.report(&shared, "开盘采样收束");
banner(&shared, "步5 查询合约窗开(登录后自动查询流上屏)");
shared.set_contracts_window_open(true);
let got = wait_for(150, || shared.contract_count() > 0);
if !got {
banner(&shared, "步5 FAIL:合约流 150s 未出数——后续步略,窗口留场");
println!("TOUR_FAIL");
return;
}
let all = shared.contracts("");
let pick = |prefix: &str| -> Option<String> {
all.iter()
.filter(|c| c.symbol.starts_with(prefix))
.map(|c| c.symbol.clone())
.max()
};
let mut symbols: Vec<String> = Vec::new();
for p in ["rb", "m", "TA"] {
if let Some(s) = pick(p) {
symbols.push(s);
}
}
if symbols.is_empty() {
symbols.push(all[0].symbol.clone());
}
banner(
&shared,
&format!(
"步5 合约流 {} 条;挑中 {}(SHFE/DCE/CZCE 各一)→ 订阅一律路由 {gw1}",
all.len(),
symbols.join(",")
),
);
shared.push_cmd(UiCmd::CtpSubscribe {
gateway: gw1.clone(),
symbols: symbols.clone(),
});
nap(6);
watch.absorb(&shared);
watch.report(&shared, "订阅后");
shared.set_contracts_window_open(false);
let main_symbol = symbols[0].clone();
let contract = all.iter().find(|c| c.symbol == main_symbol).expect("合约");
let exchange = contract.exchange;
let en_symbol = format!("{exchange:?}.{main_symbol}");
let tick = if contract.price_tick > 0.0 {
contract.price_tick
} else {
1.0
};
banner(&shared, &format!("步6 空回灌建 DM 分钟窗 {en_symbol}"));
shared.push_cmd(UiCmd::Reload {
interval: 60,
symbol: en_symbol.clone(),
bars: Vec::new(),
});
let ticked = wait_for(40, || {
shared
.market()
.cells()
.iter()
.any(|c| c.symbol == main_symbol && !c.bars.is_empty())
});
let (frames, updates, _) = shared.market().counts();
banner(
&shared,
&format!("步6 行情面 frames={frames} updates={updates} tick根={ticked}"),
);
if ticked {
shared.open_kline(&main_symbol);
nap(4);
shared.close_kline(&main_symbol);
banner(&shared, "步6 K线窗开→关(双击行情行等价)");
}
let snap_grid = |p: f64| (p / tick).round() * tick;
let anchor = shared
.market()
.cells()
.iter()
.find(|c| c.symbol == main_symbol)
.and_then(|c| c.bars.last().map(|b| b.close_price))
.unwrap_or(0.0);
let best_bid = shared
.tick_rows()
.into_iter()
.rfind(|t| t.symbol == main_symbol)
.map(|t| t.bid_price_1)
.filter(|b| *b > 0.0);
let fill_price = snap_grid(best_bid.unwrap_or(anchor * 0.99)).max(tick);
banner(
&shared,
&format!("步6 价格锚 {main_symbol} tick={tick} 采用={fill_price}"),
);
banner(
&shared,
&format!("步7① 元账号 {gw1} 发可成交单 {main_symbol} 卖开 1 手 @{fill_price}"),
);
shared.set_order_form(OrderForm {
exchange: format!("{exchange:?}"),
symbol: main_symbol.clone(),
direction: Direction::SHORT,
offset: Offset::OPEN,
order_type: OrderType::LIMIT,
price: format!("{fill_price}"),
volume: "1".to_string(),
gateway: gw1.clone(),
follow_price: false,
auto_split: true,
});
let before1: Vec<String> = shared
.trade_snap()
.orders
.iter()
.map(|o| o.orderid.clone())
.collect();
shared.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: main_symbol.clone(),
exchange,
direction: Direction::SHORT,
order_type: OrderType::LIMIT,
volume: 1.0,
price: fill_price,
offset: Offset::OPEN,
..OrderRequest::default()
},
gateway: gw1.clone(),
mode: LockMode::Auto,
});
nap(8);
let new1 = shared
.trade_snap()
.orders
.iter()
.find(|o| o.gateway_name == gw1 && !before1.contains(&o.orderid))
.cloned();
banner(
&shared,
&format!(
"步7① 元账号新单 = {}",
new1.as_ref()
.map(|o| format!("{} status={:?}", o.en_orderid(), o.status))
.unwrap_or_else(|| "无新行".to_string())
),
);
let rest_price = snap_grid(fill_price * 1.02).max(tick);
banner(
&shared,
&format!(
"步7② 纯 TD 账号 {gw2} 发单(行情源不是它):可成交 1 手 @{fill_price} + 悬单两笔 @{rest_price}"
),
);
let before2: Vec<String> = shared
.trade_snap()
.orders
.iter()
.map(|o| o.orderid.clone())
.collect();
shared.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: main_symbol.clone(),
exchange,
direction: Direction::SHORT,
order_type: OrderType::LIMIT,
volume: 1.0,
price: fill_price,
offset: Offset::OPEN,
..OrderRequest::default()
},
gateway: gw2.clone(),
mode: LockMode::Auto,
});
nap(4);
for _ in 0..2 {
shared.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: main_symbol.clone(),
exchange,
direction: Direction::LONG,
order_type: OrderType::LIMIT,
volume: 1.0,
price: rest_price,
offset: Offset::OPEN,
..OrderRequest::default()
},
gateway: gw2.clone(),
mode: LockMode::Auto,
});
}
nap(8);
let new2: Vec<String> = shared
.trade_snap()
.orders
.iter()
.filter(|o| o.gateway_name == gw2 && !before2.contains(&o.orderid))
.map(|o| format!("{}@{:?}", o.en_orderid(), o.status))
.collect();
banner(
&shared,
&format!("步7② 纯 TD 账号新单 {} 笔:{}", new2.len(), new2.join(", ")),
);
let active: Vec<(String, String)> = shared
.trade_snap()
.orders
.iter()
.filter(|o| {
matches!(
o.status,
Status::SUBMITTING | Status::NOTTRADED | Status::PARTTRADED
)
})
.map(|o| (o.orderid.clone(), o.gateway_name.clone()))
.collect();
banner(
&shared,
&format!(
"步7③ 全撤扇出(等价「全撤」钮):活动委托 {} 笔,逐笔按各自网关撤",
active.len()
),
);
for (id, gw) in &active {
shared.push_cmd(UiCmd::CancelOrder {
orderid: id.clone(),
gateway: gw.clone(),
});
}
nap(10);
let still: Vec<String> = shared
.trade_snap()
.orders
.iter()
.filter(|o| {
matches!(
o.status,
Status::SUBMITTING | Status::NOTTRADED | Status::PARTTRADED
)
})
.map(|o| format!("{}@{:?}", o.en_orderid(), o.status))
.collect();
banner(
&shared,
&format!(
"步7③ 全撤后仍在场活动单 {} 笔:{}",
still.len(),
still.join(", ")
),
);
let pos = shared
.trade_snap()
.positions
.iter()
.find(|p| p.gateway_name == gw1 && p.volume > 0.0)
.cloned();
match pos {
Some(p) => {
let dir = if p.direction == Direction::LONG {
Direction::SHORT
} else {
Direction::LONG
};
let px = snap_grid(if p.avg_price > 0.0 {
p.avg_price
} else {
fill_price
});
banner(
&shared,
&format!(
"步7④ 平仓 {gw1} {} {:?} → {:?} 1 手 @{px}(自动开平拆腿)",
p.symbol, p.direction, dir
),
);
shared.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: p.symbol.clone(),
exchange: p.exchange,
direction: dir,
order_type: OrderType::LIMIT,
volume: 1.0,
price: px,
offset: Offset::CLOSE,
..OrderRequest::default()
},
gateway: gw1.clone(),
mode: LockMode::Auto,
});
nap(8);
}
None => banner(&shared, "步7④ OBSERVE:元账号无持仓可平(快照市未成交)"),
}
dump_book(&shared, "步7 全面业务后", &[gw1.clone(), gw2.clone()]);
if symbols.len() > 1 {
let s2 = symbols[1].clone();
banner(
&shared,
&format!("步7⑥ 公用合约面:订阅 {s2}(仍路由 {gw1};{gw2} 无 MD 会话)"),
);
shared.push_cmd(UiCmd::CtpSubscribe {
gateway: gw1.clone(),
symbols: vec![s2],
});
nap(6);
let (f2, u2, _) = shared.market().counts();
banner(&shared, &format!("步7⑥ 行情计数 frames={f2} updates={u2}"));
}
banner(
&shared,
&format!("步8 摘除纯 TD 账号 {gw2}(面板二次确认开火等价)"),
);
shared.push_cmd(UiCmd::Remove { name: gw2.clone() });
shared.remove_account(&gw2);
nap(8);
let rows_ok = wait_for(20, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains(&format!("移除 {gw2}")) || l.contains(&gw2))
});
banner(
&shared,
&format!(
"步8 摘除后:清册 {} 行 / 行情源 = {} / 锁定 = {}(摘除留痕={})",
shared.accounts().len(),
shared.md_source(),
shared.accounts_locked(),
rows_ok
),
);
dump_book(&shared, "步8 摘除后", &[gw1.clone(), gw2.clone()]);
banner(
&shared,
&format!("步8b 重加 {gw2}(『保存』入清册 → 『连接』一条令上线)"),
);
match AccountRow::derive(&form2) {
Ok(row2b) => match shared.add_account(row2b.clone()) {
Ok(()) => {
shared.push_cmd(UiCmd::Connect {
gateway: row2b.gateway.clone(),
form: form2.clone(),
});
shared.note_connect_sent(&row2b.gateway);
banner(
&shared,
&format!("步8b 重加已入列:清册 {} 行", shared.accounts().len()),
);
}
Err(e) => banner(&shared, &format!("步8b 重加被拒:{e}")),
},
Err(e) => banner(&shared, &format!("步8b 派生名失败:{e}")),
}
nap(25);
let re_logged = wait_for(90, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains(&gw2) && l.contains("交易服务器登录成功"))
});
banner(
&shared,
&format!(
"步8b 重加后:清册 {} 行 / 在场 {} 行 / 登录链显形={}",
shared.accounts().len(),
shared.accounts().iter().filter(|a| a.connected).count(),
re_logged
),
);
dump_book(&shared, "步8b 重加后", &[gw1.clone(), gw2.clone()]);
let until = env_or("EK_TOUR_WATCH_UNTIL", "09:33");
let target_secs_of_day = until
.split(':')
.map(|p| p.parse::<u64>().unwrap_or(0))
.reduce(|a, b| a * 60 + b)
.map(|m| m * 60)
.unwrap_or(0);
let secs_of_day = || -> u64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
(secs + 8 * 3600) % 86_400 };
banner(
&shared,
&format!(
"步9 状态长观察窗:逐分钟采样,跑到北京钟点 {until} 为止(覆盖 09:30 中金所开盘)"
),
);
let mut round = 0u64;
while secs_of_day() < target_secs_of_day && round < 45 {
let step = 60u64.min(target_secs_of_day.saturating_sub(secs_of_day()).max(1));
nap(step);
round += 1;
let fresh = watch.absorb(&shared);
let logged2 = |gw: &str| {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains(gw) && l.contains("交易服务器登录成功"))
};
banner(
&shared,
&format!(
"步9 观察 第{round} 拍(钟点 {:02}:{:02}):新增 {} 条(累计 {};品种 {};{} 登录={})",
secs_of_day() / 3600,
(secs_of_day() % 3600) / 60,
fresh,
watch.total,
watch.by_inst.len(),
gw2,
logged2(&gw2)
),
);
}
watch.report(&shared, "长观察收束");
watch.print_sequences(80);
banner(
&shared,
"演示收束:弹窗全关,主窗留场可继续把玩(下单/撤单/全撤/账户/查询合约都在)",
);
println!("TOUR_DONE");
});
}
let app = TourApp {
shared: Arc::clone(&shared),
};
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1400.0, 900.0])
.with_title("ek 开盘观察 + 多账户全面业务演示"),
..Default::default()
};
let result = eframe::run_native(
"ek 开盘观察 + 多账户全面业务演示",
opts,
Box::new(move |cc| {
apply_cjk_fonts(&cc.egui_ctx);
Ok(Box::new(app) as Box<dyn eframe::App>)
}),
);
shared.request_stop();
let _ = watcher.join();
if let Err(e) = result {
eprintln!("opening_multi_tour: {e}");
std::process::exit(1);
}
}