1use 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
34pub 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_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/funds")?;
42 validate_header_trd_market(&body, "/api/funds")?;
44 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 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 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
176pub 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 validate_header_trd_market(&body, "/api/positions")?;
185 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
211pub 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 validate_header_trd_market_write(&body, "/api/orders")?;
221 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
237pub 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 validate_header_trd_market_write(&body, "/api/order-fills")?;
247 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
266pub 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 validate_header_trd_market_write(&body, "/api/max-trd-qtys")?;
276 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
302pub 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
324pub 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 validate_header_trd_market(&body, "/api/history-orders")?;
346 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
355pub 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 validate_header_trd_market(&body, "/api/history-order-fills")?;
376 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
400pub 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 validate_header_trd_market_write(&body, "/api/margin-ratio")?;
412 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
428pub 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 validate_header_trd_market_write(&body, "/api/order-fee")?;
444 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}