ek-ctp 0.12.4

CTP gateway for the enkeep ek ecosystem: MD/TD session drivers mapping CTP wire rows onto ek-data objects over the ek-event bus (pure-logic core; the CTP SDK ships inside this package and native bridging lands behind the optional `native` feature).
Documentation
# ek-ctp


CTP 期货行情/交易网关(Rust 移植版,`enkeep_trade` 生态)。

对 `enkeep_ctp`(`vnpy` 的 `ctp_gateway` 移植层)行为逐字移植。
**已发布至 0.5.0(0.6.0 已定版、待发布)**:0.1.0 纯逻辑层 → 0.2.0 `native` 桥(**SDK 已随 crates.io
包发布**,下游安装即得、无需自备)→ 0.3.0 `configure` 配置口(插件自带
配置面)→ 0.4.0 数据行**裸线键**上抛(D-EMIT:网关身份归载荷 `gateway_name`,
总线精确派发下带后缀即静默丢行)→ 0.5.0 configure **强制重连**
(`td_release`/`md_release`)→ 0.6.0 合约**收录勾选**(D-PC-FILTER:查询侧仍全量,
收录侧按产品类严格丢弃)。特性与 `build.rs` 见下节。

## 结构


| 模块 | 职责 |
|---|---|
| `wire` | CTP 官方字段的最小**子集**(仅 `DepthMarketData`/`InstrumentField`/`OrderField`/`TradeField`/仓位/资金/插撤单输入) |
| `map` | CTP 码值 ↔ ek-data 类型(状态/方向/开平/单型/交易所/时间戳/价格修整) |
| `driver` | 行情/交易两条**纯状态机**(`MdDriver::step`/`TdDriver::step`),零线程零时钟 |
| `plugin` | `CtpPlugin`:ek-core 宿主插件。出门 9 线值:`eTick/eContract/eOrder/eTrade/ePosition/eAccount/ePositionDone/eContractFinish/eLog`;进门 `eTimer` 与 `eReq.<gw>` 三服务 |
| `native` | (仅 `native` 特性)官方 C++ SDK 的 C ABI 桥;M2 = MD + TD 全面(会话、12 个交易回调、字段映射、传输层实现) |

## 快速用法


纯逻辑驱动层(不带任何网络库,可直接驱动 mock 做测试):

```rust
use ek_ctp::{TdConfig, TdDriver, TdEvent, TdOut, contracts_empty};

let mut td = TdDriver::new("ctp", TdConfig::default(), contracts_empty());
for out in td.step(TdEvent::FrontConnected) {
    // 命令面(TdOut::ReqXxx)交给传输层;数据面(TdOut::Order 等)上总线
}
```

总线级(同 ek-oms):

```rust
let cfg = ek_ctp::CtpConfig { gateway_name: "ctp".into(), ..Default::default() };
host.add_plugin(ek_ctp::CtpPlugin::new(cfg))?;   // 默认构建无传输;动作被吸收
```

多账号(0.7.x 起):注册名**不手填**,由 (broker, 账号) 派生 ——
`ek_ctp::derived_gateway_name("9999", "278153") → Some("ctp_9999_278153")`。
派生失败(空值 / 含 `[0-9A-Za-z]` 之外字符 / 超 32 字符)回 `None`,调用方须拒绝并说明。
同一 broker 的多个账号 = 多个网关实例(各自 TD 会话);服务口随名寻址
(`{gw}.configure` / `{gw}.subscribe` / `{gw}.send_order` / `{gw}.status` / `{gw}.disconnect` …)。

状态与断开(0.9.0,界面状态机的事实源):

