用 Rust 封装 XTP C++ 接口:从零到能用的完整教程

在这里插入图片描述
说明:这个是我花了近300元的opencode调用Kimi K3的实践。全程AI 制作,谨供参考。

面向读者:会写 Rust 基本代码、但没做过 FFI(外部函数接口)的实习生/初学者。
读完你将获得:一个能真实连接中泰证券 XTP 柜台、订阅行情、下单查询的 Rust 库,以及一套"任何 C++ 接口都能照此封装"的方法论。
配套资料:XTP 官方文档 https://xtp.zts.com.cn/doc/api/xtpDoc、SDK 头文件(xtp_trader_api.hxtpx_quote_api.h 等)。
本文代码:全部来自一个通过 23 项测试(含真实 DLL 冒烟测试)的可用工程,关键片段可直接复制。


目录


第 0 章 我们要做什么、为什么难

0.1 任务一句话

中泰证券 XTP(极速交易平台)只提供 C++ 接口(xtptraderapi.dll 交易、xtpxquoteapi.dll 行情)。我们要在 Rust 里使用它,并且做到:

  1. 调得动:创建 API、登录、订阅、查询、下单;
  2. 收得到:行情推送、报单回报等回调能进 Rust 代码;
  3. 不崩溃、不 UB:内存布局、生命周期、线程安全全部正确。

0.2 为什么不是"写个 extern 就完事"

很多教程告诉你 FFI 就是:

extern "C" { fn some_c_function(x: i32) -> i32; }

但 XTP 给的不是一组 C 函数,而是 C++ 类:

// xtp_trader_api.h(简化)
namespace XTP { namespace API {
    class TraderApi {
    public:
        static TraderApi* CreateTraderApi(uint8_t client_id, const char* path, XTP_LOG_LEVEL level);
        virtual void Release() = 0;
        virtual const char* GetApiVersion() = 0;
        virtual uint64_t Login(const char* ip, int port, ...) = 0;
        virtual uint64_t InsertOrder(XTPOrderInsertInfo* order, uint64_t session_id) = 0;
        // ... 一共 80+ 个虚函数
    };
    class TraderSpi {           // 回调接口,要求我们"实现"后注册进去
        virtual void OnOrderEvent(XTPOrderInfo*, XTPRI*, uint64_t) {};
        // ... 一共 65 个虚函数
    };
}}

这带来四座大山:

Rust 调 C++ 的 4 座大山

名字修饰

C++ 符号被 mangling 成长串怪名

没有简单的朴素函数名可链接

虚函数机制

成员函数地址藏在 vtable 里

还要隐式传 this 指针

回调反向调用

不是我们调它是它调我们

要伪造一个 C++ 对象给它调

内存布局

结构体/枚举必须逐字节一致

对齐、pack、union、位域全是坑

本教程把四座大山一座座翻过去。每座山都遵循同一套路:先用 C++ 视角搞懂原理 → 再用 Rust 语法精确复刻 → 最后用测试锁死正确性

0.3 方案选型:为什么是 libloading + 手工 vtable

方案做法优点缺点结论
A. bindgen + .lib 静态链接bindgen 读头文件生成绑定,链接 xtptraderapi.lib类型自动生成bindgen 对 C++ 类/虚函数支持很差,生成的绑定基本不可用;还要处理 MSVC 链接环境
B. 写 C++ shim 再包 C 接口用 C++ 写一层 extern "C" 包装,编译成新 DLL最稳妥需要 MSVC 编译环境,多一层构建步骤,迭代慢备选
C. libloading + 手工 vtable运行时 LoadLibrary,读出对象 vtable,把虚函数当函数指针调无需编译 C++,纯 Rust 可控,分发只需带原 DLL需要手工保证布局正确(本教程的核心技能)✅ 本文方案

提示:方案 C 要求 x64 Windows。x64 下 MSVC 的调用约定与 Rust extern "C" 一致(下一章细讲),这是方案成立的前提。32 位下 thiscall 约定不同,方案 C 不成立。


第 1 章 认识 XTP API 的构造

1.1 两个 DLL、两套类

xtptraderapi.dll (v2.2.x 交易)

xtpxquoteapi.dll (XTPX 4.0 行情)

注册

注册

回调

回调

QuoteApi
36 个虚函数
Login/Subscribe/Query...

QuoteSpi
38 个虚函数
OnDepthMarketData/OnTickByTick...

TraderApi
80+ 个虚函数
Login/InsertOrder/QueryAsset...

TraderSpi
65 个虚函数
OnOrderEvent/OnTradeEvent...

用户代码

  • Api 类(我们调它):QuoteApi / TraderApi。通过静态工厂函数 CreateQuoteApi() / CreateTraderApi() 创建,得到一个指向 C++ 对象的指针。
  • Spi 类(它调我们):QuoteSpi / TraderSpi。我们"实现"它并用 RegisterSpi() 注册,之后 DLL 在内部线程里回调。

注意:行情 API 有两代。中泰同时维护着经典版(xtpquoteapi.dll,命名空间 XTP::API,随交易包分发)和 XTPX 4.0 版(xtpxquoteapi.dll,命名空间 XTPX::API,独立行情包)。两代接口的函数名大多相同,但结构体布局、枚举值、部分接口都不同,混用必崩。本教程封装的是 XTPX 4.0 版,第 17 章专门讲两代的差异。

1.2 官方 C++ demo 的标准使用流程

从官方 demo(XTPApiDemo/src/xtp_api_demo.cpp)提炼出的骨架,我们封装的最终目标就是让 Rust 代码长得和它一一对应:

// —— 行情 ——
XTP::API::QuoteApi* pQuoteApi = XTP::API::QuoteApi::CreateQuoteApi(client_id, filepath, XTP_LOG_LEVEL_DEBUG);
MyQuoteSpi* pQuoteSpi = new MyQuoteSpi();
pQuoteApi->RegisterSpi(pQuoteSpi);
pQuoteApi->SetHeartBeatInterval(hb);
int r = pQuoteApi->Login(ip, port, user, pass, XTP_PROTOCOL_TCP);
if (r == 0) {
    pQuoteApi->SubscribeMarketData(tickers, count, XTP_EXCHANGE_SH);
    // ... 回调开始到达:OnSubMarketData / OnDepthMarketData ...
}
pQuoteApi->Logout();
pQuoteApi->Release();   // 释放对象

// —— 交易 ——
XTP::API::TraderApi* pTraderApi = XTP::API::TraderApi::CreateTraderApi(client_id, filepath, XTP_LOG_LEVEL_DEBUG);
pTraderApi->RegisterSpi(pTradeSpi);
pTraderApi->SubscribePublicTopic(XTP_TERT_QUICK);
pTraderApi->SetSoftwareVersion("1.0.0");
pTraderApi->SetSoftwareKey("YOUR_KEY");
uint64_t session = pTraderApi->Login(ip, port, user, pass, XTP_PROTOCOL_TCP);
if (session > 0) {
    XTPOrderInsertInfo order = {0};
    // ... 填单 ...
    uint64_t xtp_id = pTraderApi->InsertOrder(&order, session);
}
pTraderApi->Release();

注意时序约束:RegisterSpiSetHeartBeatIntervalSetSoftwareKey 等都必须 在 Login 之前 调用;Release 在最后。封装层的文档注释要把这些约束写清楚。

1.3 头文件即合同

XTP 的所有"合同条款"都在头文件里,封装就是逐条翻译成 Rust:

