ek-ctp 0.12.3

CTP gateway for the enkeep ek ecosystem: MD/TD session drivers mapping CTP wire rows onto ek-data objects over the ek-event bus (pure-logic core; the CTP SDK ships inside this package and native bridging lands behind the optional `native` feature).
Documentation
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
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
//! MD/TD 双会话纯状态机(逐字对照原稿 `CtpMdApi` 243-410 与 `CtpTdApi` 411-892)。
//!
//! 设计:
//! - **`step` 单步语义**:输入一个回报事件、输出一个动作/事件向量;零线程、
//!   零时钟、零 sleep —— 原稿的 `while+sleep` 流控重试与 `EventEngine` 定时器
//!   都归上层(DESIGN.md D-FLCN/D-TIME)。
//! - **合约表共享**:原稿的模块级 `symbol_contract_map` 在这里 = 注入的
//!   `Contracts` 共享表(`Arc<Mutex<HashMap<String, ContractData>>>`),
//!   由插件层创建并分发给 MD/TD 两台机。
//! - 持仓合并、order/trade 缓冲、sysid 映射、动态查询轮转全部按原稿逐字移植。

use crate::map;
use crate::wire::{
    DepthMarketData, ErrorField, InputOrder, InputOrderAction, InstrumentCommissionRateField,
    InstrumentField, InstrumentMarginRateField, InstrumentStatusField, InvestorPositionField,
    OrderField, TradeField, TradingAccountField,
};
use ek_data::constant::{Direction, Exchange, Offset, OrderType, Status};
use ek_data::log::{LEVEL_ERROR, LEVEL_INFO, LogData};
use ek_data::{
    AccountData, CancelRequest, ContractData, OrderData, OrderRequest, PositionData, TickData,
    TradeData,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use ek_data::log::LEVEL_WARNING;

/// 合约共享表(原稿全局 `symbol_contract_map` 的进程内等价物)。
pub type Contracts = Arc<Mutex<HashMap<String, ContractData>>>;

/// 空合约表构造。
pub fn contracts_empty() -> Contracts {
    Arc::new(Mutex::new(HashMap::new()))
}

// ================ 日志 helper(等价原稿 gateway.write_log/write_error) ================

/// info 级日志。
pub fn log_info(gw: &str, msg: impl Into<String>) -> LogData {
    LogData {
        gateway_name: gw.to_string(),
        msg: msg.into(),
        level: LEVEL_INFO,
    }
}

/// error 级日志(错误行自动拼"代码/信息",原稿 write_error 216-222)。
pub fn log_error_with(api_msg: &str, gw: &str, e: &ErrorField) -> LogData {
    LogData {
        gateway_name: gw.to_string(),
        msg: map::error_text(api_msg, e),
        level: LEVEL_ERROR,
    }
}

/// error 级日志,**没有 ErrorField 可拼**的失败面(0.9.2):`reqQry*` 的发送侧被拒只给
/// 一个返回码,柜台不会为它发 ErrorFrame —— 有码没文本,只能自己拼。
pub fn log_error(gw: &str, msg: impl Into<String>) -> LogData {
    LogData {
        gateway_name: gw.to_string(),
        msg: msg.into(),
        level: LEVEL_ERROR,
    }
}

/// warning 级日志(0.9.4:合约查询三次未获回报的「警告」用的就是这一级)。
pub fn log_warn(gw: &str, msg: impl Into<String>) -> LogData {
    LogData {
        gateway_name: gw.to_string(),
        msg: msg.into(),
        level: LEVEL_WARNING,
    }
}

/// 合约查询上限(0.9.5 定口径,0.9.8 改出口):一个会话里**最多问 3 次**;三次都没有合约
/// 信息过来 → **警告一次**并进入 [`QueryPhase::Halted`]。触发条件是「回报没到手」,不是
/// 「返回码非 0」:柜台答得出来就不该再问。原稿是 `while rc != 0 { sleep(1); 重发 }`(每秒
/// 一发、无次数上限、还占住 CTP 回调线程),0.9.3 的"下一拍就补"也被否掉 —— 一直问就是把
/// 这条连接每秒一个的查询名额吃光,而资金与持仓也要用同一个名额。
///
/// **0.9.8 的出口变化(用户裁定)**:问满之后**不再问资金与持仓** —— 没有合约,持仓回报
/// 映射不了(`size` 为 0、均价算不出)、界面持仓表也是空的、下单更不可能,把名额花在界面
/// 上用不上的数据上没有意义。原稿在这里是无限重试且轮询与合约无关(`init_query` 在 connect
/// 就注册定时器,ctp_gateway.py:189),这一条是偏离,写明。
const INSTRUMENT_MAX_ATTEMPTS: u32 = 3;

/// 查询阶段(0.9.8,用户定口径):**链走到第几步就是哪个阶段**,不存在额外的「闸门」。
///
/// `Idle`(链还没走到结算确认)→ `Instrument`(问合约)→ 任一合约行到手 → `Rotating`
/// (资金/持仓交替)。3 次问满仍无合约 → 警告一次 → `Halted`(本轮停止,什么都不再发)。
/// 取代 0.9.x 的 `rotation_armed` + `instrument_closed` + `cycle_pos` 三样 —— 它们表达的
/// 是同一件事的不同侧面。链本身是串行的:第 6~9 步每一步都由上一步的回报触发,阶段只是
/// 把这个事实显形,不参与任何查询决策之外的逻辑。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryPhase {
    /// 还没开始:未登录 / 未确认结算单 / 会话刚复位。
    Idle,
    /// 结算单已确认,正在问合约(最多 3 次)。
    Instrument,
    /// 有合约在册:资金与持仓交替轮询。
    Rotating,
    /// 3 次问满且一条合约都没到手:本轮查询停止(0.9.8 用户裁定,**不再问资金与持仓**)。
    Halted,
}

impl QueryPhase {
    /// 中文读数(状态行与界面「阶段」列共用一份措辞)。
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            QueryPhase::Idle => "尚未开始查询",
            QueryPhase::Instrument => "查询合约中",
            QueryPhase::Rotating => "资金/持仓轮询中",
            QueryPhase::Halted => "合约未到手,本轮查询已停止",
        }
    }
}

/// 链状态行的前缀(0.9.8,用户定「链进行的状态改为事件日志推送」)。
///
/// 消费侧(ek-ui 的 eLog 分支)按这个前缀认出状态行、落进账户表「阶段」列 —— **前缀必须
/// 单源**:两处各写一遍字面量,就是下一次改口时的坑。
pub const CHAIN_STATE_PREFIX: &str = "[链] ";

/// 首发合约的静默(0.9.8,用户定口径):从**发出结算单确认请求**那一刻起算,不满这个毫秒数
/// 就不问合约;结算确认回报到手时若已经过了 1 秒,当场就问(不白等)。
///
/// **有意偏离基线**:原稿 519-533 是结算确认当场就发 + `while rc != 0 { sleep(1); 重发 }`
/// —— 无次数上限,且 `sleep` 占住 CTP 回调线程(`onRspSettlementInfoConfirm` 是 API 线程上
/// 的回调)。我们改成「记请求时刻 → 到点才问」,上限见 [`INSTRUMENT_MAX_ATTEMPTS`]。
const INSTRUMENT_FIRST_WAIT_MS: u64 = 1000;

/// 合约重发间隔(0.9.8,用户定口径「然后等 5 秒再查」):距上一发**真正发出去**不满这个
/// 毫秒数就这一拍不问。**同一个数兼作第三发的回报窗口** —— 问满后还要再等一个完整窗口
/// 仍无合约才警告,否则柜台答得慢一点就被误判成问满。
const INSTRUMENT_RETRY_WAIT_MS: u64 = 5000;

/// 轮询自己的 1 秒闸(0.9.6 引入、0.9.8 抬到整 1 秒并改写语义):距上一发**真正发出去**
/// 不满这个毫秒数,这一拍就不发,等下一拍。
///
/// **0.9.8 从 900 抬到 1000(用户定口径「距上次查询不满 1 秒就下一次」)**。抬到整 1 秒换来的
/// 是一个硬保证:两发之间严格 ≥1000ms → **任意 1 秒窗口内最多 1 发**,正是 CTP 查询类请求的
/// 流控规则(900 做不到:900ms 间隔在一个 1 秒窗口里能落进 2 发)。
///
/// **代价写明**:宿主 ek-event 的秒针不是原子钟(单条分发线程 + 队列),相邻两拍实测在
/// 990–1010ms 之间晃 —— 间隔 990ms 的那一拍会被挡,下一拍(≈2 秒)才放行。于是实际密度是
/// 0.5~1 发/秒,资金与持仓**每路 2~4 秒一次、带抖动**(0.9.7 及以前是稳定 2 秒)。基线的口径
/// 是每路 4 秒(`count < 2` 门槛 + 两路轮转,ctp_gateway.py:224-240):这一版不写那个计数器,
/// 密度自己落到同一量级。
///
/// **边界(写明,别被误读)**:这道闸只管协调点上的周期查询(合约重发、资金、持仓)。委托与
/// 撤单从 ask 路直发传输层,结算确认与「结算确认到手当场问合约」都在事件路上直发,都不经这里。
const QUERY_MIN_INTERVAL_MS: u64 = 1000;

/// 连续被拒的指数退避:基数、封顶(1s→2s→4s→8s→15s 封底)。
const QUERY_BACKOFF_BASE_MS: u64 = 1000;
const QUERY_BACKOFF_MAX_MS: u64 = 15000;

/// 入向异常的七种 kind(0.12.0 `D-ANOMALY-SURFACE` 的**穷举清单**)。
///
/// 纪律同 `GatewayChange`:这是闭集,**加一种要同时动状态口字段、事件短名与
/// 界面文案**,所以新增须走文档 —— 不在这里随手加变体。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnomalyKind {
    /// 交易所码兜底链走完仍判不出(填 `Exchange::UNKNOWN`)。
    Exchange,
    /// 委托状态码未映射(填 `Status::UNKNOWN`)。
    Status,
    /// 委托类型三元组未映射(填 `OrderType::UNKNOWN`)。
    Type,
    /// 委托/成交/行情时间戳畸形(填 `datetime: None`)。
    Timestamp,
    /// 方向/开平码未映射(填 `None` / `Offset::NONE`)。
    Dir,
    /// 成交回报找不到在册委托号(填占位 orderid)。
    TradeSysid,
}

impl AnomalyKind {
    /// 机读短名(事件 kind 与界面去重键都吃这个,不吃中文文案)。
    #[must_use]
    pub fn code(self) -> &'static str {
        match self {
            Self::Exchange => "ReportExchangeUnmapped",
            Self::Status => "ReportStatusUnmapped",
            Self::Type => "ReportTypeUnmapped",
            Self::Timestamp => "ReportTimestampBad",
            Self::Dir => "ReportDirUnmapped",
            Self::TradeSysid => "TradeSysidMissing",
        }
    }

    /// 人读标签(日志正文用;带 CTP 原码的要求由调用方拼)。
    #[must_use]
    fn label(self) -> &'static str {
        match self {
            Self::Exchange => "交易所码未映射",
            Self::Status => "委托状态码未映射",
            Self::Type => "委托类型三元组未映射",
            Self::Timestamp => "时间戳畸形",
            Self::Dir => "方向/开平码未映射",
            Self::TradeSysid => "成交回报缺少在册委托号",
        }
    }
}

/// 委托行的「字段不可得」清单:**种类 + CTP 原始码**。
///
/// 原始码必须一起带出来。只说"状态码未映射"而不说**是哪个码**,用户既没法拿它
/// 找柜台对账,也没法判断"是不是柜台升级加了个新状态"(那时该做的是补映射,
/// 而不是盯着一条空日志猜)。
///
/// ⚠ **这里是独立于 [`map::order_from`] 的第二次求值**,只是调用了同一批 `map`
/// 判定函数。改任何一侧的判据都必须同时改另一侧 —— `tests/no_drop_matrix.rs`
/// 的每条用例都同时断言「字段填了占位值」与「计数器 +1」,漂移会在那里炸。
fn order_anomalies(of: &OrderField) -> Vec<(AnomalyKind, String)> {
    let mut v = Vec::new();
    if map::status_of(&of.order_status).is_none() {
        v.push((AnomalyKind::Status, of.order_status.clone()));
    }
    if map::order_type_from(
        &of.order_price_type,
        &of.time_condition,
        &of.volume_condition,
    )
    .is_none()
    {
        // 三元组一起报 —— 只报其中一段,用户不知道该改哪一个。
        v.push((
            AnomalyKind::Type,
            format!(
                "{}/{}/{}",
                of.order_price_type, of.time_condition, of.volume_condition
            ),
        ));
    }
    if map::ctp_datetime(&of.insert_date, &of.insert_time, 0).is_none() {
        v.push((
            AnomalyKind::Timestamp,
            format!("{} {}", of.insert_date, of.insert_time),
        ));
    }
    if map::direction_from(&of.direction).is_none() {
        v.push((AnomalyKind::Dir, format!("dir={}", of.direction)));
    }
    if map::offset_from(&of.comb_offset_flag).is_none() {
        v.push((AnomalyKind::Dir, format!("offset={}", of.comb_offset_flag)));
    }
    v
}

/// 成交行的「字段不可得」清单(形态同 [`order_anomalies`])。
fn trade_anomalies(tf: &TradeField) -> Vec<(AnomalyKind, String)> {
    let mut v = Vec::new();
    if map::ctp_datetime(&tf.trade_date, &tf.trade_time, 0).is_none() {
        v.push((
            AnomalyKind::Timestamp,
            format!("{} {}", tf.trade_date, tf.trade_time),
        ));
    }
    if map::direction_from(&tf.direction).is_none() {
        v.push((AnomalyKind::Dir, format!("dir={}", tf.direction)));
    }
    if map::offset_from(&tf.offset_flag).is_none() {
        v.push((AnomalyKind::Dir, format!("offset={}", tf.offset_flag)));
    }
    v
}

