Skip to main content

ek_ctp/
driver.rs

1//! MD/TD 双会话纯状态机(逐字对照原稿 `CtpMdApi` 243-410 与 `CtpTdApi` 411-892)。
2//!
3//! 设计:
4//! - **`step` 单步语义**:输入一个回报事件、输出一个动作/事件向量;零线程、
5//!   零时钟、零 sleep —— 原稿的 `while+sleep` 流控重试与 `EventEngine` 定时器
6//!   都归上层(DESIGN.md D-FLCN/D-TIME)。
7//! - **合约表共享**:原稿的模块级 `symbol_contract_map` 在这里 = 注入的
8//!   `Contracts` 共享表(`Arc<Mutex<HashMap<String, ContractData>>>`),
9//!   由插件层创建并分发给 MD/TD 两台机。
10//! - 持仓合并、order/trade 缓冲、sysid 映射、动态查询轮转全部按原稿逐字移植。
11
12use crate::map;
13use crate::wire::{
14    DepthMarketData, ErrorField, InputOrder, InputOrderAction, InstrumentCommissionRateField,
15    InstrumentField, InstrumentMarginRateField, InstrumentStatusField, InvestorPositionField,
16    OrderField, TradeField, TradingAccountField,
17};
18use ek_data::constant::{Direction, Exchange, Offset, OrderType, Status};
19use ek_data::log::{LEVEL_ERROR, LEVEL_INFO, LogData};
20use ek_data::{
21    AccountData, CancelRequest, ContractData, OrderData, OrderRequest, PositionData, TickData,
22    TradeData,
23};
24use std::collections::HashMap;
25use std::sync::{Arc, Mutex};
26
27use ek_data::log::LEVEL_WARNING;
28
29/// 合约共享表(原稿全局 `symbol_contract_map` 的进程内等价物)。
30pub type Contracts = Arc<Mutex<HashMap<String, ContractData>>>;
31
32/// 空合约表构造。
33pub fn contracts_empty() -> Contracts {
34    Arc::new(Mutex::new(HashMap::new()))
35}
36
37// ================ 日志 helper(等价原稿 gateway.write_log/write_error) ================
38
39/// info 级日志。
40pub fn log_info(gw: &str, msg: impl Into<String>) -> LogData {
41    LogData {
42        gateway_name: gw.to_string(),
43        msg: msg.into(),
44        level: LEVEL_INFO,
45    }
46}
47
48/// error 级日志(错误行自动拼"代码/信息",原稿 write_error 216-222)。
49pub fn log_error_with(api_msg: &str, gw: &str, e: &ErrorField) -> LogData {
50    LogData {
51        gateway_name: gw.to_string(),
52        msg: map::error_text(api_msg, e),
53        level: LEVEL_ERROR,
54    }
55}
56
57/// error 级日志,**没有 ErrorField 可拼**的失败面(0.9.2):`reqQry*` 的发送侧被拒只给
58/// 一个返回码,柜台不会为它发 ErrorFrame —— 有码没文本,只能自己拼。
59pub fn log_error(gw: &str, msg: impl Into<String>) -> LogData {
60    LogData {
61        gateway_name: gw.to_string(),
62        msg: msg.into(),
63        level: LEVEL_ERROR,
64    }
65}
66
67/// warning 级日志(0.9.4:合约查询三次未获回报的「警告」用的就是这一级)。
68pub fn log_warn(gw: &str, msg: impl Into<String>) -> LogData {
69    LogData {
70        gateway_name: gw.to_string(),
71        msg: msg.into(),
72        level: LEVEL_WARNING,
73    }
74}
75
76/// 合约查询上限(0.9.5 定口径,0.9.8 改出口):一个会话里**最多问 3 次**;三次都没有合约
77/// 信息过来 → **警告一次**并进入 [`QueryPhase::Halted`]。触发条件是「回报没到手」,不是
78/// 「返回码非 0」:柜台答得出来就不该再问。原稿是 `while rc != 0 { sleep(1); 重发 }`(每秒
79/// 一发、无次数上限、还占住 CTP 回调线程),0.9.3 的"下一拍就补"也被否掉 —— 一直问就是把
80/// 这条连接每秒一个的查询名额吃光,而资金与持仓也要用同一个名额。
81///
82/// **0.9.8 的出口变化(用户裁定)**:问满之后**不再问资金与持仓** —— 没有合约,持仓回报
83/// 映射不了(`size` 为 0、均价算不出)、界面持仓表也是空的、下单更不可能,把名额花在界面
84/// 上用不上的数据上没有意义。原稿在这里是无限重试且轮询与合约无关(`init_query` 在 connect
85/// 就注册定时器,ctp_gateway.py:189),这一条是偏离,写明。
86const INSTRUMENT_MAX_ATTEMPTS: u32 = 3;
87
88/// 查询阶段(0.9.8,用户定口径):**链走到第几步就是哪个阶段**,不存在额外的「闸门」。
89///
90/// `Idle`(链还没走到结算确认)→ `Instrument`(问合约)→ 任一合约行到手 → `Rotating`
91/// (资金/持仓交替)。3 次问满仍无合约 → 警告一次 → `Halted`(本轮停止,什么都不再发)。
92/// 取代 0.9.x 的 `rotation_armed` + `instrument_closed` + `cycle_pos` 三样 —— 它们表达的
93/// 是同一件事的不同侧面。链本身是串行的:第 6~9 步每一步都由上一步的回报触发,阶段只是
94/// 把这个事实显形,不参与任何查询决策之外的逻辑。
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum QueryPhase {
97    /// 还没开始:未登录 / 未确认结算单 / 会话刚复位。
98    Idle,
99    /// 结算单已确认,正在问合约(最多 3 次)。
100    Instrument,
101    /// 有合约在册:资金与持仓交替轮询。
102    Rotating,
103    /// 3 次问满且一条合约都没到手:本轮查询停止(0.9.8 用户裁定,**不再问资金与持仓**)。
104    Halted,
105}
106
107impl QueryPhase {
108    /// 中文读数(状态行与界面「阶段」列共用一份措辞)。
109    #[must_use]
110    pub fn label(self) -> &'static str {
111        match self {
112            QueryPhase::Idle => "尚未开始查询",
113            QueryPhase::Instrument => "查询合约中",
114            QueryPhase::Rotating => "资金/持仓轮询中",
115            QueryPhase::Halted => "合约未到手,本轮查询已停止",
116        }
117    }
118}
119
120/// 链状态行的前缀(0.9.8,用户定「链进行的状态改为事件日志推送」)。
121///
122/// 消费侧(ek-ui 的 eLog 分支)按这个前缀认出状态行、落进账户表「阶段」列 —— **前缀必须
123/// 单源**:两处各写一遍字面量,就是下一次改口时的坑。
124pub const CHAIN_STATE_PREFIX: &str = "[链] ";
125
126/// 首发合约的静默(0.9.8,用户定口径):从**发出结算单确认请求**那一刻起算,不满这个毫秒数
127/// 就不问合约;结算确认回报到手时若已经过了 1 秒,当场就问(不白等)。
128///
129/// **有意偏离基线**:原稿 519-533 是结算确认当场就发 + `while rc != 0 { sleep(1); 重发 }`
130/// —— 无次数上限,且 `sleep` 占住 CTP 回调线程(`onRspSettlementInfoConfirm` 是 API 线程上
131/// 的回调)。我们改成「记请求时刻 → 到点才问」,上限见 [`INSTRUMENT_MAX_ATTEMPTS`]。
132const INSTRUMENT_FIRST_WAIT_MS: u64 = 1000;
133
134/// 合约重发间隔(0.9.8,用户定口径「然后等 5 秒再查」):距上一发**真正发出去**不满这个
135/// 毫秒数就这一拍不问。**同一个数兼作第三发的回报窗口** —— 问满后还要再等一个完整窗口
136/// 仍无合约才警告,否则柜台答得慢一点就被误判成问满。
137const INSTRUMENT_RETRY_WAIT_MS: u64 = 5000;
138
139/// 轮询自己的 1 秒闸(0.9.6 引入、0.9.8 抬到整 1 秒并改写语义):距上一发**真正发出去**
140/// 不满这个毫秒数,这一拍就不发,等下一拍。
141///
142/// **0.9.8 从 900 抬到 1000(用户定口径「距上次查询不满 1 秒就下一次」)**。抬到整 1 秒换来的
143/// 是一个硬保证:两发之间严格 ≥1000ms → **任意 1 秒窗口内最多 1 发**,正是 CTP 查询类请求的
144/// 流控规则(900 做不到:900ms 间隔在一个 1 秒窗口里能落进 2 发)。
145///
146/// **代价写明**:宿主 ek-event 的秒针不是原子钟(单条分发线程 + 队列),相邻两拍实测在
147/// 990–1010ms 之间晃 —— 间隔 990ms 的那一拍会被挡,下一拍(≈2 秒)才放行。于是实际密度是
148/// 0.5~1 发/秒,资金与持仓**每路 2~4 秒一次、带抖动**(0.9.7 及以前是稳定 2 秒)。基线的口径
149/// 是每路 4 秒(`count < 2` 门槛 + 两路轮转,ctp_gateway.py:224-240):这一版不写那个计数器,
150/// 密度自己落到同一量级。
151///
152/// **边界(写明,别被误读)**:这道闸只管协调点上的周期查询(合约重发、资金、持仓)。委托与
153/// 撤单从 ask 路直发传输层,结算确认与「结算确认到手当场问合约」都在事件路上直发,都不经这里。
154const QUERY_MIN_INTERVAL_MS: u64 = 1000;
155
156/// 连续被拒的指数退避:基数、封顶(1s→2s→4s→8s→15s 封底)。
157const QUERY_BACKOFF_BASE_MS: u64 = 1000;
158const QUERY_BACKOFF_MAX_MS: u64 = 15000;
159
160/// 入向异常的七种 kind(0.12.0 `D-ANOMALY-SURFACE` 的**穷举清单**)。
161///
162/// 纪律同 `GatewayChange`:这是闭集,**加一种要同时动状态口字段、事件短名与
163/// 界面文案**,所以新增须走文档 —— 不在这里随手加变体。
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum AnomalyKind {
166    /// 交易所码兜底链走完仍判不出(填 `Exchange::UNKNOWN`)。
167    Exchange,
168    /// 委托状态码未映射(填 `Status::UNKNOWN`)。
169    Status,
170    /// 委托类型三元组未映射(填 `OrderType::UNKNOWN`)。
171    Type,
172    /// 委托/成交/行情时间戳畸形(填 `datetime: None`)。
173    Timestamp,
174    /// 方向/开平码未映射(填 `None` / `Offset::NONE`)。
175    Dir,
176    /// 成交回报找不到在册委托号(填占位 orderid)。
177    TradeSysid,
178}
179
180impl AnomalyKind {
181    /// 机读短名(事件 kind 与界面去重键都吃这个,不吃中文文案)。
182    #[must_use]
183    pub fn code(self) -> &'static str {
184        match self {
185            Self::Exchange => "ReportExchangeUnmapped",
186            Self::Status => "ReportStatusUnmapped",
187            Self::Type => "ReportTypeUnmapped",
188            Self::Timestamp => "ReportTimestampBad",
189            Self::Dir => "ReportDirUnmapped",
190            Self::TradeSysid => "TradeSysidMissing",
191        }
192    }
193
194    /// 人读标签(日志正文用;带 CTP 原码的要求由调用方拼)。
195    #[must_use]
196    fn label(self) -> &'static str {
197        match self {
198            Self::Exchange => "交易所码未映射",
199            Self::Status => "委托状态码未映射",
200            Self::Type => "委托类型三元组未映射",
201            Self::Timestamp => "时间戳畸形",
202            Self::Dir => "方向/开平码未映射",
203            Self::TradeSysid => "成交回报缺少在册委托号",
204        }
205    }
206}
207
208/// 委托行的「字段不可得」清单:**种类 + CTP 原始码**。
209///
210/// 原始码必须一起带出来。只说"状态码未映射"而不说**是哪个码**,用户既没法拿它
211/// 找柜台对账,也没法判断"是不是柜台升级加了个新状态"(那时该做的是补映射,
212/// 而不是盯着一条空日志猜)。
213///
214/// ⚠ **这里是独立于 [`map::order_from`] 的第二次求值**,只是调用了同一批 `map`
215/// 判定函数。改任何一侧的判据都必须同时改另一侧 —— `tests/no_drop_matrix.rs`
216/// 的每条用例都同时断言「字段填了占位值」与「计数器 +1」,漂移会在那里炸。
217fn order_anomalies(of: &OrderField) -> Vec<(AnomalyKind, String)> {
218    let mut v = Vec::new();
219    if map::status_of(&of.order_status).is_none() {
220        v.push((AnomalyKind::Status, of.order_status.clone()));
221    }
222    if map::order_type_from(
223        &of.order_price_type,
224        &of.time_condition,
225        &of.volume_condition,
226    )
227    .is_none()
228    {
229        // 三元组一起报 —— 只报其中一段,用户不知道该改哪一个。
230        v.push((
231            AnomalyKind::Type,
232            format!(
233                "{}/{}/{}",
234                of.order_price_type, of.time_condition, of.volume_condition
235            ),
236        ));
237    }
238    if map::ctp_datetime(&of.insert_date, &of.insert_time, 0).is_none() {
239        v.push((
240            AnomalyKind::Timestamp,
241            format!("{} {}", of.insert_date, of.insert_time),
242        ));
243    }
244    if map::direction_from(&of.direction).is_none() {
245        v.push((AnomalyKind::Dir, format!("dir={}", of.direction)));
246    }
247    if map::offset_from(&of.comb_offset_flag).is_none() {
248        v.push((AnomalyKind::Dir, format!("offset={}", of.comb_offset_flag)));
249    }
250    v
251}
252
253/// 成交行的「字段不可得」清单(形态同 [`order_anomalies`])。
254fn trade_anomalies(tf: &TradeField) -> Vec<(AnomalyKind, String)> {
255    let mut v = Vec::new();
256    if map::ctp_datetime(&tf.trade_date, &tf.trade_time, 0).is_none() {
257        v.push((
258            AnomalyKind::Timestamp,
259            format!("{} {}", tf.trade_date, tf.trade_time),
260        ));
261    }
262    if map::direction_from(&tf.direction).is_none() {
263        v.push((AnomalyKind::Dir, format!("dir={}", tf.direction)));
264    }
265    if map::offset_from(&tf.offset_flag).is_none() {
266        v.push((AnomalyKind::Dir, format!("offset={}", tf.offset_flag)));
267    }
268    v
269}
270
271// ================ MD 行情会话(原稿 CtpMdApi) ================
272
273/// MD 会话配置(原稿 `CtpMdApi.connect` 入参)。
274///
275/// Debug 手工掩码(0.10.0,BD-CFG-18):`password` 永远 `****`。
276#[derive(Clone, Default)]
277pub struct MdConfig {
278    /// 行情前置地址(原稿 connect 已补完 "tcp://" 前缀后传入)
279    pub addr: String,
280    /// 用户代码(行情登录用)
281    pub user: String,
282    /// 密码
283    pub password: String,
284    /// 经纪公司代码
285    pub broker: String,
286}
287
288impl std::fmt::Debug for MdConfig {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("MdConfig")
291            .field("addr", &self.addr)
292            .field("user", &self.user)
293            // 秘面永远掩码(BD-CFG-18)。
294            .field("password", &"****")
295            .field("broker", &self.broker)
296            .finish()
297    }
298}
299
300/// MD 回报事件。
301#[derive(Debug, Clone)]
302/// MD 事件面(native 桥适配官方回报;mock 层直接喂值)。
303///
304/// Box 化:#[allow(clippy::large_enum_variant)] 之类抑损不是目标——本枚举
305/// 只在适配线程步进,不做高频批量装箱,加 Box 反而破坏模式匹配直观性。
306#[allow(clippy::large_enum_variant)]
307pub enum MdEvent {
308    /// onFrontConnected(265)
309    FrontConnected,
310    /// onFrontDisconnected(reason)(270)
311    FrontDisconnected(i64),
312    /// onRspUserLogin(data, error)(275)
313    RspUserLogin {
314        /// None = 成功
315        error: Option<ErrorField>,
316    },
317    /// onRspError(288)
318    RspError(ErrorField),
319    /// onRspSubMarketData(292)
320    RspSubMarketData {
321        /// Some(err) = 订阅失败(error_id 非零;None 原稿 296 行两面皆静默)
322        error: Option<ErrorField>,
323    },
324    /// onRtnDepthMarketData(301)
325    RtnDepthMarketData(DepthMarketData),
326}
327
328/// MD 输出动作。
329#[derive(Debug, Clone)]
330/// MD 生产输出(unified 面:命令 → 传输;数据 → 总线)。
331#[allow(clippy::large_enum_variant)]
332pub enum MdOut {
333    /// 创建/连接行情 API(原稿 connect() 368-382;flow_path 供 levefile 行情流文件
334    /// 落位,原稿固定为 `{gateway_name}\Md`,native 层使用)
335    ReqConnect {
336        /// 行情前置地址
337        addr: String,
338        /// 流文件目录尾段
339        flow_path: String,
340    },
341    /// reqUserLogin(384-393)
342    ReqLogin {
343        /// 用户代码
344        user: String,
345        /// 密码
346        password: String,
347        /// 经纪公司代码
348        broker: String,
349    },
350    /// subscribeMarketData(395-399)
351    ReqSubscribe(Vec<String>),
352    /// 上抛行情(经 gateway.on_tick)
353    Tick(TickData),
354    /// 业务日志(带等级;ERROR = write_error 的形态)
355    Log(LogData),
356    /// 状态变化(0.11.0 第3刀;与 TD 侧同式,由插件 worker 折成 `eStatus.<gw>`)
357    Status(crate::plugin::GatewayChange),
358}
359
360/// MD 状态机。
361pub struct MdDriver {
362    /// 网关名(日志与流文件路径的命名)
363    pub gw: String,
364    cfg: MdConfig,
365    contracts: Contracts,
366    /// 已登记的订阅集合(原稿 subscribed set;去重保序用等值判定)
367    subscribed: Vec<String>,
368    login_status: bool,
369    connect_status: bool,
370    /// 本地视角当日("YYYYMMDD";eTimer 刷新,原稿 update_date)
371    current_date: String,
372    // ---- 0.9.0 状态面(`{gw}.status` 的事实源;只增不改既有驱动语义) ----
373    /// 行情前置断开累计次数(onFrontDisconnected 计数;跨重连保留)。
374    disconnects: u32,
375    /// 最近一次行情登录**成功**的纪元毫秒(从未成功 = None)。
376    last_login_ok_ms: Option<i64>,
377    /// 最近一条行情回报的纪元毫秒(查询断供/静默判定的原料)。
378    last_tick_ms: Option<i64>,
379    /// 订阅回报被拒累计次数(onRspSubMarketData 带 error)。
380    sub_rejects: u32,
381    /// 最近一次错误回报文本(登录失败/订阅失败/断开);登录成功即清(陈旧错误不再显形)。
382    last_error: Option<String>,
383    /// 0.12.0(D-ANOMALY-SURFACE):行情侧入向异常计数 —— 合约不在册 / 时间戳不可得。
384    /// 与 TD 侧同口径**跨重连保留**;日志按合约去重(高频 tick 逐行刷会冲爆 eLog)。
385    tick_anomalies: u32,
386    tick_contract_missing: u32,
387    tick_bad_timestamp: u32,
388    last_anomaly_ms: Option<i64>,
389    /// 已提示过"行情合约不在册"的合约代码(同合约只一条日志)。
390    tick_anomaly_seen: std::collections::HashSet<String>,
391}
392
393/// MD 状态读数(0.9.0;`{gw}.status` 的行情半边原料)。
394#[derive(Debug, Clone, Default)]
395pub struct MdStatusView {
396    /// API 句柄已建并 registerFront(**不等于在线**:掉线时这一位仍为真)。
397    pub handle_up: bool,
398    /// 已登录(onRspUserLogin 成功且未断开)。
399    pub logged_in: bool,
400    /// 前置断开累计次数。
401    pub disconnects: u32,
402    pub last_login_ok_ms: Option<i64>,
403    pub last_tick_ms: Option<i64>,
404    pub sub_rejects: u32,
405    pub last_error: Option<String>,
406    /// 已登记订阅数(断开不清订阅集:重登录自动重订阅)。
407    pub subscribed: usize,
408}
409
410impl MdDriver {
411    /// 构造(合约表注入;current_date 初值由调用方给定——原稿是 datetime.now()
412    /// 的本地日期,Rust 显式化以免隐性时钟)。
413    pub fn new(gw: &str, cfg: MdConfig, contracts: Contracts, current_date: String) -> Self {
414        Self {
415            gw: gw.to_string(),
416            cfg,
417            contracts,
418            subscribed: Vec::new(),
419            login_status: false,
420            connect_status: false,
421            current_date,
422            disconnects: 0,
423            last_login_ok_ms: None,
424            last_tick_ms: None,
425            sub_rejects: 0,
426            tick_anomalies: 0,
427            tick_contract_missing: 0,
428            tick_bad_timestamp: 0,
429            last_anomaly_ms: None,
430            tick_anomaly_seen: std::collections::HashSet::new(),
431            last_error: None,
432        }
433    }
434
435    /// eTimer 秒级刷新当日(原稿 gateway.process_timer_event 235 行调 md.update_date)。
436    pub fn update_date(&mut self, date: String) {
437        self.current_date = date;
438    }
439
440    /// 行情订阅入口(原稿 gateway.subscribe → md.subscribe 191-193/395-399;
441    /// 登录前只登记,登录后立即发起)。
442    pub fn subscribe(&mut self, symbol: &str) -> Vec<MdOut> {
443        if !self.subscribed.iter().any(|s| s == symbol) {
444            self.subscribed.push(symbol.to_string());
445        }
446        if self.login_status {
447            vec![MdOut::ReqSubscribe(vec![symbol.to_string()])]
448        } else {
449            Vec::new()
450        }
451    }
452
453    /// 连接(原稿 MD connect 368-382):首次 → 发 ReqConnect;重复调用守卫静默。
454    /// close 守卫面由 `connect_status()` getter 提供(原稿 401-404)。
455    pub fn connect(&mut self) -> Vec<MdOut> {
456        if self.connect_status {
457            return Vec::new();
458        }
459        self.connect_status = true;
460        vec![MdOut::ReqConnect {
461            addr: self.cfg.addr.clone(),
462            flow_path: format!("{}\\Md", self.gw),
463        }]
464    }
465
466    /// 换配置并复位连接/登录守卫(0.3.0 configure 面;订阅集保留 —— 登录后
467    /// RspUserLogin 重订阅链自会把新订阅推下去)。
468    pub fn reconfigure(&mut self, cfg: MdConfig) {
469        self.cfg = cfg;
470        self.connect_status = false;
471        self.login_status = false;
472    }
473
474    /// 入向异常读数(0.12.0;行情侧只有两个计数位有意义 —— 合约不在册 / 时间戳畸形)。
475    /// **口径与 TD 侧同**:`total` 一次不漏,日志才按合约去重。
476    #[must_use]
477    pub fn anomaly_counts(&self) -> AnomalyCounts {
478        AnomalyCounts {
479            total: self.tick_anomalies,
480            timestamp: self.tick_bad_timestamp,
481            tick_contract: self.tick_contract_missing,
482            ..AnomalyCounts::default()
483        }
484    }
485
486    /// 最近一次行情入向异常时刻(从未发生 = `None`)。
487    #[must_use]
488    pub fn last_anomaly_ms(&self) -> Option<i64> {
489        self.last_anomaly_ms
490    }
491
492    /// 0.9.0 状态读数(只读,不改任何状态)。
493    #[must_use]
494    pub fn status_view(&self) -> MdStatusView {
495        MdStatusView {
496            handle_up: self.connect_status,
497            logged_in: self.login_status,
498            disconnects: self.disconnects,
499            last_login_ok_ms: self.last_login_ok_ms,
500            last_tick_ms: self.last_tick_ms,
501            sub_rejects: self.sub_rejects,
502            last_error: self.last_error.clone(),
503            subscribed: self.subscribed.len(),
504        }
505    }
506
507    /// 0.9.0 `{gw}.disconnect`:停这一侧会话(守卫复位),**订阅集与配置保留**
508    /// —— 重连后 `RspUserLogin` 重订阅链自会把订阅推下去(裁定 1「断开 = 停会话」)。
509    /// 计数与「最近时刻」不清零:它们是跨会话的历史事实,清掉就等于抹掉掉线证据。
510    pub fn reset_session(&mut self) {
511        self.connect_status = false;
512        self.login_status = false;
513    }
514
515    /// 步进(原型 CtpMdApi 各回调)。
516    pub fn step(&mut self, ev: MdEvent) -> Vec<MdOut> {
517        match ev {
518            MdEvent::FrontConnected => {
519                self.connect_status = true;
520                vec![
521                    MdOut::Log(log_info(&self.gw, "行情服务器连接成功")),
522                    MdOut::Status(crate::plugin::GatewayChange::MdConnected),
523                    MdOut::ReqLogin {
524                        user: self.cfg.user.clone(),
525                        password: self.cfg.password.clone(),
526                        broker: self.cfg.broker.clone(),
527                    },
528                ]
529            }
530            MdEvent::FrontDisconnected(reason) => {
531                self.login_status = false;
532                self.disconnects += 1;
533                self.last_error = Some(format!("行情服务器连接断开,原因{reason}"));
534                vec![
535                    MdOut::Log(log_info(
536                        &self.gw,
537                        format!("行情服务器连接断开,原因{reason}"),
538                    )),
539                    MdOut::Status(crate::plugin::GatewayChange::MdDisconnected),
540                ]
541            }
542            MdEvent::RspUserLogin { error } => match error {
543                None => {
544                    self.login_status = true;
545                    self.last_login_ok_ms = Some(crate::map::now_epoch_ms());
546                    // 陈旧错误不再显形:这一轮登录是干净的。
547                    self.last_error = None;
548                    let mut outs = vec![
549                        MdOut::Log(log_info(&self.gw, "行情服务器登录成功")),
550                        MdOut::Status(crate::plugin::GatewayChange::MdLoginOk),
551                    ];
552                    if !self.subscribed.is_empty() {
553                        outs.push(MdOut::ReqSubscribe(self.subscribed.clone()));
554                    }
555                    outs
556                }
557                Some(e) => {
558                    self.last_error = Some(map::error_text("行情服务器登录失败", &e));
559                    vec![
560                        MdOut::Log(log_error_with("行情服务器登录失败", &self.gw, &e)),
561                        MdOut::Status(crate::plugin::GatewayChange::MdLoginFailed),
562                    ]
563                }
564            },
565            MdEvent::RspError(e) => {
566                self.last_error = Some(map::error_text("行情接口报错", &e));
567                vec![MdOut::Log(log_error_with("行情接口报错", &self.gw, &e))]
568            }
569            MdEvent::RspSubMarketData { error } => match error {
570                Some(e) => {
571                    self.sub_rejects += 1;
572                    self.last_error = Some(map::error_text("行情订阅失败", &e));
573                    vec![
574                        MdOut::Log(log_error_with("行情订阅失败", &self.gw, &e)),
575                        MdOut::Status(crate::plugin::GatewayChange::SubscribeRejected),
576                    ]
577                }
578                None => Vec::new(),
579            },
580            MdEvent::RtnDepthMarketData(dm) => {
581                self.last_tick_ms = Some(crate::map::now_epoch_ms());
582                let contract = {
583                    let m = self.contracts.lock().unwrap();
584                    m.get(&dm.instrument_id).cloned()
585                };
586                let missing_contract = contract.is_none();
587                let tick = map::tick_from(&dm, contract.as_ref(), &self.gw, &self.current_date);
588                // 0.12.0:**行情行没有丢弃出口**。两种"富化不了"各自计数 + 去重告警:
589                // - 合约不在册 → 交易所取报文 `ExchangeID`、名字留空(D-XFALLBACK)
590                // - 时间戳不可得 → `datetime_invalid = true`,量价照上账
591                // 计数挂在上岸路径上(不是丢弃路径):"丢弃变成上岸"之后计数必须
592                // 还在,否则等于用改进本身抹掉了改进的证据。
593                let timestamp_bad = tick.datetime_invalid;
594                if !missing_contract && !timestamp_bad {
595                    return vec![MdOut::Tick(tick)];
596                }
597                let mut outs = Vec::with_capacity(2);
598                let mut key = String::new();
599                let mut msg = String::new();
600                if missing_contract {
601                    self.tick_anomalies = self.tick_anomalies.saturating_add(1);
602                    self.tick_contract_missing += 1;
603                    key.push_str("no-contract;");
604                    msg.push_str(&format!(
605                        "合约不在册,交易所取报文、名字留空(同类累计 {} 条)",
606                        self.tick_contract_missing
607                    ));
608                }
609                if timestamp_bad {
610                    self.tick_anomalies = self.tick_anomalies.saturating_add(1);
611                    self.tick_bad_timestamp += 1;
612                    key.push_str("bad-ts;");
613                    msg.push_str(&format!(
614                        "时间戳不可得,已置 datetime_invalid=true 后照常上岸(同类累计 {} 条)",
615                        self.tick_bad_timestamp
616                    ));
617                }
618                self.last_anomaly_ms = Some(crate::map::now_epoch_ms());
619                outs.push(MdOut::Tick(tick));
620                if self
621                    .tick_anomaly_seen
622                    .insert(format!("{}|{key}", dm.instrument_id))
623                {
624                    outs.push(MdOut::Log(LogData {
625                        gateway_name: self.gw.clone(),
626                        msg: format!("行情降级上岸:{}({msg})", dm.instrument_id),
627                        level: LEVEL_WARNING,
628                    }));
629                }
630                outs
631            }
632        }
633    }
634}
635
636// ================ TD 交易会话(原稿 CtpTdApi) ================
637
638/// TD 会话配置(原稿 `CtpTdApi.connect` 749-777 入参)。
639///
640/// Debug 手工掩码(0.10.0,BD-CFG-18):`password` 永远 `****`。
641#[derive(Clone, Default)]
642pub struct TdConfig {
643    /// 交易前置地址
644    pub addr: String,
645    /// 用户代码
646    pub user: String,
647    /// 密码
648    pub password: String,
649    /// 经纪公司代码
650    pub broker: String,
651    /// 授权编码(空则跳过认证直连登录,原稿 448-451)
652    pub auth_code: String,
653    /// 产品名称(AppID,认证用)
654    pub app_id: String,
655    /// 收录的产品类白名单(D-PC-FILTER;空 = 回落默认「期货」)。
656    /// 查询侧不过滤,收录侧按它严格丢弃(不进表、不上抛)。
657    pub product_classes: map::ProductClasses,
658}
659
660impl std::fmt::Debug for TdConfig {
661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662        f.debug_struct("TdConfig")
663            .field("addr", &self.addr)
664            .field("user", &self.user)
665            // 秘面永远掩码(BD-CFG-18)。
666            .field("password", &"****")
667            .field("broker", &self.broker)
668            .field("auth_code", &self.auth_code)
669            .field("app_id", &self.app_id)
670            .field("product_classes", &self.product_classes)
671            .finish()
672    }
673}
674
675/// TD 回报事件。
676#[derive(Debug, Clone)]
677pub enum TdEvent {
678    /// onFrontConnected(445)
679    FrontConnected,
680    /// onFrontDisconnected(reason)(453)
681    FrontDisconnected(i64),
682    /// onRspAuthenticate(458-472)
683    RspAuthenticate {
684        /// None = 成功
685        error: Option<ErrorField>,
686    },
687    /// onRspUserLogin(474-490)
688    RspUserLogin {
689        /// None = 成功
690        error: Option<ErrorField>,
691        /// FrontID(仅成功时有意义,原稿取 data["FrontID"])
692        front_id: i64,
693        /// SessionID
694        session_id: i64,
695    },
696    /// onRspSettlementInfoConfirm(519-533)
697    RspSettlementInfoConfirm {
698        /// None = 成功
699        error: Option<ErrorField>,
700    },
701    /// onRspQryInstrument(614-667)
702    RspQryInstrument {
703        /// 是否本包最后一笔
704        last: bool,
705        /// 数据行(错误包时 None)
706        row: Option<InstrumentField>,
707        /// 查询被拒(0.9.1 新增;`None` = 这一包没有错误回报)。
708        ///
709        /// 0.9.0 给轮转两查询(资金/持仓)补了这一位,合约查询刻意留在 3 参
710        /// (D-NATIVE-AUDIT-REPAID 的「划界」)。0.9.1 把它补齐:三条查询路同式,
711        /// 「合约一条都没到手」与「柜台没有合约」不再长得一样(合约表空 →
712        /// 持仓/委托回报的乘数与勾选判定全瞎,而界面上只是「合约窗没数」)。
713        error: Option<ErrorField>,
714    },
715    /// onRspQryInvestorPosition(535-595)
716    RspQryInvestorPosition {
717        /// 是否本包最后一笔
718        last: bool,
719        /// 数据行
720        row: Option<InvestorPositionField>,
721        /// 查询被拒(0.9.0 新增;`None` = 这一包没有错误回报)。
722        /// 状态口 `{gw}.status` 的「查询被拒计数」事实源 —— 0.8.0 之前这一路只有
723        /// `row: None`,分不清「被拒」与「没有持仓」(F2 掉线后查询断供在界面上不可见)。
724        error: Option<ErrorField>,
725    },
726    /// onRspQryTradingAccount(597-612)
727    RspQryTradingAccount {
728        /// 是否本包最后一笔
729        last: bool,
730        /// 数据行
731        row: Option<TradingAccountField>,
732        /// 查询被拒(0.9.0 新增;口径同 [`TdEvent::RspQryInvestorPosition`] 的 `error`)。
733        error: Option<ErrorField>,
734    },
735    /// onRspOrderInsert(492-513)。原稿从 err 包 OrderRef/Direction/LimitPrice 等
736    /// 字段重造 REJECTED 单。
737    RspOrderInsert {
738        /// None = 成功受理(原稿只在 error 分支造单)
739        error: Option<ErrorField>,
740        /// 触发失败的报单原字段(transport 层随错误回报回带)
741        req: Option<InputOrder>,
742    },
743    /// onRspOrderAction(515-517)
744    RspOrderAction {
745        /// None = 成功
746        error: Option<ErrorField>,
747    },
748    /// onRtnOrder(669-718)
749    RtnOrder(OrderField),
750    /// onRtnTrade(720-747)
751    RtnTrade(TradeField),
752    // ---- 附加接口面(超出原稿):保证金率/手续费率/交易状态 ----
753    /// onRspQryInstrumentMarginRate。原稿无此路径 —— 端口按设计记录进 eLog
754    /// (不新增事件实体,记档见 DESIGN)。
755    RspQryInstrumentMarginRate {
756        /// 数据行
757        row: Option<InstrumentMarginRateField>,
758        /// 查询错误(超出设计错误面:查询没就绪等)
759        error: Option<ErrorField>,
760        /// 是否本包最后一笔
761        last: bool,
762    },
763    /// onRspQryInstrumentCommissionRate。原稿无此路径 —— 同上,记录进 eLog。
764    RspQryInstrumentCommissionRate {
765        /// 数据行
766        row: Option<InstrumentCommissionRateField>,
767        /// 查询错误
768        error: Option<ErrorField>,
769        /// 是否本包最后一笔
770        last: bool,
771    },
772    /// onRtnInstrumentStatus(纯被动推送;SDK 6.7.7 无对应 Req)。原稿无此路径。
773    RtnInstrumentStatus(InstrumentStatusField),
774}
775
776/// TD 输出动作。
777#[derive(Debug, Clone)]
778pub enum TdOut {
779    /// 创建/连接交易 API(779 前流程:connect() 749-777)
780    ReqConnect {
781        /// 交易前置地址
782        addr: String,
783        /// 流文件目录尾段(`{gateway_name}\Td`)
784        flow_path: String,
785    },
786    /// reqAuthenticate(779-792)
787    ReqAuthenticate {
788        /// 用户代码
789        user: String,
790        /// 经纪公司代码
791        broker: String,
792        /// 授权编码
793        auth_code: String,
794        /// 产品名称(AppID)
795        app_id: String,
796    },
797    /// reqUserLogin(794-806)
798    ReqLogin {
799        /// 用户代码
800        user: String,
801        /// 密码
802        password: String,
803        /// 经纪公司代码
804        broker: String,
805    },
806    /// reqSettlementInfoConfirm(482-485)
807    ReqSettlementConfirm {
808        /// 经纪公司代码
809        broker: String,
810        /// 用户代码
811        user: String,
812    },
813    /// reqQryInstrument(合约流控重试在 transport 层,DESIGN.md D-FLCN)
814    ReqQryInstrument,
815    /// reqQryTradingAccount(874-877)
816    ReqQryAccount {
817        /// 经纪公司代码
818        broker: String,
819        /// 用户代码
820        user: String,
821    },
822    /// reqQryInvestorPosition(879-887)
823    ReqQryPosition {
824        /// 经纪公司代码
825        broker: String,
826        /// 用户代码
827        user: String,
828    },
829    /// reqOrderInsert(820-845 成功路径)
830    Insert(InputOrder),
831    /// reqOrderAction(856-872 成功路径)
832    Cancel(InputOrderAction),
833    /// 上抛合约(仅期货;经 gateway.on_contract 652)
834    Contract(ContractData),
835    /// 上抛订单(经 gateway.on_order)
836    Order(OrderData),
837    /// 上抛成交(经 gateway.on_trade)
838    Trade(TradeData),
839    /// 上抛持仓(合并完成;经 gateway.on_position 589-590)
840    Position(PositionData),
841    /// 持仓查询完成(原稿 593 行 EVENT_POSITION_QUERY_DONE;对齐 ek-oms 相位门)
842    PositionQueryDone(String),
843    /// 合约收齐(原稿 659 → BaseGateway.on_contract_finish → 0.10.5 起常量
844    /// `EVENT_CONTRACT_FINISH`)
845    /// = 全局线、无 gw 后缀;载荷给网关名以便多网关辨源,D-CFIN)
846    ContractFinish(String),
847    /// 上抛账户(612)
848    Account(AccountData),
849    /// 资金查询完成(0.9.5 新增):三条查询路**谁收到 last 谁就推自己的结束事件** ——
850    /// 持仓有 `ePositionDone.<gw>`(原稿 593)、合约有 `eContractFinish`(原稿 659),
851    /// 资金这一路原来没有。落线 `eAccountDone.<gw>`,载荷给网关名(与持仓那一路同式)。
852    AccountQueryDone(String),
853    /// 业务日志
854    Log(LogData),
855    /// 状态变化(0.11.0 第3刀):链状态机的「那一刻」从 eLog 文本升格成结构化事件。
856    /// 由插件 worker 折成 `eStatus.<gw>`(随附整份 `GatewayStatus` 快照)。
857    Status(crate::plugin::GatewayChange),
858}
859
860/// 持仓 accumulator(键 = 合约 + 持仓方向码,原稿 548 的 dict key 形态)。
861struct PositionAccum {
862    pos: PositionData,
863}
864
865/// TD 状态机。
866pub struct TdDriver {
867    gw: String,
868    cfg: TdConfig,
869    contracts: Contracts,
870    /// 请求计数(原稿 reqid;native 层使用)
871    pub reqid: i64,
872    /// 本地报单号计数(原稿 order_ref)
873    order_ref: i64,
874    /// 已连接/已登录/已认证/认证失败/登录失败/合约已收齐
875    connect_status: bool,
876    login_status: bool,
877    auth_status: bool,
878    auth_failed: bool,
879    login_failed: bool,
880    /// FrontID(登录回报)
881    front_id: i64,
882    /// SessionID
883    session_id: i64,
884    /// 认证流程标志(驱动机;MERGED: auth 独立状态)
885    /// 持仓合并缓存(原稿 positions dict;_TH 初始化面)
886    positions: HashMap<String, PositionAccum>,
887    /// OrderSysID → 本地委托号表(原稿 sysid_orderid_map;718)
888    sysid_orderid: HashMap<String, String>,
889    /// 查询轮转标志:[false=资金, true=持仓](原稿 query_functions 列表轮换)
890    next_pos: bool,
891    /// 查询阶段(0.9.8):链走到第几步就是哪个阶段。取代 `rotation_armed`(能不能问)+
892    /// `instrument_closed`(合约问完没问完)+ `cycle_pos`(周期相位)三样重复表达。
893    phase: QueryPhase,
894    /// 合约查询已问了几次(0.9.5):**最多 3 次**,三次没有合约信息来 → 警告一次。
895    instrument_attempts: u32,
896    /// 三次问满且都没等到 → 已警告过(警告只写一次,不每拍刷)。
897    instrument_warned: bool,
898    /// 发出结算单确认请求的时刻(0.9.8,与 [`last_query_ms`] 同钟同基准):首发合约的 1 秒
899    /// 静默从这一刻起算。`reconfigure` / `reset_session` **必须清它** —— 旧会话的时刻必然
900    /// 早已超过 1 秒,留着会让新会话的结算确认一到就当场发,静默被跳过。
901    settlement_req_ms: Option<u64>,
902    /// 上一发查询**真正发出去**的时刻(单调毫秒,与 [`now_ms`] 同钟同基准)。`None` =
903    /// 这一条会话还没发过 → 护栏开闸。`reconfigure`/`reset_session` 清它:柜台的每秒窗口
904    /// 是按连接的,上一连接的时间戳不该挡新连接的第一发。
905    last_query_ms: Option<u64>,
906    /// 资金/持仓各自的连续被拒计数(0.11.12;槽 0 = 资金,槽 1 = 持仓)。
907    /// 混成一条串时,另一边成功会把这边清零 —— 每次失败都被当"首条"刷日志,
908    /// 2026-10-30 夜盘 35 秒 12 条就是这个成因。
909    query_reject_streaks: [u32; 2],
910    /// 分项退避截止时刻(同槽):连续被拒 1s→2s→4s→8s→15s 封底。
911    query_backoff_until: [Option<u64>; 2],
912    /// 单调毫秒钟(0.9.6)。生产实现锚定到驱动创建时刻;测试经 [`with_clock`](Self::with_clock)
913    /// 注入一个手动推进的假钟 —— 没有这个接缝,节律钉全部会被护栏挡掉(连续调用之间 elapsed≈0),
914    /// 而"排队拍挤在一起"这个真实失效模式反而永远测不到。**与状态口的墙钟
915    /// (`map::now_epoch_ms`) 是两个钟,不得互减。**
916    now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
917    /// 交易状态推送去重键(exchange|instrument|status|segment)。
918    /// 公流重放整批重推会话快照,不去重会把 eLog 冲成几百行(D-NATIVE-EXTRA-3);
919    /// 键不含 enter_time —— 快照重放同状态的多变进入时刻不重记。
920    status_seen: std::collections::HashSet<String>,
921    /// 合约阶段计数(D-PC-FILTER;收尾时汇总成一条日志,不逐行刷):
922    /// 收录 / 按勾选丢弃 / 勾选了但本数据面映射不了。
923    contract_kept: usize,
924    contract_filtered: usize,
925    contract_unmapped: usize,
926    /// 未映射的产品类码集合。汇总里必须把码列出来 —— 原来逐行日志会给码,
927    /// 改成汇总之后如果把码丢了,「未映射 3」就是个查不出原因的数字。
928    contract_unmapped_codes: std::collections::BTreeSet<String>,
929    /// 归一化后的勾选表(在 new/reconfigure 各算一次)。不在每行合约上重算 ——
930    /// 全量一发 2226 行,逐行 `normalize()` 就是 2226 次排序+分配。
931    accepted: Vec<String>,
932    /// 已提示过「回报被丢弃」的合约代码(回报侧去重:同一合约只提示一次;
933    /// 0.7.0 起丢弃理由 = 报文交易所码未映射,不再是勾选过滤)。
934    ///
935    /// **0.12.0**:不再"丢弃",改叫「回报异常去重」—— 交易所码兜底链走完仍判不出时
936    /// 行照上账(`Exchange::UNKNOWN`),这里只压日志与计数。
937    /// 入向异常日志去重键 `(kind, symbol, code)`(计数不在此去重,一次不漏)。
938    anomaly_seen: std::collections::HashSet<String>,
939    // ---- 0.12.0 入向异常计数(D-ANOMALY-SURFACE 显形三件套之二;跨重连保留) ----
940    /// 入向异常总计数(所有"字段不可得"的入向行次数之和)。
941    inbound_anomalies: u32,
942    /// 交易所码兜底链走完仍判不出的行数(委托/成交/持仓/合约/tick 五路)。
943    unmapped_exchange_rows: u32,
944    /// 委托状态码未映射的行数(填 `Status::UNKNOWN`)。
945    unmapped_status_rows: u32,
946    /// 委托类型三元组未映射的行数(填 `OrderType::UNKNOWN`)。
947    unmapped_type_rows: u32,
948    /// 委托/成交/行情时间戳畸形的行数(填 `datetime: None`)。
949    bad_timestamp_rows: u32,
950    /// 方向/开平码未映射的行数(填 `None`/`Offset::NONE`)。
951    unmapped_dir_rows: u32,
952    /// 成交回报找不到在册委托号的行数(填占位 orderid)。
953    trade_sysid_missing: u32,
954    /// 最近一次入向异常的纪元毫秒(从未发生 = None)。
955    last_anomaly_ms: Option<i64>,
956    // ---- 0.9.0 状态面(`{gw}.status` 的事实源;只增不改既有驱动语义) ----
957    /// 交易前置断开累计次数(onFrontDisconnected 计数;跨重连保留)。
958    disconnects: u32,
959    /// 最近一次交易登录**成功**的纪元毫秒(从未成功 = None)。
960    last_login_ok_ms: Option<i64>,
961    /// 最近一次**资金回报到手**的纪元毫秒(「查询断供」判据之一)。
962    last_account_ms: Option<i64>,
963    /// 最近一次**持仓回报到手**的纪元毫秒。
964    last_position_ms: Option<i64>,
965    /// 查询被拒累计次数(资金/持仓/合约三条查询路带 error)。
966    query_rejects: u32,
967    /// 连续被拒次数(任一次成功回报清零)——「查询断供」阈值判据。
968    query_reject_streak: u32,
969    /// 合约查询被拒累计次数(0.9.1):`query_rejects` 的**分项**,不是另一本账
970    /// (合约一条没到手时,消费侧要能看出「被拒的是哪一路」)。
971    instrument_rejects: u32,
972    /// 最近一次错误回报文本(认证/登录/查询/断开);登录成功即清(陈旧错误不再显形)。
973    last_error: Option<String>,
974}
975
976/// 入向异常读数(0.12.0;`{gw}.status` 的原料,也是零丢弃矩阵测试的判据面)。
977///
978/// 口径:`total` = 所有"字段不可得"的次数之和;**一次不漏**(日志才去重)。
979/// 计数跨重连保留(与 `D-STATUS ③` 同口径:掉过几次线是历史事实)。
980#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
981pub struct AnomalyCounts {
982    /// 入向异常总数
983    pub total: u32,
984    /// 交易所码兜底链走完仍判不出
985    pub exchange: u32,
986    /// 委托状态码未映射
987    pub status: u32,
988    /// 委托类型三元组未映射
989    pub order_type: u32,
990    /// 时间戳畸形(委托/成交/行情)
991    pub timestamp: u32,
992    /// 方向/开平码未映射
993    pub dir: u32,
994    /// 成交缺在册委托号
995    pub trade_sysid: u32,
996    /// 行情合约不在册
997    pub tick_contract: u32,
998}
999
1000/// TD 状态读数(0.9.0;`{gw}.status` 的交易半边原料)。
1001#[derive(Debug, Clone, Default)]
1002pub struct TdStatusView {
1003    /// API 句柄已建并 registerFront(**不等于在线**)。
1004    pub handle_up: bool,
1005    /// 已登录(onRspUserLogin 成功且未断开)。
1006    pub logged_in: bool,
1007    /// 最近一次登录失败(成功后清零)。
1008    pub login_failed: bool,
1009    /// 认证态:已认证 / 认证失败 / 未认证(空 auth_code 的直登面 = 两者皆假)。
1010    pub auth_ok: bool,
1011    pub auth_failed: bool,
1012    pub front_id: i64,
1013    pub session_id: i64,
1014    pub disconnects: u32,
1015    pub last_login_ok_ms: Option<i64>,
1016    pub last_account_ms: Option<i64>,
1017    pub last_position_ms: Option<i64>,
1018    pub query_rejects: u32,
1019    pub query_reject_streak: u32,
1020    /// 合约查询被拒累计次数(0.9.1;`query_rejects` 的分项)。
1021    pub instrument_rejects: u32,
1022    pub last_error: Option<String>,
1023}
1024
1025impl TdDriver {
1026    /// 构造(生产面):单调钟锚定到驱动创建时刻。
1027    pub fn new(gw: &str, cfg: TdConfig, contracts: Contracts) -> Self {
1028        let anchor = std::time::Instant::now();
1029        Self::with_clock(
1030            gw,
1031            cfg,
1032            contracts,
1033            Arc::new(move || anchor.elapsed().as_millis() as u64),
1034        )
1035    }
1036
1037    /// 构造 + 注入单调钟(0.9.6,**测试接缝**)。
1038    ///
1039    /// 护栏要的是「距上一发过了多久」,不是「过了几拍」。真钟下测试里连续调 `on_timer`
1040    /// 之间 elapsed≈0,会被护栏全挡 —— 那样所有节律钉都测不到周期本身,而"排队拍挤在一起
1041    /// 处理"这个真实失效模式也永远测不到。所以假钟必须能注入,这不是可选项。
1042    pub fn with_clock(
1043        gw: &str,
1044        cfg: TdConfig,
1045        contracts: Contracts,
1046        now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
1047    ) -> Self {
1048        // 归一化只在这里与 reconfigure 各算一次(cfg 随后被 move 进结构体)。
1049        let accepted = cfg.product_classes.normalize();
1050        Self {
1051            gw: gw.to_string(),
1052            cfg,
1053            contracts,
1054            reqid: 0,
1055            order_ref: 0,
1056            connect_status: false,
1057            login_status: false,
1058            auth_status: false,
1059            auth_failed: false,
1060            login_failed: false,
1061            front_id: 0,
1062            session_id: 0,
1063            positions: HashMap::new(),
1064            sysid_orderid: HashMap::new(),
1065            next_pos: false,
1066            phase: QueryPhase::Idle,
1067            instrument_attempts: 0,
1068            instrument_warned: false,
1069            settlement_req_ms: None,
1070            last_query_ms: None,
1071            query_reject_streaks: [0; 2],
1072            query_backoff_until: [None; 2],
1073            now_ms,
1074            status_seen: std::collections::HashSet::new(),
1075            contract_kept: 0,
1076            contract_filtered: 0,
1077            contract_unmapped: 0,
1078            contract_unmapped_codes: std::collections::BTreeSet::new(),
1079            accepted,
1080            anomaly_seen: std::collections::HashSet::new(),
1081            inbound_anomalies: 0,
1082            unmapped_exchange_rows: 0,
1083            unmapped_status_rows: 0,
1084            unmapped_type_rows: 0,
1085            bad_timestamp_rows: 0,
1086            unmapped_dir_rows: 0,
1087            trade_sysid_missing: 0,
1088            last_anomaly_ms: None,
1089            disconnects: 0,
1090            last_login_ok_ms: None,
1091            last_account_ms: None,
1092            last_position_ms: None,
1093            query_rejects: 0,
1094            query_reject_streak: 0,
1095            instrument_rejects: 0,
1096            last_error: None,
1097        }
1098    }
1099
1100    /// 本地委托号段(front_session_ref 三段下划线,原稿 495/681/850)。
1101    pub fn local_orderid(&self, order_ref: &str) -> String {
1102        format!("{}_{}_{}", self.front_id, self.session_id, order_ref)
1103    }
1104
1105    /// 认证失败标志(63 错;原稿 auth_failed 781-782 由 authenticate 的守卫读)。
1106    pub fn auth_failed(&self) -> bool {
1107        self.auth_failed
1108    }
1109
1110    /// 当前生效的产品类白名单(已归一化;供回包/界面显示)。
1111    pub fn product_classes(&self) -> Vec<String> {
1112        self.accepted.clone()
1113    }
1114
1115    /// 合约阶段计数快照 `(收录, 按勾选丢弃, 未映射)`(测试与日志核对用)。
1116    pub fn contract_counts(&self) -> (usize, usize, usize) {
1117        (
1118            self.contract_kept,
1119            self.contract_filtered,
1120            self.contract_unmapped,
1121        )
1122    }
1123
1124    /// 入向异常读数(0.12.0 `D-ANOMALY-SURFACE`;零丢弃矩阵与状态口的判据面)。
1125    pub fn anomaly_counts(&self) -> AnomalyCounts {
1126        AnomalyCounts {
1127            total: self.inbound_anomalies,
1128            exchange: self.unmapped_exchange_rows,
1129            status: self.unmapped_status_rows,
1130            order_type: self.unmapped_type_rows,
1131            timestamp: self.bad_timestamp_rows,
1132            dir: self.unmapped_dir_rows,
1133            trade_sysid: self.trade_sysid_missing,
1134            // 行情侧计数不在本结构里(TD 驱动不产 tick),由 `MdDriver::anomaly_counts` 填。
1135            tick_contract: 0,
1136        }
1137    }
1138
1139    /// 最近一次入向异常的纪元毫秒(从未发生 = `None`;界面不得显示"0 秒前")。
1140    pub fn last_anomaly_ms(&self) -> Option<i64> {
1141        self.last_anomaly_ms
1142    }
1143
1144    /// 合约表里的**名称**(0.11.0 第5刀 R6):插件在发单/成交/持仓行上补这一栏。
1145    ///
1146    /// 映射层刻意**不吃合约参数**(0.7.0 甲的纪律:converter 语义),所以名称不在
1147    /// `map.rs` 里填;名称是**网关侧的富化**:`map.rs` 只负责"柜台给了什么",
1148    /// 发之前的这一笔由插件把"共享表里 known 的名字"补上。查不到就留空,
1149    /// **绝不因为缺名字丢行**(红线①)。
1150    pub fn contract_name(&self, symbol: &str) -> Option<String> {
1151        let m = self.contracts.lock().unwrap();
1152        m.get(symbol)
1153            .map(|c| c.name.clone())
1154            .filter(|n| !n.is_empty())
1155    }
1156
1157    /// 交易所解析的**唯一调用面**(兜底链 + 异常显形一起做,免得调用点各写一遍)。
1158    fn resolve_exchange(
1159        &mut self,
1160        exchange_id: &str,
1161        symbol: &str,
1162        outs: &mut Vec<TdOut>,
1163    ) -> Exchange {
1164        match self.report_exchange(exchange_id, symbol) {
1165            Some(ex) => ex,
1166            None => {
1167                outs.extend(self.note_anomaly(AnomalyKind::Exchange, symbol, exchange_id));
1168                Exchange::UNKNOWN
1169            }
1170        }
1171    }
1172
1173    /// **交易所码兜底链**(0.12.0 `D-XFALLBACK`):报文码 → 共享合约表 → `None`。
1174    ///
1175    /// 为什么要有这一层:`D-REPORT-FACTS` 裁定回报自带 `ExchangeID`,但实连取证
1176    /// 只覆盖了 SHFE/INE —— 若 CZCE/CFFEX/DCE/GFEX 的回报不带交易所码,原实现
1177    /// **整条丢**。原稿用合约表查(它压根不读报文这个字段),所以报文码缺失时
1178    /// 用合约表兜底是**回到原稿语义**,不是新发明。
1179    ///
1180    /// 返回 `None` = 报文没给且合约不在册(调用方填 `Exchange::UNKNOWN` 并显形,
1181    /// **仍然不丢行**)。注意这里**不做默认交易所猜**(旧代码的 `unwrap_or(SHFE)`
1182    /// 已删):猜错交易所比留空危险得多 —— 留空界面显示"未知",猜错会让用户
1183    /// 对着错误的上期所费率下单。
1184    fn report_exchange(&self, exchange_id: &str, symbol: &str) -> Option<Exchange> {
1185        if let Some(ex) = map::exchange_of(exchange_id) {
1186            return Some(ex);
1187        }
1188        let m = self.contracts.lock().unwrap();
1189        m.get(symbol).map(|c| c.exchange)
1190    }
1191
1192    /// 一次入向异常:计数 + 去重日志(显形三件套之一/之二;事件在插件层折)。
1193    ///
1194    /// **去重键 `(kind, symbol, code)`**:回报会重放、合约查询一次 2226 行,
1195    /// 逐行刷会把 eLog 512 行环冲成几百行(与 `status_seen`、`D-NATIVE-EXTRA-3`
1196    /// 同一个道理)。计数**一次不漏**,日志同键只一条 —— 两者分工必须写在
1197    /// 一起,否则"日志少了"会被误读成"没发生"。
1198    fn note_anomaly(&mut self, kind: AnomalyKind, symbol: &str, code: &str) -> Vec<TdOut> {
1199        self.bump_anomaly(kind);
1200        let key = format!("{kind:?}|{symbol}|{code}");
1201        if !self.anomaly_seen.insert(key) {
1202            return Vec::new();
1203        }
1204        vec![TdOut::Log(LogData {
1205            gateway_name: self.gw.clone(),
1206            // 前缀带机读短名:eLog 里可以按 `[ReportStatusUnmapped]` 直接过滤,
1207            // 不必跟中文文案较劲。短名取自 `AnomalyKind::code()`,与状态口字段
1208            // 一一对应(改一处名字,两边一起变)。
1209            msg: format!(
1210                "[{}] {},已如实入账:{}(原始码={})",
1211                kind.code(),
1212                kind.label(),
1213                symbol,
1214                if code.is_empty() { "∅" } else { code }
1215            ),
1216            level: LEVEL_WARNING,
1217        })]
1218    }
1219
1220    /// 只计数不写日志(用于"一行可能有多项异常"的场合,日志由调用方合并)。
1221    fn bump_anomaly(&mut self, kind: AnomalyKind) {
1222        self.inbound_anomalies = self.inbound_anomalies.saturating_add(1);
1223        match kind {
1224            AnomalyKind::Exchange => self.unmapped_exchange_rows += 1,
1225            AnomalyKind::Status => self.unmapped_status_rows += 1,
1226            AnomalyKind::Type => self.unmapped_type_rows += 1,
1227            AnomalyKind::Timestamp => self.bad_timestamp_rows += 1,
1228            AnomalyKind::Dir => self.unmapped_dir_rows += 1,
1229            AnomalyKind::TradeSysid => self.trade_sysid_missing += 1,
1230        }
1231        self.last_anomaly_ms = Some(map::now_epoch_ms());
1232    }
1233
1234    /// 登录态(适配层复接用)。
1235    pub fn login_status(&self) -> bool {
1236        self.login_status
1237    }
1238
1239    /// 登录失败标志(原稿 796-797 的 login 守卫,登录失败不再续发;适配层用)。
1240    pub fn login_failed(&self) -> bool {
1241        self.login_failed
1242    }
1243
1244    /// 当前持仓合并缓存键数(测试/适配复接用)。
1245    pub fn positions_len(&self) -> usize {
1246        self.positions.len()
1247    }
1248
1249    /// FrontID(登录回报;适配层使用)。
1250    pub fn front_id(&self) -> i64 {
1251        self.front_id
1252    }
1253
1254    /// 连接态(原稿 close 401-404 / 889-892 的 `if connect_status: exit()` 守卫,适配层用)。
1255    pub fn connect_status(&self) -> bool {
1256        self.connect_status
1257    }
1258
1259    /// SessionID。
1260    pub fn session_id(&self) -> i64 {
1261        self.session_id
1262    }
1263
1264    /// 已本地报单计数(order_ref;适配层使用)。
1265    pub fn order_ref(&self) -> i64 {
1266        self.order_ref
1267    }
1268
1269    /// 发单(原稿 send_order 808-854)。 Returns 输出动作 + en_orderid。
1270    /// 校验路径原样(missings log + `""`):开平/类型缺者不放行;
1271    /// 方向 miss(NET)→ 空报单方向(原稿 get(...,"") 行为)。
1272    pub fn send_order(&mut self, req: &OrderRequest) -> SendOutcome {
1273        // 边界契约(2026-10-30):内部符号(裸代码或 EX.全名)一律归一成 CTP 裸代码再下发。
1274        let Some(instrument_id) = map::instrument_id(&req.symbol) else {
1275            return SendOutcome::rejected(log_info(
1276                &self.gw,
1277                format!("合约代码无法归一(空或交易所前缀未知):{}", req.symbol),
1278            ));
1279        };
1280        // 交易所取有效值:调用方给的交易所若是**已知期货交易所码**就用它;
1281        // 否则(未选 / 表单缺字段 / 默认值 / 入向占位)查合约表补 ——
1282        // 开平折叠与 exchange_id 都吃这个值,否则 SHFE 的平今会被折成平昨
1283        // (合约表里明明写着 SHFE)。**补不到就拒**:见 `effective_exchange`。
1284        let Some(exchange) = self.effective_exchange(req.exchange, &instrument_id) else {
1285            return SendOutcome::rejected(log_info(
1286                &self.gw,
1287                format!(
1288                    "该合约的交易所判不出来,已拒绝发送(不猜交易所):{}(请求的交易所={})",
1289                    instrument_id,
1290                    req.exchange.as_str()
1291                ),
1292            ));
1293        };
1294
1295        let Some(offset_code) = map::offset_to(exchange, req.offset) else {
1296            return SendOutcome::rejected(log_info(&self.gw, "请选择开平方向"));
1297        };
1298        let Some((price_type, time_cond, vol_cond)) = map::order_type_to(req.order_type) else {
1299            return SendOutcome::rejected(log_info(
1300                &self.gw,
1301                format!("当前接口不支持该类型的委托{}", req.order_type.as_str()),
1302            ));
1303        };
1304        self.order_ref += 1;
1305        let order_ref = format!("{}", self.order_ref);
1306        let input = InputOrder {
1307            instrument_id: instrument_id.clone(),
1308            exchange_id: exchange.as_str().to_string(),
1309            limit_price: req.price,
1310            volume_total_original: req.volume as i64,
1311            order_price_type: price_type.to_string(),
1312            direction: map::direction_to(req.direction).unwrap_or("").to_string(),
1313            comb_offset_flag: offset_code.to_string(),
1314            order_ref: order_ref.clone(),
1315            investor_id: self.cfg.user.clone(),
1316            user_id: self.cfg.user.clone(),
1317            broker_id: self.cfg.broker.clone(),
1318            // 830-841 固定面:投机/立即触发/非强平/不自动挂起/最小量 1
1319            comb_hedge_flag: "1".to_string(),
1320            contingent_condition: "1".to_string(),
1321            force_close_reason: "0".to_string(),
1322            is_auto_suspend: 0,
1323            time_condition: time_cond.to_string(),
1324            volume_condition: vol_cond.to_string(),
1325            min_volume: 1,
1326        };
1327        let orderid = self.local_orderid(&order_ref);
1328        // 原稿 850-852:回 SUBMITTING 单在 reqOrderInsert 送出(返回码 0)之后;
1329        // 返回码 n 的判定归传输层(0.1.0 由 plugin 按 trait 返回值裁决),
1330        // 这里只顺序产出插单命令 + SUBMITTING 单回报。
1331        let outs = vec![
1332            TdOut::Insert(input),
1333            TdOut::Order(req.create_order_data(&orderid, &self.gw)),
1334        ];
1335        SendOutcome::placed(outs, format!("{}.{}", self.gw, orderid))
1336    }
1337
1338    /// 流文件目录(原稿 createFtdcTraderApi 767:`{gateway_name}\\Td`)。
1339    pub fn flow_path(&self) -> String {
1340        format!("{}\\Td", self.gw)
1341    }
1342
1343    /// 连接(原稿 TD connect 763-777):首次 → 发 ReqConnect;**重复调用 →
1344    /// authenticate()**(含 auth_failed 守卫)。close 守卫面由 `connect_status()`
1345    /// getter 提供给适配层(原稿 889-892)。
1346    pub fn connect(&mut self) -> Vec<TdOut> {
1347        if self.connect_status {
1348            return self.authenticate();
1349        }
1350        self.connect_status = true;
1351        vec![TdOut::ReqConnect {
1352            addr: self.cfg.addr.clone(),
1353            flow_path: self.flow_path(),
1354        }]
1355    }
1356
1357    /// 换配置并复位连接/认证/登录/合约守卫(0.3.0 configure 面;错密改密
1358    /// 再连的正路)。持仓/订单缓存保留(交易所侧会重报);**TD 侧有地址时共享合约表清空**
1359    /// (0.6.0,D-PC-FILTER —— 勾选收窄必须真生效,configure 本就重走全量查询;
1360    /// MD-only 配置不清,因为没有任何东西会把表重新灌满)。
1361    pub fn reconfigure(&mut self, cfg: TdConfig) {
1362        self.cfg = cfg;
1363        self.connect_status = false;
1364        self.login_status = false;
1365        self.auth_status = false;
1366        self.auth_failed = false;
1367        self.login_failed = false;
1368        // 换配置 = 断开重连重走链,查询阶段与计数一并复位
1369        // (结算确认那一支还会再复位一次,这里保证 configure 到确认之间不漏发)。
1370        self.phase = QueryPhase::Idle;
1371        self.instrument_attempts = 0;
1372        self.instrument_warned = false;
1373        self.settlement_req_ms = None;
1374        // 柜台的「每秒 1 次」窗口是按连接的:上一连接的时间戳不该挡新连接的第一发。
1375        // `settlement_req_ms` 同理必须清:旧会话的时刻必然早已超过 1 秒,留着会让新会话
1376        // 的结算确认一到就当场问合约,1 秒静默被跳过。
1377        self.last_query_ms = None;
1378        self.next_pos = false;
1379        // 勾选可在运行时改,旧会话的计数与「已提示过」的合约都不能带进新会话。
1380        self.contract_kept = 0;
1381        self.contract_filtered = 0;
1382        self.contract_unmapped = 0;
1383        self.contract_unmapped_codes.clear();
1384        self.accepted = self.cfg.product_classes.normalize();
1385        // D-PC-FILTER:勾选可在运行时收窄,但 configure 已是「断开重连 + 全量重查」
1386        // (0.5.0 语义),旧会话灌进来的合约必须清,否则收窄只对之后新收录的合约生效 ——
1387        // 先勾期权再改回只期货,期权合约还留在表里,它的 tick/持仓/回报照样并库。
1388        // 代价写明:清表到新一轮合约查询收尾之间,已订阅标的的 tick 会因缺合约被丢。
1389        //
1390        // 守卫:**只在 TD 真会重走全量查询时清**。td addr 为空 = MD-only configure,
1391        // 没有任何东西会把表重新灌满,清了等于把所有 tick 并库永久掐死 —— 改动前旧表
1392        // 还在,MD-only 至少还能吃到上一轮的合约。这是清表引入的第一条边界。
1393        if !self.cfg.addr.is_empty() {
1394            self.contracts.lock().unwrap().clear();
1395        }
1396    }
1397
1398    /// 查询**发送侧**被拒(0.9.2):`reqQry*` 的 rt 返回码非 0。
1399    ///
1400    /// 为什么要单独一个入口:原稿把 `ReqQryTradingAccount` / `ReqQryInvestorPosition` 的返回码
1401    /// 直接丢弃(876 / 886),于是"发送侧被拒"在计数、状态口、日志里**全隐形**。实连里这就是最
1402    /// 迷惑的形状(2026-10-22 两轮实连同一形态):`logged_in=true`、`query_rejects=0`、
1403    /// `last_account_ms=None` 三百九十秒不变 —— 而同一时刻同一会话的费率查询(它检返回码)
1404    /// 明明白白回 `-2`。同一个会话、同一类请求,一个看得见一个看不见,差别只在有没有丢返回码。
1405    ///
1406    /// 口径三条:
1407    /// - 计数进**同一条** `query_rejects` / `query_reject_streak`:消费侧要的是"查询这条路连续
1408    ///   被拒几次",不分发送侧还是回报侧。发送侧不设分项,因为它没有 ErrorField 可分路。
1409    /// - 日志只在**串的首次**(0→1)与**恢复**各写一条:轮转每几秒一轮,每轮都写就是把 eLog
1410    ///   512 行环挤满 —— 那正是 G1(登录行被挤掉)的成因。计数不受此限,一次都不漏。
1411    /// - `code == 0` 且串非零 = 这条路走通了 → 清串(与回报侧共用同一条串,恢复也只写一条)。
1412    ///
1413    /// 返回的是**日志行**而不是 `TdOut`:这个方法只产日志,形状收窄一点,调用方就不必再猜
1414    /// 里面会不会混进一条查询请求(那会变成自己给自己发查询)。
1415    /// 取某槽的连续被拒次数(None = 不分槽的旧口径)。
1416    fn streak_of(&self, slot: Option<usize>) -> u32 {
1417        match slot {
1418            Some(i) => self.query_reject_streaks[i],
1419            None => self.query_reject_streak,
1420        }
1421    }
1422
1423    /// 写某槽的连续被拒次数(同时维护不分槽的总口径)。
1424    fn set_streak(&mut self, slot: Option<usize>, n: u32) {
1425        if let Some(i) = slot {
1426            self.query_reject_streaks[i] = n;
1427            if n == 0 {
1428                self.query_backoff_until[i] = None;
1429            } else {
1430                let wait = Self::query_backoff_ms(n);
1431                let now = (self.now_ms)();
1432                self.query_backoff_until[i] = Some(now.saturating_add(wait));
1433            }
1434        }
1435        self.query_reject_streak = n;
1436    }
1437
1438    pub fn note_query_send_failure(&mut self, label: &str, code: i32) -> Vec<LogData> {
1439        // 分槽(0.11.12):资金/持仓各自成串,别让另一边成功把这边清零。
1440        let slot = match label {
1441            "资金" => Some(0usize),
1442            "持仓" => Some(1usize),
1443            _ => None,
1444        };
1445        let streak = self.streak_of(slot);
1446        if code == 0 {
1447            if streak == 0 {
1448                return Vec::new();
1449            }
1450            let n = streak;
1451            self.set_streak(slot, 0);
1452            self.last_error = None;
1453            return vec![log_info(
1454                &self.gw,
1455                format!("{label}查询发送恢复(此前连续被拒 {n} 次)"),
1456            )];
1457        }
1458        self.query_rejects += 1;
1459        let first_in_streak = streak == 0;
1460        let n = streak.saturating_add(1);
1461        self.set_streak(slot, n);
1462        self.last_error = Some(format!(
1463            "{label}查询发送被拒,错误代码:{code}(连续 {} 次)",
1464            n
1465        ));
1466        if first_in_streak {
1467            return vec![log_error(
1468                &self.gw,
1469                format!(
1470                    "{label}查询发送被拒,错误代码:{code}(连续被拒 {} 次;与回报侧被拒同计一条连续串)",
1471                    n
1472                ),
1473            )];
1474        }
1475        Vec::new()
1476    }
1477
1478    /// 合约查询**发送被拒**(0.9.3 计数,0.9.4 改重发口径):
1479    /// ① 进同一条 `query_rejects` / `query_reject_streak`,**也进 `instrument_rejects` 分项**
1480    ///    (发送侧被拒同样是"合约一条没到手"的原因,分项不分发送侧还是回报侧);
1481    /// ② **不在这里排重发** —— 重发归 [`Self::on_timer`] 的合约状态机,触发条件是
1482    ///    「10 秒里没有合约信息过来」,不是「返回码非 0」。被拒当然也属于没到手,
1483    ///    但那一发已经算进 attempts 了,再排一次就是把同一秒问两遍。
1484    pub fn note_instrument_send_failure(&mut self, code: i32) -> Vec<LogData> {
1485        let logs = self.note_query_send_failure("合约", code);
1486        if code != 0 {
1487            self.instrument_rejects += 1;
1488        }
1489        logs
1490    }
1491
1492    /// 0.9.0 状态读数(只读,不改任何状态)。
1493    #[must_use]
1494    pub fn status_view(&self) -> TdStatusView {
1495        TdStatusView {
1496            handle_up: self.connect_status,
1497            logged_in: self.login_status,
1498            login_failed: self.login_failed,
1499            auth_ok: self.auth_status,
1500            auth_failed: self.auth_failed,
1501            front_id: self.front_id,
1502            session_id: self.session_id,
1503            disconnects: self.disconnects,
1504            last_login_ok_ms: self.last_login_ok_ms,
1505            last_account_ms: self.last_account_ms,
1506            last_position_ms: self.last_position_ms,
1507            query_rejects: self.query_rejects,
1508            query_reject_streak: self.query_reject_streak,
1509            instrument_rejects: self.instrument_rejects,
1510            last_error: self.last_error.clone(),
1511        }
1512    }
1513
1514    /// 0.9.0 `{gw}.disconnect`:停这一侧会话(守卫复位,查询阶段回 `Idle`)。
1515    ///
1516    /// 不清的东西写明:断开计数、登录/回报「最近时刻」、被拒计数、持仓合并缓存、
1517    /// OrderSysID 映射 —— 它们是跨会话的历史事实与账本映射,清掉等于抹掉掉线证据、
1518    /// 并让在途撤单的号对不上。`auth_failed` 也不清(它归 `reconfigure` 管:
1519    /// 『连接』走 configure,那里才是换凭据的地方)。
1520    pub fn reset_session(&mut self) {
1521        self.connect_status = false;
1522        self.login_status = false;
1523        self.auth_status = false;
1524        self.phase = QueryPhase::Idle;
1525        // 合约这一路是**这一条会话**的意图:会话没了就作废,新会话的结算确认会重新起一轮
1526        // (留着就是把旧会话的第 3 次/旧警告带到新会话上,合约一条都不会问)。
1527        self.instrument_attempts = 0;
1528        self.instrument_warned = false;
1529        self.settlement_req_ms = None;
1530        // 同 reconfigure:闸的时间戳属于**上一条连接**,跨会话不清就会挡住新会话的第一发。
1531        self.last_query_ms = None;
1532        self.next_pos = false;
1533    }
1534
1535    /// 认证(原稿 779-792;auth_failed 守卫 781-782 静默不重发)。
1536    pub fn authenticate(&mut self) -> Vec<TdOut> {
1537        if self.auth_failed {
1538            return Vec::new();
1539        }
1540        vec![TdOut::ReqAuthenticate {
1541            user: self.cfg.user.clone(),
1542            broker: self.cfg.broker.clone(),
1543            auth_code: self.cfg.auth_code.clone(),
1544            app_id: self.cfg.app_id.clone(),
1545        }]
1546    }
1547
1548    /// 登录(原稿 794-806;login_failed 守卫 796-797 静默不重发)。
1549    pub fn login(&mut self) -> Vec<TdOut> {
1550        if self.login_failed {
1551            return Vec::new();
1552        }
1553        vec![TdOut::ReqLogin {
1554            user: self.cfg.user.clone(),
1555            password: self.cfg.password.clone(),
1556            broker: self.cfg.broker.clone(),
1557        }]
1558    }
1559
1560    /// 撤单(原稿 cancel_order 856-872;订单号三段解析失败 → log + 放行,
1561    /// D6:原稿 int() 会抛 ValueError,Rust 转错误日记 + 静默)。
1562    /// 调用方给的交易所不是已知期货交易所码时,用合约表补(`Contracts` 按裸代码建键)。
1563    ///
1564    /// 用途:开平折叠(平今只在 SHFE/INE 成立)与下发 `exchange_id`。合约表也没有时
1565    /// 原样交回 —— 不猜,让柜台照实报错。
1566    /// 出向交易所取有效值:`None` = **判不出来,必须拒单**。
1567    ///
1568    /// 三类输入:
1569    /// - 已知期货交易所码 → 原样用;
1570    /// - 退化值(`LOCAL` 是 `Exchange::default()`,`UNKNOWN` 是入向译不出时的占位)
1571    ///   → 查共享合约表补;
1572    /// - 表里也没有(或表里那条自己就是 `UNKNOWN`)→ `None`。
1573    ///
1574    /// **为什么不能像 0.11.14 那样"补不到就用传进来的那个"**:那样会把字面量
1575    /// `"LOCAL"` / `"UNKNOWN"` 当成交易所码发给柜台,而更糟的是
1576    /// [`map::offset_to`] 会对它走"非 SHFE/INE"分支把 `CLOSETODAY` 折成 `CLOSE`
1577    /// —— 用户要平今,实际平了昨。这不是"少一道校验",是**静默改变用户的下单
1578    /// 意图**,比直接拒绝危险得多。
1579    fn effective_exchange(&self, requested: Exchange, bare: &str) -> Option<Exchange> {
1580        if map::exchange_of(requested.as_str()).is_some() {
1581            return Some(requested);
1582        }
1583        let m = self.contracts.lock().unwrap();
1584        m.get(bare)
1585            .map(|c| c.exchange)
1586            // 合约表里也可能存着 `UNKNOWN`(那一行入向时就没判出交易所),
1587            // 它不是可用的出向取值。
1588            .filter(|e| *e != Exchange::UNKNOWN && *e != Exchange::LOCAL)
1589    }
1590
1591    pub fn cancel_order(&mut self, req: &CancelRequest) -> Vec<TdOut> {
1592        let Some(instrument_id) = map::instrument_id(&req.symbol) else {
1593            return vec![TdOut::Log(log_info(
1594                &self.gw,
1595                format!("撤单合约代码无法归一(空或交易所前缀未知):{}", req.symbol),
1596            ))];
1597        };
1598        let Some(exchange) = self.effective_exchange(req.exchange, &instrument_id) else {
1599            return vec![TdOut::Log(log_info(
1600                &self.gw,
1601                format!(
1602                    "该合约的交易所判不出来,已拒绝撤单(不猜交易所):{}(请求的交易所={})",
1603                    instrument_id,
1604                    req.exchange.as_str()
1605                ),
1606            ))];
1607        };
1608        match map::cancel_action_from(
1609            &req.orderid,
1610            &instrument_id,
1611            exchange,
1612            &self.cfg.broker,
1613            &self.cfg.user,
1614        ) {
1615            Some(action) => vec![TdOut::Cancel(action)],
1616            None => vec![TdOut::Log(log_info(
1617                &self.gw,
1618                format!("撤单请求委托号异常:{}", req.orderid),
1619            ))],
1620        }
1621    }
1622
1623    /// 订单成功发光后登记 sysid 报表(707,原稿 718)。
1624    fn remember_sysid(&mut self, sysid: &str, orderid: &str) {
1625        self.sysid_orderid
1626            .insert(sysid.to_string(), orderid.to_string());
1627    }
1628
1629    /// **周期查询的唯一协调点**(0.9.8 改成阶段机):每一拍发不发、发哪一条由这里一次决定。
1630    ///
1631    /// 三个闸,全是墙钟(与驱动的单调毫秒钟同钟同基准):
1632    /// ① 首发合约 —— 距**发出结算单确认请求** ≥ `INSTRUMENT_FIRST_WAIT_MS`;
1633    /// ② 重发合约 / 问满警告 —— 距上一发真发出去 ≥ `INSTRUMENT_RETRY_WAIT_MS`,同一个数
1634    ///    兼作第三发的回报窗口,窗口走完仍无合约才警告;
1635    /// ③ 资金 / 持仓轮询 —— 距上一发 ≥ `QUERY_MIN_INTERVAL_MS`,不满就这一拍不发、等下一拍。
1636    ///
1637    /// 阶段(用户定口径):`Idle` 什么都不发 → `Instrument` 问合约(最多 3 次)→ 任一合约行
1638    /// 到手 → `Rotating` 交替轮询;3 次问满仍无合约 → 警告一次 → `Halted` 什么都不再发。
1639    /// 闸在阶段之前:被挡的这一拍不推进任何计数(挡一格还推进计数,就是把节律改掉一格)。
1640    pub fn on_timer(&mut self) -> Vec<TdOut> {
1641        if matches!(self.phase, QueryPhase::Idle | QueryPhase::Halted) {
1642            return Vec::new();
1643        }
1644        let now = (self.now_ms)();
1645        let outs = if self.phase == QueryPhase::Instrument {
1646            self.instrument_beat(now)
1647        } else {
1648            self.rotation_beat(now)
1649        };
1650        // 盖章只认「这一拍真发了一条查询」:状态行与警告是日志,不算一发(算就是把同一拍记两次)。
1651        if outs.iter().any(|o| !matches!(o, TdOut::Log(_))) {
1652            self.last_query_ms = Some(now);
1653        }
1654        outs
1655    }
1656
1657    /// 首发闸:距「发出结算单确认请求」那一刻是否已满 1 秒。
1658    fn first_instrument_due(&self, now: u64) -> bool {
1659        match self.settlement_req_ms {
1660            // 没记下请求时刻(异常路径)→ 不因为缺一条证据把整条查询路卡死。
1661            None => true,
1662            Some(t) => now.saturating_sub(t) >= INSTRUMENT_FIRST_WAIT_MS,
1663        }
1664    }
1665
1666    /// 距上一发真发出去是否已满 `ms` 毫秒。这一条会话还没发过 = 直接算满(闸开闸)。
1667    fn since_last_reached(&self, now: u64, ms: u64) -> bool {
1668        match self.last_query_ms {
1669            None => true,
1670            Some(last) => now.saturating_sub(last) >= ms,
1671        }
1672    }
1673
1674    /// `Instrument` 阶段的这一拍。
1675    fn instrument_beat(&mut self, now: u64) -> Vec<TdOut> {
1676        if self.instrument_attempts == 0 {
1677            // 结算确认那一支没能当场问(还不足 1 秒)→ 这一拍补。
1678            if !self.first_instrument_due(now) {
1679                return Vec::new();
1680            }
1681            return self.ask_instrument();
1682        }
1683        if self.instrument_attempts < INSTRUMENT_MAX_ATTEMPTS {
1684            if !self.since_last_reached(now, INSTRUMENT_RETRY_WAIT_MS) {
1685                return Vec::new();
1686            }
1687            return self.ask_instrument();
1688        }
1689        // 问满:还要再等一个完整回报窗口才定案(第三发刚发出去就警告 = 误警告)。
1690        if !self.since_last_reached(now, INSTRUMENT_RETRY_WAIT_MS) {
1691            return Vec::new();
1692        }
1693        self.phase = QueryPhase::Halted;
1694        self.warn_no_contract()
1695    }
1696
1697    /// `Rotating` 阶段的这一拍:1 秒闸在前,不满就这一拍不问。
1698    fn rotation_beat(&mut self, now: u64) -> Vec<TdOut> {
1699        if !self.since_last_reached(now, QUERY_MIN_INTERVAL_MS) {
1700            return Vec::new();
1701        }
1702        self.rotate_account_position()
1703    }
1704
1705    /// 问一发合约:计数 +1,状态行一条,查询一条。盖章由 [`on_timer`](Self::on_timer) 统一做。
1706    fn ask_instrument(&mut self) -> Vec<TdOut> {
1707        self.instrument_attempts += 1;
1708        let n = self.instrument_attempts;
1709        vec![
1710            TdOut::Log(log_info(
1711                &self.gw,
1712                format!("{CHAIN_STATE_PREFIX}查询合约 第{n}次"),
1713            )),
1714            TdOut::ReqQryInstrument,
1715        ]
1716    }
1717
1718    /// 问满定案:警告一次(只一次,不每拍刷),停在 `Halted`。
1719    ///
1720    /// 0.9.8 用户裁定:**问满之后不再问资金与持仓** —— 没有合约,持仓回报映射不了、界面
1721    /// 持仓表也是空的、下单更不可能。恢复只有一条路:新会话(`reset_session` / `reconfigure`)
1722    /// 重新起一轮;或者迟到的合约行把阶段救回 `Rotating`(见 `RspQryInstrument`)。
1723    fn warn_no_contract(&mut self) -> Vec<TdOut> {
1724        if self.instrument_warned {
1725            return Vec::new();
1726        }
1727        self.instrument_warned = true;
1728        let msg = format!(
1729            "{CHAIN_STATE_PREFIX}合约 {} 次未获合约信息:本轮查询已停止(不再问资金与持仓)",
1730            INSTRUMENT_MAX_ATTEMPTS
1731        );
1732        self.last_error = Some(msg.clone());
1733        vec![
1734            TdOut::Log(log_warn(&self.gw, msg)),
1735            // 0.11.0 第3刀:同一条事实的结构化面(界面吃 kind 去重告警,不靠抓文本)
1736            TdOut::Status(crate::plugin::GatewayChange::ContractQueryStopped),
1737        ]
1738    }
1739
1740    /// 查询回报落账(0.11.12):按类清退避/推进退避。
1741    ///
1742    /// CTP 对同一类查询「上一条没回就拒下一条」(-2);时间闸只回答"距上次发出多久",
1743    /// 回答不了"上一条回来了没有",于是资金/持仓两类互相踩。分槽处理:
1744    /// 槽 0 = 资金,槽 1 = 持仓。
1745    fn settle_query(&mut self, was_position: bool, rejected: bool) {
1746        let slot = usize::from(was_position);
1747        if rejected {
1748            self.query_reject_streaks[slot] = self.query_reject_streaks[slot].saturating_add(1);
1749            let wait = Self::query_backoff_ms(self.query_reject_streaks[slot]);
1750            self.query_backoff_until[slot] = Some((self.now_ms)().saturating_add(wait));
1751        } else {
1752            self.query_reject_streaks[slot] = 0;
1753            self.query_backoff_until[slot] = None;
1754        }
1755    }
1756
1757    /// 连续被拒 n 次后的等待(1s→2s→4s→8s→15s 封顶)。
1758    fn query_backoff_ms(streak: u32) -> u64 {
1759        let shift = streak.saturating_sub(1).min(4);
1760        QUERY_BACKOFF_BASE_MS
1761            .saturating_mul(1u64 << shift)
1762            .min(QUERY_BACKOFF_MAX_MS)
1763    }
1764
1765    /// 资金 / 持仓交替(原稿 `query_functions` 列表轮换)。
1766    ///
1767    /// 0.11.12 退避:某类还在退避窗内就**跳过这一拍并让位给另一类**,不空转、
1768    /// 也不把另一类饿死(夜盘 7x24 环境实测:35 秒被拒 12 次)。
1769    fn rotate_account_position(&mut self) -> Vec<TdOut> {
1770        let now = (self.now_ms)();
1771        let slot = usize::from(self.next_pos);
1772        if let Some(until) = self.query_backoff_until[slot] {
1773            if now < until {
1774                // 让位:这一拍不发,下一拍轮到另一类。
1775                self.next_pos = !self.next_pos;
1776                return Vec::new();
1777            }
1778            self.query_backoff_until[slot] = None;
1779        }
1780        // 0.7.0(甲):「合约表为空就跳过持仓查询」的守卫拆除(原稿 881-882)——
1781        // 持仓回报也不再依赖合约(见 RspQryInvestorPosition)。0.9.8 起这一条只在
1782        // `Rotating` 阶段被走到,而进入该阶段的前提就是已有合约在册,守卫的意图由阶段
1783        // 保证;`Halted` 阶段根本不会走到这里。
1784        let out = if self.next_pos {
1785            TdOut::ReqQryPosition {
1786                broker: self.cfg.broker.clone(),
1787                user: self.cfg.user.clone(),
1788            }
1789        } else {
1790            TdOut::ReqQryAccount {
1791                broker: self.cfg.broker.clone(),
1792                user: self.cfg.user.clone(),
1793            }
1794        };
1795        self.next_pos = !self.next_pos;
1796        vec![out]
1797    }
1798
1799    /// 合约这一路的当前读数(0.9.5;测试用):已问几次、是否已了结、是否已警告。
1800    /// 「已了结」= 阶段已离开 `Instrument`(到手进 `Rotating`,问满进 `Halted`)。
1801    #[must_use]
1802    pub fn instrument_retry(&self) -> (u32, bool, bool) {
1803        (
1804            self.instrument_attempts,
1805            !matches!(self.phase, QueryPhase::Idle | QueryPhase::Instrument),
1806            self.instrument_warned,
1807        )
1808    }
1809
1810    /// 当前查询阶段(0.9.8;状态口与测试用)。
1811    #[must_use]
1812    pub fn query_phase(&self) -> QueryPhase {
1813        self.phase
1814    }
1815
1816    /// 步进(原型 CtpTdApi 各回调)。
1817    pub fn step(&mut self, ev: TdEvent) -> Vec<TdOut> {
1818        match ev {
1819            TdEvent::FrontConnected => {
1820                self.connect_status = true;
1821                let mut outs = vec![
1822                    TdOut::Log(log_info(&self.gw, "交易服务器连接成功")),
1823                    // 0.11.0 第3刀:0.9.8 的 `[链] 前置已连接` 日志行升格成状态变化事件
1824                    // (状态类文本从 eLog 搬走;业务文案「交易服务器连接成功」一条不动)。
1825                    TdOut::Status(crate::plugin::GatewayChange::TdConnected),
1826                ];
1827                // 原稿 448-451:auth_code 空 → 直登
1828                if self.cfg.auth_code.is_empty() {
1829                    outs.extend(self.login());
1830                } else {
1831                    outs.extend(self.authenticate());
1832                }
1833                outs
1834            }
1835            TdEvent::FrontDisconnected(reason) => {
1836                self.login_status = false;
1837                // 0.9.8 自查补的一条:前置断了 = 这一条会话连结算确认都没走到,阶段回 `Idle`。
1838                // 不回,`Rotating` 会在一具死会话上继续按秒白发资金/持仓查询:每一条都被传输层
1839                // 拒(没有会话 = -1),把 `query_rejects` / `query_reject_streak` 往上堆 ——
1840                // 而 ek-ui 的「查询断供」判据吃的正是这两个数。重连后 `RspSettlementInfoConfirm`
1841                // 自己会把阶段摆回 `Instrument`,所以这里回 `Idle` 不会把查询路锁死。
1842                self.phase = QueryPhase::Idle;
1843                self.disconnects += 1;
1844                self.last_error = Some(format!("交易服务器连接断开,原因{reason}"));
1845                vec![
1846                    TdOut::Log(log_info(
1847                        &self.gw,
1848                        format!("交易服务器连接断开,原因{reason}"),
1849                    )),
1850                    TdOut::Status(crate::plugin::GatewayChange::TdDisconnected),
1851                ]
1852            }
1853            TdEvent::RspAuthenticate { error } => match error {
1854                None => {
1855                    self.auth_status = true;
1856                    vec![
1857                        TdOut::Log(log_info(&self.gw, "交易服务器授权验证成功")),
1858                        // 0.11.0 第3刀:`[链] 授权通过` 日志行升格成状态变化事件
1859                        TdOut::Status(crate::plugin::GatewayChange::TdAuthOk),
1860                        TdOut::ReqLogin {
1861                            user: self.cfg.user.clone(),
1862                            password: self.cfg.password.clone(),
1863                            broker: self.cfg.broker.clone(),
1864                        },
1865                    ]
1866                }
1867                Some(e) => {
1868                    self.last_error = Some(map::error_text("交易服务器授权验证失败", &e));
1869                    let outs = vec![
1870                        TdOut::Log(log_error_with("交易服务器授权验证失败", &self.gw, &e)),
1871                        TdOut::Status(crate::plugin::GatewayChange::TdAuthFailed),
1872                    ];
1873                    if e.error_id == 63 {
1874                        self.auth_failed = true;
1875                    }
1876                    outs
1877                }
1878            },
1879            TdEvent::RspUserLogin {
1880                error,
1881                front_id,
1882                session_id,
1883            } => match error {
1884                None => {
1885                    self.front_id = front_id;
1886                    self.session_id = session_id;
1887                    self.login_status = true;
1888                    // 0.9.0:登录成功即清「最近一次登录失败」与陈旧错误 ——
1889                    // 状态口把 `td_login_failed` 读成「最近一次登录的成败」,
1890                    // 一次早先的失败不得在成功之后继续显形。
1891                    self.login_failed = false;
1892                    self.last_login_ok_ms = Some(crate::map::now_epoch_ms());
1893                    self.last_error = None;
1894                    // 0.9.8 首发闸的参照点 = **发出结算单确认请求的那一刻**(与 `last_query_ms`
1895                    // 同钟同基准,不得与状态口的墙钟互减)。结算确认回报到手时判它满没满 1 秒。
1896                    self.settlement_req_ms = Some((self.now_ms)());
1897                    vec![
1898                        TdOut::Log(log_info(&self.gw, "交易服务器登录成功")),
1899                        // 0.11.0 第3刀:`[链] 登录成功` 日志行升格成状态变化事件
1900                        TdOut::Status(crate::plugin::GatewayChange::TdLoginOk),
1901                        TdOut::ReqSettlementConfirm {
1902                            broker: self.cfg.broker.clone(),
1903                            user: self.cfg.user.clone(),
1904                        },
1905                    ]
1906                }
1907                Some(e) => {
1908                    self.login_failed = true;
1909                    self.last_error = Some(map::error_text("交易服务器登录失败", &e));
1910                    vec![
1911                        TdOut::Log(log_error_with("交易服务器登录失败", &self.gw, &e)),
1912                        TdOut::Status(crate::plugin::GatewayChange::TdLoginFailed),
1913                    ]
1914                }
1915            },
1916            TdEvent::RspSettlementInfoConfirm { error: _ } => {
1917                // 原稿 523:无条件写 log;查询与重发归 [`Self::on_timer`] 那一个协调点
1918                // 新一轮合约查询开始:计数复位(收尾汇总的基准必须是本轮)。
1919                self.contract_kept = 0;
1920                self.contract_filtered = 0;
1921                self.contract_unmapped = 0;
1922                self.contract_unmapped_codes.clear();
1923                // 0.9.8:这一拍把阶段摆成 `Instrument`(合约这一路重新起一轮、轮转从资金起),
1924                // 再按首发闸决定要不要**当场就问**合约:距「发出结算单请求」已满 1 秒 → 当场问
1925                // (柜台慢时不白等);不足 1 秒 → 这一拍什么都不发,交给协调点下一拍补。
1926                // 原稿在这里并存两条查询(合约 + 显式资金),驱动注释自己就写明那一发
1927                // 「大概率撞每秒 1 次流控被拒」;三条路共用一个每秒名额,并存不再可能。
1928                self.phase = QueryPhase::Instrument;
1929                self.instrument_attempts = 0;
1930                self.instrument_warned = false;
1931                self.next_pos = false;
1932                let now = (self.now_ms)();
1933                let mut outs = vec![
1934                    TdOut::Log(log_info(&self.gw, "结算信息确认成功")),
1935                    // 0.11.0 第3刀:`[链] 结算单已确认` 日志行升格成状态变化事件
1936                    TdOut::Status(crate::plugin::GatewayChange::SettlementConfirmed),
1937                ];
1938                if self.first_instrument_due(now) {
1939                    outs.extend(self.ask_instrument());
1940                    // 当场问也要盖章:不盖,重发的 5 秒窗口就从更早那一发起算,少等一截。
1941                    self.last_query_ms = Some(now);
1942                }
1943                outs
1944            }
1945            TdEvent::RspQryInstrument { last, row, error } => {
1946                let mut outs = Vec::new();
1947                // 0.9.1 状态面:与轮转两查询同式 —— 被拒计数记在 `row` 判定**之前**。
1948                // 被拒包正是 `row: None`,记在后面就永远记不到。
1949                // 合约查询也进同一条连续串(`query_reject_streak`):消费侧要的是
1950                // 「查询这条路连续被拒几次」,不分路;分路的账在 `instrument_rejects`。
1951                if let Some(e) = &error {
1952                    self.query_rejects += 1;
1953                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
1954                    self.instrument_rejects += 1;
1955                    self.last_error = Some(map::error_text("合约查询被拒", e));
1956                    outs.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
1957                } else if row.is_some() {
1958                    // 有数据行 = 这条路走通了:连续串清零、陈旧错误不再是「最近一次」。
1959                    // (合约查询的收尾包/错误包常带 row: None,那不算走通也不算被拒。)
1960                    self.query_reject_streak = 0;
1961                    self.last_error = None;
1962                }
1963                if let Some(inf) = row {
1964                    // 0.9.5 口径、0.9.8 落到阶段:**合约信息一到手就进轮询**(用户定口径
1965                    // 「已经有任何一个合约信息过来,则直接不再查询合约信息」)。被勾选丢弃的
1966                    // 行也算到手 —— 这一条判的是"柜台答没答",不是"这行要不要"。
1967                    // 0.9.8 另加一条:迟到的合约行(警告之后才到)也把 `Halted` 救回 `Rotating`
1968                    // —— 停的是"柜台没答",不是"柜台答晚了"。状态行只在阶段真的变化时落一条
1969                    // (合约回报是几百包,每包一条会把 eLog 冲成几百行)。
1970                    if matches!(self.phase, QueryPhase::Instrument | QueryPhase::Halted) {
1971                        self.phase = QueryPhase::Rotating;
1972                        // 0.11.0 第3刀:`[链] 合约已到手…` 日志行升格成状态变化事件
1973                        // (仍然只在阶段真的变化时发一条:合约回报是几百包)
1974                        outs.push(TdOut::Status(crate::plugin::GatewayChange::ContractArrived));
1975                    }
1976                    // D-PC-FILTER:先勾选、再 map,同一行只计一次(否则「未勾选」与
1977                    // 「未映射」会重复计数,收尾汇总就是假的)。
1978                    // 不在勾选内 → 不进表、不上抛:这是**唯一的合法过滤器**
1979                    // (配置面 = 用户决定,D-PRODUCT-GATE),不是网关在裁决数据。
1980                    if !self.accepted.iter().any(|c| c == &inf.product_class) {
1981                        self.contract_filtered += 1;
1982                    } else {
1983                        // 勾选内的行**必须上岸**(0.12.0 零丢弃):产品类/交易所翻译
1984                        // 不了就填 `UNKNOWN`,不再走 `Mapped::Skip` 丢行 —— 但仍计数,
1985                        // 汇总日志里要看得见"勾了但没映射"有多少(否则数字是假的)。
1986                        if map::asset_from(&inf.product_class).is_none() {
1987                            self.contract_unmapped += 1;
1988                            self.contract_unmapped_codes
1989                                .insert(inf.product_class.clone());
1990                        }
1991                        let c = map::contract_from(&inf, &self.gw);
1992                        // 勾选即收录 + 上抛(取代原稿「全表收录、仅期货上抛」的硬编码)
1993                        self.contracts
1994                            .lock()
1995                            .unwrap()
1996                            .insert(c.symbol.clone(), c.clone());
1997                        self.contract_kept += 1;
1998                        outs.push(TdOut::Contract(c));
1999                    }
2000                }
2001                if last {
2002                    if let Some(e) = &error {
2003                        // 收尾包带错误 = 这条查询被拒:**不得写「成功」,也不得上抛 ContractFinish**。
2004                        // ek-ui 的查询合约窗靠 ContractFinish 解锁勾选 —— 一条合约都没到手却解锁,
2005                        // 就是让用户对着空表勾选,下一笔单注定撞 `contract:unknown`。
2006                        outs.push(TdOut::Log(log_error_with("合约查询被拒", &self.gw, e)));
2007                    } else {
2008                        // 0.7.0(甲):「未齐缓存 + 收尾回放」整个退役 —— 委托/成交
2009                        // 回报自登录起直接上账,不再等收尾包;这里只剩汇总日志与
2010                        // 「合约流完成」(UI 查询合约窗靠它解锁勾选)。
2011                        let unmapped_codes = if self.contract_unmapped_codes.is_empty() {
2012                            String::new()
2013                        } else {
2014                            format!(
2015                                "(码:{})",
2016                                self.contract_unmapped_codes
2017                                    .iter()
2018                                    .cloned()
2019                                    .collect::<Vec<_>>()
2020                                    .join(",")
2021                            )
2022                        };
2023                        outs.push(TdOut::Log(log_info(
2024                        &self.gw,
2025                        format!(
2026                            "合约信息查询成功:收录 {} / 按勾选丢弃 {} / 未映射 {}{unmapped_codes}(收录产品类:{})",
2027                            self.contract_kept,
2028                            self.contract_filtered,
2029                            self.contract_unmapped,
2030                            self.accepted.join(" ")
2031                        ),
2032                    )));
2033                        outs.push(TdOut::ContractFinish(self.gw.clone()));
2034                    }
2035                }
2036                outs
2037            }
2038            TdEvent::RspQryInvestorPosition { last, row, error } => {
2039                self.settle_query(true, error.is_some());
2040                // 0.9.0 状态面:被拒计数必须记在 `row` 早退**之前** —— 被拒包正是
2041                // `row: None`,记在早退之后等于永远记不到(F2 的可见性就在这一步)。
2042                // 0.11.0 第3刀:同一拍把「被拒」显形成状态变化事件(早退前攒进 outs)。
2043                let mut outs: Vec<TdOut> = Vec::new();
2044                if let Some(e) = &error {
2045                    self.query_rejects += 1;
2046                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
2047                    self.last_error = Some(map::error_text("持仓查询被拒", e));
2048                    outs.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
2049                } else {
2050                    // 这一包没带错误 = 查询走通了(哪怕没数据):连续串清零、
2051                    // 陈旧错误不再是「最近一次」。总数保留在 `query_rejects` 里,
2052                    // 取证要看今天一共被拒过几次,看的是那个数。
2053                    self.query_reject_streak = 0;
2054                    self.last_error = None;
2055                    if row.is_some() {
2056                        self.last_position_ms = Some(crate::map::now_epoch_ms());
2057                    }
2058                }
2059                // 原稿 539-540:data 缺 → 直接 return(空持仓查询不发事件、不冲刷、
2060                // 连 PositionDone 都不发)。
2061                //
2062                // **0.12.0 修正(`D-LAST-3BRANCH`)**:这是原稿的怪癖,不是纪律。
2063                // `row: None` 有两种来路 ——
2064                //   (a) `last=false` = 查询中途的空包(理论上不出现,真出现就是异常);
2065                //   (b) `last=true`  = 查询的正常结尾,**且这一轮一行持仓都没有**
2066                //                      (空仓),或前面几包已经攒好(此时 row 为 None
2067                //                      只说明末包不含数据)。
2068                // 旧写法把 (b) 也早退了,后果不是"少一条日志",而是:
2069                // 消费侧的持仓列表**永远停在上一轮** —— 用户平掉最后一手之后,
2070                // 界面上的仓位不会消失。这是用户最直接的"数据不对"。
2071                //
2072                // 现在:末包无论有没有数据行都要冲刷 + 发 `PositionQueryDone`。
2073                // 真正的空包(`last=false` 且 `row=None`)仍然不产出任何东西 ——
2074                // 它不是"查询完成",发 done 会让消费侧把半截数据当全量。
2075                let mut tail = Vec::new();
2076                if let Some(row) = row {
2077                    // 0.7.0(甲):持仓回报不依赖合约在册。「合约表为空就跳过持仓
2078                    // 查询」的守卫(原稿 881-882)同拍拆除。
2079                    // **0.12.0**:交易所走兜底链(报文码 → 合约表 → UNKNOWN),
2080                    // 判不出也照上账;合约乘数仍是在册才富化(不在册 size=0 →
2081                    // 均价不更新,量/冻结/盈亏照记,下一轮轮转补上精确值)。
2082                    let exchange =
2083                        self.resolve_exchange(&row.exchange_id, &row.instrument_id, &mut tail);
2084                    let size = self
2085                        .contracts
2086                        .lock()
2087                        .unwrap()
2088                        .get(&row.instrument_id)
2089                        .map(|c| c.size)
2090                        .unwrap_or(0.0);
2091                    let key = format!("{}:{}", row.instrument_id, row.posi_direction);
2092                    // 0.12.0(净仓 = 0):方向解析不出来时**填 `NET` 上岸**,不再
2093                    // 让该行消失 —— `NET` 的语义本就是"净",量 0 照上账,界面
2094                    // 能看到"已平到零"这条轨迹;旧写法让行直接不见,等于网关
2095                    // 替用户判断"这条不该显示"。
2096                    let direction = map::position_direction(&row.posi_direction, row.position)
2097                        .unwrap_or(ek_data::constant::Direction::NET);
2098                    if !self.positions.contains_key(&key) {
2099                        let pos = map::new_position_data(
2100                            &row.instrument_id,
2101                            exchange,
2102                            Some(direction),
2103                            &self.gw,
2104                        )
2105                        .unwrap_or_default();
2106                        self.positions.insert(key.clone(), PositionAccum { pos });
2107                    }
2108                    let entry = self.positions.get_mut(&key).expect("just inserted");
2109                    map::accumulate_position(&row, &mut entry.pos, exchange, size);
2110                }
2111                if last {
2112                    let done = self
2113                        .positions
2114                        .values()
2115                        .map(|p| p.pos.clone())
2116                        .collect::<Vec<_>>();
2117                    for pos in done {
2118                        tail.push(TdOut::Position(pos));
2119                    }
2120                    tail.push(TdOut::PositionQueryDone(self.gw.clone()));
2121                    self.positions.clear();
2122                }
2123                outs.extend(tail);
2124                outs
2125            }
2126            TdEvent::RspQryTradingAccount { last, row, error } => {
2127                self.settle_query(false, error.is_some());
2128                // 0.9.0 状态面:同持仓一路 —— 被拒包正是 `row: None`,计数须在早退前记。
2129                // 0.11.0 第3刀:被拒的同一拍显形成状态变化事件(先记事实,再排数据)。
2130                let mut pre: Vec<TdOut> = Vec::new();
2131                if let Some(e) = &error {
2132                    self.query_rejects += 1;
2133                    self.query_reject_streak = self.query_reject_streak.saturating_add(1);
2134                    self.last_error = Some(map::error_text("资金查询被拒", e));
2135                    pre.push(TdOut::Status(crate::plugin::GatewayChange::QueryRejected));
2136                } else {
2137                    // 口径同持仓一路:没带错误 = 查询走通,连续串清零、陈旧错误退场。
2138                    self.query_reject_streak = 0;
2139                    self.last_error = None;
2140                    if row.is_some() {
2141                        self.last_account_ms = Some(crate::map::now_epoch_ms());
2142                    }
2143                }
2144                let mut outs = match row {
2145                    Some(ta) => {
2146                        let mut outs = vec![TdOut::Account(map::account_from(&ta, &self.gw))];
2147                        // 0.9.8:0.9.3 那条「未 arm 就自启轮询」的兜底**撤除** —— 新口径是
2148                        // 「合约阶段结束才开始轮询」,让一笔资金回报自己把轮询启动起来直接违反
2149                        // 它。新时序下不发查询就不会有资金回报,真触发即异常:资金照常上账,
2150                        // 但落一条 WARNING,让异常看得见而不是被兜底吃掉。轮询正常跑时
2151                        // (`Rotating`)这一支一条都不写,不刷屏。
2152                        if self.phase != QueryPhase::Rotating {
2153                            let msg = format!(
2154                                "{CHAIN_STATE_PREFIX}资金回报到手但当前阶段是「{}」:轮询不自启",
2155                                self.phase.label()
2156                            );
2157                            outs.push(TdOut::Log(log_warn(&self.gw, msg)));
2158                        }
2159                        outs
2160                    }
2161                    None => Vec::new(),
2162                };
2163                outs.splice(0..0, pre);
2164                // 0.9.5 结束事件(用户定口径:三条查询路**谁收到 last 谁就推自己的结束事件**;
2165                // 持仓有 ePositionDone、合约有 eContractFinish,资金这一路原来没有)。
2166                // **被拒的 last 不算** —— 与合约那一路同一条纪律(收尾包带错误不得上抛
2167                // ContractFinish):结束事件是给下游相位门开的闸,"被拒"不能开闸。
2168                if last && error.is_none() {
2169                    outs.push(TdOut::AccountQueryDone(self.gw.clone()));
2170                }
2171                outs
2172            }
2173            TdEvent::RspOrderInsert { error, req } => match (error, req) {
2174                (Some(e), Some(input)) => {
2175                    let mut outs = vec![TdOut::Log(log_error_with("交易委托失败", &self.gw, &e))];
2176                    let contract = {
2177                        let m = self.contracts.lock().unwrap();
2178                        m.get(&input.instrument_id).cloned()
2179                    };
2180                    // 边界契约(2026-10-30):拒单必须可见 —— 合约表未收录(产品类收窄/查询未收尾)时
2181                    // 用报文字段兜底造单,不再只留一行日志把拒单吞掉。
2182                    let (symbol, exchange, name) = match contract {
2183                        Some(c) => (c.symbol.clone(), c.exchange, c.name.clone()),
2184                        None => (
2185                            input.instrument_id.clone(),
2186                            map::exchange_of(&input.exchange_id).unwrap_or(ek_data::Exchange::SHFE),
2187                            String::new(),
2188                        ),
2189                    };
2190                    {
2191                        let orderid = self.local_orderid(&input.order_ref);
2192                        let direction = map::direction_from(&input.direction);
2193                        let offset =
2194                            map::offset_from(&input.comb_offset_flag).unwrap_or(Offset::NONE);
2195                        outs.push(TdOut::Order(OrderData {
2196                            gateway_name: self.gw.clone(),
2197                            symbol,
2198                            // 0.11.0 第5刀 R6:这一条本就拿着合约 → 名称当场填
2199                            name,
2200                            exchange,
2201                            orderid,
2202                            order_type: OrderType::LIMIT,
2203                            direction: Some(direction.unwrap_or(Direction::NET)),
2204                            offset,
2205                            price: input.limit_price,
2206                            volume: input.volume_total_original as f64,
2207                            status: Status::REJECTED,
2208                            // 拒单原因就在手上(`e`)—— 填进去。以前靠 `..Default` 兜底,
2209                            // 结果"为什么被拒"只活在日志里,账本上是空串。
2210                            status_msg: map::error_text("交易委托失败", &e),
2211                            // 本地意象单:没成交、没有成交价、还没到交易所。
2212                            traded: 0.0,
2213                            traded_price: 0.0,
2214                            // 报文字段建单时无柜台时间;`reference` 是本地附言,此处没有。
2215                            datetime: None,
2216                            reference: String::new(),
2217                        }));
2218                    }
2219                    outs
2220                }
2221                _ => Vec::new(),
2222            },
2223            TdEvent::RspOrderAction { error } => match error {
2224                Some(e) => vec![TdOut::Log(log_error_with("交易撤单失败", &self.gw, &e))],
2225                None => Vec::new(),
2226            },
2227            TdEvent::RtnOrder(of) => {
2228                // 0.7.0(甲):回报不依赖合约在册。**0.12.0**:交易所码走兜底链,
2229                // 判不出也不丢 —— 填 `UNKNOWN` 上账 + 计数显形(D-NO-DROP)。
2230                let mut outs = Vec::new();
2231                let exchange = self.resolve_exchange(&of.exchange_id, &of.instrument_id, &mut outs);
2232                // 字段级异常:只计数 + 一次性日志(带去重键,回报重放不会刷屏)。
2233                for (kind, code) in order_anomalies(&of) {
2234                    outs.extend(self.note_anomaly(kind, &of.instrument_id, &code));
2235                }
2236                let order = map::order_from(&of, exchange, &self.gw);
2237                let orderid = order.orderid.clone();
2238                self.remember_sysid(&of.order_sys_id, &orderid);
2239                outs.push(TdOut::Order(order));
2240                outs
2241            }
2242            TdEvent::RtnTrade(tf) => {
2243                // 0.7.0(甲):语义同 RtnOrder;成交的 orderid 仍靠 sysid_orderid
2244                // 表(重传次序实测先委托后成交)。
2245                // **0.12.0**:孤儿成交不再丢 —— 用 `SYS:<OrderSysID>` 占位上账
2246                // (丢一条成交 = 均价/盈亏永久错,这是最不能丢的一类)。
2247                let mut outs = Vec::new();
2248                let exchange = self.resolve_exchange(&tf.exchange_id, &tf.instrument_id, &mut outs);
2249                for (kind, code) in trade_anomalies(&tf) {
2250                    outs.extend(self.note_anomaly(kind, &tf.instrument_id, &code));
2251                }
2252                let orderid = match self.sysid_orderid.get(&tf.order_sys_id) {
2253                    Some(id) => id.clone(),
2254                    None => {
2255                        outs.extend(self.note_anomaly(
2256                            AnomalyKind::TradeSysid,
2257                            &tf.instrument_id,
2258                            &tf.order_sys_id,
2259                        ));
2260                        // D4 占位形:带 `SYS:` 前缀便于人眼与撤单口区分
2261                        // (撤单口按三段下划线解析,`SYS:` 开头的号会被判异常并拒 ——
2262                        //  这正是不许对孤儿成交撤单的显式表达)。
2263                        format!("SYS:{}", tf.order_sys_id)
2264                    }
2265                };
2266                outs.push(TdOut::Trade(map::trade_from(
2267                    &tf, &orderid, exchange, &self.gw,
2268                )));
2269                outs
2270            }
2271            // ---- 附加接口面(超出原稿):三路只进 eLog,不新增事件实体 ----
2272            TdEvent::RspQryInstrumentMarginRate { row, error, last } => {
2273                let mut outs = Vec::new();
2274                if let Some(e) = error {
2275                    outs.push(TdOut::Log(log_error_with("保证金率查询失败", &self.gw, &e)));
2276                }
2277                if let Some(r) = row {
2278                    if last {
2279                        outs.push(TdOut::Log(LogData {
2280                            gateway_name: self.gw.clone(),
2281                            msg: format!(
2282                                "保证金率(末包):{} {} 档 long {}/{} short {}/{} 相对交易所={}",
2283                                r.instrument_id,
2284                                r.hedge_flag,
2285                                r.long_margin_ratio_by_money,
2286                                r.long_margin_ratio_by_volume,
2287                                r.short_margin_ratio_by_money,
2288                                r.short_margin_ratio_by_volume,
2289                                r.is_relative,
2290                            ),
2291                            level: LEVEL_INFO,
2292                        }));
2293                    }
2294                } else if last {
2295                    outs.push(TdOut::Log(LogData {
2296                        gateway_name: self.gw.clone(),
2297                        msg: "保证金率查询:空结果(末包)".to_string(),
2298                        level: LEVEL_INFO,
2299                    }));
2300                }
2301                outs
2302            }
2303            TdEvent::RspQryInstrumentCommissionRate { row, error, last } => {
2304                let mut outs = Vec::new();
2305                if let Some(e) = error {
2306                    outs.push(TdOut::Log(log_error_with("手续费率查询失败", &self.gw, &e)));
2307                }
2308                if let Some(r) = row {
2309                    if last {
2310                        outs.push(TdOut::Log(LogData {
2311                            gateway_name: self.gw.clone(),
2312                            msg: format!(
2313                                "手续费率(末包):{} 开 {}/{} 平 {}/{} 平今 {}/{}",
2314                                r.instrument_id,
2315                                r.open_ratio_by_money,
2316                                r.open_ratio_by_volume,
2317                                r.close_ratio_by_money,
2318                                r.close_ratio_by_volume,
2319                                r.close_today_ratio_by_money,
2320                                r.close_today_ratio_by_volume,
2321                            ),
2322                            level: LEVEL_INFO,
2323                        }));
2324                    }
2325                } else if last {
2326                    outs.push(TdOut::Log(LogData {
2327                        gateway_name: self.gw.clone(),
2328                        msg: "手续费率查询:空结果(末包)".to_string(),
2329                        level: LEVEL_INFO,
2330                    }));
2331                }
2332                outs
2333            }
2334            TdEvent::RtnInstrumentStatus(s) => {
2335                // 键不含 enter_time:公流重放的会话快照同状态常带不同进入时刻,
2336                // 只在 (exchange, instrument, status, segment) 变化时显形一次。
2337                let key = format!(
2338                    "{}|{}|{}|{}",
2339                    s.exchange_id, s.instrument_id, s.instrument_status, s.trading_segment_sn
2340                );
2341                if !self.status_seen.insert(key) {
2342                    return Vec::new();
2343                }
2344                vec![TdOut::Log(LogData {
2345                    gateway_name: self.gw.clone(),
2346                    msg: format!(
2347                        "交易状态:{}/{} status={} 进入 @{} (原因 {})",
2348                        s.exchange_id,
2349                        s.instrument_id,
2350                        s.instrument_status,
2351                        s.enter_time,
2352                        s.enter_reason
2353                    ),
2354                    level: LEVEL_INFO,
2355                })]
2356            }
2357        }
2358    }
2359}
2360
2361/// 发单结果(en_orderid = 成功路径;error 记拒绝 reason)。
2362pub struct SendOutcome {
2363    /// 成功时的 en_orderid(全空 = 原稿返回 `""` 的形态)
2364    pub en_orderid: String,
2365    /// 拒绝时的 log(原稿 write_log 后返回 "")
2366    pub error: Option<LogData>,
2367    /// 插单前序(发起连接/认证/登录)动作
2368    pub outs: Vec<TdOut>,
2369}
2370
2371impl SendOutcome {
2372    fn rejected(log: LogData) -> Self {
2373        Self {
2374            en_orderid: String::new(),
2375            error: Some(log),
2376            outs: Vec::new(),
2377        }
2378    }
2379
2380    fn placed(outs: Vec<TdOut>, en_orderid: String) -> Self {
2381        Self {
2382            en_orderid,
2383            error: None,
2384            outs,
2385        }
2386    }
2387}