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