```rust
let s = plugin.status();                 // 或 ask `{gw}.status`(StatusReq → GatewayStatus)
if s.td_logged_in { /* 在线的唯一硬证据 */ }
// s.td_handle_up 只说明"句柄已建",掉线时它仍是真 —— 别拿它当在线
// s.td_last_account_ms: Option<i64> 纪元毫秒;None = 从未到手过(不是"0 秒前")
// s.td_query_reject_streak / td_disconnects: 跨重连保留(掉过几次线是历史事实)
// s.td_instrument_rejects(0.9.1): 合约查询被拒的分项 —— 合约一条没到手与"柜台没有合约"
//   从此分得开(0.9.0 之前两者同形,都是 row: None)
// s.td_query_rejects / td_query_reject_streak(0.9.2 起)也含**发送侧**被拒:
//   reqQry* 返回非 0(请求根本没发出去)与"柜台答了但答的是错误"计同一条连续串。
//   0.9.1 及之前只数回报侧 —— 实连里这就是「logged_in=true、零被拒、资金 None 三百九十秒不变」
//   那个形状的盲区。
// 0.9.3 起这些数字不再包含"自己撞自己":周期查询共用「每条连接每秒 1 次查询」这一个名额,
//   由 TdDriver::on_timer 一个协调点决定,一拍最多一条。
//   0.9.8 的现行节律(串行阶段机,取代 0.9.5 的 7 拍周期):结算单确认 → 等 1 秒 → 问合约,
//   每 5 秒一发、最多 3 次;任一合约信息到手即结束并**才开始**资金/持仓交替(每拍一条,
//   距上一发不足 1 秒就等下一拍)。3 次问满仍无合约 → 警告一次(warning 级 + 写进 td_last_error)
//   并**停止本轮查询**(不再问资金与持仓);恢复只有两条路:新会话,或迟到的合约行。
//   三条路谁收到 last 谁推自己的结束事件:eAccountDone.<gw> / ePositionDone.<gw> / eContractFinish。
//   每次阶段变化另推一条 `[链] ` 前缀日志(ek-ui 日志窗 + 账户表「阶段」列吃它)——纯显形,
//   不参与任何查询决策。
//   1 秒闸(0.9.6 的 900ms 护栏在 0.9.8 抬成整 1 秒):判的是「距上一发真正发出去过了多久」,
//   不满就不发、等下一拍 —— 换来「任意 1 秒窗口内最多 1 发」这个硬保证(900ms 做不到)。
//   代价写明:秒针相邻拍在 990–1010ms 晃,不足 1 秒的拍会被挡,实际每路 2~4 秒带抖动。
//   被挡的拍不推进任何计数与交替。
// 0.9.6 起费率查询({gw}.query_rate)**不自动查询、不自动重发**:被拒就把错误码回给调用方,
//   节律归调用方;它也不进上面那些计数(那是网关自己这条查询路的体检数)。

let rep = plugin.disconnect();           // 或 ask `{gw}.disconnect`
// 停会话:实例、账号配置、订阅集、计数全保留;『连接』可恢复(重登自动重订阅)
```

「查询断供」的阈值(连续被拒几次 / 多久没回报)归**消费侧**配置,网关只给原料不裁决。
`status()` 是只读口:不发查询、不写日志 —— 按秒轮询它不会把 eLog 冲爆。详见 DESIGN **D-STATUS** / **D-DISCONNECT**。

收录哪些产品类(0.6.0,D-PC-FILTER):`CtpConfig.product_classes` 定启动默认,
`ConfigureReq.product_classes` 运行时改(界面勾选走这条;`ConfigureReply` 带回实际生效值)。
CTP 单字符码,默认 `'1'` 期货 —— **空表不等于全丢**,归一化一律回落默认。查询侧不过滤
(实测三个查询口都无法按类型过滤),不勾的产品类在收到合约行时直接丢弃:不进共享合约表、
不上抛 `eContract`,该合约的持仓/委托回报/成交回报/tick 一并消失。

```rust
let cfg = ek_ctp::CtpConfig {
    gateway_name: "ctp".into(),
    product_classes: ek_ctp::map::ProductClasses(vec!["1".into(), "2".into()]), // 期货 + 期权
    ..Default::default()
};
```

## native(0.2.0)


