ek-ctp 0.11.1

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 做测试):

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):

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,界面状态机的事实源):

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 一并消失。

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: 本包的 MIT 不覆盖这些上期技术二进制。
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 忽略,永不提交/推送):

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 -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 仅提供快照行情(无分笔),周末与节假日环境关闭。

真连端到端联调

$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 —— 该轮还抓出并修掉两个时间戳真 bug(决策见 DESIGN D-TZ)。

生态依赖(expected)

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