// ================ MD 行情会话(原稿 CtpMdApi) ================

/// MD 会话配置(原稿 `CtpMdApi.connect` 入参)。
///
/// Debug 手工掩码(0.10.0,BD-CFG-18):`password` 永远 `****`。
#[derive(Clone, Default)]
pub struct MdConfig {
    /// 行情前置地址(原稿 connect 已补完 "tcp://" 前缀后传入)
    pub addr: String,
    /// 用户代码(行情登录用)
    pub user: String,
    /// 密码
    pub password: String,
    /// 经纪公司代码
    pub broker: String,
}

impl std::fmt::Debug for MdConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MdConfig")
            .field("addr", &self.addr)
            .field("user", &self.user)
            // 秘面永远掩码(BD-CFG-18)。
            .field("password", &"****")
            .field("broker", &self.broker)
            .finish()
    }
}

/// MD 回报事件。
#[derive(Debug, Clone)]
/// MD 事件面(native 桥适配官方回报;mock 层直接喂值)。
///
/// Box 化:#[allow(clippy::large_enum_variant)] 之类抑损不是目标——本枚举
/// 只在适配线程步进,不做高频批量装箱,加 Box 反而破坏模式匹配直观性。
#[allow(clippy::large_enum_variant)]
pub enum MdEvent {
    /// onFrontConnected(265)
    FrontConnected,
    /// onFrontDisconnected(reason)(270)
    FrontDisconnected(i64),
    /// onRspUserLogin(data, error)(275)
    RspUserLogin {
        /// None = 成功
        error: Option<ErrorField>,
    },
    /// onRspError(288)
    RspError(ErrorField),
    /// onRspSubMarketData(292)
    RspSubMarketData {
        /// Some(err) = 订阅失败(error_id 非零;None 原稿 296 行两面皆静默)
        error: Option<ErrorField>,
    },
    /// onRtnDepthMarketData(301)
    RtnDepthMarketData(DepthMarketData),
}

/// MD 输出动作。
#[derive(Debug, Clone)]
/// MD 生产输出(unified 面:命令 → 传输;数据 → 总线)。
#[allow(clippy::large_enum_variant)]
pub enum MdOut {
    /// 创建/连接行情 API(原稿 connect() 368-382;flow_path 供 levefile 行情流文件
    /// 落位,原稿固定为 `{gateway_name}\Md`,native 层使用)
    ReqConnect {
        /// 行情前置地址
        addr: String,
        /// 流文件目录尾段
        flow_path: String,
    },
    /// reqUserLogin(384-393)
    ReqLogin {
        /// 用户代码
        user: String,
        /// 密码
        password: String,
        /// 经纪公司代码
        broker: String,
    },
    /// subscribeMarketData(395-399)
    ReqSubscribe(Vec<String>),
    /// 上抛行情(经 gateway.on_tick)
    Tick(TickData),
    /// 业务日志(带等级;ERROR = write_error 的形态)
    Log(LogData),
    /// 状态变化(0.11.0 第3刀;与 TD 侧同式,由插件 worker 折成 `eStatus.<gw>`)
    Status(crate::plugin::GatewayChange),
}

/// MD 状态机。
pub struct MdDriver {
    /// 网关名(日志与流文件路径的命名)
    pub gw: String,
    cfg: MdConfig,
    contracts: Contracts,
    /// 已登记的订阅集合(原稿 subscribed set;去重保序用等值判定)
    subscribed: Vec<String>,
    login_status: bool,
    connect_status: bool,
    /// 本地视角当日("YYYYMMDD";eTimer 刷新,原稿 update_date)
    current_date: String,
    // ---- 0.9.0 状态面(`{gw}.status` 的事实源;只增不改既有驱动语义) ----
    /// 行情前置断开累计次数(onFrontDisconnected 计数;跨重连保留)。
    disconnects: u32,
    /// 最近一次行情登录**成功**的纪元毫秒(从未成功 = None)。
    last_login_ok_ms: Option<i64>,
    /// 最近一条行情回报的纪元毫秒(查询断供/静默判定的原料)。
    last_tick_ms: Option<i64>,
    /// 订阅回报被拒累计次数(onRspSubMarketData 带 error)。
    sub_rejects: u32,
    /// 最近一次错误回报文本(登录失败/订阅失败/断开);登录成功即清(陈旧错误不再显形)。
    last_error: Option<String>,
    /// 0.12.0(D-ANOMALY-SURFACE):行情侧入向异常计数 —— 合约不在册 / 时间戳不可得。
    /// 与 TD 侧同口径**跨重连保留**;日志按合约去重(高频 tick 逐行刷会冲爆 eLog)。
    tick_anomalies: u32,
    tick_contract_missing: u32,
    tick_bad_timestamp: u32,
    last_anomaly_ms: Option<i64>,
    /// 已提示过"行情合约不在册"的合约代码(同合约只一条日志)。
    tick_anomaly_seen: std::collections::HashSet<String>,
}

/// MD 状态读数(0.9.0;`{gw}.status` 的行情半边原料)。
#[derive(Debug, Clone, Default)]
pub struct MdStatusView {
    /// API 句柄已建并 registerFront(**不等于在线**:掉线时这一位仍为真)。
    pub handle_up: bool,
    /// 已登录(onRspUserLogin 成功且未断开)。
    pub logged_in: bool,
    /// 前置断开累计次数。
    pub disconnects: u32,
    pub last_login_ok_ms: Option<i64>,
    pub last_tick_ms: Option<i64>,
    pub sub_rejects: u32,
    pub last_error: Option<String>,
    /// 已登记订阅数(断开不清订阅集:重登录自动重订阅)。
    pub subscribed: usize,
}

impl MdDriver {
    /// 构造(合约表注入;current_date 初值由调用方给定——原稿是 datetime.now()
    /// 的本地日期,Rust 显式化以免隐性时钟)。
    pub fn new(gw: &str, cfg: MdConfig, contracts: Contracts, current_date: String) -> Self {
        Self {
            gw: gw.to_string(),
            cfg,
            contracts,
            subscribed: Vec::new(),
            login_status: false,
            connect_status: false,
            current_date,
            disconnects: 0,
            last_login_ok_ms: None,
            last_tick_ms: None,
            sub_rejects: 0,
            tick_anomalies: 0,
            tick_contract_missing: 0,
            tick_bad_timestamp: 0,
            last_anomaly_ms: None,
            tick_anomaly_seen: std::collections::HashSet::new(),
            last_error: None,
        }
    }

    /// eTimer 秒级刷新当日(原稿 gateway.process_timer_event 235 行调 md.update_date)。
    pub fn update_date(&mut self, date: String) {
        self.current_date = date;
    }

    /// 行情订阅入口(原稿 gateway.subscribe → md.subscribe 191-193/395-399;
    /// 登录前只登记,登录后立即发起)。
    pub fn subscribe(&mut self, symbol: &str) -> Vec<MdOut> {
        if !self.subscribed.iter().any(|s| s == symbol) {
            self.subscribed.push(symbol.to_string());
        }
        if self.login_status {
            vec![MdOut::ReqSubscribe(vec![symbol.to_string()])]
        } else {
            Vec::new()
        }
    }

    /// 连接(原稿 MD connect 368-382):首次 → 发 ReqConnect;重复调用守卫静默。
    /// close 守卫面由 `connect_status()` getter 提供(原稿 401-404)。
    pub fn connect(&mut self) -> Vec<MdOut> {
        if self.connect_status {
            return Vec::new();
        }
        self.connect_status = true;
        vec![MdOut::ReqConnect {
            addr: self.cfg.addr.clone(),
            flow_path: format!("{}\\Md", self.gw),
        }]
    }

    /// 换配置并复位连接/登录守卫(0.3.0 configure 面;订阅集保留 —— 登录后
    /// RspUserLogin 重订阅链自会把新订阅推下去)。
    pub fn reconfigure(&mut self, cfg: MdConfig) {
        self.cfg = cfg;
        self.connect_status = false;
        self.login_status = false;
    }

    /// 入向异常读数(0.12.0;行情侧只有两个计数位有意义 —— 合约不在册 / 时间戳畸形)。
    /// **口径与 TD 侧同**:`total` 一次不漏,日志才按合约去重。
    #[must_use]
    pub fn anomaly_counts(&self) -> AnomalyCounts {
        AnomalyCounts {
            total: self.tick_anomalies,
            timestamp: self.tick_bad_timestamp,
            tick_contract: self.tick_contract_missing,
            ..AnomalyCounts::default()
        }
    }

    /// 最近一次行情入向异常时刻(从未发生 = `None`)。
    #[must_use]
    pub fn last_anomaly_ms(&self) -> Option<i64> {
        self.last_anomaly_ms
    }

    /// 0.9.0 状态读数(只读,不改任何状态)。
    #[must_use]
    pub fn status_view(&self) -> MdStatusView {
        MdStatusView {
            handle_up: self.connect_status,
            logged_in: self.login_status,
            disconnects: self.disconnects,
            last_login_ok_ms: self.last_login_ok_ms,
            last_tick_ms: self.last_tick_ms,
            sub_rejects: self.sub_rejects,
            last_error: self.last_error.clone(),
            subscribed: self.subscribed.len(),
        }
    }

    /// 0.9.0 `{gw}.disconnect`:停这一侧会话(守卫复位),**订阅集与配置保留**
    /// —— 重连后 `RspUserLogin` 重订阅链自会把订阅推下去(裁定 1「断开 = 停会话」)。
    /// 计数与「最近时刻」不清零:它们是跨会话的历史事实,清掉就等于抹掉掉线证据。
    pub fn reset_session(&mut self) {
        self.connect_status = false;
        self.login_status = false;
    }

    /// 步进(原型 CtpMdApi 各回调)。
    pub fn step(&mut self, ev: MdEvent) -> Vec<MdOut> {
        match ev {
            MdEvent::FrontConnected => {
                self.connect_status = true;
                vec![
                    MdOut::Log(log_info(&self.gw, "行情服务器连接成功")),
                    MdOut::Status(crate::plugin::GatewayChange::MdConnected),
                    MdOut::ReqLogin {
                        user: self.cfg.user.clone(),
                        password: self.cfg.password.clone(),
                        broker: self.cfg.broker.clone(),
                    },
                ]
            }
            MdEvent::FrontDisconnected(reason) => {
                self.login_status = false;
                self.disconnects += 1;
                self.last_error = Some(format!("行情服务器连接断开,原因{reason}"));
                vec![
                    MdOut::Log(log_info(
                        &self.gw,
                        format!("行情服务器连接断开,原因{reason}"),
                    )),
                    MdOut::Status(crate::plugin::GatewayChange::MdDisconnected),
                ]
            }
            MdEvent::RspUserLogin { error } => match error {
                None => {
                    self.login_status = true;
                    self.last_login_ok_ms = Some(crate::map::now_epoch_ms());
                    // 陈旧错误不再显形:这一轮登录是干净的。
                    self.last_error = None;
                    let mut outs = vec![
                        MdOut::Log(log_info(&self.gw, "行情服务器登录成功")),
                        MdOut::Status(crate::plugin::GatewayChange::MdLoginOk),
                    ];
                    if !self.subscribed.is_empty() {
                        outs.push(MdOut::ReqSubscribe(self.subscribed.clone()));
                    }
                    outs
                }
                Some(e) => {
                    self.last_error = Some(map::error_text("行情服务器登录失败", &e));
                    vec![
                        MdOut::Log(log_error_with("行情服务器登录失败", &self.gw, &e)),
                        MdOut::Status(crate::plugin::GatewayChange::MdLoginFailed),
                    ]
                }
            },
            MdEvent::RspError(e) => {
                self.last_error = Some(map::error_text("行情接口报错", &e));
                vec![MdOut::Log(log_error_with("行情接口报错", &self.gw, &e))]
            }
            MdEvent::RspSubMarketData { error } => match error {
                Some(e) => {
                    self.sub_rejects += 1;
                    self.last_error = Some(map::error_text("行情订阅失败", &e));
                    vec![
                        MdOut::Log(log_error_with("行情订阅失败", &self.gw, &e)),
                        MdOut::Status(crate::plugin::GatewayChange::SubscribeRejected),
                    ]
                }
                None => Vec::new(),
            },
            MdEvent::RtnDepthMarketData(dm) => {
                self.last_tick_ms = Some(crate::map::now_epoch_ms());
                let contract = {
                    let m = self.contracts.lock().unwrap();
                    m.get(&dm.instrument_id).cloned()
                };
                let missing_contract = contract.is_none();
                let tick = map::tick_from(&dm, contract.as_ref(), &self.gw, &self.current_date);
                // 0.12.0:**行情行没有丢弃出口**。两种"富化不了"各自计数 + 去重告警:
                // - 合约不在册 → 交易所取报文 `ExchangeID`、名字留空(D-XFALLBACK)
                // - 时间戳不可得 → `datetime_invalid = true`,量价照上账
                // 计数挂在上岸路径上(不是丢弃路径):"丢弃变成上岸"之后计数必须
                // 还在,否则等于用改进本身抹掉了改进的证据。
                let timestamp_bad = tick.datetime_invalid;
                if !missing_contract && !timestamp_bad {
                    return vec![MdOut::Tick(tick)];
                }
                let mut outs = Vec::with_capacity(2);
                let mut key = String::new();
                let mut msg = String::new();
                if missing_contract {
                    self.tick_anomalies = self.tick_anomalies.saturating_add(1);
                    self.tick_contract_missing += 1;
                    key.push_str("no-contract;");
                    msg.push_str(&format!(
                        "合约不在册,交易所取报文、名字留空(同类累计 {} 条)",
                        self.tick_contract_missing
                    ));
                }
                if timestamp_bad {
                    self.tick_anomalies = self.tick_anomalies.saturating_add(1);
                    self.tick_bad_timestamp += 1;
                    key.push_str("bad-ts;");
                    msg.push_str(&format!(
                        "时间戳不可得,已置 datetime_invalid=true 后照常上岸(同类累计 {} 条)",
                        self.tick_bad_timestamp
                    ));
                }
                self.last_anomaly_ms = Some(crate::map::now_epoch_ms());
                outs.push(MdOut::Tick(tick));
                if self
                    .tick_anomaly_seen
                    .insert(format!("{}|{key}", dm.instrument_id))
                {
                    outs.push(MdOut::Log(LogData {
                        gateway_name: self.gw.clone(),
                        msg: format!("行情降级上岸:{}({msg})", dm.instrument_id),
                        level: LEVEL_WARNING,
                    }));
                }
                outs
            }
        }
    }
}

