# DESIGN — ek-ctp 0.2.0(开发中;0.1.0 决策全部沿用)
行为基线:`C:\dev\fuke\enkeep_ctp\enkeep_ctp\gateway\ctp_gateway.py`(899 行,vnpy 移植)。
移植纪律:行为逐字;结构则按 Rust 分层归位。凡与原稿有出入处,
在本文档登记(带行号)。
## 分层决策
- **wire**:仅供 native 桥用的最小字段集合。真实 `CThostFtdc*Field` → 本面
转换归 native(0.2.0)。**mock/纯逻辑路径不触碰任何 CTP 头文件**。
- **map**:纯函数,无状态。码值表逐字取自原稿 `STATUS/DIRECTION/OFFSET/ORDER_TYPE/EXCHANGE` 五张映射(67-73/76-103/113-118/120-128)。
- **driver**:`MdDriver`/`TdDriver` — `step(ev) -> Vec<Out>` 一步一输出向量。
零线程;时钟由插件层喂 `eTimer`;连接/认证/流控(原稿 while + `time.sleep`)
归传输层(DESIGN:D-FLCN)。
- **plugin**:总线身份 `CtpPlugin`,出门按**裸线键 `e<Which>.`**(0.4.0 起,见
D-EMIT;网关身份在载荷 `gateway_name`);进门
`eReq.<gw> = "[订阅|发单|撤单|查询|配置]"` 五服务 + `eTimer`。
## 关键决策(与原稿出入或内化)
| ID | 内容 |
|---|---|
| **D-SYS** | 未知委托状态/类型:原稿 `write_log + return`(683-686/704-707),本移植同(不走 `Mapped::Skip` 面向"已知但 miss"的行) |
| **D-FLCN** | CNC 流控(`while + time.sleep` 重发)原稿在 API 类内的查询循环;本移植 `ReqQryInstrument` 只发一次,重发由传输层(native 0.2.0)承担——纯逻辑面保留分片语义 |
| **D-AVAIL** | `AccountData.available()` 在 ek-data 是 **派生字段**(`balance − frozen`);原稿显式落 `Available`(CTP 语义=扣占用保证金)。契约面显式化记录:两者差额 = `balance − frozen − Available` = 占用保证金;if 需 CTP 面对照表加入 `Margin` 字段且由下游落。此行为锁定于测试 `td_account_mapping`。wire 面 `TradingAccountField.available` 保留为**原生对照记录面**(native 桥层使用,不映射进 ek-data) |
| **D-TIME** | 原稿 `datetime.now()` 取**本地上海(+08)**,本移植以 `SystemTime` + 固定 +08 偏移换算(UTC → +08),避免 tz 依赖(`days_from_civil`,Hinnant 算法) |
| **D6** | 撤单订单号非三段/非整数段 → 原稿 `int()` 抛 `ValueError` 逃向测试调用;本移植改为日志丢弃(D 结构化面,不崩调用方) |
| **D-PC** | 原稿 `PRODUCT_CTP2VT` 含 `PC_Combination('3') → Asset.SPREAD`;ek-data 0.2.2 的 `Asset` 无 SPREAD 变体 → 组合类行**跳过并告警**(与原稿"上抛"面差一档,等 ek-data 生态有 SPREAD 后再统一) |
| **D-PC-FILTER** | 合约收录勾选(2026-10-20 定,用户裁定)。**查询侧不过滤**:实测三个查询口都无法按类型过滤(SimNow 12 小时套 —— `ReqQryInstrument` 结构体无 `ProductClass`;`ReqQryClassifiedInstrument` 受理、立即收尾、**0 行**(连不过滤也是 0);`ReqQryProduct` 的 `ProductClass`/`ExchangeID` 被前置忽略、只有 `ProductID` 生效 —— 134 → 1),故仍按原稿 `reqQryInstrument({})` 全量一发(实测 2226 行 = `'1'`848 / `'2'`1304 / `'6'`74)。**收录侧严格丢弃**:按 `CtpConfig.product_classes` / `ConfigureReq.product_classes`(CTP 单字符码,**默认 `'1'` 期货`)判定,不在名单内 → 不进共享合约表、不上抛 `eContract`。**与原稿差一档**:原稿 `symbol_contract_map` 全缓存(期权在表里、只是不 `on_contract`),本移植未勾选的连表都不进 → 该合约的持仓/委托回报/成交回报/tick 一并消失(用户知情选定;要退回"表仍收录、只关上抛"只动 `TdDriver` 合约分支一处)。**空名单绝不等于全丢**:`map::ProductClasses::normalize()` 一律回落默认 —— 不是便利,是守卫,`contracts` 为空会让持仓查询整条被跳过(`contracts.is_empty()`)。**逐行告警改收尾汇总**(修 D-EXMISS 里"OnRspQryInstrument 告警+跳过"的日志面):原 Skip 每行一条 WARNING(期权 1304 行会刷 1304 条),现整个合约阶段一条「收录 N / 按勾选丢弃 M / 未映射 K」;两层判定顺序固定**先勾选、再 map**,同一行只计一次,否则汇总数字是假的。**被过滤合约的回报降级**:`RtnOrder`/`RtnTrade` 缺在册合约由 `LEVEL_ERROR` 改 `LEVEL_WARNING` + 措辞「该合约已被网关产品类勾选过滤」(这是我们自己勾选出来的预期结果,报 ERROR 会被当成故障),且**同一合约只提示一次**(回报会重放,同 D-NATIVE-EXTRA-3 的去重理由);持仓面原稿静默(543-546),现补同一条去重 WARNING,免得期权持仓消失得毫无痕迹。**0.7.0 订正**:被过滤合约的**回报降级面整个退役** —— 回报不再依赖合约在册(见 D-REPORT-FACTS),勾选只作用于合约查询侧;丢弃只剩「交易所码未映射」一条理由。UI 勾选只给期货/期权两项(`Asset` 无 SPREAD,给「组合/现货」是点了没用的选项)。**`reconfigure` 清共享合约表**(0.6.0 补,行为变化):勾选可在运行时收窄,不清的话「先勾期权、再改回只期货」对已在表里的期权合约不生效(它的 tick/持仓/回报照样并库),而 configure 本就是断开重连 + 全量重查(0.5.0 语义),清表才让收窄真生效。**守卫:只在 TD 侧有地址时清** —— MD-only 配置没有任何东西会把表重新灌满,清了等于把所有 tick 并库永久掐死。**代价写明**:清表到新一轮合约查询收尾之间,已订阅标的的 tick 会因缺合约被丢;原稿模块级全局表从不清,此处是有意偏差 |
| **D-REPORT-FACTS** | 回报入账两事实自足(2026-10-20 审计后定,0.7.0 甲)。原稿把 `contract` 当回报映射硬依赖:symbol/exchange 取自合约行(677-678/727)、缺合约整条丢弃、未齐先缓冲等收尾回放(661-667/722-724)、持仓查询见空表跳过(881-882)。审计裁定这是**时序耦合的误放大**:InstrumentID 每条回报自带,ExchangeID 亦随报文携带。落地:wire/POD/shim 三层给 Order/Trade/Position 三 POD 补 `exchange_id`;`map::order_from`/`trade_from` 的 `contract` 参数退役(改传报文预映射 `Exchange`);「未齐缓存 + 收尾回放」删除;持仓路径合约乘数降级为**可选富化**(不在册 size=0 → 均价不更新,量/冻结/盈亏照记,下一轮轮转补上)。丢弃只剩「交易所码未映射」一条理由(空/出六键,`report_drop_log` 同码去重 WARNING)。副作用(有意):勾选「只期货」时期权委托/成交(柜台手工单)也上账 —— 报文无 ProductClass,回报侧不再有过滤判据。`is_convert_required` 未动:下单面判今昨拆必须要合约(ek-converter 语义面,0.1.3 起回报入账同此放宽) |
| **D-MD-GATE** | 原稿 `md.current_date` 由 `datetime.now()` 撑;本移植每 2s 插门处同步推进(`md.update_date`) |
| **D-ORDER** | 订单号三段下划线 `front_session_ref`(原稿 681/830/850 同) |
| **D-EXMISS** | 交易所映射 miss → 原稿 `KeyError` 崩回调;本移植 `Mapped::Skip` + 日志(见 D-SYS 家族纪律,`OnRspQryInstrument` 走"告警 + 跳过") |
| **D-LASTVOL** | `last_volume` = 0 契约:原稿网关不复算(只在 `onRtnDepthMarketData` 起手 open 档),本移植同;**下游**(如 `OmsGateway`/组合应用)自算 |
| **D-QROT** | 2s 插门轮转原稿 `self.count`(226-233,≥2 清零);本移植独立计数器同面(account → position 轮转;合约未齐跳过持仓 = 874-881 一致);**次序**与原稿一致:先执行查询函数再 `md.update_date`(231-235)。**0.7.0 重排(armed 闸门)**:登录成功即轮转的老链在登录前只会白发(CTP 拒、native 丢返回码),还抢每秒查询名额 —— 现改为结算确认拍先显式查一笔资金(定稿顺序先合约后资金,大概率撞流控被拒,兜底自会补),`settle_pending` 起 3 秒拍计数,**资金回报到手即 arm**(下一拍先查持仓)或满 3 秒兜底 arm(`on_timer_tick` 按 1s 计,不进 2s gate —— 那会退化成 4 秒);重连/`reconfigure` 复位,幂等。「合约表为空跳过持仓查询」守卫同拍拆除(见 D-REPORT-FACTS) |
| **D-POS-YD** | 上期系昨仓特判的**原稿缩进真相**(559-565):`if exchange in {SHFE,INE}: if YdPosition and not TodayPosition: yd=Position`;不满足条件**什么都不写**(yd 保持缺省 0);外层 else(非上期系)取 `Position − TodayPosition`(不回读 YdPosition 字段——CTP 对非上期系不给该值,原稿兜用 P−T)。仓位键面 f-string 所含量为「(InstrumentID, PosiDirection)」元组(548),本移植同值键 `"{inst}:{dir}"`。审计记录:初次修复曾把缩进写成两面分支并锁错断言,后依原稿逐字重修(§td_position_merge_shfe_and_others 三行断言样例) |
| **D-POS-EMPTY** | 持仓查询的**空数据面**(原稿 539-540):`data` 缺(None 末包/空账号)→ 回调直接 return:不冲刷、不发 on_position、**连 EVENT_POSITION_QUERY_DONE 也不发**。本移植 `row=None → 空 Vec`,并有回归锁 `td_empty_position_last_quiet` |
| **D-CFIN** | 合约收齐事件(原稿 659 → `BaseGateway.on_contract_finish` → `EVENT_CONTRACT_FINISH = "eContractFinish"`,见 `enkeep_trade/trader/event.py:21`):全局线、无 gw 后缀。ek-oms 0.2.1 未移植此线;ek-ctp 按逐字纪律补上,载荷给网关名以辨源(原稿该事件无载荷——多网关场景 ek 场面需要,偏差已记)。0.2.0 若 ek-oms 补线,本线值不变 |
| **D-ADDR** | 前置地址规整(原稿 172-187)属 gateway.connect 层:缺 `tcp://|ssl://|socks` 前缀则补 `tcp://`。ek-ctp 面由**调用方**在 CtpConfig 填完整地址(plugin 注释明确),native 0.2.0 如需兼容裸地址,在桥层补同一守卫 |
| **D-GWNAME** | 多账号面的注册名**由 (broker, 账号) 派生,不写进配置**:`ek_ctp::derived_gateway_name(broker, account) → "ctp_<broker>_<账号>"`(例 `ctp_9999_278153`)。理由:宿主名字规则 `[A-Za-z][A-Za-z0-9_-]{0,31}` 禁点号且 ≤32(`en_orderid = "{gw}.{front}_{session}_{ref}"` 的反解用 `split_once('.')`,gw 带点号会把反解串成 `"ctp"`);手填会重名/打错,派生名相同则宿主 `Duplicate` 直接挡下(不需要账号注册表)。非法输入(空 / 含 `[0-9A-Za-z]` 之外字符 / 派生名超 32)回 `None`,**调用方须拒绝并说明,不得静默替换**。`CtpConfig::gateway_name_or_default()` 的 `"ctp"` 缺省仍在(单账号旧装配面) |
| **D-WERR** | 错误日志文本逐字(原稿 216-222):`"{msg},代码:{id},信息:{text}"`(全角逗号/冒号),ek-ctp `map::error_text` 同式,driver 全部 `Some(e)` 臂走同一函数 |
| **D-CTR-FILL** | `ContractData.is_trading`(原稿 634-649 未填,Python 模型默认 None;ek-data/enkeep 两边字段面**均有**该位——`object.py:368`/`constant.rs:62`):ek-ctp 显式填 `Some(data['IsTrading'] == 1)`。属 ek-data 字段面完整化而非多余面;若要严格回退可置 `None` |
| **D-STRUCT-SKIP** | 原稿裸崩面(`symbol_contract_map[...]` KeyError / `strptime` ValueError / `DIRECTION[...]` KeyError / sysid 缺失 KeyError)在 Rust 统一转为 `Mapped::Skip` 结构化丢行(日志可观测、不坠回调线程)。数据正常路径与原稿完全等价;畸形数据路径为有意偏差(更稳) |
| **D-INS** | 插单 rt 返回码(原稿 844-848):`reqOrderInsert` 返回非 0 → 日志「委托请求发送失败,错误代码:n」+ 回 `""` 且**不回 SUBMITTING 单**。0.1.0 起该裁决已落在 trait 面:`CtpTransport::td_insert(&InputOrder) -> i32`,插件层按返回码放行/否决 SUBMITTING 单(测试 `plugin_insert_rt_rejects_submitting_release` 锁定);native 桥 0.2.0 只需回填真实 rt |
| **D-CANCELRT** | 撤单 `reqOrderAction` rt 原稿不检(870-872),trait 原样透传(`td_cancel -> i32`)由 native 自定面处理 |
| **D-SDK-SHIP** | SDK 分发形态(0.2.0 改):四份头文件 + win64 导入库/DLL + linux SO **随 crates.io 包发布**(原始 16.66MB → 压缩后约 5.9MB,单包容许 10MB),撤销原 `exclude = ["native/**"]`;下游安装即得、无需自备 SDK。**许可边界**由 `NOTICE` 划清(MIT 不覆盖上期技术二进制,API 6.7.7)。**应急预案**:若 SDK 膨胀(如加 macOS framework)逼近上限,按平台拆包(win64 / linux 各一包) |
| **D-NATIVE-SHIM** | 原生接入形态(0.2.0/M1):CTP 的工厂是 MSVC 修饰的 C++ 静态成员函数(`CreateFtdcMdApi`),SPI 是抽象 C++ 类 —— Rust 无法直连。故引入**唯一**的 C++ 翻译单元 `native/shim/ek_ctp_shim.cpp`(配纯 C 头 `ek_ctp_shim.h`):工厂/虚函数全在 C++ 侧适配,暴露给 Rust 的是 `extern "C"` + POD 面。不引入 `cc`/`bindgen`(`build.rs` 直接驱动 `cl.exe`/`lib.exe`),保持"零第三方 crate" |
| **D-NATIVE-ABI** | 跨 ABI 取值纪律:只传整数 / C 字符串 / 不透明句柄 / `#[repr(C)]` 回调表;**绝不**传 `std::string`/`std::vector`/C++ 类指针(CTP DLL 由旧 MSVC 构建,跨版本传 STL 对象是 UB)。回调表在 C++ 侧**按值拷贝**进 SPI 对象,故 Rust 侧可安全传临时量(初版误存指针 → 悬垂,自查后改掉) |
| **D-NATIVE-ABI-CHK** | 回调表错位的双保险:① C++ 侧每处 SPI 覆盖都带 `override`(SDK 改虚函数签名 → **编译失败**);② shim 导出 `sizeof(EkMdSpiCallbacks)`,测试断言它等于 Rust 侧 `size_of::<MdSpiCallbacks>()`(字段漏/错位 → 测试失败)。**M2 订正**:原计划的"字段级 `static_assert`(>= size/offset)"**取消**——事件结构改为 C++ 侧逐字段抽取成 POD(D-NATIVE-EXTRACT),Rust 不镜像 CTP 结构布局,故没有 offset 可断;取而代之的是每个 POD 的 `sizeof` 断言 + 逐字段自检(Ek* 全字段) |
| **D-NATIVE-NOOP** | 特性开关纪律:`native` **默认关闭**;关闭时 `build.rs` 在首个 `CARGO_FEATURE_NATIVE` 检查处直接 return,**完全 no-op** —— 纯逻辑消费方与 docs.rs 不触碰任何 C++ 工具链(`[package.metadata.docs.rs] no-default-features = true`) |
| **D-NATIVE-TARGET** | M1 只支持 **x86_64-pc-windows-msvc**:`target_os/env/arch` 任一不符即在 `build.rs` panic 并说明原因(CTP 导入库是 MSVC x64 产物),而不是让用户面对链接期符号错误。**Linux/GCC 路径尚未实现**(SDK 的 `.so` 已随包备好,待补)。此条构成对 README/CHANGELOG 早期"MSVC 或 GCC"说法的**修正** |
| **D-NATIVE-TOOLCHAIN** | MSVC 定位策略:优先用环境里现成的 `cl.exe` + `INCLUDE` + `LIB`(已配好的机器零成本);否则经 `vswhere` 自建(vswhere → `Microsoft.VCToolsVersion.default.txt` → VC include/lib + Windows SDK 最新版 ucrt/um/x64)。**不要求"VS 开发者命令行"**;`EK_CTP_FORCE_VSWHERE=1` 强制走自建路径,使该分支能在本机被真实验证 |
| **D-NATIVE-DLL** | DLL 投送:`build.rs` 把 `thost*.dll` 复制到 `target/<profile>/` **与** `target/<profile>/deps/`。理由:Windows 只在 exe 所在目录、系统目录、cwd、PATH 里找 DLL,而测试可执行文件在 `deps/`、cwd 是包根 —— 只放一处会"能编过但加载失败"。目标目录由 `OUT_DIR` 上溯三级推导(兼容自定义 `CARGO_TARGET_DIR`) |
| **D-NATIVE-ENC** | 编码纪律:`native/shim/**`(C++)、`build.rs` 的报错文本、`scripts/*.ps1` 正文一律 **ASCII**。CTP 头是 GBK;源码保持 ASCII 且不给 MSVC 传 `/utf-8`,它才按系统码页正确解码头文件(否则非中文码页机器上会踩 C4819/乱码解析)。中文只出现在 Rust/Cargo 文档与 markdown 里 |
| **D-NATIVE-SPI** | M1 只覆盖实际消费的 6 个 MD 回调(`OnFrontConnected`/`OnFrontDisconnected`/`OnRspUserLogin`/`OnRspError`/`OnRspSubMarketData`/`OnRtnDepthMarketData`),其余 7 个虚函数保留头文件里的空内联体;行情体在 M1 只**透传不透明指针**,字段落地属 M2(`wire` 面) |
| **D-NATIVE-SURF** | **不发明原稿没有的面**(M1 自查修正):shim/FFI 只暴露原稿 MD 路径**真正调用**的调用 —— `createFtdcMdApi` / `registerFront` / `init` / `reqUserLogin` / `subscribeMarketData` / `exit`(→ `Release`),外加 `registerSpi`(原稿由 Python 绑定内部完成)。**刻意不提供**:`Join`、`GetTradingDay`、`ReqUserLogout`、`UnSubscribeMarketData`、`SubscribeForQuoteRsp`、`UnSubscribeForQuoteRsp`、`RegisterFensUserInfo`(已核原稿行 368-380 / 749-777:**全文无 FENS 分支**)。初版把这 7 个一并暴露,属多余面,已裁掉 |
| **D-NATIVE-DIAG** | 唯一超出原稿调用面的导出:`GetApiVersion`(`ek_shim_md_api_version`),外加 `ek_shim_last_error` 与 `ek_shim_sizeof_md_spi_callbacks`。三者**都不进业务路径**,只服务冒烟测试与诊断:证明"加载到的确实是 6.7.7 这套 SDK"、失败原因可读、回调表 ABI 不错位 |
| **D-NATIVE-EXTRACT** | 字段抽取的**位置裁决**(M2):CTP 结构体 → POD 的逐字段搬运放 **C++ 侧**(`native/shim/ek_ctp_pod.cpp`),Rust 只认 POD(`src/native/pod.rs`)。理由:源字段名写错在 C++ 是**编译错误**(`p->LastPrice`),而在 Rust 镜像布局下只会是**静默错值**。代价是无法离线端到端验证 C 侧映射 → 用两件事补偿:① 每个 POD 的 `sizeof` 断言;② `ek_pod_selftest_*` 逐字段自检(在 C++ 里构造带唯一值的 CTP 报文,经真实转换函数后由 Rust 逐字段断言,顺序/类型/映射错位都会被抓住)。字符串一律 `const char*`(UTF-8,缓冲区由回调栈帧持有,Rust 消费完即弃),不用固定 char 数组 —— 免去"容量约定"这一层人为风险 |
| **D-NATIVE-ENC-IN** | 入参编码(M2 修正 M1 的潜在偏差):原稿经 Python 绑定把所有字符串参数以 **GBK** 交给 SDK;M1 直接把 Rust 的 UTF-8 字节透传,非 ASCII 口令/路径会与原稿不一致(ASCII 下不可见,属潜伏)。M2 起写方向统一 UTF-8→GBK(CP936,`WideCharToMultiByte`),读方向 GBK→UTF-8,转换全在 shim 内 —— 零第三方 crate,`common.rs` 的文档即契约。**流文件路径**也走同一条转换(原稿 `str(path).encode("GBK")`) |
| **D-NATIVE-TD** | TD 面范围(M2):12 个 TraderSpi 覆写(= 原稿 `CtpTdApi` 实现的那 12 个)+ 请求面 `createFtdcTraderApi`/`subscribePrivateTopic(0)`/`subscribePublicTopic(0)`/`registerFront`/`init`/`reqAuthenticate`/`reqUserLogin`/`reqSettlementInfoConfirm`/`reqQryInstrument`/`reqQryTradingAccount`/`reqQryInvestorPosition`/`reqOrderInsert`/`reqOrderAction`/`exit`。`subscribePrivateTopic(0)` 的 0 = `THOST_TERT_RESTART`(枚举值,原稿裸字面量、无注释;Rust 侧命名常量 `TE_RESUME_RESTART`)。回调**只抽驱动消费的字段**:认证/结算确认/撤单回报只带 error,查询回报带行 + `is_last`,登录回报额外带 `FrontID`/`SessionID`(原稿 477-478 用它拼委托号)——多抽的字段是"关于报文形状的无人验证的断言" |
| **D-NATIVE-ERR** | 错误的 `None/Some` 判定面(M2):`error_id == 0` → `None`(成功),这是原稿 `if not error["ErrorID"]` 的判定面(认证 462、登录 476、行情订阅 296、行情登录 277)。**两处例外**:`onRspOrderInsert`(492-513 不判错、一律重造 REJECTED 单)与 `onRspOrderAction`(515-517 无条件记撤单失败)按"SDK 给了 info 就带上"原样搬运 —— 那里若按 `error_id` 过滤会丢掉原稿行为(驱动侧 `RspOrderInsert` 要求 `Some(e)` 才重造单)。两者在 `transport.rs` 里分别是 `error_checked` / `error_raw`,差异写进了函数文档 |
| **D-REQID** | reqid 归属(M2):`CtpTransport` 的形参里**没有** reqid(0.1.0 定的接缝形状),故由传输层持有两个 `AtomicI64`(MD/TD 各一,原稿两个独立计数器,首个 = 1)。CTP 只要求会话内唯一,且回报里的 reqid 驱动并不消费(TdEvent 不含该字段) |
| **D-FLOWPATH** | 流文件落盘(M2):`<flow_root>/<网关名小写>/{Md,Td}`,对齐原稿 `get_folder_path(gateway_name.lower()) + "\\Md"`;驱动给的 `flow_path` 尾段形如 `"<gw>\\Md"`,传输层把网关名段小写后拼到根目录下并 `create_dir_all`(目录不存在 → CTP 建会话失败)。`flow_root` 走 `CtpPlugin::with_native(cfg, flow_root)` 形参,**不污染纯逻辑的 `CtpConfig`**(native-only 概念不塞进业务配置) |
| **D-CONNECT-TRIGGER** | 连接触发(M2):**住插件内**,不进核心/公共层 —— 每个插件自持自己的连接。宿主 `start()` → `Plugin::on_start` → `CtpPlugin::connect()`(先 TD 后 MD,原稿 `CtpGateway.connect` 186-187 次序);也可显式调 `connect()`(先配后连/重连)。幂等由驱动守卫裁决(MD 重复静默、TD 重复转认证)。原稿 `connect` 末尾的 `init_query()` 只是把查询轮转挂到 `EVENT_TIMER`(237-240),本 crate 的对应面是宿主 eTimer → 插件 `on_timer`,不在 connect 里动作 |
| **D-FLCN-IMPL** | D-FLCN 的落点订正(M2,依原稿复核):重发**不在** `onRspQryInstrument`(614-667),而在 `onRspSettlementInfoConfirm`(519-533)——`while True: n = reqQryInstrument({}); reqid += 1; if n == 0: break; sleep(1)`,**无次数上限、无日志、重试也消耗 reqid**。实现落 `NativeTransport::td_qry_instrument`(native 下它本就跑在 CTP 回调线程,与原稿"回调里阻塞重发"结构同构;原稿注释 525 自陈理由是流控)。**已知且刻意保留的风险**:返回码永远非 0 时该回调线程被占住(原件同此行为),不擅自加上限 |
| **D-NATIVE-THREAD** | 线程模型(M2):SPI 回调跑在 CTP 自己的线程上,**不引入 ingress 线程** —— 回调直接 `on_md_event`/`on_td_event` 进插件:驱动状态本就在 `Mutex` 里自锁,`PluginContext` 是 `Arc<EventEngine>`(可跨线程 `put`),故回调线程 = 原稿回调线程,`put` 与原稿"回调里直接 `gateway.on_tick`"同构。(M2 规划阶段曾设想 ingress 线程;读 `ek-core` 后确认不需要。) |
| **D-NATIVE-THREADSAFE** | 句柄跨线程(M2):`MdApi`/`TdApi` 只实现 `Send`(**不实现 `Sync`**),且传输层用 `Mutex` 串行化全部句柄访问 —— 依据是 CTP 的请求方法可跨线程调用(请求与 SPI 回调本就分处两线程,vnpy 与主流用法皆如此)。**回退方案**(若 M3 实连暴露出 SDK 并非线程安全):把全部 api 调用固定到单一 api 线程 + 命令队列,那时才真需要 ingress 线程 |
| **D-NATIVE-BACKPRESSURE** | 背压语义差异(M2,生态属性、改不了,只记录):ek-core 的邮箱有上限(`MAILBOX_CAP = 1024`),溢出时**事件被丢弃**;原稿 Python 是**无界队列不丢**。高频行情下两者行为不同(丢 tick vs 全收)。ek-ctp 侧不做聚合/补偿;若日后需要,属 ek-core 或载荷方的事 |
| **D-NATIVE-KEEP** | 刻意**不抽象** MD/TD 两套平行代码(回调表、RAII 句柄、请求包装各自成文)。理由:两侧基类/方法名/报文都不同,抽象收益低于可读性损失。但共用件必须共用:`common.rs`(C 字符串搬运、shim 错误文本)、`pod.rs`(转换)、`transport.rs`(回调投递与接线)。**M2 也不得"顺手加回"** M1 已裁掉的 7 个调用(D-NATIVE-SURF) |
| **D-NATIVE-POD-NOTE** | POD 面三条"忠实但下游需知道"的事实(M2,独立复核记录):①`CombOffsetFlag`/`CombHedgeFlag` 在 CTP 是 `char[5]`,抽取按**固定数组长度**取串(可能没有 NUL 结尾,直接 `strlen` 会越界),故回显若真填满 5 字节就是多字符串 —— wire 面**原样保留**,消费侧(映射表按单字符比较)应只取首字符;②写方向 `i64 → int`(`volume_total_original`/`min_volume`/`front_id`/`session_id`)**不做范围检查**,超 `INT_MAX` 静默截断(正常单量不可达,记档);③`ek_pod_to_input_order*` 先把整个 CTP 结构 memset 清零,**wire 面之外的字段**(`RequestID`/`StopPrice` 等)保持 0,需要时由调用方补 —— 这是"绝不把栈垃圾当报单参数发出去"的对价。另:读方向零堆分配(≤256 wchar_t 用栈缓冲),写方向每字段一次 `malloc`(非热路径) |
| **D-NATIVE-AUDIT** | M2 独立复核(两位只读审计)的订正与记档。**已修真错**:① `OnRspOrderInsert` 的 `order_scratch` 曾声明在 `if (pInputOrder != 0)` 块**内**,出块即失效,而回调在块外才被调用 —— POD 里所有 `const char*` 指向已结束生命周期的栈缓冲(悬垂指针/UB),已提到函数作用域(并保留"与错误文本各自独立缓冲"的原意);② `map::ctp_datetime` 先按**字节**长度判 8、随后做 str 切片,非 ASCII 输入(GBK 报文经 shim 转 UTF-8 后的汉字,恰 8 字节)会 panic,而它由行情/委托/成交三条回报路径调用(全在 `extern "C"` 帧内,edition 2024 遇 panic 直接 abort),已加 `is_ascii()` 守卫 + 2 条回归测试;③ 纳秒换算改 `checked_`(公元 2262 后装不进 i64,毫秒是经纪人可控字段),溢出 → `None` 丢行。**回调帧内禁止 panic** 由此成为面纪律:可达的两个 panic 源已改成丢行,其余 `lock().unwrap()` 的中毒路径**当前不可达**(唯一 panic 源都在回调帧内,第一次即 abort,观察不到毒化),**不引入 `catch_unwind`**(会把 bug 藏起来),以 `extern "C"` 的 abort 语义作最后兜底。**只记不改**:④ `register_spi` 重复调用是"先析构旧 SPI 再 `RegisterSpi`",窗口期 api 持悬垂指针 —— 本 crate 只注册一次(Rust 侧 `slot.is_some()` 门),shim 内已加注释;⑤ `td_qry_account` 填了 `BrokerID`/`InvestorID` 而原稿传 `{}`(874-877),属 M0 起的**有意偏差**(更安全),记录不改;⑥ `TdDriver.reqid` 在 native 下成了只写字段(原稿 `CtpTdApi.self.reqid` 的忠实残留 + D-REQID 的后果),保留以免动 M0 已锁测试面。**已知覆盖缺口**:`EkInputOrderAction` 无 C++ 侧独立自检(仅 POD→CTP→POD 闭环,成对写错会抵消)**——已收口(2026-10-15)**:新增 `ek_ctp_verify_input_order_action`(真实转换 → 按 CTP 字段名独立重建期望结构 → 逐字段比对,8 项序号化;Rust 侧 `input_order_action_converter_fields_match_names` 断言 0)⇒ 转换内任何"拿错源字段"的错位会在比对处显形;写方向 `char[5]` 最多写 4 字节(5 腿组合单会丢第 5 个标志,**仍留**);**新增欠账(2026-10-16)**:`OnRspQryInstrument`/`OnRspQryInvestorPosition`/`OnRspQryTradingAccount` 三个查询回调在 C++ 桥里 `(void)pRspInfo`(ek_ctp_shim.cpp:283 等)——查询错误(如 SimNow "查询未就绪"被吞,只收 (NULL 行,is_last));住户三个新增接口回调已携带错误面,这三路存量因 ABI 已发布不改,0.3.0 事件化时一并收口 |
| **D-CTR-SHARE** | 合约表共享面:原稿为**模块级全局** `symbol_contract_map`(131;该文件唯一可变全局;读写 309/498/544/654/676/727,查询 gate 881),即**跨网关实例共享**。ek-ctp 按 ek 构架(核心只管生命周期、不经手业务,插件内务归插件)改为**每插件各持一份**,插件内 md/td 共享同一份(与原稿等价)。故**单网关场景行为完全等价**,**多网关场景不作跨网关覆盖**(比原稿更稳,属有意偏差)。将来若需共享,做加法:`pub use driver::Contracts` + 可选注入构造 |
| **D-TZ** | CTP 日期时间的时区口径,以及 **2026-10-15 实盘联调抓到的两个真错**。CTP 报文里的 `ActionDay`/`UpdateTime`/`InsertDate`/`InsertTime`… 是**北京墙钟**,原稿按 `CHINA_TZ = Asia/Shanghai` 建 tz-aware datetime(`ZoneInfo`,128 行;1991 年后无夏令时 ⇒ 与固定 +08:00 逐秒等价),而本 crate 的 `DtNs` 是 **UTC 纪元纳秒**(ek-data `time.rs:3-6/29-30`)⇒ 换算必须**减** 8 小时。① `map::ctp_datetime` 曾写成 `+ 8 * 3_600`:tick/委托/成交时间戳**整体偏 +16 小时**(ASCII 报文看不出、自比型断言也看不出;实盘拿"行情新鲜度 vs `now_nanos()`"一比即显形 —— 实测差 28782s);② `map::days_from_civil`(Hinnant)里 `(153*mx + 2)/5` 的 `+2` 曾写成 `-2`:**部分日期整体差一天**(实测 2026-01-01、2026-04-15/16 差一天,而 2026-03-01、2026-12-30 恰好正确 —— 这正是"拿被测函数自己当期望"的断言恒过的原因)。两条已修,并立**面纪律**:时间戳一律用 ek-data `civil_to_nanos`/`now_nanos` 这类**独立真值**断言(`ctp_datetime_matches_independent_utc_oracle`,覆盖跨年/闰日/毫秒/旧错日期 7 例;组合层 5 例:ActionDay 正常所/DCE→current_date/空 ActionDay→current_date/InsertDate+Time/TradeDate+Time),**禁止自比**。**组合规则总表(以原稿为唯一真源,2026-10-15 钉死)**:行情 =`f"{日期} {UpdateTime}.{UpdateMillisec}"` → `%Y%m%d %H:%M:%S.%f`(313-321;日期 = ActionDay,空或 DCE → current_date),Python 的 `.%f` 把 `"500"` 解析成 500000µs ⇒ 与本端口 `millis×1e6ns` 逐值一致;委托 = `InsertDate+InsertTime`(`%Y%m%d %H:%M:%S`,688-690);成交 = `TradeDate+TradeTime`(731-733);`current_date` 由 gateway timer 每 2 拍经 `update_date()` 刷新(226-235→406-408;MD 构造时初始化 263)。全文件 datetime 组合只此三处;持仓/资金/合约无 datetime。**面纪律**:date×time 组合只许存在于 `map::ctp_datetime` 一个入口。附带三条"忠实原稿但消费侧需知"的事实:`TickData.update_time` 原稿就没填(端口保持空串),要时间只能走 `datetime`;DCE 分支用"本地当前日期"拼 tick 日期 ⇒ 午夜后到日盘前,DCE 的 tick 时间戳会落在**未来**(原稿同此,非缺陷);收盘后交易所仍推**旧快照**(实测 p2709 曾被推 14:59 的当日尾盘快照,`ActionDay` 是当时的),`ActionDay` 不能当"当前日期"用 |
| **D-DATE-AXIS** | 两轴附加字段(0.2.1,**有意偏差**,2026-10-15 定):原稿网关不产出交易日(全文件不读 `TradingDay`;下游 engine.py 2514-2521 用 `hour>17 或 hour<5 → +1` 启发式推 trading_day 且有会话缓存)。该启发式三种必错:**周五夜盘**(给周六,真实是周一)、**长假前夜盘**(给假期,真实是节后)、**午夜后重启**(多算一天)。端口在行情面补两条轴,组合语义零改动:`TickData.trading_day` = 报文 `TradingDay` 原值 → `Option<i32>`(业务日权威;报文缺省/畸形 → `None`);`TickData.action_day` = 参与拼 `datetime` 的日期串(规则化自然日=DCE 特判后的 `date_str`,与原稿口径一致)。**POD 面同补** `EkDepthMarketData.trading_day`(C++ 字段名 `TradingDay` 写错即编译失败,自检兜底;`wire::DepthMarketData` 是唯一真源,POD 逐字段序号 +1,trading_day=k34/action_day=k35)。**不做的部分**:委托/成交面两轴(ek-data `OrderData`/`TradeData` 无字段,defer 到 ek-data 0.3.0,两个回报本就有 `TradingDay`+`InsertDate`/`TradeDate` 双轴齐全);登录回报的 `TradingDay` 与六所时间 `SHFETime`…`GFEXTime`(POD 面大、收盘后语义不明,报价自带 TradingDay 已够行情面)。**禁止**引入"按交易所推算交易日"的分支——交易所给的才是权威。**实测口径(2026-10-15 21:05 跨日窗口,6 所 48 样本,LIVE-TEST §8)**:SHFE/INE/DCE/CZCE/CFFEX/GFEX 的 ActionDay **一律=自然日(当日 1015)**、TradingDay=次日(1016),无例外 —— 与原稿"DCE 是交易日口径"的注释**不符**(SimNow 单点);端口保持原稿 DCE 特判(byte-faithful,21:00-23:59 等价、午夜后同原稿产生未来时间戳),撤否待拍板。**午夜后补测(2026-10-16 00:06 轮,判定收口)**:SHFE/INE 午夜后实时 tick 的 ActionDay **立刻切到 20261016**(=自然日=TradingDay,连续 50/3 行无中断,SHFE 0.21s→300.94s)⇒ 交易所 00:00 立即翻转 ActionDay,"ad=报文原样"全时段零修正;其它所 00:06 后只剩过期收市快照(**ad=1015** 恰证"收盘后旧快照持续推送");DCE 两窗口都是自然日 ⇒ 特判在 SimNow 的输出与通用规则**永远相等**(本地日=自然日),无法证伪;td 全所全时段恒 20261016 |
| **D-TIMER-ROUTE** | eTimer 驱动路(M3 实测勘误,2026-10-15):宿主 ek-event 引擎**自带 1s 定时线程**(`ek-event engine.rs run_timer`,注释明言对齐原版 `_run_timer`),每 1s 投递 `eTimer` ⇒ 插件订阅 `ROUTE_TIMER`(`on_mail` → `on_timer`)即得原稿节奏。**实盘曾双驱动**:示例另起一条 1s 线程直调 `p.on_timer()`,与宿主线程相位交错(~0.5s 一调用)⇒ 2 拍门槛每 ~1s 过一次 ⇒ 账户/持仓查询 **2× 快**(实测 3 拍出 a@1.05/p@2.04/a@3.06;而且首个 eAccount 早于理论首拍)。已删示例线程,实测回原稿节奏(a@2.02/p@4.02/a@6.03/p@8.02,每类 ~4s 对齐 224-235)。纪律:**任何宿主都不自造定时源**(与 D-EVENT 同判),eTimer 只从宿主事件路来 |
| **D-EMIT** | 数据行上抛 0.4.0 改**裸线键**(`eTick.`/`eOrder.`/… + 载荷,不再 `.<gw>` 后缀):原稿 emit 面带 gw 后缀,但 ek 总线精确派发 + 消费侧 dm/oms 注册裸前缀 → 带后缀行**零订阅者**,ctp 数据流在装配中被静默丢弃(T5 实连揭出;sim 裸键先例全绿)。网关身份在载荷 `gateway_name`,消费侧按它分流足够 |
| **D-CONFIGURE** | 插件自带配置面(0.3.0,2026-10-17 用户定盘):「激活列表时,插件需要配置就在插件里配置」——**插件永不读配置文件**,界面/宿主手工输入经 ask `{gw}.configure` 进来。`ConfigureReq` 校验(双地址至少一非空;TD 侧 broker/td_user/td_password 齐;md_user/md_password 空回落 td 侧)→ 驱动 `reconfigure`(复位 connect/auth/login/contract 守卫;订阅集与持仓/订单缓存保留;**共享合约表 0.6.0 起清空(TD 侧有地址时;MD-only 不清)**,理由见 D-PC-FILTER)→ 插件 cfg/broker/user 换血 → 立即 `connect()`(先 TD 后 MD)。`connect()` 空地址守卫:皆空 → 跳过 + INFO 日志(on_start 空配置形态不瞎连)。已连接态再 configure:**0.5.0 起 = 强制断开重连**(`td_release`/`md_release` 先断旧会话——传输层 connect 幂等,不断开则新账号/密码永没有登录显形机会,T5 步 11/12 实测钉死;Release 归 RAII Drop,可能阻塞到回调收尾;纯逻辑面 release 默认空)。`CtpPlugin::configure` 与 `connect` 同为公开显式控制口 |
| **D-NATIVE-EXTRA-3** | 附加接口面三件(超出原稿,2026-10-16 定):**合约保证金率**(`ReqQryInstrumentMarginRate` + `OnRspQryInstrumentMarginRate`,主动+被动成对,SDK TraderApi.h:690/143)、**合约手续费率**(`ReqQryInstrumentCommissionRate` + 应答,:693/146)、**交易状态**(`OnRtnInstrumentStatus`,:275 —— **SDK 6.7.7 无 `ReqQryInstrumentStatus`,主动面不存在,该件纯被动**,从公有流推送)。数据镜像 `wire::InstrumentMarginRateField/InstrumentCommissionRateField/InstrumentStatusField`(12/13/8 字段,reserve1/2 省略同 InstrumentField 先例),k 计数逐 struct 从 1(f64→k/i64→1000+k/i32→2000+k/String→s<k>/CTP 标量 char→'A'+k%26),镜像+自检三件齐;**查询输入两 POD 分立**:`QryInstrumentMarginRate` 带 `HedgeFlag`(6 字段)、`QryInstrumentCommissionRate` **无** `HedgeFlag`(5 字段)—— CTP 两个查询 struct 字段集本就不同,不得共用(共用即写向错位)。事件接缝:TD 回调表追加 3 列(先事件后 eLog,对照 D-NATIVE-KEEP 尾注记档"0.3.0 再设事件型"),Rust 驱动三臂**只产出 eLog**(不新增 ek-data 实体);status 显形为 eLog 且带 `(exchange,instrument,status,segment)` **去重**(公流重放整批推全市场快照,实测重推把 eLog 冲刷成一屏,LIVE-TEST §9.5)。**审计修正**:两个新查询回调在 C++ 侧**携带错误面**(`fill_error` 同 `on_rsp_order_insert` 范式),区别于既有三个查询回调对 `pRspInfo` 的 `(void)` 丢弃 —— 存量三路不改(ABI 已发布),记为欠账(**0.9.0 已还一半,见 D-NATIVE-AUDIT-REPAID**;且该"审计修正"本身有个 scratch 共用 bug,同见该行)。**示例落点**:`live_simnow` 第 11 步(费率/状态查询面),margin 用 hedge='1' 投机档、两查询间隔 ≥1.05s(同会话 1 次/秒,SimNow 压端会吞查询),status 由公流自然到的帧即时显形(实测 ag/j 档位值与全市场非交易快照) |
| **D-NATIVE-AUDIT-REPAID** | 0.9.0 还掉 **D-NATIVE-EXTRA-3** 记的那笔欠账的一半:`OnRspQryInvestorPosition`/`OnRspQryTradingAccount` 两路带上 CTP 的 `pRspInfo`(`fill_error` 同范式),Rust 事件 `RspQryInvestorPosition`/`RspQryTradingAccount` 新增 `error: Option<ErrorField>`,判据用 `error_checked`(只有 `error_id != 0` 才算被拒 —— SDK 常带 error_id=0 的空 info 结构,与认证/登录一路同式)。**为什么必须还**:此前这一路只有 `row: None`,「查询被拒」与「真没持仓」在 Rust 侧长得一模一样,状态口的「查询被拒计数」就是无源之水(F2 掉线后查询断供在界面上完全不可见)。**合约查询仍不带**(`on_rsp_qry_instrument` 保持 3 参):它不在轮转两查询的「断供」判据里,0.9.0 刻意划界,不为了整齐而扩面。**0.9.1 还清另一半**(`on_rsp_qry_instrument` 也带 `pRspInfo`,Rust 事件 `RspQryInstrument` 新增 `error`,状态口加分项 `td_instrument_rejects`)—— 划界当时站得住(合约查询不在轮转里),但「柜台拒了这条查询」与「柜台没有合约」在 Rust 侧都是 `row: None`,这一档歧义会让合约表空掉时下游全线瞎(持仓乘数、勾选、查询窗解锁),所以扩面不是为整齐是为消歧。见 **D-INSTRUMENT-REJECT**。**顺带修真 bug**:费率两查询把错误文本与数据行写进同一块 `scratch`,而 `ek_pod_from_*` 一律从偏移 0 起写 ⇒ 行转换把刚填好的 `error_msg` 覆盖成行内容,费率被拒时上层拿到的是一串垃圾;现每路各占一块 scratch。**ABI 面**:两个函数指针多一个参数,`native/td.rs` 的 Rust 镜像与 `ek_ctp_shim.h` 必须一字对齐(字段顺序即 shim 结构体顺序,错一位就是把回调接到别的槽上;编译期类型不匹配是唯一护栏,`native_smoke` 的 layout 测试挡不住参数表) |
| **D-SEND-REJECT** | 查询**发送侧**被拒显形(0.9.2)。0.9.0/0.9.1 接齐的是**回报侧**(柜台答了、答的是错误);这一条接的是**请求根本没发出去**。**实连证据逼出来的**(2026-10-22 两轮同一形态):同一会话同一时刻,费率查询(检返回码)明明白白回 `-2`,而轮转资金/持仓查询(原稿 876/886 **丢弃返回码**)三百九十秒里 `query_rejects=0`、`last_account_ms=None`、`last_error=None` 一动不动 —— 状态口上"一切正常",因为没人看过那个返回码。**口径**:① `CtpTransport::td_qry_account`/`td_qry_position` 返回 `i32`(native 里没有会话 = `-1`,不是 0:0 会被读成"已发出");`td_qry_instrument` **不动** —— 它本来就是阻塞重发直到 `rc==0`(D-FLCN),它的发送侧被拒表现为**卡住**而不是返回码,那是另一件事,别混进同一条判据;② 非 0 记进**同一条** `query_rejects`/`query_reject_streak`(消费侧要的是"这条路连续被拒几次",不分发送侧/回报侧;发送侧不设分项,因为它没有 ErrorField 可分路);③ **计数一次都不漏,日志只在串的首次(0→1)与恢复各写一条** —— 轮转每几秒一轮,每轮都写就是把 eLog 512 行环挤满,那正是巡回 G1「登录行被挤掉」的成因。返回类型收成 `Vec<LogData>` 而不是 `Vec<TdOut>`:这个方法只产日志,形状收窄调用方就不必猜里面会不会混进一条查询请求 |
| **D-STATUS** | 状态口 `{gw}.status`(0.9.0,ask 契约 `StatusReq` → `GatewayStatus`;公开方法 `CtpPlugin::status()`)。**存在理由**:界面要有「这个账户到底活着没有」的事实源,0.8.0 之前 ek-ui 只能显示「装配成功」,登录/掉线/查询被拒零出口(U5 挂空)。**四条硬口径**:① `*_handle_up` = 句柄已建并 registerFront,**不等于在线** —— 原稿 `onFrontDisconnected` 只把 `login_status` 打回假,`connect_status` 仍是真(0.8.0 前把 `connect_status` 当在线就是假信息),`*_logged_in` 才是唯一硬证据;② 「从未发生」= `None` 不是 0(0 会被界面读成「0 秒前刚回报过」);③ 计数与「最近时刻」**跨重连保留**(`disconnects`/`query_rejects`/`last_*_ms` 不在 `reconfigure`/`reset_session` 里清零)—— 重连清零等于把「今天掉过几次线」抹掉;④ **只读纪律**:`status()` 不改守卫、不发传输动作、不写日志(界面按秒级轮询,写日志会把 512 行 eLog 取证环冲爆;测试 `status_reads_are_side_effect_free_and_repeatable` 钉死)。**时刻用纪元毫秒墙钟**(`map::now_epoch_ms()`,不是单调钟):跨插件、跨重启可读,消费侧能直接算「距最后一次回报多久」。**阈值不在网关**:`td_query_reject_streak` 与 `td_last_account_ms` 只是原料,「连续几次算断供」「隔多久算超时」归消费侧(ek-ui)配置 —— 网关不知道界面多久刷一次,替它拍数字就是越界。**不做**:不新增被动掉线事件(掉线由消费侧轮询得知;总线多一条按账户按秒的流量,换来的信息轮询同样能拿到,而流量是永久成本);查询**发送层返回码** 0.9.0–0.9.1 按原稿丢弃(被拒只以回报 `error` 为准),**0.9.2 起不再丢弃**(见 **D-SEND-REJECT**);合约查询被拒**在 0.9.0 仍不可见,0.9.1 起可见**(见 D-INSTRUMENT-REJECT) |
| **D-DISCONNECT** | 断开口 `{gw}.disconnect`(0.9.0,ask 契约 `DisconnectReq` → `DisconnectReply`;公开方法 `CtpPlugin::disconnect()`)。**裁定 1(用户定):断开 = 停会话,移除 = 停实例** —— 两个动作不得混成一个。放掉:两侧会话(传输层 `td_release`/`md_release`,复用 0.5.0 configure 的强制重连面,不新增 SDK `ReqUserLogout` 调用 —— Release 归 RAII Drop)+ 驱动守卫复位(`reset_session`:connect/login/auth 归假、轮转闸门关闭、结算拍复位)。**保留清单(写明,因为每一项都有后果)**:插件实例与 cfg(恢复走『连接』= `configure` 重走全链)、**订阅集**(重登后 `RspUserLogin` 自动重订阅,否则用户得把合约再勾一遍)、断开计数与历史时刻(掉线证据)、持仓合并缓存与 OrderSysID 映射(在途撤单的号要对得上)、`auth_failed`(归 `reconfigure` 管,『连接』才是换凭据的地方)。**主动断开不计入柜台掉线数**(`disconnects` 只由 `onFrontDisconnected` 加一,自己点的钮不算柜台断)且**幂等**(连点两下不炸)。落一条 INFO 到 eLog(用户主动动作,一次一条,不是轮询噪音) |
| **D-INSTRUMENT-REJECT** | 合约查询被拒显形(0.9.1)。**为什么扩面**:0.9.0 把合约查询刻意留在 3 参(它不在轮转两查询的断供判据里),但代价是「柜台拒了这条查询」与「柜台没有合约」在 Rust 侧同形(都是 `row: None`)——合约表空掉时下游全线跟着瞎(持仓均价的乘数、产品类勾选、查询合约窗的勾选解锁),而界面上只表现为「那个窗没数」。**三处口径**:① 被拒计数记在 `row` 判定**之前**(被拒包正是 `row: None`),与轮转两路同式,判据仍是 `error_checked`(`error_id != 0` 才算被拒);② 分项 `instrument_rejects` 同时进总数 `query_rejects` 与连续串 `query_reject_streak` —— 消费侧要的是「查询这条路连续被拒几次」,不分路,要分路看分项(加字段用 `Default`,读侧无破坏);③ **`last` 包带错误时不得写「合约信息查询成功」、不得上抛 `ContractFinish`**:`ContractFinish` 是 ek-ui 查询合约窗解锁勾选的信号,一条都没到手却解锁 = 让用户对着空表勾选,下一笔单注定撞 `contract:unknown`;改写成 `LEVEL_ERROR` 的「合约查询被拒:<码+文本>」。**「空收尾包」口径**:`row: None` 且无 `error` 的收尾包既不算走通也不算被拒(它没证明任何事)——不清零连续串、不记被拒;有数据行才算走通(清零连续串 + 清陈旧 `last_error`)。**ABI**:四处一字对齐(`ek_ctp_shim.h` typedef / `ek_ctp_shim.cpp` override / `src/native/td.rs` 镜像 / `src/native/transport.rs` 回调),且这一路也遵守「错误文本与数据行各占一块 scratch」(0.9.0 修的那个覆盖 bug 不得在新路上复现) |
## 原稿行号对照(移植正文注释里均有)
- MD:`243-410`;行情骨架(303-317);订阅 gate(393-399)
- TD:`411-892`;认证(437-471);登录(474-499);委托插入失败(492-513);撤单失败(515-517);结算(519-533);资金(597-612);合约(614-667);订单(669-718);成交(720-747);连接/认证/登录发起(749-806);发单(808-854);撤单(856-872);查询轮转(874-887);close(889-892)
- 表:`STATUS_CTP2VT`(67-73)、`DIRECTION`(76-88)/`PD`(91-96) 合并、`OFFSET`(99-103)、`ORDER_TYPE`(106-112)、`PRODUCT`(113-120)、`EXCHANGE`(123-128)
## 测试面
`tests/gateway.rs`:MD 会话/过滤器/调价/DCE 日特判;
TD 全链(认证/登录/结算/合约收齐/订单/成交/未知状态/缓冲重放/持仓合并上期特判/空持仓静默);
发单/撤单字段面;REJECTED 拒单;插件总线级(smoke + 服务数 + gate + 插单 rt 否决面);
附加接口面(费率查询投递/未知类型拒绝 + 三路新事件只产 eLog 臂);
POD 面(逐字段自检 11 结构、两查询输入写向独立 verify、roundtrip、GBK 面)。
`tests/native_smoke.rs`(仅 `--features native`):4 用例,**完全离线**——回调表 `sizeof`
ABI 自检、SDK 版本串(实测 `v6.7.7_20240607 15:51:36.7347`)、创建/析构往返、
前置 + 空回调表装配。真连网(SimNow)验证属 M3,需本机能直达前置地址。
## 依赖语义
- 零第三方运行时依赖:非官方 `ek-*` 生态内,cargo 依赖仅包含
`ek-core`/`ek-data`/`ek-event`;无 `cc`/`bindgen`(native 0.2.0 手写 build.rs 承担)。
- ABI 面:wire 结构字段与 CTP 官方头一字段一映射。**M1 已落地的校验**是回调表
`sizeof(EkMdSpiCallbacks)` 双面比对 + C++ 侧 `override` 的编译期签名校验;
字段级 `static_assert`(>= size/offset)在 M2 落行情体/委托结构时补上。
## 发布面(0.2.0 起)
- crates.io 包**包含** `native/**`(头文件 + win64 导入库/DLL + linux SO),压缩后约 5.9MB
(单包上限 10MB);未设 `exclude`,打包面按 git 跟踪规则推导 → 私密文件由 `.gitignore` 挡住。
- **发布前必跑** `scripts/check-package.ps1`:双向断言(包内必须含 SDK 关键文件;不得含
`*.local.env`/`.env`/`*.pem`/`*.key` 等私密面)。
- **永不** `cargo publish --allow-dirty`(脏树 + 未跟踪文件会使打包面不可预测)。
- `native` 特性**默认关闭**,M1 已落地 MD 面(创建/前置/回调装配/登录/订阅):纯逻辑消费方与 docs.rs 不触碰 C++ 工具链。
## 质量门禁(0.2.0 起)
一键:`powershell -ExecutionPolicy Bypass -File scripts/gate.ps1 [-AllowDirty] [-WithNative]`。
| 门禁 | 判据 |
|---|---|
| `cargo fmt --check` | 0 输出。**基线 rustfmt 1.8.0-stable / rustc 1.94.0**(格式结果与 rustfmt 版本绑定,换版本可能假红) |
| `cargo test` | 全绿(用例数随版本增长,以 gate 输出为准;0.6.0 实测 24 单元 + 34 集成) |
| `cargo clippy --all-targets -- -D warnings` | 0 警告 |
| `cargo doc --no-deps` | 0 警告 |
| `cargo package` + `scripts/check-package.ps1` | 包内含 SDK 关键文件、不含私密面;`.crate` < 10MB |
| `-WithNative` | 追加 `cargo test/clippy --features native`(再 +8 native 自检) |
- 纪律:**永不** `cargo publish --allow-dirty`;发布前必须跑**严格模式**守卫(工作树干净)。
- 记档:0.1.0 期间门禁只跑 test/clippy/doc,未含 `fmt --check`,因此全仓格式漂移一直未被发现;
0.2.0 起纳入(格式修正在提交 `747a015`)。