**M2 已落地(MD + TD 全面)**,用 `--features native` 启用(**默认关闭**,纯逻辑消费方与 docs.rs
不触碰 C++ 工具链)。SDK **已随包发布**(D-SDK-SHIP):四份 `ThostFtdc*.h` + win64 导入库/DLL
+ linux SO 都在 `native/sdk/`(来源 `enkeep_ctp` 自带的一套,头与二进制同源,API 版本 **6.7.7**、
DLL 内嵌构建时间戳 `20240607`),压缩后约 5.9MB、在 crates.io 单包 10MB 上限内 ——
下游 `cargo add ek-ctp` 即得,**不需要自备 SDK**。许可边界见 [`NOTICE`]NOTICE:
本包的 MIT **不覆盖**这些上期技术二进制。

```powershell
cargo test --features native        # 46 用例:逻辑 21 + native 侧 25(POD 自检 14 / 传输层 3 / 离线冒烟 8)
powershell -ExecutionPolicy Bypass -File scripts/gate.ps1 -WithNative   # 门禁含 native
```

- **形态**:`src/native/`(`common` 共用工具 + `md`/`td` 两面的 `extern "C"` 声明与 RAII 句柄
  + `pod` 的 CTP↔POD 转换 + `transport` 的 `CtpTransport` 官方实现)
  + 两个 C++ 单元(`native/shim/ek_ctp_shim.{h,cpp}` 会话/回调面、`ek_ctp_pod.{h,cpp}` 字段面,
  **唯一**说 C++ 的地方)
  + 手写 `build.rs`(仅 std,零第三方 crate;特性关闭时**完全 no-op**)。
- **平台**:只支持 **x86_64-pc-windows-msvc**;其他目标在 `build.rs` 里给出明确报错,
  而不是让用户在链接期面对读不懂的符号错误。**Linux/GCC 路径尚未实现**(SDK 的 `.so`
  已随包备好,待补;见 DESIGN D-NATIVE-TARGET)。
- **工具链**:MSVC 环境优先用现成的 `cl.exe`/`INCLUDE`/`LIB`;缺失时经 `vswhere` 自建,
  不要求"VS 开发者命令行"(`EK_CTP_FORCE_VSWHERE=1` 可强制走 vswhere 路径以便自验)。
- **运行期 DLL**:`build.rs` 把 `thost*.dll` 投送到 `target/<profile>/` 与 `deps/`
  —— Windows 只在 exe 所在目录、系统目录、cwd、PATH 里找 DLL,两处都要有。
- **ABI 自检三层**:① 回调表 —— shim 导出 `sizeof`,测试断言等于 Rust 侧 `size_of`
  (MD/TD 各一张表);② 每个事件 POD —— 同样的 `sizeof` 断言;③ 字段映射 ——
  `ek_pod_selftest_*` 在 C++ 里构造带唯一值的 CTP 报文、经真实转换函数后由 Rust
  **逐字段**断言(顺序/类型/映射错位都会被抓住)。C++ 侧每处 SPI 覆盖都带 `override`,
  SDK 换了虚函数签名会**编译失败**,而不是留到运行期崩。
- **线程模型**:SPI 回调跑在 CTP 自己的线程上,**不引入 ingress 线程** —— 回调直接步进
  插件(驱动状态自锁在 `Mutex` 里,`PluginContext` 可跨线程 `put`),与原稿"回调里直接
  上抛"同构(见 DESIGN D-NATIVE-THREAD)。
- **连接触发在插件内**:宿主 `start()` → `on_start` → `CtpPlugin::connect()`(先 TD 后 MD);
  也可显式调用。`CtpPlugin::with_native(cfg, flow_root)` 一次完成"传输层 + 回调接线"装配
  (回调入口用 `Weak` 回指插件,避免循环引用)。
- **离线可验**:native 用例完全不触网(编译 → 链接 → DLL 加载 → 创建/装配/析构 + 版本串
  + POD 逐字段映射);真连网登录(SimNow)属 M3,且需本机能直达前置地址。
- **`shim`/`build.rs`/`.ps1` 正文一律 ASCII**:CTP 头是 GBK,ASCII 源码 + 不给 MSVC 传
  `/utf-8`,它才按系统码页正确解码头文件(见 DESIGN D-NATIVE-ENC)。
- 发布前跑 `scripts/check-package.ps1`:双向断言包内**必须含** SDK、**不得含**私密配置。

## 本地测试配置(SimNow)


账户与前置地址放在**本机私密文件**里(已被 `.gitignore` 忽略,永不提交/推送):