// ================ TD 交易会话(原稿 CtpTdApi) ================

/// TD 会话配置(原稿 `CtpTdApi.connect` 749-777 入参)。
///
/// Debug 手工掩码(0.10.0,BD-CFG-18):`password` 永远 `****`。
#[derive(Clone, Default)]
pub struct TdConfig {
    /// 交易前置地址
    pub addr: String,
    /// 用户代码
    pub user: String,
    /// 密码
    pub password: String,
    /// 经纪公司代码
    pub broker: String,
    /// 授权编码(空则跳过认证直连登录,原稿 448-451)
    pub auth_code: String,
    /// 产品名称(AppID,认证用)
    pub app_id: String,
    /// 收录的产品类白名单(D-PC-FILTER;空 = 回落默认「期货」)。
    /// 查询侧不过滤,收录侧按它严格丢弃(不进表、不上抛)。
    pub product_classes: map::ProductClasses,
}

impl std::fmt::Debug for TdConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TdConfig")
            .field("addr", &self.addr)
            .field("user", &self.user)
            // 秘面永远掩码(BD-CFG-18)。
            .field("password", &"****")
            .field("broker", &self.broker)
            .field("auth_code", &self.auth_code)
            .field("app_id", &self.app_id)
            .field("product_classes", &self.product_classes)
            .finish()
    }
}

/// TD 回报事件。
#[derive(Debug, Clone)]
pub enum TdEvent {
    /// onFrontConnected(445)
    FrontConnected,
    /// onFrontDisconnected(reason)(453)
    FrontDisconnected(i64),
    /// onRspAuthenticate(458-472)
    RspAuthenticate {
        /// None = 成功
        error: Option<ErrorField>,
    },
    /// onRspUserLogin(474-490)
    RspUserLogin {
        /// None = 成功
        error: Option<ErrorField>,
        /// FrontID(仅成功时有意义,原稿取 data["FrontID"])
        front_id: i64,
        /// SessionID
        session_id: i64,
    },
    /// onRspSettlementInfoConfirm(519-533)
    RspSettlementInfoConfirm {
        /// None = 成功
        error: Option<ErrorField>,
    },
    /// onRspQryInstrument(614-667)
    RspQryInstrument {
        /// 是否本包最后一笔
        last: bool,
        /// 数据行(错误包时 None)
        row: Option<InstrumentField>,
        /// 查询被拒(0.9.1 新增;`None` = 这一包没有错误回报)。
        ///
        /// 0.9.0 给轮转两查询(资金/持仓)补了这一位,合约查询刻意留在 3 参
        /// (D-NATIVE-AUDIT-REPAID 的「划界」)。0.9.1 把它补齐:三条查询路同式,
        /// 「合约一条都没到手」与「柜台没有合约」不再长得一样(合约表空 →
        /// 持仓/委托回报的乘数与勾选判定全瞎,而界面上只是「合约窗没数」)。
        error: Option<ErrorField>,
    },
    /// onRspQryInvestorPosition(535-595)
    RspQryInvestorPosition {
        /// 是否本包最后一笔
        last: bool,
        /// 数据行
        row: Option<InvestorPositionField>,
        /// 查询被拒(0.9.0 新增;`None` = 这一包没有错误回报)。
        /// 状态口 `{gw}.status` 的「查询被拒计数」事实源 —— 0.8.0 之前这一路只有
        /// `row: None`,分不清「被拒」与「没有持仓」(F2 掉线后查询断供在界面上不可见)。
        error: Option<ErrorField>,
    },
    /// onRspQryTradingAccount(597-612)
    RspQryTradingAccount {
        /// 是否本包最后一笔
        last: bool,
        /// 数据行
        row: Option<TradingAccountField>,
        /// 查询被拒(0.9.0 新增;口径同 [`TdEvent::RspQryInvestorPosition`] 的 `error`)。
        error: Option<ErrorField>,
    },
    /// onRspOrderInsert(492-513)。原稿从 err 包 OrderRef/Direction/LimitPrice 等
    /// 字段重造 REJECTED 单。
    RspOrderInsert {
        /// None = 成功受理(原稿只在 error 分支造单)
        error: Option<ErrorField>,
        /// 触发失败的报单原字段(transport 层随错误回报回带)
        req: Option<InputOrder>,
    },
    /// onRspOrderAction(515-517)
    RspOrderAction {
        /// None = 成功
        error: Option<ErrorField>,
    },
    /// onRtnOrder(669-718)
    RtnOrder(OrderField),
    /// onRtnTrade(720-747)
    RtnTrade(TradeField),
    // ---- 附加接口面(超出原稿):保证金率/手续费率/交易状态 ----
    /// onRspQryInstrumentMarginRate。原稿无此路径 —— 端口按设计记录进 eLog
    /// (不新增事件实体,记档见 DESIGN)。
    RspQryInstrumentMarginRate {
        /// 数据行
        row: Option<InstrumentMarginRateField>,
        /// 查询错误(超出设计错误面:查询没就绪等)
        error: Option<ErrorField>,
        /// 是否本包最后一笔
        last: bool,
    },
    /// onRspQryInstrumentCommissionRate。原稿无此路径 —— 同上,记录进 eLog。
    RspQryInstrumentCommissionRate {
        /// 数据行
        row: Option<InstrumentCommissionRateField>,
        /// 查询错误
        error: Option<ErrorField>,
        /// 是否本包最后一笔
        last: bool,
    },
    /// onRtnInstrumentStatus(纯被动推送;SDK 6.7.7 无对应 Req)。原稿无此路径。
    RtnInstrumentStatus(InstrumentStatusField),
}

/// TD 输出动作。
#[derive(Debug, Clone)]
pub enum TdOut {
    /// 创建/连接交易 API(779 前流程:connect() 749-777)
    ReqConnect {
        /// 交易前置地址
        addr: String,
        /// 流文件目录尾段(`{gateway_name}\Td`)
        flow_path: String,
    },
    /// reqAuthenticate(779-792)
    ReqAuthenticate {
        /// 用户代码
        user: String,
        /// 经纪公司代码
        broker: String,
        /// 授权编码
        auth_code: String,
        /// 产品名称(AppID)
        app_id: String,
    },
    /// reqUserLogin(794-806)
    ReqLogin {
        /// 用户代码
        user: String,
        /// 密码
        password: String,
        /// 经纪公司代码
        broker: String,
    },
    /// reqSettlementInfoConfirm(482-485)
    ReqSettlementConfirm {
        /// 经纪公司代码
        broker: String,
        /// 用户代码
        user: String,
    },
    /// reqQryInstrument(合约流控重试在 transport 层,DESIGN.md D-FLCN)
    ReqQryInstrument,
    /// reqQryTradingAccount(874-877)
    ReqQryAccount {
        /// 经纪公司代码
        broker: String,
        /// 用户代码
        user: String,
    },
    /// reqQryInvestorPosition(879-887)
    ReqQryPosition {
        /// 经纪公司代码
        broker: String,
        /// 用户代码
        user: String,
    },
    /// reqOrderInsert(820-845 成功路径)
    Insert(InputOrder),
    /// reqOrderAction(856-872 成功路径)
    Cancel(InputOrderAction),
    /// 上抛合约(仅期货;经 gateway.on_contract 652)
    Contract(ContractData),
    /// 上抛订单(经 gateway.on_order)
    Order(OrderData),
    /// 上抛成交(经 gateway.on_trade)
    Trade(TradeData),
    /// 上抛持仓(合并完成;经 gateway.on_position 589-590)
    Position(PositionData),
    /// 持仓查询完成(原稿 593 行 EVENT_POSITION_QUERY_DONE;对齐 ek-oms 相位门)
    PositionQueryDone(String),
    /// 合约收齐(原稿 659 → BaseGateway.on_contract_finish → 0.10.5 起常量
    /// `EVENT_CONTRACT_FINISH`)
    /// = 全局线、无 gw 后缀;载荷给网关名以便多网关辨源,D-CFIN)
    ContractFinish(String),
    /// 上抛账户(612)
    Account(AccountData),
    /// 资金查询完成(0.9.5 新增):三条查询路**谁收到 last 谁就推自己的结束事件** ——
    /// 持仓有 `ePositionDone.<gw>`(原稿 593)、合约有 `eContractFinish`(原稿 659),
    /// 资金这一路原来没有。落线 `eAccountDone.<gw>`,载荷给网关名(与持仓那一路同式)。
    AccountQueryDone(String),
    /// 业务日志
    Log(LogData),
    /// 状态变化(0.11.0 第3刀):链状态机的「那一刻」从 eLog 文本升格成结构化事件。
    /// 由插件 worker 折成 `eStatus.<gw>`(随附整份 `GatewayStatus` 快照)。
    Status(crate::plugin::GatewayChange),
}

/// 持仓 accumulator(键 = 合约 + 持仓方向码,原稿 548 的 dict key 形态)。
struct PositionAccum {
    pos: PositionData,
}

