use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use ek_core::context::PluginContext;
use ek_core::main_engine::MainEngine;
use ek_core::plugin::Plugin;
use ek_data::constant::{Direction, Exchange, Offset, OrderType, Status};
use ek_data::order_request::OrderRequest;
use ek_oms::LockMode;
use ek_station::StationConfig;
use ek_ui::runtime::{StageApp, ui_kinds};
use ek_ui::state::{LoginState, OrderForm, UiCmd, UiShared};
#[path = "../tests/common/l5_matrix.rs"]
mod l5;
const GW_A: &str = "ctp_9999_278153"; const GW_B: &str = "ctp_9999_220380";
struct Probe {
ctx: Mutex<Option<PluginContext>>,
order_seq: Mutex<Vec<(String, Status)>>,
}
impl Plugin for Probe {
fn name(&self) -> &str {
"probe"
}
fn on_init(&self, ctx: &PluginContext) {
*self.ctx.lock().unwrap() = Some(ctx.clone());
}
fn routes(&self) -> Vec<std::borrow::Cow<'static, str>> {
vec![ek_data::event::EVENT_ORDER.into()]
}
fn on_mail(&self, event: &ek_event::Event) {
if event.event_type == ek_data::event::EVENT_ORDER
&& let Some(o) = event.data.downcast_ref::<ek_data::order::OrderData>()
{
self.order_seq
.lock()
.unwrap()
.push((o.orderid.clone(), o.status));
}
}
}
struct Shell {
inner: StageApp,
done: Arc<AtomicBool>,
}
impl eframe::App for Shell {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.inner.update(ctx, frame);
if self.done.load(Ordering::SeqCst) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
struct Rec {
rows: Vec<String>,
fails: usize,
}
impl Rec {
fn add(&mut self, id: &str, ok: bool, detail: &str) {
let verdict = if ok { "PASS" } else { "FAIL" };
if !ok {
self.fails += 1;
}
let line = format!(
r#"{{"step":"{id}","acct":"-","req":"有头实跑","expect":"见判据","observed":"{}","verdict":"{verdict}"}}"#,
esc(detail)
);
println!("[headed] {verdict:<4} {id:<6} {detail}");
self.rows.push(line);
}
fn env(&mut self, id: &str, detail: &str) {
let line = format!(
r#"{{"step":"{id}","acct":"-","req":"有头实跑","expect":"见判据","observed":"{}","verdict":"ENV"}}"#,
esc(detail)
);
println!("[headed] ENV {id:<6} {detail}");
self.rows.push(line);
}
}
fn esc(s: &str) -> String {
s.replace('\\', "/").replace('"', "'")
}
fn wait_for(secs: u64, mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
false
}
fn main() {
if std::env::var("EK_HEADED").is_err() {
println!("跳过:EK_HEADED 未设(有头实跑门;本程序会开真窗口)");
return;
}
let src_root = PathBuf::from("target/arch-real");
let run_root = PathBuf::from("target/arch-headed");
let _ = std::fs::remove_dir_all(&run_root);
for rel in ["master.key", "cfg/cfg.db"] {
let to = run_root.join(rel);
if let Some(p) = to.parent() {
let _ = std::fs::create_dir_all(p);
}
std::fs::copy(src_root.join(rel), &to)
.unwrap_or_else(|e| panic!("复制档案 {rel} 失败: {e:?}"));
}
unsafe {
std::env::set_var("EK_CFG_FALLBACK_ROOT", run_root.join("cfg"));
}
let store = ek_cfg::Store::eco().expect("档案根可用");
println!("[headed] 档案副本 = {}", run_root.display());
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
let probe = Arc::new(Probe {
ctx: Mutex::new(None),
order_seq: Mutex::new(Vec::new()),
});
let probe_build = Arc::clone(&probe);
let mut kinds = ui_kinds(&shared, true);
kinds.push(ek_station::PlugKind {
name: "probe",
doc: "订单状态流水探针(三档共用判据的原料)",
build: Arc::new(move || -> Arc<dyn Plugin> { Arc::clone(&probe_build) as Arc<dyn Plugin> }),
build_named: None,
name_validator: None,
});
ek_station::boot_with_cfg(
&engine,
&kinds,
&StationConfig {
plugins: vec![
"cfg".to_string(),
ek_ui::plugin::UI_NAME.to_string(),
"probe".to_string(),
],
},
Some(&store),
)
.expect("station boot");
let restored = shared.restore_saved_accounts_from(&store);
println!("[headed] 档案复原行 = {restored:?}");
for gw in &restored {
let _ = shared.set_auto_connect(gw, false);
}
shared
.set_md_source(GW_A)
.expect("指定行情源 278153(需在连接前)");
let mut app = StageApp::new_setup(
Arc::clone(&engine),
Arc::clone(&shared),
ui_kinds(&shared, true),
Some(store.clone()),
None,
);
let msg = app.do_start();
println!("[headed] do_start: {msg}");
shared.set_accounts_window_open(true);
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let ctx_for_win = Arc::clone(&ctx_slot);
let drv_shared = Arc::clone(&shared);
let drv_probe = Arc::clone(&probe);
let drv_ctx = Arc::clone(&ctx_slot);
let done = Arc::new(AtomicBool::new(false));
let drv_done = Arc::clone(&done);
let driver = std::thread::spawn(move || {
let mut rec = Rec {
rows: Vec::new(),
fails: 0,
};
let up = wait_for(30, || drv_ctx.lock().unwrap().is_some());
rec.add("H0 真窗就绪", up, "run_native 已建窗口(桌面可见)");
for f in ["dm", "oms"] {
drv_shared.push_cmd(UiCmd::Start {
factory: f.to_string(),
name: String::new(),
});
std::thread::sleep(Duration::from_secs(2));
}
let online = |gw: &str| {
drv_shared
.account(gw)
.map(|r| r.login == LoginState::Online)
.unwrap_or(false)
};
for gw in [GW_A, GW_B] {
drv_shared.push_cmd(UiCmd::Start {
factory: "ctp".to_string(),
name: gw.to_string(),
});
println!("[headed] 已发 Start(连接)= {gw}");
}
let mut settle = [false, false];
let mut login_a = online(GW_A);
let mut login_b = online(GW_B);
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline && !(login_a && login_b && settle[0] && settle[1]) {
let (logs, _) = drv_shared.logs_snapshot();
for (i, gw) in [GW_A, GW_B].iter().enumerate() {
if logs.iter().any(|l| l.contains(gw) && l.contains("结算")) {
settle[i] = true;
}
}
login_a = online(GW_A);
login_b = online(GW_B);
std::thread::sleep(Duration::from_millis(200));
}
rec.add(
"H1 双账户登录",
login_a && login_b,
&format!("A[{GW_A}]在线={login_a} B[{GW_B}]在线={login_b}"),
);
rec.add(
"H2 结算确认显形",
settle[0] && settle[1],
&format!("两账户结算确认日志(到货即采) = {settle:?}"),
);
let store_run = ek_cfg::Store::eco().expect("档案根");
let classes_of = |gw: &str| -> Option<String> {
match store_run.load(&format!("ctp.{gw}")) {
Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => {
map.get_plain("product_classes").map(str::to_string)
}
_ => None,
}
};
let b_classes_before = classes_of(GW_B);
drv_shared.modules().set_open("ctp", true);
let menu_open = drv_shared.accounts_window_open();
let rows: Vec<String> = drv_shared
.accounts()
.iter()
.map(|a| a.gateway.clone())
.collect();
let two_rows = rows.iter().filter(|g| *g == GW_A).count() == 1
&& rows.iter().filter(|g| *g == GW_B).count() == 1;
let bridge = ek_ui::mount::CfgBridge::with_store(&drv_shared, store_run.clone());
let saved = ek_ui::gateway::save_product_classes(
&drv_shared,
&bridge,
GW_A,
&["1".to_string(), "2".to_string()],
);
let a_after = classes_of(GW_A);
let b_after = classes_of(GW_B);
rec.add(
"H10 网关窗改行",
menu_open
&& two_rows
&& online(GW_A)
&& online(GW_B)
&& saved.is_ok()
&& a_after.as_deref() == Some("1,2")
&& b_after == b_classes_before,
&format!(
"窗开={menu_open} 行={rows:?};已连接 A={} B={};存 A 收录→{a_after:?};\
B 段 {b_classes_before:?}→{b_after:?}(不许动) saved={:?}",
online(GW_A),
online(GW_B),
saved.as_ref().err()
),
);
let size_set = [911.0f32, 644.0];
{
let mut reg = drv_shared.modules();
if let Some(w) = reg.list.iter_mut().find(|w| w.id == "ctp") {
w.size = size_set;
}
}
drv_shared.set_edit_alias("改到一半的别名");
drv_shared.modules().set_open("ctp", false);
let closed = !drv_shared.accounts_window_open();
drv_shared.modules().set_open("ctp", true);
let reopened = drv_shared.accounts_window_open();
let size_kept = drv_shared
.modules()
.list
.iter()
.find(|w| w.id == "ctp")
.map(|w| w.size == size_set)
.unwrap_or(false);
let alias_kept = drv_shared.edit_alias() == "改到一半的别名";
rec.add(
"H11 关窗再开",
closed && reopened && size_kept && alias_kept,
&format!("关={closed} 再开={reopened} 尺寸={size_kept} 编辑态={alias_kept}"),
);
let contracts_ok = wait_for(180, || drv_shared.contract_count() >= 500);
rec.add(
"H3 合约全量",
contracts_ok,
&format!("在册合约 {} 条", drv_shared.contract_count()),
);
let want: [&str; 2] = ["278153", "220380"];
let funds_ok = wait_for(150, || {
let snap = drv_shared.trade_snap();
want.iter()
.all(|u| snap.accounts.iter().any(|a| a.accountid == *u))
});
let snap = drv_shared.trade_snap();
let acc_ids: Vec<String> = snap.accounts.iter().map(|a| a.accountid.clone()).collect();
if funds_ok {
rec.add(
"H4 资金归属",
true,
&format!("资金 {} 帧:{}", snap.accounts.len(), acc_ids.join(",")),
);
} else if acc_ids.iter().any(|i| i == "278153") {
rec.env(
"H4 资金归属",
&format!("150s 内只到资金帧 {:?}(缺另一账户;查询轮转未到)", acc_ids),
);
} else {
rec.add(
"H4 资金归属",
false,
&format!(
"资金帧 {} 条:{}(查询面整轮没回来)",
snap.accounts.len(),
acc_ids.join(",")
),
);
}
let foreign = snap
.positions
.iter()
.filter(|p| p.gateway_name != GW_A && p.gateway_name != GW_B)
.count();
rec.add(
"H5 持仓归属",
foreign == 0,
&format!(
"持仓 {} 行;外来网关 {} 行(历史仓合法,只看归属)",
snap.positions.len(),
foreign
),
);
let ags: Vec<String> = drv_shared
.contracts("")
.iter()
.filter(|c| c.symbol.starts_with("ag"))
.map(|c| c.symbol.clone())
.take(2)
.collect();
if !ags.is_empty() {
drv_shared.push_cmd(UiCmd::CtpSubscribe {
gateway: GW_A.to_string(),
symbols: ags.clone(),
});
}
let ticks_ok = wait_for(60, || !drv_shared.tick_rows().is_empty());
let ticks = drv_shared.tick_rows();
let ta = ticks.iter().filter(|t| t.gateway_name == GW_A).count();
let tb = ticks.iter().filter(|t| t.gateway_name == GW_B).count();
rec.add(
"H6 行情单源",
ticks_ok && ta > 0 && tb == 0,
&format!("订阅 {ags:?};tick 共 {} 条:A {ta} / B {tb}", ticks.len()),
);
let anchor = ticks
.iter()
.rev()
.find(|t| t.bid_price_1 > 0.0)
.map(|t| t.bid_price_1)
.unwrap_or(12400.0);
let symbol = ags.first().cloned().unwrap_or_else(|| "ag2612".to_string());
drv_shared.set_order_form(OrderForm {
exchange: "SHFE".to_string(),
symbol: symbol.clone(),
direction: Direction::LONG,
offset: Offset::OPEN,
order_type: OrderType::LIMIT,
price: anchor.to_string(),
volume: "1".to_string(),
gateway: GW_A.to_string(),
follow_price: false,
auto_split: true,
});
let known: std::collections::HashSet<String> = drv_shared
.trade_snap()
.orders
.iter()
.map(|o| o.orderid.clone())
.collect();
println!("[headed] 历史委托 {} 笔(不计入本会话残留)", known.len());
{
let sh = Arc::clone(&drv_shared);
let pb = Arc::clone(&drv_probe);
let gw = GW_A.to_string();
let known_l5 = known.clone();
let vs = l5::run(
|price, sym| {
sh.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: sym.to_string(),
exchange: Exchange::SHFE,
direction: Direction::LONG,
order_type: OrderType::LIMIT,
volume: 1.0,
price,
offset: Offset::OPEN,
..Default::default()
},
gateway: gw.clone(),
mode: LockMode::None,
});
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let hit = sh
.trade_snap()
.orders
.iter()
.find(|o| {
o.symbol == sym
&& (o.price - price).abs() < 1e-6
&& o.gateway_name == gw
&& !known_l5.contains(&o.orderid)
})
.map(|o| o.orderid.clone());
if let Some(oid) = hit {
return Ok(oid);
}
if Instant::now() > deadline {
return Err("10s 内未入账".to_string());
}
std::thread::sleep(Duration::from_millis(150));
}
},
|oid| {
sh.trade_snap()
.orders
.iter()
.find(|o| o.orderid == oid)
.cloned()
},
|oid| {
pb.order_seq
.lock()
.unwrap()
.iter()
.filter(|(id, _)| id == oid)
.map(|(_, s)| *s)
.collect()
},
|oid| {
sh.push_cmd(UiCmd::CancelOrder {
orderid: oid.to_string(),
gateway: gw.clone(),
});
true
},
anchor,
1.0,
&symbol,
);
for v in vs {
rec.add(v.id, v.ok(), &v.detail);
}
}
let far = (anchor * 0.975).floor();
drv_shared.push_cmd(UiCmd::SendOrder {
req: OrderRequest {
symbol: symbol.clone(),
exchange: Exchange::SHFE,
direction: Direction::LONG,
order_type: OrderType::LIMIT,
volume: 1.0,
price: far,
offset: Offset::OPEN,
..Default::default()
},
gateway: GW_A.to_string(),
mode: LockMode::None,
});
let mut hang: Option<String> = None;
let hunt = Instant::now() + Duration::from_secs(60);
while Instant::now() < hunt {
if let Some(o) = drv_shared.trade_snap().orders.iter().find(|o| {
(o.price - far).abs() < 1e-6
&& o.gateway_name == GW_A
&& !known.contains(&o.orderid)
&& matches!(o.status, Status::NOTTRADED)
}) {
hang = Some(o.orderid.clone());
break;
}
std::thread::sleep(Duration::from_millis(200));
}
match hang {
Some(oid) => {
drv_shared.push_cmd(UiCmd::CancelOrder {
orderid: oid.clone(),
gateway: GW_B.to_string(),
});
let rejected = wait_for(25, || {
drv_shared
.logs_snapshot()
.0
.iter()
.rev()
.take(80)
.any(|l| l.contains("撤单被拒") && l.contains("没有这笔委托"))
});
let survived = drv_shared
.trade_snap()
.orders
.iter()
.find(|o| o.orderid == oid)
.map(|o| o.status);
drv_shared.push_cmd(UiCmd::CancelOrder {
orderid: oid.clone(),
gateway: GW_A.to_string(),
});
let cancelled = wait_for(40, || {
drv_shared
.trade_snap()
.orders
.iter()
.any(|o| o.orderid == oid && o.status == Status::CANCELLED)
});
rec.add(
"H8 跨账户撤单被拒",
rejected
&& matches!(survived, Some(Status::NOTTRADED) | Some(Status::SUBMITTING))
&& cancelled,
&format!(
"{oid} 错网关撤 → 拒因到货={rejected};拒后仍在场={survived:?};\
改正确网关撤 → CANCELLED={cancelled}"
),
);
}
None => rec.env(
"H8 跨账户撤单被拒",
"60s 内没有一张远价单停在 NOTTRADED(柜台回报慢/被吃穿),本拍验不了撤单守卫",
),
}
let alive = |s: Option<LoginState>| {
matches!(s, Some(LoginState::Online) | Some(LoginState::QueryStarved))
};
let a_login_before = drv_shared.account(GW_A).map(|r| r.login);
drv_shared.push_cmd(UiCmd::Disconnect {
gateway: GW_B.to_string(),
});
let b_down = wait_for(60, || !online(GW_B));
let a_login_after = drv_shared.account(GW_A).map(|r| r.login);
rec.add(
"H11b 断开 B",
b_down && a_login_before == a_login_after && alive(a_login_after),
&format!("B 下线={b_down};A {a_login_before:?}→{a_login_after:?}(不变=不受牵连)"),
);
let counts = |sh: &UiShared, gw: &str| {
let s = sh.trade_snap();
(
s.orders.iter().filter(|o| o.gateway_name == gw).count(),
s.trades.iter().filter(|t| t.gateway_name == gw).count(),
s.positions.iter().filter(|p| p.gateway_name == gw).count(),
)
};
let before_a = counts(&drv_shared, GW_A);
let before_b = counts(&drv_shared, GW_B);
let rows_before = drv_shared.accounts().len();
drv_shared.remove_account(GW_B);
drv_shared.push_cmd(UiCmd::Remove {
name: GW_B.to_string(),
});
let zeroed = wait_for(60, || counts(&drv_shared, GW_B) == (0, 0, 0));
let after_a = counts(&drv_shared, GW_A);
let after_b = counts(&drv_shared, GW_B);
let rows_after = drv_shared.accounts().len();
rec.add(
"H9 摘户隔离",
zeroed && after_a == before_a && rows_after + 1 == rows_before,
&format!(
"摘前 A{before_a:?} B{before_b:?} → 摘后 A{after_a:?} B{after_b:?};\
清册 {rows_before} 行 → {rows_after} 行"
),
);
let residual: Vec<String> = drv_shared
.trade_snap()
.orders
.iter()
.filter(|o| o.gateway_name == GW_A)
.filter(|o| !known.contains(&o.orderid))
.filter(|o| {
matches!(
o.status,
Status::SUBMITTING | Status::NOTTRADED | Status::PARTTRADED
)
})
.map(|o| format!("{}:{:?}", o.orderid, o.status))
.collect();
rec.add(
"H7 收场(本会话无残留活单)",
residual.is_empty(),
&format!("残留 = {residual:?}"),
);
let _ = std::fs::create_dir_all("target/headed");
let path = "target/headed/steps.jsonl";
let body = rec.rows.join("\n") + "\n";
let _ = std::fs::write(path, body);
println!(
"[headed] 判据 {} 行 → {path};FAIL {}",
rec.rows.len(),
rec.fails
);
std::thread::sleep(Duration::from_secs(1));
drv_done.store(true, Ordering::SeqCst);
if let Some(ctx) = drv_ctx.lock().unwrap().clone() {
ctx.request_repaint();
}
if rec.fails > 0 {
std::process::exit(1);
}
});
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1280.0, 860.0])
.with_title("ek-station 有头测试"),
..Default::default()
};
let win_done = Arc::clone(&done);
let result = eframe::run_native(
"ek-station 有头测试",
opts,
Box::new(move |cc| {
ek_ui::apply_cjk_fonts(&cc.egui_ctx);
*ctx_for_win.lock().unwrap() = Some(cc.egui_ctx.clone());
Ok(Box::new(Shell {
inner: app,
done: win_done,
}) as Box<dyn eframe::App>)
}),
);
shared.request_stop();
let _ = driver.join();
engine.close_all(Duration::from_secs(5));
match result {
Ok(()) => println!("[headed] 窗口已关,收场完成"),
Err(e) => println!("[headed] run_native 返回错误: {e:?}"),
}
}