```powershell
Copy-Item config/simnow.example.env config/simnow.local.env   # 然后填入自己的账号
```

SimNow 现行两套前置(**2026-10 实测口径**,来源:openctp 模拟环境监控 <http://www.openctp.cn/simenv.html>):

| 套 | 交易前置 | 行情前置 | 服务时间 |
|---|---|---|---|
| 第一套(仿真,与实盘时段一致,**优先**) | `tcp://182.254.243.31:30001`(备用 `:30002` / `:30003`) | `tcp://182.254.243.31:30011`(备用 `:30012` / `:30013`) | 与实盘生产时段一致(日盘 / 夜盘) |
| 第二套(7x24) | `tcp://182.254.243.31:40001` | `tcp://182.254.243.31:40011` | 全天候(周末与节假日另有维护窗口) |

> **2026-10 地址变更**:SimNow 前置已从 `180.168.146.187`(电信)/ `218.202.237.33`(移动)
> 迁到 `182.254.243.31`。老地址的失效形态很好认 —— 电信线路 **TCP 超时**(包被丢),
> 移动线路**主动拒绝**(RST,端口已关)。地址会随 SimNow 运维变动:连不上时先查
> openctp 监控页,再改 `config/simnow.local.env`(本仓库已按 2026-10 口径更新)。

选择规则(脚本自动裁决):**交易时段**(北京时区,日盘 08:55–15:15 / 夜盘 20:55–02:35)→ 第一套;其余时间 → 第二套。两套都通时优先第一套。

```powershell
powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1          # 看当前该用哪套(含理由)
powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Probe   # 先实测 TCP 连通性再选
powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Env     # 输出 EK_CTP_TD_ADDR / EK_CTP_MD_ADDR 赋值行
powershell -ExecutionPolicy Bypass -File scripts/simnow-fronts.ps1 -Force 724
```

脚本正文**只含 ASCII**(输出为英文):Windows PowerShell 5.1 对无 BOM 的 `.ps1` 按 ANSI 解码,
中文串会乱码并炸解析;配置文件是 `KEY=VALUE`,读取时显式按 UTF-8,故其中文注释安全。

排查:`-Probe` 前置全部 `down` 时先分清两种形态 —— **超时**(包被丢:本机/网络策略或对端下线)
与**主动拒绝 RST**(对端可达但端口已关:地址过时)。SimNow 前置是裸 TCP(不是 80/443),
可用 `Test-NetConnection 182.254.243.31 -Port 40001` 或 `telnet 182.254.243.31 40001` 复核;
本机网络本身是否通,用 `Test-NetConnection www.baidu.com -Port 443` 对照。

登录字段:BrokerID `9999`、AppID `simnow_client_test`、AuthCode `0000000000000000`(16 个 0)。
`CtpConfig.td_auth_code` 非空即走认证分支(与 `enkeep_ctp` 原稿 448-451 的分支一致)。
SimNow 仅提供快照行情(无分笔),周末与节假日环境关闭。

### 真连端到端联调


```powershell
$env:EK_CTP_FRONT_SET = 'live'                    # live = 仿真那套 / 724 = 7x24
cargo run --features native --example live_simnow # 真 SDK、真前置,非 mock
```

`examples/live_simnow.rs` 按原稿启动顺序跑完整业务面(启动链 → 结算确认 → 合约查询 →
订阅行情并挑"当前真在交易"的标的 → 可成交价下单/成交 → 挂单/撤单 → 拒单探针 ×2 →
资金/持仓),逐步判定 + 完整时间线 + Markdown 报告落盘 `target/ctp-live-report.md`。

**2026-10-15 实测**:7 步全 PASS(真实成交 / 撤单 / 拒单 / 资金 / 持仓),详见
[LIVE-TEST.md](LIVE-TEST.md) —— 该轮还抓出并修掉两个时间戳真 bug(决策见 DESIGN `D-TZ`)。

## 生态依赖(expected)


- `ek-core` `0.3.0`(宿主/服务)
- `ek-data` `0.2.2`(数据类型)
- `ek-event` `0.1.0`(总线形态)