Skip to main content

futu_rest/routes/trd/
read.rs

1//! REST trade/account read routes.
2
3use std::sync::Arc;
4
5use axum::extract::{Extension, Json, State};
6use axum::http::StatusCode;
7use serde_json::Value;
8
9use futu_auth::KeyRecord;
10use futu_core::proto_id;
11use futu_core::trade_currency::{funds_currency_mismatch_warning, parse_currency_label};
12use futu_core::trade_security::{
13    TrdSecMarketInput, derive_trd_sec_market_like_cpp, strip_market_prefix,
14};
15use futu_proto::trd_get_combo_max_trd_qtys;
16use futu_proto::trd_get_funds;
17use futu_proto::trd_get_history_order_fill_list;
18use futu_proto::trd_get_history_order_list;
19use futu_proto::trd_get_margin_ratio;
20use futu_proto::trd_get_max_trd_qtys;
21use futu_proto::trd_get_order_fee;
22use futu_proto::trd_get_order_fill_list;
23use futu_proto::trd_get_order_list;
24use futu_proto::trd_get_position_list;
25
26use super::card_num::normalize_and_resolve_card_num_for_route;
27use super::validation::{
28    read_handler_acc_id_check, validate_header_trd_env_present, validate_header_trd_market,
29    validate_header_trd_market_write,
30};
31use super::{ApiResult, RawApiResult};
32use crate::adapter::{self, RestState};
33
34/// POST /api/funds — 获取资金
35pub async fn get_funds(
36    State(state): State<RestState>,
37    rec: Option<Extension<Arc<KeyRecord>>>,
38    Json(mut body): Json<Value>,
39) -> ApiResult {
40    // normalize + card_num -> acc_id 必须在 validate / allowed_acc_ids 之前完成。
41    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/funds")?;
42    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单(防 silent misroute)
43    validate_header_trd_market(&body, "/api/funds")?;
44    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
45    validate_header_trd_env_present(&body, "/api/funds")?;
46    read_handler_acc_id_check(
47        &state,
48        rec.as_deref().map(|r| r.as_ref()),
49        &body,
50        "/api/funds",
51    )?;
52    normalize_trd_currency_labels(&mut body).map_err(|e| {
53        (
54            axum::http::StatusCode::BAD_REQUEST,
55            Json(serde_json::json!({
56                "ret_type": -1,
57                "ret_msg": e,
58                "error": e,
59            })),
60        )
61    })?;
62
63    // 记录 user 显式请求 currency, 用于 response post-process: 若 backend
64    // 返回了不同币种, 对 REST caller 明确暴露短 warning。
65    let requested_currency: Option<i32> = body
66        .pointer("/c2s/currency")
67        .or_else(|| body.pointer("/currency"))
68        .and_then(|v| v.as_i64())
69        .map(|v| v as i32);
70
71    let mut resp = adapter::proto_request::<trd_get_funds::Request, trd_get_funds::Response>(
72        &state,
73        proto_id::TRD_GET_FUNDS,
74        Some(body),
75    )
76    .await?;
77
78    let response_currency = resp
79        .0
80        .pointer("/s2c/funds/currency")
81        .and_then(|v| v.as_i64())
82        .map(|v| v as i32);
83    if let Some(warn_msg) = funds_currency_mismatch_warning(requested_currency, response_currency)
84        && let Some(obj) = resp.0.as_object_mut()
85    {
86        obj.insert(
87            "currency_warning".to_string(),
88            serde_json::Value::String(warn_msg.clone()),
89        );
90        // 也在 ret_msg 追加 hint (若已有 ret_msg 则前置 warning)
91        let existing_msg = obj
92            .get("ret_msg")
93            .and_then(|v| v.as_str())
94            .unwrap_or("")
95            .to_string();
96        let new_msg = if existing_msg.is_empty() {
97            format!("⚠️  {warn_msg}")
98        } else {
99            format!("⚠️  {warn_msg}\n[既有] {existing_msg}")
100        };
101        obj.insert("ret_msg".to_string(), serde_json::Value::String(new_msg));
102    }
103
104    Ok(resp)
105}
106
107fn normalize_trd_currency_labels(body: &mut Value) -> Result<(), String> {
108    for pointer in ["/c2s/currency", "/currency"] {
109        let Some(value) = body.pointer_mut(pointer) else {
110            continue;
111        };
112        let Value::String(label) = value else {
113            continue;
114        };
115        let parsed = parse_currency_label(label)?;
116        *value = Value::Number(serde_json::Number::from(parsed));
117    }
118    Ok(())
119}
120
121fn normalize_max_trd_qtys_for_rest(body: &mut Value) -> Result<(), String> {
122    let nested = matches!(body.get("c2s"), Some(Value::Object(_)));
123    let c2s = if nested {
124        body.get_mut("c2s")
125            .and_then(Value::as_object_mut)
126            .ok_or_else(|| "max-trd-qtys c2s must be a JSON object".to_string())?
127    } else {
128        body.as_object_mut()
129            .ok_or_else(|| "max-trd-qtys request body must be a JSON object".to_string())?
130    };
131
132    let code = c2s
133        .get("code")
134        .and_then(Value::as_str)
135        .ok_or_else(|| "max-trd-qtys code is required to derive sec_market".to_string())?
136        .to_string();
137    let has_explicit_sec_market = c2s.get("sec_market").is_some_and(|value| !value.is_null());
138    let trd_market = if nested {
139        c2s.get("header")
140            .and_then(Value::as_object)
141            .and_then(|header| header.get("trd_market"))
142            .and_then(Value::as_i64)
143    } else {
144        c2s.get("trd_market").and_then(Value::as_i64)
145    }
146    .and_then(|value| i32::try_from(value).ok())
147    .unwrap_or(0);
148
149    if !has_explicit_sec_market {
150        let sec_market = derive_trd_sec_market_like_cpp(TrdSecMarketInput {
151            ftapi_sec_market: 0,
152            trd_market,
153            code: &code,
154        });
155        if sec_market == 0 {
156            return Err(format!(
157                "max-trd-qtys cannot derive sec_market from trd_market={trd_market} and code"
158            ));
159        }
160        c2s.insert(
161            "sec_market".to_string(),
162            Value::Number(serde_json::Number::from(sec_market)),
163        );
164    }
165
166    let bare_code = strip_market_prefix(&code);
167    if bare_code != code {
168        c2s.insert("code".to_string(), Value::String(bare_code));
169    }
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests;
175
176/// POST /api/positions — 获取持仓
177pub async fn get_positions(
178    State(state): State<RestState>,
179    rec: Option<Extension<Arc<KeyRecord>>>,
180    Json(mut body): Json<Value>,
181) -> RawApiResult {
182    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/positions")?;
183    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单
184    validate_header_trd_market(&body, "/api/positions")?;
185    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
186    validate_header_trd_env_present(&body, "/api/positions")?;
187    read_handler_acc_id_check(
188        &state,
189        rec.as_deref().map(|r| r.as_ref()),
190        &body,
191        "/api/positions",
192    )?;
193    normalize_trd_currency_labels(&mut body).map_err(|e| {
194        (
195            axum::http::StatusCode::BAD_REQUEST,
196            Json(serde_json::json!({
197                "ret_type": -1,
198                "ret_msg": e,
199                "error": e,
200            })),
201        )
202    })?;
203    adapter::proto_request_raw::<trd_get_position_list::Request, trd_get_position_list::Response>(
204        &state,
205        proto_id::TRD_GET_POSITION_LIST,
206        Some(body),
207    )
208    .await
209}
210
211/// POST /api/orders — 获取订单列表
212pub async fn get_orders(
213    State(state): State<RestState>,
214    rec: Option<Extension<Arc<KeyRecord>>>,
215    Json(mut body): Json<Value>,
216) -> RawApiResult {
217    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/orders")?;
218    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单
219    // v1.4.102 codex 32 F6 (P2): active order reads 用 write allowlist (无 fund markets, 未 verified)
220    validate_header_trd_market_write(&body, "/api/orders")?;
221    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
222    validate_header_trd_env_present(&body, "/api/orders")?;
223    read_handler_acc_id_check(
224        &state,
225        rec.as_deref().map(|r| r.as_ref()),
226        &body,
227        "/api/orders",
228    )?;
229    adapter::proto_request_raw::<trd_get_order_list::Request, trd_get_order_list::Response>(
230        &state,
231        proto_id::TRD_GET_ORDER_LIST,
232        Some(body),
233    )
234    .await
235}
236
237/// POST /api/order-fills — 获取成交列表
238pub async fn get_order_fills(
239    State(state): State<RestState>,
240    rec: Option<Extension<Arc<KeyRecord>>>,
241    Json(mut body): Json<Value>,
242) -> RawApiResult {
243    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/order-fills")?;
244    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单
245    // v1.4.102 codex 32 F6 (P2): active order reads 用 write allowlist (无 fund markets, 未 verified)
246    validate_header_trd_market_write(&body, "/api/order-fills")?;
247    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
248    validate_header_trd_env_present(&body, "/api/order-fills")?;
249    read_handler_acc_id_check(
250        &state,
251        rec.as_deref().map(|r| r.as_ref()),
252        &body,
253        "/api/order-fills",
254    )?;
255    adapter::proto_request_raw::<
256        trd_get_order_fill_list::Request,
257        trd_get_order_fill_list::Response,
258    >(
259        &state,
260        proto_id::TRD_GET_ORDER_FILL_LIST,
261        Some(body),
262    )
263    .await
264}
265
266/// POST /api/max-trd-qtys — 获取最大交易数量
267pub async fn get_max_trd_qtys(
268    State(state): State<RestState>,
269    rec: Option<Extension<Arc<KeyRecord>>>,
270    Json(mut body): Json<Value>,
271) -> RawApiResult {
272    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/max-trd-qtys")?;
273    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单
274    // v1.4.102 codex 29 F3 / 31 F5 (P2): trade-calculation endpoint 用 write allowlist (无 fund markets)
275    validate_header_trd_market_write(&body, "/api/max-trd-qtys")?;
276    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
277    validate_header_trd_env_present(&body, "/api/max-trd-qtys")?;
278    read_handler_acc_id_check(
279        &state,
280        rec.as_deref().map(|r| r.as_ref()),
281        &body,
282        "/api/max-trd-qtys",
283    )?;
284    normalize_max_trd_qtys_for_rest(&mut body).map_err(|error| {
285        (
286            StatusCode::BAD_REQUEST,
287            Json(serde_json::json!({
288                "ret_type": -1,
289                "ret_msg": error,
290                "error": error,
291            })),
292        )
293    })?;
294    adapter::proto_request_raw::<trd_get_max_trd_qtys::Request, trd_get_max_trd_qtys::Response>(
295        &state,
296        proto_id::TRD_GET_MAX_TRD_QTYS,
297        Some(body),
298    )
299    .await
300}
301
302/// POST /api/combo-max-trd-qtys — 获取组合订单最大交易数量
303pub async fn get_combo_max_trd_qtys(
304    State(state): State<RestState>,
305    rec: Option<Extension<Arc<KeyRecord>>>,
306    Json(mut body): Json<Value>,
307) -> RawApiResult {
308    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/combo-max-trd-qtys")?;
309    validate_header_trd_market_write(&body, "/api/combo-max-trd-qtys")?;
310    validate_header_trd_env_present(&body, "/api/combo-max-trd-qtys")?;
311    read_handler_acc_id_check(
312        &state,
313        rec.as_deref().map(|r| r.as_ref()),
314        &body,
315        "/api/combo-max-trd-qtys",
316    )?;
317    adapter::proto_request_raw::<
318        trd_get_combo_max_trd_qtys::Request,
319        trd_get_combo_max_trd_qtys::Response,
320    >(&state, proto_id::TRD_GET_COMBO_MAX_TRD_QTYS, Some(body))
321    .await
322}
323
324/// POST /api/history-orders — 获取历史订单
325pub async fn get_history_orders(
326    State(state): State<RestState>,
327    rec: Option<Extension<Arc<KeyRecord>>>,
328    Json(mut body): Json<Value>,
329) -> RawApiResult {
330    adapter::normalize_json_keys_snake_case(&mut body);
331    adapter::normalize_endpoint_local_request_aliases_for_rest_path(
332        "/api/history-orders",
333        &mut body,
334    )
335    .map_err(trade_read_alias_error)?;
336    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/history-orders")?;
337    read_handler_acc_id_check(
338        &state,
339        rec.as_deref().map(|r| r.as_ref()),
340        &body,
341        "/api/history-orders",
342    )?;
343    // v1.4.96 BUG #003 hotfix (external reviewer matrix-double-confirmed): trd_market=999
344    // 之前 silent accept 200 OK, broker routing 风险.
345    validate_header_trd_market(&body, "/api/history-orders")?;
346    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
347    validate_header_trd_env_present(&body, "/api/history-orders")?;
348    adapter::proto_request_raw::<
349        trd_get_history_order_list::Request,
350        trd_get_history_order_list::Response,
351    >(&state, proto_id::TRD_GET_HISTORY_ORDER_LIST, Some(body))
352    .await
353}
354
355/// POST /api/history-order-fills — 获取历史成交
356pub async fn get_history_order_fills(
357    State(state): State<RestState>,
358    rec: Option<Extension<Arc<KeyRecord>>>,
359    Json(mut body): Json<Value>,
360) -> RawApiResult {
361    adapter::normalize_json_keys_snake_case(&mut body);
362    adapter::normalize_endpoint_local_request_aliases_for_rest_path(
363        "/api/history-order-fills",
364        &mut body,
365    )
366    .map_err(trade_read_alias_error)?;
367    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/history-order-fills")?;
368    read_handler_acc_id_check(
369        &state,
370        rec.as_deref().map(|r| r.as_ref()),
371        &body,
372        "/api/history-order-fills",
373    )?;
374    // v1.4.96 BUG #003 hotfix (external reviewer matrix-double-confirmed)
375    validate_header_trd_market(&body, "/api/history-order-fills")?;
376    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
377    validate_header_trd_env_present(&body, "/api/history-order-fills")?;
378    adapter::proto_request_raw::<
379        trd_get_history_order_fill_list::Request,
380        trd_get_history_order_fill_list::Response,
381    >(
382        &state,
383        proto_id::TRD_GET_HISTORY_ORDER_FILL_LIST,
384        Some(body),
385    )
386    .await
387}
388
389fn trade_read_alias_error(message: String) -> (StatusCode, Json<Value>) {
390    (
391        StatusCode::BAD_REQUEST,
392        Json(serde_json::json!({
393            "ret_type": -1,
394            "ret_msg": message,
395            "error": message,
396        })),
397    )
398}
399
400/// POST /api/margin-ratio — 获取融资融券比率
401pub async fn get_margin_ratio(
402    State(state): State<RestState>,
403    rec: Option<Extension<Arc<KeyRecord>>>,
404    Json(mut body): Json<Value>,
405) -> RawApiResult {
406    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/margin-ratio")?;
407    adapter::normalize_endpoint_local_request_aliases_for_rest_path("/api/margin-ratio", &mut body)
408        .map_err(trade_read_alias_error)?;
409    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单
410    // v1.4.102 codex 29 F3 / 31 F5 (P2): trade-calculation endpoint 用 write allowlist (无 fund markets)
411    validate_header_trd_market_write(&body, "/api/margin-ratio")?;
412    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
413    validate_header_trd_env_present(&body, "/api/margin-ratio")?;
414    read_handler_acc_id_check(
415        &state,
416        rec.as_deref().map(|r| r.as_ref()),
417        &body,
418        "/api/margin-ratio",
419    )?;
420    adapter::proto_request_raw::<trd_get_margin_ratio::Request, trd_get_margin_ratio::Response>(
421        &state,
422        proto_id::TRD_GET_MARGIN_RATIO,
423        Some(body),
424    )
425    .await
426}
427
428/// POST /api/order-fee — 获取订单费用
429pub async fn get_order_fee(
430    State(state): State<RestState>,
431    rec: Option<Extension<Arc<KeyRecord>>>,
432    Json(mut body): Json<Value>,
433) -> RawApiResult {
434    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/order-fee")?;
435    read_handler_acc_id_check(
436        &state,
437        rec.as_deref().map(|r| r.as_ref()),
438        &body,
439        "/api/order-fee",
440    )?;
441    // v1.4.96 BUG #003 hotfix (external reviewer matrix-double-confirmed)
442    // v1.4.102 codex 29 F3 / 31 F5 (P2): trade-calculation endpoint 用 write allowlist (无 fund markets)
443    validate_header_trd_market_write(&body, "/api/order-fee")?;
444    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
445    validate_header_trd_env_present(&body, "/api/order-fee")?;
446    adapter::proto_request_raw::<trd_get_order_fee::Request, trd_get_order_fee::Response>(
447        &state,
448        proto_id::TRD_GET_ORDER_FEE,
449        Some(body),
450    )
451    .await
452}