/// TD 状态机。
pub struct TdDriver {
    gw: String,
    cfg: TdConfig,
    contracts: Contracts,
    /// 请求计数(原稿 reqid;native 层使用)
    pub reqid: i64,
    /// 本地报单号计数(原稿 order_ref)
    order_ref: i64,
    /// 已连接/已登录/已认证/认证失败/登录失败/合约已收齐
    connect_status: bool,
    login_status: bool,
    auth_status: bool,
    auth_failed: bool,
    login_failed: bool,
    /// FrontID(登录回报)
    front_id: i64,
    /// SessionID
    session_id: i64,
    /// 认证流程标志(驱动机;MERGED: auth 独立状态)
    /// 持仓合并缓存(原稿 positions dict;_TH 初始化面)
    positions: HashMap<String, PositionAccum>,
    /// OrderSysID → 本地委托号表(原稿 sysid_orderid_map;718)
    sysid_orderid: HashMap<String, String>,
    /// 查询轮转标志:[false=资金, true=持仓](原稿 query_functions 列表轮换)
    next_pos: bool,
    /// 查询阶段(0.9.8):链走到第几步就是哪个阶段。取代 `rotation_armed`(能不能问)+
    /// `instrument_closed`(合约问完没问完)+ `cycle_pos`(周期相位)三样重复表达。
    phase: QueryPhase,
    /// 合约查询已问了几次(0.9.5):**最多 3 次**,三次没有合约信息来 → 警告一次。
    instrument_attempts: u32,
    /// 三次问满且都没等到 → 已警告过(警告只写一次,不每拍刷)。
    instrument_warned: bool,
    /// 发出结算单确认请求的时刻(0.9.8,与 [`last_query_ms`] 同钟同基准):首发合约的 1 秒
    /// 静默从这一刻起算。`reconfigure` / `reset_session` **必须清它** —— 旧会话的时刻必然
    /// 早已超过 1 秒,留着会让新会话的结算确认一到就当场发,静默被跳过。
    settlement_req_ms: Option<u64>,
    /// 上一发查询**真正发出去**的时刻(单调毫秒,与 [`now_ms`] 同钟同基准)。`None` =
    /// 这一条会话还没发过 → 护栏开闸。`reconfigure`/`reset_session` 清它:柜台的每秒窗口
    /// 是按连接的,上一连接的时间戳不该挡新连接的第一发。
    last_query_ms: Option<u64>,
    /// 资金/持仓各自的连续被拒计数(0.11.12;槽 0 = 资金,槽 1 = 持仓)。
    /// 混成一条串时,另一边成功会把这边清零 —— 每次失败都被当"首条"刷日志,
    /// 2026-10-30 夜盘 35 秒 12 条就是这个成因。
    query_reject_streaks: [u32; 2],
    /// 分项退避截止时刻(同槽):连续被拒 1s→2s→4s→8s→15s 封底。
    query_backoff_until: [Option<u64>; 2],
    /// 单调毫秒钟(0.9.6)。生产实现锚定到驱动创建时刻;测试经 [`with_clock`](Self::with_clock)
    /// 注入一个手动推进的假钟 —— 没有这个接缝,节律钉全部会被护栏挡掉(连续调用之间 elapsed≈0),
    /// 而"排队拍挤在一起"这个真实失效模式反而永远测不到。**与状态口的墙钟
    /// (`map::now_epoch_ms`) 是两个钟,不得互减。**
    now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
    /// 交易状态推送去重键(exchange|instrument|status|segment)。
    /// 公流重放整批重推会话快照,不去重会把 eLog 冲成几百行(D-NATIVE-EXTRA-3);
    /// 键不含 enter_time —— 快照重放同状态的多变进入时刻不重记。
    status_seen: std::collections::HashSet<String>,
    /// 合约阶段计数(D-PC-FILTER;收尾时汇总成一条日志,不逐行刷):
    /// 收录 / 按勾选丢弃 / 勾选了但本数据面映射不了。
    contract_kept: usize,
    contract_filtered: usize,
    contract_unmapped: usize,
    /// 未映射的产品类码集合。汇总里必须把码列出来 —— 原来逐行日志会给码,
    /// 改成汇总之后如果把码丢了,「未映射 3」就是个查不出原因的数字。
    contract_unmapped_codes: std::collections::BTreeSet<String>,
    /// 归一化后的勾选表(在 new/reconfigure 各算一次)。不在每行合约上重算 ——
    /// 全量一发 2226 行,逐行 `normalize()` 就是 2226 次排序+分配。
    accepted: Vec<String>,
    /// 已提示过「回报被丢弃」的合约代码(回报侧去重:同一合约只提示一次;
    /// 0.7.0 起丢弃理由 = 报文交易所码未映射,不再是勾选过滤)。
    ///
    /// **0.12.0**:不再"丢弃",改叫「回报异常去重」—— 交易所码兜底链走完仍判不出时
    /// 行照上账(`Exchange::UNKNOWN`),这里只压日志与计数。
    /// 入向异常日志去重键 `(kind, symbol, code)`(计数不在此去重,一次不漏)。
    anomaly_seen: std::collections::HashSet<String>,
    // ---- 0.12.0 入向异常计数(D-ANOMALY-SURFACE 显形三件套之二;跨重连保留) ----
    /// 入向异常总计数(所有"字段不可得"的入向行次数之和)。
    inbound_anomalies: u32,
    /// 交易所码兜底链走完仍判不出的行数(委托/成交/持仓/合约/tick 五路)。
    unmapped_exchange_rows: u32,
    /// 委托状态码未映射的行数(填 `Status::UNKNOWN`)。
    unmapped_status_rows: u32,
    /// 委托类型三元组未映射的行数(填 `OrderType::UNKNOWN`)。
    unmapped_type_rows: u32,
    /// 委托/成交/行情时间戳畸形的行数(填 `datetime: None`)。
    bad_timestamp_rows: u32,
    /// 方向/开平码未映射的行数(填 `None`/`Offset::NONE`)。
    unmapped_dir_rows: u32,
    /// 成交回报找不到在册委托号的行数(填占位 orderid)。
    trade_sysid_missing: u32,
    /// 最近一次入向异常的纪元毫秒(从未发生 = None)。
    last_anomaly_ms: Option<i64>,
    // ---- 0.9.0 状态面(`{gw}.status` 的事实源;只增不改既有驱动语义) ----
    /// 交易前置断开累计次数(onFrontDisconnected 计数;跨重连保留)。
    disconnects: u32,
    /// 最近一次交易登录**成功**的纪元毫秒(从未成功 = None)。
    last_login_ok_ms: Option<i64>,
    /// 最近一次**资金回报到手**的纪元毫秒(「查询断供」判据之一)。
    last_account_ms: Option<i64>,
    /// 最近一次**持仓回报到手**的纪元毫秒。
    last_position_ms: Option<i64>,
    /// 查询被拒累计次数(资金/持仓/合约三条查询路带 error)。
    query_rejects: u32,
    /// 连续被拒次数(任一次成功回报清零)——「查询断供」阈值判据。
    query_reject_streak: u32,
    /// 合约查询被拒累计次数(0.9.1):`query_rejects` 的**分项**,不是另一本账
    /// (合约一条没到手时,消费侧要能看出「被拒的是哪一路」)。
    instrument_rejects: u32,
    /// 最近一次错误回报文本(认证/登录/查询/断开);登录成功即清(陈旧错误不再显形)。
    last_error: Option<String>,
}

/// 入向异常读数(0.12.0;`{gw}.status` 的原料,也是零丢弃矩阵测试的判据面)。
///
/// 口径:`total` = 所有"字段不可得"的次数之和;**一次不漏**(日志才去重)。
/// 计数跨重连保留(与 `D-STATUS ③` 同口径:掉过几次线是历史事实)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AnomalyCounts {
    /// 入向异常总数
    pub total: u32,
    /// 交易所码兜底链走完仍判不出
    pub exchange: u32,
    /// 委托状态码未映射
    pub status: u32,
    /// 委托类型三元组未映射
    pub order_type: u32,
    /// 时间戳畸形(委托/成交/行情)
    pub timestamp: u32,
    /// 方向/开平码未映射
    pub dir: u32,
    /// 成交缺在册委托号
    pub trade_sysid: u32,
    /// 行情合约不在册
    pub tick_contract: u32,
}

/// TD 状态读数(0.9.0;`{gw}.status` 的交易半边原料)。
#[derive(Debug, Clone, Default)]
pub struct TdStatusView {
    /// API 句柄已建并 registerFront(**不等于在线**)。
    pub handle_up: bool,
    /// 已登录(onRspUserLogin 成功且未断开)。
    pub logged_in: bool,
    /// 最近一次登录失败(成功后清零)。
    pub login_failed: bool,
    /// 认证态:已认证 / 认证失败 / 未认证(空 auth_code 的直登面 = 两者皆假)。
    pub auth_ok: bool,
    pub auth_failed: bool,
    pub front_id: i64,
    pub session_id: i64,
    pub disconnects: u32,
    pub last_login_ok_ms: Option<i64>,
    pub last_account_ms: Option<i64>,
    pub last_position_ms: Option<i64>,
    pub query_rejects: u32,
    pub query_reject_streak: u32,
    /// 合约查询被拒累计次数(0.9.1;`query_rejects` 的分项)。
    pub instrument_rejects: u32,
    pub last_error: Option<String>,
}

impl TdDriver {
    /// 构造(生产面):单调钟锚定到驱动创建时刻。
    pub fn new(gw: &str, cfg: TdConfig, contracts: Contracts) -> Self {
        let anchor = std::time::Instant::now();
        Self::with_clock(
            gw,
            cfg,
            contracts,
            Arc::new(move || anchor.elapsed().as_millis() as u64),
        )
    }

    /// 构造 + 注入单调钟(0.9.6,**测试接缝**)。
    ///
    /// 护栏要的是「距上一发过了多久」,不是「过了几拍」。真钟下测试里连续调 `on_timer`
    /// 之间 elapsed≈0,会被护栏全挡 —— 那样所有节律钉都测不到周期本身,而"排队拍挤在一起
    /// 处理"这个真实失效模式也永远测不到。所以假钟必须能注入,这不是可选项。
    pub fn with_clock(
        gw: &str,
        cfg: TdConfig,
        contracts: Contracts,
        now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
    ) -> Self {
        // 归一化只在这里与 reconfigure 各算一次(cfg 随后被 move 进结构体)。
        let accepted = cfg.product_classes.normalize();
        Self {
            gw: gw.to_string(),
            cfg,
            contracts,
            reqid: 0,
            order_ref: 0,
            connect_status: false,
            login_status: false,
            auth_status: false,
            auth_failed: false,
            login_failed: false,
            front_id: 0,
            session_id: 0,
            positions: HashMap::new(),
            sysid_orderid: HashMap::new(),
            next_pos: false,
            phase: QueryPhase::Idle,
            instrument_attempts: 0,
            instrument_warned: false,
            settlement_req_ms: None,
            last_query_ms: None,
            query_reject_streaks: [0; 2],
            query_backoff_until: [None; 2],
            now_ms,
            status_seen: std::collections::HashSet::new(),
            contract_kept: 0,
            contract_filtered: 0,
            contract_unmapped: 0,
            contract_unmapped_codes: std::collections::BTreeSet::new(),
            accepted,
            anomaly_seen: std::collections::HashSet::new(),
            inbound_anomalies: 0,
            unmapped_exchange_rows: 0,
            unmapped_status_rows: 0,
            unmapped_type_rows: 0,
            bad_timestamp_rows: 0,
            unmapped_dir_rows: 0,
            trade_sysid_missing: 0,
            last_anomaly_ms: None,
            disconnects: 0,
            last_login_ok_ms: None,
            last_account_ms: None,
            last_position_ms: None,
            query_rejects: 0,
            query_reject_streak: 0,
            instrument_rejects: 0,
            last_error: None,
        }
    }

    /// 本地委托号段(front_session_ref 三段下划线,原稿 495/681/850)。
    pub fn local_orderid(&self, order_ref: &str) -> String {
        format!("{}_{}_{}", self.front_id, self.session_id, order_ref)
    }

    /// 认证失败标志(63 错;原稿 auth_failed 781-782 由 authenticate 的守卫读)。
    pub fn auth_failed(&self) -> bool {
        self.auth_failed
    }

    /// 当前生效的产品类白名单(已归一化;供回包/界面显示)。
    pub fn product_classes(&self) -> Vec<String> {
        self.accepted.clone()
    }

    /// 合约阶段计数快照 `(收录, 按勾选丢弃, 未映射)`(测试与日志核对用)。
    pub fn contract_counts(&self) -> (usize, usize, usize) {
        (
            self.contract_kept,
            self.contract_filtered,
            self.contract_unmapped,
        )
    }

    /// 入向异常读数(0.12.0 `D-ANOMALY-SURFACE`;零丢弃矩阵与状态口的判据面)。
    pub fn anomaly_counts(&self) -> AnomalyCounts {
        AnomalyCounts {
            total: self.inbound_anomalies,
            exchange: self.unmapped_exchange_rows,
            status: self.unmapped_status_rows,
            order_type: self.unmapped_type_rows,
            timestamp: self.bad_timestamp_rows,
            dir: self.unmapped_dir_rows,
            trade_sysid: self.trade_sysid_missing,
            // 行情侧计数不在本结构里(TD 驱动不产 tick),由 `MdDriver::anomaly_counts` 填。
            tick_contract: 0,
        }
    }

    /// 最近一次入向异常的纪元毫秒(从未发生 = `None`;界面不得显示"0 秒前")。
    pub fn last_anomaly_ms(&self) -> Option<i64> {
        self.last_anomaly_ms
    }

    /// 合约表里的**名称**(0.11.0 第5刀 R6):插件在发单/成交/持仓行上补这一栏。
    ///
    /// 映射层刻意**不吃合约参数**(0.7.0 甲的纪律:converter 语义),所以名称不在
    /// `map.rs` 里填;名称是**网关侧的富化**:`map.rs` 只负责"柜台给了什么",
    /// 发之前的这一笔由插件把"共享表里 known 的名字"补上。查不到就留空,
    /// **绝不因为缺名字丢行**(红线①)。
    pub fn contract_name(&self, symbol: &str) -> Option<String> {
        let m = self.contracts.lock().unwrap();
        m.get(symbol)
            .map(|c| c.name.clone())
            .filter(|n| !n.is_empty())
    }

    /// 交易所解析的**唯一调用面**(兜底链 + 异常显形一起做,免得调用点各写一遍)。
    fn resolve_exchange(
        &mut self,
        exchange_id: &str,
        symbol: &str,
        outs: &mut Vec<TdOut>,
    ) -> Exchange {
        match self.report_exchange(exchange_id, symbol) {
            Some(ex) => ex,
            None => {
                outs.extend(self.note_anomaly(AnomalyKind::Exchange, symbol, exchange_id));
                Exchange::UNKNOWN
            }
        }
    }

    /// **交易所码兜底链**(0.12.0 `D-XFALLBACK`):报文码 → 共享合约表 → `None`。
    ///
    /// 为什么要有这一层:`D-REPORT-FACTS` 裁定回报自带 `ExchangeID`,但实连取证
    /// 只覆盖了 SHFE/INE —— 若 CZCE/CFFEX/DCE/GFEX 的回报不带交易所码,原实现
    /// **整条丢**。原稿用合约表查(它压根不读报文这个字段),所以报文码缺失时
    /// 用合约表兜底是**回到原稿语义**,不是新发明。
    ///
    /// 返回 `None` = 报文没给且合约不在册(调用方填 `Exchange::UNKNOWN` 并显形,
    /// **仍然不丢行**)。注意这里**不做默认交易所猜**(旧代码的 `unwrap_or(SHFE)`
    /// 已删):猜错交易所比留空危险得多 —— 留空界面显示"未知",猜错会让用户
    /// 对着错误的上期所费率下单。
    fn report_exchange(&self, exchange_id: &str, symbol: &str) -> Option<Exchange> {
        if let Some(ex) = map::exchange_of(exchange_id) {
            return Some(ex);
        }
        let m = self.contracts.lock().unwrap();
        m.get(symbol).map(|c| c.exchange)
    }

    /// 一次入向异常:计数 + 去重日志(显形三件套之一/之二;事件在插件层折)。
    ///
    /// **去重键 `(kind, symbol, code)`**:回报会重放、合约查询一次 2226 行,
    /// 逐行刷会把 eLog 512 行环冲成几百行(与 `status_seen`、`D-NATIVE-EXTRA-3`
    /// 同一个道理)。计数**一次不漏**,日志同键只一条 —— 两者分工必须写在
    /// 一起,否则"日志少了"会被误读成"没发生"。
    fn note_anomaly(&mut self, kind: AnomalyKind, symbol: &str, code: &str) -> Vec<TdOut> {
        self.bump_anomaly(kind);
        let key = format!("{kind:?}|{symbol}|{code}");
        if !self.anomaly_seen.insert(key) {
            return Vec::new();
        }
        vec![TdOut::Log(LogData {
            gateway_name: self.gw.clone(),
            // 前缀带机读短名:eLog 里可以按 `[ReportStatusUnmapped]` 直接过滤,
            // 不必跟中文文案较劲。短名取自 `AnomalyKind::code()`,与状态口字段
            // 一一对应(改一处名字,两边一起变)。
            msg: format!(
                "[{}] {},已如实入账:{}(原始码={})",
                kind.code(),
                kind.label(),
                symbol,
                if code.is_empty() { "∅" } else { code }
            ),
            level: LEVEL_WARNING,
        })]
    }

