Kimi K3:Rust封装XTP接口详细教程实践
用 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.h、xtpx_quote_api.h等)。
本文代码:全部来自一个通过 23 项测试(含真实 DLL 冒烟测试)的可用工程,关键片段可直接复制。
目录
- 第 0 章 我们要做什么、为什么难
- 第 1 章 认识 XTP API 的构造
- 第 2 章 补课:ABI、调用约定与 vtable
- 第 3 章 工程搭建与 DLL 加载
- 第 4 章 重建 vtable:调用第一个 C++ 虚函数
- 第 5 章 结构体逐字段映射(含对齐/pack/union/位域)
- 第 6 章 枚举与常量映射
- 第 7 章 回调(SPI)桥:在 Rust 里伪造一个 C++ 对象
- 第 8 章 安全封装层设计
- 第 9 章 踩坑实录:21 个真实 bug 的分类清单
- 第 10 章 测试与调试方法论
- 第 11 章 完整使用示例
- 第 12 章 连接、账号与生命周期(官方规则)
- 第 13 章 订单与查询的业务语义
- 第 14 章 行情数据语义与生产环境调优
- 第 15 章 风控、错误代码与下单参数速查
- 第 16 章 综合实战:最小可用行情接收器
- 第 17 章 XTPX 4.0 与经典版 API 的差异
- 附录 A 速查表
- 附录 B 术语表
- 附录 C 常用错误代码表
- 附录 D 下单参数组合速查表
- 附录 E 官方文档索引
第 0 章 我们要做什么、为什么难
0.1 任务一句话
中泰证券 XTP(极速交易平台)只提供 C++ 接口(xtptraderapi.dll 交易、xtpxquoteapi.dll 行情)。我们要在 Rust 里使用它,并且做到:
- 调得动:创建 API、登录、订阅、查询、下单;
- 收得到:行情推送、报单回报等回调能进 Rust 代码;
- 不崩溃、不 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 个虚函数
};
}}
这带来四座大山:
本教程把四座大山一座座翻过去。每座山都遵循同一套路:先用 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、两套类
- 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();
注意时序约束:
RegisterSpi、SetHeartBeatInterval、SetSoftwareKey等都必须 在 Login 之前 调用;Release在最后。封装层的文档注释要把这些约束写清楚。
1.3 头文件即合同
XTP 的所有"合同条款"都在头文件里,封装就是逐条翻译成 Rust:
| 头文件 | 内容 | 我们的对应物 |
|---|---|---|
xtpx_quote_api.h | QuoteApi/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.h | TraderApi/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.h | XTPRspInfoStruct(错误信息) | 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):
要点:
-
Rust 的
extern "C" fn在 x86_64-pc-windows-msvc 目标上就是这套约定; -
C++ 非静态成员函数会把
this当作第一个隐藏参数传入 RCX。所以:obj->Login(ip, port) // C++ 写法 // 等价于 C 写法: TraderApi_Login(obj, ip, port) // this 成了第一个参数 -
成员函数与
extern "C" fn(this: *mut c_void, ...)参数逐一对齐后,可以直接互调; -
bool是 1 字节,C++bool与 Rustbool兼容; -
超过 8 字节的结构体按值返回时有特殊规则(隐藏返回参数)——XTP 没有这种函数,虚函数全部返回指针/整数/bool,所以不用管。
2.3 虚函数与 vtable:C++ 多态的内存真相
C++ 对象如果有虚函数,它的前 8 字节是一个指向"虚函数表"(vtable)的指针:
调用 obj->GetApiVersion() 时,编译器生成的逻辑是:
1. vtable = *(void***)obj; // 取对象前 8 字节
2. fn = vtable[1]; // GetApiVersion 是第 2 个虚函数(槽位 1)
3. fn(obj); // 把 obj 当 this 传进去调
槽位顺序 = 头文件中虚函数的声明顺序(单继承、无虚析构的情况下)。XTP 的两个 Api 类析构函数都不是 virtual,所以第一个虚函数(Release)就在槽位 0,没有偏移。
我们只要:
- 读出对象前 8 字节得到 vtable 地址;
- 把 vtable 解释成一个"函数指针数组";
- 用对应槽位的函数指针,以
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(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.h 里 QuoteApi 的虚函数声明顺序,写一个 #[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 / 普通 enum | i32 (参数也可用 c_int) | enum 默认 int |
uint32_t / typedef uint32_t X | u32 | 行情的 XTP_EXCHANGE_TYPE 就是这种 |
uint64_t | u64 | session_id、order_xtp_id |
int64_t | i64 | 数量、时间 |
double | f64 | 价格、金额 |
bool | bool | 同为 1 字节 |
uint8_t | u8 | client_id |
int16_t | i16 | channel_number |
char | u8(字符串数组用 [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 🎉
能打印出版本号,就证明方案 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,
}
规则:
#[repr(C)]必须加,否则 Rust 自由重排字段;char[N]→[u8; N],不要用String/CString(它们是指针,布局完全不同);- 定长数组、嵌套结构体、union 全部按同样规则递归翻译;
- 字符串读取另写辅助函数,字段本身保持字节数组。
/// 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_id | u64 | 8 | 8 | 0 | |
| order_client_id | u32 | 4 | 4 | 8 | |
| order_cancel_client_id | u32 | 4 | 4 | 12 | |
| order_cancel_xtp_id | u64 | 8 | 8 | 16 | |
| ticker | [u8;16] | 16 | 1 | 24 | |
| market | i32 | 4 | 4 | 40 | |
| (填充) | - | 4 | - | 44 | f64 需 8 对齐 |
| price | f64 | 8 | 8 | 48 | |
| quantity | i64 | 8 | 8 | 56 | |
| price_type | i32 | 4 | 4 | 64 | |
| side / position_effect / reserved1 / reserved2 | u8×4 | 4 | 1 | 68 | C++ 是 union{u32; 4×u8} |
| business_type | i32 | 4 | 4 | 72 | |
| (填充) | - | 4 | - | 76 | |
| qty_traded | i64 | 8 | 8 | 80 | |
| qty_left | i64 | 8 | 8 | 88 | |
| insert_time | i64 | 8 | 8 | 96 | |
| update_time | i64 | 8 | 8 | 104 | |
| cancel_time | i64 | 8 | 8 | 112 | |
| trade_amount | f64 | 8 | 8 | 120 | |
| order_local_id | [u8;11] | 11 | 1 | 128 | |
| (填充) | - | 1 | - | 139 | |
| order_status | i32 | 4 | 4 | 140 | |
| order_submit_status | i32 | 4 | 4 | 144 | |
| order_type | u8 | 1 | 1 | 148 | |
| (尾部填充) | - | 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 的内容。
正确译法:
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_of 和 offset_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 头文件里有三种"枚举",译法略有不同:
// 交易枚举(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;
// ...
逐个数字核对! 真实案例:FundTransferType 的 Unknown 在 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 函数:
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: "E_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,防止被提前释放
}
生命周期铁律:
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 回调里的工程纪律(写给使用者)
封装文档里必须警告用户:
- 回调在 DLL 线程里跑,必须快速返回。要处理数据就
channel.send()丢给自己的线程。官方 FAQ 明确:回调处理过慢会填满接收缓冲区,服务器会断开连接(UDP 下则直接丢包); - 回调里不要再调同一 API 的阻塞函数(可能死锁),官方文档注明
Logout不允许在回调线程调; - 回调参数(
&XtpMarketData等)只在调用期间有效——官方 FAQ 232 条明确警告"只保存指针不拷贝数据,之后读到的是被复用的内存"。Rust 的&T+clone在类型层面就强制了这一点(C++ 里靠自觉); - 断线回调
on_disconnected里可以重新Login(官方推荐做法,见第 12 章),但不要在回调里调Release或Logout; - 屏幕输出(
println!)是耗时操作,行情高峰(每秒约 20 万笔)时千万别在回调里逐条打印——官方 FAQ 58 条把"有屏幕输出"列为全订阅断线的首要嫌疑; - 回调线程不止一个(官方 FAQ 146-149):QuoteSpi 在 TCP 下是 1 个线程,UDP 下是 1 个 TCP 线程 + 每组播组 1 个 UDP 线程;TraderSpi 的查询回调是 1 个线程,但
OnOrderEvent/OnTradeEvent可能由正常线程 + 超时线程两个线程回调。所以 trait 要求Send + Sync,用户实现里的共享状态必须自己加锁(Mutex)或用无锁结构; - XTP 字符串(
ticker_name等)是 UTF-8 编码。RustString天然 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 项目的"事故树"。
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 总是先登录所以不触发。对策:
- 封装文档里写明:创建后请先 Login 再 Release;
- 测试用例按"创建 → 尝试登录 → 释放"的顺序写;
- 交易 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 三层测试体系
#[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(访问冲突)不要慌,按这个流程:
复盘我们遇到的崩溃:
- 现象:创建 QuoteApi → 打印版本(成功)→ drop 时崩;
mem::forget(不 Release):不崩 ⇒ 问题在 Release 或 DLL 卸载;- 显式
release()后立刻崩 ⇒ Release 内部崩; - 官方 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 结构体 | 头文件行号 |
| 3 | dumpbin 抄工厂函数修饰名 | 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@... |
| SPI | Service Provider Interface,XTP 的回调接口类(QuoteSpi/TraderSpi) |
| trampoline | 跳板函数:C++ 调进来后转发到 Rust trait 方法的 extern “C” 函数 |
| UB | 未定义行为:如通过共享引用写内存、悬垂指针——不保证报错但随时爆炸 |
#[repr(C)] | 让 Rust 结构体按 C 规则布局(字段顺序、对齐) |
#[repr(C, packed)] | C 布局 + 无填充,对应 #pragma pack(1) |
| FFI | Foreign Function Interface,跨语言调用接口 |
| RAII | 资源获取即初始化:用 Drop 自动释放 C++ 对象 |
| session_id | 交易登录成功后返回的会话编号,后续所有交易请求都要带 |
| request_id | 查询请求的编号,用来把查询应答回调和请求配对 |
第 12 章 连接、账号与生命周期(官方规则)
本章素材来自《API 常见问题》《XTP 行情服务接入前指引》《XTP 行情 Quote-API 断线后应对措施》《XTP 交易 Trader-API 使用示例说明》等官方文档。封装能编译只是起点,按官方规则正确使用才不会在生产环境翻车。
12.1 环境与连接方式速查
XTP 的行情有多种推送方式,选错连接方式是最常见的新手错误(收不到数据的第一嫌疑):
| 环境 | 连接方式 | 数据内容 | 关键限制 |
|---|---|---|---|
| 公网测试环境 | 只能 TCP | L1 级别:5 档快照、买一卖一队列、逐笔委托/成交,没有 OrderBook | 全体用户共享 2MB 带宽;单订阅接口单市场上限 100 只(沪深共 200);每日 23:30-00:10、8:00-9:00 维护 |
| 生产 L1 | TCP(以运营通知为准) | 5 档快照 | — |
| 生产 L2 | 必须 UDP(组播) | 10 档快照、买一卖一队列、逐笔 | 组播可达性、缓冲区调优(见第 14 章) |
| 交易(任何环境) | 只能 TCP | — | — |
要点:
Login的sock_type参数必须与服务器推送方式一致,否则登录成功也收不到行情(官方 FAQ 84);- 公网测试环境不要用全市场订阅(
SubscribeAllMarketData),带宽打满会被断线——用subscribe_market_data订几只股票即可(官方 FAQ 43、68、99); - 模拟测试环境的行情是 L1 五档,来自网页抓取(约 5 秒一次),与实盘有时间差;
- 交易服务器一般 8:45 开启(最晚 9:15),17:00 关闭;资金/持仓查询可用时段为 8:45-17:00。
12.2 client_id 与实例规则(官方硬约束)
| 规则 | 内容 | 出处 |
|---|---|---|
| client_id 取值 | **199**;100255 是 XTP 预留 | Trader-API 示例说明 |
| client_id 数量 | 交易中建议不超过 5 个;超限报 used client_id number exceeded | FAQ 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.log | FAQ 11 |
| 过夜 | 不支持。策略程序必须每日重启:销毁 API → 次日重新 Create + Login | FAQ 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):
OnDisconnected被触发时,不要调Release()、不要调Logout();- 直接在回调里(或通知主线程)循环调
Login(),失败间隔 ≥3 秒; - 重连成功后必须更新 session_id(重连后会变,FAQ 34、207);
- 未 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)
行情断线要分协议处理(官方《断线后应对措施》):
第 13 章 订单与查询的业务语义
13.1 订单生命周期与回调时序
XTP 订单的状态机与回调规则(官方 FAQ 14、33、101、173、242):
五条铁律:
- 部成不推
OnOrderEvent。要知道部成,只能累加OnTradeEvent的成交量,或调QueryOrdersEx查qty_left(FAQ 31); - 时序保证:开始状态在所有成交回报之前到达,结束状态在所有成交回报之后到达(FAQ 33);API 内部还有约 3~5 秒的保序窗口(FAQ 101);
InsertOrder还没返回,就可能先收到OnOrderEvent(FAQ 80/81)!此时回调里的order_xtp_id还没关联上你的本地订单——对策:- 用
order_client_id做关联键(回调原样带回); - 收到不认识的订单先缓存不要丢弃,等
InsertOrder返回后再对账; - 或者用
GetANewOrderXTPID()预取 id +InsertOrderExtra()报单(返回值即传入的 id,关联在发送前就建立);
- 用
qty_left的含义随状态变:未成交/全成/废单时是"未成交数量",部撤/全撤时是"被撤数量"(FAQ 24);- 撤单成功没有单独回调,原订单的
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_time、trade_time | 交易所时间 |
交易 insert_time、update_time | XTP 本地时间 |
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_price | SZ 实时给出;SH 来自初始化文件(盘前可查,见 QueryAllTickers) |
trades_count | SHL1 无意义(0),SHL2/SZ 有值 |
avg_price | 无意义字段 |
data_type_v2 | 0 指数 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];- 字符型字段对照表:
| 字段 | SH | SZ |
|---|---|---|
| entrust.side | B买 S卖 | 1买 2卖 G借入 F出借 |
| entrust.ord_type | A增加 D删除(撤单在这里) | 1市价 2限价 U本方最优 |
| trade.trade_flag | B主动买 S主动卖 N未知(集合竞价都是 N) | 4撤单 F成交 |
- 委托↔成交追溯:SH 用
trade.bid_no/ask_no对entrust.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 生产环境调优清单
- 接收缓冲区:经典版用
SetUDPBufferSize()(2 的次方 MB,官方建议 256~512MB,最低 64MB);XTPX 4.0 没有这个接口,改由quote_config.ini的L1_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_code 为 NO_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 API | XTPX 4.0 Quote API(本教程封装对象) |
|---|---|---|
| DLL | xtpquoteapi.dll(随交易包分发) | xtpxquoteapi.dll(独立行情包) |
| 命名空间 | XTP::API | XTPX::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.ini 的 L1/L2_buf_capacity 决定 |
| UDP 异步日志开关 | SetUDPSeqLogOutPutFlag(bool) | 工厂函数第 4 参 udpseq_output |
| 线程绑核 | SetUDPRecvThreadAffinityArray + SetUDPParseThreadAffinityArray(两个) | SetUDPThreadAffinityArray(一个) |
| 行情回补 | LoginToRebuildQuoteServer/LogoutFromRebuildQuoteServer 单独登录回补服务器 | 集成进主 API:RequestRebuildQuote 直接调 |
| 回补断线回调 | OnRebuildQuoteServerDisconnected | 无(并入主连接) |
XTP_EXCHANGE_TYPE | enum:SH=1,SZ=2,NQ=3,UNKNOWN=4 | typedef uint32_t:SH=1,SZ=2,NQ=3,HK=4,UNKNOWN=5 |
XTPMarketDataStruct 布局 | exchange_id 在 ticker 之前;data_type/data_type_v2 在结构体末尾 | ticker 在前,exchange_id、data_type_v2 紧随其后(见第 5 章) |
| 新三板静态信息 | XTPNQFI | XTPQuoteNQFullInfo(位域布局见 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/tbt 里 enable=ON 的通道按数组顺序重新分配 CPU(配置文件中 enable=OFF 的不会分配)。这与第 8 章修复的接口一一对应。
17.3 给封装者的警告
- 先确认目标 DLL 属于哪一代,再选头文件。
GetApiVersion()返回形如1.2.1-r.3的是 XTPX;形如2.2.x.x的是经典版; - 两个包的
XTP_EXCHANGE_TYPE枚举末位不同(UNKNOWN=4 vs 5,且 XTPX 多了 HK=4)——全订阅传 UNKNOWN 时,传错值行为全变; - 两个包的
XTPMarketDataStruct布局不同(经典版 exchange_id 在前且判别字段在末尾)。网上(包括官方部分老文档)流传的结构图是经典版的,照抄必错; - 经典版文档里的
SetUDPBufferSize、LoginToRebuildQuoteServer在 XTPX 里不存在,vtable 里找不到对应槽位; - 同一个进程可以同时使用交易 DLL + XTPX 行情 DLL(本教程的组合),也可以交易 DLL + 经典行情 DLL(官方 demo 的组合)——但别在同一个类里混用两代行情接口。
附录 C 常用错误代码表
完整表见官方《XTP 错误代码速查表》。按"你在什么时候会遇到"分类整理。
连接与登录(102xxxxx)
| 代码 | 含义 | 对策 |
|---|---|---|
| 10200000 | 行情服务器未开启/无网络连接 | 检查 IP/端口/防火墙,走重连 |
| 10200004 | 重复连接(已登录) | 先 Logout 或用已有连接 |
| 10200101 | CreateQuoteApi 参数错(client_id=0 或路径错) | 检查构造参数 |
| 10210000 | 交易服务器未开启/无网络连接 | 同上 |
| 10210101 | CreateTraderApi 参数错 | 同上 |
账号与权限(11000xxx)
| 代码 | 含义 | 对策 |
|---|---|---|
| 11000030 | 用户名或密码不正确 | 报警终止,检查凭据 |
| 11000033 | 交易密钥错误或未设置 | 检查 SetSoftwareKey 是否在 Login 前调用且 key 正确 |
| 11000043/44/45 | client_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 等)
| 代码 | 含义 | 对策 |
|---|---|---|
| 11210100 | tickers or sessions used up(订阅超限) | 测试环境单市场 ≤100 只 |
| 11000390 | 获取 ETF 基本信息失败 | 该 ETF 不支持申赎或市场填错 |
| 11100055 | Tgw of the pbu id not found | 实盘找营业部查账户配置;测试环境查代码/市场 |
附录 D 下单参数组合速查表
灰色参数(不需要的)一律置 0。
session_id为登录返回值。
表 D-1 现货与常见业务
| 业务 | market | price_type | side | position_effect | business_type | 备注 |
|---|---|---|---|---|---|---|
| 买入股票 | SHA/SZA/BJA | 按注释选(1~8) | 1 BUY | 0 | 0 CASH | 普通股票 100 股整数倍;科创板 ≥200 股起、1 股递增 |
| 卖出股票 | 同上 | 同上 | 2 SELL | 0 | 0 CASH | 不超过 sellable_qty;零散股一次性卖出 |
| 撤单 | — | — | — | — | — | cancel_order(order_xtp_id) |
| 新股申购 | SHA/SZA | 1 LIMIT | 1 BUY | 0 | 1 IPOS | 数量 ≤ min(可申购额度, 最大允许申购数量) |
| 新债申购 | 同上 | 1 LIMIT | 1 BUY | 0 | 1 IPOS | 同上 |
| 配股 | 对应市场 | 1 LIMIT | 1 BUY | 0 | 6 ALLOTMENT | 配债同(business_type=6) |
| 国债逆回购 | SHA/SZA | 1 LIMIT | 2 SELL | 0 | 2 REPO | quantity 单位张(1000 元面额=10 张=1 手),1000 元面额整数倍 |
| ETF 申购 | 对应市场 | 1 LIMIT | 7 PURCHASE | 0 | 3 ETF | 仅现货账户支持;二级市场代码 |
| ETF 赎回 | 对应市场 | 1 LIMIT | 8 REDEMPTION | 0 | 3 ETF | 当日申购的份额当日不可赎回(可卖出) |
表 D-2 两融与期权业务
| 业务 | market | price_type | side | position_effect | business_type | 备注 |
|---|---|---|---|---|---|---|
| 担保品买(两融) | 对应市场 | 1 LIMIT | 1 BUY | 0 | 4 MARGIN | |
| 担保品卖(两融) | 对应市场 | 1 LIMIT | 2 SELL | 0 | 4 MARGIN | |
| 融资买入 | 对应市场 | 1 LIMIT | 21 MARGIN_TRADE | 0 | 4 MARGIN | 标的两融标的 |
| 融券卖出 | 对应市场 | 1 LIMIT | 22 SHORT_SELL | 0 | 4 MARGIN | 同上 |
| 卖券还款 | 对应市场 | 1 LIMIT | 23 REPAY_MARGIN | 0 | 4 MARGIN | |
| 买券还券 | 对应市场 | 1 LIMIT | 24 REPAY_STOCK | 0 | 4 MARGIN | |
| 现券还券 | 对应市场 | 1 LIMIT | 26 STOCK_REPAY_STOCK | 0 | 4 MARGIN | 无成交回报,看订单确认 |
| 担保品转入 | 对应市场 | 1 LIMIT | 28 GRTSTK_TRANSIN | 0 | 4 MARGIN | 普通户→信用户,信用户登录操作 |
| 担保品转出 | 对应市场 | 1 LIMIT | 29 GRTSTK_TRANSOUT | 0 | 4 MARGIN | 维持担保比例 300% 以上部分可转 |
| 期权开/平仓 | 对应市场 | 期权可用类型 | 1/2 | 1 OPEN/2 CLOSE | 10 OPTION | position_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断线后应对措施.md | TCP/UDP 断线策略 + 分页查询示例 |
XTP交易Trader-API使用示例说明.md | 交易 QuickStart、client_id/实例/过夜规则 |
XTP交易业务报单接口参数说明.md | 每种业务的下单参数组合(附录 D 的来源) |
API常见问题.md | 130+ 条 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。
更多推荐
所有评论(0)