ek-ui 0.9.39

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
//! **有界面·有头** 实跑驱动:真 `winit` 窗口(桌面可见)+ 真档案 + 24 小时套柜台。
//!
//! 与两档的关系:
//! - **没界面**(`ek-station/tests/archive_chain.rs`)= 无 UI 进程;
//! - **有界面·无头**(`tests/live_multi_accounts.rs`)= UiShared/UiCmd 链路,kittest 离屏;
//! - **本文件**= 同一条 UiShared/UiCmd 链路,但 **`eframe::run_native` 开真窗口**,
//!   主界面真实渲染在屏幕上,人可以看见。
//!
//! 驱动口径:出令口与手点**同一处**(`UiShared::push_cmd`)——
//! `UiCmd::Start{factory:"ctp", name}` 就是复原行上点「连接」发的那一条
//! (`render.rs` 的 `restored=true` 分支);`UiCmd::SendOrder` / `CancelOrder`
//! 就是下单/撤单钮发的那两条。**不是**手点鼠标(那要 UIA 注入,脆),
//! 但走的是同一份命令与同一份状态机。
//!
//! # 跑法(凭据全程走密库,测试零明文)
//!
//! ```text
//! cd C:\dev\fuke\ek-ui
//! cargo run --example headed_live --features ctp-native
//! ```
//!
//! 判据落 `target/headed/steps.jsonl`;窗口标题「ek-station 有头测试」。
//! 门:`EK_HEADED=1` 才跑(缺省直接退出说明理由,不误开窗)。

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"; // 元账号:TD + MD(行情唯一源)
const GW_B: &str = "ctp_9999_220380"; // 从账号:只 TD(md 位为空 = 单源纪律现场)

/// 订单状态到达流水(L5-7 的唯一原料:账本只有末态)。
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));
        }
    }
}

/// 真窗外壳:把 `StageApp` 原样渲染,只在 `done` 置位后**在帧内**发收窗命令。
///
/// 为什么要这层壳:驱动线程从别的线程 `ctx.send_viewport_cmd(Close)` 时,
/// 若此刻没有重绘,队列里的视口命令不会被处理 —— 2026-11-01 实测窗口
/// 一直挂着,最后靠 `WM_CLOSE` 才退。收窗必须在 `update` 里发,并用
/// `request_repaint()` 把那一帧叫醒。
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);
    }
    /// 环境侧没给到的(柜台轮转/窗口),记 ENV —— 不进失败分母,但必须写明证据。
    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;
    }
    // ── 一次性档案副本(母本 → 本次运行):有头档会写 ui 段(自动连接勾选等) ──
    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:?}"));
    }
    // SAFETY: 单线程装配前写;`Store::eco()` 每次重读。
    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");

    // ── 档案复原行(与首启同一条路):先清自动连接旗,连接由本驱动显式发 Start ──
    // 理由:`do_start` 的自动连接走 `push_connect`(表单路径,复原行的 td_password 为空);
    // 复原行该走的「连接」是 `UiCmd::Start`(档案段解密),这也是 `render.rs`
    // `restored=true` 分支发的那一条。这里把同一个选择显式做出来。
    let restored = shared.restore_saved_accounts_from(&store);
    println!("[headed] 档案复原行 = {restored:?}");
    for gw in &restored {
        let _ = shared.set_auto_connect(gw, false);
    }
    // 单源纪律:行情源 = 元账号(有 md_addr 的那个),必须在任何账户连接**之前**定。
    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);

    // 窗口上下文槽:驱动线程拿到 `egui::Context` 才能请求关窗。
    let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
    let ctx_for_win = Arc::clone(&ctx_slot);

    // ── 驱动线程(与渲染线程并行;出令全走 UiShared) ──
    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,
        };
        // 等窗口起来(创建闭包填 ctx)
        let up = wait_for(30, || drv_ctx.lock().unwrap().is_some());
        rec.add("H0 真窗就绪", up, "run_native 已建窗口(桌面可见)");

        // ── 运维面:dm / oms 起跑(与界面点「启动」同一条命令) ──
        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)
        };
        // ── 两个账户:复原行「连接」= UiCmd::Start(档案段,插件自解密) ──
        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}");
        }
        // ── 登录 + 结算确认:**到货即采**(日志环 512 行,合约流一发就把登录链淹掉) ──
        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:?}"),
        );

        // ── H10 交易网关模块窗(真 UI 表):菜单一项 → 一行一个 → 行内改只动该行 ──
        // 与手点的差只在于不注入鼠标:这里调的是「点菜单项」「改收录→保存」背后
        // **同一对函数**(`Registry::set_open` / `gateway::save_product_classes`)。
        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()
            ),
        );

        // ── H11a 关窗不销毁:尺寸与编辑中的内容都还在 ──
        // 「用户拖过尺寸」在本仓就是 `ModuleWin.size` 这个字段,同一条口径。
        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()),
        );
        // 资金帧是轮询出来的(B 账户可能比 A 晚一轮)。给 150s;仍只到一帧 = ENV
        // (无头档 C6 已证两帧归属,这里缺的只是轮转时间,不是归属错)。
        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,
        });
        // 历史委托(昨/上一会话留在柜台账本里的行)**不算本会话残留** ——
        // 收场判据只看"本次发出去的单是否到终态"(2026-11-01 口径)。
        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();
            // 同 B 档口径:只认本会话新单(柜台账本带历史委托,同价会撞车)。
            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);
            }
        }

        // ── H8 跨账户撤单必须被拒(有头档补 B 档 C5a) ─────────────────
        // 造一张远价挂单(A 账户),然后**用 B 网关**去撤:UI 路径必须拒,
        // 且单子还留在 A 的账本上;再用正确网关撤 → CANCELLED。
        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(柜台回报慢/被吃穿),本拍验不了撤单守卫",
            ),
        }

        // ── H11b 断开 B → A 不动(断 ≠ 移除) ────────────────────────
        // 口径(2026-11-01 实测校正):A 的判据是「**没被 B 的断开影响**」,
        // 不是「恰好停在 Online」。7×24 环境跑久了 A 会进 `QueryStarved`
        // (查询通道被节流,登录仍在) —— 那是另一种「活着」,拿它当 FAIL 是判据错,
        // 不是产品错。故判:断开前后 A 状态**一致**,且不是 Offline/Error。
        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:?}(不变=不受牵连)"),
        );

        // ── H9 摘户隔离(有头档补 B 档 C10) ───────────────────────────
        // 摘 B 之后:清册少一行、B 的委托/成交/持仓清零、**A 一行不动**。
        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
        );

        // ── 收窗:置旗 + 叫醒一帧,由 Shell::update 发 Close(见 Shell 注释) ──
        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:?}"),
    }
}