Skip to main content

ek_ctp/
map.rs

1//! CTP 码值映射与 MySQL 时间换算(逐字对照 `enkeep_ctp/gateway/ctp_gateway.py`
2//! 顶部映射表 66-128 行)。
3//!
4//! 码值常量带线值引用(`api/ctp_constant.py` 行号);错配翻译策略:能 `Option`
5//! 的全 `Option`,由调用方按原稿行为处置(合约行静默丢/委托行 info 日志丢/
6//! 撤单 parse 失败 console 级特价)。
7
8use crate::wire::{
9    DepthMarketData, ErrorField, InputOrderAction, InstrumentField, InvestorPositionField,
10    OrderField, TradeField, TradingAccountField,
11};
12use ek_data::account::AccountData;
13use ek_data::constant::{Asset, Direction, Exchange, InstrumentType, Offset, OrderType, Status};
14use ek_data::contract::ContractData;
15use ek_data::order::OrderData;
16use ek_data::tick::TickData;
17use ek_data::time::DtNs;
18use ek_data::trade::TradeData;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21// ================ CTP 码值(原稿 `constants` 表;线值取 api/ctp_constant.py) ================
22
23/// 浮点极值标识(原稿 127 行 `MAX_FLOAT = sys.float_info.max`;
24/// IEEE-754 与 Rust `f64::MAX` 同值)。
25pub const MAX_FLOAT: f64 = f64::MAX;
26
27/// 委托状态码(常量 120-128):`'0'`全成交/`'1'`部分成交尚在队列/`'3'`未成交尚在队列/
28/// `'5'`已撤销/`'a'`未知;原稿 STATUS_CTP2VT 只登记这五键(67-73)。
29const OST_ALL_TRADED: &str = "0";
30const OST_PART_TRADED_Q: &str = "1";
31const OST_NO_TRADE_Q: &str = "3";
32const OST_PART_TRADED_NQ: &str = "2";
33const OST_NO_TRADE_NQ: &str = "4";
34const OST_CANCELED: &str = "5";
35const OST_UNKNOWN: &str = "a";
36
37/// 报单方向/持仓方向码(常量 163-169):买 `'0'`/卖 `'1'`/净 `'1'`/多 `'2'`/空 `'3'`;
38/// 原稿 DIRECTION_CTP2VT 把 D_Buy/D_Sell 与 PD_Long/PD_Short 合并一表(80-82)。
39const D_BUY: &str = "0";
40const D_SELL: &str = "1";
41/// 持仓方向码:净 `'1'`(槽位带说明,不参表——注意 CTP 码值冲突:PD_Net 与
42/// D_Sell 都是 `'1'`,原稿 dict 同冲突,`'1'` 一律解析为 SHORT,行为逐字保留)。
43#[allow(dead_code)]
44const PD_NET: &str = "1";
45const PD_LONG: &str = "2";
46const PD_SHORT: &str = "3";
47
48/// 开平标志码(常量 206-212 / OFEN 变体 1118-1124):开 `'0'`/平 `'1'`/平今 `'3'`/
49/// 平昨 `'4'`;原稿用 OFEN_* 线值(94-100)。
50const OFEN_OPEN: &str = "0";
51const OFEN_CLOSE: &str = "1";
52const OFEN_CLOSE_TODAY: &str = "3";
53const OFEN_CLOSE_YESTERDAY: &str = "4";
54
55/// 报单价格类型码(190-192):市价 `'1'`/限价 `'2'`。
56const OPT_ANY_PRICE: &str = "1";
57const OPT_LIMIT_PRICE: &str = "2";
58
59/// 有效期类型码(233/235):IOC `'1'`/GFD `'3'`。
60const TC_IOC: &str = "1";
61const TC_GFD: &str = "3";
62
63/// 成交量类型码(239/241):任意量 `'1'`/全部量 `'3'`。
64const VC_AV: &str = "1";
65const VC_CV: &str = "3";
66
67/// 产品类码(143-149):期货 `'1'`/期权 `'2'`/组合 `'3'`/现货期权 `'6'`;
68/// 原稿 PRODUCT_CTP2VT 这四键(113-118)。
69const PC_FUTURES: &str = "1";
70const PC_OPTIONS: &str = "2";
71/// 产品类码(143-149):期货 `'1'`/期权 `'2'`/组合 `'3'`/现货期权 `'6'`;
72/// 原稿 PRODUCT_CTP2VT 这四键(113-118)。
73///
74/// **0.12.0 改**:组合 `'3'` 与税式期货 `'7'` 不再丢弃 —— `ek-data` 的
75/// [`InstrumentType`] 早有 `SPREAD` 变体,用"资产大类 + 形态"正交表达即可
76/// (`asset = FUTURES` + `instrument_type = SPREAD`),不必等 `Asset` 有 SPREAD。
77/// 这修正了 `D-PC` 记档的"等 ek-data 生态有 SPREAD 后再统一"——条件已具备。
78const PC_COMBINATION: &str = "3";
79const PC_SPOT_OPTION: &str = "6";
80/// 税式期货(CTP 6.7.7 新增,`THOST_FTDC_PC_TAS = '7'`;SimNow 合约表实测含此类)。
81const PC_TAS: &str = "7";
82
83// ================ 产品类白名单(网关勾选) ================
84
85/// 网关收录的产品类白名单(CTP 单字符码 `THOST_FTDC_PC_*`)。
86///
87/// 语义(D-PC-FILTER,DESIGN.md):**查询侧不过滤**(原稿 `reqQryInstrument({})` 全量,
88/// 且实测三个查询口都无法按类型过滤),**收录侧严格丢弃** —— 不在名单内的合约行
89/// 既不进共享合约表,也不上抛 `eContract`。
90///
91/// 空名单**绝不等于「全丢」**:一律回落默认(期货)。理由不是便利,是守卫 ——
92/// `contracts` 为空会让持仓查询整条被跳过(driver.rs 的 `contracts.is_empty()` 守卫),
93/// 一次误配置就把持仓数据关掉。
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct ProductClasses(pub Vec<String>);
96
97impl ProductClasses {
98    /// 默认勾选:只有期货。
99    pub fn default_classes() -> Vec<String> {
100        vec![PC_FUTURES.to_string()]
101    }
102
103    /// 归一化:去首尾空白、去重、丢弃非法码(CTP 产品类码是单字符),
104    /// 结果为空则回落 [`ProductClasses::default_classes`]。返回有序去重表。
105    pub fn normalize(&self) -> Vec<String> {
106        let mut out: Vec<String> = self
107            .0
108            .iter()
109            .map(|c| c.trim().to_string())
110            .filter(|c| c.chars().count() == 1 && !c.is_empty())
111            .collect();
112        out.sort();
113        out.dedup();
114        if out.is_empty() {
115            return Self::default_classes();
116        }
117        out
118    }
119
120    /// 该产品类码是否收录。
121    pub fn accepts(&self, product_class: &str) -> bool {
122        self.normalize().iter().any(|c| c == product_class)
123    }
124}
125
126// ================ 枚举翻译 ================
127
128/// 交易所代码 → [`Exchange`](原稿 EXCHANGE_CTP2VT 103-110 的**内联 match**
129/// 六键,不依赖 ek-data `Exchange::ALL` 变体顺序,避免跨 crate 耦合;miss → `None`)。
130pub fn exchange_of(exchange_id: &str) -> Option<Exchange> {
131    match exchange_id {
132        "CFFEX" => Some(Exchange::CFFEX),
133        "SHFE" => Some(Exchange::SHFE),
134        "CZCE" => Some(Exchange::CZCE),
135        "DCE" => Some(Exchange::DCE),
136        "INE" => Some(Exchange::INE),
137        "GFEX" => Some(Exchange::GFEX),
138        _ => None,
139    }
140}
141
142/// 产品类码 → [`Asset`](原稿 PRODUCT_CTP2VT;miss → `None`,调用方填
143/// [`Asset::UNKNOWN`](0.12.0 起不再丢行))。
144///
145/// 偏差记录(D-PC,DESIGN.md):原稿把 `PC_Combination('3')` 映到 `Asset.SPREAD`
146/// —— `Asset` 无 SPREAD 变体。**0.12.0 订正**:组合类映到 `FUTURES`,由
147/// [`instrument_type_from`] 给出 `InstrumentType::SPREAD` 作细分形态(正交表达,
148/// 与原稿"组合属于期货大类"的语义等价);`'7'` 税式期货同法映 `FUTURES` +
149/// `FUTURES_CONTRACT`。真正判不出资产的码(`'4'` 现货 / `'5'` EFP / `'I'` MI)
150/// 由调用方填 `Asset::UNKNOWN` —— **仍然不丢行**,只计数显形。
151pub fn asset_from(product_class: &str) -> Option<Asset> {
152    match product_class {
153        PC_FUTURES | PC_COMBINATION | PC_TAS => Some(Asset::FUTURES),
154        PC_OPTIONS | PC_SPOT_OPTION => Some(Asset::OPTION),
155        _ => None,
156    }
157}
158
159/// 产品类码 → [`InstrumentType`](组合/税式期货的细分形态;0.12.0 新增)。
160///
161/// `None` = 本码没有额外形态信息(期货/期权就是它们自己),不是"失败"。
162pub fn instrument_type_from(product_class: &str) -> Option<InstrumentType> {
163    match product_class {
164        PC_COMBINATION => Some(InstrumentType::SPREAD),
165        // TAS = 结算价交易,标的仍是期货合约本体。
166        PC_TAS => Some(InstrumentType::FUTURES_CONTRACT),
167        _ => None,
168    }
169}
170
171/// 委托状态码 → [`Status`](原稿 STATUS_CTP2VT 五键;miss → `None`,
172/// 原稿行为 = `write_log` 后丢弃该行)。
173pub fn status_of(code: &str) -> Option<Status> {
174    match code {
175        OST_NO_TRADE_Q => Some(Status::NOTTRADED),
176        OST_PART_TRADED_Q => Some(Status::PARTTRADED),
177        OST_ALL_TRADED => Some(Status::ALLTRADED),
178        OST_PART_TRADED_NQ => Some(Status::PARTTRADED),
179        OST_NO_TRADE_NQ => Some(Status::NOTTRADED),
180        OST_CANCELED => Some(Status::CANCELLED),
181        OST_UNKNOWN => Some(Status::SUBMITTING),
182        _ => None,
183    }
184}
185
186/// 方向码 → [`Direction`](D_* 与 PD_* 合并表,原稿 80-82)。
187pub fn direction_from(code: &str) -> Option<Direction> {
188    match code {
189        D_BUY | PD_LONG => Some(Direction::LONG),
190        D_SELL | PD_SHORT => Some(Direction::SHORT),
191        _ => None,
192    }
193}
194
195/// 开平码(OFEN_*)→ [`Offset`]。
196pub fn offset_from(code: &str) -> Option<Offset> {
197    match code {
198        OFEN_OPEN => Some(Offset::OPEN),
199        OFEN_CLOSE => Some(Offset::CLOSE),
200        OFEN_CLOSE_TODAY => Some(Offset::CLOSETODAY),
201        OFEN_CLOSE_YESTERDAY => Some(Offset::CLOSEYESTERDAY),
202        _ => None,
203    }
204}
205
206/// (价格类型, 有效期, 成交量类型)三元组 → [`OrderType`](原稿 ORDERTYPE_VT2CTP
207/// 85-90 反演;misse → `None`,原稿 write_log 后丢行)。
208pub fn order_type_from(price_type: &str, time_cond: &str, volume_cond: &str) -> Option<OrderType> {
209    match (price_type, time_cond, volume_cond) {
210        (OPT_LIMIT_PRICE, TC_GFD, VC_AV) => Some(OrderType::LIMIT),
211        (OPT_ANY_PRICE, TC_GFD, VC_AV) => Some(OrderType::MARKET),
212        (OPT_LIMIT_PRICE, TC_IOC, VC_AV) => Some(OrderType::FAK),
213        (OPT_LIMIT_PRICE, TC_IOC, VC_CV) => Some(OrderType::FOK),
214        _ => None,
215    }
216}
217
218/// 发单用的方向/开平/类型三元组回探(原稿 DIRECTION_VT2CTP / OFFSET_VT2CTP /
219/// ORDERTYPE_VT2CTP 76-90;`OrderType` 回撤三元组是正演)。
220pub fn direction_to(d: Direction) -> Option<&'static str> {
221    match d {
222        Direction::LONG => Some(D_BUY),
223        Direction::SHORT => Some(D_SELL),
224        _ => None,
225    }
226}
227
228/// [`Offset`] → 开平码(OFEN_*);`Offset::NONE` 不在原稿表内(misse → `None`)。
229pub fn offset_to(exchange: Exchange, o: Offset) -> Option<&'static str> {
230    // 开平语义按交易所折叠:CloseToday/CloseYesterday 只有上期所(SHFE)与能源中心(INE)
231    // 认;其余交易所只认 Close,直接发 '3'/'4' 会被 CTP 拒("开平标志错误")。
232    let o = match o {
233        Offset::CLOSETODAY | Offset::CLOSEYESTERDAY
234            if exchange != Exchange::SHFE && exchange != Exchange::INE =>
235        {
236            Offset::CLOSE
237        }
238        other => other,
239    };
240    match o {
241        Offset::OPEN => Some(OFEN_OPEN),
242        Offset::CLOSE => Some(OFEN_CLOSE),
243        Offset::CLOSETODAY => Some(OFEN_CLOSE_TODAY),
244        Offset::CLOSEYESTERDAY => Some(OFEN_CLOSE_YESTERDAY),
245        _ => None,
246    }
247}
248
249/// [`OrderType`] → (价格类型码, 有效期码, 量码) 发单三元组。
250pub fn order_type_to(t: OrderType) -> Option<(&'static str, &'static str, &'static str)> {
251    match t {
252        OrderType::LIMIT => Some((OPT_LIMIT_PRICE, TC_GFD, VC_AV)),
253        OrderType::MARKET => Some((OPT_ANY_PRICE, TC_GFD, VC_AV)),
254        OrderType::FAK => Some((OPT_LIMIT_PRICE, TC_IOC, VC_AV)),
255        OrderType::FOK => Some((OPT_LIMIT_PRICE, TC_IOC, VC_CV)),
256        _ => None,
257    }
258}
259
260// ================ 精度与时间 ================
261
262/// 异常浮点值修正(原稿 895-899 行):MAX_FLOAT → 0。
263pub fn adjust_price(price: f64) -> f64 {
264    if price == MAX_FLOAT { 0.0 } else { price }
265}
266
267/// Howard Hinnant civil→days 算法(自 1970-01-01 的天数;零依赖)。
268///
269/// 注:`doy` 里的 `(153*mx + 2)/5` 那个 **`+2` 不能写成 `-2`** —— 写成 `-2` 会让
270/// **部分日期整体差一天**(实测 2026-01-01、2026-04-15/16 差一天,而 2026-03-01、
271/// 2026-12-30 恰好正确,所以自比型单测照不出来)。2026-10-15 实盘联调靠"行情新鲜度"
272/// 与 ek-data `civil_to_nanos` 独立真值比对才抓到。
273fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
274    let y = if m <= 2 { y - 1 } else { y };
275    let era = if y >= 0 { y } else { y - 399 } / 400;
276    let yoe = y - era * 400;
277    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
278    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
279    era * 146097 + doe - 719468
280}
281
282/// CTP 时间戳 → [`DtNs`](UTC 纪元纳秒)。
283///
284/// date = "YYYYMMDD"、time = "HH:MM:SS";约定(D-TZ):CTP 原稿把该组合按
285/// `CHINA_TZ = Asia/Shanghai` 处理,本实现按【固定 +08:00】换算为 UTC 纪元纳秒。
286/// 畸形输入(长度/数字缺失)→ `None`,调用方全量丢行(原稿会 strptime 异常)。
287pub fn ctp_datetime(date: &str, time: &str, millis: i64) -> Option<DtNs> {
288    // 长度按**字节**判,所以必须先证明两边都是 ASCII:非 ASCII 输入(GBK 报文经 shim
289    // 转成 UTF-8 后的汉字)会让下面的 `&time[..2]` 落在多字节字符中间 —— 那是 str 切片
290    // panic,而本函数跑在 CTP 回调帧里(edition 2024 的 `extern "C"` 遇 panic 直接
291    // abort,全仓无 catch_unwind)。8 字节且全 ASCII 之后,下面每个下标都是字符边界。
292    if date.len() != 8 || time.len() != 8 || !date.is_ascii() || !time.is_ascii() {
293        return None;
294    }
295    let digits_ok = |s: &str| s.bytes().all(|b: u8| b.is_ascii_digit());
296    if !(digits_ok(date)
297        && digits_ok(&time[..2])
298        && digits_ok(&time[3..5])
299        && digits_ok(&time[6..8]))
300    {
301        return None;
302    }
303    let (y, m, d) = (
304        date[0..4].parse::<i64>().ok()?,
305        date[4..6].parse::<i64>().ok()?,
306        date[6..8].parse::<i64>().ok()?,
307    );
308    let (h, mi, s) = (
309        time[0..2].parse::<i64>().ok()?,
310        time[3..5].parse::<i64>().ok()?,
311        time[6..8].parse::<i64>().ok()?,
312    );
313    if !((1..=12).contains(&m) && h < 24 && mi < 60 && s < 60) {
314        return None;
315    }
316    // 溢出保护:年月日与毫秒都是经纪人可控字段;公元 2262 之后纳秒不再装得进 i64。
317    // 溢出 → `None` 丢行(D-STRUCT-SKIP),而不是 dbg 里 panic(回调帧里 = abort)、
318    // release 里静默回绕成错值。
319    // CTP 报文的日期/时间是【北京本地墙钟】(原稿按 CHINA_TZ 建 tz-aware datetime),
320    // 而 DtNs 是 **UTC 纪元纳秒**(ek-data `time.rs:3-6/29-30`)⇒ 换 UTC 必须**减** 8 小时。
321    // 2026-10-15 实盘联调暴露:此前写成 `+ 8*3_600`,所有 tick/委托/成交时间戳整体偏
322    // +16 小时(拿"行情新鲜度"跟 `now_nanos()` 一比立刻显形)。
323    let secs = days_from_civil(y, m, d)
324        .checked_mul(86_400)?
325        .checked_add(h * 3_600 + mi * 60 + s - 8 * 3_600)?;
326    secs.checked_mul(1_000_000_000)?
327        .checked_add(millis.checked_mul(1_000_000)?)
328}
329
330/// 纪元毫秒(0.9.0 状态口用:登录/回报/断开的「最近时刻」都以此为准)。
331///
332/// 口径:ek-data `time::now_nanos()`(墙上时钟,纪元纳秒)**除以 1e6** —— 不另起
333/// 一个时钟源,状态口的时刻与 tick/委托/成交的时间戳同源于同一把钟,消费侧
334/// (ek-ui)拿 `sampled_ms` 减别的 crate 的 `now_nanos()/1e6` 才是同一把尺。
335/// 不用单调钟:`ctp.status` 的回带值要被**跨插件、跨进程重启**的界面读,
336/// 单调钟的零点在别处没有意义;差值由消费侧做减法,不受时区影响。
337#[must_use]
338pub fn now_epoch_ms() -> i64 {
339    ek_data::time::now_nanos() / 1_000_000
340}
341
342/// 上海本地当日("YYYYMMDD";eTimer 秒级刷新口径,对齐原稿 md 的 update_date 406-408)。
343pub fn shanghai_now_date() -> String {
344    let secs = SystemTime::now()
345        .duration_since(UNIX_EPOCH)
346        .map(|d| d.as_secs() as i64)
347        .unwrap_or(0)
348        + 8 * 3_600;
349    let days = secs.div_euclid(86_400);
350    // 反算 civil(经 Hinnant days_from_civil 的逆,长年等价于儒略法;40 年内无损)
351    let z = days + 719_468;
352    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
353    let doe = z - era * 146_097;
354    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
355    let y = yoe + era * 400;
356    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
357    let mp = (5 * doy + 2) / 153;
358    let d = doy - (153 * mp + 2) / 5 + 1;
359    let m = if mp < 10 { mp + 3 } else { mp - 9 };
360    let y = if m <= 2 { y + 1 } else { y };
361    format!("{y:04}{m:02}{d:02}")
362}
363
364// ================ 对象映射(wire → ek-data) ================
365
366/// CTP 错误行 → 日志正文(原稿 `write_error` 216-222:`"{msg},代码:{id},信息:{m}"`)。
367pub fn error_text(msg: &str, e: &ErrorField) -> String {
368    format!("{},代码:{},信息:{}", msg, e.error_id, e.error_msg)
369}
370
371// ================ 映射 ================
372//
373// 0.12.0 删除了原先的 `Mapped<T>` 枚举(它曾是映射层的"可以丢"出口:
374// `Ok(T)` / `Skip(原因)`)。零丢弃改造后,五个 `*_from` 全部改成直接返回对象
375// 类型:**编译器不再允许**这个模块产出"整行消失"的结果。
376//
377// 删掉那个枚举不是清理,而是把纪律从"注释里的约定"升级成"类型系统的约束"
378// —— 想再丢一行,得先把返回类型加回来,那一步一定会被 review 看到。
379//
380// 历史:`Mapped::Skip` 曾在 21 处吞掉入向行(清单见
381// `docs/ek-ctp-no-drop-refactor-plan-2026-10-31.md`)。
382
383/// "YYYYMMDD" → `Option<i32>`(8 位 ASCII 数字严格验;缺省/畸形 ⇒ `None`)。
384///
385/// 附加轴专用:CTP 日期字符串恒为 ASCII(GBK↔UTF-8 转换不改变它)。
386/// 全零串 `"00000000"` 视为缺省(校历无效的整零值不该作为日历标签上岸)。
387fn yyyymmdd_i32(s: &str) -> Option<i32> {
388    if s.len() != 8 || !s.bytes().all(|b| b.is_ascii_digit()) {
389        return None;
390    }
391    match s.parse::<i32>() {
392        Ok(0) => None,
393        Ok(v) => Some(v),
394        Err(_) => None,
395    }
396}
397
398/// 行情行 → [`TickData`](原稿 301-366。
399///
400/// **0.12.0(边界零丢弃)本函数不再返回 `Mapped`** —— 行情行没有"丢"这条出口。
401/// - 时间戳不可得(空 / 畸形)→ `datetime_invalid = true`,`datetime` 留 `DtNs` 缺省;
402///   量价盘口照上账。原稿在 303-305 / 319-322 两处**整条丢**,而丢掉的其实是
403///   "一条真实成交的量价" —— 只是时间轴不可用。
404/// - 合约不在册(`D-XFALLBACK`)→ 交易所取报文 `ExchangeID`(再兜底 `UNKNOWN`),
405///   `name` 留空。旧实现整条丢 —— 而"合约查询还没跑完"与"这个合约真的没行情"
406///   在界面上长得一模一样,用户无从分辨。
407/// - 交易日口径:ActionDay 空或大商所 → 用上层传入的 current_date(313-317)。
408/// - 大商所特判原样:DCE 的 ActionDay 不能当自然日用 ⇒ 全部 current_date
409///   (`contract.exchange == Exchange::DCE` 或空 action_day)。
410/// - 时间戳带毫秒:`.{UpdateMillisec}`(319-320)。
411/// - `datetime` 之外的两条日期轴(记档偏差,原稿网关不产出;下游 2514-2521 才用启发式
412///   推交易日):`trading_day` = 报文 `TradingDay` 原值(业务日权威);`action_day` =
413///   参与拼 `datetime` 的那个日期串(规则化自然日)。两轴都保留报文缺省 → `None`。
414/// - 二至五档骨架:`BidVolume2 或 AskVolume2 非零`才添盘口(345-364)。
415/// - `last_volume` 原稿网关**不复算**(TickData 缺省 0),此处也照留。
416pub fn tick_from(
417    dm: &DepthMarketData,
418    contract: Option<&ContractData>,
419    gw: &str,
420    current_date: &str,
421) -> TickData {
422    let exchange = match contract {
423        Some(c) => c.exchange,
424        // 合约不在册:交易所取报文镜像(取不到才 `UNKNOWN`,不猜)
425        None => exchange_of(&dm.exchange_id).unwrap_or(Exchange::UNKNOWN),
426    };
427    let date_str = if dm.action_day.is_empty() || exchange == Exchange::DCE {
428        current_date.to_string()
429    } else {
430        dm.action_day.clone()
431    };
432    // 时间戳不可得 ≠ 这条行情不存在:留 `datetime_invalid = true` 显式标注。
433    let dt = ctp_datetime(&date_str, &dm.update_time, dm.update_millisec);
434    let trading_day = yyyymmdd_i32(&dm.trading_day);
435    let action_day = yyyymmdd_i32(&date_str);
436    let mut tick = TickData {
437        gateway_name: gw.to_string(),
438        symbol: dm.instrument_id.clone(),
439        exchange,
440        datetime: dt.unwrap_or_default(),
441        // 已知不可用才标 true;默认 false = 沿今天的行为(时间有效)。
442        datetime_invalid: dt.is_none(),
443        trading_day,
444        action_day,
445        update_time: dm.update_time.clone(),
446        update_millisec: dm.update_millisec,
447        // 合约不在册 ⇒ 名字与乘数富化不了,留空/零;量价字段照上账(红线①)
448        name: contract.map(|c| c.name.clone()).unwrap_or_default(),
449        volume: dm.volume,
450        turnover: dm.turnover,
451        open_interest: dm.open_interest,
452        last_price: adjust_price(dm.last_price),
453        limit_up: adjust_price(dm.upper_limit_price),
454        limit_down: adjust_price(dm.lower_limit_price),
455        open_price: adjust_price(dm.open_price),
456        high_price: adjust_price(dm.highest_price),
457        low_price: adjust_price(dm.lowest_price),
458        pre_close: adjust_price(dm.pre_close_price),
459        bid_price_1: adjust_price(dm.bid_price1),
460        ask_price_1: adjust_price(dm.ask_price1),
461        bid_volume_1: dm.bid_volume1,
462        ask_volume_1: dm.ask_volume1,
463        // ── 以下二十个字段快照不直接给,显式写默认值,不靠 `..Default` ─────
464        // 二到五档:浅档行情只有一档,默认 0;**有值时下面按包覆盖**。
465        bid_price_2: 0.0,
466        bid_price_3: 0.0,
467        bid_price_4: 0.0,
468        bid_price_5: 0.0,
469        ask_price_2: 0.0,
470        ask_price_3: 0.0,
471        ask_price_4: 0.0,
472        ask_price_5: 0.0,
473        bid_volume_2: 0.0,
474        bid_volume_3: 0.0,
475        bid_volume_4: 0.0,
476        bid_volume_5: 0.0,
477        ask_volume_2: 0.0,
478        ask_volume_3: 0.0,
479        ask_volume_4: 0.0,
480        ask_volume_5: 0.0,
481        // 现手:CTP 快照**没有**"本次增量"字段,只能靠前后帧成交量差分;
482        // 映射层无状态、算不了,留 0 交给上层。
483        last_volume: 0.0,
484        // 本地接收时刻:要本地钟,映射层不持有时钟。
485        localtime: None,
486        // 涨跌幅:需要昨结算价,快照与合约表都不直接给,留 0。
487        pct_change: 0.0,
488    };
489    if dm.bid_volume2 != 0.0 || dm.ask_volume2 != 0.0 {
490        tick.bid_price_2 = adjust_price(dm.bid_price2);
491        tick.bid_price_3 = adjust_price(dm.bid_price3);
492        tick.bid_price_4 = adjust_price(dm.bid_price4);
493        tick.bid_price_5 = adjust_price(dm.bid_price5);
494        tick.ask_price_2 = adjust_price(dm.ask_price2);
495        tick.ask_price_3 = adjust_price(dm.ask_price3);
496        tick.ask_price_4 = adjust_price(dm.ask_price4);
497        tick.ask_price_5 = adjust_price(dm.ask_price5);
498        tick.bid_volume_2 = dm.bid_volume2;
499        tick.bid_volume_3 = dm.bid_volume3;
500        tick.bid_volume_4 = dm.bid_volume4;
501        tick.bid_volume_5 = dm.bid_volume5;
502        tick.ask_volume_2 = dm.ask_volume2;
503        tick.ask_volume_3 = dm.ask_volume3;
504        tick.ask_volume_4 = dm.ask_volume4;
505        tick.ask_volume_5 = dm.ask_volume5;
506    }
507    tick
508}
509
510/// 合约行 → [`ContractData`](原稿 632-654)。
511///
512/// **0.12.0(边界零丢弃)**:不返回 `Mapped` —— 勾选已在调用方判过(唯一过滤器),
513/// 到了这里就必须上岸。两个"翻译不了"填表达:
514/// - 交易所码未映射 → [`Exchange::UNKNOWN`](原稿此处 `KeyError` 崩回调)
515/// - 产品类码未映射 → [`Asset::UNKNOWN`](原稿此处 `if product:` 静默丢行)
516pub fn contract_from(inf: &InstrumentField, gw: &str) -> ContractData {
517    ContractData {
518        gateway_name: gw.to_string(),
519        symbol: inf.instrument_id.clone(),
520        name: inf.instrument_name.clone(),
521        exchange: exchange_of(&inf.exchange_id).unwrap_or(Exchange::UNKNOWN),
522        asset: asset_from(&inf.product_class).unwrap_or(Asset::UNKNOWN),
523        // 0.12.0:组合 → SPREAD、TAS → FUTURES_CONTRACT(正交形态,不占 Asset 变体)。
524        instrument_type: instrument_type_from(&inf.product_class),
525        size: inf.volume_multiple,
526        price_tick: inf.price_tick,
527        max_limit_order_volume: Some(inf.max_limit_order_volume),
528        min_limit_order_volume: Some(inf.min_limit_order_volume),
529        max_market_order_volume: Some(inf.max_market_order_volume),
530        min_market_order_volume: Some(inf.min_market_order_volume),
531        delivery_year: Some(inf.delivery_year as i32),
532        delivery_month: Some(inf.delivery_month as i32),
533        product_id: Some(inf.product_id.clone()),
534        // D-CTR-FILL:原稿不填(缺省 None),这里显式填。
535        is_trading: Some(inf.is_trading == 1),
536        // 上市日(空 = 柜台未给 → None)。合约表没有涨跌停价,那两个字段
537        // 保持 None 是对的:真值只在行情 tick 的 limit_up/limit_down 里。
538        open_date: (!inf.open_date.is_empty()).then(|| inf.open_date.clone()),
539        // ── 以下四个字段 CTP 合约表**不提供**,显式写 None,不靠 `..Default` ──
540        // 涨跌停价:合约表**没有** UpperLimitPrice/LowerLimitPrice(已对 SDK 核实),
541        // 真值只在 DepthMarketData,由 `TickData.limit_up/limit_down` 承担。
542        // 这两个字段保持 None 是**正确的**,不是漏填。
543        up_stop_price: None,
544        down_stop_price: None,
545        // `pre_close`(昨收)只在 `CThostFtdcDepthMarketDataField` 里,
546        // 合约查询回报没有;要拿只能从行情 tick 或另查。
547        pre_close: None,
548        // 流通股本 / 总股本:这两个是 **A 股概念**,期货没有。
549        // 它们是从股票框架搬过来的字段,CTP 任何结构都不给,永远 None。
550        float_volume: None,
551        total_volume: None,
552        // 合约交易状态来自**另一个查询**(`CThostFtdcInstrumentStatusField`),
553        // 不在合约表字段里。合约表只有 `InstLifePhase`(生命周期),语义不同。
554        instrument_status: None,
555    }
556}
557
558/// 单委托行 → [`OrderData`](原稿 onRtnOrder 669-716;订单号三段下划线)。
559///
560/// 0.7.0(甲):`contract` 参数退役 —— `symbol` 取报文 InstrumentID,
561/// `exchange` 由调用方用报文 ExchangeID 经 [`exchange_of`] 预映射后传入。
562///
563/// **0.12.0(边界零丢弃,D-NO-DROP)**:本函数**不再返回 `Mapped`** —— 委托行
564/// 没有"丢"这条出口。翻译不了的值一律填表达,由调用方计数显形:
565/// - 状态码未映射 → [`Status::UNKNOWN`],不替柜台猜在途
566/// - 类型三元组未映射 → [`OrderType::UNKNOWN`],不借 `LIMIT` 顶替
567/// - 时间戳畸形 → `datetime: None`(原稿此处 `strptime` 抛异常,整条丢)
568/// - 方向码未映射 → `direction: None`(Python 模型本身允许 `None`,object.py:163)
569/// - 开平码未映射 → [`Offset::NONE`],原稿拒单面就这么填(`ctp_gateway.py:505`)
570pub fn order_from(of: &OrderField, exchange: Exchange, gw: &str) -> OrderData {
571    // 原稿 681:委托号先算(日志/载荷都带完整订单号)。
572    let orderid = format!("{}_{}_{}", of.front_id, of.session_id, of.order_ref);
573    OrderData {
574        gateway_name: gw.to_string(),
575        symbol: of.instrument_id.clone(),
576        exchange,
577        orderid,
578        order_type: order_type_from(
579            &of.order_price_type,
580            &of.time_condition,
581            &of.volume_condition,
582        )
583        .unwrap_or(OrderType::UNKNOWN),
584        direction: direction_from(&of.direction),
585        offset: offset_from(&of.comb_offset_flag).unwrap_or(Offset::NONE),
586        price: adjust_price(of.limit_price),
587        volume: of.volume_total_original,
588        traded: of.volume_traded,
589        status: status_of(&of.order_status).unwrap_or(Status::UNKNOWN),
590        // 原稿把 InsertDate+InsertTime 直接 strptime,畸形即抛;这里给 None,
591        // 行照上账(时间不可得 ≠ 委托不存在)。
592        datetime: ctp_datetime(&of.insert_date, &of.insert_time, 0),
593        // ── 以下四个字段柜台**不提供**,显式写出来,不留 `..Default` ────────
594        // 老写法用 `..OrderData::default()` 兜底,后果是 `ek-data` 以后新增
595        // 任何字段都静默变默认值、编译器一声不响(`traded_price` 就这么躺了
596        // 好几轮)。写全了以后,加字段会直接编译失败,逼人面对。
597        //
598        // `reference` 是下单时本地附言,柜台回报不带。
599        reference: String::new(),
600        // `name` 由 name_enrich 段按合约表补,映射层不知道合约中文名。
601        name: String::new(),
602        // CThostFtdcOrderField **没有** StatusMsg 列(与 InputOrder 不同),
603        // 所以废单原因拿不到 —— 柜台限制,不是漏映射。
604        status_msg: String::new(),
605        // 委托单没有成交价:价在 `TradeData.price` 里,一笔委托可能多笔成交、
606        // 多个价,"委托的成交均价"这个概念本身不成立。
607        traded_price: 0.0,
608    }
609}
610
611/// 单成交行 → [`TradeData`](原稿 onRtnTrade 720-747;仓位报单号由 sysid 表换算)。
612///
613/// 0.7.0(甲):`contract` 参数退役 —— 语义同 [`order_from`];原稿的成交
614/// 更新本来就取 `trade.exchange`(converter 语义),这里只是把同一条纪律
615/// 落到映射层。
616///
617/// **0.12.0**:同 [`order_from`],不返回 `Mapped` —— 成交更没有"丢"的出口
618/// (丢一条成交 = 均价/盈亏永久错)。时间戳畸形 → `None`,方向/开平未映射 →
619/// `None`/`NONE`。
620pub fn trade_from(tf: &TradeField, orderid: &str, exchange: Exchange, gw: &str) -> TradeData {
621    TradeData {
622        gateway_name: gw.to_string(),
623        symbol: tf.instrument_id.clone(),
624        exchange,
625        orderid: orderid.to_string(),
626        tradeid: tf.trade_id.clone(),
627        direction: direction_from(&tf.direction),
628        offset: offset_from(&tf.offset_flag).unwrap_or(Offset::NONE),
629        price: tf.price,
630        volume: tf.volume,
631        datetime: ctp_datetime(&tf.trade_date, &tf.trade_time, 0),
632        // ── 以下四个字段柜台不提供,显式留白,不靠 `..Default` ──────────────
633        // 手续费/滑点/平仓盈亏要额外查费率或本地算;成交额 = 价 × 量 × 合约乘数,
634        // 乘数在合约表、映射层拿不到。**一律写 0 而不是编造** —— 拿 0 出来
635        // 比拿一个假数安全,下游能看出"这项没有"。
636        commission: 0.0,
637        slippage: 0.0,
638        trade_pnl: 0.0,
639        // 合约中文名同样由 name_enrich 补,成交回报只带代码。
640        name: String::new(),
641        traded_amount: 0.0,
642    }
643}
644
645/// 资金行 → [`AccountData`](原稿 604-610)。
646///
647/// frozen = FrozenMargin + FrozenCash + FrozenCommission 三项求和;
648/// CTP `Available` 字段**按原稿语义属派生面**(balance − frozen 的差 =
649/// 占用保证金),ek-data 的 available() 为派生 —— 差额详见 DESIGN.md D-AVAIL。
650pub fn account_from(ta: &TradingAccountField, gw: &str) -> AccountData {
651    AccountData {
652        gateway_name: gw.to_string(),
653        accountid: ta.account_id.clone(),
654        balance: ta.balance,
655        frozen: ta.frozen_margin + ta.frozen_cash + ta.frozen_commission,
656    }
657}
658
659/// 累积一次仓位行(原稿 546-586;合并语义:键 = (InstrumentID, PosiDirection),
660/// SHFE/INE 昨仓特判,对向仓量记冻结)。
661pub fn accumulate_position(
662    row: &InvestorPositionField,
663    pos: &mut ek_data::PositionData,
664    exchange: Exchange,
665    contract_size: f64,
666) {
667    // 上期系昨仓特判(原稿 559-565 逐字,**零补全**):
668    //   if exchange in {SHFE, INE}:
669    //       if YdPosition and not TodayPosition: yd_volume = Position
670    //   else:                                # 连回上 if(非上期系)
671    //       yd_volume = Position - TodayPosition
672    // 注意两处均为**赋值**(562/565),非累加——SHFE 今/昨两行同键时,昨行
673    // 命中特判最后一次赋值,结果与原稿一致;不满足特判则**什么都不写**。
674    if exchange == Exchange::SHFE || exchange == Exchange::INE {
675        if row.yd_position != 0.0 && row.today_position == 0.0 {
676            pos.yd_volume = row.position;
677        }
678    } else {
679        pos.yd_volume = row.position - row.today_position;
680    }
681    // 累积量与盈亏(571-580):volume += Position;pnl += PositionProfit;
682    // 均价 = (上成本 + 本行 PositionCost) / (量×乘数),量与乘数皆非零才写。
683    let cost = pos.price * pos.volume * contract_size;
684    pos.volume += row.position;
685    pos.pnl += row.position_profit;
686    if pos.volume != 0.0 && contract_size != 0.0 {
687        pos.price = (cost + row.position_cost) / (pos.volume * contract_size);
688    }
689    // 冻结量:多头仓累计空头冻结,空头仓累计多头冻结(583-586)。
690    if pos.direction == Direction::LONG {
691        pos.frozen += row.short_frozen;
692    } else {
693        pos.frozen += row.long_frozen;
694    }
695}
696
697/// 单仓位行建仓初值(原稿 551-556;零值骨架由驱动层累加)。
698pub fn new_position_data(
699    symbol: &str,
700    exchange: Exchange,
701    direction: Option<Direction>,
702    gw: &str,
703) -> Option<ek_data::PositionData> {
704    Some(ek_data::PositionData {
705        gateway_name: gw.to_string(),
706        symbol: symbol.to_string(),
707        exchange,
708        direction: direction?,
709        ..ek_data::PositionData::default()
710    })
711}
712
713// ================ 下单/撤单请求构建(P1 面) ================
714
715/// 撤单请求的委托号解析(原稿 `cancel_order` 858 行 `req.orderid.split("_")`)。
716///
717/// 三段 `front_session_ref`(原稿 `f"{frontid}_{sessionid}_{order_ref}"`);
718/// 非三段或 front/session 非整数 → `None`(原稿 int() 会抛 ValueError,
719/// 本实现转结构化丢弃并记日志,DESIGN.md D6)。
720pub fn cancel_action_from(
721    orderid: &str,
722    symbol: &str,
723    exchange: Exchange,
724    broker: &str,
725    user: &str,
726) -> Option<InputOrderAction> {
727    let mut parts = orderid.split('_');
728    let (front, session, ore) = (parts.next()?, parts.next()?, parts.next()?);
729    if parts.next().is_some() {
730        return None;
731    }
732    let (front_id, session_id): (i64, i64) = (front.parse().ok()?, session.parse().ok()?);
733    Some(InputOrderAction {
734        instrument_id: symbol.to_string(),
735        exchange_id: exchange.as_str().to_string(),
736        order_ref: ore.to_string(),
737        front_id,
738        session_id,
739        // AF_Delete = '0'(常量 258;原稿 THOST_FTDC_AF_Delete)
740        action_flag: "0".to_string(),
741        broker_id: broker.to_string(),
742        investor_id: user.to_string(),
743    })
744}
745
746// ================ 边界契约:内部格式 → CTP 格式(2026-10-30 收口) ================
747
748/// 内部符号 → CTP 裸合约代码。**出向一切字符串形参的唯一归一化点**。
749///
750/// 内部有两种写法:裸代码(`ag2612`)与显示全名(`SHFE.ag2612`,`ContractData::en_symbol()` 所出)。
751/// 订阅 / 发单 / 撤单 / 费率查询四个入口都必须先过这里 —— CTP 只认裸代码,
752/// 且交易所是**独立字段**,喂全名会拼成 `EX.EX.代码`(发单直接整体拒)。
753///
754/// 规则(不猜):裸代码原样;点号前是**已知交易所码**则取点号后;其余一律 `None`。
755pub fn instrument_id(raw: &str) -> Option<String> {
756    let s = raw.trim();
757    if s.is_empty() {
758        return None;
759    }
760    match s.split_once('.') {
761        None => Some(s.to_string()),
762        Some((head, rest)) => {
763            let rest = rest.trim();
764            if head.is_empty() || rest.is_empty() {
765                return None;
766            }
767            // 前缀大小写不敏感:CTP 侧只认大写码,内部/用户输入常写小写(`shfe.ag2612`)。
768            exchange_of(&head.trim().to_ascii_uppercase()).map(|_| rest.to_string())
769        }
770    }
771}
772
773/// 持仓方向码 → [`Direction`],净持仓按数量正负判。
774///
775/// CTP 的 `PosiDirection` 在净持仓交易所(CFFEX)报 `'1'`,与卖 `'1'` 同码;
776/// 老实现一律解析成 SHORT,多头净仓会被翻成空头(方向与盈亏符号同时错)。
777/// 这里:净码按 `Position` 正负还原(>0 多 / <0 空 / =0 无净仓 → `None`);
778/// 多空码照旧。
779pub fn position_direction(code: &str, position: f64) -> Option<Direction> {
780    match code {
781        PD_NET => match position.partial_cmp(&0.0)? {
782            std::cmp::Ordering::Greater => Some(Direction::LONG),
783            std::cmp::Ordering::Less => Some(Direction::SHORT),
784            std::cmp::Ordering::Equal => None,
785        },
786        PD_LONG => Some(Direction::LONG),
787        PD_SHORT => Some(Direction::SHORT),
788        _ => None,
789    }
790}
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    /// 上市日全链:CTP `InstrumentField.OpenDate` → `wire.open_date` → `ContractData.open_date`。
796    ///
797    /// 这条存在的原因:2026-11-01 审计发现该字段声明了但**从来没有值**,
798    /// 补上 wire 列后必须有一条测试钉住,否则下次重构又会悄悄丢掉。
799    #[test]
800    fn contract_carries_open_date_from_wire() {
801        let inf = InstrumentField {
802            instrument_id: "rb2611".into(),
803            exchange_id: "SHFE".into(),
804            instrument_name: "螺纹钢2611".into(),
805            product_class: "1".into(),
806            volume_multiple: 10.0,
807            price_tick: 1.0,
808            max_limit_order_volume: 500.0,
809            min_limit_order_volume: 1.0,
810            max_market_order_volume: 500.0,
811            min_market_order_volume: 1.0,
812            delivery_year: 2026,
813            delivery_month: 11,
814            product_id: "rb".into(),
815            is_trading: 1,
816            open_date: "20260115".into(),
817        };
818        let c = contract_from(&inf, "ctp");
819        assert_eq!(c.open_date.as_deref(), Some("20260115"), "上市日必须透传");
820    }
821
822    /// 柜台没给上市日(空串)→ `None`,不是 `Some("")`。
823    /// 空串当值会让下游"有没有上市日"的判断失效。
824    #[test]
825    fn contract_open_date_empty_maps_to_none() {
826        let inf = InstrumentField {
827            instrument_id: "x".into(),
828            exchange_id: "SHFE".into(),
829            instrument_name: "x".into(),
830            product_class: "1".into(),
831            volume_multiple: 10.0,
832            price_tick: 1.0,
833            max_limit_order_volume: 1.0,
834            min_limit_order_volume: 1.0,
835            max_market_order_volume: 1.0,
836            min_market_order_volume: 1.0,
837            delivery_year: 2026,
838            delivery_month: 1,
839            product_id: "x".into(),
840            is_trading: 1,
841            open_date: String::new(),
842        };
843        let c = contract_from(&inf, "ctp");
844        assert_eq!(c.open_date, None);
845    }
846
847    #[test]
848    fn yyyymmdd_i32_accepts_real_dates_and_rejects_garbage() {
849        assert_eq!(yyyymmdd_i32("20261015"), Some(20261015));
850        // 全零串=缺省(校历无效的整零值不上岸)。
851        assert_eq!(yyyymmdd_i32("00000000"), None);
852        // 形状畸形:长度错/非 ASCII 数字/空串。
853        assert_eq!(yyyymmdd_i32("2026101"), None);
854        assert_eq!(yyyymmdd_i32("202610150"), None);
855        assert_eq!(yyyymmdd_i32("2026101a"), None);
856        assert_eq!(yyyymmdd_i32(""), None);
857    }
858
859    // ---- 边界契约(2026-10-30):出向归一化 / 入向补全的判据 ----
860
861    #[test]
862    fn instrument_id_accepts_bare_and_en_symbol_and_rejects_garbage() {
863        // 裸代码:原样
864        assert_eq!(instrument_id("ag2612").as_deref(), Some("ag2612"));
865        assert_eq!(instrument_id("  cu2612 ").as_deref(), Some("cu2612"));
866        // 全名(en_symbol 的形态):剥交易所前缀
867        assert_eq!(instrument_id("SHFE.ag2612").as_deref(), Some("ag2612"));
868        assert_eq!(instrument_id("INE.sc2612").as_deref(), Some("sc2612"));
869        assert_eq!(instrument_id("CFFEX.IF2412").as_deref(), Some("IF2412"));
870        // 不猜:空串 / 只有前缀 / 未知交易所前缀 → None
871        assert_eq!(instrument_id(""), None);
872        assert_eq!(instrument_id("   "), None);
873        assert_eq!(instrument_id("SHFE."), None);
874        assert_eq!(instrument_id(".ag2612"), None);
875        assert_eq!(instrument_id("XX.ag2612"), None);
876        // 小写前缀:CTP 只认大写码,归一时抬成大写再判
877        assert_eq!(instrument_id("shfe.ag2612").as_deref(), Some("ag2612"));
878        assert_eq!(instrument_id("Shfe. ag2612 ").as_deref(), Some("ag2612"));
879    }
880
881    #[test]
882    fn offset_to_folds_close_today_by_exchange() {
883        // 上期所 / 能源中心:保留平今/平昨
884        for ex in [Exchange::SHFE, Exchange::INE] {
885            assert_eq!(offset_to(ex, Offset::CLOSETODAY), Some(OFEN_CLOSE_TODAY));
886            assert_eq!(
887                offset_to(ex, Offset::CLOSEYESTERDAY),
888                Some(OFEN_CLOSE_YESTERDAY)
889            );
890            assert_eq!(offset_to(ex, Offset::OPEN), Some(OFEN_OPEN));
891            assert_eq!(offset_to(ex, Offset::CLOSE), Some(OFEN_CLOSE));
892        }
893        // 其余四所:只认 Close(直接发 '3'/'4' 会被 CTP 拒)
894        for ex in [
895            Exchange::CFFEX,
896            Exchange::CZCE,
897            Exchange::DCE,
898            Exchange::GFEX,
899        ] {
900            assert_eq!(
901                offset_to(ex, Offset::CLOSETODAY),
902                Some(OFEN_CLOSE),
903                "{ex:?}"
904            );
905            assert_eq!(
906                offset_to(ex, Offset::CLOSEYESTERDAY),
907                Some(OFEN_CLOSE),
908                "{ex:?}"
909            );
910            assert_eq!(offset_to(ex, Offset::OPEN), Some(OFEN_OPEN));
911            assert_eq!(offset_to(ex, Offset::CLOSE), Some(OFEN_CLOSE));
912        }
913    }
914
915    #[test]
916    fn status_of_covers_part_and_no_trade_not_queueing() {
917        assert_eq!(status_of("2"), Some(Status::PARTTRADED));
918        assert_eq!(status_of("4"), Some(Status::NOTTRADED));
919        // 老五码行为不变
920        assert_eq!(status_of("0"), Some(Status::ALLTRADED));
921        assert_eq!(status_of("1"), Some(Status::PARTTRADED));
922        assert_eq!(status_of("3"), Some(Status::NOTTRADED));
923        assert_eq!(status_of("5"), Some(Status::CANCELLED));
924        assert_eq!(status_of("a"), Some(Status::SUBMITTING));
925        assert_eq!(status_of("z"), None);
926    }
927
928    #[test]
929    fn position_direction_uses_sign_for_net_positions() {
930        // CFFEX 净持仓:'1' 靠数量正负还原方向
931        assert_eq!(position_direction("1", 3.0), Some(Direction::LONG));
932        assert_eq!(position_direction("1", -2.0), Some(Direction::SHORT));
933        assert_eq!(position_direction("1", 0.0), None);
934        // 多空码照旧
935        assert_eq!(position_direction("2", 5.0), Some(Direction::LONG));
936        assert_eq!(position_direction("3", 5.0), Some(Direction::SHORT));
937        assert_eq!(position_direction("9", 1.0), None);
938    }
939
940    #[test]
941    fn order_from_normalizes_market_order_sentinel_price() {
942        let of = OrderField {
943            instrument_id: "ag2612".into(),
944            exchange_id: "SHFE".into(),
945            order_ref: "1".into(),
946            front_id: 1,
947            session_id: 2,
948            order_status: "3".into(),
949            order_price_type: "1".into(),
950            time_condition: "3".into(),
951            volume_condition: "1".into(),
952            insert_date: "20261030".into(),
953            insert_time: "00:15:00".into(),
954            direction: "0".into(),
955            comb_offset_flag: "0".into(),
956            limit_price: MAX_FLOAT,
957            volume_total_original: 1.0,
958            volume_traded: 0.0,
959            ..OrderField::default()
960        };
961        // 0.12.0:`order_from` 不再返回 `Mapped` —— 委托行没有"丢"的出口。
962        let order = order_from(&of, Exchange::SHFE, "ctp");
963        assert_eq!(order.price, 0.0);
964        assert_eq!(order.symbol, "ag2612");
965        assert_eq!(order.status, Status::NOTTRADED);
966    }
967}