# 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<Whicht>.<gw>` 后缀;进门
`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-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) |
| **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-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 个标志,**仍留**) |
| **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 只从宿主事件路来 |
## 原稿行号对照(移植正文注释里均有)
- 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`:15 用例,MD 会话/过滤器/调价/DCE 日特判;
TD 全链(认证/登录/结算/合约收齐/订单/成交/未知状态/缓冲重放/持仓合并上期特判/空持仓静默);
发单/撤单字段面;REJECTED 拒单;插件总线级(smoke + 服务数 + gate + 插单 rt 否决面)。
`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` | 15 用例全绿(默认特性) |
| `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`(15 + 4 用例;M1 起) |
- 纪律:**永不** `cargo publish --allow-dirty`;发布前必须跑**严格模式**守卫(工作树干净)。
- 记档:0.1.0 期间门禁只跑 test/clippy/doc,未含 `fmt --check`,因此全仓格式漂移一直未被发现;
0.2.0 起纳入(格式修正在提交 `747a015`)。