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
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
//! 多账户**信息隔离**实连对账巡回(SimNow 双账号;真窗口,观看者零操作)。
//!
//! 目的不是"能不能下单",而是**两个账号的数据在界面/账本里会不会互相污染**。
//! 判定全部落在窗口日志里(【判定 …】行),控制台同步打印 PASS/FAIL 矩阵。
//!
//! 运行:
//! ```text
//! cargo run --features ctp-native --example demo_multi_check
//! ```
//! 凭据经环境变量注入(账号 A = `EK_CTP_USER`/`EK_CTP_PASSWORD`,账号 B = `EK_CTP_2_*`);
//! 屏幕掩码、控制台零回显。B 缺省配成**纯 TD 账号**(md 前置留空)—— 行情只能从元账号来,
//! 这正是「订阅只路由行情元账号」这条口径的现场判据。
//!
//! 判定清单:
//! - C1 双账户装配 + 登录态(两行都到「在线」)
//! - C2 行情归属:全部 tick 的 gateway 只可能是行情元账号,从账号一条都不许有
//! - C3 委托归属:每笔委托 `en_orderid` 前缀 == 自己的 gateway,且 gateway ∈ {A,B}
//! - C4 成交归属:成交行的 gateway 必须与它所归属的委托同网关
//! - C5 **跨账户撤单守卫**:拿 A 的单交给 B 的网关撤 → 必须显形 `mismatch:gateway`
//! 且那一笔仍挂在场上;交回正确网关才转 CANCELLED(B→A 反向同做一遍)
//! - C6 资金归属:资金行 `accountid` 与 gateway 配对不错位(A↔账号A、B↔账号B)
//! - C7 持仓归属:持仓行按网关分组,两边互不出现对方的合约行(串号 = 0)
//! - C8 凭据串味(U4 回归):添加完 A 之后草稿表单的凭据位必须清空(B 不会继承 A 的密码/前置)
//! - C9 合约收录面(2026-10-28 裁定定型):两网关都只期货收录(D-PC-FILTER 按勾选执行);
//! **期权行在任何网关回流 = 判负**,外来网关行 = 判负;并集两网关都在场
//! - C10 摘账户隔离:摘掉 B 之后,B 的行全清、A 的行一条不少
//! - C24 主题双档往返(0.8.0,BD-UI-61):缺省深色 → 切浅 → 切深,状态位与画面底色同拍,落盘标记只由手点式立起
//! - C25 全左对齐机制(0.8.0,裁定 D):表头与表体吃同一支格内边距、数字走等宽、右对齐机制已退役
//! - C26 三层文字落点(0.8.2):两档 Visuals 里表体/强文字/次级三支分层、行线与表头描边分两支
//! - 柜台窗口三态纪律(0.8.2,C5/C6/C7/C18 与收编侧孪生钉同款):
//! 「柜台窗口内没给的证据不打分」= SKIP(不进分母),到了才判,判错了才 FAIL;
//! C22 的分母 = 本轮在册集内的行(集外行天生无名字可富化,单独显形)。
use std::sync::Arc;
use std::time::Duration;
use ek_core::context::PluginContext;
use ek_core::error::AskError;
use ek_core::main_engine::MainEngine;
use ek_core::plugin::Plugin;
use ek_data::constant::{Direction, Offset, OrderType, Status};
use ek_data::order_request::OrderRequest;
use ek_oms::LockMode;
use ek_oms::plugin::{OmsCancelOrderReq, OmsCancelReply};
use ek_station::StationConfig;
use ek_station::wire::{ListReq, PlugRow, SVC_LIST};
use ek_ui::state::{AccountRow, AccountStage, CtpForm, LoginState, OrderForm, UiCmd, UiShared};
use ek_ui::{apply_cjk_fonts, render_body, spawn_host_watcher, ui_kinds};
/// 总线探针插件(0.6.x 巡回用):界面上的『撤单』钮组的是 `{请求网关}.{单号}`,跨家必落空;
/// 归属守卫 `mismatch:gateway` 真正的入口是**总线层的 `oms.cancel_order`**(调用方自带 en_orderid +
/// gateway_name 两个字段)。本插件替巡回在总线层发这一发,把守卫本体打出显形。
struct ProbePlugin {
ctx: std::sync::Mutex<Option<PluginContext>>,
/// 0.11.0 第4刀现场:合约流分批计数(帧数 / 行数 / 首帧到货时刻)
cf_frames: std::sync::atomic::AtomicUsize,
cf_rows: std::sync::atomic::AtomicUsize,
cf_first_ms: std::sync::atomic::AtomicI64,
}
impl ProbePlugin {
/// 总线层撤单请求(en_orderid 与 gateway_name 两个字段都由调用方给定 = 守卫的判据面)。
fn cancel(&self, en_orderid: &str, gateway_name: &str) -> Result<OmsCancelReply, AskError> {
let ctx = self
.ctx
.lock()
.unwrap()
.clone()
.expect("probe:PluginContext 未就位(on_init 没跑到?)");
ctx.ask_service::<OmsCancelOrderReq, OmsCancelReply>(
"oms.cancel_order",
OmsCancelOrderReq {
en_orderid: en_orderid.to_string(),
gateway_name: gateway_name.to_string(),
},
Duration::from_secs(10),
)
}
/// 0.7.0 背压刀:逐插件水位读数(svctl.list 一次问齐,数据源 = ek-core 的 mail_stats)。
fn water(&self) -> Result<Vec<PlugRow>, AskError> {
let ctx = self
.ctx
.lock()
.unwrap()
.clone()
.expect("probe:PluginContext 未就位(on_init 没跑到?)");
ctx.ask_service::<ListReq, Vec<PlugRow>>(SVC_LIST, ListReq, Duration::from_secs(5))
}
}
impl Plugin for ProbePlugin {
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!["eContract.".into()]
}
fn on_mail(&self, event: &ek_event::Event) {
if event.event_type == "eContract." {
use std::sync::atomic::Ordering::SeqCst;
if let Some(v) = event
.data
.downcast_ref::<Vec<ek_data::contract::ContractData>>()
{
self.cf_frames.fetch_add(1, SeqCst);
self.cf_rows.fetch_add(v.len(), SeqCst);
} else if event
.data
.downcast_ref::<ek_data::contract::ContractData>()
.is_some()
{
self.cf_frames.fetch_add(1, SeqCst);
self.cf_rows.fetch_add(1, SeqCst);
}
if self.cf_first_ms.load(SeqCst) == 0 {
self.cf_first_ms.store(ek_ctp::map::now_epoch_ms(), SeqCst);
}
}
}
}
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 verdict(shared: &UiShared, id: &str, pass: bool, detail: &str) -> bool {
let tag = if pass { "PASS" } else { "FAIL" };
let line = format!("【判定 {id} {tag}】{detail}");
println!("{line}");
shared.log_line(line);
pass
}
/// 三态判定(0.7.0 第3刀):`None` = SKIP —— 现场触发不了的不算进矩阵分母。
fn verdict3(shared: &UiShared, id: &str, state: Option<bool>, detail: &str) {
let tag = match state {
Some(true) => "PASS",
Some(false) => "FAIL",
None => "SKIP",
};
let line = format!("【判定 {id} {tag}】{detail}");
println!("{line}");
shared.log_line(line);
}
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(200));
}
false
}
/// 0.7.0 背压刀:水位仪表(逐插件 丢/爆/积压/峰/软顶,数据源 = svctl.list 带出的
/// ek-core `mail_stats` 读数)。三轮各打一行,收束前那一行同时是判定 C17 的原料。
fn water_gauge(shared: &UiShared, probe: &ProbePlugin, tag: &str) -> Vec<PlugRow> {
let (line, rows) = match probe.water() {
Ok(rows) => {
let parts: Vec<String> = rows
.iter()
.map(|r| {
format!(
"{}[丢{} 爆{} 积压{} 峰{} 软{}]",
r.name, r.spilled, r.panics, r.pending, r.peak, r.soft_cap
)
})
.collect();
(format!("【水位·{tag}】{}", parts.join(" ")), rows)
}
Err(e) => (format!("【水位·{tag}】svctl.list 失败: {e}"), Vec::new()),
};
println!("{line}");
shared.log_line(line);
rows
}
/// 0.7.0 现场取证:把界面日志环里新出现的**链状态/状态口失败/水位**行同步打到 stdout。
/// 巡回跑在窗口线程之外,链状态(连接→认证→登录→结算→合约→资金/持仓)此前只在屏幕上;
/// 出问题时 stdout 里必须有痕迹,才不用靠猜。
fn pump_chain(shared: &UiShared, cursor: &mut usize, tag: &str) {
let (lines, _) = shared.logs_snapshot();
if lines.len() < *cursor {
*cursor = 0;
}
for l in lines.iter().skip(*cursor) {
// 现场取证不打摘要:整条原样打出去(链状态、状态口失败、[ops] 受理、水位告警都在里面)。
println!("【环·{tag}】{l}");
}
*cursor = lines.len();
}
/// 日志环里含 needle 的行清单(探针要的是「我这一发之后新增的那一条」,不是「历史上出现过」)。
fn log_lines_with(shared: &UiShared, needle: &str) -> Vec<String> {
shared
.logs_snapshot()
.0
.iter()
.filter(|l| l.contains(needle))
.cloned()
.collect()
}
/// 某网关在账本里的行数(委托/成交/持仓/资金)。
fn counts_of(shared: &UiShared, gw: &str) -> (usize, usize, usize, usize) {
let s = shared.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(),
s.accounts.iter().filter(|a| a.gateway_name == gw).count(),
)
}
fn main() {
let user_a = std::env::var("EK_CTP_USER").expect("缺 EK_CTP_USER(账号 A)");
let pw_a = std::env::var("EK_CTP_PASSWORD").expect("缺 EK_CTP_PASSWORD(账号 A)");
let user_b = env_or("EK_CTP_2_USER", &user_a);
let pw_b = env_or("EK_CTP_2_PASSWORD", &pw_a);
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 engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
// 巡回自带一个总线探针插件(运维窗里会多一行 `probe`,它就是为这一段判据存在的)。
let probe = Arc::new(ProbePlugin {
ctx: std::sync::Mutex::new(None),
cf_frames: std::sync::atomic::AtomicUsize::new(0),
cf_rows: std::sync::atomic::AtomicUsize::new(0),
cf_first_ms: std::sync::atomic::AtomicI64::new(0),
});
let probe_build = Arc::clone(&probe);
let mut kinds = ui_kinds(&shared, true);
kinds.push(ek_station::PlugKind {
name: "probe",
doc: "巡回总线探针:在总线层直发 oms.cancel_order(归属守卫显形用)",
build: std::sync::Arc::new(move || -> std::sync::Arc<dyn Plugin> {
std::sync::Arc::clone(&probe_build) as std::sync::Arc<dyn Plugin>
}),
build_named: None,
name_validator: None,
});
ek_station::boot(
&engine,
&kinds,
&StationConfig {
plugins: vec!["cfg".to_string(), "ui".to_string(), "probe".to_string()],
},
)
.expect("boot");
engine.start().expect("start");
let watcher = spawn_host_watcher(&engine, &shared);
{
let shared = Arc::clone(&shared);
let probe = Arc::clone(&probe);
std::thread::spawn(move || {
let nap = |secs: u64| std::thread::sleep(Duration::from_secs(secs));
// ── 步 1:运维窗激活 dm / oms ──
nap(2);
banner(&shared, "步1 运维窗开 → svctl.start dm / oms");
shared.set_ops_window_open(true);
for f in ["dm", "oms"] {
shared.push_cmd(UiCmd::Start {
factory: f.to_string(),
name: String::new(),
});
nap(2);
}
shared.set_ops_window_open(false);
// ── 步 2:添加账号 A(行情元账号,TD+MD 双会话) ──
banner(
&shared,
&format!("步2 账户面板开 → 填账号 A(broker {broker})并『连接』(TD+MD;行情源)"),
);
shared.set_accounts_window_open(true);
let form_a = CtpForm {
broker_id: broker.clone(),
td_addr: td_addr.clone(),
td_user: user_a.clone(),
td_password: pw_a.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,
// 2026-10-28 用户裁定:期权在收录面**全线退役** —— A 不再故意勾期权。
// 原演习(A 勾期货+期权/B 只期货,拿期权行当串号探针)退役;漏串号护线
// 转入 C2/C3/C4/C10 + 新 C9「收录面(全网关只期货;任何期权行回流 = 判负)」。
accept_options: false,
};
shared.set_ctp_form(form_a.clone());
nap(3);
let row_a = AccountRow::derive(&form_a).expect("派生 A 注册名");
let gw_a = row_a.gateway.clone();
shared.add_account(row_a).expect("添加 A");
shared.set_md_source(&gw_a).expect("指定行情源 A");
banner(&shared, &format!("步2 连接 A = {gw_a}"));
shared.push_cmd(UiCmd::Connect {
gateway: gw_a.clone(),
form: form_a.clone(),
});
nap(10);
// 0.7.0 现场取证:登录期链状态同步到 stdout(30 拍 × 1 秒)
// ── C18 的原料(0.8.2):链句峰值在本衔接里采 —— [链] 句进环的时序是
// 「A 全链 → md 连接 → 全市场交易状态广播风暴(每 instrument 一行)」,
// 风暴会从环头往下挤:A 的 [链] 在风暴最前面,风暴一满环(ring 512)它就被挤出,
// B 在后连接,其 [链] 贴在环尾 → C1 之后再读永远是 A 0 / B 3(三轮巡回落款同一串读数实锤)。
// 所以:在 A/B 登录期的 pump 循环里逐拍把峰值抓下来,C18 收束时只消费,不再扫环。
let mut chain_peak_a = 0usize;
let mut polls_at_arrival = 0usize;
let mut chain_cur = 0usize;
for _ in 0..30 {
pump_chain(&shared, &mut chain_cur, "A 登录期");
let ring = shared.logs_snapshot().0;
let ca = ring
.iter()
.filter(|l| l.contains(ek_ui::state::CHAIN_STATE_PREFIX) && l.contains(&gw_a))
.count();
if ca > chain_peak_a {
chain_peak_a = ca;
}
polls_at_arrival = ring.iter().filter(|l| l.contains("状态口问不到")).count();
nap(1);
}
// C8 凭据串味(U4 回归)判据挪到两账户都入册之后(见 C8a/C8b/C8c):
// 0.2.18 起「窗内一张共享表单」已退役 → 判据要打在**账户行各自的 form** 上,
// 外加「添加下一家时草稿位清不清空」这一条(规划 Q20 缓解)。
// ── 步 3:添加账号 B(纯 TD:md 前置留空 → 从账号不该有任何 tick) ──
banner(
&shared,
&format!("步3 填账号 B(纯 TD:md 前置留空)并『连接』(派生名 = ctp_{broker}_账号B)"),
);
// 纯 TD 从账号:md 前置留空(订阅不会发给它,tick 也不该从它来)。
let form_b = CtpForm {
broker_id: broker.clone(),
td_addr: td_addr.clone(),
td_user: user_b.clone(),
td_password: pw_b.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(form_b.clone());
nap(3);
let row_b = AccountRow::derive(&form_b).expect("派生 B 注册名");
let gw_b = row_b.gateway.clone();
shared.add_account(row_b).expect("添加 B");
banner(&shared, &format!("步3 连接 B = {gw_b}(纯 TD)"));
shared.push_cmd(UiCmd::Connect {
gateway: gw_b.clone(),
form: form_b.clone(),
});
nap(6);
// 0.7.0 现场取证:B 登录期链状态(20 拍)+ C18 的 B 侧峰值采集(风暴前的时窗)
let mut chain_peak_b = 0usize;
for _ in 0..20 {
pump_chain(&shared, &mut chain_cur, "B 登录期");
let ring = shared.logs_snapshot().0;
let cb = ring
.iter()
.filter(|l| l.contains(ek_ui::state::CHAIN_STATE_PREFIX) && l.contains(&gw_b))
.count();
if cb > chain_peak_b {
chain_peak_b = cb;
}
nap(1);
}
shared.set_accounts_window_open(false);
// ── C1 双账户登录态 ──
let login_ok = wait_for(150, || {
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw_a);
let b = rows.iter().find(|r| r.gateway == gw_b);
matches!(
a.map(|r| (r.stage, r.login)),
Some((AccountStage::Assembled, LoginState::Online))
) && matches!(
b.map(|r| (r.stage, r.login)),
Some((AccountStage::Assembled, LoginState::Online))
)
});
let rows = shared.accounts();
let la = rows
.iter()
.find(|r| r.gateway == gw_a)
.map(|r| r.status_label())
.unwrap_or_else(|| "无行".to_string());
let lb = rows
.iter()
.find(|r| r.gateway == gw_b)
.map(|r| r.status_label())
.unwrap_or_else(|| "无行".to_string());
verdict(
&shared,
"C1 双账户装配+登录",
login_ok,
&format!(
"A[{gw_a}] = {la} ; B[{gw_b}] = {lb}(清册 {} 行,行情源 = {})",
rows.len(),
shared.md_source()
),
);
// (C18 的原料已在步2/步3 的登录期 pump 循环里逐拍采完:A/B 链句峰值 + 零轮询读数;
// 收束时只消费,不再扫环 —— 环被交易状态风暴与合约流灌满后,迟到的读数全是「取时点的错」。)
// ── C8 凭据串味(U4 / 规划 Q20 回归,三条判据) ──
// 0.2.18 起「窗内一张共享表单」退役:每账户一行一份 form。判据 =
// ① 两行各自的留档是不是自己那家(含 B 的 md 位应被单源纪律清空)
// ② 点『添加账户』开下一家的弹窗时,草稿位凭据必须清空(broker/AppID/AuthCode 可继承)
// ③ 点『编辑 A』载入的必须是 A 的留档,不许把 B 的凭据带进来
let fa8 = rows
.iter()
.find(|r| r.gateway == gw_a)
.map(|r| r.form.clone());
let fb8 = rows
.iter()
.find(|r| r.gateway == gw_b)
.map(|r| r.form.clone());
let own_ok = matches!(&fa8, Some(f) if f.td_user == user_a && f.td_password == pw_a && f.md_addr == md_addr)
&& matches!(&fb8, Some(f) if f.td_user == user_b && f.td_password == pw_b && f.md_addr.is_empty());
verdict(
&shared,
"C8a 每账户独立留档",
own_ok,
&format!(
"A 行:账号{} 密码{} md前置{} ; B 行:账号{} 密码{} md前置{}(B 为纯 TD → md 位应空)",
fa8.as_ref().map(|f| f.td_user.clone()).unwrap_or_default(),
if fa8.as_ref().is_some_and(|f| !f.td_password.is_empty()) {
"有"
} else {
"无"
},
if fa8.as_ref().is_some_and(|f| !f.md_addr.is_empty()) {
"有"
} else {
"空"
},
fb8.as_ref().map(|f| f.td_user.clone()).unwrap_or_default(),
if fb8.as_ref().is_some_and(|f| !f.td_password.is_empty()) {
"有"
} else {
"无"
},
if fb8.as_ref().is_some_and(|f| !f.md_addr.is_empty()) {
"有"
} else {
"空"
},
),
);
shared.begin_account_edit(None); // 等价点『添加账户』钮
let draft = shared.ctp_form();
verdict(
&shared,
"C8b 添加下一家时草稿位清空",
draft.td_user.is_empty()
&& draft.td_password.is_empty()
&& draft.td_addr.is_empty()
&& draft.md_addr.is_empty()
&& draft.broker_id == broker
&& draft.td_app_id == app_id,
&format!(
"『添加账户』开档:账号[{}] 密码[{}] td前置[{}] md前置[{}] broker[{}] AppID[{}](凭据全空 = 不会把上一家带进下一家)",
draft.td_user,
if draft.td_password.is_empty() {
"空"
} else {
"非空"
},
draft.td_addr,
draft.md_addr,
draft.broker_id,
draft.td_app_id,
),
);
shared.begin_account_edit(Some(&gw_a)); // 等价点『编辑 A』
let draft_a = shared.ctp_form();
verdict(
&shared,
"C8c 编辑载入 = 该行留档",
draft_a.td_user == user_a && draft_a.md_addr == md_addr,
&format!(
"『编辑 {}』载入:账号[{}] td前置[{}] md前置[{}](不许是 B 的账号 {})",
gw_a, draft_a.td_user, draft_a.td_addr, draft_a.md_addr, user_b,
),
);
shared.begin_account_edit(Some(&gw_b)); // 等价点『编辑 B』
let draft_b = shared.ctp_form();
verdict(
&shared,
"C8d 编辑载入 = 该行留档(B 侧)",
draft_b.td_user == user_b && draft_b.md_addr.is_empty(),
&format!(
"『编辑 {}』载入:账号[{}] td前置[{}] md前置[{}](不许是 A 的账号 {};纯 TD 装配的 md 位应留空)",
gw_b, draft_b.td_user, draft_b.td_addr, draft_b.md_addr, user_a,
),
);
shared.end_account_edit();
// ── 步 4:合约流 → 两账号各挑一支白银近月(订阅一律路由行情元账号) ──
banner(
&shared,
"步4 查询合约窗开 → 等合约流 → 订阅 ag2611(A 用)/ ag2612(B 用)",
);
shared.set_contracts_window_open(true);
// 0.7.0 现场取证:等合约流的 18 秒里同步把日志环打出来(每 1 秒一条)
let got = {
let mut ok = false;
for i in 0..180 {
if i % 10 == 0 {
pump_chain(&shared, &mut chain_cur, "步4 合约流");
}
if shared.contract_count() > 3 {
ok = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
ok
};
pump_chain(&shared, &mut chain_cur, "步4 收口");
let all = shared.contracts("");
// 尖判据 = 收录面(2026-10-28 裁定定型):**双账户都只期货**(D-PC-FILTER
// 各按自家勾选执行)。期权行在任何网关回流 = 有人改勾选/滤波被穿 = 当场判负;
// 外来网关行 = 共享合约表被别家污染。每支期货行必须恰好属于自己的收录网 ——
// 共享表按 symbol 键覆盖,两家都收同支 → 谁后写谁留,那是收录口径在基线里
// (python 侧同构);隔离的神经测面在 C2/C3/C4/C10,这里守的是**收口面的形制**。
let opt_rows: Vec<_> = all
.iter()
.filter(|c| c.asset == ek_data::constant::Asset::OPTION)
.collect();
let opt_from_b = opt_rows.iter().filter(|c| c.gateway_name == gw_b).count();
let fut_foreign = all
.iter()
.filter(|c| c.asset != ek_data::constant::Asset::OPTION)
.filter(|c| c.gateway_name != gw_a && c.gateway_name != gw_b)
.count();
let from_a = all.iter().filter(|c| c.gateway_name == gw_a).count();
let from_b = all.iter().filter(|c| c.gateway_name == gw_b).count();
let fut_rows = all.len() - opt_rows.len();
let shoulu_lines = shared
.logs_snapshot()
.0
.iter()
.filter(|l| l.contains("收录"))
.cloned()
.collect::<Vec<_>>();
verdict(
&shared,
"C9 合约收录面(全网关只期货;期权/外来归零)",
got && opt_rows.is_empty()
&& opt_from_b == 0
&& fut_foreign == 0
&& from_a > 0
&& from_b > 0,
&format!(
"在册 {} 条 = 期货 {}(自 A {} / 自 B {});期权行 {} 条(裁定后必须 0,自 B {});外来网关 {} 条。两路收录汇总:{}",
all.len(),
fut_rows,
from_a,
from_b,
opt_rows.len(),
opt_from_b,
fut_foreign,
shoulu_lines.join(" | "),
),
);
let sym_a = "ag2611".to_string();
let sym_b = "ag2612".to_string();
let have = |s: &str| all.iter().any(|c| c.symbol == s);
let (sym_a, sym_b) = if have(&sym_a) && have(&sym_b) {
(sym_a, sym_b)
} else {
let ags: Vec<_> = all
.iter()
.filter(|c| c.symbol.starts_with("ag"))
.map(|c| c.symbol.clone())
.collect();
if ags.len() >= 2 {
(ags[0].clone(), ags[1].clone())
} else {
(sym_a, sym_b)
}
};
shared.push_cmd(UiCmd::CtpSubscribe {
gateway: gw_a.clone(),
symbols: vec![sym_a.clone(), sym_b.clone()],
});
nap(12);
shared.set_contracts_window_open(false);
// ── C2 行情归属:所有 tick 只可能来自行情元账号 ──
let ticks = shared.tick_rows();
let tick_a = ticks.iter().filter(|t| t.gateway_name == gw_a).count();
let tick_b = ticks.iter().filter(|t| t.gateway_name == gw_b).count();
let tick_other = ticks
.iter()
.filter(|t| t.gateway_name != gw_a && t.gateway_name != gw_b)
.count();
verdict(
&shared,
"C2 行情归属(订阅只走元账号)",
tick_a > 0 && tick_b == 0 && tick_other == 0,
&format!(
"tick 共 {} 条:自行情源 A {} 条 / 自纯 TD 从账号 B {} 条 / 第三方 {} 条;订阅集 {:?}",
ticks.len(),
tick_a,
tick_b,
tick_other,
shared.subscriptions()
),
);
// ── 步 5:两账户各下一笔「吃穿盘口」的卖开(可成交)+ 各一笔远价悬单 ──
let bid_of = |s: &str| {
shared
.tick_rows()
.into_iter()
.rfind(|t| t.symbol == s)
.map(|t| t.bid_price_1)
.filter(|b| *b > 0.0)
};
let price_a = bid_of(&sym_a).unwrap_or(1.0);
let price_b = bid_of(&sym_b).unwrap_or(1.0);
let rest_a = (price_a * 0.99).floor().max(1.0);
let rest_b = (price_b * 0.99).floor().max(1.0);
// ── 步4.6 登录即上账取证(本会话还一笔没发) ──
// 委托/成交:网关侧**没有查询面**(shim 未导出 ReqQryOrder/ReqQryTrade),
// 只有柜台实时回报 onRtnOrder/onRtnTrade 会进来 → 这一行的数量就是
// 「登录时柜台推来了什么」的直接读数;持仓是查来的,拿来当对照。
let pre = shared.trade_snap();
let pre_ids: Vec<_> = pre
.orders
.iter()
.map(|o| {
format!(
"{}@{} {} {:?} 已成{}",
o.symbol, o.price, o.orderid, o.status, o.traded
)
})
.collect();
for line in [
format!(
"登录即上账(本会话零发单):委托 {} 笔 / 成交 {} 笔 / 持仓 {} 行 / 资金 {} 行",
pre.orders.len(),
pre.trades.len(),
pre.positions.len(),
pre.accounts.len()
),
format!(
"登录即上账的委托明细:{}",
if pre_ids.is_empty() {
"(空)".to_string()
} else {
pre_ids.join(" ; ")
}
),
] {
println!("【取证】{line}");
shared.log_line(format!("【取证】{line}"));
}
// ── 步4.7 水位仪表基线(0.7.0 背压刀:邮箱无界默认不丢,积压要看得见) ──
water_gauge(&shared, &probe, "步4.7 登录完成");
banner(
&shared,
&format!(
"步5 发单:A {sym_a} 卖开1 @{price_a} ; B {sym_b} 卖开1 @{price_b}(各吃自家买一)"
),
);
let mut req_a = OrderRequest {
symbol: sym_a.clone(),
exchange: ek_data::constant::Exchange::SHFE,
direction: Direction::SHORT,
order_type: OrderType::LIMIT,
volume: 1.0,
price: price_a,
offset: Offset::OPEN,
..OrderRequest::default()
};
shared.set_order_form(OrderForm {
exchange: "SHFE".to_string(),
symbol: sym_a.clone(),
direction: Direction::SHORT,
offset: Offset::OPEN,
order_type: OrderType::LIMIT,
price: price_a.to_string(),
volume: "1".to_string(),
gateway: gw_a.clone(),
follow_price: false,
auto_split: true,
});
shared.push_cmd(UiCmd::SendOrder {
req: req_a.clone(),
gateway: gw_a.clone(),
mode: LockMode::Auto,
});
nap(4);
req_a.symbol = sym_b.clone();
req_a.price = price_b;
shared.push_cmd(UiCmd::SendOrder {
req: req_a.clone(),
gateway: gw_b.clone(),
mode: LockMode::Auto,
});
nap(6);
banner(
&shared,
&format!(
"步5 悬单:A {sym_a} 买开1 @{rest_a} ; B {sym_b} 买开1 @{rest_b}(远价不成)"
),
);
let mut rest_req = OrderRequest {
symbol: sym_a.clone(),
exchange: ek_data::constant::Exchange::SHFE,
direction: Direction::LONG,
order_type: OrderType::LIMIT,
volume: 1.0,
price: rest_a,
offset: Offset::OPEN,
..OrderRequest::default()
};
shared.push_cmd(UiCmd::SendOrder {
req: rest_req.clone(),
gateway: gw_a.clone(),
mode: LockMode::Auto,
});
nap(2);
rest_req.symbol = sym_b.clone();
rest_req.price = rest_b;
shared.push_cmd(UiCmd::SendOrder {
req: rest_req,
gateway: gw_b.clone(),
mode: LockMode::Auto,
});
nap(8);
let snap = shared.trade_snap();
// ── C3 委托归属:en_orderid 前缀必等于自己的网关 ──
let foreign_gateway = snap
.orders
.iter()
.filter(|o| o.gateway_name != gw_a && o.gateway_name != gw_b)
.count();
let prefix_bad: Vec<_> = snap
.orders
.iter()
.filter(|o| !o.en_orderid().starts_with(&format!("{}.", o.gateway_name)))
.map(|o| o.en_orderid())
.collect();
let n_a = snap
.orders
.iter()
.filter(|o| o.gateway_name == gw_a)
.count();
let n_b = snap
.orders
.iter()
.filter(|o| o.gateway_name == gw_b)
.count();
verdict(
&shared,
"C3 委托归属",
n_a > 0 && n_b > 0 && foreign_gateway == 0 && prefix_bad.is_empty(),
&format!(
"委托 A {} 笔 / B {} 笔 / 外来网关 {} 笔;en_orderid 前缀错位 {} 处{:?}",
n_a,
n_b,
foreign_gateway,
prefix_bad.len(),
prefix_bad
),
);
// ── C4 成交归属:成交行的网关必须与它归属的委托同一家 ──
let bad_trade: Vec<_> = snap
.trades
.iter()
.filter(|t| {
snap.orders
.iter()
.any(|o| o.orderid == t.orderid && o.gateway_name != t.gateway_name)
})
.map(|t| format!("{}@{} gw={}", t.symbol, t.price, t.gateway_name))
.collect();
let t_a = snap
.trades
.iter()
.filter(|t| t.gateway_name == gw_a)
.count();
let t_b = snap
.trades
.iter()
.filter(|t| t.gateway_name == gw_b)
.count();
verdict(
&shared,
"C4 成交归属",
bad_trade.is_empty(),
&format!(
"成交 A {} 笔 / B {} 笔;与委托网关不一致的成交 {} 笔{:?}",
t_a,
t_b,
bad_trade.len(),
bad_trade
),
);
// ── C5 跨账户撤单:两道入口都探,外加「正确网关」对照 ──
// ① UI 路径(『撤单』钮/全撤):界面把 en_orderid 组成 `{请求网关}.{单号}`,
// 跨家时这一发在 oms 缓存里根本不存在 → `unknown:order`,单留场;
// ② 总线路径(调用方自带 en_orderid + gateway_name 两字段)→ 才是归属守卫
// `mismatch:gateway` 的真入口(ek-oms 0.3.5 修的 F1);
// ③ 交回正确网关 → 必须转 CANCELLED(守卫不能把合法撤单一起挡掉)。
banner(
&shared,
"步6 跨账户撤单探针:UI 路径(跨家)→ 总线路径(伪造 gateway_name)→ 正确网关对照",
);
let snap3 = shared.trade_snap();
let rest_a_hit = snap3
.orders
.iter()
.find(|o| {
o.gateway_name == gw_a
&& o.symbol == sym_a
&& (o.price - rest_a).abs() < f64::EPSILON
&& o.is_active()
})
.map(|o| (o.orderid.clone(), o.en_orderid()));
let rest_b_hit = snap3
.orders
.iter()
.find(|o| {
o.gateway_name == gw_b
&& o.symbol == sym_b
&& (o.price - rest_b).abs() < f64::EPSILON
&& o.is_active()
})
.map(|o| (o.orderid.clone(), o.en_orderid()));
let still_active = |id: &str| {
shared
.trade_snap()
.orders
.iter()
.any(|o| o.orderid == id && o.status != Status::CANCELLED)
};
// ① + ② + ③:A 的单
match &rest_a_hit {
Some((id, en)) => {
let n0 = log_lines_with(&shared, "撤单被拒").len();
shared.push_cmd(UiCmd::CancelOrder {
orderid: id.clone(),
gateway: gw_b.clone(),
});
let ui_rejected =
wait_for(12, || log_lines_with(&shared, "撤单被拒").len() > n0);
let ui_reason = shared
.logs_snapshot()
.0
.iter()
.rev()
.find(|l| l.contains("撤单被拒"))
.cloned()
.unwrap_or_else(|| "(无拒单行)".to_string());
verdict(
&shared,
"C5a UI 路径跨家撤单(A单交B网关)",
ui_rejected && still_active(id),
&format!(
"新增拒单行: {ui_reason} ;那一笔仍在场上 = {}",
still_active(id)
),
);
let bus = probe.cancel(en, &gw_b);
let _ = match bus {
Ok(rep) => verdict(
&shared,
"C5b 总线归属守卫(A单 + 伪造 gateway_name=B)",
!rep.ok && rep.msg.contains("mismatch:gateway") && still_active(id),
&format!(
"oms 回包 ok={} msg={} ;那一笔仍在场上 = {}",
rep.ok,
rep.msg,
still_active(id)
),
),
Err(e) => verdict(
&shared,
"C5b 总线归属守卫(A单 + 伪造 gateway_name=B)",
false,
&format!("ask 失败: {e:?}"),
),
};
shared.push_cmd(UiCmd::CancelOrder {
orderid: id.clone(),
gateway: gw_a.clone(),
});
let cancelled = wait_for(20, || {
shared
.trade_snap()
.orders
.iter()
.any(|o| &o.orderid == id && o.status == Status::CANCELLED)
});
// 柜台窗口三态(0.8.2 对齐收编侧孪生钉):20s 内柜台**没回**这一笔(状态没动)
// = 柜台侧没打分 → SKIP;交回正确网关却被拒(status=REJECTED)= 产品问题 → FAIL。
let snap5c = shared.trade_snap();
let rejected = snap5c
.orders
.iter()
.any(|o| &o.orderid == id && o.status == Status::REJECTED);
drop(snap5c);
verdict3(
&shared,
"C5c 正确网关对照撤单(A单交A网关)",
if cancelled {
Some(true)
} else if rejected {
Some(false)
} else {
None
},
&format!(
"单 {id} 交回 {gw_a} → 转 CANCELLED = {cancelled}(守卫不许把合法撤单一起挡掉){}",
if !cancelled && !rejected {
";20s 内柜台没回这一笔的撤单回执 → SKIP(守卫本体见 C5b)"
} else {
""
}
),
);
}
None => {
// 柜台窗口三态(0.8.2,与 B 侧 None 分支同口径):A 悬单没落地 → SKIP
verdict3(
&shared,
"C5a UI 路径跨家撤单(A单交B网关)",
None,
"40s 内没有可撤的悬单落地(A 悬单到货探测失败)→ SKIP",
);
verdict3(
&shared,
"C5b 总线归属守卫(A单 + 伪造 gateway_name=B)",
None,
"同上",
);
verdict3(&shared, "C5c 正确网关对照撤单(A单交A网关)", None, "同上");
}
}
// ① + ② + ③:B 的单(反向同形)
match &rest_b_hit {
Some((id, en)) => {
let n0 = log_lines_with(&shared, "撤单被拒").len();
shared.push_cmd(UiCmd::CancelOrder {
orderid: id.clone(),
gateway: gw_a.clone(),
});
let ui_rejected =
wait_for(12, || log_lines_with(&shared, "撤单被拒").len() > n0);
let ui_reason = shared
.logs_snapshot()
.0
.iter()
.rev()
.find(|l| l.contains("撤单被拒"))
.cloned()
.unwrap_or_else(|| "(无拒单行)".to_string());
verdict(
&shared,
"C5d UI 路径跨家撤单(B单交A网关)",
ui_rejected && still_active(id),
&format!(
"新增拒单行: {ui_reason} ;那一笔仍在场上 = {}",
still_active(id)
),
);
// C23(第6刀 R7):屏幕那一行必须**人话 + 机器码原文**都在。
// 界面路径(en_orderid 由「请求网关 + 单号」拼出)跨家必然先撞
// `unknown:order` —— 拼出来的键在账本里根本不存在;`mismatch:gateway`
// 只有总线路径(真 en_orderid + 伪造 gateway_name)才走到(C5e 钉它)。
// 两条路的译文都在这里验:认得出的码 → 有人话 + 原文照留。
let translated = (ui_reason.contains("这台网关的账本")
&& ui_reason.contains("unknown:order"))
|| (ui_reason.contains("该委托不属于这个账户")
&& ui_reason.contains("mismatch:gateway"));
verdict(
&shared,
"C23 撤单拒因翻译(人话 + 机器码原文同屏)",
ui_rejected && translated,
&format!("屏幕上那一行:{ui_reason}"),
);
let bus = probe.cancel(en, &gw_a);
let _ = match bus {
Ok(rep) => verdict(
&shared,
"C5e 总线归属守卫(B单 + 伪造 gateway_name=A)",
!rep.ok && rep.msg.contains("mismatch:gateway") && still_active(id),
&format!(
"oms 回包 ok={} msg={} ;那一笔仍在场上 = {}",
rep.ok,
rep.msg,
still_active(id)
),
),
Err(e) => verdict(
&shared,
"C5e 总线归属守卫(B单 + 伪造 gateway_name=A)",
false,
&format!("ask 失败: {e:?}"),
),
};
shared.push_cmd(UiCmd::CancelOrder {
orderid: id.clone(),
gateway: gw_b.clone(),
});
let cancelled = wait_for(20, || {
shared
.trade_snap()
.orders
.iter()
.any(|o| &o.orderid == id && o.status == Status::CANCELLED)
});
// 柜台窗口三态(0.8.2 对齐收编侧孪生钉):20s 内柜台**没回**这一笔
// (状态没动;SimNow 繁忙/该笔已终结)= 柜台侧没打分 → SKIP,不在窗口外打分;
// 交回正确网关却被拒(status=REJECTED)= 产品侧的问题,才是 FAIL。
let snap5f = shared.trade_snap();
let rejected = snap5f
.orders
.iter()
.any(|o| &o.orderid == id && o.status == Status::REJECTED);
drop(snap5f);
verdict3(
&shared,
"C5f 正确网关对照撤单(B单交B网关)",
if cancelled {
Some(true)
} else if rejected {
Some(false)
} else {
None
},
&format!(
"单 {id} 交回 {gw_b} → 转 CANCELLED = {cancelled}{}",
if !cancelled && !rejected {
";20s 内柜台没回这一笔的撤单回执 → SKIP(守卫本体见 C5e)"
} else {
""
}
),
);
}
None => {
// 柜台窗口三态:两账户没有可撤的悬单落地 = 撤单判据没得判 → 全部 SKIP
// (不在柜台窗口外打分;与收编侧孪生钉同口径,C5b 的守卫本体不受影响)
verdict3(
&shared,
"C5d UI 路径跨家撤单(B单交A网关)",
None,
"40s 内没有可撤的悬单落地(B 悬单到货探测失败)→ SKIP",
);
verdict3(
&shared,
"C5e 总线归属守卫(B单 + 伪造 gateway_name=A)",
None,
"同上",
);
verdict3(&shared, "C5f 正确网关对照撤单(B单交B网关)", None, "同上");
// C23 也必须在本轮有交代(0.8.4 审计补):它的判定原点在 Some 臂
// (跨家拒单行产出的那一路),B 悬单不落地 = 拒单行没产出 → 翻译无从验
// → SKIP。不补这条,矩阵就会像 0.8.2 的四巡那样静默少一个条号
// (实测四巡 20 条而非 21,对账才被抓出来)。
verdict3(
&shared,
"C23 撤单拒因翻译(人话 + 机器码原文同屏)",
None,
"B 悬单未落地 → 跨家拒单行本轮没产出 → 翻译无从验证 → SKIP",
);
}
}
// ── C6 资金归属:accountid ↔ gateway 配对 ──
// B 侧资金/持仓行由查询链带来,要等它到货再取快照(0.8.2 对齐收编侧孪生钉;
// 等待收口与否打印在 SKIP/判定细目里,窗口外不打分)。
let b_side = wait_for(90, || {
let s = shared.trade_snap();
s.accounts.iter().any(|a| a.gateway_name == gw_b)
&& s.positions.iter().any(|p| p.gateway_name == gw_b)
});
let snap2 = shared.trade_snap();
let acc_a: Vec<_> = snap2
.accounts
.iter()
.filter(|a| a.gateway_name == gw_a)
.map(|a| a.accountid.clone())
.collect();
let acc_b: Vec<_> = snap2
.accounts
.iter()
.filter(|a| a.gateway_name == gw_b)
.map(|a| a.accountid.clone())
.collect();
let pair_ok = acc_a.iter().all(|id| *id == user_a)
&& acc_b.iter().all(|id| *id == user_b)
&& !acc_a.is_empty()
&& !acc_b.is_empty();
let fa = snap2.accounts.iter().find(|a| a.gateway_name == gw_a);
let fb = snap2.accounts.iter().find(|a| a.gateway_name == gw_b);
// 三态(0.8.2 对齐收编侧孪生钉):一侧资金行整窗未到(查询链没带上来)= 柜台窗口外
// 不打分 → SKIP;到了而 accountid 与网关串号 = 归属错,才是 FAIL。
if acc_a.is_empty() || acc_b.is_empty() {
verdict3(
&shared,
"C6 资金归属(accountid↔网关)",
None,
&format!(
"一侧资金行整窗未到货(A {acc_a:?} / B {acc_b:?})→ 查询链不在柜台窗口外打分;到货等待收口={b_side}",
),
);
} else {
verdict(
&shared,
"C6 资金归属(accountid↔网关)",
pair_ok,
&format!(
"A 报 accountid {:?}(应={}) ; B 报 accountid {:?}(应={}) ; 资金 A {:.2} / B {:.2}",
acc_a,
user_a,
acc_b,
user_b,
fa.map(|a| a.balance).unwrap_or(0.0),
fb.map(|a| a.balance).unwrap_or(0.0),
),
);
}
// ── C7 持仓归属:两网关的持仓行互不出现对方的单号 ──
let pos_a: Vec<_> = snap2
.positions
.iter()
.filter(|p| p.gateway_name == gw_a)
.map(|p| p.symbol.clone())
.collect();
let pos_b: Vec<_> = snap2
.positions
.iter()
.filter(|p| p.gateway_name == gw_b)
.map(|p| p.symbol.clone())
.collect();
let pos_foreign = snap2
.positions
.iter()
.filter(|p| p.gateway_name != gw_a && p.gateway_name != gw_b)
.count();
let pos_shared_syms: Vec<_> = pos_a
.iter()
.filter(|s| pos_b.contains(s))
.cloned()
.collect();
// 三态(0.8.2 对齐收编侧孪生钉):B 侧持仓/资金行未到货 → 「外来 = 0」是空转通过,
// 不算分;到货才判(A 有行、B 有行、外来 0)。
let c7_state = if !b_side {
None
} else {
Some(pos_foreign == 0)
};
verdict3(
&shared,
"C7 持仓归属(按网关分组)",
c7_state,
&format!(
"A 持仓 {} 行 / B 持仓 {} 行 / 外来网关 {} 行;两边同名的合约 {} 支{:?}(同名不等于串号:两账户各自的持仓查询){}",
pos_a.len(),
pos_b.len(),
pos_foreign,
pos_shared_syms.len(),
pos_shared_syms,
if c7_state.is_none() {
";B 侧持仓/资金行未到货 → SKIP(不在柜台窗口外打分)"
} else {
""
}
),
);
let _ = &probe;
// ── C10 摘账户隔离:摘掉 B 后 B 的行全清、A 一条不少 ──
let before_a = counts_of(&shared, &gw_a);
let before_b = counts_of(&shared, &gw_b);
banner(
&shared,
&format!("步7 摘除 B = {gw_b}(等价「移除」钮,带撤单守卫)→ 看 A 的行是否受损"),
);
shared.remove_account(&gw_b);
shared.push_cmd(UiCmd::Remove { name: gw_b.clone() });
nap(8);
let after_a = counts_of(&shared, &gw_a);
let after_b = counts_of(&shared, &gw_b);
let rows_after = shared.accounts().len();
verdict(
&shared,
"C10 摘账户隔离",
after_b == (0, 0, 0, 0) && after_a.0 >= before_a.0 && rows_after == 1,
&format!(
"摘前 A(委托/成交/持仓/资金)={before_a:?} B={before_b:?} → 摘后 A={after_a:?} B={after_b:?};清册剩 {rows_after} 行",
),
);
// ── 步8 名称覆盖度盘点:合约 / 行情 / 委托 / 成交 / 持仓 各表里 name 字段到底有没有值 ──
// (CTP 报文只有合约查询回报带 InstrumentName;委托/成交/持仓/行情回报里都没有,
// 所以这一列有没有名字,取决于映射层有没有去合约表补。)
banner(&shared, "步8 名称覆盖度盘点(合约/行情/委托/成交/持仓)");
let snapn = shared.trade_snap();
let cov = |total: usize, nonempty: usize, sample: &str| -> String {
format!("{}/{} 非空{sample}", nonempty, total)
};
let cs = shared.contracts("");
let c_non = cs.iter().filter(|c| !c.name.is_empty()).count();
let c_sample = cs
.iter()
.find(|c| c.symbol == sym_a)
.map(|c| format!(";样例 {} → 「{}」", c.symbol, c.name))
.unwrap_or_default();
let ts = shared.tick_rows();
let t_non = ts.iter().filter(|t| !t.name.is_empty()).count();
let t_sample = ts
.first()
.map(|t| format!(";样例 {} → 「{}」", t.symbol, t.name))
.unwrap_or_default();
let o_non = snapn.orders.iter().filter(|o| !o.name.is_empty()).count();
let o_sample = snapn
.orders
.first()
.map(|o| format!(";样例 {} → 「{}」", o.symbol, o.name))
.unwrap_or_default();
let tr_non = snapn.trades.iter().filter(|t| !t.name.is_empty()).count();
let tr_sample = snapn
.trades
.first()
.map(|t| format!(";样例 {} → 「{}」", t.symbol, t.name))
.unwrap_or_default();
let p_non = snapn
.positions
.iter()
.filter(|p| !p.name.is_empty())
.count();
let p_sample = snapn
.positions
.first()
.map(|p| format!(";样例 {} → 「{}」", p.symbol, p.name))
.unwrap_or_default();
for (label, cov) in [
("合约表 name", cov(cs.len(), c_non, &c_sample)),
("行情 tick name", cov(ts.len(), t_non, &t_sample)),
("委托表 name", cov(snapn.orders.len(), o_non, &o_sample)),
("成交表 name", cov(snapn.trades.len(), tr_non, &tr_sample)),
("持仓表 name", cov(snapn.positions.len(), p_non, &p_sample)),
] {
println!("【名称覆盖】{label}:{cov}");
shared.log_line(format!("【名称覆盖】{label}:{cov}"));
}
// 取证细目:哪些委托/成交行没名字(网关 + 代码 + 状态),给下一轮定位用
let thin: Vec<String> = snapn
.orders
.iter()
.filter(|o| o.name.is_empty())
.take(6)
.map(|o| format!("{}:{}#{}", o.gateway_name, o.symbol, o.orderid))
.chain(
snapn
.trades
.iter()
.filter(|t| t.name.is_empty())
.take(6)
.map(|t| format!("{}:成交{}#{}", t.gateway_name, t.symbol, t.tradeid)),
)
.collect();
shared.log_line(format!("【名称缺牌细目】{}", thin.join(" | ")));
// C22(第5刀 R6 现场半边):三张交易行的名称覆盖率必须 100%(网关侧补名;
// 界面兜底只在柜台补不到时兜)。**分母 = 本轮在册集内的行**(0.8.2 对齐收编侧
// 孪生钉同款口径):落在在册集之外的合约**天生没有名字可富化**,不算分母、单独显形;
// 分母之内(名字应当有)缺一个就是 FAIL。
let in_set: std::collections::HashSet<String> =
cs.iter().map(|c| c.symbol.clone()).collect();
let cov22 = |rows: Vec<(String, String)>| -> (usize, usize, usize) {
let mut named = 0usize;
let mut den = 0usize;
let mut out = 0usize;
for (symbol, name) in rows {
if in_set.contains(&symbol) {
den += 1;
if !name.is_empty() {
named += 1;
}
} else {
out += 1;
}
}
(named, den, out)
};
let (o_named, o_den, o_out) = cov22(
snapn
.orders
.iter()
.map(|o| (o.symbol.clone(), o.name.clone()))
.collect(),
);
let (tr_named, tr_den, tr_out) = cov22(
snapn
.trades
.iter()
.map(|t| (t.symbol.clone(), t.name.clone()))
.collect(),
);
let (p_named, p_den, p_out) = cov22(
snapn
.positions
.iter()
.map(|p| (p.symbol.clone(), p.name.clone()))
.collect(),
);
// 三态(0.8.2):某张面整窗一行都没有 = 柜台窗口没回那一面的查询(如持仓),
// 覆盖度没有可打分的分母 → SKIP;三面都有行才判(分母内缺一个 = FAIL)。
let empty_faces: Vec<&str> = [
("委托", snapn.orders.is_empty()),
("成交", snapn.trades.is_empty()),
("持仓", snapn.positions.is_empty()),
]
.iter()
.filter(|(_, e)| *e)
.map(|(n, _)| *n)
.collect();
let c22_state = if empty_faces.is_empty() {
Some(o_named == o_den && tr_named == tr_den && p_named == p_den)
} else {
None
};
verdict3(
&shared,
"C22 交易行名称覆盖 100%(委托/成交/持仓)",
c22_state,
&format!(
"在册集内:委托 {o_named}/{o_den} 成交 {tr_named}/{tr_den} 持仓 {p_named}/{p_den}(集外行 {o_out}/{tr_out}/{p_out} 天生无名字可富化,不进分母)样例 委托{o_sample}{}",
if empty_faces.is_empty() {
String::new()
} else {
format!(
";{faces}整窗 0 行(柜台窗口内未到货)→ SKIP",
faces = empty_faces.join("、")
)
}
),
);
// 柜台侧口径:合约表里 name 与 symbol 同形的有多少条(= 柜台把代码当名字上抛),
// 并各采样例(期货 6)——中文名到底有没有,看这一行就知道。期权样例行保留为
// 空摆设(2026-10-28 裁定:收录面期权退役,它的空 = 正确状态,仅作显形)。
let same = cs.iter().filter(|c| c.name == c.symbol).count();
let fut: Vec<_> = cs
.iter()
.filter(|c| c.asset != ek_data::constant::Asset::OPTION)
.take(6)
.collect();
let opt: Vec<_> = cs
.iter()
.filter(|c| c.asset == ek_data::constant::Asset::OPTION)
.take(6)
.collect();
let dump = |rows: &[&ek_data::contract::ContractData]| -> String {
rows.iter()
.map(|c| format!("{}[{}]→「{}」", c.symbol, c.exchange.as_str(), c.name))
.collect::<Vec<_>>()
.join(" ")
};
for line in [
format!("合约 name 与代码同形:{}/{} 条(其余为异名)", same, cs.len()),
format!("期货样例:{}", dump(&fut)),
format!("期权样例(退役期,全应空):{}", dump(&opt)),
] {
println!("【名称柜台口径】{line}");
shared.log_line(format!("【名称柜台口径】{line}"));
}
// ── 步8.6 第3刀现场取证:状态推送显形 + 断线同一拍到货 + 零轮询自证 ──
// (a) 链状态到货:证据取在**到货那一刻**(C1 后采的 chain_peak_*),不在收束时补扫。
// 收束时环早被合约流淹没 → 扫环读到 0 是取时点的错,不是「没推」。
let chain_a = chain_peak_a;
let chain_b = chain_peak_b;
let c18_detail = format!(
"到货采到链句 A {chain_a} / B {chain_b}(峰值;采证窗口 = A/B 登录期逐拍,风暴挤出前抓取);状态口问不到 {polls} 条(零轮询:问不到就该显形)",
polls = polls_at_arrival,
);
if chain_a == 0 && chain_b == 0 {
// 一条链句都没采到 = 柜台窗口/登录链没跑起来 → SKIP,不在窗口外打分
verdict3(
&shared,
"C18 状态推送显形(链状态经 eStatus 到货,界面零轮询)",
None,
&c18_detail,
);
} else {
verdict3(
&shared,
"C18 状态推送显形(链状态经 eStatus 到货,界面零轮询)",
Some(chain_a >= 3 && chain_b >= 3 && polls_at_arrival == 0),
&c18_detail,
);
}
// (b) 顶号断线(0.7.0 第3刀现场的确定性断线法):SimNow 一人一登录 ——
// 第二进程用 A 的账号登录同一前置,柜台把本会话踢下线 → 前置断开
// 事件必须**同一拍**进环,状态列翻告警态、告警计数涨。
// (`{gw}.disconnect` 是主动停会话:CTP 的 Release 不触发断开回调,
// 所以它显形的是「会话已复位 + 已断开」,不是掉线 —— 两条分开钉,
// 放在步8.8 单独判。)
let alerts_before = shared.alerted_count();
banner(
&shared,
"步8.7 顶号断线(第二进程用 A 的账号登录同一前置;柜台踢下线)",
);
let spawn =
std::process::Command::new(std::env::var("EK_LIVE_SPAWN").unwrap_or_else(|_| {
r"C:\dev\fuke\ek-ctp\target\debug\examples\live_simnow.exe".to_string()
}))
.current_dir(r"C:\dev\fuke\ek-ctp")
.env("EK_CTP_FRONT_SET", "724")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let spawn_ok = spawn.is_ok();
let mut child = match spawn {
Ok(c) => Some(c),
Err(e) => {
shared.log_line(format!("[ops] 顶号进程起不来: {e}"));
None
}
};
let drop_seen = wait_for(180, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains(&gw_a) && l.contains("交易前置断开"))
});
if let Some(mut c) = child.take() {
let _ = c.kill();
let _ = c.wait();
}
let row_a = shared.account(&gw_a);
let login_a: Option<ek_ui::state::LoginState> = row_a.as_ref().map(|r| r.login);
let note_a = row_a
.as_ref()
.and_then(|r| r.chain_note.clone())
.unwrap_or_default();
// 现场触发判据:柜台侧真把会话踢下线(顶号)。实测本机 SimNow 7×24(set2)
// **允许同一账号多会话并行**(2026-10-27 02:42 双进程实测:两路都登录成功、
// 两路都跑完场景)→ 顶号不生效,断线事件到不了。主动 `{gw}.disconnect` 也
// 不产生掉线(CTP `Release()` 不触发断开回调,步8.8 单独钉)。
// 所以这一条是**三态**:到货就判(同一拍三件事),没到货记 SKIP 不算分,
// 判据本体由单元/集成钉把门(ek-ctp status_events S1/S4/S5 + ek-ui C11/C12/C14)。
verdict3(
&shared,
"C19 顶号断线同一拍显形(事件到货 = 文案+状态列+告警同拍)",
if !spawn_ok {
None
} else if drop_seen {
Some(
login_a.is_some_and(|l| l.is_alarm())
&& shared.alerted_count() > alerts_before,
)
} else {
None
},
&format!(
"断开事件进环={drop_seen}(本柜台 7×24 允许同账号多会话 → 顶号不生效);A 登录态={login_a:?};阶段栏=「{note_a}」;告警计数 {alerts_before}→{}",
shared.alerted_count()
),
);
// ── 步8.8 主动断开显形(裁定 1「断开 = 停会话」:不叫掉线,不刷告警) ──
banner(
&shared,
"步8.8 主动断 A 会话(显形应为「已断开 + 会话已复位」)",
);
shared.push_cmd(UiCmd::Disconnect {
gateway: gw_a.clone(),
});
let stop_seen = wait_for(120, || {
let rows = shared.accounts();
let note = rows
.iter()
.find(|r| r.gateway == gw_a)
.and_then(|r| r.chain_note.clone())
.unwrap_or_default();
note.contains("会话已复位")
&& rows
.iter()
.any(|r| r.gateway == gw_a && r.login == ek_ui::state::LoginState::Stopped)
});
verdict(
&shared,
"C20 主动断开显形(停会话 ≠ 掉线)",
stop_seen,
"断开后阶段栏回「会话已复位」且登录态=已断开(不得谎称在线)",
);
// ── 步9 水位二轮(行情+发单+撤单全跑完之后的积压与峰值读数) ──
let _mid = water_gauge(&shared, &probe, "步9 巡回中段");
// ── C21 合约流分批(0.11.0 第4刀 R5;判据 C16 的现场半边) ──
use std::sync::atomic::Ordering::SeqCst;
let frames = probe.cf_frames.load(SeqCst);
let rows = probe.cf_rows.load(SeqCst);
// 两家网关各自有一批尾巴:帧数上限 = ⌈总行/2000⌉ + 2
let frame_cap = rows.div_ceil(ek_ctp::CONTRACT_BATCH_ROWS) + 2;
let gw_peak = _mid
.iter()
.filter(|r| r.name == gw_a || r.name == gw_b)
.map(|r| r.peak)
.max()
.unwrap_or(0);
verdict(
&shared,
"C21 合约流分批(≤2000 行/帧;首屏不退化;网关邮箱峰值受控)",
rows > 0
&& frames >= 1
&& frames <= frame_cap
&& gw_peak <= 20_000
&& probe.cf_first_ms.load(SeqCst) > 0,
&format!(
"合约 {rows} 行 = {frames} 帧(上限 {frame_cap} 帧);网关邮箱历史峰值 {gw_peak} 帧(阈 20000);首帧时刻已记",
),
);
// ── C24 主题双档往返(0.8.0,BD-UI-61 的现场半边;`u1`/`u13` 钉形状,这条钉真线程) ──
// 巡回判据跑在**独立线程**里没有 ctx,所以底色的那一半读的是 `visuals_for(档)`
// —— 与 `apply_ui_style` 派生画面底色用的是同一支函数(同址同判,不是另造一套口径)。
let theme_before = shared.theme();
let fill_before = ek_ui::tokens::visuals_for(theme_before).window_fill;
let t_light = shared.toggle_theme();
let persisted_flag = shared.take_theme_persist_due();
let fill_light = ek_ui::tokens::visuals_for(shared.theme()).window_fill;
let t_dark = shared.toggle_theme();
let fill_dark = ek_ui::tokens::visuals_for(shared.theme()).window_fill;
let theme_after = shared.theme();
let flag_after_second_click = shared.take_theme_persist_due();
verdict(
&shared,
"C24 主题双档往返(缺省深 → 浅 → 深;状态位 + 底色派生同拍)",
theme_before == ek_ui::tokens::Theme::Dark
&& t_light == ek_ui::tokens::Theme::Light
&& t_dark == ek_ui::tokens::Theme::Dark
&& theme_after == ek_ui::tokens::Theme::Dark
&& persisted_flag
&& flag_after_second_click
&& fill_before == ek_ui::tokens::PALETTE_DARK.bg1
&& fill_light == ek_ui::tokens::PALETTE_LIGHT.bg1
&& fill_dark == ek_ui::tokens::PALETTE_DARK.bg1,
"缺省必须深色;两档各派生出各自的页面底(与 apply_ui_style 同一支 visuals_for);落盘标记只由手点式立起且一次性(命令线程随后写 ui.theme)",
);
// ── C25 全左对齐机制(0.8.0,裁定 D 的现场半边;几何半边在 kittest `t3`/`t8`/`s91`) ──
// 巡回线程量不了像素(没有语义树),量的是「机制在场 + 退役干净」三件:
// ① 表头与表体同吃一支格内边距(单值 CELL_INSET_X);② 数字格统一走等宽入口 num_text;
// ③ 右对齐那套(`RIGHT_COLS` 六支常量 + pin_cell 的 right_align 形参)已从渲染侧退役。
let render_src = include_str!("../src/render.rs");
// 只扫代码:剥掉行注释/文档注释(注释里写「RIGHT_COLS 已退役」是记档,不该算残留)
let code_only: String = render_src
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.map(|l| match l.find("//") {
Some(i) => &l[..i],
None => l,
})
.collect::<Vec<_>>()
.join("\n");
let num_cells = code_only.matches("tokens::num_text(").count();
verdict(
&shared,
"C25 全左对齐机制(同一支内边距 + 等宽数字 + 右对齐退役)",
ek_ui::tokens::CELL_INSET_X == 6.0
&& ek_ui::tokens::FONT_MONO == 15.0
&& !code_only.contains("RIGHT_COLS")
&& !code_only.contains("right_align")
&& code_only.contains("CELL_INSET_X")
&& num_cells >= 32,
&format!(
"格内边距单值 {:.0}px(表头与表体同吃);等宽字号 {:.0}px;render.rs 里 num_text {num_cells} 处(0.8.5 列改动后的基线 32 = 行情带 11 + 盘口 2 + 委托 4 + 成交 3 + 持仓 6 + 资金 3 + 合约 3;行情 +成交额/持仓量,持仓撤五个死列,合约 +最小委托量);RIGHT_COLS 与 right_align 出现 0 次",
ek_ui::tokens::CELL_INSET_X,
ek_ui::tokens::FONT_MONO,
),
);
// ── C26 三层文字落点(0.8.2,用户复看「表头和表格里的字的颜色就不对」的现场半边;
// 落屏半边在 kittest `s94`,源码半边在 `u14`)──
// 这一条为什么存在:0.8.0 色板里有 TEXT2、`Chrome.header_text` 也在、判据也绿,
// 而渲染侧一处都没吃 → 屏幕上表头/表体/正文同一个色。钉色板救不了落点,
// 所以巡回这一边钉的是**两档各自派生出的 Visuals 里三层是否真的分层**:
// 普通 label 源(noninteractive.fg_stroke)= text2、强文字源(active)= text1、
// 弱文字 = text3,三支两两不等;行线走 border_soft(与表头带描边分开的两支)。
let ladder = |t: ek_ui::tokens::Theme| {
let v = ek_ui::tokens::visuals_for(t);
let pal = t.palette();
v.widgets.noninteractive.fg_stroke.color == pal.text2
&& v.widgets.active.fg_stroke.color == pal.text1
&& v.weak_text_color == Some(pal.text3)
&& v.widgets.noninteractive.fg_stroke.color != v.widgets.active.fg_stroke.color
&& pal.border != pal.border_soft
};
verdict(
&shared,
"C26 三层文字落点(表体=text2 / 强文字=text1 / 次级=text3;行线另立一支)",
ladder(ek_ui::tokens::Theme::Dark) && ladder(ek_ui::tokens::Theme::Light),
"两档的 Visuals 必须各自把三层分开学进去(普通 label 取 noninteractive=表体色、强文字取 active=正文色);行线描边与表头带描边必须是两支(参考站 line / line-soft)",
);
// ── 判定 C17 水位仪表(0.7.0 背压刀核心判据:默认不丢 = 软顶丢弃计数为 0) ──
let bad: Vec<String> = _mid
.iter()
.filter(|r| r.spilled > 0)
.map(|r| format!("{}丢{}条", r.name, r.spilled))
.collect();
let peak_now = _mid.iter().map(|r| r.pending).max().unwrap_or(0);
let peak_all = _mid.iter().map(|r| r.peak).max().unwrap_or(0);
let c17 = if bad.is_empty() {
format!(
"【判定 C17 水位仪表 PASS】巡回全程无插件触软顶丢帧(丢=0);当前最大积压 {peak_now} 帧,历史峰值 {peak_all} 帧"
)
} else {
format!("【判定 C17 水位仪表 FAIL】软顶触发丢弃:{}", bad.join(","))
};
println!("{c17}");
shared.log_line(c17);
// ── 收束前水位三轮(与运维窗同源的最后一份读数) ──
let _end = water_gauge(&shared, &probe, "收束前");
// ── 收束:矩阵 ──
let (lines, _) = shared.logs_snapshot();
let verdicts: Vec<_> = lines
.iter()
.filter(|l| l.contains("【判定 ") && !l.contains(" SKIP】"))
.cloned()
.collect();
let skipped: Vec<_> = lines
.iter()
.filter(|l| l.contains("【判定 ") && l.contains(" SKIP】"))
.cloned()
.collect();
let passed = verdicts.iter().filter(|l| l.contains(" PASS】")).count();
println!(
"── 多账户隔离判定矩阵:{} 条,{}/{} PASS ──",
verdicts.len(),
passed,
verdicts.len()
);
for v in &verdicts {
println!(" {v}");
}
for v in &skipped {
println!(" {v}");
}
banner(
&shared,
&format!(
"收束:判定矩阵 {} 条,{}/{} PASS;主窗留场(关窗即优雅收场)",
verdicts.len(),
passed,
verdicts.len()
),
);
// ── 0.7.0 第7刀:留单口(EK_TOUR_LEAVE)已删 ──
// 「在真柜台上留一只不撤的挂单」不属于测试面(收编测试同理不许留单):
// 它会污染下一轮的「登录即上账」读数,还会在柜台上留下无人认领的委托。
println!("MULTI_DONE");
});
}
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1280.0, 860.0])
.with_title("ek-station 多账户隔离对账(SimNow 双账号)"),
..Default::default()
};
let shared_main = Arc::clone(&shared);
let result = eframe::run_native(
"ek-station 多账户隔离对账(SimNow 双账号)",
opts,
Box::new(|cc| {
apply_cjk_fonts(&cc.egui_ctx);
Ok(Box::new(TourApp {
shared: Arc::clone(&shared_main),
}) as Box<dyn eframe::App>)
}),
);
shared.request_stop();
let _ = watcher.join();
if let Err(e) = result {
eprintln!("ek-ui multi check: {e}");
std::process::exit(1);
}
}