    /// 只计数不写日志(用于"一行可能有多项异常"的场合,日志由调用方合并)。
    fn bump_anomaly(&mut self, kind: AnomalyKind) {
        self.inbound_anomalies = self.inbound_anomalies.saturating_add(1);
        match kind {
            AnomalyKind::Exchange => self.unmapped_exchange_rows += 1,
            AnomalyKind::Status => self.unmapped_status_rows += 1,
            AnomalyKind::Type => self.unmapped_type_rows += 1,
            AnomalyKind::Timestamp => self.bad_timestamp_rows += 1,
            AnomalyKind::Dir => self.unmapped_dir_rows += 1,
            AnomalyKind::TradeSysid => self.trade_sysid_missing += 1,
        }
        self.last_anomaly_ms = Some(map::now_epoch_ms());
    }

    /// 登录态(适配层复接用)。
    pub fn login_status(&self) -> bool {
        self.login_status
    }

    /// 登录失败标志(原稿 796-797 的 login 守卫,登录失败不再续发;适配层用)。
    pub fn login_failed(&self) -> bool {
        self.login_failed
    }

    /// 当前持仓合并缓存键数(测试/适配复接用)。
    pub fn positions_len(&self) -> usize {
        self.positions.len()
    }

    /// FrontID(登录回报;适配层使用)。
    pub fn front_id(&self) -> i64 {
        self.front_id
    }

    /// 连接态(原稿 close 401-404 / 889-892 的 `if connect_status: exit()` 守卫,适配层用)。
    pub fn connect_status(&self) -> bool {
        self.connect_status
    }

    /// SessionID。
    pub fn session_id(&self) -> i64 {
        self.session_id
    }

    /// 已本地报单计数(order_ref;适配层使用)。
    pub fn order_ref(&self) -> i64 {
        self.order_ref
    }

    /// 发单(原稿 send_order 808-854)。 Returns 输出动作 + en_orderid。
    /// 校验路径原样(missings log + `""`):开平/类型缺者不放行;
    /// 方向 miss(NET)→ 空报单方向(原稿 get(...,"") 行为)。
    pub fn send_order(&mut self, req: &OrderRequest) -> SendOutcome {
        // 边界契约(2026-10-30):内部符号(裸代码或 EX.全名)一律归一成 CTP 裸代码再下发。
        let Some(instrument_id) = map::instrument_id(&req.symbol) else {
            return SendOutcome::rejected(log_info(
                &self.gw,
                format!("合约代码无法归一(空或交易所前缀未知):{}", req.symbol),
            ));
        };
        // 交易所取有效值:调用方给的交易所若是**已知期货交易所码**就用它;
        // 否则(未选 / 表单缺字段 / 默认值 / 入向占位)查合约表补 ——
        // 开平折叠与 exchange_id 都吃这个值,否则 SHFE 的平今会被折成平昨
        // (合约表里明明写着 SHFE)。**补不到就拒**:见 `effective_exchange`。
        let Some(exchange) = self.effective_exchange(req.exchange, &instrument_id) else {
            return SendOutcome::rejected(log_info(
                &self.gw,
                format!(
                    "该合约的交易所判不出来,已拒绝发送(不猜交易所):{}(请求的交易所={})",
                    instrument_id,
                    req.exchange.as_str()
                ),
            ));
        };

        let Some(offset_code) = map::offset_to(exchange, req.offset) else {
            return SendOutcome::rejected(log_info(&self.gw, "请选择开平方向"));
        };
        let Some((price_type, time_cond, vol_cond)) = map::order_type_to(req.order_type) else {
            return SendOutcome::rejected(log_info(
                &self.gw,
                format!("当前接口不支持该类型的委托{}", req.order_type.as_str()),
            ));
        };
        self.order_ref += 1;
        let order_ref = format!("{}", self.order_ref);
        let input = InputOrder {
            instrument_id: instrument_id.clone(),
            exchange_id: exchange.as_str().to_string(),
            limit_price: req.price,
            volume_total_original: req.volume as i64,
            order_price_type: price_type.to_string(),
            direction: map::direction_to(req.direction).unwrap_or("").to_string(),
            comb_offset_flag: offset_code.to_string(),
            order_ref: order_ref.clone(),
            investor_id: self.cfg.user.clone(),
            user_id: self.cfg.user.clone(),
            broker_id: self.cfg.broker.clone(),
            // 830-841 固定面:投机/立即触发/非强平/不自动挂起/最小量 1
            comb_hedge_flag: "1".to_string(),
            contingent_condition: "1".to_string(),
            force_close_reason: "0".to_string(),
            is_auto_suspend: 0,
            time_condition: time_cond.to_string(),
            volume_condition: vol_cond.to_string(),
            min_volume: 1,
        };
        let orderid = self.local_orderid(&order_ref);
        // 原稿 850-852:回 SUBMITTING 单在 reqOrderInsert 送出(返回码 0)之后;
        // 返回码 n 的判定归传输层(0.1.0 由 plugin 按 trait 返回值裁决),
        // 这里只顺序产出插单命令 + SUBMITTING 单回报。
        let outs = vec![
            TdOut::Insert(input),
            TdOut::Order(req.create_order_data(&orderid, &self.gw)),
        ];
        SendOutcome::placed(outs, format!("{}.{}", self.gw, orderid))
    }

    /// 流文件目录(原稿 createFtdcTraderApi 767:`{gateway_name}\\Td`)。
    pub fn flow_path(&self) -> String {
        format!("{}\\Td", self.gw)
    }

    /// 连接(原稿 TD connect 763-777):首次 → 发 ReqConnect;**重复调用 →
    /// authenticate()**(含 auth_failed 守卫)。close 守卫面由 `connect_status()`
    /// getter 提供给适配层(原稿 889-892)。
    pub fn connect(&mut self) -> Vec<TdOut> {
        if self.connect_status {
            return self.authenticate();
        }
        self.connect_status = true;
        vec![TdOut::ReqConnect {
            addr: self.cfg.addr.clone(),
            flow_path: self.flow_path(),
        }]
    }

    /// 换配置并复位连接/认证/登录/合约守卫(0.3.0 configure 面;错密改密
    /// 再连的正路)。持仓/订单缓存保留(交易所侧会重报);**TD 侧有地址时共享合约表清空**
    /// (0.6.0,D-PC-FILTER —— 勾选收窄必须真生效,configure 本就重走全量查询;
    /// MD-only 配置不清,因为没有任何东西会把表重新灌满)。
    pub fn reconfigure(&mut self, cfg: TdConfig) {
        self.cfg = cfg;
        self.connect_status = false;
        self.login_status = false;
        self.auth_status = false;
        self.auth_failed = false;
        self.login_failed = false;
        // 换配置 = 断开重连重走链,查询阶段与计数一并复位
        // (结算确认那一支还会再复位一次,这里保证 configure 到确认之间不漏发)。
        self.phase = QueryPhase::Idle;
        self.instrument_attempts = 0;
        self.instrument_warned = false;
        self.settlement_req_ms = None;
        // 柜台的「每秒 1 次」窗口是按连接的:上一连接的时间戳不该挡新连接的第一发。
        // `settlement_req_ms` 同理必须清:旧会话的时刻必然早已超过 1 秒,留着会让新会话
        // 的结算确认一到就当场问合约,1 秒静默被跳过。
        self.last_query_ms = None;
        self.next_pos = false;
        // 勾选可在运行时改,旧会话的计数与「已提示过」的合约都不能带进新会话。
        self.contract_kept = 0;
        self.contract_filtered = 0;
        self.contract_unmapped = 0;
        self.contract_unmapped_codes.clear();
        self.accepted = self.cfg.product_classes.normalize();
        // D-PC-FILTER:勾选可在运行时收窄,但 configure 已是「断开重连 + 全量重查」
        // (0.5.0 语义),旧会话灌进来的合约必须清,否则收窄只对之后新收录的合约生效 ——
        // 先勾期权再改回只期货,期权合约还留在表里,它的 tick/持仓/回报照样并库。
        // 代价写明:清表到新一轮合约查询收尾之间,已订阅标的的 tick 会因缺合约被丢。
        //
        // 守卫:**只在 TD 真会重走全量查询时清**。td addr 为空 = MD-only configure,
        // 没有任何东西会把表重新灌满,清了等于把所有 tick 并库永久掐死 —— 改动前旧表
        // 还在,MD-only 至少还能吃到上一轮的合约。这是清表引入的第一条边界。
        if !self.cfg.addr.is_empty() {
            self.contracts.lock().unwrap().clear();
        }
    }

    /// 查询**发送侧**被拒(0.9.2):`reqQry*` 的 rt 返回码非 0。
    ///
    /// 为什么要单独一个入口:原稿把 `ReqQryTradingAccount` / `ReqQryInvestorPosition` 的返回码
    /// 直接丢弃(876 / 886),于是"发送侧被拒"在计数、状态口、日志里**全隐形**。实连里这就是最
    /// 迷惑的形状(2026-10-22 两轮实连同一形态):`logged_in=true`、`query_rejects=0`、
    /// `last_account_ms=None` 三百九十秒不变 —— 而同一时刻同一会话的费率查询(它检返回码)
    /// 明明白白回 `-2`。同一个会话、同一类请求,一个看得见一个看不见,差别只在有没有丢返回码。
    ///
    /// 口径三条:
    /// - 计数进**同一条** `query_rejects` / `query_reject_streak`:消费侧要的是"查询这条路连续
    ///   被拒几次",不分发送侧还是回报侧。发送侧不设分项,因为它没有 ErrorField 可分路。
    /// - 日志只在**串的首次**(0→1)与**恢复**各写一条:轮转每几秒一轮,每轮都写就是把 eLog
    ///   512 行环挤满 —— 那正是 G1(登录行被挤掉)的成因。计数不受此限,一次都不漏。
    /// - `code == 0` 且串非零 = 这条路走通了 → 清串(与回报侧共用同一条串,恢复也只写一条)。
    ///
    /// 返回的是**日志行**而不是 `TdOut`:这个方法只产日志,形状收窄一点,调用方就不必再猜
    /// 里面会不会混进一条查询请求(那会变成自己给自己发查询)。
    /// 取某槽的连续被拒次数(None = 不分槽的旧口径)。
    fn streak_of(&self, slot: Option<usize>) -> u32 {
        match slot {
            Some(i) => self.query_reject_streaks[i],
            None => self.query_reject_streak,
        }
    }

    /// 写某槽的连续被拒次数(同时维护不分槽的总口径)。
    fn set_streak(&mut self, slot: Option<usize>, n: u32) {
        if let Some(i) = slot {
            self.query_reject_streaks[i] = n;
            if n == 0 {
                self.query_backoff_until[i] = None;
            } else {
                let wait = Self::query_backoff_ms(n);
                let now = (self.now_ms)();
                self.query_backoff_until[i] = Some(now.saturating_add(wait));
            }
        }
        self.query_reject_streak = n;
    }

    pub fn note_query_send_failure(&mut self, label: &str, code: i32) -> Vec<LogData> {
        // 分槽(0.11.12):资金/持仓各自成串,别让另一边成功把这边清零。
        let slot = match label {
            "资金" => Some(0usize),
            "持仓" => Some(1usize),
            _ => None,
        };
        let streak = self.streak_of(slot);
        if code == 0 {
            if streak == 0 {
                return Vec::new();
            }
            let n = streak;
            self.set_streak(slot, 0);
            self.last_error = None;
            return vec![log_info(
                &self.gw,
                format!("{label}查询发送恢复(此前连续被拒 {n} 次)"),
            )];
        }
        self.query_rejects += 1;
        let first_in_streak = streak == 0;
        let n = streak.saturating_add(1);
        self.set_streak(slot, n);
        self.last_error = Some(format!(
            "{label}查询发送被拒,错误代码:{code}(连续 {} 次)",
            n
        ));
        if first_in_streak {
            return vec![log_error(
                &self.gw,
                format!(
                    "{label}查询发送被拒,错误代码:{code}(连续被拒 {} 次;与回报侧被拒同计一条连续串)",
                    n
                ),
            )];
        }
        Vec::new()
    }

    /// 合约查询**发送被拒**(0.9.3 计数,0.9.4 改重发口径):
    /// ① 进同一条 `query_rejects` / `query_reject_streak`,**也进 `instrument_rejects` 分项**
    ///    (发送侧被拒同样是"合约一条没到手"的原因,分项不分发送侧还是回报侧);
    /// ② **不在这里排重发** —— 重发归 [`Self::on_timer`] 的合约状态机,触发条件是
    ///    「10 秒里没有合约信息过来」,不是「返回码非 0」。被拒当然也属于没到手,
    ///    但那一发已经算进 attempts 了,再排一次就是把同一秒问两遍。
    pub fn note_instrument_send_failure(&mut self, code: i32) -> Vec<LogData> {
        let logs = self.note_query_send_failure("合约", code);
        if code != 0 {
            self.instrument_rejects += 1;
        }
        logs
    }

    /// 0.9.0 状态读数(只读,不改任何状态)。
    #[must_use]
    pub fn status_view(&self) -> TdStatusView {
        TdStatusView {
            handle_up: self.connect_status,
            logged_in: self.login_status,
            login_failed: self.login_failed,
            auth_ok: self.auth_status,
            auth_failed: self.auth_failed,
            front_id: self.front_id,
            session_id: self.session_id,
            disconnects: self.disconnects,
            last_login_ok_ms: self.last_login_ok_ms,
            last_account_ms: self.last_account_ms,
            last_position_ms: self.last_position_ms,
            query_rejects: self.query_rejects,
            query_reject_streak: self.query_reject_streak,
            instrument_rejects: self.instrument_rejects,
            last_error: self.last_error.clone(),
        }
    }

