Skip to main content

ek_ctp/
plugin.rs

1//! # CtpPlugin —— ek-core 宿主注册的 CTP 网关插件。
2//!
3//! 谁发谁收:
4//! - **发**:驱动输出 → `ctx.put` 上总线(纯生产者:**裸线键** `eTick.`/`eContract.`/
5//!   `eOrder.`/`eTrade.`/`ePosition.`/`eAccount.`/
6//!   `ePositionDone.`/`eLog`;0.4.0 D-EMIT 起裸键,网关身份归载荷 `gateway_name`;
7//!   线值与 ek-oms 的 wire 模块同表同值)。
8//! - **收**:`eTimer`(ek-event 秒针,2s 轮转驱动 + 行情日期刷新)与自身寻址
9//!   `eReq.<名>`(订阅/发单/撤单三项服务,对齐 oms 的 ask 面形制)。
10//!
11//! 传输层(`CtpTransport`)是 native 桥的接缝:默认构建无传输(可测纯逻辑与
12//! 请求面),native 特性(0.2.0)以官方 SDK 二进制实现该接口。
13//!
14//! 约定:
15//! - 原稿 `CtpGateway.count`(224-233 的 2 秒 gate)在此为插件内计数器;
16//! - `md.update_date` 与查询轮转同 gate(共节拍);
17//! - `EVENT_POSITION_QUERY_DONE` 落线值 `ePositionDone.<gw>`(与 ek-oms 相位门同表)。
18
19use crate::driver::{self, MdDriver, MdEvent, MdOut, TdDriver, TdEvent, TdOut};
20use crate::wire::InputOrder;
21use ek_core::context::PluginContext;
22use ek_core::envelope::{Envelope, ReplyErr, e_req_type};
23use ek_core::plugin::Plugin;
24use ek_core::section::{ApplyReport, Configurable, Field, SectionMap};
25use ek_core::service::ServiceDesc;
26use ek_data::cancel_request::CancelRequest;
27use ek_data::contract::ContractData;
28use ek_data::log::LogData;
29// 只被 native 面的 `transport_log` 使用(默认构建没有它,故按特性引入)。
30#[cfg(feature = "native")]
31use ek_data::log::LEVEL_ERROR;
32// 0.9.0 断开口落一条 INFO(默认构建也要能编译,故不挂特性)。
33use ek_data::log::LEVEL_INFO;
34use ek_data::order_request::OrderRequest;
35use ek_event::Event;
36use std::borrow::Cow;
37use std::sync::atomic::{AtomicU32, Ordering};
38use std::sync::{Arc, Mutex, OnceLock};
39
40// ================ 总线线值(注意:与 ek-oms 的 wire 模块同表同值) ================
41
42const W_TICK: &str = "eTick.";
43const W_ORDER: &str = "eOrder.";
44const W_TRADE: &str = "eTrade.";
45const W_POSITION: &str = "ePosition.";
46const W_ACCOUNT: &str = "eAccount.";
47const W_CONTRACT: &str = "eContract.";
48
49/// 合约流分批上限(0.11.0 第4刀 R5):**每 2000 行发一批**,收尾发尾巴。
50///
51/// 为什么不是"整批一次发":那会把「查询流渐进上屏」这个用户可见行为改掉(合约窗
52/// 从"一段段长出来"变成"憋 8 秒一次性出现")。分批保住了渐进显形,同时把一次
53/// 1.5 万行的洪峰切成 8 帧(每帧 2000 行),消费方邮箱里的帧数降两个数量级。
54///
55/// 线名不变(`eContract.`),载荷**新增批形**:`Vec<ContractData>`(批)。
56/// **单条形 `ContractData` 继续受理**(兼容:测试注入与旧消费方),消费方两条都吃。
57pub const CONTRACT_BATCH_ROWS: usize = 2000;
58/// 原稿 EVENT_POSITION_QUERY_DONE(event.py:17):**裸键,无 gw 后缀**,gw 放载荷
59/// ——ek-oms 侧以该裸键精确订阅(CtpPlugin emit 面),此处保持一字面。
60const W_POS_DONE: &str = "ePositionDone.";
61/// 资金查询完成(0.9.5):三条查询路谁收到 last 谁就推自己的结束事件,资金这一路原来没有。
62const W_ACCOUNT_DONE: &str = "eAccountDone.";
63/// 合约流完成线(0.10.5;**全局线,无 gw 后缀**,载荷=网关名以便多源辨源,D-CFIN;
64/// 0.10.5 起导出单源常量 —— ui/station 等消费方一律改引,禁再造裸字面)。
65pub const EVENT_CONTRACT_FINISH: &str = "eContractFinish";
66/// 网关通用状态面后缀(0.10.5 单源导出;**`{gw}` 参数化面** —— 消费方按
67/// skill §3 全名公式组 `format!("{gw}.{SVC_STATUS}")`,分隔符是语法非事实)。
68pub const SVC_STATUS: &str = "status";
69/// 日志线单源(ek-data::event::EVENT_LOG = "eLog",不再本地复定义)。
70use ek_data::event::EVENT_LOG as W_LOG;
71/// ek-event 秒针广播键(引用共享常量,不再重复定义字面值)。
72/// 由 [ek_event::EVENT_TIMER] 单源提供。
73pub use ek_event::EVENT_TIMER as ROUTE_TIMER;
74
75// ================ 服务请求/回包契约(ask 载荷类型) ================
76
77/// 订阅行情请求。
78#[derive(Debug, Clone)]
79pub struct SubscribeReq {
80    /// 合约代码symbols(逐笔)
81    pub symbols: Vec<String>,
82}
83
84/// 订阅请求回包。
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SubscribeReply {
87    /// 是否受理(true = 已登记/已发起)
88    pub ok: bool,
89    /// 说明
90    pub msg: String,
91}
92
93/// 发单请求。
94#[derive(Debug, Clone)]
95pub struct SendOrderReq {
96    /// 下单请求
97    pub req: OrderRequest,
98}
99
100/// 发单回包。
101#[derive(Debug, Clone, PartialEq)]
102pub struct SendOrderReply {
103    /// 是否受理(原稿 `""` 面 → false)
104    pub ok: bool,
105    /// 成功 = `"{gw}.{front}_{session}_{ref}"`(OrderData.en_orderid 同式)
106    pub en_orderid: String,
107    /// 拒绝时说明
108    pub msg: String,
109}
110
111/// 撤单请求。
112#[derive(Debug, Clone)]
113pub struct CancelOrderReq {
114    /// 撤单请求
115    pub req: CancelRequest,
116}
117
118/// 撤单回包。
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CancelOrderReply {
121    /// 是否受理(true = 命令已交付)
122    pub ok: bool,
123    /// 说明
124    pub msg: String,
125}
126
127/// 费率/保证金率查询请求(附加接口面,超出原稿)。
128///
129/// `kind`: "margin" = 保证金率(hedge 过滤参与查询);"commission" = 手续费率
130/// (无 hedge 过滤)。查询结果经 TD 回调 → 驱动 eLog 落线(不新增事件实体)。
131#[derive(Debug, Clone)]
132pub struct QueryRateReq {
133    /// 查询类型("margin" / "commission")
134    pub kind: String,
135    /// 合约代码
136    pub instrument_id: String,
137    /// 投机套保档(仅 margin 查询用;'1'投机)
138    pub hedge_flag: String,
139    /// 交易所代码(过滤;可空)
140    pub exchange_id: String,
141}
142
143/// 费率查询回包(是否已交付 + RT 返回码文本)。
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct QueryRateReply {
146    /// 是否受理(true = 命令已交付 native)
147    pub ok: bool,
148    /// 说明(交付失败时含错误代码)
149    pub msg: String,
150}
151
152/// 网关配置请求(**插件自带配置面**,2026-10-17 定盘:配置归插件自身,
153/// 界面/宿主手工输入经 ask 进来;插件永不读配置文件)。
154///
155/// 语义 = 更新账号/前置 + 复位连接守卫 + 立即发起连接(先 TD 后 MD,与
156/// `connect` 同式)。`md_user`/`md_password` 留空回落 `td_user`/`td_password`
157/// (SimNow 同账号常态,界面少填两格)。
158///
159/// Debug 手工掩码(0.10.0,BD-CFG-18):`td_password`/`md_password` 永远
160/// `****` —— 0.9.x 这一路派生 `Debug`,日志/panic 报文可能带出明文口令。
161#[derive(Clone, Default)]
162pub struct ConfigureReq {
163    /// 经纪公司代码
164    pub broker_id: String,
165    /// 交易前置(空 = 不连 TD;与 md_addr 至少一个非空)
166    pub td_addr: String,
167    /// 交易用户
168    pub td_user: String,
169    /// 交易密码
170    pub td_password: String,
171    /// 授权编码(空 = 直接登录)
172    pub td_auth_code: String,
173    /// 产品名称(AppID)
174    pub td_app_id: String,
175    /// 行情前置(空 = 不连 MD)
176    pub md_addr: String,
177    /// 行情用户(空回落 td_user)
178    pub md_user: String,
179    /// 行情密码(空回落 td_password)
180    pub md_password: String,
181    /// 收录的产品类白名单(CTP 单字符码,如 `["1"]`;空 = 回落默认「期货」)。
182    /// 界面勾选面:期货 `'1'` / 期权 `'2'`(现货期权 `'6'` 可配但不在默认勾选里)。
183    pub product_classes: Vec<String>,
184}
185
186impl std::fmt::Debug for ConfigureReq {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("ConfigureReq")
189            .field("broker_id", &self.broker_id)
190            .field("td_addr", &self.td_addr)
191            // 秘面两键永远掩码(BD-CFG-18)。
192            .field("td_password", &"****")
193            .field("td_auth_code", &self.td_auth_code)
194            .field("td_app_id", &self.td_app_id)
195            .field("md_addr", &self.md_addr)
196            .field("md_user", &self.md_user)
197            .field("md_password", &"****")
198            .field("product_classes", &self.product_classes)
199            .finish()
200    }
201}
202
203/// 配置回包(ok = 已应用并发起连接;false = 校验未过,msg 报字段)。
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct ConfigureReply {
206    /// 是否受理
207    pub ok: bool,
208    /// 说明
209    pub msg: String,
210    /// 实际生效的产品类白名单(已归一化;空请求回落默认「期货」`'1'`)。
211    /// 校验未过时回带**当前仍在生效**的那一份 —— 不是空表:空表会让界面误清空勾选,
212    /// 而网关其实还跑在旧勾选上。
213    pub product_classes: Vec<String>,
214}
215
216/// 状态查询请求(0.9.0 `{gw}.status`;无参数,单位结构体只为契约类型名占位)。
217#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub struct StatusReq;
219
220/// 网关状态读数(0.9.0 新增;`{gw}.status` 的返回体 = 界面状态机的唯一事实源)。
221///
222/// 口径边界(消费侧文案必须照这个说,不得越界):
223/// - `*_handle_up` = API 句柄已建并 registerFront,**不等于在线**(掉线时这一位仍为真);
224/// - `*_logged_in` = 最近一次登录成功且未断开 —— 「在线/掉线」的唯一硬证据;
225/// - 「查询断供」= `td_query_reject_streak` 达阈 **或** 距 `td_last_account_ms` 超时;
226///   阈值归消费侧(ek-ui)配置,网关只给原料不裁决(它不知道界面多久刷一次)。
227///   0.9.1 起合约查询被拒也进同一条连续串(消费侧要的是「查询这条路连续被拒几次」,
228///   不分路);要分路看 `td_instrument_rejects` 这个分项。
229/// - 计数与「最近时刻」跨重连保留:掉过几次线是历史事实,不因重连清零;
230/// - 从未发生过 = `None`(界面须显示「无证据」,不得显示 0 秒前)。
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
232pub struct GatewayStatus {
233    /// 网关注册名(派生名 `ctp_<broker>_<账号>`)
234    pub gateway: String,
235    /// 读数时刻(纪元毫秒;消费侧据此算「距最后一次回报多久」)
236    pub sampled_ms: i64,
237    // ---- 交易侧 ----
238    /// TD 句柄已建(不等于在线)
239    pub td_handle_up: bool,
240    /// TD 已登录
241    pub td_logged_in: bool,
242    /// 最近一次 TD 登录失败(成功后清零)
243    pub td_login_failed: bool,
244    /// TD 认证通过
245    pub td_auth_ok: bool,
246    /// TD 认证失败
247    pub td_auth_failed: bool,
248    /// 登录回报带的 FrontID(0 = 从未登录成功)
249    pub td_front_id: i64,
250    /// 登录回报带的 SessionID
251    pub td_session_id: i64,
252    /// TD 断开累计次数
253    pub td_disconnects: u32,
254    /// 最近一次 TD 登录成功时刻
255    pub td_last_login_ok_ms: Option<i64>,
256    /// 最近一次资金回报时刻
257    pub td_last_account_ms: Option<i64>,
258    /// 最近一次持仓回报时刻
259    pub td_last_position_ms: Option<i64>,
260    /// 查询被拒累计次数(合约/持仓/资金三条查询路,0.9.1 起含合约)
261    pub td_query_rejects: u32,
262    /// 连续被拒次数(成功回报清零)
263    pub td_query_reject_streak: u32,
264    /// 合约查询被拒累计次数(0.9.1;`td_query_rejects` 的分项,不是另一本账)
265    pub td_instrument_rejects: u32,
266    /// 最近一次 TD 错误回报
267    pub td_last_error: Option<String>,
268    // ---- 行情侧 ----
269    /// MD 句柄已建(不等于在线)
270    pub md_handle_up: bool,
271    /// MD 已登录
272    pub md_logged_in: bool,
273    /// MD 断开累计次数
274    pub md_disconnects: u32,
275    /// 最近一次 MD 登录成功时刻
276    pub md_last_login_ok_ms: Option<i64>,
277    /// 最近一条行情回报时刻
278    pub md_last_tick_ms: Option<i64>,
279    /// 订阅被拒累计次数
280    pub md_sub_rejects: u32,
281    /// 最近一次 MD 错误回报
282    pub md_last_error: Option<String>,
283    /// 已登记订阅数(断开不清订阅集)
284    pub md_subscribed: usize,
285}
286
287/// 状态变化种类(0.11.0 第3刀):网关状态机里**值得显形的那一刻**。
288///
289/// 口径(与 `GatewayStatus` 的分工钉死):
290/// - `GatewayStatus` = **状态面**(一直在着的读数:在不在、第几次、多久没回);
291/// - `GatewayChange` = **变化面**(那一刻发生了什么的类型名);
292/// - `GatewayStatusEvent` 把两者**一次到货**:变化发生时随附整份快照 → 消费者零回查。
293///
294/// 15 种是**穷举清单**,不是枚举可扩展的开放集:界面按 kind 去重告警、
295/// 判据按 kind 断言;加一种要同时动界面文案与判据,所以新增须走文档。
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum GatewayChange {
298    /// TD 前置已连接(会话还没起,下一步是认证/登录)
299    TdConnected,
300    /// TD 前置断开(会话作废,查询阶段回 Idle)
301    TdDisconnected,
302    /// TD 授权(认证)通过
303    TdAuthOk,
304    /// TD 授权失败
305    TdAuthFailed,
306    /// TD 登录成功(带 FrontID/SessionID)
307    TdLoginOk,
308    /// TD 登录失败
309    TdLoginFailed,
310    /// 结算单已确认(接下来问合约)
311    SettlementConfirmed,
312    /// 合约已到手(接下来轮询资金/持仓)
313    ContractArrived,
314    /// 合约查询连续未获 → 本轮查询停止(不再问资金与持仓)
315    ContractQueryStopped,
316    /// 查询被拒(资金/持仓/合约任一路;连续计数在状态面里)
317    QueryRejected,
318    /// MD 前置已连接
319    MdConnected,
320    /// MD 前置断开
321    MdDisconnected,
322    /// MD 登录成功(订阅集自会重推)
323    MdLoginOk,
324    /// MD 登录失败
325    MdLoginFailed,
326    /// MD 订阅被拒
327    SubscribeRejected,
328}
329
330impl GatewayChange {
331    /// 机读短名(界面去重键、判据断言都吃这个,不吃中文文案)。
332    pub fn kind(&self) -> &'static str {
333        match self {
334            Self::TdConnected => "td-connected",
335            Self::TdDisconnected => "td-disconnected",
336            Self::TdAuthOk => "td-auth-ok",
337            Self::TdAuthFailed => "td-auth-failed",
338            Self::TdLoginOk => "td-login-ok",
339            Self::TdLoginFailed => "td-login-failed",
340            Self::SettlementConfirmed => "settlement-confirmed",
341            Self::ContractArrived => "contract-arrived",
342            Self::ContractQueryStopped => "contract-query-stopped",
343            Self::QueryRejected => "query-rejected",
344            Self::MdConnected => "md-connected",
345            Self::MdDisconnected => "md-disconnected",
346            Self::MdLoginOk => "md-login-ok",
347            Self::MdLoginFailed => "md-login-failed",
348            Self::SubscribeRejected => "subscribe-rejected",
349        }
350    }
351
352    /// 人话文案(**生产者侧生成**,0.11.0 第3刀「二合一」):界面 `log_line(ev.describe())`
353    /// 逐字照打,不再自己拼句子 —— 人看的那行与机器读的结构同源同次到货。
354    ///
355    /// 前缀沿用 0.9.8 的 `[链] `(界面与既有判据的观感不断档);数字一律取自
356    /// 随附快照(同一份事实,不会出现"文案说第 3 次、状态列说第 2 次"这种分家)。
357    pub fn describe(&self, gw: &str, st: &GatewayStatus) -> String {
358        let p = driver::CHAIN_STATE_PREFIX;
359        match self {
360            Self::TdConnected => format!("{p}{gw} 交易前置已连接(下一步:认证)"),
361            Self::TdDisconnected => format!(
362                "{p}{gw} 交易前置断开(累计 {} 次;会话作废,查询阶段回 Idle)",
363                st.td_disconnects
364            ),
365            Self::TdAuthOk => format!("{p}{gw} 授权通过(下一步:登录)"),
366            Self::TdAuthFailed => format!(
367                "{p}{gw} 授权失败:{}",
368                st.td_last_error.as_deref().unwrap_or("无错误文本")
369            ),
370            Self::TdLoginOk => format!(
371                "{p}{gw} 登录成功(Front {} / Session {})",
372                st.td_front_id, st.td_session_id
373            ),
374            Self::TdLoginFailed => format!(
375                "{p}{gw} 登录失败:{}",
376                st.td_last_error.as_deref().unwrap_or("无错误文本")
377            ),
378            Self::SettlementConfirmed => {
379                format!("{p}{gw} 结算单已确认(开始问合约)")
380            }
381            Self::ContractArrived => {
382                format!("{p}{gw} 合约已到手,开始资金/持仓轮询")
383            }
384            Self::ContractQueryStopped => {
385                format!("{p}{gw} 合约查询问满:本轮查询已停止(不再问资金与持仓)")
386            }
387            Self::QueryRejected => format!(
388                "{p}{gw} 查询被拒(连续 {} 次 / 累计 {} 次):{}",
389                st.td_query_reject_streak,
390                st.td_query_rejects,
391                st.td_last_error.as_deref().unwrap_or("无错误文本")
392            ),
393            Self::MdConnected => format!("{p}{gw} 行情前置已连接(下一步:登录)"),
394            Self::MdDisconnected => format!("{p}{gw} 行情前置断开(累计 {} 次)", st.md_disconnects),
395            Self::MdLoginOk => format!(
396                "{p}{gw} 行情登录成功(已登记订阅 {} 项,自会重推)",
397                st.md_subscribed
398            ),
399            Self::MdLoginFailed => format!(
400                "{p}{gw} 行情登录失败:{}",
401                st.md_last_error.as_deref().unwrap_or("无错误文本")
402            ),
403            Self::SubscribeRejected => format!(
404                "{p}{gw} 行情订阅被拒(累计 {} 次):{}",
405                st.md_sub_rejects,
406                st.md_last_error.as_deref().unwrap_or("无错误文本")
407            ),
408        }
409    }
410}
411
412/// 状态变化事件(0.11.0 第3刀;线名 `eStatus.<派生网关名>`)。
413///
414/// 一条事件 = 变化 + 整份快照(**二合一**):界面一次到货做三件事
415/// (打文案 / 回写状态列 / 去重告警),**零回查**、**零轮询**。
416/// 发布纪律:SDK 回调 → `with_ingress` → `TdOut::Status`/`MdOut::Status` →
417/// 插件 worker `publish`;回调线程不许裸发(与数据面同一条规矩)。
418#[derive(Debug, Clone)]
419pub struct GatewayStatusEvent {
420    /// 网关注册名(与 `eStatus.` 后缀一致)
421    pub gateway: String,
422    /// 变化发生(= 快照采样)时刻,纪元毫秒
423    pub at_ms: i64,
424    /// 变化种类
425    pub change: GatewayChange,
426    /// 变化发生那一刻的整份状态快照
427    pub status: Box<GatewayStatus>,
428}
429
430impl GatewayStatusEvent {
431    /// 人话文案(逐字给界面打日志环用;文案由生产者生成)。
432    pub fn describe(&self) -> String {
433        self.change.describe(&self.gateway, &self.status)
434    }
435}
436
437/// 状态变化线的前缀(线名 = `eStatus.<派生网关名>`,与 `eReq.` 同式后缀型)。
438pub const EVENT_STATUS_PREFIX: &str = "eStatus.";
439
440/// 主动断开请求(0.9.0 `{gw}.disconnect`;裁定 1「断开 = 停会话,实例与订阅保留」)。
441#[derive(Debug, Clone, Default, PartialEq, Eq)]
442pub struct DisconnectReq;
443
444/// 断开回带(ok = 会话守卫已复位;msg 说明放掉了什么、保留了什么)。
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct DisconnectReply {
447    /// 是否执行
448    pub ok: bool,
449    /// 说明
450    pub msg: String,
451}
452
453// ================ 传输层接缝(native 桥实现/dev 以测试桩实现) ================
454
455/// 传输出口:驱动动作落到真实 CTP 会话(0.2.0 native 才有官方实现)。
456pub trait CtpTransport: Send + Sync {
457    /// 创建行情 API 并 registerFront/init。
458    fn md_connect(&self, addr: &str, flow_path: &str);
459    /// 行情登录。
460    fn md_login(&self, user: &str, password: &str, broker: &str);
461    /// 行情订阅。
462    fn md_subscribe(&self, symbols: &[String]);
463    /// 创建交易 API 并 registerFront/init。
464    fn td_connect(&self, addr: &str, flow_path: &str);
465    /// 认证。
466    fn td_auth(&self, user: &str, broker: &str, auth_code: &str, app_id: &str);
467    /// 登录。
468    fn td_login(&self, user: &str, password: &str, broker: &str);
469    /// 结算单确认。
470    fn td_settlement_confirm(&self, broker: &str, user: &str);
471    /// 合约查询;返回 `reqQryInstrument` 的 rt 返回码(0.9.3)。原稿 519-533 在这里
472    /// `while rc != 0 { sleep(1); 重发 }` —— 阻塞的是 CTP 的回调线程,而且每秒一发
473    /// 正好把轮转查询挤出台阶。现在**不排重发**:下一问的位置由 `TdDriver::on_timer` 那个
474    /// 协调点的周期给出(最多 3 次,任一合约信息到手即退出),被拒进同一条查询连续串。
475    fn td_qry_instrument(&self) -> i32;
476    /// 资金查询;返回 `reqQryTradingAccount` 的 rt 返回码(0.9.2)。原稿丢弃该返回码(876),
477    /// 于是发送侧被拒在计数与状态口里全隐形;非 0 现由插件喂进同一条查询连续串
478    /// (`TdDriver::note_query_send_failure`)。
479    fn td_qry_account(&self, broker: &str, user: &str) -> i32;
480    /// 持仓查询;返回 `reqQryInvestorPosition` 的 rt 返回码(0.9.2,同上,原稿 886)。
481    fn td_qry_position(&self, broker: &str, user: &str) -> i32;
482    /// 插单;返回 `reqOrderInsert` 的 rt 返回码(0 = 受理;非 0 = 原稿 846-848 的
483    /// 失败面:记「委托请求发送失败,错误代码:n」且**不回 SUBMITTING 单**)。
484    fn td_insert(&self, order: &InputOrder) -> i32;
485    /// 撤单;返回 `reqOrderAction` 的 rt 返回码(原稿不检,原样透传给 native 自定)。
486    fn td_cancel(&self, action: &crate::wire::InputOrderAction) -> i32;
487    /// 合约保证金率查询(附加接口面;返回 `ReqQryInstrumentMarginRate` rt 返回码)。
488    fn td_qry_margin_rate(&self, query: &crate::wire::QryInstrumentMarginRate) -> i32;
489    /// 合约手续费率查询(附加接口面;返回 `ReqQryInstrumentCommissionRate` rt 返回码)。
490    fn td_qry_commission_rate(&self, query: &crate::wire::QryInstrumentCommissionRate) -> i32;
491
492    // 交易状态没有查询面(SDK 6.7.7 无 ReqQryInstrumentStatus):该件纯被动,
493    // 由公有流推送经 TD 回调直达驱动,传输层不提供主动入口。
494
495    /// 关闭原生会话(宿主停插件时调用)。
496    ///
497    /// 默认空实现 = 无资源可放。官方实现里这一步是必需的:`Release()` 之后 SDK
498    /// **不再回调**,回调入口(ctx)才允许被放下 —— 顺序反了就是 use-after-free。
499    fn shutdown(&self) {}
500
501    /// 断开交易会话(释放句柄;默认空 = 纯逻辑面无会话可断)。
502    /// configure 强制重连面(0.5.0)用:旧登录态先断,新账号/密码才有显形机会。
503    fn td_release(&self) {}
504    /// 断开行情会话(释放句柄;默认空 = 纯逻辑面无会话可断)。
505    fn md_release(&self) {}
506}
507
508// ================ 网关配置 ================
509
510/// 网关配置(对齐原稿 connect setting 141-171;地址补前缀由使用方完成——
511/// 原稿在 connect 里对缺 "tcp://"/"ssl://" 的地址自动补 "tcp://",
512/// 本 crate 要求调用方先行规整,保持驱动数据纯净)。
513///
514/// Debug 手工掩码(0.10.0,BD-CFG-18):`td_password`/`md_password` 永远 `****`。
515#[derive(Clone, Default)]
516pub struct CtpConfig {
517    /// 网关/插件注册名(唯一;默认 "ctp")
518    pub gateway_name: String,
519    /// 经纪公司代码
520    pub broker_id: String,
521    /// 交易前置
522    pub td_addr: String,
523    /// 交易用户
524    pub td_user: String,
525    /// 交易密码
526    pub td_password: String,
527    /// 授权编码(空 = 直接登录,原稿 448-451)
528    pub td_auth_code: String,
529    /// 产品名称(AppID)
530    pub td_app_id: String,
531    /// 行情前置
532    pub md_addr: String,
533    /// 行情用户
534    pub md_user: String,
535    /// 行情密码
536    pub md_password: String,
537    /// 收录的产品类白名单(D-PC-FILTER;空 = 回落默认「期货」`'1'`)。
538    /// 查询侧不过滤,收录侧按它严格丢弃(不进共享表、不上抛 `eContract`)。
539    pub product_classes: crate::map::ProductClasses,
540}
541
542impl std::fmt::Debug for CtpConfig {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        f.debug_struct("CtpConfig")
545            .field("gateway_name", &self.gateway_name)
546            .field("broker_id", &self.broker_id)
547            .field("td_addr", &self.td_addr)
548            .field("td_user", &self.td_user)
549            // 秘面两键永远掩码(BD-CFG-18)。
550            .field("td_password", &"****")
551            .field("td_auth_code", &self.td_auth_code)
552            .field("td_app_id", &self.td_app_id)
553            .field("md_addr", &self.md_addr)
554            .field("md_user", &self.md_user)
555            .field("md_password", &"****")
556            .field("product_classes", &self.product_classes)
557            .finish()
558    }
559}
560
561impl CtpConfig {
562    /// 缺省名 "ctp" 的兜底。
563    pub fn gateway_name_or_default(&self) -> String {
564        if self.gateway_name.is_empty() {
565            "ctp".to_string()
566        } else {
567            self.gateway_name.clone()
568        }
569    }
570}
571
572/// 凭据档位快照行(0.10.0;`{gw}.credentials_state` ask 回包的元素;运维窗用)。
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct CredentialEntry {
575    /// 字段名(`td_password`/`md_password`)。
576    pub field: String,
577    /// 档位:`REFERENCED`(引用已登记)/`RESOLVED`(内存已解密,连接可用)/
578    /// `ABSENT`(待手配;连接面按原有空值守卫跳过)。
579    pub state: String,
580    /// 引用的秘键(空 = 无)。
581    pub ref_key: String,
582}
583
584/// 凭据状态请求(无载荷)。
585#[derive(Debug, Clone, Default, PartialEq, Eq)]
586pub struct CredentialStateReq;
587
588/// 多账号面的注册名派生(**界面唯一入口**;配置面不存网关名)。
589///
590/// 规则 `ctp_<broker>_<账号>`,例 `ctp_9999_278153`。为什么必须是这个形状:
591///
592/// - 宿主名字规则 `[A-Za-z][A-Za-z0-9_-]{0,31}`(ek-core `valid_plugin_name`)
593///   ——**不能含点号**,总长 ≤32;
594/// - `en_orderid = "{gw}.{front}_{session}_{ref}"` 的反解用 `split_once('.')`
595///   (ek-converter `holding.rs`)——gw 里带点号会把反解串成 `"ctp"`;
596/// - 派生而非手填:手填会重名/打错;同 broker+账号 重复添加时派生名相同,
597///   由宿主 `Duplicate` 直接挡下(不需要额外的账号注册表)。
598///
599/// 返回 `None` = 输入不可用(broker 或账号为空、含 `[0-9A-Za-z]` 之外的字符、
600/// 或派生名超 32 字符)。调用方(界面)须拒绝并说明,**不得静默替换**。
601///
602/// 0.10.4 边角收紧:字段**统一小写归一** —— 唯一键即 `ctp+broker+账号`
603/// 字符串恒等(大小写变体 = 同键 = 添加面重复拦截)。
604#[must_use]
605pub fn derived_gateway_name(broker_id: &str, account: &str) -> Option<String> {
606    let broker = sanitize_id_field(broker_id)?;
607    let account = sanitize_id_field(account)?;
608    let name = format!("ctp_{broker}_{account}");
609    // 宿主上限 32(`[A-Za-z][A-Za-z0-9_-]{0,31}`);超限界面提前拦。
610    if name.len() > 32 {
611        return None;
612    }
613    Some(name)
614}
615
616/// 账号位清洗:去首尾空白,只允许 `[0-9A-Za-z]`,**统一小写归一**
617/// (0.10.4 边角收紧:"I130"与"i130"是同一键 —— 变体 = 重复,不是两家;
618/// 与流文件目录/秘库/配置 ns 共用这一处大小写真值,别处不再各自改写)。
619/// 空或含非法字符 → `None`。
620fn sanitize_id_field(raw: &str) -> Option<String> {
621    let trimmed = raw.trim();
622    if trimmed.is_empty() || !trimmed.chars().all(|c| c.is_ascii_alphanumeric()) {
623        return None;
624    }
625    Some(trimmed.to_ascii_lowercase())
626}
627
628/// 注册名形状钉(0.10.4):命名实例只收**派生名形状** —— `ctp_<broker>_<账号>`,
629/// 两段非空且仅 `[0-9a-z]`,总长 ≤32,小写。宿主工厂面在收 svctl 命名请求时
630/// 以本口前置闸(任意名/变体名/超长名在源头拒,不再给出孤儿档案开口);
631/// 界面添加面走 `derived_gateway_name`,同样只产本形状。
632#[must_use]
633pub fn is_derived_gateway_name(name: &str) -> bool {
634    let Some(rest) = name.strip_prefix("ctp_") else {
635        return false;
636    };
637    if name.len() > 32 {
638        return false;
639    }
640    let ok = |s: &str| {
641        !s.is_empty()
642            && s.chars()
643                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
644    };
645    match rest.split_once('_') {
646        Some((broker, account)) => ok(broker) && ok(account),
647        None => false,
648    }
649}
650
651// ================ 插件 ================
652
653/// 锁克隆辅助(poison 恢复语义与插件既有 `unwrap_or_else(into_inner)` 同式)。
654fn lock_clone<T: Clone>(l: &std::sync::Mutex<T>) -> T {
655    match l.lock() {
656        Ok(g) => g.clone(),
657        Err(e) => e.into_inner().clone(),
658    }
659}
660
661/// CTP 网关插件(纯生产者 + 自服务)。
662pub struct CtpPlugin {
663    gw: String,
664    /// 配置托底段 ns(0.10.0:缺省实例 `ctp`,命名实例 `ctp.<网关注册名>`)。
665    ns: String,
666    /// 配置段已见最大 rev(dump 单调递增的底;BD-CFG-4 语义)。
667    cfg_rev: AtomicU32,
668    /// 已回灌的秘面引用行((字段名, 秘键)) —— 解密在 on_start(worker 面,
669    /// BD-CFG-16);apply_config 自身禁取秘。
670    sec_refs: Mutex<Vec<(String, String)>>,
671    /// 当前网关配置(connect 空地址守卫与费率查询的单一事实源)。
672    cfg: Mutex<CtpConfig>,
673    /// 经纪公司代码(费率查询的 broker_id;configure 可换)。
674    broker: Mutex<String>,
675    /// 投资者代码(费率查询的 investor_id;configure 可换)。
676    user: Mutex<String>,
677    md: Mutex<MdDriver>,
678    td: Mutex<TdDriver>,
679    /// 2s gate 计数器(原稿 self.count)
680    timer_count: Mutex<u32>,
681    /// 传输层(默认构建 None;native/测试注入)
682    trans: Option<Box<dyn CtpTransport>>,
683    ctx: OnceLock<PluginContext>,
684    /// 「未启用传输」提示只发一次
685    trans_warned: Mutex<bool>,
686    /// 直写托底兜底审计行只发一次(BD-CFG-11)
687    save_fallback: Mutex<bool>,
688    /// 合约流分批缓冲(0.11.0 第4刀 R5):攒到 `CONTRACT_BATCH_ROWS` 就发一批,
689    /// 收尾(`ContractFinish`)/会话复位时把不满一批的尾巴发掉。
690    contract_batch: Mutex<Vec<ContractData>>,
691}
692
693impl CtpPlugin {
694    /// 纯逻辑构造(native 尚未接传输)。
695    pub fn new(cfg: CtpConfig) -> Self {
696        let gw = cfg.gateway_name_or_default();
697        Self::with_parts(cfg, gw, None, None)
698    }
699
700    /// 注入传输层(native 桥或测试桩)。
701    pub fn with_transport(cfg: CtpConfig, trans: Box<dyn CtpTransport>) -> Self {
702        let gw = cfg.gateway_name_or_default();
703        Self::with_parts(cfg, gw, Some(trans), None)
704    }
705
706    /// 注入传输层 + 驱动单调钟(0.9.6,**测试接缝**)。
707    ///
708    /// 协调点的护栏按**真实时间**放行:真钟下测试里连续调 `on_timer` 之间 elapsed≈0,会被
709    /// 全挡。驱动级有 `TdDriver::with_clock`,但插件级钉(`plugin_full_bus_roundtrip` 那三条)
710    /// 走的是插件内部自建的驱动 —— 没有这个接缝,它们只能改成真 `sleep(1s)`:网关测试套从
711    /// 1 秒拖成 6 秒,而且变成时间依赖(以后会偶发红)。
712    pub fn with_transport_and_clock(
713        cfg: CtpConfig,
714        trans: Box<dyn CtpTransport>,
715        now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
716    ) -> Self {
717        let gw = cfg.gateway_name_or_default();
718        Self::with_parts(cfg, gw, Some(trans), Some(now_ms))
719    }
720
721    fn with_parts(
722        cfg: CtpConfig,
723        gw: String,
724        trans: Option<Box<dyn CtpTransport>>,
725        clock: Option<Arc<dyn Fn() -> u64 + Send + Sync>>,
726    ) -> Self {
727        let contracts = driver::contracts_empty();
728        let cfg_broker = cfg.broker_id.clone();
729        let cfg_user = cfg.td_user.clone();
730        let md_cfg = driver::MdConfig {
731            addr: cfg.md_addr.clone(),
732            user: cfg.md_user.clone(),
733            password: cfg.md_password.clone(),
734            broker: cfg.broker_id.clone(),
735        };
736        let td_cfg = driver::TdConfig {
737            addr: cfg.td_addr.clone(),
738            user: cfg.td_user.clone(),
739            password: cfg.td_password.clone(),
740            broker: cfg.broker_id.clone(),
741            auth_code: cfg.td_auth_code.clone(),
742            app_id: cfg.td_app_id.clone(),
743            product_classes: cfg.product_classes.clone(),
744        };
745        // 原稿 md.current_date 由 datetime.now() 起手;这里初值 = 启动时的上海日期
746        let md = Mutex::new(MdDriver::new(
747            &gw,
748            md_cfg,
749            contracts.clone(),
750            crate::map::shanghai_now_date(),
751        ));
752        let td = Mutex::new(match clock {
753            Some(f) => TdDriver::with_clock(&gw, td_cfg, contracts.clone(), f),
754            None => TdDriver::new(&gw, td_cfg, contracts.clone()),
755        });
756        let ns = if gw == "ctp" {
757            // 缺省实例 ns = "ctp"(与工厂名一致)。
758            "ctp".to_owned()
759        } else {
760            // 命名实例 ns = `ctp.<网关注册名>`(多账号天然分档)。
761            format!("ctp.{gw}")
762        };
763        Self {
764            gw,
765            ns,
766            cfg_rev: AtomicU32::new(0),
767            sec_refs: Mutex::new(Vec::new()),
768            cfg: Mutex::new(cfg),
769            broker: Mutex::new(cfg_broker),
770            user: Mutex::new(cfg_user),
771            md,
772            td,
773            timer_count: Mutex::new(0),
774            trans,
775            ctx: OnceLock::new(),
776            trans_warned: Mutex::new(false),
777            save_fallback: Mutex::new(false),
778            contract_batch: Mutex::new(Vec::with_capacity(CONTRACT_BATCH_ROWS)),
779        }
780    }
781
782    /// native 构造:装配官方 SDK 传输层并完成回调接线(见 `native::transport` 文档)。
783    ///
784    /// 两相接线:① 先建回调入口 + 传输层(传输层持有 `Arc<Ingress>`);② 插件建好后
785    /// 回填 `Weak<CtpPlugin>` —— 必须用弱引用断环,否则 插件 ↔ 传输层 互相持有,
786    /// 两者永不析构(回调也不会停)。
787    ///
788    /// `flow_root` = CTP 流文件落盘根目录(D-FLOWPATH)。构造本身**不碰磁盘**:
789    /// 目录在真正连接时才按 `<flow_root>/<网关名小写>/{Md,Td}` 创建。
790    #[cfg(feature = "native")]
791    pub fn with_native(cfg: CtpConfig, flow_root: impl Into<std::path::PathBuf>) -> Arc<Self> {
792        let (trans, ingress) = crate::native::transport::native_parts(flow_root);
793        let plugin = Arc::new(Self::with_transport(cfg, trans));
794        crate::native::transport::attach_plugin(&ingress, &plugin);
795        plugin
796    }
797
798    /// 连接(原稿 `CtpGateway.connect` 162-190:先 TD 后 MD;`init_query` 只是挂秒针)。
799    ///
800    /// **连接触发点住在本插件里**(不放到核心/公共层,每个插件自持自己的连接):
801    /// 宿主 `start()` 会经 [`Plugin::on_start`] 调它一次;要显式控制(先配后连、
802    /// 断线后重连)也可以直接调。幂等由驱动守卫裁决 —— 原稿 MD 重复调用静默,
803    /// TD 重复调用转认证(776-777),两者都在 `MdDriver::connect`/`TdDriver::connect`。
804    ///
805    /// 原稿末尾的 `init_query` 只是把查询轮转注册到 `EVENT_TIMER`(237-240);
806    /// 本 crate 的对应面是宿主 eTimer → [`CtpPlugin::on_timer`],这里无需动作。
807    pub fn connect(&self) {
808        // 空地址守卫(0.3.0):td/md 前置都空 → 不瞎连(on_start 空配置形态),
809        // 等 `{gw}.configure` 手工注入后再发起(插件自带配置面,定盘口径)。
810        let (td_empty, md_empty) = {
811            let c = self.cfg.lock().unwrap();
812            (c.td_addr.is_empty(), c.md_addr.is_empty())
813        };
814        if td_empty && md_empty {
815            self.put_log(LogData {
816                gateway_name: self.gw.clone(),
817                msg: "未配置前置地址,跳过连接(等待 configure)".to_string(),
818                level: 20,
819            });
820            return;
821        }
822        // 先 TD 后 MD:与原稿 connect 的调用次序一致。
823        // 锁在各自语句结束时释放 —— emit 可能落到会阻塞的原生调用(如合约查询的
824        // 流控重发),绝不能在持驱动锁时做。
825        let td_outs = if td_empty {
826            Vec::new()
827        } else {
828            self.td.lock().unwrap().connect()
829        };
830        let md_outs = if md_empty {
831            Vec::new()
832        } else {
833            self.md.lock().unwrap().connect()
834        };
835        for o in td_outs {
836            self.emit_td(o);
837        }
838        for o in md_outs {
839            self.emit_md(o);
840        }
841    }
842
843    /// 传输层级失败上报:native 桥在回调面之外没有别的出口。
844    ///
845    /// 只用在本插件/原生层自己的故障上(建不出 API、建不出流目录)。原稿没有对应面
846    /// ——那里失败会直接抛异常,进程级可见;Rust 侧不能抛,所以落一条 ERROR 日志。
847    /// **请求返回码不在此列**:原稿除下单外一律丢弃返回码,那些日志会是原稿没有的噪音。
848    #[cfg(feature = "native")]
849    pub(crate) fn transport_log(&self, msg: String) {
850        self.put_log(LogData {
851            gateway_name: self.gw.clone(),
852            msg,
853            level: LEVEL_ERROR,
854        });
855    }
856
857    /// 原生适配入口(native 桥的回调直接调这四个):MD 回报步进 + 总线上抛。
858    pub fn on_md_event(&self, ev: MdEvent) {
859        let outs = self.md.lock().unwrap().step(ev);
860        for o in outs {
861            self.emit_md(o);
862        }
863    }
864
865    /// 原生适配入口(native 桥的回调直接调这四个):TD 回报步进 + 总线上抛。
866    pub fn on_td_event(&self, ev: TdEvent) {
867        let outs = self.td.lock().unwrap().step(ev);
868        for o in outs {
869            self.emit_td(o);
870        }
871    }
872
873    /// eTimer 一拍(原稿 gateway.process_timer_event)。
874    ///
875    /// 0.9.4 分工:**每一拍都问驱动** —— [`TdDriver::on_timer`] 是三条查询路(合约重发 /
876    /// 资金轮 / 持仓轮)的唯一协调点,它自己按拍决定这一拍发哪一条、最多发一条。
877    /// 插件这里保留原稿的 2 秒 gate 只给行情日期刷新(`md.update_date` 走的是另一条
878    /// 连接,不占交易前置的查询名额)。
879    pub fn on_timer(&self) {
880        let outs = self.td.lock().unwrap().on_timer();
881        for o in outs {
882            self.emit_td(o);
883        }
884        let gate_pass = {
885            let mut c = self.timer_count.lock().unwrap();
886            *c += 1;
887            if *c < 2 {
888                false
889            } else {
890                *c = 0;
891                true
892            }
893        };
894        if !gate_pass {
895            return;
896        }
897        // 原稿 231-235 次序:先查询,再 md_api.update_date
898        let date = crate::map::shanghai_now_date();
899        self.md.lock().unwrap().update_date(date);
900    }
901
902    fn emit_md(&self, o: MdOut) {
903        match o {
904            MdOut::Log(l) => self.put_log(l),
905            // 0.11.0 第3刀:状态变化 → eStatus.<gw>(worker 侧 publish,回调线程不裸发)
906            MdOut::Status(ch) => self.publish_status(ch),
907            MdOut::Tick(t) => self.put_data(W_TICK, t),
908            MdOut::ReqConnect { addr, flow_path } => {
909                if let Some(t) = &self.trans {
910                    t.md_connect(&addr, &flow_path);
911                } else {
912                    self.warn_no_transport();
913                }
914            }
915            MdOut::ReqLogin {
916                user,
917                password,
918                broker,
919            } => {
920                if let Some(t) = &self.trans {
921                    t.md_login(&user, &password, &broker);
922                } else {
923                    self.warn_no_transport();
924                }
925            }
926            MdOut::ReqSubscribe(symbols) => {
927                if let Some(t) = &self.trans {
928                    t.md_subscribe(&symbols);
929                } else {
930                    self.warn_no_transport();
931                }
932            }
933        }
934    }
935
936    fn emit_td(&self, o: TdOut) {
937        match o {
938            TdOut::Log(l) => self.put_log(l),
939            // 0.11.0 第3刀:状态变化 → eStatus.<gw>(随附整份状态快照,消费者零回查)
940            TdOut::Status(ch) => self.publish_status(ch),
941            TdOut::Contract(c) => self.emit_contract(c),
942            TdOut::Order(o) => self.publish_enriched(W_ORDER, o),
943            TdOut::Trade(t) => self.publish_enriched(W_TRADE, t),
944            TdOut::Position(p) => self.publish_enriched(W_POSITION, p),
945            TdOut::Account(a) => self.put_data(W_ACCOUNT, a),
946            TdOut::PositionQueryDone(gw) => {
947                // 原稿 593:on_event(EVENT_POSITION_QUERY_DONE, self.gateway_name)
948                // → 裸键 "ePositionDone." + 载荷 gw(ek-oms 以此精确订阅)
949                if let Some(ctx) = self.ctx.get() {
950                    let _ = ctx.put(Event::with_data(W_POS_DONE, Arc::new(gw)));
951                }
952            }
953            TdOut::AccountQueryDone(gw) => {
954                // 0.9.5:与持仓那一路同式 —— 裸键 "eAccountDone." + 载荷 gw。
955                if let Some(ctx) = self.ctx.get() {
956                    let _ = ctx.put(Event::with_data(W_ACCOUNT_DONE, Arc::new(gw)));
957                }
958            }
959            // 原稿 EVENT_CONTRACT_FINISH(0.10.5 起本 crate 导出常量):全局线无 gw 后缀;
960            // 载荷放网关名(多网关辨源,D-CFIN)。
961            TdOut::ContractFinish(gw) => {
962                // 0.11.0 第4刀:先把不满一批的尾巴发掉,再收尾(收尾语义不变)
963                self.flush_contract();
964                if let Some(ctx) = self.ctx.get() {
965                    let _ = ctx.put(Event::with_data(EVENT_CONTRACT_FINISH, Arc::new(gw)));
966                }
967            }
968            TdOut::ReqConnect { addr, flow_path } => {
969                if let Some(t) = &self.trans {
970                    t.td_connect(&addr, &flow_path);
971                } else {
972                    self.warn_no_transport();
973                }
974            }
975            TdOut::ReqAuthenticate {
976                user,
977                broker,
978                auth_code,
979                app_id,
980            } => {
981                if let Some(t) = &self.trans {
982                    t.td_auth(&user, &broker, &auth_code, &app_id);
983                } else {
984                    self.warn_no_transport();
985                }
986            }
987            TdOut::ReqLogin {
988                user,
989                password,
990                broker,
991            } => {
992                if let Some(t) = &self.trans {
993                    t.td_login(&user, &password, &broker);
994                } else {
995                    self.warn_no_transport();
996                }
997            }
998            TdOut::ReqSettlementConfirm { broker, user } => {
999                if let Some(t) = &self.trans {
1000                    t.td_settlement_confirm(&broker, &user);
1001                } else {
1002                    self.warn_no_transport();
1003                }
1004            }
1005            TdOut::ReqQryInstrument => {
1006                if let Some(t) = &self.trans {
1007                    let n = t.td_qry_instrument();
1008                    // 0.9.3:返回码不再丢(原稿在这里阻塞重发)。被拒 → 进连续串 +
1009                    // 进合约分项 + 排下一拍重发,全在驱动那一个方法里。
1010                    for l in self.td.lock().unwrap().note_instrument_send_failure(n) {
1011                        self.put_log(l);
1012                    }
1013                } else {
1014                    self.warn_no_transport();
1015                }
1016            }
1017            TdOut::ReqQryAccount { broker, user } => {
1018                if let Some(t) = &self.trans {
1019                    let n = t.td_qry_account(&broker, &user);
1020                    for l in self.td.lock().unwrap().note_query_send_failure("资金", n) {
1021                        self.put_log(l);
1022                    }
1023                } else {
1024                    self.warn_no_transport();
1025                }
1026            }
1027            TdOut::ReqQryPosition { broker, user } => {
1028                if let Some(t) = &self.trans {
1029                    let n = t.td_qry_position(&broker, &user);
1030                    for l in self.td.lock().unwrap().note_query_send_failure("持仓", n) {
1031                        self.put_log(l);
1032                    }
1033                } else {
1034                    self.warn_no_transport();
1035                }
1036            }
1037            TdOut::Insert(o) => {
1038                if let Some(t) = &self.trans {
1039                    t.td_insert(&o);
1040                } else {
1041                    self.warn_no_transport();
1042                }
1043            }
1044            TdOut::Cancel(a) => {
1045                if let Some(t) = &self.trans {
1046                    t.td_cancel(&a);
1047                } else {
1048                    self.warn_no_transport();
1049                }
1050            }
1051        }
1052    }
1053
1054    /// 业务日志统一出口(LogData 挂 "eLog" 固定键;ctx 文档同款)。
1055    fn put_log(&self, l: LogData) {
1056        if let Some(ctx) = self.ctx.get() {
1057            let _ = ctx.put(Event::with_data(W_LOG, Arc::new(l)));
1058        }
1059    }
1060
1061    /// 数据上抛(**裸线键 `eXxx.` + 载荷**;0.4.0 集成修正)。
1062    ///
1063    /// 原稿 emit 面带 gw 后缀(`eOrder.ctp`),但 ek 总线派发是精确匹配
1064    /// (ek-event register 精确键,ek-core 照 routes 原样注册),而消费侧
1065    /// dm/oms 注册的是裸前缀(`eTick.`/`eOrder.`)——带后缀的行**没有任何
1066    /// 订阅者**,整条 ctp 数据流在装配里被静默丢弃(T5 实连揭出)。网关身份
1067    /// 本就在载荷 `gateway_name` 字段里,消费侧按它分流足够;sim 侧同款裸键
1068    /// 先例(ui_sim 全绿)钉死此契约。见 DESIGN D-EMIT。
1069    fn put_data<T: Send + Sync + 'static>(&self, prefix: &str, payload: T) {
1070        if let Some(ctx) = self.ctx.get() {
1071            let _ = ctx.put(Event::with_data(prefix, Arc::new(payload)));
1072        }
1073    }
1074
1075    /// 状态变化上抛(0.11.0 第3刀;线名 = 裸前缀 `eStatus.`,网关名在载荷里)。
1076    ///
1077    /// 为什么不是 `eStatus.<gw>` 带后缀:本总线是**精确匹配**
1078    /// (`ek_event::HandlerRegistry::snapshot` 只查 `typed.get(event_type)`),
1079    /// 带后缀的线只有"知道完整网关名的订阅者"收得到,而消费侧(routes 在插件启动时
1080    /// 一次性注册)没法为将来添加的账户预注册线。数据面早就是同一条契约
1081    /// (D-EMIT:裸线键 + 载荷带 `gateway_name`),状态面照它走,消费侧按载荷分流。
1082    /// 合约行入账并发批(0.11.0 第4刀 R5)。
1083    ///
1084    /// 攒满 [`CONTRACT_BATCH_ROWS`] 就发一帧 `Vec<ContractData>`;不满一批**不发**
1085    /// (等下一行/收尾),所以一次 1.5 万行的查询只产生 8 帧而不是 1.5 万帧。
1086    fn emit_contract(&self, c: ContractData) {
1087        let mut batch = self.contract_batch.lock().unwrap();
1088        batch.push(c);
1089        if batch.len() >= CONTRACT_BATCH_ROWS {
1090            let payload = std::mem::take(&mut *batch);
1091            drop(batch);
1092            self.put_data(W_CONTRACT, payload);
1093        }
1094    }
1095
1096    /// 发掉不满一批的尾巴(收尾 / 会话复位 / 摘插件时调用)。空则什么都不做。
1097    pub fn flush_contract(&self) {
1098        let payload = {
1099            let mut batch = self.contract_batch.lock().unwrap();
1100            std::mem::take(&mut *batch)
1101        };
1102        if !payload.is_empty() {
1103            self.put_data(W_CONTRACT, payload);
1104        }
1105    }
1106
1107    /// 发一行并补名称(0.11.0 第5刀 R6)。
1108    ///
1109    /// 网关侧富化:行自带名字就用它的(柜台哪天给名字了就不覆盖),空了才查共享
1110    /// 合约表;**查不到照发**(红线①:绝不因为缺名字丢行)。
1111    fn publish_enriched<T>(&self, line: &str, mut row: T)
1112    where
1113        T: EnrichName + Send + Sync + 'static,
1114    {
1115        if row.name_mut().is_empty() {
1116            let symbol = row.symbol().to_string();
1117            if let Some(n) = self.td.lock().unwrap().contract_name(&symbol) {
1118                *row.name_mut() = n;
1119            }
1120        }
1121        self.put_data(line, row);
1122    }
1123
1124    fn publish_status(&self, change: GatewayChange) {
1125        let st = self.status();
1126        self.put_data(
1127            EVENT_STATUS_PREFIX,
1128            GatewayStatusEvent {
1129                gateway: self.gw.clone(),
1130                at_ms: st.sampled_ms,
1131                change,
1132                status: Box::new(st),
1133            },
1134        );
1135    }
1136
1137    /// 无传输时的静默风告(只发一次;info 级防刷屏)。
1138    fn warn_no_transport(&self) {
1139        let mut warned = self.trans_warned.lock().unwrap();
1140        if !*warned {
1141            *warned = true;
1142            self.put_log(LogData {
1143                gateway_name: self.gw.clone(),
1144                msg: "传输层未接入(默认构建):连接/查询/发送动作已被吸收".to_string(),
1145                level: 20,
1146            });
1147        }
1148    }
1149
1150    /// 服务面:`eReq.<gw>` 分派(arm 范式与 oms 同)。
1151    fn on_request(&self, event: &Event) {
1152        let Some(env) = event.data.downcast_ref::<Envelope>() else {
1153            return;
1154        };
1155        macro_rules! arm {
1156            ($ty:ty, $res:ty, $body:expr) => {
1157                if let Some(req) = env.payload.downcast_ref::<$ty>() {
1158                    let res: $res = $body(req);
1159                    let _ = env.reply.send(Box::new(Ok::<$res, ReplyErr>(res)));
1160                    return;
1161                }
1162            };
1163        }
1164        arm!(SubscribeReq, SubscribeReply, |r: &SubscribeReq| self
1165            .do_subscribe(r));
1166        arm!(SendOrderReq, SendOrderReply, |r: &SendOrderReq| self
1167            .do_send_order(r));
1168        arm!(CancelOrderReq, CancelOrderReply, |r: &CancelOrderReq| self
1169            .do_cancel_order(r));
1170        arm!(QueryRateReq, QueryRateReply, |r: &QueryRateReq| self
1171            .do_query_rate(r));
1172        arm!(ConfigureReq, ConfigureReply, |r: &ConfigureReq| self
1173            .do_configure(r));
1174        arm!(StatusReq, GatewayStatus, |r: &StatusReq| self.do_status(r));
1175        arm!(DisconnectReq, DisconnectReply, |r: &DisconnectReq| self
1176            .do_disconnect(r));
1177        // 0.10.0(BD-CFG 环节7):凭据档位只读口(运维窗「已存/未存」)。
1178        arm!(
1179            CredentialStateReq,
1180            Vec<CredentialEntry>,
1181            |_: &CredentialStateReq| self.credentials_state()
1182        );
1183        // 载荷不是任何已声明契约 → 协议错误(宿主既有语义)
1184        let _ = env
1185            .reply
1186            .send(Box::new(Err::<(), ReplyErr>(ReplyErr::InvalidPayload)));
1187    }
1188
1189    fn do_subscribe(&self, r: &SubscribeReq) -> SubscribeReply {
1190        let mut outs = Vec::new();
1191        {
1192            let mut md = self.md.lock().unwrap();
1193            for s in &r.symbols {
1194                outs.extend(md.subscribe(s));
1195            }
1196        }
1197        for o in outs {
1198            self.emit_md(o);
1199        }
1200        SubscribeReply {
1201            ok: true,
1202            msg: String::new(),
1203        }
1204    }
1205
1206    fn do_send_order(&self, r: &SendOrderReq) -> SendOrderReply {
1207        let outcome = {
1208            let mut td = self.td.lock().unwrap();
1209            td.send_order(&r.req)
1210        };
1211        if let Some(log) = &outcome.error {
1212            self.put_log(log.clone());
1213        }
1214        // 原稿 844-848:插单命令的 rt 返回码由传输层裁决;n != 0 →
1215        // 日志「委托请求发送失败,错误代码:n」且回 ""(不回 SUBMITTING 单)。
1216        // 其余命令面照常下发。
1217        let mut submit_order: Option<ek_data::order::OrderData> = None;
1218        let mut send_failed: Option<String> = None;
1219        for o in outcome.outs {
1220            match o {
1221                TdOut::Insert(input) => match &self.trans {
1222                    Some(t) => {
1223                        let n = t.td_insert(&input);
1224                        if n != 0 {
1225                            send_failed = Some(format!("委托请求发送失败,错误代码:{n}"));
1226                        }
1227                    }
1228                    None => self.warn_no_transport(),
1229                },
1230                TdOut::Order(od) => submit_order = Some(od),
1231                other => self.emit_td(other),
1232            }
1233        }
1234        if let Some(msg) = send_failed {
1235            let log = LogData {
1236                gateway_name: self.gw.clone(),
1237                msg: msg.clone(),
1238                level: 20,
1239            };
1240            self.put_log(log);
1241            return SendOrderReply {
1242                ok: false,
1243                en_orderid: String::new(),
1244                msg,
1245            };
1246        }
1247        if let Some(od) = submit_order {
1248            self.emit_td(TdOut::Order(od));
1249        }
1250        if outcome.en_orderid.is_empty() {
1251            let msg = outcome.error.map(|l| l.msg).unwrap_or_default();
1252            SendOrderReply {
1253                ok: false,
1254                en_orderid: String::new(),
1255                msg,
1256            }
1257        } else if self.trans.is_none() {
1258            self.warn_no_transport();
1259            SendOrderReply {
1260                ok: false,
1261                en_orderid: outcome.en_orderid,
1262                msg: "传输层未接入(默认构建)".to_string(),
1263            }
1264        } else {
1265            SendOrderReply {
1266                ok: true,
1267                en_orderid: outcome.en_orderid,
1268                msg: String::new(),
1269            }
1270        }
1271    }
1272
1273    /// 费率查询实现(附加接口面)。margin → `td_qry_margin_rate`,
1274    /// commission → `td_qry_commission_rate`;status 无 Req,不在此线
1275    /// (纯被动,官文见 DESIGN)。RT 非零命中原稿式发送失败日志并回值。
1276    fn do_query_rate(&self, r: &QueryRateReq) -> QueryRateReply {
1277        let broker = self.broker.lock().unwrap().clone();
1278        let user = self.user.lock().unwrap().clone();
1279        let is_margin = r.kind == "margin";
1280        let is_commission = r.kind == "commission";
1281        if !is_margin && !is_commission {
1282            self.put_log(LogData {
1283                gateway_name: self.gw.clone(),
1284                msg: format!("费率查询类型未知:{}", r.kind),
1285                level: 20,
1286            });
1287            return QueryRateReply {
1288                ok: false,
1289                msg: format!("费率查询类型未知:{}", r.kind),
1290            };
1291        }
1292        if let Some(t) = &self.trans {
1293            let n = if is_margin {
1294                let query = crate::wire::QryInstrumentMarginRate {
1295                    broker_id: broker.clone(),
1296                    investor_id: user.clone(),
1297                    hedge_flag: r.hedge_flag.clone(),
1298                    exchange_id: r.exchange_id.clone(),
1299                    invest_unit_id: String::new(),
1300                    instrument_id: r.instrument_id.clone(),
1301                };
1302                t.td_qry_margin_rate(&query)
1303            } else {
1304                let query = crate::wire::QryInstrumentCommissionRate {
1305                    broker_id: broker.clone(),
1306                    investor_id: user.clone(),
1307                    exchange_id: r.exchange_id.clone(),
1308                    invest_unit_id: String::new(),
1309                    instrument_id: r.instrument_id.clone(),
1310                };
1311                t.td_qry_commission_rate(&query)
1312            };
1313            let label = if is_margin {
1314                "保证金率"
1315            } else {
1316                "手续费率"
1317            };
1318            if n != 0 {
1319                // 0.9.6(用户定口径):费率**不自动查询、不自动重发** —— 这一口只负责把 CTP
1320                // 两个费率查询封装好,供别的插件按需调用,谁调谁负责自己的节律。
1321                // 原稿示例第 11 步那套「RT != 0 就隔 1.05s 重发一次」在这里有两个害处:
1322                // ① 它睡在**总线唯一的分发线程**上(ek-event engine.rs `run_dispatch` 单线程),
1323                //    一次被拒 = 全进程所有插件的下单/撤单/状态回包停 1.05 秒,并把排队的
1324                //    eTimer 拍挤到一起处理;② 它自己就是第二发,与协调点的周期查询落在同一秒
1325                //    里正好凑成 `-3`。
1326                // **不进 query_rejects / query_reject_streak**(用户定):那个数是网关自己那条
1327                // 查询路的体检数,ek-ui 拿它数「查询这条路连续被拒几次」;费率是别人按需调的,
1328                // 混进去就是替别人记账。**日志级别保持 INFO**(用户定:不与三条查询那套对齐)。
1329                let msg =
1330                    format!("{label}查询请求发送失败,错误代码:{n}(不自动重发:费率查询按需调用)");
1331                self.put_log(LogData {
1332                    gateway_name: self.gw.clone(),
1333                    msg: msg.clone(),
1334                    level: 20,
1335                });
1336                return QueryRateReply { ok: false, msg };
1337            }
1338            QueryRateReply {
1339                ok: true,
1340                msg: String::new(),
1341            }
1342        } else {
1343            self.warn_no_transport();
1344            QueryRateReply {
1345                ok: false,
1346                msg: "传输层未接入(默认构建)".to_string(),
1347            }
1348        }
1349    }
1350
1351    fn do_cancel_order(&self, r: &CancelOrderReq) -> CancelOrderReply {
1352        let outs = {
1353            let mut td = self.td.lock().unwrap();
1354            td.cancel_order(&r.req)
1355        };
1356        let mut rejected_log: Option<LogData> = None;
1357        let outs: Vec<TdOut> = outs
1358            .into_iter()
1359            .filter(|o| match o {
1360                TdOut::Log(l) => {
1361                    rejected_log = Some(l.clone());
1362                    false
1363                }
1364                _ => true,
1365            })
1366            .collect();
1367        if let Some(l) = rejected_log {
1368            let msg = l.msg.clone();
1369            self.put_log(l);
1370            return CancelOrderReply { ok: false, msg };
1371        }
1372        for o in outs {
1373            self.emit_td(o);
1374        }
1375        if self.trans.is_none() {
1376            self.warn_no_transport();
1377            return CancelOrderReply {
1378                ok: false,
1379                msg: "传输层未接入(默认构建)".to_string(),
1380            };
1381        }
1382        CancelOrderReply {
1383            ok: true,
1384            msg: String::new(),
1385        }
1386    }
1387
1388    /// 配置实现(插件自带配置面,0.3.0;定盘:界面/宿主手工输入经 ask,
1389    /// 插件不读配置文件)。
1390    ///
1391    /// 校验 → 应用(驱动 reconfigure 复位连接/认证/登录守卫 + 插件侧
1392    /// cfg/broker/user 换血)→ **强制重连**(0.5.0:先 `td_release`/`md_release`
1393    /// 断开旧会话——传输层幂等 connect 下,不断开就没有新登录显形,换账号/
1394    /// 改密码永不生效;Release 归 RAII Drop,可能阻塞到回调收尾)→ `connect()`
1395    /// (先 TD 后 MD)。纯逻辑面 release 默认空,行为不变。
1396    fn do_configure(&self, r: &ConfigureReq) -> ConfigureReply {
1397        self.configure(r)
1398    }
1399
1400    fn do_status(&self, _r: &StatusReq) -> GatewayStatus {
1401        self.status()
1402    }
1403
1404    fn do_disconnect(&self, _r: &DisconnectReq) -> DisconnectReply {
1405        self.disconnect()
1406    }
1407
1408    /// 状态口(0.9.0 新增;公开与 `configure` 同级;ask 臂 `{gw}.status` 走它)。
1409    ///
1410    /// 纪律:**只读**——不改任何守卫、不触发重连、不写日志(界面按秒级刷,
1411    /// 每次刷都写日志会把 eLog 环冲爆,512 行取证环就是这么饱和的)。
1412    pub fn status(&self) -> GatewayStatus {
1413        let td = self.td.lock().unwrap().status_view();
1414        let md = self.md.lock().unwrap().status_view();
1415        GatewayStatus {
1416            gateway: self.gw.clone(),
1417            sampled_ms: crate::map::now_epoch_ms(),
1418            td_handle_up: td.handle_up,
1419            td_logged_in: td.logged_in,
1420            td_login_failed: td.login_failed,
1421            td_auth_ok: td.auth_ok,
1422            td_auth_failed: td.auth_failed,
1423            td_front_id: td.front_id,
1424            td_session_id: td.session_id,
1425            td_disconnects: td.disconnects,
1426            td_last_login_ok_ms: td.last_login_ok_ms,
1427            td_last_account_ms: td.last_account_ms,
1428            td_last_position_ms: td.last_position_ms,
1429            td_query_rejects: td.query_rejects,
1430            td_query_reject_streak: td.query_reject_streak,
1431            td_instrument_rejects: td.instrument_rejects,
1432            td_last_error: td.last_error,
1433            md_handle_up: md.handle_up,
1434            md_logged_in: md.logged_in,
1435            md_disconnects: md.disconnects,
1436            md_last_login_ok_ms: md.last_login_ok_ms,
1437            md_last_tick_ms: md.last_tick_ms,
1438            md_sub_rejects: md.sub_rejects,
1439            md_last_error: md.last_error,
1440            md_subscribed: md.subscribed,
1441        }
1442    }
1443
1444    /// 断开口(0.9.0 新增;裁定 1「断开 = 停会话,实例与订阅保留」)。
1445    ///
1446    /// 放掉:两侧会话(传输层 release + 驱动守卫复位,查询阶段回 `Idle`)。
1447    /// 保留:插件实例、账号配置、订阅集、断开计数与「最近时刻」、持仓合并缓存、
1448    /// OrderSysID 映射。恢复走『连接』(= `configure` 重走全链,登录后自动重订阅)。
1449    pub fn disconnect(&self) -> DisconnectReply {
1450        if let Some(t) = &self.trans {
1451            t.td_release();
1452            t.md_release();
1453        }
1454        {
1455            self.td.lock().unwrap().reset_session();
1456        }
1457        {
1458            self.md.lock().unwrap().reset_session();
1459        }
1460        // 0.11.0 第4刀:会话复位 = 这一轮查询结束,把没发出去的合约尾巴发掉
1461        // (下一轮从头攒,不能让半批压在锁里)
1462        self.flush_contract();
1463        self.put_log(LogData {
1464            gateway_name: self.gw.clone(),
1465            msg: "会话已停(实例与订阅保留;『连接』可恢复)".to_string(),
1466            level: LEVEL_INFO,
1467        });
1468        // 0.9.8 链状态行:账户表「阶段」列跟着回到「尚未开始查询」。
1469        self.put_log(LogData {
1470            gateway_name: self.gw.clone(),
1471            msg: format!("{}会话已复位", driver::CHAIN_STATE_PREFIX),
1472            level: LEVEL_INFO,
1473        });
1474        DisconnectReply {
1475            ok: true,
1476            msg: "会话已停(实例与订阅保留;『连接』可恢复)".to_string(),
1477        }
1478    }
1479
1480    /// 当前生效的产品类勾选(D-PC-FILTER)。
1481    ///
1482    /// 校验被拒时也回带它,而不是空表 —— 空表会让界面把勾选框清空,而网关其实还跑在
1483    /// 旧勾选上,那是假信息。
1484    fn effective_classes(&self) -> Vec<String> {
1485        self.td.lock().unwrap().product_classes()
1486    }
1487
1488    /// 配置口(公开,与 `connect` 同级的显式控制面;ask 臂 `{gw}.configure`
1489    /// 走的也是它)。
1490    pub fn configure(&self, r: &ConfigureReq) -> ConfigureReply {
1491        if r.td_addr.is_empty() && r.md_addr.is_empty() {
1492            return ConfigureReply {
1493                ok: false,
1494                msg: "配置校验: td_addr/md_addr 至少一个非空".to_string(),
1495                product_classes: self.effective_classes(),
1496            };
1497        }
1498        if !r.td_addr.is_empty()
1499            && (r.broker_id.is_empty() || r.td_user.is_empty() || r.td_password.is_empty())
1500        {
1501            return ConfigureReply {
1502                ok: false,
1503                msg: "配置校验: TD 侧需 broker_id/td_user/td_password 齐".to_string(),
1504                product_classes: self.effective_classes(),
1505            };
1506        }
1507        let md_user = if !r.md_user.is_empty() {
1508            r.md_user.clone()
1509        } else {
1510            r.td_user.clone()
1511        };
1512        let md_password = if !r.md_password.is_empty() {
1513            r.md_password.clone()
1514        } else {
1515            r.td_password.clone()
1516        };
1517        if !r.md_addr.is_empty() && (md_user.is_empty() || md_password.is_empty()) {
1518            return ConfigureReply {
1519                ok: false,
1520                msg: "配置校验: MD 侧需 md_user/md_password(或 td 侧回落)".to_string(),
1521                product_classes: self.effective_classes(),
1522            };
1523        }
1524        // 产品类勾选校验(D-PC-FILTER):CTP 产品类码是单字符。非法项不静默接受,
1525        // 直接拒并列出;空串项视为「没填」,由归一化忽略(全空 → 回落默认期货)。
1526        let bad_codes: Vec<String> = r
1527            .product_classes
1528            .iter()
1529            .map(|c| c.trim().to_string())
1530            .filter(|c| !c.is_empty() && c.chars().count() != 1)
1531            .collect();
1532        if !bad_codes.is_empty() {
1533            return ConfigureReply {
1534                ok: false,
1535                msg: format!(
1536                    "配置校验: product_classes 需为单字符 CTP 码(非法项:{})",
1537                    bad_codes.join(",")
1538                ),
1539                product_classes: self.effective_classes(),
1540            };
1541        }
1542        let classes = crate::map::ProductClasses(r.product_classes.clone());
1543        let effective_classes = classes.normalize();
1544        {
1545            self.td.lock().unwrap().reconfigure(driver::TdConfig {
1546                addr: r.td_addr.clone(),
1547                user: r.td_user.clone(),
1548                password: r.td_password.clone(),
1549                broker: r.broker_id.clone(),
1550                auth_code: r.td_auth_code.clone(),
1551                app_id: r.td_app_id.clone(),
1552                product_classes: classes.clone(),
1553            });
1554        }
1555        {
1556            self.md.lock().unwrap().reconfigure(driver::MdConfig {
1557                addr: r.md_addr.clone(),
1558                user: md_user,
1559                password: md_password,
1560                broker: r.broker_id.clone(),
1561            });
1562        }
1563        *self.broker.lock().unwrap() = r.broker_id.clone();
1564        *self.user.lock().unwrap() = r.td_user.clone();
1565        *self.cfg.lock().unwrap() = CtpConfig {
1566            gateway_name: self.gw.clone(),
1567            broker_id: r.broker_id.clone(),
1568            td_addr: r.td_addr.clone(),
1569            td_user: r.td_user.clone(),
1570            td_password: r.td_password.clone(),
1571            td_auth_code: r.td_auth_code.clone(),
1572            td_app_id: r.td_app_id.clone(),
1573            md_addr: r.md_addr.clone(),
1574            md_user: r.md_user.clone(),
1575            md_password: r.md_password.clone(),
1576            product_classes: classes,
1577        };
1578        // 强制重连(0.5.0):旧会话先断,connect() 才会新建句柄走全链登录。
1579        if let Some(t) = &self.trans {
1580            t.td_release();
1581            t.md_release();
1582        }
1583        self.connect();
1584        // 受理三行序(BD-CFG-8/14;0.10.1):受理成功 = 唯一持久面。秘面登记/
1585        // 引用化在 dump_config 内完成,ask `cfg.save` 落手;cfg 缺席 → 直写
1586        // 库兜底(BD-CFG-10,审计行 BD-CFG-11 一次性)。回执零改(UX 零变)。
1587        self.persist_config();
1588        ConfigureReply {
1589            ok: true,
1590            msg: format!(
1591                "已配置并发起连接(先 TD 后 MD);收录产品类:{}",
1592                effective_classes.join(" ")
1593            ),
1594            product_classes: effective_classes,
1595        }
1596    }
1597
1598    /// 凭据档位快照(只读;`{gw}.credentials_state` ask 臂与运维窗呈现共用,
1599    /// BD-CFG-17 的呈现语义:以文件引用为准)。
1600    #[must_use]
1601    pub fn credentials_state(&self) -> Vec<CredentialEntry> {
1602        let refs = lock_clone(&self.sec_refs);
1603        let cfg = lock_clone(&self.cfg);
1604        let mut out = Vec::with_capacity(2);
1605        for field in ["td_password", "md_password"] {
1606            let sk = refs
1607                .iter()
1608                .find(|(f, _)| f == field)
1609                .map(|(_, k)| k.clone())
1610                .unwrap_or_default();
1611            let resolved = if field == "td_password" {
1612                !cfg.td_password.is_empty()
1613            } else {
1614                !cfg.md_password.is_empty()
1615            };
1616            let state = if resolved {
1617                "RESOLVED"
1618            } else if sk.is_empty() {
1619                "ABSENT"
1620            } else {
1621                "REFERENCED"
1622            };
1623            out.push(CredentialEntry {
1624                field: field.to_owned(),
1625                state: state.to_owned(),
1626                ref_key: sk,
1627            });
1628        }
1629        out
1630    }
1631
1632    /// 解密已回灌的引用行(**只允许 worker 时机调用** —— on_start;
1633    /// BD-CFG-16:宿主管理线程禁调秘取)。取不到 = 待手配,不改连接守卫
1634    /// (连接空值守卫原有语义原样);库值缺 / 非文本字节都当场 eLog 显形,
1635    /// 不过这两个显形挡翻倍:每次实例启动至多各一行(info 级防刷屏)。
1636    fn resolve_pending_secrets(&self) {
1637        let refs = lock_clone(&self.sec_refs);
1638        if refs.is_empty() {
1639            return;
1640        }
1641        let mut cfg = lock_clone(&self.cfg);
1642        let mut hit = false;
1643        for (field, sk) in &refs {
1644            match ek_cfg::secret::get(&self.ns, sk) {
1645                Some(v) => match std::str::from_utf8(v.as_bytes()) {
1646                    Ok(text) => {
1647                        if field == "td_password" {
1648                            cfg.td_password = text.to_owned();
1649                            hit = true;
1650                        } else if field == "md_password" {
1651                            cfg.md_password = text.to_owned();
1652                            hit = true;
1653                        }
1654                    }
1655                    Err(_) => {
1656                        // 坏字节(keyring 属文本面走不到;env 注入面可能发生)
1657                        // → 该键空置待手配,显形不静默(BD-U4)。
1658                        self.put_log(LogData {
1659                            gateway_name: self.gw.clone(),
1660                            msg: format!("{field} 的秘值不是文本(键 {sk}),按待手配处理"),
1661                            level: 40,
1662                        });
1663                    }
1664                },
1665                None => {
1666                    self.put_log(LogData {
1667                        gateway_name: self.gw.clone(),
1668                        msg: format!("{field} 的引用在库值缺(键 {sk}),待手配"),
1669                        level: 20,
1670                    });
1671                }
1672            }
1673        }
1674        if !hit {
1675            return;
1676        }
1677        // 解密结果落驱动(与 configure 的 reconfigure 同式但不 release/不
1678        // connect —— 连接时机仍归 on_start 的 connect,UX 行为零变)。
1679        let td_cfg = driver::TdConfig {
1680            addr: cfg.td_addr.clone(),
1681            user: cfg.td_user.clone(),
1682            password: cfg.td_password.clone(),
1683            broker: cfg.broker_id.clone(),
1684            auth_code: cfg.td_auth_code.clone(),
1685            app_id: cfg.td_app_id.clone(),
1686            product_classes: cfg.product_classes.clone(),
1687        };
1688        let md_cfg = driver::MdConfig {
1689            addr: cfg.md_addr.clone(),
1690            user: if cfg.md_user.is_empty() {
1691                cfg.td_user.clone()
1692            } else {
1693                cfg.md_user.clone()
1694            },
1695            password: if cfg.md_password.is_empty() {
1696                cfg.td_password.clone()
1697            } else {
1698                cfg.md_password.clone()
1699            },
1700            broker: cfg.broker_id.clone(),
1701        };
1702        self.td.lock().unwrap().reconfigure(td_cfg);
1703        self.md.lock().unwrap().reconfigure(md_cfg);
1704        *self.cfg.lock().unwrap() = cfg;
1705    }
1706
1707    /// 受理三行序的落手段(BD-CFG-14):`dump_config`(秘面引用在这一步登记
1708    /// 进秘库)→ ask `cfg.save`;cfg 实例缺席(BD-CFG-9:UnknownService/
1709    /// TargetGone/超时同闸单分支)→ 直写 `Store`(BD-CFG-10 软集中,兜底
1710    /// 审计行 BD-CFG-11 每插件一次性)。成败都不改 configure 回执(UX 零变),
1711    /// 问题走 eLog 显形。**调用面 = 插件 worker 线程**(on_request →
1712    /// do_configure;secret::set 的 keyring 写在此许可,BD-CFG-16)。
1713    fn persist_config(&self) {
1714        // dump 一次取定(0.10.2 自审修正:原先兜底分支再 dump 一遍 —— 秘面
1715        // 重复登记无害但 rev 平白多爬一级;现在同一 map 走两条路)。
1716        let map = self.dump_config();
1717        if map.is_empty() {
1718            return; // BD-CFG-15:不输出 = 不持久(理应不会到;受理校验保底)
1719        }
1720        let ns = Configurable::config_ns(self).to_owned();
1721        let req = ek_cfg::wire::SaveReq {
1722            ns: ns.clone(),
1723            map: map.clone(),
1724        };
1725        let ctx = self.ctx.get();
1726        let res = match ctx {
1727            Some(c) => c.ask_service::<ek_cfg::wire::SaveReq, ek_cfg::wire::SaveRes>(
1728                "cfg.save",
1729                req,
1730                std::time::Duration::from_millis(1500),
1731            ),
1732            // on_request 面 ctx 必在(on_init 先行);纯逻辑测试兜直库面。
1733            None => Err(ek_core::error::AskError::UnknownService {
1734                service: "cfg.save".to_owned(),
1735            }),
1736        };
1737        match res {
1738            Ok(r) if r.ok => {
1739                self.put_log(LogData {
1740                    gateway_name: self.gw.clone(),
1741                    msg: format!("配置已持久化(段 {});警告{:?}", ns, r.warns),
1742                    level: 20,
1743                });
1744            }
1745            Ok(r) => {
1746                self.put_log(LogData {
1747                    gateway_name: self.gw.clone(),
1748                    msg: format!("配置落盘失败(段 {}):{}", ns, r.msg),
1749                    level: 40,
1750                });
1751            }
1752            Err(_) => {
1753                // cfg 缺席 → 直接落库(BD-CFG-10);审计行一次性(BD-CFG-11)。
1754                let once = {
1755                    let mut f = self.save_fallback.lock().unwrap();
1756                    let first = !*f;
1757                    *f = true;
1758                    first
1759                };
1760                if once {
1761                    self.put_log(LogData {
1762                        gateway_name: self.gw.clone(),
1763                        msg: "cfg 缺席,已直写落盘".to_string(),
1764                        level: 20,
1765                    });
1766                }
1767                let ok = Self::store_direct(&ns, &map);
1768                if !ok {
1769                    self.put_log(LogData {
1770                        gateway_name: self.gw.clone(),
1771                        msg: format!("直写托底也失败(段 {})", ns),
1772                        level: 40,
1773                    });
1774                }
1775            }
1776        }
1777    }
1778
1779    /// 直写库面(兜底;根同 cfg 实例的 eco → 本地回退口径)。
1780    /// `EK_CFG_FALLBACK_ROOT` = 部署/测试显缝(设了就走该根,不碰真配置目录)。
1781    fn store_direct(ns: &str, map: &SectionMap) -> bool {
1782        let store = match std::env::var("EK_CFG_FALLBACK_ROOT") {
1783            Ok(v) if !v.is_empty() => ek_cfg::Store::with_root(std::path::PathBuf::from(v)),
1784            _ => ek_cfg::Store::eco()
1785                .unwrap_or_else(|_| ek_cfg::Store::with_root("./ekcfg-local".into())),
1786        };
1787        store.store(ns, map).is_ok()
1788    }
1789}
1790
1791// ================ 配置托底契约(BD-CFG 环节7) ================
1792
1793impl Configurable for CtpPlugin {
1794    fn config_ns(&self) -> &str {
1795        &self.ns
1796    }
1797
1798    /// 键位与段语义(0.10.0 v1;apply 为**补丁合入**,段缺键 = 沿用现值)。
1799    /// **本方法禁调 `secret::get`**(只被 boot/管理线程调用,BD-CFG-16)——
1800    /// 秘面只登记引用行,解密在 on_start(worker 面)。
1801    fn apply_config(&self, m: &SectionMap) -> Result<ApplyReport, String> {
1802        if m.rev > self.cfg_rev.load(Ordering::Relaxed) {
1803            self.cfg_rev.store(m.rev, Ordering::Relaxed);
1804        }
1805        let mut rep = ApplyReport::all_adopted();
1806        let mut patch = lock_clone(&self.cfg);
1807        let mut new_refs: Vec<(String, String)> = Vec::new();
1808        for (k, f) in m.iter() {
1809            match k {
1810                "broker_id" => {
1811                    patch.broker_id = f.plain_value().unwrap_or("").to_owned();
1812                    rep.adopted.push(k.to_owned());
1813                }
1814                "td_addr" => {
1815                    patch.td_addr = f.plain_value().unwrap_or("").to_owned();
1816                    rep.adopted.push(k.to_owned());
1817                }
1818                "td_user" => {
1819                    patch.td_user = f.plain_value().unwrap_or("").to_owned();
1820                    rep.adopted.push(k.to_owned());
1821                }
1822                "td_auth_code" => {
1823                    patch.td_auth_code = f.plain_value().unwrap_or("").to_owned();
1824                    rep.adopted.push(k.to_owned());
1825                }
1826                "td_app_id" => {
1827                    patch.td_app_id = f.plain_value().unwrap_or("").to_owned();
1828                    rep.adopted.push(k.to_owned());
1829                }
1830                "md_addr" => {
1831                    patch.md_addr = f.plain_value().unwrap_or("").to_owned();
1832                    rep.adopted.push(k.to_owned());
1833                }
1834                "md_user" => {
1835                    patch.md_user = f.plain_value().unwrap_or("").to_owned();
1836                    rep.adopted.push(k.to_owned());
1837                }
1838                "product_classes" => {
1839                    patch.product_classes = crate::map::ProductClasses(
1840                        f.plain_value()
1841                            .unwrap_or("")
1842                            .split(',')
1843                            .map(str::trim)
1844                            .filter(|c| !c.is_empty())
1845                            .map(str::to_owned)
1846                            .collect(),
1847                    );
1848                    rep.adopted.push(k.to_owned());
1849                }
1850                "td_password" | "md_password" => {
1851                    if let Some(sk) = f.secret_key() {
1852                        new_refs.push((k.to_owned(), sk.to_owned())); // 只登记引用
1853                        rep.adopted.push(k.to_owned());
1854                    } else {
1855                        rep.deferred.push(k.to_owned()); // 段里不该有明文
1856                    }
1857                }
1858                _ => rep.deferred.push(k.to_owned()), // 未知键:不采纳不覆盖
1859            }
1860        }
1861        *self.sec_refs.lock().unwrap() = new_refs;
1862        // 驱动同步(同 configure 的 reconfigure 形,但不 release/不 connect ——
1863        // 连接时机仍是 on_start 的 connect;本通道不改 UX 行为)。
1864        let td_cfg = driver::TdConfig {
1865            addr: patch.td_addr.clone(),
1866            user: patch.td_user.clone(),
1867            password: patch.td_password.clone(),
1868            broker: patch.broker_id.clone(),
1869            auth_code: patch.td_auth_code.clone(),
1870            app_id: patch.td_app_id.clone(),
1871            product_classes: patch.product_classes.clone(),
1872        };
1873        let md_cfg = driver::MdConfig {
1874            addr: patch.md_addr.clone(),
1875            user: if patch.md_user.is_empty() {
1876                patch.td_user.clone()
1877            } else {
1878                patch.md_user.clone()
1879            },
1880            password: if patch.md_password.is_empty() {
1881                patch.td_password.clone()
1882            } else {
1883                patch.md_password.clone()
1884            },
1885            broker: patch.broker_id.clone(),
1886        };
1887        self.td
1888            .lock()
1889            .map_err(|e| e.to_string())?
1890            .reconfigure(td_cfg);
1891        self.md
1892            .lock()
1893            .map_err(|e| e.to_string())?
1894            .reconfigure(md_cfg);
1895        *self.broker.lock().unwrap() = patch.broker_id.clone();
1896        *self.user.lock().unwrap() = patch.td_user.clone();
1897        *self.cfg.lock().unwrap() = patch;
1898        Ok(rep)
1899    }
1900
1901    /// 导出(BD-CFG-15):非秘键 plain 出(**该键非空才落**),秘键只在
1902    /// 引用已登记时出引用形态;内存明文无引用 → 在本次落库登记(保存动作
1903    /// 线程;keyring 写毫秒级可忍)并出引用。**值永不落文件**(BD-CFG-12)。
1904    fn dump_config(&self) -> SectionMap {
1905        let next = self.cfg_rev.load(Ordering::Relaxed) + 1;
1906        self.cfg_rev.store(next, Ordering::Relaxed);
1907        let cfg = lock_clone(&self.cfg);
1908        let mut map = SectionMap::new(next);
1909        for (k, v) in [
1910            ("broker_id", &cfg.broker_id),
1911            ("td_addr", &cfg.td_addr),
1912            ("td_user", &cfg.td_user),
1913            ("td_auth_code", &cfg.td_auth_code),
1914            ("td_app_id", &cfg.td_app_id),
1915            ("md_addr", &cfg.md_addr),
1916            ("md_user", &cfg.md_user),
1917        ] {
1918            if !v.is_empty() {
1919                map = map.set(k, Field::plain(v));
1920            }
1921        }
1922        if !cfg.product_classes.0.is_empty() {
1923            map = map.set(
1924                "product_classes",
1925                Field::plain(cfg.product_classes.0.join(",")),
1926            );
1927        }
1928        for (field, value) in [
1929            ("td_password", &cfg.td_password),
1930            ("md_password", &cfg.md_password),
1931        ] {
1932            let refs = lock_clone(&self.sec_refs);
1933            if let Some((_, sk)) = refs.iter().find(|(f, _)| f == field) {
1934                // 既有引用:库值**幂等覆写为当前受理值**(受理面真相 = 连接面
1935                // 当前用着的密码;0.10.1 修正 —— 只换明文不覆写库值时,下一次
1936                // 启动会解回旧密码)。空明文 = 引用行原样(不碰库)。
1937                if !value.is_empty() {
1938                    let _ = ek_cfg::secret::set(
1939                        &self.ns,
1940                        sk,
1941                        ek_cfg::SecretValue::from_bytes(value.clone()),
1942                    );
1943                }
1944                map = map.set(field, Field::secret(sk.as_str()));
1945            } else if !value.is_empty() {
1946                // 明文登记落库 + 出引用(值永不出本 crate 边界)。
1947                let sk = format!("gw_{field}");
1948                if ek_cfg::secret::set(
1949                    &self.ns,
1950                    &sk,
1951                    ek_cfg::SecretValue::from_bytes(value.clone()),
1952                )
1953                .is_ok()
1954                {
1955                    self.sec_refs
1956                        .lock()
1957                        .unwrap()
1958                        .push((field.to_owned(), sk.clone()));
1959                    map = map.set(field, Field::secret(sk.as_str()));
1960                }
1961                // 存库失败(缺平台后端面):跳过该键不落盘。
1962            }
1963            // 空值:跳过(不持久空形)。
1964        }
1965        map
1966    }
1967}
1968
1969impl Default for CtpPlugin {
1970    fn default() -> Self {
1971        Self::new(CtpConfig::default())
1972    }
1973}
1974
1975impl Plugin for CtpPlugin {
1976    fn name(&self) -> &str {
1977        &self.gw
1978    }
1979
1980    fn routes(&self) -> Vec<Cow<'static, str>> {
1981        vec![Cow::Owned(e_req_type(&self.gw)), Cow::Borrowed(ROUTE_TIMER)]
1982    }
1983
1984    fn version(&self) -> Cow<'static, str> {
1985        Cow::Borrowed(env!("CARGO_PKG_VERSION"))
1986    }
1987
1988    fn services(&self) -> Vec<ServiceDesc> {
1989        vec![
1990            ServiceDesc::describe::<SubscribeReq, SubscribeReply>(
1991                format!("{}.subscribe", self.gw),
1992                "登记并(已登录时)下发行情订阅",
1993            ),
1994            ServiceDesc::describe::<SendOrderReq, SendOrderReply>(
1995                format!("{}.send_order", self.gw),
1996                "下单(InputOrder + SUBMITTING 单已上总线)",
1997            ),
1998            ServiceDesc::describe::<CancelOrderReq, CancelOrderReply>(
1999                format!("{}.cancel_order", self.gw),
2000                "撤单(三段订单号解析)",
2001            ),
2002            ServiceDesc::describe::<QueryRateReq, QueryRateReply>(
2003                format!("{}.query_rate", self.gw),
2004                "费率查询(margin=保证金率/commission=手续费率;状态为纯被动推送)",
2005            ),
2006            ServiceDesc::describe::<ConfigureReq, ConfigureReply>(
2007                format!("{}.configure", self.gw),
2008                "网关配置(插件自带配置面;手工输入账号/前置 → 复位守卫并发起连接)",
2009            ),
2010            ServiceDesc::describe::<StatusReq, GatewayStatus>(
2011                format!("{}.{}", self.gw, SVC_STATUS),
2012                "状态读数(0.9.0;登录态 / 断开计数 / 查询被拒计数 / 最近回报时刻;只读、不写日志)",
2013            ),
2014            ServiceDesc::describe::<DisconnectReq, DisconnectReply>(
2015                format!("{}.disconnect", self.gw),
2016                "停会话(0.9.0;实例与订阅保留,『连接』可恢复)",
2017            ),
2018            // 0.10.0(BD-CFG 环节7):凭据档位只读口(运维窗「已存/未存」)。
2019            ServiceDesc::describe::<CredentialStateReq, Vec<CredentialEntry>>(
2020                format!("{}.credentials_state", self.gw),
2021                "凭据档位(REFERENCED/RESOLVED/ABSENT;只读)",
2022            ),
2023        ]
2024    }
2025
2026    fn on_init(&self, ctx: &PluginContext) {
2027        let _ = self.ctx.set(ctx.clone());
2028    }
2029
2030    fn on_start(&self) {
2031        // 0.10.0(BD-CFG-16):先在 worker 时机解密已回灌的引用行(管理线程
2032        // 禁取秘;on_start 属插件 worker 面),再按原有 connect 语义发起 ——
2033        // 拿不到的键留在「待手配」,连接空值守卫原有语义不变(UX 零变)。
2034        self.resolve_pending_secrets();
2035        // 传输层已接入 → 按配置发起连接(原稿由 app 显式调 CtpGateway.connect)。
2036        // 无传输层的纯逻辑构建保持原状:emit 走 warn_no_transport 提示一次。
2037        self.connect();
2038    }
2039
2040    fn on_stop(&self) {
2041        // 先释放原生会话:之后 SDK 不再回调,回调入口才会随插件一起被放下。
2042        if let Some(t) = &self.trans {
2043            t.shutdown();
2044        }
2045        // 0.11.0 第4刀:摘插件前把攒着的合约尾巴发掉(半批不随插件进坟墓)
2046        self.flush_contract();
2047    }
2048
2049    fn on_mail(&self, event: &Event) {
2050        if event.event_type == ROUTE_TIMER {
2051            self.on_timer();
2052            return;
2053        }
2054        if event.event_type == e_req_type(&self.gw) {
2055            self.on_request(event);
2056        }
2057    }
2058
2059    fn as_configurable(&self) -> Option<&dyn Configurable> {
2060        // 0.10.0(BD-CFG 环节7):CTP 有配置面 → 显式 opt-in。
2061        Some(self)
2062    }
2063}
2064
2065/// 名称富化面(0.11.0 第5刀 R6):委托 / 成交 / 持仓三张行共用同一个动作。
2066///
2067/// 刻意做成插件侧私有 trait —— 这是**发送前的网关侧富化**,不是映射层的事实;
2068/// `map.rs` 保持"柜台给了什么就写什么"(0.7.0 甲的纪律:映射层不吃合约参数)。
2069trait EnrichName {
2070    /// 查表键(en_symbol 同形:合约表就以它为键)
2071    fn symbol(&self) -> &str;
2072    /// 名称栏(空 = 待补)
2073    fn name_mut(&mut self) -> &mut String;
2074}
2075
2076impl EnrichName for ek_data::order::OrderData {
2077    fn symbol(&self) -> &str {
2078        &self.symbol
2079    }
2080    fn name_mut(&mut self) -> &mut String {
2081        &mut self.name
2082    }
2083}
2084
2085impl EnrichName for ek_data::trade::TradeData {
2086    fn symbol(&self) -> &str {
2087        &self.symbol
2088    }
2089    fn name_mut(&mut self) -> &mut String {
2090        &mut self.name
2091    }
2092}
2093
2094impl EnrichName for ek_data::position::PositionData {
2095    fn symbol(&self) -> &str {
2096        &self.symbol
2097    }
2098    fn name_mut(&mut self) -> &mut String {
2099        &mut self.name
2100    }
2101}