头文件内容我们的对应物
xtpx_quote_api.hQuoteApi/QuoteSpi 类声明(虚函数顺序)vtable 结构体(第 4 章)
xquote_x_api_struct.h行情结构体(注意 #pragma pack(1))quote_types.rs
xquote_x_api_data_type.h行情枚举/常量(多为 typedef uint32_t + constexpr)quote_types.rs 枚举
xtp_trader_api.hTraderApi/TraderSpi 类声明vtable 结构体
xoms_api_struct.h交易结构体(#pragma pack(8))trader_types.rs
xtp_api_data_type.h交易枚举/常量/字符串长度宏trader_types.rs/common.rs
xtp_api_struct_common.hXTPRspInfoStruct(错误信息)common.rs::XtpRspInfo

铁律:任何字段、枚举值、槽位顺序,只信头文件,不信记忆、不信别的语言的封装、不信文档站的二手描述。


第 2 章 补课:ABI、调用约定与 vtable

这一章是全教程的理论地基,小白务必看懂。

2.1 ABI 是什么

源代码层面的"函数签名"在编译后并不存在。两个编译产物要互相调用,必须约定:

  • 参数放在哪里(寄存器?栈?顺序?)
  • 返回值放在哪里
  • 谁清理栈
  • 结构体在内存里怎么排布(对齐)
  • 符号叫什么名字(名字修饰)

这套约定叫 ABI(Application Binary Interface)。Rust 和 C++ 语言不同,但只要 遵守同一份 ABI,就能互相调用。

2.2 x64 Windows 调用约定(我们唯一的依靠)

Windows x64 只有一种调用约定(有时叫 Microsoft x64 calling convention):

返回值

整数 / 指针 → RAX

浮点 → XMM0

浮点参数:独立通道

① XMM0

② XMM1

③ XMM2

④ XMM3

整数 / 指针参数:从左到右依次放入寄存器

① RCX

② RDX

③ R8

④ R9

⑤ 起:栈上

要点:

  1. Rust 的 extern "C" fn 在 x86_64-pc-windows-msvc 目标上就是这套约定;

  2. C++ 非静态成员函数会把 this 当作第一个隐藏参数传入 RCX。所以:

    obj->Login(ip, port)     // C++ 写法
    // 等价于 C 写法:
    TraderApi_Login(obj, ip, port)   // this 成了第一个参数
    
  3. 成员函数与 extern "C" fn(this: *mut c_void, ...) 参数逐一对齐后,可以直接互调;

  4. bool 是 1 字节,C++ bool 与 Rust bool 兼容;

  5. 超过 8 字节的结构体按值返回时有特殊规则(隐藏返回参数)——XTP 没有这种函数,虚函数全部返回指针/整数/bool,所以不用管。

2.3 虚函数与 vtable:C++ 多态的内存真相

C++ 对象如果有虚函数,它的前 8 字节是一个指向"虚函数表"(vtable)的指针:

③ 调用过程(以 GetApiVersion 为例)

② vtable:DLL 中的只读函数指针数组

① C++ 对象内存

指向

取槽位 1

vtable 指针
对象的前 8 字节

成员数据 …

槽位 0 → Release

槽位 1 → GetApiVersion

槽位 2 → GetApiLastError

槽位 3 → RegisterSpi

槽位 4 → SetHeartBeatInterval

… 按头文件声明顺序排 …

取 vtable[1]
得到函数指针

把 obj 作为 this
传入并调用

调用 obj->GetApiVersion() 时,编译器生成的逻辑是:

1. vtable = *(void***)obj;          // 取对象前 8 字节
2. fn = vtable[1];                  // GetApiVersion 是第 2 个虚函数(槽位 1)
3. fn(obj);                         // 把 obj 当 this 传进去调

槽位顺序 = 头文件中虚函数的声明顺序(单继承、无虚析构的情况下)。XTP 的两个 Api 类析构函数都不是 virtual,所以第一个虚函数(Release)就在槽位 0,没有偏移。

我们只要:

  1. 读出对象前 8 字节得到 vtable 地址;
  2. 把 vtable 解释成一个"函数指针数组";
  3. 用对应槽位的函数指针,以 this 为第一参数调用——

就等于在 Rust 里调用了 C++ 虚函数。这就是第 4 章的全部秘密。

2.4 MSVC 名字修饰(name mangling)

C++ 支持重载,所以编译器把 CreateQuoteApi(uint8_t, const char*, XTP_LOG_LEVEL, bool) 这类信息编码进符号名:

?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z

我们不用读懂全部,只要会用工具查:

dumpbin /exports xtpxquoteapi.dll | findstr Create
#    6    5 000135C0 ?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z

dumpbin /exports xtptraderapi.dll | findstr Create
#    6    5 0000FE20 ?CreateTraderApi@TraderApi@API@XTP@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@@@Z

dumpbin 随 Visual Studio 附带(在开始菜单 “x64 Native Tools Command Prompt” 里可用)。

工程建议:代码里先试修饰名、再试朴素名 "CreateQuoteApi" 兜底(某些平台导出未修饰名)。
验证习惯:修饰名写错一个字符 library.get() 就会失败,把实际导出名复制进代码注释里备查。

2.5 对齐、padding 与 #pragma pack

CPU 读 8 字节的 double 时,要求地址是 8 的倍数(对齐)。编译器在结构体字段间自动插入填充(padding):

#pragma pack(1)

0-4: u32 a

4-12: f64 b

默认对齐(align 8)

0-4: u32 a

4-8: (填充4字节)

8-16: f64 b

  • 交易头文件用 #pragma pack(8)自然对齐,与 Rust #[repr(C)] 完全一致;
  • 行情头文件用 #pragma pack(1)紧凑排列,理论上要对应用 #[repr(C, packed)]

但实测发现:行情结构体因为字段排列的巧合(char[16] 开头 + 成对的 4 字节字段),自然对齐结果与 pack(1) 完全相同——唯独 XtpNqFullInfo 的 6 个单字节位域翻了车(第 5 章详讲)。这种巧合不能靠猜,必须逐个结构体手工计算验证。


第 3 章 工程搭建与 DLL 加载

3.1 工程骨架

xtp-api/
├── Cargo.toml
├── build.rs              # 构建期:找 SDK、拷贝 DLL
├── src/
│   ├── lib.rs            # 模块声明
│   ├── common.rs         # 公共枚举、错误类型、XtpRspInfo
│   ├── quote_ffi.rs      # 行情:vtable 定义 + 裸调用
│   ├── quote_types.rs    # 行情:结构体/枚举
│   ├── quote.rs          # 行情:安全封装 + SPI 桥
│   ├── trader_ffi.rs     # 交易:同上
│   ├── trader_types.rs
│   └── trader.rs
├── tests/integration.rs
└── examples/
# Cargo.toml
[package]
name = "xtp-api"
version = "0.2.0"
edition = "2021"

[dependencies]
libloading = "0.8"   # 运行时加载 DLL
thiserror = "2"      # 错误类型 derive

[dev-dependencies]
env_logger = "0.11"

3.2 用 libloading 加载 DLL 并找到工厂函数

use libloading::{Library, Symbol};
use std::ffi::{c_char, c_void};

const QUOTE_DLL_NAME: &str = "xtpxquoteapi.dll";

// CreateQuoteApi(uint8_t, const char*, XTP_LOG_LEVEL, bool) -> QuoteApi*
type CreateQuoteApiFn = unsafe extern "C" fn(
    client_id: u8,
    save_file_path: *const c_char,
    log_level: i32,
    udpseq_output: bool,
) -> *mut c_void;

let library = unsafe { Library::new(QUOTE_DLL_NAME) }
    .expect("加载 DLL 失败,确认 xtpxquoteapi.dll 在 exe 同目录或 PATH 中");

// 先试 MSVC 修饰名,再试朴素名兜底
let create_fn: Symbol<CreateQuoteApiFn> = {
    let names = [
        "?CreateQuoteApi@QuoteApi@API@XTPX@@SAPEAV123@EPEBDW4XTP_LOG_LEVEL@23@_N@Z",
        "CreateQuoteApi",
    ];
    let mut found = None;
    for name in &names {
        if let Ok(sym) = unsafe { library.get::<CreateQuoteApiFn>(name.as_bytes()) } {
            found = Some(sym);
            break;
        }
    }
    found.expect("两个名字都找不到,用 dumpbin /exports 核对真实导出名")
};

Library::new("xtpxquoteapi.dll") 的搜索路径与 Windows LoadLibrary 一致:exe 所在目录 → 系统目录 → 当前目录 → PATH。开发期最省心的做法是把 DLL 放在 crate 根目录(cargo run/test 的工作目录),或写 build.rs 自动拷贝(见 3.4)。

小坑:Library::new(path) 在 libloading 0.8 中是 unsafe(官方理由:加载的库可能运行任意初始化代码)。包一层错误处理即可。

3.3 创建 C++ 对象

let save_path = std::ffi::CString::new("./data").unwrap();
let obj = unsafe { create_fn(1, save_path.as_ptr(), 4 /*DEBUG*/, true) };
assert!(!obj.is_null(), "CreateQuoteApi 返回空指针");

此刻 obj 就是一个活生生的 C++ 对象,它的前 8 字节就是 vtable 指针

3.4 build.rs:自动拷贝 DLL

// build.rs(核心逻辑)
use std::{env, fs, path::PathBuf};

fn main() {
    println!("cargo:rerun-if-env-changed=XTP_SDK_PATH");
    if let Ok(root) = env::var("XTP_SDK_PATH") {
        let root = PathBuf::from(root);
        let dst = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
        for (dir, dll) in [
            (find_sub(&root, "XTP_API_", "bin/win64/dll"), "xtptraderapi.dll"),
            (find_sub(&root, "XTPXQuoteAPI_", "lib/win64"), "xtpxquoteapi.dll"),
        ] {
            if let Some(d) = dir {
                let src = d.join(dll);
                if src.exists() {
                    let _ = fs::copy(&src, dst.join(dll));
                }
            }
        }
    } else {
        println!("cargo:warning=未设置 XTP_SDK_PATH,请手动把 DLL 放到 crate 根目录");
    }
}

反面教材(真实踩坑):第一版 build.rs 把 C:\Users\xxx\Desktop\xtp 这种个人绝对路径XTP_API_20250806_2.2.50.8 这种带版本号的目录名硬编码进去,换台机器就废。规则:只用环境变量,且拷贝前校验目标 DLL 真实存在


第 4 章 重建 vtable:调用第一个 C++ 虚函数

4.1 把 vtable 声明成一个 Rust 结构体

对着 xtpx_quote_api.hQuoteApi 的虚函数声明顺序,写一个 #[repr(C)] 结构体,每个字段是一个函数指针:

use std::ffi::{c_char, c_int, c_void};

#[repr(C)]
pub struct QuoteApiVTable {
    pub release:             unsafe extern "C" fn(this: *mut c_void),
    pub get_api_version:     unsafe extern "C" fn(this: *mut c_void) -> *const c_char,
    pub get_api_last_error:  unsafe extern "C" fn(this: *mut c_void) -> *mut XtpRspInfo,
    pub register_spi:        unsafe extern "C" fn(this: *mut c_void, spi: *mut c_void),
    pub set_heart_beat_interval: unsafe extern "C" fn(this: *mut c_void, interval: u32),
    pub set_config_file:     unsafe extern "C" fn(this: *mut c_void, filename: *const c_char) -> bool,
    pub set_udp_thread_affinity: unsafe extern "C" fn(this: *mut c_void, cpu: *mut i32, count: i32) -> bool,
    pub subscribe_market_data:   unsafe extern "C" fn(this: *mut c_void, ticker: *mut *mut c_char, count: c_int, ex: u32) -> c_int,
    // ... 36 个槽位,与头文件逐行对应,一个不能多、一个不能少、顺序不能乱
}

检查清单(抄 vtable 时对着勾):

  • 头文件里每个 virtual xxx = 0; 都有对应字段;
  • 顺序与声明顺序完全一致(纯虚/带默认实现不影响顺序);
  • 析构函数不是 virtual ⇒ 不占槽位(若是 virtual,会占槽位,顺序还要算它);
  • 参数类型逐个翻译(见下面映射表),返回值类型别漏;
  • 默认参数(如 const char* local_ip = NULL)不影响签名,照写。

4.2 参数类型映射速查

C++ 类型Rust 类型说明
int / 普通 enumi32 (参数也可用 c_int)enum 默认 int
uint32_t / typedef uint32_t Xu32行情的 XTP_EXCHANGE_TYPE 就是这种
uint64_tu64session_id、order_xtp_id
int64_ti64数量、时间
doublef64价格、金额
boolbool同为 1 字节
uint8_tu8client_id
int16_ti16channel_number
charu8(字符串数组用 [u8; N])C 的 char 有无符号平台差异,FFI 里按字节处理最稳
const char**const c_char入参字符串
char* / char*[]*mut c_char / *mut *mut c_char订阅 ticker 数组
T* (结构体指针)*mut T / *const T视 const 性
void 返回无返回类型

4.3 读 vtable 指针并调用

pub struct RawQuoteApi {
    _library: std::sync::Arc<Library>,   // 保证 DLL 一直加载(见第 8 章)
    vtable: *const QuoteApiVTable,       // 指向 DLL 内部的 vtable
    obj: *mut c_void,
}

impl RawQuoteApi {
    fn vt(&self) -> &QuoteApiVTable {
        unsafe { &*self.vtable }   // vtable 随 DLL 常驻,生命周期由 _library 保证
    }
}

// 构造时(接 3.3 的 obj):
let vtable = unsafe { *(obj as *const *const QuoteApiVTable) };
assert!(!vtable.is_null());

// 调用 GetApiVersion:
let version = unsafe {
    let p = (api.vt().get_api_version)(api.obj);
    if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
};
println!("{}", version);   // => 1.2.1-r.3  🎉
GetApiVersion() vtable C++ 对象 Rust 代码 GetApiVersion() vtable C++ 对象 Rust 代码 读前 8 字节 vtable 地址 取槽位 1 函数指针 调用(this=obj) "1.2.1-r.3"

能打印出版本号,就证明方案 C 全链路成立。 剩下的所有虚函数都是同一姿势,纯体力活。

4.4 返回值与错误处理

XTP 的约定:

  • 行情接口:返回 0 成功,非 0 失败,失败原因用 GetApiLastError() 拿(XTPRI*);
  • 交易登录:返回 session_id,> 0 成功,0 失败;
  • 下单/资金划拨:返回 id,0 失败。

封装一个统一的取错函数:

fn err(&self, api: &str, ret: i32) -> XtpError {
    let ptr = unsafe { (self.vt().get_api_last_error)(self.obj) };
    // 注意:GetApiLastError 也可能返回空指针,必须兜底
    let rsp = unsafe { ptr.as_ref() }.copied().unwrap_or(XtpRspInfo::OK);
    if rsp.error_id != 0 { XtpError::XtpRspError(rsp) }
    else { XtpError::ApiError(format!("{} returned {}", api, ret)) }
}

真实案例:登录失败时能正确拿到 XTP error 10200000: Login to quote server failed: the quote authentication server offline or no connection.——错误传播链就这么验证。


第 5 章 结构体逐字段映射(含对齐/pack/union/位域)

这是最容易出错、出错后最隐蔽的部分。一个字段错位,读出来的就是垃圾数据,而且不报错。

5.1 翻译规则

// C++
struct XTPTickerPriceInfo {
    char     ticker[XTP_QUOTE_TICKER_LEN];  // 16
    XTP_EXCHANGE_TYPE exchange_id;          // uint32_t
    int32_t  unused;
    double   last_price;
};
// Rust —— 一一对应
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct XtpTickerPriceInfo {
    pub ticker: [u8; 16],   // char[16] → [u8;16],不是 String!
    pub exchange_id: u32,
    pub unused: i32,
    pub last_price: f64,
}

规则:

  1. #[repr(C)] 必须加,否则 Rust 自由重排字段;
  2. char[N][u8; N],不要String/CString(它们是指针,布局完全不同);
  3. 定长数组、嵌套结构体、union 全部按同样规则递归翻译;
  4. 字符串读取另写辅助函数,字段本身保持字节数组。
/// C 字符串(以 NUL 结尾) → String
pub fn from_cstring_bytes(buf: &[u8]) -> String {
    std::ffi::CStr::from_bytes_until_nul(buf)
        .map(|c| c.to_string_lossy().into_owned())
        .unwrap_or_default()
}

impl XtpTickerPriceInfo {
    pub fn ticker_str(&self) -> String { from_cstring_bytes(&self.ticker) }
}

5.2 手工计算布局:一个完整演练

XTPOrderInfo 为例(交易头文件,#pragma pack(8) 即自然对齐)。每个封装者都应该会这张表的手工推导:

字段类型大小对齐偏移备注
order_xtp_idu64880
order_client_idu32448
order_cancel_client_idu324412
order_cancel_xtp_idu648816
ticker[u8;16]16124
marketi324440
(填充)-4-44f64 需 8 对齐
pricef648848
quantityi648856
price_typei324464
side / position_effect / reserved1 / reserved2u8×44168C++ 是 union{u32; 4×u8}
business_typei324472
(填充)-4-76
qty_tradedi648880
qty_lefti648888
insert_timei648896
update_timei6488104
cancel_timei6488112
trade_amountf6488120
order_local_id[u8;11]111128
(填充)-1-139
order_statusi3244140
order_submit_statusi3244144
order_typeu811148
(尾部填充)-3-149结构体对齐到 8
合计152
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct XtpOrderInfo {
    pub order_xtp_id: u64,
    pub order_client_id: u32,
    pub order_cancel_client_id: u32,
    pub order_cancel_xtp_id: u64,
    pub ticker: [u8; 16],
    pub market: i32,
    pub price: f64,
    pub quantity: i64,
    pub price_type: i32,
    pub side: u8,
    pub position_effect: u8,
    pub reserved1: u8,   // union 里的预留字节,照 C++ 写
    pub reserved2: u8,
    pub business_type: i32,
    pub qty_traded: i64,
    pub qty_left: i64,
    pub insert_time: i64,
    pub update_time: i64,
    pub cancel_time: i64,
    pub trade_amount: f64,
    pub order_local_id: [u8; 11],
    pub order_status: i32,
    pub order_submit_status: i32,
    pub order_type: u8,
    // C++ 到此为止(152 字节)。第一版在这里多加了 reserved: [u8; 7]
    // 变成 160 字节 —— 一个真实 bug。
}

心得:Rust repr(C) 的对齐规则与 C 编译器相同,所以不需要手写填充字段,Rust 会自动在同位置填充。你要保证的只是"字段序列与 C++ 相同"。显式写出 reserved1/reserved2 是因为 C++ 的 union 里真的命名了它们

5.3 union 怎么翻译

C++ union → Rust union,同样 #[repr(C)],大小 = 最大成员,对齐 = 最大对齐:

union {
    XTPMarketDataStockExData  stk;   // 224 字节
    XTPMarketDataOptionExData opt;   // 24 字节
    XTPMarketDataBondExData   bond;  // 208 字节
};
#[repr(C)]
#[derive(Copy, Clone)]
pub union MarketDataExUnion {
    pub stk: MarketDataStockExData,
    pub opt: MarketDataOptionExData,
    pub bond: MarketDataBondExData,
}
// 大小 224。访问 union 字段是 unsafe:
// 先根据 data_type_v2 判断是哪个成员,再 unsafe { md.ex_data.stk.total_bid_qty }

配套的判别字段(data_type_v2)在结构体里:

pub fn stock_ex(&self) -> Option<&MarketDataStockExData> {
    match self.data_type_v2 {
        2 /* ACTUAL */ => Some(unsafe { &self.ex_data.stk }),
        _ => None,
    }
}

5.4 #pragma pack(1) 与位域:最大的坑

行情头文件是 #pragma pack(1)。我们说过大部分结构体"巧合地"与自然对齐一致——但 XTPQuoteNQFullInfo 里有这么一段:

XTP_TRADE_STATUS        trade_status : 8;    // uint8_t 位域,占 1 字节
XTP_SECURITY_LEVEL      security_level : 8;  // 1 字节
XTP_TRADE_TYPE          trade_type : 8;      // 1 字节
XTP_SUSPEND_FLAG        suspend_flag : 8;    // 1 字节
XTP_EX_DIVIDEND_FLAG    ex_dividend_flag : 8;// 1 字节
XTP_SECURITY_LAYER_TYPE layer_type : 8;      // 1 字节
char    reserved1[2];
char    industry_type[6];

6 个位域共 6 字节。第一版封装写成了 bitfields: u32(4 字节),于是 reserved1 之后所有字段全部向前错 2 字节——读 industry_type 读到的是 reserved1 的内容。

第一版 Rust:只有 4 字节,整体错位

280 · bitfields u32(4 字节)

284 · reserved1(2 字节)

286 · industry_type ❌ 错位

C++ 真实布局:6 个字节

280 · trade_status

281 · security_level

282 · trade_type

283 · suspend_flag

284 · ex_dividend_flag

285 · layer_type

286 · reserved1(2 字节)

288 · industry_type(6 字节)

正确译法:

pub trade_status: u8,
pub security_level: u8,
pub trade_type: u8,
pub suspend_flag: u8,
pub ex_dividend_flag: u8,
pub layer_type: u8,
pub reserved1: [u8; 2],
pub industry_type: [u8; 6],

方法论:凡遇位域,把每个位域展开成它的基类型字段再验算;凡遇 pack(1),每个结构体都手工验算一遍偏移,不能靠"它们通常一致"的运气。

5.5 用测试把布局锁死(重要!)

布局对不对,不要靠肉眼。std::mem::size_ofoffset_of!(Rust 1.77+)直接写断言:

#[test]
fn test_layout() {
    use std::mem::{offset_of, size_of};
    assert_eq!(size_of::<XtpOrderInfo>(), 152);
    assert_eq!(offset_of!(XtpOrderInfo, price), 48);
    assert_eq!(offset_of!(XtpOrderInfo, side), 68);
    assert_eq!(offset_of!(XtpOrderInfo, order_status), 140);

    assert_eq!(size_of::<XtpNqFullInfo>(), 328);
    assert_eq!(offset_of!(XtpNqFullInfo, trade_status), 280);
    assert_eq!(offset_of!(XtpNqFullInfo, industry_type), 288);

    assert_eq!(size_of::<XtpAsset>(), 416);          // 曾经错成 136
    assert_eq!(size_of::<XtpStkPosition>(), 536);    // 曾经错成 208
    assert_eq!(size_of::<XtpMarketData>(), 736);
}

反面教材:第一版测试只写 assert!(size > 400) 这种下限断言,结果 136 字节的 XtpAsset 也"通过"了。断言必须精确等值,尺寸和关键偏移都要。


第 6 章 枚举与常量映射

XTP 头文件里有三种"枚举",译法略有不同:

C++ 写法

Rust 译法

enum X { A=1, B=2 }

#[repr(i32)] pub enum X { A=1, B=2 }

typedef uint32_t X;
constexpr uint32_t A=1;

#[repr(u32)] enum + from_raw() 或直接 pub const

#define XTP_SIDE_BUY 1

pub const SIDE_BUY: u8 = 1;

// 交易枚举(int):
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum MarketType { Init = 0, SZA = 1, SHA = 2, BJA = 3, HK = 4, Unknown = 5 }

// 行情"枚举"(实为 uint32_t typedef):
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum ExchangeType { SH = 1, SZ = 2, NQ = 3, HK = 4, Unknown = 5 }
impl ExchangeType {
    pub fn from_raw(v: u32) -> Self { /* match 映射,未知值给 Unknown */ }
}

// #define 常量组(值太多或不连续时比 enum 顺手):
pub type SideType = u8;
pub const SIDE_BUY: u8 = 1;
pub const SIDE_SELL: u8 = 2;
pub const SIDE_MARGIN_TRADE: u8 = 21;
// ...

逐个数字核对! 真实案例:FundTransferTypeUnknown 在 C++ 里是 8(因为前面有 0~7 共 8 个值),第一版写成了 9——差一个数,跨节点划拨的类型判断就全错。

版本陷阱:同名枚举在两个 DLL 里可能不同。XTP_EXCHANGE_TYPE 在行情(XTPX)里是 SH=1,SZ=2,NQ=3,HK=4,UNKNOWN=5(uint32_t),在老行情包里却是 SH=1,SZ=2,NQ=3,UNKNOWN=4(enum)。封装哪个 DLL,就只信哪个包的头文件。


第 7 章 回调(SPI)桥:在 Rust 里伪造一个 C++ 对象

到了全教程技术含量最高的部分。前面都是我们调 DLL;现在是 DLL 调我们。

7.1 问题本质

pQuoteApi->RegisterSpi(pQuoteSpi) 期望收到一个 QuoteSpi*——一个前 8 字节是 vtable 指针、vtable 里有 38 个函数指针的 C++ 对象。DLL 收到后,内部线程会这样回调:

spi->OnDepthMarketData(md, bid1_qty, ...);
// 等价于:spi_vtable[4](spi, md, bid1_qty, ...)

所以我们必须手工造出这样的内存布局,并把函数指针指向 Rust 函数:

③ trampoline(Rust 函数)

② 静态 QUOTE_SPI_VTABLE(Rust 数据段)

① QuoteSpiHolder(Rust 分配)

指向

跳转到

回调 spi 的虚函数

vtable 指针
对象的前 8 字节

callback
Box<dyn QuoteSpi>

槽位 0 → tramp_on_disconnected

槽位 1 → tramp_on_error

槽位 2 → tramp_on_sub_market_data

… 共 38 个 trampoline …

解出 holder.callback
调用对应 trait 方法

XTP DLL 内部线程

7.2 定义 Rust 回调 trait

pub trait QuoteSpi: Send + Sync {
    fn on_disconnected(&self, reason: i32) {}
    fn on_error(&self, error_info: &XtpRspInfo) {}
    fn on_sub_market_data(&self, ticker: &XtpSpecificTicker, error_info: &XtpRspInfo, is_last: bool) {}
    fn on_depth_market_data(&self, md: &XtpMarketData,
        bid1_qty: &[i64], bid1_count: i32, max_bid1_count: i32,
        ask1_qty: &[i64], ask1_count: i32, max_ask1_count: i32) {}
    fn on_order_book(&self, order_book: &XtpOrderBook) {}
    fn on_tick_by_tick(&self, tbt: &XtpTickByTick) {}
    // ... 与 C++ 38 个回调一一对应,全部给默认空实现
}
  • Send + Sync:回调发生在 DLL 的内部线程,用户的实现必须能跨线程;
  • 全默认实现:用户只覆盖关心的几个;
  • #[allow(unused_variables)]:默认空实现的未用参数不报警。

7.3 holder 与 vtable 结构体

// 布局即合同:第一个字段必须是 vtable 指针
#[repr(C)]
struct QuoteSpiHolder {
    vtable: *const QuoteSpiVTable,
    callback: Box<dyn QuoteSpi>,
}

// vtable:38 个槽位,顺序与 xtpx_quote_api.h 里 QuoteSpi 的声明顺序一致
#[repr(C)]
struct QuoteSpiVTable {
    on_disconnected: unsafe extern "C" fn(*mut QuoteSpiHolder, c_int),
    on_error: unsafe extern "C" fn(*mut QuoteSpiHolder, *mut XtpRspInfo),
    on_sub_market_data: unsafe extern "C" fn(*mut QuoteSpiHolder, *mut XtpSpecificTicker, *mut XtpRspInfo, bool),
    on_unsub_market_data: unsafe extern "C" fn(*mut QuoteSpiHolder, *mut XtpSpecificTicker, *mut XtpRspInfo, bool),
    on_depth_market_data: unsafe extern "C" fn(*mut QuoteSpiHolder, *mut XtpMarketData, *mut i64, c_int, c_int, *mut i64, c_int, c_int),
    // ... 全部 38 个
}

注意每个 trampoline 的第一个参数是 holder 指针——因为 C++ 会把 spi(即我们的 &mut QuoteSpiHolder)当 this 传进来。

7.4 写 trampoline:四条军规

// 军规 1:panic 不能跨 FFI。catch_unwind 兜住。
fn guard<F: FnOnce()>(f: F) {
    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
}

// 军规 2:this 可能为 null(防御性)。
// 军规 3:error_info 可能为 null —— 官方文档明确允许,统一回退到 OK。
// 军规 4:数据指针可能为 null,判空再调。
static OK_RSP: XtpRspInfo = XtpRspInfo { error_id: 0, error_msg: [0; 124] };

unsafe extern "C" fn tramp_on_sub_market_data(
    this: *mut QuoteSpiHolder,
    ticker: *mut XtpSpecificTicker,
    error_info: *mut XtpRspInfo,
    is_last: bool,
) {
    guard(|| {
        let Some(h) = (unsafe { this.as_ref() }) else { return };
        let Some(t) = (unsafe { ticker.as_ref() }) else { return };
        let rsp = unsafe { error_info.as_ref() }.unwrap_or(&OK_RSP);
        h.callback.on_sub_market_data(t, rsp, is_last);
    });
}

unsafe extern "C" fn tramp_on_depth_market_data(
    this: *mut QuoteSpiHolder, md: *mut XtpMarketData,
    bid1_qty: *mut i64, bid1_count: c_int, max_bid1_count: c_int,
    ask1_qty: *mut i64, ask1_count: c_int, max_ask1_count: c_int,
) {
    guard(|| {
        let Some(h) = (unsafe { this.as_ref() }) else { return };
        let Some(md) = (unsafe { md.as_ref() }) else { return };
        // 原始指针+计数 → 切片;空指针或非正计数给空切片
        let bq = slice_from_c(bid1_qty, bid1_count);
        let aq = slice_from_c(ask1_qty, ask1_count);
        h.callback.on_depth_market_data(md, bq, bid1_count, max_bid1_count, aq, ask1_count, max_ask1_count);
    });
}

unsafe fn slice_from_c<'a>(ptr: *const i64, count: c_int) -> &'a [i64] {
    if ptr.is_null() || count <= 0 { &[] } else { unsafe { std::slice::from_raw_parts(ptr, count as usize) } }
}

38 个 trampoline 全是这个模式,机械但必须零失误。写完后装进静态表:

static QUOTE_SPI_VTABLE: QuoteSpiVTable = QuoteSpiVTable {
    on_disconnected: tramp_on_disconnected,
    on_error: tramp_on_error,
    on_sub_market_data: tramp_on_sub_market_data,
    // ... 38 个,顺序!顺序!顺序!
};

槽位顺序写错的后果:DLL 调 OnOrderBook(槽位 8)时实际跳到了 OnTickByTick(槽位 11)的函数——参数布局不同,轻则数据全错,重则栈错乱崩溃。写完务必和头文件行号逐一对照。

7.5 注册:让 holder 活得比注册久

pub struct QuoteApi {
    raw: RawQuoteApi,
    spi: Option<Box<QuoteSpiHolder>>,   // 持有 holder,地址才稳定
}

pub fn register_spi(&mut self, spi: Box<dyn QuoteSpi>) {
    let mut holder = Box::new(QuoteSpiHolder {
        vtable: &QUOTE_SPI_VTABLE,
        callback: spi,
    });
    // 把 holder 的地址交给 DLL —— 之后 DLL 会把它当 QuoteSpi* 用
    self.raw.register_spi(&mut *holder as *mut QuoteSpiHolder as *mut c_void);
    self.spi = Some(holder);   // 存进 self,防止被提前释放
}

生命周期铁律:

绝不早于

register_spi

DLL 持有 holder 地址

holder 何时能释放?

raw.Release()
(DLL 停止回调线程)

再释放 holder

Rust 结构体字段按声明顺序 drop,自定义 Drop::drop 又先于字段 drop 执行,所以:

pub struct QuoteApi {
    raw: RawQuoteApi,                // 声明在前:先 Release,回调停止
    spi: Option<Box<QuoteSpiHolder>>,// 声明在后:后释放 holder
}
// RawQuoteApi 的 Drop 里调 (vtable.release)(obj)

这个顺序是安全性的一部分,要写注释锁住,防止后来者重排字段。

7.6 交易 SPI:65 个槽位怎么消化

TraderSpi 有 65 个虚函数,我们只想暴露 13 个常用回调。其余 52 个槽位也必须有 ABI 正确的占位函数,因为 DLL 可能调其中任何一个:

macro_rules! stub {
    ($name:ident ( $($t:ty),* )) => {
        unsafe extern "C" fn $name(_this: *mut TraderSpiHolder $(, _: $t)*) {}
    };
}

stub!(noop_on_query_order_ex(*mut c_void, *mut XtpRspInfo, c_int, bool, u64));
stub!(noop_on_query_order_by_page(*mut c_void, i64, i64, i64, c_int, bool, u64));
// ... 52 个,参数个数/类型与头文件一致(未封装的结构体统一 *mut c_void)

为什么 stub 也要参数正确?x64 上前 4 个参数在寄存器,其余在栈上。函数签名决定被调方如何理解栈布局。stub 什么都不做虽然不读参数,但保持签名一致是零成本的保险,万一以后要在 stub 里加日志也不会踩雷。

7.7 回调里的工程纪律(写给使用者)

封装文档里必须警告用户:

  1. 回调在 DLL 线程里跑,必须快速返回。要处理数据就 channel.send() 丢给自己的线程。官方 FAQ 明确:回调处理过慢会填满接收缓冲区,服务器会断开连接(UDP 下则直接丢包);
  2. 回调里不要再调同一 API 的阻塞函数(可能死锁),官方文档注明 Logout 不允许在回调线程调;
  3. 回调参数(&XtpMarketData 等)只在调用期间有效——官方 FAQ 232 条明确警告"只保存指针不拷贝数据,之后读到的是被复用的内存"。Rust 的 &T + clone 在类型层面就强制了这一点(C++ 里靠自觉);
  4. 断线回调 on_disconnected 里可以重新 Login(官方推荐做法,见第 12 章),但不要在回调里调 ReleaseLogout;
  5. 屏幕输出(println!)是耗时操作,行情高峰(每秒约 20 万笔)时千万别在回调里逐条打印——官方 FAQ 58 条把"有屏幕输出"列为全订阅断线的首要嫌疑;
  6. 回调线程不止一个(官方 FAQ 146-149):QuoteSpi 在 TCP 下是 1 个线程,UDP 下是 1 个 TCP 线程 + 每组播组 1 个 UDP 线程;TraderSpi 的查询回调是 1 个线程,但 OnOrderEvent/OnTradeEvent 可能由正常线程 + 超时线程两个线程回调。所以 trait 要求 Send + Sync,用户实现里的共享状态必须自己加锁(Mutex)或用无锁结构;
  7. XTP 字符串(ticker_name 等)是 UTF-8 编码。Rust String 天然 UTF-8,to_string_lossy 直接正确——这点反而比 C++ 省心(C++ demo 在 GBK 控制台上打印中文是乱码)。

第 8 章 安全封装层设计

裸 FFI 能跑了,现在把它包成"用着不会错"的 Rust API。

8.1 RAII 与 Drop

impl Drop for RawQuoteApi {
    fn drop(&mut self) {
        unsafe { (self.vt().release)(self.obj); }
    }
}

要点:

  • _library: Arc<Library> 保证 DLL 在对象存续期间不卸载;Drop::drop 先于字段 drop 执行,所以 release 时 DLL 必然还在;
  • vtable裸指针而不是 &'static——vtable 内存属于 DLL,标 'static 是生命周期撒谎(第一版的真实问题);
  • Release 只允许调一次。不要提供公开的 release() 然后又让 Drop 再调一次。要么 ManuallyDrop,要么把 release 完全交给 Drop(本文选择后者)。

8.2 线程安全:Send/Sync

// XTP 官方文档声明 API 线程安全。对象归 DLL 管,DLL 由 _library 保活。
unsafe impl Send for RawQuoteApi {}
unsafe impl Sync for RawQuoteApi {}

这样 QuoteApi/TraderApi 才能放进 Arc、跨线程调用——量化程序里策略线程、风控线程都要碰它。

8.3 错误类型

#[derive(Debug, thiserror::Error)]
pub enum XtpError {
    #[error("Failed to load DLL '{dll}': {source}")]
    DllLoadError { dll: String, source: libloading::Error },
    #[error("Symbol '{symbol}' not found in DLL '{dll}'")]
    SymbolNotFound { dll: String, symbol: String },
    #[error("API call failed: {0}")]
    ApiError(String),
    #[error("XTP error: {0}")]
    XtpRspError(XtpRspInfo),      // 柜台返回的业务错误(error_id + 中文消息)
    #[error("Not logged in")]
    NotLoggedIn,
    #[error("Invalid argument: {0}")]
    InvalidArgument(String),
    #[error("Nul byte error: {0}")]
    NulError(#[from] std::ffi::NulError),
}

所有 CString::new 都走 map_err 而不是 unwrap()——第一版在 login 里 unwrap(),一个含 NUL 的异常输入就跨 FFI panic。

8.4 会回写的参数必须用 &mut(血的教训)

C++ 的下单接口:

virtual uint64_t InsertOrder(XTPOrderInsertInfo *order, uint64_t session_id) = 0;
//                                                  ^^^^^ API 会把分配到的 order_xtp_id 写回这个结构体

第一版封装:

pub fn insert_order(&self, order: &XtpOrderInsertInfo) -> Result<u64, XtpError> {
    self.raw.insert_order(order as *const _ as *mut _, sid)  // ❌ &T 强转 *mut T 让 C++ 写入
}

&T 在 Rust 里承诺"内存不会被写",把它交给会写的 C++ 是未定义行为(编译器可能基于只读假设做优化)。正确做法:

pub fn insert_order(&self, order: &mut XtpOrderInsertInfo) -> Result<u64, XtpError> {
    let sid = self.session_id.ok_or(XtpError::NotLoggedIn)?;
    let xtp_id = self.raw.insert_order(order as *mut _, sid);
    if xtp_id == 0 { Err(XtpError::XtpRspError(self.raw.get_api_last_error())) } else { Ok(xtp_id) }
}
// fund_transfer(&mut XtpFundTransferReq) 同理:C++ 回写 serial_id。

判断规则:看 C++ 参数是不是非 const 指针。非 const ⇒ 可能写 ⇒ Rust 侧用 &mut

8.5 会话与请求编号

pub struct TraderApi {
    raw: RawTraderApi,
    spi: Option<Box<TraderSpiHolder>>,
    session_id: Option<u64>,          // login 成功后才有
    next_request_id: Mutex<i32>,      // 查询类接口的 request_id 自增器
}

pub fn login(&mut self, ...) -> Result<u64, XtpError> {
    let sid = self.raw.login(...)?;
    if sid > 0 { self.session_id = Some(sid); }
    Ok(sid)
}

pub fn query_asset(&self) -> Result<i32, XtpError> {
    let sid = self.session_id.ok_or(XtpError::NotLoggedIn)?;
    let req_id = self.next_req_id();
    self.raw.query_asset(sid, req_id)?;
    Ok(req_id)   // 返回给调用者,用于和回调里的 request_id 对账
}

官方规则补充:session_id 在进程重启后相同,但断线重连后会变化——重连成功后必须更新保存的 session_id(见第 12.4 节)。另外官方 FAQ 185 条指出 GetApiLastError 不是线程安全的(其余接口都是),多线程并发取错时注意。

8.6 字符串入参:ticker 数组

订阅接口要 char* ticker[](指针数组)。正确姿势:

fn sub_cstrs(tickers: &[&str]) -> Result<(Vec<CString>, Vec<*mut c_char>), XtpError> {
    let cs: Vec<CString> = tickers.iter()
        .map(|t| CString::new(*t).map_err(|e| XtpError::InvalidArgument(e.to_string())))
        .collect::<Result<_, _>>()?;
    let ptrs: Vec<*mut c_char> = cs.iter().map(|t| t.as_ptr() as *mut c_char).collect();
    Ok((cs, ptrs))   // 两个 Vec 一起返回,cs 保活字符串内存
}

// 调用时:
let (_cs, mut ptrs) = Self::sub_cstrs(tickers)?;
let r = unsafe { vfn(self.obj, ptrs.as_mut_ptr(), ptrs.len() as c_int, ex as u32) };
// _cs 活过这次 FFI 调用,指针才有效

铁律:C 字符串内存必须活过 FFI 调用的整个时长。cs 若在调用前 drop,指针全部悬垂。


第 9 章 踩坑实录:21 个真实 bug 的分类清单

以下每一条都来自对第一版封装的真实质检。这比正面说教更有价值——它们是 FFI 项目的"事故树"。

21 个真实 bug

功能缺失

SPI 从未注册——最致命

set_udp_thread_affinity 空实现

query_account_trade_market 未包安全层

布局错误

XtpAsset 136B vs 416B

XtpStkPosition 208B vs 536B

XtpNqFullInfo 位域 4B vs 6B

XtpOrderInfo 多 7B

XtpFundTransferNotice 多 8B

XtpQueryOrderRsp 臆造 padding

臆造 XtpTickerInfo

数值错误

FundTransferType.Unknown 应为 8 错写成 9

PositionEffect 缺 4 个信用值

未定义行为

共享引用强转可写指针供 C++ 回写

vtable 生命周期撒谎

CString unwrap 跨 FFI panic

工程问题

硬编码个人路径

真实账号密码进 git

测试断言太松只比下限

126 个编译警告

9.1 重点讲解三条

第 1 名:SPI 从未注册。 register_spi 把回调存进 Arc<Mutex<Box>> 就结束了。编译通过、测试通过、demo 能登录——但任何回调都不来。这类"看起来完整实则断芯"的 bug 只有端到端冒烟测试能抓出来。

第 2 名:结构体"差不多就行"。 XtpAsset 只写了前 13 个字段,刚好覆盖 demo 用到的 total_asset/buying_power,于是"能用"。直到有人读 repay_stock_aval_banlance(信用账户余额)才发现拿到的是别的字段。**FFI 结构体必须 100% 翻译,包括保留字段。**保留位不是装饰,是布局的一部分。

第 3 名:测试自欺欺人。 assert!(size_of::<T>() > 400)——136 字节的错误结构体也满足。改成 == 416 后立刻现形。布局断言必须等值,并抽查关键偏移。

9.2 DLL 的怪癖(不是我们的 bug,但必须知道)

实测 XTPX 行情 DLL(1.2.1-r.3):

从未调用过 Login() 的 QuoteApi 对象,直接 Release() 会在 DLL 内部崩溃(0xC0000005)。 登录尝试过一次(哪怕失败)后,Release 正常。

原因在 DLL 内部(某些状态只在 Login 路径上初始化),官方 demo 总是先登录所以不触发。对策:

  1. 封装文档里写明:创建后请先 Login 再 Release;
  2. 测试用例按"创建 → 尝试登录 → 释放"的顺序写;
  3. 交易 DLL 无此问题。

另外观察到:同一进程内极快速反复创建/销毁多个 API 实例,DLL 偶发崩溃(线程竞态)。实务上一个进程内的 API 实例应长寿命,不要频繁重建。

9.3 凭据安全

第一版 examples/quote_demo.rs 里硬编码了真实测试账号和密码并提交了 git。规矩:

fn env_or(key: &str, default: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| default.to_string())
}
let user = env_or("XTP_TRADER_USER", "your_username");
let password = env_or("XTP_TRADER_PASSWORD", "your_password");

第 10 章 测试与调试方法论

10.1 三层测试体系

第 3 层:联机测试(需要测试账号)

登录 → 订阅 → 收行情

下单 → 撤单 → 查询

第 2 层:DLL 冒烟测试(需要 DLL,无需账号)

加载 + 创建 + GetApiVersion + 释放

注册 SPI 不崩溃

登录失败能拿到错误消息

第 1 层:静态布局测试(无需 DLL)

size_of / offset_of 等值断言

枚举值断言

字符串辅助函数单测

#[test]
#[ignore = "Requires XTP DLLs"]
fn test_quote_api_create_and_destroy() {
    let api = QuoteApi::new(1, "./test_data", LogLevel::Debug, true).unwrap();
    assert!(!api.get_api_version().is_empty());
    // 怪癖规避:先尝试登录再释放(见 9.2)
    let _ = api.login("127.0.0.1", 6001, "none", "none", ProtocolType::Tcp, None);
}

#[ignore] 标注需要 DLL 的测试,平时 cargo test 只跑第 1 层,cargo test -- --include-ignored 全量。

10.2 FFI 崩溃调试法(真实案例复盘)

遇到 0xC0000005(访问冲突)不要慌,按这个流程:

我们的错

DLL 的错

崩溃

1. 最小复现
写一个 20 行的 main 单独跑

2. 加无缓冲日志 eprintln!
定位崩在哪一行

3. 二分变量
mem::forget 跳过 Release?
不加载 SPI?

4. 对照实验
官方 C++ demo 同操作崩不崩?

结论

修布局/签名/生命周期

记录怪癖 + 规避路径

复盘我们遇到的崩溃:

  1. 现象:创建 QuoteApi → 打印版本(成功)→ drop 时崩;
  2. mem::forget(不 Release):不崩 ⇒ 问题在 Release 或 DLL 卸载;
  3. 显式 release() 后立刻崩 ⇒ Release 内部崩;
  4. 官方 demo 同路径(先登录)不崩 ⇒ DLL 对"未登录"路径支持不良,记入怪癖清单。

这套"最小复现 + 变量隔离 + 对照组"的流程,比上调试器盲猜快得多。

10.3 验证清单(发布前逐项打勾)

  • cargo check --all-targets:0 错误 0 警告
  • 每个结构体都有 size_of 等值断言
  • 关键结构体抽查 offset_of! 断言
  • 枚举值与头文件逐一核对
  • vtable 槽位与头文件行号逐一核对(Api 和 Spi 两张表)
  • dumpbin 验证工厂函数修饰名
  • DLL 冒烟测试通过(版本号、错误传播、SPI 注册)
  • 回调端到端收到过真实数据(联机)
  • 无凭据、无个人路径入库

第 11 章 完整使用示例

最终,使用者看到的是这样的 Rust 代码(与第 1 章 C++ demo 逐步对应):

use xtp_api::common::*;
use xtp_api::quote::*;

struct MySpi;

impl QuoteSpi for MySpi {
    fn on_depth_market_data(&self, md: &XtpMarketData,
        bid1: &[i64], _: i32, _: i32, ask1: &[i64], _: i32, _: i32) {
        println!("{} 最新价 {:.2} 买一 {} x {:?}", md.ticker_str(), md.last_price, md.bid[0], bid1);
    }
    fn on_disconnected(&self, reason: i32) {
        eprintln!("断线! reason={}", reason);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 凭据从环境变量读(不要硬编码进代码库!)
    let user = std::env::var("XTP_QUOTE_USER")?;
    let pass = std::env::var("XTP_QUOTE_PASSWORD")?;

    // 1. 创建(对应 CreateQuoteApi)
    let mut api = QuoteApi::new(1, "./data", LogLevel::Debug, true)?;
    println!("API 版本: {}", api.get_api_version());

    // 2. 配置(必须在 Login 前)
    api.set_config_file("./quote_config.ini")?;   // 注意:官方要求绝对路径
    api.set_heart_beat_interval(30);

    // 3. 注册回调(对应 RegisterSpi,必须在 Login 前)
    api.register_spi(Box::new(MySpi));

    // 4. 登录(同步阻塞)
    api.login("119.3.103.38", 6002, &user, &pass, ProtocolType::Tcp, None)?;

    // 5. 订阅
    api.subscribe_market_data(&["600000"], ExchangeType::SH)?;
    api.subscribe_tick_by_tick(&["000001"], ExchangeType::SZ)?;

    // 6. 跑事件循环(回调在 DLL 线程里到达)
    std::thread::sleep(std::time::Duration::from_secs(60));

    // 7. 登出(对应 Logout;Release 由 Drop 自动完成)
    api.logout()?;
    Ok(())
}

交易侧:

use xtp_api::common::*;
use xtp_api::trader::*;

struct MyTradeSpi;
impl TraderSpi for MyTradeSpi {
    fn on_order_event(&self, order: &XtpOrderInfo, err: &XtpRspInfo, _sid: u64) {
        println!("报单 {} 状态={}", order.order_xtp_id, order.order_status);
    }
    fn on_trade_event(&self, trade: &XtpTradeReport, _sid: u64) {
        println!("成交 {} 价格={:.2} 数量={}", trade.order_xtp_id, trade.price, trade.quantity);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 凭据从环境变量读(不要硬编码进代码库!)
    let (ip, user, pass, key) = (
        std::env::var("XTP_TRADER_IP")?,
        std::env::var("XTP_TRADER_USER")?,
        std::env::var("XTP_TRADER_PASSWORD")?,
        std::env::var("XTP_SOFTWARE_KEY")?,
    );

    let mut api = TraderApi::new(1, "./data", LogLevel::Debug)?;
    api.set_software_key(&key)?;
    api.set_software_version("rust-xtp-0.2")?;
    api.subscribe_public_topic(ResumeType::Quick);
    api.register_spi(Box::new(MyTradeSpi));

    let session = api.login(&ip, 6102, &user, &pass, ProtocolType::Tcp, None)?;
    if session == 0 { panic!("登录失败: {}", api.get_api_last_error()); }

    // 下单(注意 &mut:C++ 会回写 order_xtp_id)
    let mut order = XtpOrderInsertInfo::default();
    order.set_ticker("600000");
    order.market = MarketType::SHA as i32;
    order.price = 10.50;
    order.quantity = 100;
    order.price_type = PriceType::Limit as i32;
    order.side = SIDE_BUY;
    order.business_type = BusinessType::Cash as i32;
    let xtp_id = api.insert_order(&mut order)?;

    // 撤单
    api.cancel_order(xtp_id)?;

    // 查询(回调 on_query_asset / on_query_position 里收结果)
    api.query_asset()?;
    api.query_position(None, MarketType::Init)?;

    api.logout()?;
    Ok(())
}

附录 A 速查表

A.1 封装一个 C++ 接口的固定动作

步骤动作工具
1找到头文件,确定 pack 值#pragma pack
2抄虚函数顺序成 vtable 结构体头文件行号
3dumpbin 抄工厂函数修饰名dumpbin /exports
4逐字段翻译结构体+写 size/offset 断言手工验算表
5逐值翻译枚举头文件
6写 SPI 桥(holder + 静态 vtable + trampoline)本教程第 7 章
7安全层:RAII/错误/&mut/Send+Sync第 8 章
8冒烟测试 + 联机测试第 10 章

A.2 常用命令

# 查 DLL 导出符号
dumpbin /exports xtptraderapi.dll | findstr Create

# 跑全部测试(含需要 DLL 的)
$env:XTP_SDK_PATH="C:\path\to\xtp"; cargo test -- --include-ignored --test-threads=1

# 只跑静态测试(无 DLL)
cargo test

A.3 字段翻译口诀

char 数组用 [u8;N];枚举整型用 i32;typedef uint32 用 u32;
指针入参看 const,非 const 用 &mut;
union 先看判别字段;位域展开成单字节;
pack(1) 逐个验偏移;断言全用等值写。

附录 B 术语表

术语解释
ABI二进制接口约定:参数怎么传、栈谁清、符号叫什么、内存怎么排
vtable虚函数表:C++ 对象前 8 字节指向的函数指针数组,多态的实现机制
this 指针C++ 非静态成员函数的第一个隐藏参数,指向对象本身
name mangling名字修饰:C++ 编译器把签名编码进符号名,如 ?CreateQuoteApi@...
SPIService Provider Interface,XTP 的回调接口类(QuoteSpi/TraderSpi)
trampoline跳板函数:C++ 调进来后转发到 Rust trait 方法的 extern “C” 函数
UB未定义行为:如通过共享引用写内存、悬垂指针——不保证报错但随时爆炸
#[repr(C)]让 Rust 结构体按 C 规则布局(字段顺序、对齐)
#[repr(C, packed)]C 布局 + 无填充,对应 #pragma pack(1)
FFIForeign Function Interface,跨语言调用接口
RAII资源获取即初始化:用 Drop 自动释放 C++ 对象
session_id交易登录成功后返回的会话编号,后续所有交易请求都要带
request_id查询请求的编号,用来把查询应答回调和请求配对

第 12 章 连接、账号与生命周期(官方规则)

本章素材来自《API 常见问题》《XTP 行情服务接入前指引》《XTP 行情 Quote-API 断线后应对措施》《XTP 交易 Trader-API 使用示例说明》等官方文档。封装能编译只是起点,按官方规则正确使用才不会在生产环境翻车。

12.1 环境与连接方式速查

XTP 的行情有多种推送方式,选错连接方式是最常见的新手错误(收不到数据的第一嫌疑):

环境连接方式数据内容关键限制
公网测试环境只能 TCPL1 级别:5 档快照、买一卖一队列、逐笔委托/成交,没有 OrderBook全体用户共享 2MB 带宽;单订阅接口单市场上限 100 只(沪深共 200);每日 23:30-00:10、8:00-9:00 维护
生产 L1TCP(以运营通知为准)5 档快照
生产 L2必须 UDP(组播)10 档快照、买一卖一队列、逐笔组播可达性、缓冲区调优(见第 14 章)
交易(任何环境)只能 TCP

要点:

  • Loginsock_type 参数必须与服务器推送方式一致,否则登录成功也收不到行情(官方 FAQ 84);
  • 公网测试环境不要用全市场订阅(SubscribeAllMarketData),带宽打满会被断线——用 subscribe_market_data 订几只股票即可(官方 FAQ 43、68、99);
  • 模拟测试环境的行情是 L1 五档,来自网页抓取(约 5 秒一次),与实盘有时间差;
  • 交易服务器一般 8:45 开启(最晚 9:15),17:00 关闭;资金/持仓查询可用时段为 8:45-17:00。

公网测试

生产 L1

生产 L2

交易

你的环境?

ProtocolType::Tcp
单订阅几只股票

ProtocolType::Tcp(以运营通知为准)

ProtocolType::Udp
+ 组播调优(第 14 章)

ProtocolType::Tcp(唯一选择)

12.2 client_id 与实例规则(官方硬约束)

规则内容出处
client_id 取值**199**;100255 是 XTP 预留Trader-API 示例说明
client_id 数量交易中建议不超过 5 个;超限报 used client_id number exceededFAQ 6、71
同一 account + client_id同时只能有一个 session 连接,后者无法登录FAQ 4
多客户端登录同一账户不同 client_id 即可FAQ 6
一个进程内 TraderApi 数量只能创建一个(多账户共用这一个实例,多次 Login 各自拿 session_id)FAQ 11
一个进程内 QuoteApi 数量2.2.33.5 起可多个,但只有第一次 Create 的参数有效,共用一份 quote.logFAQ 11
过夜不支持。策略程序必须每日重启:销毁 API → 次日重新 Create + LoginFAQ 12
CreateApi 返回 NULL常见于:实例超上限、client_id 为 0、存储路径不存在、头文件与库版本不一致FAQ 104

对封装的启示:client_id 的类型是 u8(0-255),但合法业务区间是 1-99。可以在 QuoteApi::new/TraderApi::new 里加一个防御性检查:

if client_id == 0 || client_id > 99 {
    return Err(XtpError::InvalidArgument(
        "client_id 官方建议取值 1~99(0 和 100~255 均不合法)".into()));
}

12.3 同步 vs 异步:一张表记牢

接口同步/异步说明
Login同步阻塞返回即知成败:行情返回 0 成功(-1 连接错、-2 已存在连接、-3 参数错);交易返回 session_id(>0 成功)
Logout同步阻塞不允许在回调线程调用
订阅/退订异步返回值只是"发送是否成功",结果看 OnSubMarketData 等回调
查询异步结果逐条推送:N 条结果回调 N 次,最后一次 is_last = true
InsertOrder/CancelOrder同步发送返回值是 order_xtp_id(0 失败);订单状态看 OnOrderEvent
GetApiLastError同步非线程安全(官方 FAQ 185),其余接口都线程安全

查询回调里的两个高频误判:

fn on_query_position(&self, pos: &XtpStkPosition, err: &XtpRspInfo, req: i32, is_last: bool, _: u64) {
    // 误判 1:error_id 非 0 不一定是错误
    if err.error_id == 11000350 {
        return; // "Find none record" —— 只是没有持仓,不是查询失败
    }
    // 误判 2:is_last=true 时数据依然有效(可能只有 error_info 有效,先看 error_id 再看数据)
    if !err.is_ok() { return; }
    // ... 处理 pos ...
    if is_last { println!("本次查询完毕"); }
}

12.4 断线重连的正确姿势(交易)

官方推荐流程(FAQ 44、49):

  1. OnDisconnected 被触发时,不要Release()不要Logout();
  2. 直接在回调里(或通知主线程)循环调 Login(),失败间隔 ≥3 秒;
  3. 重连成功后必须更新 session_id(重连后会变,FAQ 34、207);
  4. 未 Logout 的重连默认是 Resume 模式:从断点消息处续传,不会丢订单回报。

官方 C++ 模板翻译成 Rust(放在策略线程里跑,不要在回调线程里死循环):

// 回调线程:只发通知
impl TraderSpi for MySpi {
    fn on_disconnected(&self, session_id: u64, reason: i32) {
        let _ = self.tx.send(Event::Disconnected { session_id, reason });
    }
}

// 主循环:负责重连
fn run_reconnect_loop(api: &mut TraderApi, rx: &Receiver<Event>, cfg: &LoginCfg) {
    while let Ok(ev) = rx.recv() {
        if let Event::Disconnected { .. } = ev {
            loop {
                match api.login(&cfg.ip, cfg.port, &cfg.user, &cfg.pass, ProtocolType::Tcp, None) {
                    Ok(sid) if sid > 0 => {
                        println!("重连成功,新 session_id = {}", sid); // 已自动更新
                        break;
                    }
                    _ => std::thread::sleep(std::time::Duration::from_secs(10)), // 建议不小于 3s
                }
            }
        }
    }
}

12.5 公共流重传:Restart / Quick / Resume

SubscribePublicTopic() 必须在 Login 之前 调用,决定订单响应、成交回报这些"公共流"消息怎么补发:

方式行为用途
ResumeType::Restart (0)本交易日开始重传全部公共流(OnOrderEvent/OnTradeEvent/OnFundTransfer)程序重启后恢复全量状态
ResumeType::Quick (1)只收登录之后的新消息干净启动,最常用
Resume(隐式)断线后不调 Logout 直接 Login,从断点续传断线重连(见 12.4)

注意官方 FAQ 20:断线后如果先 Logout 再 Login,就按你设置的 Restart/Quick 来;如果不 Logout 直接 Login,默认 Resume 续传。两种路径收到的消息不同,别搞混。

12.6 行情断线的处理(Quote)

行情断线要分协议处理(官方《断线后应对措施》):

TCP(测试环境/L1)

UDP(生产 L2)

不需要

需要

OnDisconnected(reason) 触发

当前是哪种连接?

循环 Login(≥3s 间隔)
成功后重新订阅
TCP 不会自动重推行情

只是 TCP 通道断了
UDP 组播可能还在收行情

还需要查询静态数据吗?

可以不重连

重连 Login 即可
无需重新订阅

怀疑丢包?

RequestRebuildQuote 回补(见 14.6)


第 13 章 订单与查询的业务语义

13.1 订单生命周期与回调时序

XTP 订单的状态机与回调规则(官方 FAQ 14、33、101、173、242):

InsertOrder 返回 order_xtp_id

OnOrderEvent(开始状态)

OnOrderEvent(终态)

OnOrderEvent(终态)

OnOrderEvent(终态)

OnOrderEvent(终态,带 error_info)

OnTradeEvent(部成不推 OnOrderEvent!)

本地初始

未成交

全部成交

部分撤单

已撤单

已拒绝

五条铁律:

  1. 部成不推 OnOrderEvent。要知道部成,只能累加 OnTradeEvent 的成交量,或调 QueryOrdersExqty_left(FAQ 31);
  2. 时序保证:开始状态在所有成交回报之前到达,结束状态在所有成交回报之后到达(FAQ 33);API 内部还有约 3~5 秒的保序窗口(FAQ 101);
  3. InsertOrder 还没返回,就可能先收到 OnOrderEvent(FAQ 80/81)!此时回调里的 order_xtp_id 还没关联上你的本地订单——对策:
    • order_client_id 做关联键(回调原样带回);
    • 收到不认识的订单先缓存不要丢弃,等 InsertOrder 返回后再对账;
    • 或者用 GetANewOrderXTPID() 预取 id + InsertOrderExtra() 报单(返回值即传入的 id,关联在发送前就建立);
  4. qty_left 的含义随状态变:未成交/全成/废单时是"未成交数量",部撤/全撤时是"被撤数量"(FAQ 24);
  5. 撤单成功没有单独回调,原订单的 OnOrderEvent 会变更为部撤/全撤;只有撤单失败才有 OnCancelOrderError(FAQ 25)。

13.2 查询的正确姿势

  • 查询结果逐条推送,N 条结果 N 次回调,is_last=true 收尾(FAQ 15);is_last 那条也可能只带 error_info 没有数据(FAQ 234);
  • error_id = 11000350(“Find none record”)表示没有记录,不是错误(FAQ 62);
  • request_id 自己管理,回调原样带回,用来把应答和请求配对;
  • 数据特别多时用分页查询(QueryOrdersByPage 等):req_count 每页条数、reference 起始索引(首次传 0,之后传回调里的 query_reference)、回调里 order_sequence == req_count 说明还有下一页(官方分页示例);
  • 不要轮询查询接口。官方明确"不建议轮询使用,当报单量过多时,容易造成用户线路拥堵,导致 api 断线"——要数据用订阅推送;
  • 沪深两个市场的静态信息查询分开调,等一个市场全部回来(is_last)再查另一个(官方 Quote-API 示例说明)。

13.3 时间与编码

字段时间来源
行情 data_time交易所时间
交易 cancel_timetrade_time交易所时间
交易 insert_timeupdate_timeXTP 本地时间

ticker_name 等字符串是 UTF-8,Rust 侧 from_cstring_bytes + to_string_lossy 直接正确;时间格式统一为 YYYYMMDDHHMMSSsss(i64)。

13.4 回调数据时效与线程模型(再强调)

  • 回调参数指针只在回调期间有效(FAQ 232):Rust 的 &T 引用 + 需要时 clone(),从类型系统上杜绝了 C++ 里"存指针后读脏数据"的经典坑;
  • 线程模型(FAQ 146-149):
    • QuoteSpi:TCP = 1 个回调线程;UDP = 1 个 TCP 线程 + 每个组播组 1 个 UDP 线程(快照/逐笔/订单簿可能在不同线程);
    • TraderSpi:查询回调 1 个线程;OnOrderEvent/OnTradeEvent 可能有 2 个线程(正常 + 超时线程);
    • 回补回调(OnRebuildTickByTick 等)与订阅回调不在同一个线程;
  • 结论:同一批数据可能并发到达,用户状态要么 Mutex,要么无锁;这也是为什么 trait 必须 Send + Sync

第 14 章 行情数据语义与生产环境调优

14.1 ticker_status:8 字节交易状态解码

XtpMarketData.ticker_status: [u8; 8] 的每一位含义(官方《接入前指引》+ FAQ 64):

含义取值
0交易时段S启动(开市前) C集合竞价 T连续竞价 B休市( SZ) E闭市 P停牌 M可恢复熔断 N不可恢复熔断 U收盘集合竞价(SH) D集合竞价结束-连续竞价前(SH) A盘后交易(SZ) V波动性中断
1可否正常交易0不可 / 1可(无意义填空格)
2是否上市0未上市 / 1已上市(深市无此位)
3当前时段是否接受报单0不接受 / 1接受(深市无此位)

判停牌的正确姿势(FAQ 229):看第 0 位是不是 P 不够,还要看第 1 位——停牌股交易所也可能发 T。债券例外:上交所 L2 债券的 ticker_status 无意义,要用 bond.instrument_status(FAQ 65)。

14.2 快照字段语义要点

字段注意事项
last_price集合竞价阶段为 0(无成交)
close_price上交易所有(>0 有效);深交所无此字段,XTP 赋值为 last_price
qty单位:股票为股;债券行情里 SH 为手、SZ 为张(1 手=10 张);但交易报单统一为张
bid/ask 十档L1 只有 5 档有效;期权永远只有 5 档;集合竞价阶段仅买一卖一有值
upper/lower_limit_priceSZ 实时给出;SH 来自初始化文件(盘前可查,见 QueryAllTickers)
trades_countSHL1 无意义(0),SHL2/SZ 有值
avg_price无意义字段
data_type_v20 指数 1 期权 2 现货 3 债券——读 union 前先看它
买一卖一队列仅 L2 提供,数组最大 50 笔,实际长度看 bid1_count/ask1_count,9:25 后才有

14.3 逐笔(TickByTick)语义

  • channel_no:频道号。同一股票的逐笔委托与逐笔成交同频道;一个频道里有多只股票;seq 在频道内连续(丢包检测依据)。SH 现有频道 [1,6] [20] [801],SZ [2011-2015] [2021-2025] [2031-2035] [2061] [2071];
  • 字符型字段对照表:
字段SHSZ
entrust.sideBS12G借入 F出借
entrust.ord_typeA增加 D删除(撤单在这里)1市价 2限价 U本方最优
trade.trade_flagB主动买 S主动卖 N未知(集合竞价都是 N)4撤单 F成交
  • 委托↔成交追溯:SH 用 trade.bid_no/ask_noentrust.order_no;SZ 对 entrust.seq(FAQ 160);
  • 判断主动买卖:bid_no > ask_no 为主动买(买方后报单),反之为卖(FAQ 196);
  • 期权没有逐笔数据(FAQ 168)。

14.4 行情更新频率(心里有数)

品种集合竞价连续竞价午间休市
SH 指数约 5 秒约 5 秒60 秒
SH 股票有变化 3 秒/无变化 60 秒同左60 秒
SH 期权有变化 15 秒/无变化 30 秒有变化 0.5 秒/无变化 30 秒30 秒
SZ 指数60 秒3 秒60 秒
SZ 股票60 秒有变化 3 秒/无变化 60 秒60 秒
逐笔/订单簿实时实时

高峰时段(9:15、9:30、13:00、15:00)数据量约 20 万笔/秒,带宽约 22MB/秒——这就是为什么回调里什么都不许干。

14.5 UDP 生产环境调优清单

UDP 收不到/丢包排查

1. sock_type 必须是 Udp

2. 防火墙关闭或放行组播

3. Windows 注册表:IGMPVersion=3 / IGMPLevel=2
DefaultReceiveWindow=134217728(128M)

4. Linux:net.core.rmem_max=134217728
netstat -gn 确认组播组绑定到正确网卡

5. 缓冲区开大(见下)

6. 日志级别降到 INFO(DEBUG 太吵会丢包)

7. tcpdump/wireshark 确认组播包到没到机器

  • 接收缓冲区:经典版用 SetUDPBufferSize()(2 的次方 MB,官方建议 256~512MB,最低 64MB);XTPX 4.0 没有这个接口,改由 quote_config.iniL1_buf_capacity/L2_buf_capacity 配置(见第 17 章);
  • 绑核:XTPX 用 SetUDPThreadAffinityArray(必须在 SetConfigFile 之后、Login 之前调);经典版是 SetUDPRecvThreadAffinityArray/SetUDPParseThreadAffinityArray 分开绑。绑定靠后的核,绑第 1 个核容易和系统抢资源;绑的是逻辑 CPU,是"绑定"不是"隔离"(FAQ 53、131);
  • 异步日志(udpseq 丢包排查日志):经典版用 SetUDPSeqLogOutPutFlag 开关;XTPX 用 CreateQuoteApi 的第 4 个参数 udpseq_output。调试期开,稳定后关(省一个核);
  • 丢包排查:quote.log 里出现大量 discrete 关键字 = 丢包(启动时偶尔一条可忽略,那是订阅前服务器已发的包);udpseq0_0.*(快照 seq)、udpseq0_1.*(订单簿 seq)、udpseq0_2.*(逐笔 seq)、udpseq3_0.*(缓冲区满)分别检查;
  • 回调纪律:TCP 下收得慢会被服务器断开;UDP 下收得慢会丢包。官方建议"生产者/消费者"模式(第 16 章实战)。

14.6 行情回补(RequestRebuildQuote)

UDP 丢包后,可以请求回补(官方《L2 行情数据回补功能的使用说明》):

let req = XtpQuoteRebuildReq {
    request_id: 1,
    data_type: QuoteRebuildDataType::TickByTick as u32,  // 1=快照 2=逐笔 3=指定股票逐笔
    exchange_id: ExchangeType::SZ as u32,
    channel_number: 2011,        // 逐笔才用
    _unuse: [0; 2],
    ticker: [0; 16],             // 快照/指定股票逐笔时用
    begin: 20,                   // 逐笔:seq 起点(闭区间);快照:时间起点(闭区间)
    end: 78,                     // 逐笔:seq 终点(闭区间);快照:时间终点(开区间)
};
api.request_rebuild_quote(&req)?;
// 数据从 on_rebuild_tick_by_tick / on_rebuild_market_data 回调(与订阅回调不同线程)
// 结果从 on_request_rebuild_quote 回调:result_code=PARTLY(2) 说明没补完,要接着请求

要点:一次最多回补 1000 条,超了分批;result_codeNO_DATA(3) 可能服务器也缺数据,等会儿再试;FREQUENTLY(5) 是被限频,降速。XTPX 4.0 的回补集成在主 API 里(无需单独登录);经典版要 LoginToRebuildQuoteServer 单独连回补服务器,用后及时断开(第 17 章详述)。


第 15 章 风控、错误代码与下单参数速查

15.1 风控规则(别把你的策略打成筛子)

交易服务器有多层风控(官方《XTP 风控规则说明》),触发后限开仓甚至断线,回滚/熔断时间各自独立:

规则作用
Rule1/2单笔订单股数/金额上限(防乌龙指)
Rule9/10限价单/市价单股数上限
Rule14单位时间(1 秒)订单次数上限,触发"熔断"(一段时间内继续报违规)
Rule40单位时间单连接订单+撤单次数上限,触发直接断线
Rule25/26/27/38被市场/柜台拒绝次数累计或周期上限,超限拦开仓单
Rule41累计撤单次数上限
Rule42撤单/订单比上限(撤单比例太高拦开仓单)
Rule43单位时间单只股票订单次数上限
Rule44委托订单最小金额下限
默认 1/2/3可用资金不足 / 可用仓位不足 / 超涨跌停报价(永远生效)

相关错误码:11000450(订单频率被限)、11000451(账户被限制交易)、11000301(风控拒绝)。策略要做好限流(每秒报单/撤单计数),触发风控后当天基本只能等第二天(FAQ 36:模拟环境触发风控要等服务器重启)。

15.2 模拟撮合:用 order_client_id 控制撮合结果

公网测试环境默认轮询撮合(未成交/部成/全成/拒单轮流)。API 下单时可用 order_client_id 指定(FAQ 36):

order_client_id撮合结果
1未成交
2全部成交(单笔回报)
3部分成交
4废单
5全部成交(多笔回报)
6按当前快照盘口撮合(主板/科创/创业/ETF 买卖支持;债券仅上海)

模拟交易所的拒单 error_code 为 11110000/11100000(msg 为 217/10000/29999)是模拟拒单,方便你测试拒单处理路径,不代表报单有错。

15.3 常用错误代码速查

完整表见附录 C。使用建议:程序里对 error_id分类处理——11000350 当"空结果"而非错误;10200000/10210000 当"网络/服务器未就绪"走重连;11000030/11000033 当"账号/密钥错误"直接报警终止。


第 16 章 综合实战:最小可用行情接收器

把前面所有原则落成一个能跑的生产者/消费者行情接收器:回调线程只负责"拷贝数据、扔进 channel",处理线程负责业务。这是官方反复推荐的模式。

//! market_data_receiver.rs —— 生产者/消费者模式行情接收器
use std::sync::mpsc::{channel, Receiver, Sender};
use xtp_api::common::*;
use xtp_api::quote::*;

/// 传给处理线程的消息(拥有所有权,不依赖回调内存)
#[derive(Debug)]
enum MdEvent {
    Snapshot(Box<XtpMarketData>),
    TickByTick(Box<XtpTickByTick>),
    Disconnected(i32),
    SubAck { ticker: String, ok: bool, is_last: bool },
}

struct SpiImpl { tx: Sender<MdEvent> }

impl QuoteSpi for SpiImpl {
    // 军规:回调里只做最轻的事 —— clone 数据 + send,立刻返回
    fn on_depth_market_data(&self, md: &XtpMarketData, _: &[i64], _: i32, _: i32, _: &[i64], _: i32, _: i32) {
        let _ = self.tx.send(MdEvent::Snapshot(Box::new(*md)));
    }
    fn on_tick_by_tick(&self, tbt: &XtpTickByTick) {
        let _ = self.tx.send(MdEvent::TickByTick(Box::new(*tbt)));
    }
    fn on_disconnected(&self, reason: i32) {
        let _ = self.tx.send(MdEvent::Disconnected(reason));
    }
    fn on_sub_market_data(&self, t: &XtpSpecificTicker, e: &XtpRspInfo, is_last: bool) {
        let _ = self.tx.send(MdEvent::SubAck { ticker: t.ticker_str(), ok: e.is_ok(), is_last });
    }
}

fn consumer_loop(rx: Receiver<MdEvent>) {
    let mut snaps = 0u64;
    let mut tbts = 0u64;
    let mut last = std::time::Instant::now();
    while let Ok(ev) = rx.recv() {
        match ev {
            MdEvent::Snapshot(md) => {
                snaps += 1;
                // 业务处理在这里:策略信号、落盘、转发…
                if snaps % 1000 == 1 {
                    println!("快照 {} | {} last={:.2} vol={}", snaps, md.ticker_str(), md.last_price, md.qty);
                }
            }
            MdEvent::TickByTick(_) => tbts += 1,
            MdEvent::Disconnected(r) => {
                eprintln!("[!] 断线 reason={},由外层负责重连(见 12.6)", r);
            }
            MdEvent::SubAck { ticker, ok, is_last } => {
                println!("订阅{}: {} {}", if ok { "成功" } else { "失败" }, ticker, if is_last { "(最后一条)" } else { "" });
            }
        }
        if last.elapsed().as_secs() >= 10 {
            println!("-- 统计:快照 {} 条,逐笔 {} 条 --", snaps, tbts);
            last = std::time::Instant::now();
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (tx, rx) = channel::<MdEvent>();
    std::thread::spawn(move || consumer_loop(rx));

    let user = std::env::var("XTP_QUOTE_USER")?;
    let pass = std::env::var("XTP_QUOTE_PASSWORD")?;
    let mut api = QuoteApi::new(1, "./data", LogLevel::Info, true)?; // 生产用 Info,别用 Debug
    api.set_config_file("./quote_config.ini")?;
    api.register_spi(Box::new(SpiImpl { tx }));
    api.login(&std::env::var("XTP_QUOTE_IP")?, 6002, &user, &pass, ProtocolType::Tcp, None)?;

    // 公网测试环境:只订少量股票(共享 2MB 带宽!)
    api.subscribe_market_data(&["600000", "600519"], ExchangeType::SH)?;
    api.subscribe_tick_by_tick(&["000001"], ExchangeType::SZ)?;

    println!("接收中,Ctrl+C 退出");
    loop { std::thread::sleep(std::time::Duration::from_secs(60)); }
}

对照检查清单(这个例子做对了什么):

  • 回调里零格式化、零锁竞争,只 clone + send;
  • 日志级别用 Info 而不是 Debug(DEBUG 日志量大到能丢包);
  • 测试环境单订阅少量股票,不碰全市场订阅;
  • 消息拥有所有权(Box<XtpMarketData>),不依赖回调内存时效;
  • 断线只发事件,重连逻辑放外层(回调线程不死循环)。

第 17 章 XTPX 4.0 与经典版 API 的差异

这是最容易被忽视、后果最严重的一章。中泰有两代行情 API,函数名大量雷同,但 ABI 细节不同。封装错版本 = 全部白干。

17.1 差异总表

维度经典版 Quote APIXTPX 4.0 Quote API(本教程封装对象)
DLLxtpquoteapi.dll(随交易包分发)xtpxquoteapi.dll(独立行情包)
命名空间XTP::APIXTPX::API
工厂函数CreateQuoteApi(client_id, path, log_level) 3 参数CreateQuoteApi(client_id, path, log_level, udpseq_output) 4 参数
行情配置文件SetConfigFile(quote_config.ini)(登录前必须设置,否则收不到行情)
UDP 缓冲区SetUDPBufferSize(MB)无此接口,由 quote_config.iniL1/L2_buf_capacity 决定
UDP 异步日志开关SetUDPSeqLogOutPutFlag(bool)工厂函数第 4 参 udpseq_output
线程绑核SetUDPRecvThreadAffinityArray + SetUDPParseThreadAffinityArray(两个)SetUDPThreadAffinityArray(一个)
行情回补LoginToRebuildQuoteServer/LogoutFromRebuildQuoteServer 单独登录回补服务器集成进主 API:RequestRebuildQuote 直接调
回补断线回调OnRebuildQuoteServerDisconnected无(并入主连接)
XTP_EXCHANGE_TYPEenum:SH=1,SZ=2,NQ=3,UNKNOWN=4typedef uint32_t:SH=1,SZ=2,NQ=3,HK=4,UNKNOWN=5
XTPMarketDataStruct 布局exchange_idticker 之前;data_type/data_type_v2 在结构体末尾ticker 在前,exchange_iddata_type_v2 紧随其后(见第 5 章)
新三板静态信息XTPNQFIXTPQuoteNQFullInfo(位域布局见 5.4)
港股通/指数通有(SubscribeAllHKCMarketData/SubscribeAllIndexPress 等)
逐笔状态订单2.2.32 起新增同左(TbtType::State=3)
pack 值头文件 #pragma pack(8)头文件 #pragma pack(1)

17.2 quote_config.ini 详解(XTPX 特有)

XTPX 4.0 把 UDP 接收的各项参数从代码挪到了配置文件(官方要求绝对路径,必须在 Login 前 set_config_file):

[md]                  ; 快照(MarketData)通道
decode_flag = 1
parse_cpu_id = 2      ; 解析线程绑核
[md.normal]
enable = ON
local_ip = 127.0.0.1  ; 接收网卡(生产环境填实际网卡 IP)
recv_cpu_id = 3       ; 接收线程绑核
enable_efvi = OFF     ; solarflare 网卡加速
L1_buf_capacity = 256 ; L1 接收缓冲(MB)
L2_buf_capacity = 8   ; L2 接收缓冲(MB)
busy_wait = ON        ; 忙等模式(低延迟,费 CPU)

[tbt] ...             ; 逐笔通道(同上结构)
[ob] ...              ; 订单簿通道(同上结构)

[subscribe_quote_type]; 各品种行情源开关(ON/OFF)
sh_level2_md_stock = ON
sz_level2_tbt_stock = ON
...

SetUDPThreadAffinityArray 的作用是把 md/ob/tbtenable=ON 的通道按数组顺序重新分配 CPU(配置文件中 enable=OFF 的不会分配)。这与第 8 章修复的接口一一对应。

17.3 给封装者的警告

  1. 先确认目标 DLL 属于哪一代,再选头文件。GetApiVersion() 返回形如 1.2.1-r.3 的是 XTPX;形如 2.2.x.x 的是经典版;
  2. 两个包的 XTP_EXCHANGE_TYPE 枚举末位不同(UNKNOWN=4 vs 5,且 XTPX 多了 HK=4)——全订阅传 UNKNOWN 时,传错值行为全变;
  3. 两个包的 XTPMarketDataStruct 布局不同(经典版 exchange_id 在前且判别字段在末尾)。网上(包括官方部分老文档)流传的结构图是经典版的,照抄必错;
  4. 经典版文档里的 SetUDPBufferSizeLoginToRebuildQuoteServer 在 XTPX 里不存在,vtable 里找不到对应槽位;
  5. 同一个进程可以同时使用交易 DLL + XTPX 行情 DLL(本教程的组合),也可以交易 DLL + 经典行情 DLL(官方 demo 的组合)——但别在同一个类里混用两代行情接口

附录 C 常用错误代码表

完整表见官方《XTP 错误代码速查表》。按"你在什么时候会遇到"分类整理。

连接与登录(102xxxxx)

代码含义对策
10200000行情服务器未开启/无网络连接检查 IP/端口/防火墙,走重连
10200004重复连接(已登录)先 Logout 或用已有连接
10200101CreateQuoteApi 参数错(client_id=0 或路径错)检查构造参数
10210000交易服务器未开启/无网络连接同上
10210101CreateTraderApi 参数错同上

账号与权限(11000xxx)

代码含义对策
11000030用户名或密码不正确报警终止,检查凭据
11000033交易密钥错误或未设置检查 SetSoftwareKey 是否在 Login 前调用且 key 正确
11000043/44/45client_id 使用个数超限固定使用 1~99 内的少量 client_id
11000311未在对应节点交易对应市场(一账号两中心)检查连接的节点与报单市场
11000550~11000573未开通某权限(风险警示板/市价单/创业板/科创板等)联系营业部开通

报单参数(11001xxx / 110003xx)

代码含义对策
11000107无效订单数量(超 100 万股/零散股规则)检查 quantity 规则(见附录 D)
11000109无效价格类型检查 price_type 与市场/品种匹配
11000301风控拒绝见 15.1
11000305找不到原始订单(撤单 id 错)检查传入的 order_xtp_id
11000306业务尚未支持换业务类型
11000308/09/10业务类型与证券/方向/用户类型不匹配对照附录 D 检查 business_type/side
11000350未发现记录不是错误,是空结果
11000381/11000384资金余额不足/可划转资金不足查资金再下单
11000450报单频率被限限流,见 15.1

查询与行情(11210xxx 等)

代码含义对策
11210100tickers or sessions used up(订阅超限)测试环境单市场 ≤100 只
11000390获取 ETF 基本信息失败该 ETF 不支持申赎或市场填错
11100055Tgw of the pbu id not found实盘找营业部查账户配置;测试环境查代码/市场

附录 D 下单参数组合速查表

灰色参数(不需要的)一律置 0。session_id 为登录返回值。

表 D-1 现货与常见业务

业务marketprice_typesideposition_effectbusiness_type备注
买入股票SHA/SZA/BJA按注释选(1~8)1 BUY00 CASH普通股票 100 股整数倍;科创板 ≥200 股起、1 股递增
卖出股票同上同上2 SELL00 CASH不超过 sellable_qty;零散股一次性卖出
撤单cancel_order(order_xtp_id)
新股申购SHA/SZA1 LIMIT1 BUY01 IPOS数量 ≤ min(可申购额度, 最大允许申购数量)
新债申购同上1 LIMIT1 BUY01 IPOS同上
配股对应市场1 LIMIT1 BUY06 ALLOTMENT配债同(business_type=6)
国债逆回购SHA/SZA1 LIMIT2 SELL02 REPOquantity 单位张(1000 元面额=10 张=1 手),1000 元面额整数倍
ETF 申购对应市场1 LIMIT7 PURCHASE03 ETF仅现货账户支持;二级市场代码
ETF 赎回对应市场1 LIMIT8 REDEMPTION03 ETF当日申购的份额当日不可赎回(可卖出)

表 D-2 两融与期权业务

业务marketprice_typesideposition_effectbusiness_type备注
担保品买(两融)对应市场1 LIMIT1 BUY04 MARGIN
担保品卖(两融)对应市场1 LIMIT2 SELL04 MARGIN
融资买入对应市场1 LIMIT21 MARGIN_TRADE04 MARGIN标的两融标的
融券卖出对应市场1 LIMIT22 SHORT_SELL04 MARGIN同上
卖券还款对应市场1 LIMIT23 REPAY_MARGIN04 MARGIN
买券还券对应市场1 LIMIT24 REPAY_STOCK04 MARGIN
现券还券对应市场1 LIMIT26 STOCK_REPAY_STOCK04 MARGIN无成交回报,看订单确认
担保品转入对应市场1 LIMIT28 GRTSTK_TRANSIN04 MARGIN普通户→信用户,信用户登录操作
担保品转出对应市场1 LIMIT29 GRTSTK_TRANSOUT04 MARGIN维持担保比例 300% 以上部分可转
期权开/平仓对应市场期权可用类型1/21 OPEN/2 CLOSE10 OPTIONposition_effect 仅期权有效

报单被拒自查清单(官方 FAQ 30):数量规则(100 整数倍/科创板 200 起/逆回购面额)→ 价格是否超涨跌停 → price_type 是否被该品种支持(可转债只支持 LIMIT)→ ticker 与 market 是否匹配 → business_type 是否正确(信用账户普通买卖用 MARGIN + 担保品方向)→ position_effect 非期权填 0 → 是否触发风控(频率/撤单比)→ 是否在可报单时段(8:45 前报单只是缓存,9:15 才发往交易所)。

附录 E 官方文档索引

在线资源

资源地址
XTP 官方文档https://xtp.zts.com.cn/doc/api/xtpDoc
API 下载https://xtp.zts.com.cn/service/download
Python 接口https://github.com/ztsec/xtp_api_python
Java 接口https://github.com/ztsec/xtp_api_java
错误代码速查官网文档内"错误代码速查表";交易所拒单见 IS111(上交所)/深交所 Binary 接口规范

本地官方文档包(xtp_c++)导读

文档什么时候读
头文件.md / 类库文件.md开始封装前:确认头文件清单与各平台库文件
QuoteApi.md / quoteSpi.md / TraderApi.md / TraderSpi.md逐函数翻译 vtable 与 SPI 时(与头文件对照)
XTP行情服务接入前指引.md接行情前必读:字段语义、更新频率、丢包排查
XTP行情Quote-API使用示例说明.md行情 QuickStart 与断线重连模板
XTP行情Quote-API断线后应对措施.mdTCP/UDP 断线策略 + 分页查询示例
XTP交易Trader-API使用示例说明.md交易 QuickStart、client_id/实例/过夜规则
XTP交易业务报单接口参数说明.md每种业务的下单参数组合(附录 D 的来源)
API常见问题.md130+ 条 FAQ,出问题先搜它
XTP错误代码速查表.md收到陌生 error_id 时查
XTP风控规则说明.md写限流逻辑前读
XTP关于L2行情数据回补功能的使用说明.md用回补接口前读(注意是经典版接口)
XTP模拟测试环境常见问题.md用测试环境前读(撮合规则/时段/限制)
版本更新介绍.md升级 API 版本时读,关注结构体/枚举变更
新股/新债/配股/资金划拨/期逻辑示例代码.md做对应业务时参考参数填法

结语:FFI 封装的功夫不在 Rust 语法,而在对 C++ 内存模型的精确理解用测试锁死每一条假设的纪律。前 11 章解决"怎么封装"——头文件是唯一合同、布局要手工验算、断言必须等值、回调注意安全;第 12~17 章解决"怎么用对"——连接方式别选错、client_id 别乱用、回调里别干活、生产环境要调优。两者兼备,你就有能力封装并投产任何 C++ 交易接口了。祝实习顺利,代码无 UB。

更多推荐