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}