futu_cache/trd_cache/types.rs
1// 交易缓存数据模型。
2
3use futu_core::account_locator::AccountCardRecord;
4
5/// 账户 key: acc_id
6pub type AccKey = u64;
7
8/// 缓存的账户信息
9#[derive(Debug, Clone, Default)]
10pub struct CachedTrdAcc {
11 /// 账户 ID
12 pub acc_id: u64,
13 /// 后端/mobile native intra account id (C++ `Ndt_Trd_AccItem.nIntraAccID`).
14 ///
15 /// 公开 FTAPI `acc_id` 与 backend 查询 body 里的 `account_id` 不是同一层
16 /// 语义。需要发 backend native account_id 的 handler 应优先用这个字段,
17 /// 不要从公开 `acc_id` 低 32 位反推。
18 pub intra_acc_id: Option<u64>,
19 /// 交易环境(0=Simulate / 1=Real)
20 pub trd_env: i32,
21 /// 该账户有权限访问的交易市场列表
22 pub trd_market_auth_list: Vec<i32>,
23 /// 账户类型(Cash / Margin / Derivative / ...)
24 pub acc_type: Option<i32>,
25 /// 账户卡号(后段数字,仅用于显示识别)
26 pub card_num: Option<String>,
27 /// 账户所属 broker(FutuHK=1 / FutuUS=2 / ...)
28 pub security_firm: Option<i32>,
29 /// 模拟账户子类型
30 pub sim_acc_type: Option<i32>,
31 /// 统一卡号(跨市场账户聚合标识)
32 pub uni_card_num: Option<String>,
33 /// 账户状态码(正常 / 冻结 / ...)
34 pub acc_status: Option<i32>,
35 /// Backend raw `FTUsrTrdAcc::Account.state`.
36 ///
37 /// C++ API layer keeps this distinct from public `TrdAccStatus`:
38 /// `OPENED(1)` is returned as Active, `CLOSED(2)` is returned in the
39 /// disabled-real tail, while `OPENING(0)` is skipped by
40 /// `APIServer_Trd_GetAccList.cpp:109-115`. Do not derive this back from
41 /// `acc_status`, because both CLOSED and OPENING are non-active.
42 pub acc_open_state: Option<i32>,
43 /// 账户角色(主账户 / 子账户 / 顾问)
44 pub acc_role: Option<i32>,
45 /// Daemon-derived user-visible account label.
46 ///
47 /// Some opened business accounts are not representable by
48 /// `Trd_Common.TrdMarket` (for example crypto) or overload protocol role
49 /// values (for example equity-incentive / IPO route). The bridge derives a
50 /// label from backend account metadata and stores it here so public account
51 /// discovery does not rely on numeric market allowlists.
52 pub acc_label: Option<String>,
53 /// 日本账户附加类型标签
54 pub jp_acc_type: Vec<i32>,
55 // --- 以下为审计补全的字段 ---
56 /// 账户所有者 UID
57 pub owner_uid: Option<u64>,
58 /// 账户操作者 UID
59 pub opr_uid: Option<u64>,
60 /// 混合状态 (C++ enAccState / MixedState)
61 pub mixed_state: Option<i32>,
62 /// IRA 类型 (CA: TFSA=1, RRSP=2, SRRSP=3)
63 pub ira_type: Option<i32>,
64 /// 授权状态 (GrantState)
65 pub grant_state: Option<i32>,
66 /// 口座类型 (JP: Cash=1, Margin=2, Derivative=3)
67 pub kouza_type: Option<i32>,
68 /// 交易市场 (Account.market, 单个值)
69 pub trd_market: Option<i32>,
70 /// 关联账户 ID (基金账户绑定)
71 pub association_acc_id: Option<u64>,
72 /// 综合账户子账户标志 (0=非子账户)
73 pub acc_flag: Option<i32>,
74 /// 原始顺序索引 (用于保持后端返回的自然顺序)
75 pub order_index: usize,
76 /// C++ 排序 key: (BrokerID << 48) | (TrdMkt << 32) | IntraAccID
77 pub sort_key: u64,
78}
79
80impl AccountCardRecord for CachedTrdAcc {
81 fn acc_id(&self) -> u64 {
82 self.acc_id
83 }
84
85 fn card_num(&self) -> Option<&str> {
86 self.card_num.as_deref()
87 }
88
89 fn uni_card_num(&self) -> Option<&str> {
90 self.uni_card_num.as_deref()
91 }
92}
93
94impl CachedTrdAcc {
95 /// v1.4.108: identify crypto from bridge-derived backend metadata label.
96 ///
97 /// Account discovery must not hide opened crypto accounts, but `Trd_Common`
98 /// has no public `TrdMarket_Crypto` variant. The bridge therefore derives
99 /// `acc_label=crypto` from `FTUsrTrdAcc.AccountMarket::Crypto` /
100 /// `TradingCapability::NaCrypto`; cache consumers should read that label
101 /// rather than re-hardcoding market numbers.
102 pub fn is_crypto_account(&self) -> bool {
103 self.acc_label.as_deref() == Some("crypto")
104 }
105
106 pub fn is_encrypted(&self) -> bool {
107 self.is_crypto_account()
108 }
109
110 /// v1.4.97 J-Acc-Q3 + v1.4.98 T2-6: derived `acc_label` for
111 /// `/api/accounts` REST response.
112 ///
113 /// **Priority order**:
114 /// 1. bridge-derived backend label (`crypto`, `equity_incentive`,
115 /// `ipo_route`, ...);
116 /// 2. `"paper_trade"` — `trd_env==0 (Simulate)` (v1.4.98).
117 ///
118 /// Returns `None` for "no special label" (default Margin / regular Cash).
119 ///
120 /// **Spec**: REST-only enrichment (no proto change for gRPC clients —
121 /// gRPC 不看 proto extension field, 只看 /api/accounts REST output).
122 /// Clients should `treat unknown labels as opaque strings` for forward
123 /// compatibility; new labels may appear when backend account categories are
124 /// surfaced through the bridge.
125 ///
126 /// Labels are opaque strings for clients. Unknown labels should be rendered
127 /// as-is rather than treated as an error.
128 pub fn derive_acc_label(&self) -> Option<&str> {
129 if let Some(label) = self.acc_label.as_deref() {
130 return Some(label);
131 }
132 if self.trd_env == 0 {
133 return Some("paper_trade");
134 }
135 None
136 }
137}
138
139/// 缓存的资金 (对齐 C++ Ndt_Trd_AccFund 全字段)
140#[derive(Debug, Clone, Default)]
141pub struct CachedFunds {
142 pub power: f64, // 最大做多购买力
143 pub total_assets: f64, // 资产净值
144 pub cash: f64, // 现金
145 pub market_val: f64, // 证券市值
146 pub frozen_cash: f64, // 冻结资金
147 pub debt_cash: f64, // 欠款金额
148 pub avl_withdrawal_cash: f64, // 可提金额
149 pub currency: Option<i32>, // 货币类型
150 pub available_funds: Option<f64>, // 可用资金 (期货)
151 pub unrealized_pl: Option<f64>, // 未实现盈亏 (期货)
152 pub realized_pl: Option<f64>, // 已实现盈亏 (期货)
153 pub risk_level: Option<i32>, // 风险等级
154 pub initial_margin: Option<f64>, // 初始保证金
155 pub maintenance_margin: Option<f64>, // 维持保证金
156 pub max_power_short: Option<f64>, // 最大做空购买力
157 pub net_cash_power: Option<f64>, // 现金购买力
158 pub long_mv: Option<f64>, // 多头市值
159 pub short_mv: Option<f64>, // 空头市值
160 pub pending_asset: Option<f64>, // 在途资产
161 pub max_withdrawal: Option<f64>, // 最大可提
162 pub risk_status: Option<i32>, // 风险状态码
163 pub margin_call_margin: Option<f64>, // margin call 保证金
164 pub securities_assets: Option<f64>, // 证券资产
165 pub fund_assets: Option<f64>, // 基金资产
166 pub bond_assets: Option<f64>, // 债券资产
167 pub crypto_mv: Option<f64>, // 数字货币市值
168 pub exposure_level: Option<i32>, // 数字货币风险等级
169 pub exposure_limit: Option<f64>, // 数字货币持仓限额
170 pub used_limit: Option<f64>, // 数字货币已用限额
171 pub remaining_limit: Option<f64>, // 数字货币剩余额度
172
173 // v1.4.98 T1-4 (mobile-source-audit Phase 2): US PDT (Pattern Day
174 // Trader) 6 字段. proto/Trd_Common.proto:377-382 字段 24-29.
175 // 仅富途证券(美国)账户适用. mobile App 账户首页"日内交易"卡片直接显示.
176 // futu-trd::Funds 已读 5 字段 (缺 beginning_dtbp), CachedFunds 之前
177 // 6 字段全漏 → cache-only path silent drop.
178 /// 是否 PDT 账户 (Pattern Day Trader, 仅 US)
179 pub is_pdt: Option<bool>,
180 /// 剩余日内交易次数 (string 表示, mobile UI 直接显示)
181 pub pdt_seq: Option<String>,
182 /// 初始日内交易购买力 (DTBP)
183 pub beginning_dtbp: Option<f64>,
184 /// 剩余日内交易购买力 (DTBP)
185 pub remaining_dtbp: Option<f64>,
186 /// 日内交易待缴金额 (DT Call)
187 pub dt_call_amount: Option<f64>,
188 /// 日内交易限制状态 (DTStatus enum)
189 pub dt_status: Option<i32>,
190
191 /// 分币种现金信息: (currency, cash, avl_withdrawal, net_cash_power)
192 pub cash_info_list: Vec<CachedCashInfo>,
193 /// 分市场资产信息: (trd_market, assets)
194 pub market_info_list: Vec<CachedMarketInfo>,
195}
196
197/// 分币种现金信息
198#[derive(Debug, Clone, Default)]
199pub struct CachedCashInfo {
200 /// 币种(对齐 proto `TrdCommon.Currency`)
201 pub currency: i32,
202 /// 该币种现金
203 pub cash: f64,
204 /// 该币种可用余额
205 pub available_balance: f64,
206 /// 该币种净购买力(无杠杆)
207 pub net_cash_power: f64,
208}
209
210/// 分市场资产信息
211#[derive(Debug, Clone, Default)]
212pub struct CachedMarketInfo {
213 /// 所属交易市场(对齐 proto `TrdCommon.TrdMarket`)
214 pub trd_market: i32,
215 /// 该市场资产总值
216 pub assets: f64,
217}
218
219/// 缓存的持仓 (对齐 C++ Ndt_Trd_AccPosition 全字段)
220#[derive(Debug, Clone, Default)]
221pub struct CachedPosition {
222 pub position_id: u64,
223 /// Backend business position id used by JP combo close/order paths.
224 ///
225 /// C++ `NNProto_Trd_AccReal.cpp:262-269` stores
226 /// `asset_query.AccPstnInfo.business_position_id` as
227 /// `Ndt_Trd_AccPosition.sBusinessPositionID` and, for FutuJP, exposes
228 /// `Position.positionID = HashStrToU64(sBusinessPositionID)`. Combo
229 /// trade-write paths must reverse that mapping before sending backend
230 /// CMD2297/CMD4701.
231 pub business_position_id: Option<String>,
232 /// C++ `Ndt_Trd_AccPosition.nPositionAccID`; used by JP combo legs as
233 /// backend `pos_account_id`.
234 pub position_acc_id: Option<u64>,
235 /// C++ `Ndt_Trd_AccPosition.nSubAccountID`; used by JP combo legs as
236 /// backend `pos_sub_account_id`.
237 pub sub_account_id: Option<u64>,
238 pub position_side: i32, // 0=多仓, 1=空仓
239 pub code: String,
240 pub name: String,
241 pub qty: f64,
242 pub can_sell_qty: f64,
243 pub price: f64, // 当前价
244 pub cost_price: f64, // 摊薄成本价
245 pub val: f64, // 市值
246 pub pl_val: f64, // 盈亏金额
247 pub pl_ratio: Option<f64>, // 盈亏比例
248 pub sec_market: Option<i32>, // 证券市场
249 pub td_pl_val: Option<f64>, // 今日盈亏
250 pub td_trd_val: Option<f64>, // 今日成交额
251 pub td_buy_val: Option<f64>, // 今日买入金额
252 pub td_buy_qty: Option<f64>, // 今日买入数量
253 pub td_sell_val: Option<f64>, // 今日卖出金额
254 pub td_sell_qty: Option<f64>, // 今日卖出数量
255 pub unrealized_pl: Option<f64>, // 未实现盈亏 (期货)
256 pub realized_pl: Option<f64>, // 已实现盈亏 (期货)
257 pub currency: Option<i32>, // 货币
258 pub trd_market: Option<i32>, // 交易市场
259 pub diluted_cost_price: Option<f64>, // 摊薄成本
260 pub average_cost_price: Option<f64>, // 平均成本
261 pub average_pl_ratio: Option<f64>, // 平均盈亏比例
262 /// C++ 10.7 `Ndt_Trd_AccPosition.nComboIDHash`, projected as
263 /// `Trd_Common.Position.comboID` only for combo summary/leg rows.
264 pub combo_id: Option<u64>,
265 /// Backend asset-system combo id string (`Ndt_Trd_AccPosition.sComboIDSvr`).
266 ///
267 /// Public `comboID` is a hash, but C++ JP combo close/order paths write the
268 /// original string back to backend `OrderNewReq.combo_id`; keep both
269 /// representations so public projection and backend write paths do not
270 /// fight each other.
271 pub business_combo_id: Option<String>,
272 /// C++ 10.7 option strategy type after backend `combo_identify` ->
273 /// NN -> public `Qot_Common.OptionStrategyType` mapping.
274 pub strategy_type: Option<i32>,
275 /// C++ 10.7 `NN_PositionType`, projected as public
276 /// `Trd_Common.PositionType` (`Combined=1`, `Leg=2`).
277 pub position_type: Option<i32>,
278 /// C++ 10.7 position account id. JP sub-account rows may use a different
279 /// long account id; otherwise the handler falls back to request acc_id.
280 pub acc_id: Option<u64>,
281 /// C++ 10.7 JP sub-account type.
282 pub jp_acc_type: Option<i32>,
283 /// v1.4.42 (external reviewer v1.4.40 roadmap #2 + P2.3): 期权持仓到期日距今天数。
284 /// backend 不返,daemon 按 code 推导(option code 才有值)。
285 pub expiry_date_distance: Option<i32>,
286}
287
288/// 缓存的订单 (对齐 C++ Ndt_Trd_Order 全字段)
289#[derive(Debug, Clone, Default)]
290pub struct CachedOrder {
291 pub order_id: u64,
292 pub order_id_ex: String, // 服务端订单 ID 字符串
293 pub code: String,
294 pub name: String,
295 pub trd_side: i32,
296 pub order_type: i32,
297 pub order_status: i32,
298 pub qty: f64,
299 pub price: f64,
300 pub fill_qty: f64,
301 pub fill_avg_price: f64,
302 pub create_time: String,
303 pub update_time: String,
304 pub last_err_msg: Option<String>, // 最后错误信息
305 pub sec_market: Option<i32>, // 证券市场
306 pub create_timestamp: Option<f64>, // 创建时间戳
307 pub update_timestamp: Option<f64>, // 更新时间戳
308 pub remark: Option<String>, // 备注
309 pub time_in_force: Option<i32>, // 有效期类型
310 pub fill_outside_rth: Option<bool>, // 是否允许盘前盘后成交
311 pub aux_price: Option<f64>, // 触发价格
312 pub trail_type: Option<i32>, // 跟踪类型
313 pub trail_value: Option<f64>, // 跟踪值
314 pub trail_spread: Option<f64>, // 跟踪价差
315 pub currency: Option<i32>, // 货币
316 pub trd_market: Option<i32>, // 交易市场
317
318 /// v1.4.106 codex 0219 Finding 4 / 0226 F7: backend snapshot 字段集合.
319 ///
320 /// PlaceOrder ack 后 backend 返 `OrderNewRsp.order_id` (= `szOrderID`,
321 /// 服务端真实订单 id, alphanumeric 字符串). FTAPI `Trd_PlaceOrder.S2C.order_id`
322 /// 是这个 string 的 hash (`HashStrToU64` 结果, 见 `trade_query::hash_str_to_u64`).
323 ///
324 /// **必填语义**: ModifyOrder / CancelOrder backend req 的 `order_id` 字段
325 /// **必须**填 backend `szOrderID`, 不是 hash. 反模式 (v1.4.105 及以前):
326 /// 没有 orderIDEx 时直接 `order_id_ex.parse().unwrap_or(0)` 把 hash 当
327 /// backend id 发 — backend 拒错或匹配失败.
328 ///
329 /// 修法 (v1.4.106 Finding 1+4): cache 存 `backend_order_id` (= szOrderID)
330 /// 字段; trade-write handler 通过 `find_order_for_trade_write` lookup 拿到
331 /// `ResolvedOrderContext { backend_order_id, version, exchange, exchange_code,
332 /// security_type, ... }` 后再发 backend req.
333 pub backend_order_id: String,
334
335 /// v1.4.106 codex 0219 Finding 4: backend `Order.version` (proto field 21).
336 ///
337 /// ModifyOrder backend `OrderReplaceReq.order_version` 必填 — 让 backend
338 /// 能拒接收已经被其他客户端改过版本的旧请求. C++ `FillModifyOrderReq:736`:
339 /// `req.set_order_version((u32_t)order.nVersion)`.
340 pub order_version: i32,
341
342 /// v1.4.106 codex 0219 Finding 4: backend `Order.exchange_code` (proto field 37).
343 ///
344 /// 期货所属交易所代码 (e.g. `1` = HKEX, `2` = NYSE 之类, 取值参考
345 /// `NN_QotMarket`). ModifyOrder / CancelOrder backend 必填 (期货必填,
346 /// 股票为 0). C++ `FillModifyOrderReq:773`:
347 /// `req.set_exchange_code((u32_t)order.enMktID)`.
348 pub exchange_code: i32,
349
350 /// v1.4.106 codex 0219 Finding 4: backend `Order.exchange` (proto field 49).
351 ///
352 /// 股票所属交易所字符串 (e.g. "SEHK", "NYSE", "NASDAQ"). ModifyOrder /
353 /// CancelOrder backend 必填. C++ `FillModifyOrderReq:774`:
354 /// `req.set_exchange(order.szExchange)`.
355 pub exchange: String,
356
357 /// v1.4.106 codex 0219 Finding 4: backend `Order.security_type` (proto field 29).
358 ///
359 /// 取值参考 backend `odr_sys_cmn::SecurityType`
360 /// (1=COMMON, 2=OPTION, 4=FUTURES, 5=BOND).
361 /// CancelOrder single 必填 (`req.add_security_type(GetSecurityType(...))`,
362 /// C++ `FillCancelOrderReq:817`).
363 pub security_type: i32,
364
365 /// v1.4.98 T1-8 (mobile-source-audit): 美股盘前/盘中/盘后 session 标识.
366 /// proto/Trd_Common.proto:455 字段 27. 同 time_in_force / fill_outside_rth
367 /// 系列, 美股 RTH/Pre-Market/After-Hours order routing 显示用.
368 pub session: Option<i32>,
369
370 /// Backend `odr_sys_cmn.Order.order_trade_time_type` / C++ `order.enOrderTradeTimeType`.
371 ///
372 /// `GetMaxTrdQtys` real option IM side request (CMD5004) mirrors C++
373 /// `NNProto_Trd_MaxQty::QueryOptionIM`: when querying max qty for a
374 /// modification order, the side request forwards the cached backend order's
375 /// trade-time type if it is not UNSET.
376 pub order_trade_time_type: Option<u32>,
377
378 /// v1.4.98 T1-8 (mobile-source-audit): 日本子账户类型 (security_firm=7
379 /// FutuJP 时填充). proto/Trd_Common.proto:456 字段 28.
380 /// 8 enum 值: GENERAL/TOKUTEI/NISA_GENERAL/NISA_TSUMITATE 等
381 /// (per docs/reference/rest-api.md D6).
382 pub jp_acc_type: Option<i32>,
383
384 /// v1.4.90 S BUG-e4da-009: stub 标志。
385 ///
386 /// PlaceOrder/CancelOrder handler 成功响应后**立刻** upsert 一个 stub
387 /// `CachedOrder`(v1.4.82 A2 / `place_order.rs:427`),让 `/api/orders`
388 /// 0ms 可见。`is_stub=true` 标记此条尚未被 backend 权威列表 ack。
389 ///
390 /// 后续 backend `query_orders` 返回包含同 `order_id` 的 enriched 数据时,
391 /// 经 `merge_preserving_stubs` 合并 → `is_stub=false`。
392 ///
393 /// 历史坑:v1.4.73 A1 PlaceOrder 后 spawn refresh 直接 `orders.insert`
394 /// **整覆盖**,把刚 upsert 的 stub 抹掉(race 22ms 内即清零)。
395 /// 跨 v1.4.73 → v1.4.89 7 版未真修。本字段是根因修法的一部分。
396 pub is_stub: bool,
397
398 /// C++ `Ndt_Trd_Order.bIsLocalOrder` equivalent.
399 ///
400 /// C++ `UpdateOrderList` replaces the backend list, then re-inserts rows
401 /// returned by `INNData_Trd_Order::GetLocalOrderList` where
402 /// `bIsLocalOrder=true`.
403 ///
404 /// Refs:
405 /// - `FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.cpp:345-359`
406 /// - `FutuOpenD/Src/NNDataCenter/Trade/INNData_Trd_Order.cpp:13-31`
407 /// - `FutuOpenD/Src/NNProtoCenter/Trade/Operate/NNProto_Trd_OrderOpBase.cpp:95`
408 ///
409 /// Rust uses this narrowly for local terminal rows that C++ keeps visible
410 /// after backend refresh, notably DeleteFailOrder success -> Deleted(23).
411 /// It is intentionally distinct from `is_stub`: stubs expire by TTL, while
412 /// local Deleted rows are C++ list state and must survive an empty backend
413 /// refresh until a backend row with the same id replaces them.
414 pub is_local_order: bool,
415
416 /// v1.4.90 S BUG-e4da-009: stub 插入时间(unix epoch ms)。
417 ///
418 /// `merge_preserving_stubs` 用于 evict 老 stub:backend 如果连续多次
419 /// 不返某 stub `order_id` 且 stub 已超过 `STUB_TTL_MS` (30s) → evict。
420 /// 防止 stub 因 backend 拒单(never appear in list)永久滞留。
421 ///
422 /// `0` 表示非 stub(与 `is_stub=false` 配套)。
423 pub stub_inserted_at_ms: u64,
424
425 /// v1.4.105 BUG-v1.4.104-001 (P0): broker 异步 confirm 标志.
426 ///
427 /// PlaceOrder backend 同步 ack (CMD 4701 result=0) 仅说明 backend 收到请求,
428 /// **不**代表 broker 真的接受订单. C++ `OnOMEvent_Reply_PlaceOrder`
429 /// (`APIServer_Trd_PlaceOrder.cpp:794`) 是**异步 event handler** — broker
430 /// 真 confirm 后才 fire `set_orderid(nOrderIDHash)`. backend `OrderNewRsp.
431 /// need_op_confirm` (proto field 6, default=true) 即表示 "真 broker confirm
432 /// 还在路上, 等 OMEvent push (notice_type 4/5/8/100)".
433 ///
434 /// **历史坑 (external reviewer BUG-v1.4.104-001 实锤)**: v1.4.82-104 stub 上 cache 时直接
435 /// 视为已确认, 没有 broker async confirm 等待 → 三次不同订单返同 order_id_ex
436 /// 时客户端看 success → 误以为生效 → 加仓重下 → 风控 auto-cancel error 10003.
437 ///
438 /// **语义**:
439 /// - `true`: backend 已 ack 但 broker 未确认 — `is_stub=true` 时 stub 不进
440 /// `/api/orders` 响应 (filter), 等 push notice_type=4/5/8/100 confirm 后翻
441 /// `false` 才 expose. 30s 内未 confirm → cleanup task 删 stub + warn.
442 /// - `false`: backend 权威 / broker 已 confirm — 正常 expose 给 client.
443 /// query_orders 返的所有 order 都是 `false` (backend list 是 broker-confirmed
444 /// 的权威列表).
445 ///
446 /// **与 `is_stub` 关系**:
447 /// - `is_stub=true && is_pending_broker_confirm=true`: 刚 PlaceOrder ack, 还没
448 /// push confirm. **client 不可见** (Layer 4 filter).
449 /// - `is_stub=true && is_pending_broker_confirm=false`: backend `need_op_confirm=
450 /// false` 路径 (sim 账户 / 立即生效场景). client 可见.
451 /// - `is_stub=false`: backend authoritative (query_orders 返 / push merge 后).
452 /// `is_pending_broker_confirm` 必为 false. client 可见.
453 pub is_pending_broker_confirm: bool,
454}
455
456/// v1.4.106 codex 0219 Finding 1+4: trade-write resolution snapshot.
457///
458/// 用于 ModifyOrder / CancelOrder handler 把 cached order 字段一次性
459/// 拿出来构造 backend req. **不在 hot path 改 CachedOrder**, 而是返一个
460/// 只读 snapshot 防 caller mutate cache.
461///
462/// 字段 1:1 映射 backend `OrderReplaceReq` / `OrderCancelReq` 必填项 +
463/// modify validation 用到的 (`order_type` / `trd_side` / `qty` /
464/// `price` / `order_status`).
465#[derive(Debug, Clone, Default, PartialEq)]
466pub struct CachedOrderSnapshot {
467 /// = backend `szOrderID` (alphanumeric 字符串, 服务端真实 id).
468 pub backend_order_id: String,
469 /// = backend `Order.version`.
470 pub order_version: i32,
471 /// = backend `Order.exchange_code` (期货所属交易所代码, e.g. NN_QotMarket).
472 pub exchange_code: i32,
473 /// = backend `Order.exchange` (股票所属交易所字符串, e.g. "SEHK").
474 pub exchange: String,
475 /// = backend `Order.security_type` (1=COMMON, 2=OPTION, 4=FUTURES, 5=BOND).
476 pub security_type: i32,
477 /// FTAPI `OrderType` (modify validation 按原 order_type 决定 price /
478 /// aux_price / trail* 是否必填).
479 pub order_type: i32,
480 /// FTAPI `TrdSide` (1=Buy / 2=Sell / 3=SellShort / 4=BuyBack).
481 /// trailing modify 计算 sign 用.
482 pub trd_side: i32,
483 /// FTAPI `OrderStatus` (`IsNotSupportOrderOp` 检查用).
484 pub order_status: i32,
485 /// FTAPI `TrdMarket` (modify validation 中 sec_type futures 路径用).
486 pub trd_market: Option<i32>,
487 /// FTAPI `Order.qty` (历史值, 仅 modify validation 引用).
488 pub qty: f64,
489 /// FTAPI `Order.price` (历史值, 仅 modify validation 引用).
490 pub price: f64,
491 /// FTAPI `Order.code` (用于错误提示 + log 关联).
492 pub code: String,
493 /// PlaceOrder stub 标记. PlaceOrder ack 时插入的 stub `is_stub=true` +
494 /// `order_version=0`. ModifyOrder 按 C++ 本地回显订单 `nVersion=-1`
495 /// 映射为 backend wire `u32::MAX`; CancelOrder 不依赖 order_version.
496 pub is_stub: bool,
497 /// PlaceOrder stub 是否仍在等待 broker/backend 权威确认.
498 ///
499 /// Real write handlers use this to avoid reporting success against a local
500 /// optimistic echo when the authoritative order list has not accepted the
501 /// order yet.
502 pub is_pending_broker_confirm: bool,
503}
504
505impl CachedOrderSnapshot {
506 /// 从 `CachedOrder` 抽出 trade-write resolution 用到的字段子集.
507 pub fn from_order(o: &CachedOrder) -> Self {
508 Self {
509 backend_order_id: o.backend_order_id.clone(),
510 order_version: o.order_version,
511 exchange_code: o.exchange_code,
512 exchange: o.exchange.clone(),
513 security_type: o.security_type,
514 order_type: o.order_type,
515 trd_side: o.trd_side,
516 order_status: o.order_status,
517 trd_market: o.trd_market,
518 qty: o.qty,
519 price: o.price,
520 code: o.code.clone(),
521 is_stub: o.is_stub,
522 is_pending_broker_confirm: o.is_pending_broker_confirm,
523 }
524 }
525}
526
527/// v1.4.106 codex 0219 Finding 1: ResolveOrderError 区分三种 cache miss 形态.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum ResolveOrderError {
530 /// 同时缺 `order_id` 和 `order_id_ex`. caller 必须早期 reject.
531 InvalidInput,
532 /// `(acc_id, order_id)` 在 cache 找不到. 提示用户先刷新 `/api/orders`
533 /// 或传 orderIDEx.
534 CacheMiss,
535 /// cache 命中但 `backend_order_id` 字段空 (老版本 cache entry).
536 /// 提示用户先刷新 `/api/orders` 或传 orderIDEx.
537 MissingBackendId,
538}
539
540impl std::fmt::Display for ResolveOrderError {
541 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542 match self {
543 Self::InvalidInput => f.write_str(
544 "modify/cancel: 必须传 order_id 或 order_id_ex (orderIDEx 优先)",
545 ),
546 Self::CacheMiss => f.write_str(
547 "modify/cancel: 订单不在本地缓存. 请先调 /api/orders 刷新, 或在请求里传 orderIDEx (= backend szOrderID).",
548 ),
549 Self::MissingBackendId => f.write_str(
550 "modify/cancel: 本地缓存缺 backend orderIDEx (老版本 daemon 写入的 cache). 请先调 /api/orders 刷新, 或在请求里传 orderIDEx.",
551 ),
552 }
553 }
554}
555
556impl std::error::Error for ResolveOrderError {}
557
558/// **v1.4.106 Finding A** (codex source audit 2026-05-01): funds cache currency-aware key.
559///
560/// 对齐 C++ `INNData_Trd_Acc.cpp::m_mapAccFund`:
561/// `m_mapAccFund: NN_AssetKey -> NN_TrdCurrency -> Ndt_Trd_AccFund`
562///
563/// Universal/Futures 账户对**不同 currency** 有独立 funds snapshot, 之前 Rust
564/// 用 `DashMap<AccKey, CachedFunds>` (1 acc_id → 1 snapshot) 会被 backend
565/// pushed snapshots **互相覆盖** — 用户传 `currency=USD` 拿到的可能是 stale
566/// CAD 数据, 客户端无法察觉.
567///
568/// 字段语义:
569/// - `acc_id`: 账户 (主 key)
570/// - `asset_category`: `Trd_Common.proto::AssetCategory` enum (0=Default 等),
571/// 对齐 C++ NN_AssetKey 子集. 若 client 传 `c2s.asset_category=None`, 用 0.
572/// - `currency`: `Some(c)` 表示 per-currency snapshot (Futures/Universal 路径);
573/// `None` 表示 legacy 单币种账户 native (无 per-currency 概念). C++ 等价于
574/// "first available currency" snapshot.
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
576pub struct FundsCacheKey {
577 pub acc_id: u64,
578 pub asset_category: i32,
579 pub currency: Option<i32>,
580}
581
582impl FundsCacheKey {
583 /// Legacy single-account snapshot key (acc_id only, no per-currency / no
584 /// per-asset_category dimension). 用于 SingleCurrency 账户 / 无 currency
585 /// context 的 cache write.
586 #[must_use]
587 pub const fn legacy(acc_id: u64) -> Self {
588 Self {
589 acc_id,
590 asset_category: 0,
591 currency: None,
592 }
593 }
594
595 /// Per-currency snapshot key (Universal/Futures 路径).
596 #[must_use]
597 pub const fn per_currency(acc_id: u64, currency: i32) -> Self {
598 Self {
599 acc_id,
600 asset_category: 0,
601 currency: Some(currency),
602 }
603 }
604
605 /// Per-asset-category + per-currency snapshot key (full path, asset_category
606 /// 非 0 时用).
607 #[must_use]
608 pub const fn full(acc_id: u64, asset_category: i32, currency: Option<i32>) -> Self {
609 Self {
610 acc_id,
611 asset_category,
612 currency,
613 }
614 }
615}
616
617/// **v1.4.107 PositionList asset-category key**.
618///
619/// C++ `APIServer_Trd_GetPositionList.cpp::FillPositionList` reads positions
620/// by `NN_AssetKey { accid, enCategory }`. FutuJP margin / derivative accounts
621/// therefore need independent position snapshots per asset category, just like
622/// funds. Category 0 keeps the legacy single-bucket behavior for non-JP and sim
623/// accounts.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
625pub struct PositionsCacheKey {
626 pub acc_id: u64,
627 pub asset_category: i32,
628}
629
630impl PositionsCacheKey {
631 #[must_use]
632 pub const fn legacy(acc_id: u64) -> Self {
633 Self {
634 acc_id,
635 asset_category: 0,
636 }
637 }
638
639 #[must_use]
640 pub const fn scoped(acc_id: u64, asset_category: i32) -> Self {
641 Self {
642 acc_id,
643 asset_category,
644 }
645 }
646}
647
648/// v1.4.106 codex 0226 F1+F2: PlaceOrder 阶段 backend 返回 `OrderNewRsp.action`
649/// (CltAction.type == ORDER_CONFIRM=5) 时, 携带 `CltActionOrderConfirm` 二次
650/// 确认上下文 — 客户端需把这些字段透传给 backend `OrderConfirmReq` (cmd 4728).
651///
652/// 来源 (proto-internal/odr_sys_cmn.proto:883-893):
653/// ```text
654/// message CltActionOrderConfirm {
655/// optional string order_id = 1; // 订单 id (= backend 真实
656/// // szOrderID, alphanumeric)
657/// optional string title = 2; // 弹窗标题文案
658/// optional string content = 3; // 弹窗内容文案
659/// optional string confirm_button_title = 4;
660/// optional string cancel_button_title = 5;
661/// optional uint32 confirm_type = 6; // 必传给 OrderConfirmReq
662/// optional uint32 exchange_code = 7; // 期货上游交易所代码
663/// optional string exchange = 8; // 股票所属交易所
664/// }
665/// ```
666///
667/// daemon 在 PlaceOrder ack 路径里 capture 此 context, 然后在
668/// `Trd_ReconfirmOrder` 收到客户端确认请求时按 (acc_id, ftapi_order_id) 取出来
669/// 构造 backend `OrderConfirmReq`. 缺 context = backend 不需要二次确认 (e.g.
670/// HK 高买低卖未触发 / sim 路径) → ReconfirmOrder handler 早 reject loud.
671///
672/// **来源 cmd_id**: `CS_CMDID_TRADE_ORDER_CONFIRM_ORDER = 4728`
673/// (`moomoo/Moomoo/Include/FTTrade/TradeCmdDefine.h:132`).
674#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
675pub struct OrderConfirmContext {
676 /// backend 真实 szOrderID (alphanumeric); 写到 `OrderConfirmReq.order_id`.
677 pub backend_order_id: String,
678 /// `CltActionOrderConfirm.confirm_type` (来自 OrderConfirmType enum).
679 /// **必传** (backend 用此值校验客户端确实看到了对应弹窗).
680 pub confirm_type: u32,
681 /// `CltActionOrderConfirm.exchange_code` (期货必传).
682 pub exchange_code: u32,
683 /// `CltActionOrderConfirm.exchange` (股票必传).
684 pub exchange: String,
685 /// 弹窗 title (用于 daemon 日志, 客户端可参考).
686 pub title: String,
687 /// 弹窗 content (用于 daemon 日志, 客户端可参考).
688 pub content: String,
689 /// confirm_button / cancel_button title 透传 (UX, 不影响 backend).
690 pub confirm_button_title: String,
691 pub cancel_button_title: String,
692 /// 写入时间 (unix epoch ms), TTL 用. PlaceOrder 与 ReconfirmOrder 之间通常
693 /// <60s, 远超 60s 视为 stale → handler 拒绝.
694 pub inserted_at_ms: u64,
695}
696
697/// v1.4.106 codex 0226 F1+F2: pending OrderConfirm cache key.
698///
699/// `(acc_id, ftapi_order_id)` 而不是 `(acc_id, backend_order_id)`, 因为 FTAPI
700/// 客户端发 ReconfirmOrder 用的是 PlaceOrder 返的 `s2c.order_id` (FTAPI u64,
701/// 由 `hash_backend_id_to_u64` 派生). FTAPI 客户端**不**直接看到 backend
702/// alphanumeric szOrderID; daemon 必须按 FTAPI order_id 反查 context.
703#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
704pub struct OrderConfirmKey {
705 pub acc_id: u64,
706 /// FTAPI order_id (= `hash_backend_id_to_u64(backend.order_id)`).
707 pub ftapi_order_id: u64,
708}
709
710impl OrderConfirmKey {
711 pub fn new(acc_id: u64, ftapi_order_id: u64) -> Self {
712 Self {
713 acc_id,
714 ftapi_order_id,
715 }
716 }
717}
718
719/// C++ `m_mapReqIDOrderID` key, scoped by account to avoid cross-account
720/// collisions in the shared Rust trade cache.
721#[derive(Debug, Clone, PartialEq, Eq, Hash)]
722pub struct OrderOpReqKey {
723 pub acc_id: u64,
724 pub req_id: String,
725}
726
727impl OrderOpReqKey {
728 pub fn new(acc_id: u64, req_id: String) -> Self {
729 Self { acc_id, req_id }
730 }
731}
732
733/// C++ `FindOrderIDByReqID` clears the helper map once it reaches 512 entries.
734pub const ORDER_OP_REQ_ORDER_MAP_CLEAR_LIMIT: usize = 512;
735
736/// v1.4.106 codex 0226 F1+F2: pending order confirm context TTL (ms).
737///
738/// PlaceOrder → ReconfirmOrder 通常 <60s (用户看到弹窗后立即点确认). 超过 60s
739/// 视为弹窗已被忽略 / 用户 abandon → daemon 不允许 reconfirm (backend 也会拒绝
740/// 因为 confirm_type 已过期). 当前选 5min (300_000 ms) 作宽松 TTL — 给真机用户
741/// 更多缓冲, 同时防内存泄漏.
742pub const ORDER_CONFIRM_CONTEXT_TTL_MS: u64 = 300_000;