    /// 0.9.0 `{gw}.disconnect`:停这一侧会话(守卫复位,查询阶段回 `Idle`)。
    ///
    /// 不清的东西写明:断开计数、登录/回报「最近时刻」、被拒计数、持仓合并缓存、
    /// OrderSysID 映射 —— 它们是跨会话的历史事实与账本映射,清掉等于抹掉掉线证据、
    /// 并让在途撤单的号对不上。`auth_failed` 也不清(它归 `reconfigure` 管:
    /// 『连接』走 configure,那里才是换凭据的地方)。
    pub fn reset_session(&mut self) {
        self.connect_status = false;
        self.login_status = false;
        self.auth_status = false;
        self.phase = QueryPhase::Idle;
        // 合约这一路是**这一条会话**的意图:会话没了就作废,新会话的结算确认会重新起一轮
        // (留着就是把旧会话的第 3 次/旧警告带到新会话上,合约一条都不会问)。
        self.instrument_attempts = 0;
        self.instrument_warned = false;
        self.settlement_req_ms = None;
        // 同 reconfigure:闸的时间戳属于**上一条连接**,跨会话不清就会挡住新会话的第一发。
        self.last_query_ms = None;
        self.next_pos = false;
    }

    /// 认证(原稿 779-792;auth_failed 守卫 781-782 静默不重发)。
    pub fn authenticate(&mut self) -> Vec<TdOut> {
        if self.auth_failed {
            return Vec::new();
        }
        vec![TdOut::ReqAuthenticate {
            user: self.cfg.user.clone(),
            broker: self.cfg.broker.clone(),
            auth_code: self.cfg.auth_code.clone(),
            app_id: self.cfg.app_id.clone(),
        }]
    }

    /// 登录(原稿 794-806;login_failed 守卫 796-797 静默不重发)。
    pub fn login(&mut self) -> Vec<TdOut> {
        if self.login_failed {
            return Vec::new();
        }
        vec![TdOut::ReqLogin {
            user: self.cfg.user.clone(),
            password: self.cfg.password.clone(),
            broker: self.cfg.broker.clone(),
        }]
    }

    /// 撤单(原稿 cancel_order 856-872;订单号三段解析失败 → log + 放行,
    /// D6:原稿 int() 会抛 ValueError,Rust 转错误日记 + 静默)。
    /// 调用方给的交易所不是已知期货交易所码时,用合约表补(`Contracts` 按裸代码建键)。
    ///
    /// 用途:开平折叠(平今只在 SHFE/INE 成立)与下发 `exchange_id`。合约表也没有时
    /// 原样交回 —— 不猜,让柜台照实报错。
    /// 出向交易所取有效值:`None` = **判不出来,必须拒单**。
    ///
    /// 三类输入:
    /// - 已知期货交易所码 → 原样用;
    /// - 退化值(`LOCAL` 是 `Exchange::default()`,`UNKNOWN` 是入向译不出时的占位)
    ///   → 查共享合约表补;
    /// - 表里也没有(或表里那条自己就是 `UNKNOWN`)→ `None`。
    ///
    /// **为什么不能像 0.11.14 那样"补不到就用传进来的那个"**:那样会把字面量
    /// `"LOCAL"` / `"UNKNOWN"` 当成交易所码发给柜台,而更糟的是
    /// [`map::offset_to`] 会对它走"非 SHFE/INE"分支把 `CLOSETODAY` 折成 `CLOSE`
    /// —— 用户要平今,实际平了昨。这不是"少一道校验",是**静默改变用户的下单
    /// 意图**,比直接拒绝危险得多。
    fn effective_exchange(&self, requested: Exchange, bare: &str) -> Option<Exchange> {
        if map::exchange_of(requested.as_str()).is_some() {
            return Some(requested);
        }
        let m = self.contracts.lock().unwrap();
        m.get(bare)
            .map(|c| c.exchange)
            // 合约表里也可能存着 `UNKNOWN`(那一行入向时就没判出交易所),
            // 它不是可用的出向取值。
            .filter(|e| *e != Exchange::UNKNOWN && *e != Exchange::LOCAL)
    }

    pub fn cancel_order(&mut self, req: &CancelRequest) -> Vec<TdOut> {
        let Some(instrument_id) = map::instrument_id(&req.symbol) else {
            return vec![TdOut::Log(log_info(
                &self.gw,
                format!("撤单合约代码无法归一(空或交易所前缀未知):{}", req.symbol),
            ))];
        };
        let Some(exchange) = self.effective_exchange(req.exchange, &instrument_id) else {
            return vec![TdOut::Log(log_info(
                &self.gw,
                format!(
                    "该合约的交易所判不出来,已拒绝撤单(不猜交易所):{}(请求的交易所={})",
                    instrument_id,
                    req.exchange.as_str()
                ),
            ))];
        };
        match map::cancel_action_from(
            &req.orderid,
            &instrument_id,
            exchange,
            &self.cfg.broker,
            &self.cfg.user,
        ) {
            Some(action) => vec![TdOut::Cancel(action)],
            None => vec![TdOut::Log(log_info(
                &self.gw,
                format!("撤单请求委托号异常:{}", req.orderid),
            ))],
        }
    }

    /// 订单成功发光后登记 sysid 报表(707,原稿 718)。
    fn remember_sysid(&mut self, sysid: &str, orderid: &str) {
        self.sysid_orderid
            .insert(sysid.to_string(), orderid.to_string());
    }

    /// **周期查询的唯一协调点**(0.9.8 改成阶段机):每一拍发不发、发哪一条由这里一次决定。
    ///
    /// 三个闸,全是墙钟(与驱动的单调毫秒钟同钟同基准):
    /// ① 首发合约 —— 距**发出结算单确认请求** ≥ `INSTRUMENT_FIRST_WAIT_MS`;
    /// ② 重发合约 / 问满警告 —— 距上一发真发出去 ≥ `INSTRUMENT_RETRY_WAIT_MS`,同一个数
    ///    兼作第三发的回报窗口,窗口走完仍无合约才警告;
    /// ③ 资金 / 持仓轮询 —— 距上一发 ≥ `QUERY_MIN_INTERVAL_MS`,不满就这一拍不发、等下一拍。
    ///
    /// 阶段(用户定口径):`Idle` 什么都不发 → `Instrument` 问合约(最多 3 次)→ 任一合约行
    /// 到手 → `Rotating` 交替轮询;3 次问满仍无合约 → 警告一次 → `Halted` 什么都不再发。
    /// 闸在阶段之前:被挡的这一拍不推进任何计数(挡一格还推进计数,就是把节律改掉一格)。
    pub fn on_timer(&mut self) -> Vec<TdOut> {
        if matches!(self.phase, QueryPhase::Idle | QueryPhase::Halted) {
            return Vec::new();
        }
        let now = (self.now_ms)();
        let outs = if self.phase == QueryPhase::Instrument {
            self.instrument_beat(now)
        } else {
            self.rotation_beat(now)
        };
        // 盖章只认「这一拍真发了一条查询」:状态行与警告是日志,不算一发(算就是把同一拍记两次)。
        if outs.iter().any(|o| !matches!(o, TdOut::Log(_))) {
            self.last_query_ms = Some(now);
        }
        outs
    }

    /// 首发闸:距「发出结算单确认请求」那一刻是否已满 1 秒。
    fn first_instrument_due(&self, now: u64) -> bool {
        match self.settlement_req_ms {
            // 没记下请求时刻(异常路径)→ 不因为缺一条证据把整条查询路卡死。
            None => true,
            Some(t) => now.saturating_sub(t) >= INSTRUMENT_FIRST_WAIT_MS,
        }
    }

    /// 距上一发真发出去是否已满 `ms` 毫秒。这一条会话还没发过 = 直接算满(闸开闸)。
    fn since_last_reached(&self, now: u64, ms: u64) -> bool {
        match self.last_query_ms {
            None => true,
            Some(last) => now.saturating_sub(last) >= ms,
        }
    }

    /// `Instrument` 阶段的这一拍。
    fn instrument_beat(&mut self, now: u64) -> Vec<TdOut> {
        if self.instrument_attempts == 0 {
            // 结算确认那一支没能当场问(还不足 1 秒)→ 这一拍补。
            if !self.first_instrument_due(now) {
                return Vec::new();
            }
            return self.ask_instrument();
        }
        if self.instrument_attempts < INSTRUMENT_MAX_ATTEMPTS {
            if !self.since_last_reached(now, INSTRUMENT_RETRY_WAIT_MS) {
                return Vec::new();
            }
            return self.ask_instrument();
        }
        // 问满:还要再等一个完整回报窗口才定案(第三发刚发出去就警告 = 误警告)。
        if !self.since_last_reached(now, INSTRUMENT_RETRY_WAIT_MS) {
            return Vec::new();
        }
        self.phase = QueryPhase::Halted;
        self.warn_no_contract()
    }

    /// `Rotating` 阶段的这一拍:1 秒闸在前,不满就这一拍不问。
    fn rotation_beat(&mut self, now: u64) -> Vec<TdOut> {
        if !self.since_last_reached(now, QUERY_MIN_INTERVAL_MS) {
            return Vec::new();
        }
        self.rotate_account_position()
    }

    /// 问一发合约:计数 +1,状态行一条,查询一条。盖章由 [`on_timer`](Self::on_timer) 统一做。
    fn ask_instrument(&mut self) -> Vec<TdOut> {
        self.instrument_attempts += 1;
        let n = self.instrument_attempts;
        vec![
            TdOut::Log(log_info(
                &self.gw,
                format!("{CHAIN_STATE_PREFIX}查询合约 第{n}次"),
            )),
            TdOut::ReqQryInstrument,
        ]
    }

    /// 问满定案:警告一次(只一次,不每拍刷),停在 `Halted`。
    ///
    /// 0.9.8 用户裁定:**问满之后不再问资金与持仓** —— 没有合约,持仓回报映射不了、界面
    /// 持仓表也是空的、下单更不可能。恢复只有一条路:新会话(`reset_session` / `reconfigure`)
    /// 重新起一轮;或者迟到的合约行把阶段救回 `Rotating`(见 `RspQryInstrument`)。
    fn warn_no_contract(&mut self) -> Vec<TdOut> {
        if self.instrument_warned {
            return Vec::new();
        }
        self.instrument_warned = true;
        let msg = format!(
            "{CHAIN_STATE_PREFIX}合约 {} 次未获合约信息:本轮查询已停止(不再问资金与持仓)",
            INSTRUMENT_MAX_ATTEMPTS
        );
        self.last_error = Some(msg.clone());
        vec![
            TdOut::Log(log_warn(&self.gw, msg)),
            // 0.11.0 第3刀:同一条事实的结构化面(界面吃 kind 去重告警,不靠抓文本)
            TdOut::Status(crate::plugin::GatewayChange::ContractQueryStopped),
        ]
    }

    /// 查询回报落账(0.11.12):按类清退避/推进退避。
    ///
    /// CTP 对同一类查询「上一条没回就拒下一条」(-2);时间闸只回答"距上次发出多久",
    /// 回答不了"上一条回来了没有",于是资金/持仓两类互相踩。分槽处理:
    /// 槽 0 = 资金,槽 1 = 持仓。
    fn settle_query(&mut self, was_position: bool, rejected: bool) {
        let slot = usize::from(was_position);
        if rejected {
            self.query_reject_streaks[slot] = self.query_reject_streaks[slot].saturating_add(1);
            let wait = Self::query_backoff_ms(self.query_reject_streaks[slot]);
            self.query_backoff_until[slot] = Some((self.now_ms)().saturating_add(wait));
        } else {
            self.query_reject_streaks[slot] = 0;
            self.query_backoff_until[slot] = None;
        }
    }

    /// 连续被拒 n 次后的等待(1s→2s→4s→8s→15s 封顶)。
    fn query_backoff_ms(streak: u32) -> u64 {
        let shift = streak.saturating_sub(1).min(4);
        QUERY_BACKOFF_BASE_MS
            .saturating_mul(1u64 << shift)
            .min(QUERY_BACKOFF_MAX_MS)
    }

    /// 资金 / 持仓交替(原稿 `query_functions` 列表轮换)。
    ///
    /// 0.11.12 退避:某类还在退避窗内就**跳过这一拍并让位给另一类**,不空转、
    /// 也不把另一类饿死(夜盘 7x24 环境实测:35 秒被拒 12 次)。
    fn rotate_account_position(&mut self) -> Vec<TdOut> {
        let now = (self.now_ms)();
        let slot = usize::from(self.next_pos);
        if let Some(until) = self.query_backoff_until[slot] {
            if now < until {
                // 让位:这一拍不发,下一拍轮到另一类。
                self.next_pos = !self.next_pos;
                return Vec::new();
            }
            self.query_backoff_until[slot] = None;
        }
        // 0.7.0(甲):「合约表为空就跳过持仓查询」的守卫拆除(原稿 881-882)——
        // 持仓回报也不再依赖合约(见 RspQryInvestorPosition)。0.9.8 起这一条只在
        // `Rotating` 阶段被走到,而进入该阶段的前提就是已有合约在册,守卫的意图由阶段
        // 保证;`Halted` 阶段根本不会走到这里。
        let out = if self.next_pos {
            TdOut::ReqQryPosition {
                broker: self.cfg.broker.clone(),
                user: self.cfg.user.clone(),
            }
        } else {
            TdOut::ReqQryAccount {
                broker: self.cfg.broker.clone(),
                user: self.cfg.user.clone(),
            }
        };
        self.next_pos = !self.next_pos;
        vec![out]
    }

    /// 合约这一路的当前读数(0.9.5;测试用):已问几次、是否已了结、是否已警告。
    /// 「已了结」= 阶段已离开 `Instrument`(到手进 `Rotating`,问满进 `Halted`)。
    #[must_use]
    pub fn instrument_retry(&self) -> (u32, bool, bool) {
        (
            self.instrument_attempts,
            !matches!(self.phase, QueryPhase::Idle | QueryPhase::Instrument),
            self.instrument_warned,
        )
    }

    /// 当前查询阶段(0.9.8;状态口与测试用)。
    #[must_use]
    pub fn query_phase(&self) -> QueryPhase {
        self.phase
    }

    /// 步进(原型 CtpTdApi 各回调)。
    pub fn step(&mut self, ev: TdEvent) -> Vec<TdOut> {
        match ev {
            TdEvent::FrontConnected => {
                self.connect_status = true;
                let mut outs = vec![
                    TdOut::Log(log_info(&self.gw, "交易服务器连接成功")),
                    // 0.11.0 第3刀:0.9.8 的 `[链] 前置已连接` 日志行升格成状态变化事件
                    // (状态类文本从 eLog 搬走;业务文案「交易服务器连接成功」一条不动)。
                    TdOut::Status(crate::plugin::GatewayChange::TdConnected),
                ];
                // 原稿 448-451:auth_code 空 → 直登
                if self.cfg.auth_code.is_empty() {
                    outs.extend(self.login());
                } else {
                    outs.extend(self.authenticate());
                }
                outs
            }
            TdEvent::FrontDisconnected(reason) => {
                self.login_status = false;
                // 0.9.8 自查补的一条:前置断了 = 这一条会话连结算确认都没走到,阶段回 `Idle`。
                // 不回,`Rotating` 会在一具死会话上继续按秒白发资金/持仓查询:每一条都被传输层
                // 拒(没有会话 = -1),把 `query_rejects` / `query_reject_streak` 往上堆 ——
                // 而 ek-ui 的「查询断供」判据吃的正是这两个数。重连后 `RspSettlementInfoConfirm`
                // 自己会把阶段摆回 `Instrument`,所以这里回 `Idle` 不会把查询路锁死。
                self.phase = QueryPhase::Idle;
                self.disconnects += 1;
                self.last_error = Some(format!("交易服务器连接断开,原因{reason}"));
                vec![
                    TdOut::Log(log_info(
                        &self.gw,
                        format!("交易服务器连接断开,原因{reason}"),
                    )),
                    TdOut::Status(crate::plugin::GatewayChange::TdDisconnected),
                ]
            }
            TdEvent::RspAuthenticate { error } => match error {
                None => {
                    self.auth_status = true;
                    vec![
                        TdOut::Log(log_info(&self.gw, "交易服务器授权验证成功")),
                        // 0.11.0 第3刀:`[链] 授权通过` 日志行升格成状态变化事件
                        TdOut::Status(crate::plugin::GatewayChange::TdAuthOk),
                        TdOut::ReqLogin {
                            user: self.cfg.user.clone(),
                            password: self.cfg.password.clone(),
                            broker: self.cfg.broker.clone(),
                        },
                    ]
                }
                Some(e) => {
                    self.last_error = Some(map::error_text("交易服务器授权验证失败", &e));
                    let outs = vec![
                        TdOut::Log(log_error_with("交易服务器授权验证失败", &self.gw, &e)),
                        TdOut::Status(crate::plugin::GatewayChange::TdAuthFailed),
                    ];
                    if e.error_id == 63 {
                        self.auth_failed = true;
                    }
                    outs
                }
            },
            TdEvent::RspUserLogin {
                error,
                front_id,
                session_id,
            } => match error {
                None => {
                    self.front_id = front_id;
                    self.session_id = session_id;
                    self.login_status = true;
                    // 0.9.0:登录成功即清「最近一次登录失败」与陈旧错误 ——
                    // 状态口把 `td_login_failed` 读成「最近一次登录的成败」,
                    // 一次早先的失败不得在成功之后继续显形。
                    self.login_failed = false;
                    self.last_login_ok_ms = Some(crate::map::now_epoch_ms());
                    self.last_error = None;
                    // 0.9.8 首发闸的参照点 = **发出结算单确认请求的那一刻**(与 `last_query_ms`
                    // 同钟同基准,不得与状态口的墙钟互减)。结算确认回报到手时判它满没满 1 秒。
                    self.settlement_req_ms = Some((self.now_ms)());
                    vec![
                        TdOut::Log(log_info(&self.gw, "交易服务器登录成功")),
                        // 0.11.0 第3刀:`[链] 登录成功` 日志行升格成状态变化事件
                        TdOut::Status(crate::plugin::GatewayChange::TdLoginOk),
                        TdOut::ReqSettlementConfirm {
                            broker: self.cfg.broker.clone(),
                            user: self.cfg.user.clone(),
                        },
                    ]
                }
                Some(e) => {
                    self.login_failed = true;
                    self.last_error = Some(map::error_text("交易服务器登录失败", &e));
                    vec![
                        TdOut::Log(log_error_with("交易服务器登录失败", &self.gw, &e)),
                        TdOut::Status(crate::plugin::GatewayChange::TdLoginFailed),
                    ]
                }
            },
            TdEvent::RspSettlementInfoConfirm { error: _ } => {
                // 原稿 523:无条件写 log;查询与重发归 [`Self::on_timer`] 那一个协调点
                // 新一轮合约查询开始:计数复位(收尾汇总的基准必须是本轮)。
                self.contract_kept = 0;
                self.contract_filtered = 0;
                self.contract_unmapped = 0;
                self.contract_unmapped_codes.clear();
                // 0.9.8:这一拍把阶段摆成 `Instrument`(合约这一路重新起一轮、轮转从资金起),
                // 再按首发闸决定要不要**当场就问**合约:距「发出结算单请求」已满 1 秒 → 当场问
                // (柜台慢时不白等);不足 1 秒 → 这一拍什么都不发,交给协调点下一拍补。
                // 原稿在这里并存两条查询(合约 + 显式资金),驱动注释自己就写明那一发
                // 「大概率撞每秒 1 次流控被拒」;三条路共用一个每秒名额,并存不再可能。
                self.phase = QueryPhase::Instrument;
                self.instrument_attempts = 0;
                self.instrument_warned = false;
                self.next_pos = false;
                let now = (self.now_ms)();
                let mut outs = vec![
                    TdOut::Log(log_info(&self.gw, "结算信息确认成功")),
                    // 0.11.0 第3刀:`[链] 结算单已确认` 日志行升格成状态变化事件
                    TdOut::Status(crate::plugin::GatewayChange::SettlementConfirmed),
                ];
                if self.first_instrument_due(now) {
                    outs.extend(self.ask_instrument());
                    // 当场问也要盖章:不盖,重发的 5 秒窗口就从更早那一发起算,少等一截。
                    self.last_query_ms = Some(now);
                }
                outs
            }
            TdEvent::RspQryInstrument { last, row, error } => {
                let mut outs = Vec::new();
                // 0.9.1 状态面:与轮转两查询同式 —— 被拒计数记在 `row` 判定**之前**。
                // 被拒包正是 `row: None`,记在后面就永远记不到。
                // 合约查询也进同一条连续串(`query_reject_streak`):消费侧要的是
                // 「查询这条路连续被拒几次」,不分路;分路的账在 `instrument_rejects`。
                if let Some(e) = &error {
                    self.query_rejects += 1;
                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
                    self.instrument_rejects += 1;
                    self.last_error = Some(map::error_text("合约查询被拒", e));
                    outs.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
                } else if row.is_some() {
                    // 有数据行 = 这条路走通了:连续串清零、陈旧错误不再是「最近一次」。
                    // (合约查询的收尾包/错误包常带 row: None,那不算走通也不算被拒。)
                    self.query_reject_streak = 0;
                    self.last_error = None;
                }
                if let Some(inf) = row {
                    // 0.9.5 口径、0.9.8 落到阶段:**合约信息一到手就进轮询**(用户定口径
                    // 「已经有任何一个合约信息过来,则直接不再查询合约信息」)。被勾选丢弃的
                    // 行也算到手 —— 这一条判的是"柜台答没答",不是"这行要不要"。
                    // 0.9.8 另加一条:迟到的合约行(警告之后才到)也把 `Halted` 救回 `Rotating`
                    // —— 停的是"柜台没答",不是"柜台答晚了"。状态行只在阶段真的变化时落一条
                    // (合约回报是几百包,每包一条会把 eLog 冲成几百行)。
                    if matches!(self.phase, QueryPhase::Instrument | QueryPhase::Halted) {
                        self.phase = QueryPhase::Rotating;
                        // 0.11.0 第3刀:`[链] 合约已到手…` 日志行升格成状态变化事件
                        // (仍然只在阶段真的变化时发一条:合约回报是几百包)
                        outs.push(TdOut::Status(crate::plugin::GatewayChange::ContractArrived));
                    }
                    // D-PC-FILTER:先勾选、再 map,同一行只计一次(否则「未勾选」与
                    // 「未映射」会重复计数,收尾汇总就是假的)。
                    // 不在勾选内 → 不进表、不上抛:这是**唯一的合法过滤器**
                    // (配置面 = 用户决定,D-PRODUCT-GATE),不是网关在裁决数据。
                    if !self.accepted.iter().any(|c| c == &inf.product_class) {
                        self.contract_filtered += 1;
                    } else {
                        // 勾选内的行**必须上岸**(0.12.0 零丢弃):产品类/交易所翻译
                        // 不了就填 `UNKNOWN`,不再走 `Mapped::Skip` 丢行 —— 但仍计数,
                        // 汇总日志里要看得见"勾了但没映射"有多少(否则数字是假的)。
                        if map::asset_from(&inf.product_class).is_none() {
                            self.contract_unmapped += 1;
                            self.contract_unmapped_codes
                                .insert(inf.product_class.clone());
                        }
                        let c = map::contract_from(&inf, &self.gw);
                        // 勾选即收录 + 上抛(取代原稿「全表收录、仅期货上抛」的硬编码)
                        self.contracts
                            .lock()
                            .unwrap()
                            .insert(c.symbol.clone(), c.clone());
                        self.contract_kept += 1;
                        outs.push(TdOut::Contract(c));
                    }
                }
                if last {
                    if let Some(e) = &error {
                        // 收尾包带错误 = 这条查询被拒:**不得写「成功」,也不得上抛 ContractFinish**。
                        // ek-ui 的查询合约窗靠 ContractFinish 解锁勾选 —— 一条合约都没到手却解锁,
                        // 就是让用户对着空表勾选,下一笔单注定撞 `contract:unknown`。
                        outs.push(TdOut::Log(log_error_with("合约查询被拒", &self.gw, e)));
                    } else {
                        // 0.7.0(甲):「未齐缓存 + 收尾回放」整个退役 —— 委托/成交
                        // 回报自登录起直接上账,不再等收尾包;这里只剩汇总日志与
                        // 「合约流完成」(UI 查询合约窗靠它解锁勾选)。
                        let unmapped_codes = if self.contract_unmapped_codes.is_empty() {
                            String::new()
                        } else {
                            format!(
                                "(码:{})",
                                self.contract_unmapped_codes
                                    .iter()
                                    .cloned()
                                    .collect::<Vec<_>>()
                                    .join(",")
                            )
                        };
                        outs.push(TdOut::Log(log_info(
                        &self.gw,
                        format!(
                            "合约信息查询成功:收录 {} / 按勾选丢弃 {} / 未映射 {}{unmapped_codes}(收录产品类:{})",
                            self.contract_kept,
                            self.contract_filtered,
                            self.contract_unmapped,
                            self.accepted.join(" ")
                        ),
                    )));
                        outs.push(TdOut::ContractFinish(self.gw.clone()));
                    }
                }
                outs
            }
            TdEvent::RspQryInvestorPosition { last, row, error } => {
                self.settle_query(true, error.is_some());
                // 0.9.0 状态面:被拒计数必须记在 `row` 早退**之前** —— 被拒包正是
                // `row: None`,记在早退之后等于永远记不到(F2 的可见性就在这一步)。
                // 0.11.0 第3刀:同一拍把「被拒」显形成状态变化事件(早退前攒进 outs)。
                let mut outs: Vec<TdOut> = Vec::new();
                if let Some(e) = &error {
                    self.query_rejects += 1;
                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
                    self.last_error = Some(map::error_text("持仓查询被拒", e));
                    outs.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
                } else {
                    // 这一包没带错误 = 查询走通了(哪怕没数据):连续串清零、
                    // 陈旧错误不再是「最近一次」。总数保留在 `query_rejects` 里,
                    // 取证要看今天一共被拒过几次,看的是那个数。
                    self.query_reject_streak = 0;
                    self.last_error = None;
                    if row.is_some() {
                        self.last_position_ms = Some(crate::map::now_epoch_ms());
                    }
                }
                // 原稿 539-540:data 缺 → 直接 return(空持仓查询不发事件、不冲刷、
                // 连 PositionDone 都不发)。
                //
                // **0.12.0 修正(`D-LAST-3BRANCH`)**:这是原稿的怪癖,不是纪律。
                // `row: None` 有两种来路 ——
                //   (a) `last=false` = 查询中途的空包(理论上不出现,真出现就是异常);
                //   (b) `last=true`  = 查询的正常结尾,**且这一轮一行持仓都没有**
                //                      (空仓),或前面几包已经攒好(此时 row 为 None
                //                      只说明末包不含数据)。
                // 旧写法把 (b) 也早退了,后果不是"少一条日志",而是:
                // 消费侧的持仓列表**永远停在上一轮** —— 用户平掉最后一手之后,
                // 界面上的仓位不会消失。这是用户最直接的"数据不对"。
                //
                // 现在:末包无论有没有数据行都要冲刷 + 发 `PositionQueryDone`。
                // 真正的空包(`last=false` 且 `row=None`)仍然不产出任何东西 ——
                // 它不是"查询完成",发 done 会让消费侧把半截数据当全量。
                let mut tail = Vec::new();
                if let Some(row) = row {
                    // 0.7.0(甲):持仓回报不依赖合约在册。「合约表为空就跳过持仓
                    // 查询」的守卫(原稿 881-882)同拍拆除。
                    // **0.12.0**:交易所走兜底链(报文码 → 合约表 → UNKNOWN),
                    // 判不出也照上账;合约乘数仍是在册才富化(不在册 size=0 →
                    // 均价不更新,量/冻结/盈亏照记,下一轮轮转补上精确值)。
                    let exchange =
                        self.resolve_exchange(&row.exchange_id, &row.instrument_id, &mut tail);
                    let size = self
                        .contracts
                        .lock()
                        .unwrap()
                        .get(&row.instrument_id)
                        .map(|c| c.size)
                        .unwrap_or(0.0);
                    let key = format!("{}:{}", row.instrument_id, row.posi_direction);
                    // 0.12.0(净仓 = 0):方向解析不出来时**填 `NET` 上岸**,不再
                    // 让该行消失 —— `NET` 的语义本就是"净",量 0 照上账,界面
                    // 能看到"已平到零"这条轨迹;旧写法让行直接不见,等于网关
                    // 替用户判断"这条不该显示"。
                    let direction = map::position_direction(&row.posi_direction, row.position)
                        .unwrap_or(ek_data::constant::Direction::NET);
                    if !self.positions.contains_key(&key) {
                        let pos = map::new_position_data(
                            &row.instrument_id,
                            exchange,
                            Some(direction),
                            &self.gw,
                        )
                        .unwrap_or_default();
                        self.positions.insert(key.clone(), PositionAccum { pos });
                    }
                    let entry = self.positions.get_mut(&key).expect("just inserted");
                    map::accumulate_position(&row, &mut entry.pos, exchange, size);
                }
                if last {
                    let done = self
                        .positions
                        .values()
                        .map(|p| p.pos.clone())
                        .collect::<Vec<_>>();
                    for pos in done {
                        tail.push(TdOut::Position(pos));
                    }
                    tail.push(TdOut::PositionQueryDone(self.gw.clone()));
                    self.positions.clear();
                }
                outs.extend(tail);
                outs
            }
            TdEvent::RspQryTradingAccount { last, row, error } => {
                self.settle_query(false, error.is_some());
                // 0.9.0 状态面:同持仓一路 —— 被拒包正是 `row: None`,计数须在早退前记。
                // 0.11.0 第3刀:被拒的同一拍显形成状态变化事件(先记事实,再排数据)。
                let mut pre: Vec<TdOut> = Vec::new();
                if let Some(e) = &error {
                    self.query_rejects += 1;
                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
                    self.last_error = Some(map::error_text("资金查询被拒", e));
                    pre.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
                } else {
                    // 口径同持仓一路:没带错误 = 查询走通,连续串清零、陈旧错误退场。
                    self.query_reject_streak = 0;
                    self.last_error = None;
                    if row.is_some() {
                        self.last_account_ms = Some(crate::map::now_epoch_ms());
                    }
                }
                let mut outs = match row {
                    Some(ta) => {
                        let mut outs = vec![TdOut::Account(map::account_from(&ta, &self.gw))];
                        // 0.9.8:0.9.3 那条「未 arm 就自启轮询」的兜底**撤除** —— 新口径是
                        // 「合约阶段结束才开始轮询」,让一笔资金回报自己把轮询启动起来直接违反
                        // 它。新时序下不发查询就不会有资金回报,真触发即异常:资金照常上账,
                        // 但落一条 WARNING,让异常看得见而不是被兜底吃掉。轮询正常跑时
                        // (`Rotating`)这一支一条都不写,不刷屏。
                        if self.phase != QueryPhase::Rotating {
                            let msg = format!(
                                "{CHAIN_STATE_PREFIX}资金回报到手但当前阶段是「{}」:轮询不自启",
                                self.phase.label()
                            );
                            outs.push(TdOut::Log(log_warn(&self.gw, msg)));
                        }
                        outs
                    }
                    None => Vec::new(),
                };
                outs.splice(0..0, pre);
                // 0.9.5 结束事件(用户定口径:三条查询路**谁收到 last 谁就推自己的结束事件**;
                // 持仓有 ePositionDone、合约有 eContractFinish,资金这一路原来没有)。
                // **被拒的 last 不算** —— 与合约那一路同一条纪律(收尾包带错误不得上抛
                // ContractFinish):结束事件是给下游相位门开的闸,"被拒"不能开闸。
                if last && error.is_none() {
                    outs.push(TdOut::AccountQueryDone(self.gw.clone()));
                }
                outs
            }
            TdEvent::RspOrderInsert { error, req } => match (error, req) {
                (Some(e), Some(input)) => {
                    let mut outs = vec![TdOut::Log(log_error_with("交易委托失败", &self.gw, &e))];
                    let contract = {
                        let m = self.contracts.lock().unwrap();
                        m.get(&input.instrument_id).cloned()
                    };
                    // 边界契约(2026-10-30):拒单必须可见 —— 合约表未收录(产品类收窄/查询未收尾)时
                    // 用报文字段兜底造单,不再只留一行日志把拒单吞掉。
                    let (symbol, exchange, name) = match contract {
                        Some(c) => (c.symbol.clone(), c.exchange, c.name.clone()),
                        None => (
                            input.instrument_id.clone(),
                            map::exchange_of(&input.exchange_id).unwrap_or(ek_data::Exchange::SHFE),
                            String::new(),
                        ),
                    };
                    {
                        let orderid = self.local_orderid(&input.order_ref);
                        let direction = map::direction_from(&input.direction);
                        let offset =
                            map::offset_from(&input.comb_offset_flag).unwrap_or(Offset::NONE);
                        outs.push(TdOut::Order(OrderData {
                            gateway_name: self.gw.clone(),
                            symbol,
                            // 0.11.0 第5刀 R6:这一条本就拿着合约 → 名称当场填
                            name,
                            exchange,
                            orderid,
                            order_type: OrderType::LIMIT,
                            direction: Some(direction.unwrap_or(Direction::NET)),
                            offset,
                            price: input.limit_price,
                            volume: input.volume_total_original as f64,
                            status: Status::REJECTED,
                            // 拒单原因就在手上(`e`)—— 填进去。以前靠 `..Default` 兜底,
                            // 结果"为什么被拒"只活在日志里,账本上是空串。
                            status_msg: map::error_text("交易委托失败", &e),
                            // 本地意象单:没成交、没有成交价、还没到交易所。
                            traded: 0.0,
                            traded_price: 0.0,
                            // 报文字段建单时无柜台时间;`reference` 是本地附言,此处没有。
                            datetime: None,
                            reference: String::new(),
                        }));
                    }
                    outs
                }
                _ => Vec::new(),
            },
            TdEvent::RspOrderAction { error } => match error {
                Some(e) => vec![TdOut::Log(log_error_with("交易撤单失败", &self.gw, &e))],
                None => Vec::new(),
            },
            TdEvent::RtnOrder(of) => {
                // 0.7.0(甲):回报不依赖合约在册。**0.12.0**:交易所码走兜底链,
                // 判不出也不丢 —— 填 `UNKNOWN` 上账 + 计数显形(D-NO-DROP)。
                let mut outs = Vec::new();
                let exchange = self.resolve_exchange(&of.exchange_id, &of.instrument_id, &mut outs);
                // 字段级异常:只计数 + 一次性日志(带去重键,回报重放不会刷屏)。
                for (kind, code) in order_anomalies(&of) {
                    outs.extend(self.note_anomaly(kind, &of.instrument_id, &code));
                }
                let order = map::order_from(&of, exchange, &self.gw);
                let orderid = order.orderid.clone();
                self.remember_sysid(&of.order_sys_id, &orderid);
                outs.push(TdOut::Order(order));
                outs
            }
            TdEvent::RtnTrade(tf) => {
                // 0.7.0(甲):语义同 RtnOrder;成交的 orderid 仍靠 sysid_orderid
                // 表(重传次序实测先委托后成交)。
                // **0.12.0**:孤儿成交不再丢 —— 用 `SYS:<OrderSysID>` 占位上账
                // (丢一条成交 = 均价/盈亏永久错,这是最不能丢的一类)。
                let mut outs = Vec::new();
                let exchange = self.resolve_exchange(&tf.exchange_id, &tf.instrument_id, &mut outs);
                for (kind, code) in trade_anomalies(&tf) {
                    outs.extend(self.note_anomaly(kind, &tf.instrument_id, &code));
                }
                let orderid = match self.sysid_orderid.get(&tf.order_sys_id) {
                    Some(id) => id.clone(),
                    None => {
                        outs.extend(self.note_anomaly(
                            AnomalyKind::TradeSysid,
                            &tf.instrument_id,
                            &tf.order_sys_id,
                        ));
                        // D4 占位形:带 `SYS:` 前缀便于人眼与撤单口区分
                        // (撤单口按三段下划线解析,`SYS:` 开头的号会被判异常并拒 ——
                        //  这正是不许对孤儿成交撤单的显式表达)。
                        format!("SYS:{}", tf.order_sys_id)
                    }
                };
                outs.push(TdOut::Trade(map::trade_from(
                    &tf, &orderid, exchange, &self.gw,
                )));
                outs
            }
            // ---- 附加接口面(超出原稿):三路只进 eLog,不新增事件实体 ----
            TdEvent::RspQryInstrumentMarginRate { row, error, last } => {
                let mut outs = Vec::new();
                if let Some(e) = error {
                    outs.push(TdOut::Log(log_error_with("保证金率查询失败", &self.gw, &e)));
                }
                if let Some(r) = row {
                    if last {
                        outs.push(TdOut::Log(LogData {
                            gateway_name: self.gw.clone(),
                            msg: format!(
                                "保证金率(末包):{} {} 档 long {}/{} short {}/{} 相对交易所={}",
                                r.instrument_id,
                                r.hedge_flag,
                                r.long_margin_ratio_by_money,
                                r.long_margin_ratio_by_volume,
                                r.short_margin_ratio_by_money,
                                r.short_margin_ratio_by_volume,
                                r.is_relative,
                            ),
                            level: LEVEL_INFO,
                        }));
                    }
                } else if last {
                    outs.push(TdOut::Log(LogData {
                        gateway_name: self.gw.clone(),
                        msg: "保证金率查询:空结果(末包)".to_string(),
                        level: LEVEL_INFO,
                    }));
                }
                outs
            }
            TdEvent::RspQryInstrumentCommissionRate { row, error, last } => {
                let mut outs = Vec::new();
                if let Some(e) = error {
                    outs.push(TdOut::Log(log_error_with("手续费率查询失败", &self.gw, &e)));
                }
                if let Some(r) = row {
                    if last {
                        outs.push(TdOut::Log(LogData {
                            gateway_name: self.gw.clone(),
                            msg: format!(
                                "手续费率(末包):{} 开 {}/{} 平 {}/{} 平今 {}/{}",
                                r.instrument_id,
                                r.open_ratio_by_money,
                                r.open_ratio_by_volume,
                                r.close_ratio_by_money,
                                r.close_ratio_by_volume,
                                r.close_today_ratio_by_money,
                                r.close_today_ratio_by_volume,
                            ),
                            level: LEVEL_INFO,
                        }));
                    }
                } else if last {
                    outs.push(TdOut::Log(LogData {
                        gateway_name: self.gw.clone(),
                        msg: "手续费率查询:空结果(末包)".to_string(),
                        level: LEVEL_INFO,
                    }));
                }
                outs
            }
            TdEvent::RtnInstrumentStatus(s) => {
                // 键不含 enter_time:公流重放的会话快照同状态常带不同进入时刻,
                // 只在 (exchange, instrument, status, segment) 变化时显形一次。
                let key = format!(
                    "{}|{}|{}|{}",
                    s.exchange_id, s.instrument_id, s.instrument_status, s.trading_segment_sn
                );
                if !self.status_seen.insert(key) {
                    return Vec::new();
                }
                vec![TdOut::Log(LogData {
                    gateway_name: self.gw.clone(),
                    msg: format!(
                        "交易状态:{}/{} status={} 进入 @{} (原因 {})",
                        s.exchange_id,
                        s.instrument_id,
                        s.instrument_status,
                        s.enter_time,
                        s.enter_reason
                    ),
                    level: LEVEL_INFO,
                })]
            }
        }
    }
}

/// 发单结果(en_orderid = 成功路径;error 记拒绝 reason)。
pub struct SendOutcome {
    /// 成功时的 en_orderid(全空 = 原稿返回 `""` 的形态)
    pub en_orderid: String,
    /// 拒绝时的 log(原稿 write_log 后返回 "")
    pub error: Option<LogData>,
    /// 插单前序(发起连接/认证/登录)动作
    pub outs: Vec<TdOut>,
}

impl SendOutcome {
    fn rejected(log: LogData) -> Self {
        Self {
            en_orderid: String::new(),
            error: Some(log),
            outs: Vec::new(),
        }
    }

    fn placed(outs: Vec<TdOut>, en_orderid: String) -> Self {
        Self {
            en_orderid,
            error: None,
            outs,
        }
    }
}