1use std::sync::Arc;
4
5use axum::extract::{Extension, Json, State};
6use axum::http::{HeaderMap, StatusCode};
7use serde_json::Value;
8
9use futu_auth::{CheckCtx, KeyRecord};
10use futu_core::proto_id;
11use futu_proto::trd_modify_order;
12use futu_proto::trd_place_combo_order;
13use futu_proto::trd_place_order;
14use futu_proto::trd_reconfirm_order;
15
16use super::ApiResult;
17use super::card_num::{
18 extract_and_resolve_card_num_into_acc_id, normalize_and_resolve_card_num_for_route,
19};
20use super::validation::{
21 authorize_trade_write, read_handler_acc_id_check, rest_handler_limit_check, trd_market_str,
22 validate_header_trd_market_write,
23};
24use crate::adapter::{self, RestState};
25
26use super::write_pipeline::{
27 idempotency_key_from_headers, limit_reject_response, place_order_body_is_moc,
28 surface_spec_or_internal_error,
29};
30
31pub async fn place_order(
38 State(state): State<RestState>,
39 rec: Option<Extension<Arc<KeyRecord>>>,
40 headers: HeaderMap,
41 Json(mut body): Json<Value>,
42) -> ApiResult {
43 crate::adapter::normalize_json_keys_snake_case(&mut body);
45 let caller_key_id = rec.as_ref().map(|Extension(record)| record.id.clone());
46 authorize_trade_write(
47 &state,
48 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
49 &body,
50 "/api/order",
51 )?;
52 let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
62 extract_and_resolve_card_num_into_acc_id(
63 &state,
64 rec_ref_for_card_num,
65 &mut body,
66 "/api/order",
67 )?;
68 validate_header_trd_market_write(&body, "/api/order")?;
71 if let Some(Extension(rec)) = rec {
72 match serde_json::from_value::<trd_place_order::Request>(body.clone()) {
74 Ok(parsed) => rest_handler_limit_check(&state, &rec, &parsed)?,
75 Err(_) => {
76 }
79 }
80 }
81 let idem_key = idempotency_key_from_headers(&headers);
84 if place_order_body_is_moc(&body)
85 && idem_key
86 .as_deref()
87 .map(str::trim)
88 .filter(|key| !key.is_empty())
89 .is_none()
90 {
91 let message = "MOC order_type=20 requires a non-empty Idempotency-Key header";
92 return Err((
93 StatusCode::BAD_REQUEST,
94 Json(serde_json::json!({
95 "ret_type": -1,
96 "ret_msg": message,
97 "error": message,
98 })),
99 ));
100 }
101 adapter::proto_request_with_idempotency_and_caller::<
102 trd_place_order::Request,
103 trd_place_order::Response,
104 >(
105 &state,
106 proto_id::TRD_PLACE_ORDER,
107 Some(body),
108 idem_key,
109 caller_key_id,
110 )
111 .await
112}
113
114pub async fn place_combo_order(
116 State(state): State<RestState>,
117 rec: Option<Extension<Arc<KeyRecord>>>,
118 headers: HeaderMap,
119 Json(mut body): Json<Value>,
120) -> ApiResult {
121 crate::adapter::normalize_json_keys_snake_case(&mut body);
122 authorize_trade_write(
123 &state,
124 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
125 &body,
126 "/api/combo-order",
127 )?;
128 let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
129 extract_and_resolve_card_num_into_acc_id(
130 &state,
131 rec_ref_for_card_num,
132 &mut body,
133 "/api/combo-order",
134 )?;
135 validate_header_trd_market_write(&body, "/api/combo-order")?;
136 if let Some(Extension(rec)) = rec
137 && let Ok(parsed) = serde_json::from_value::<trd_place_combo_order::Request>(body.clone())
138 {
139 let market = trd_market_str(parsed.c2s.header.trd_market);
140 let symbol = parsed
141 .c2s
142 .combo_legs
143 .first()
144 .map(|leg| leg.security.code.as_str())
145 .filter(|code| !market.is_empty() && !code.is_empty())
146 .map(|code| format!("{market}.{code}"))
147 .unwrap_or_default();
148 let ctx = CheckCtx {
149 market: market.to_string(),
150 symbol,
151 order_value: parsed.c2s.price.map(|price| price * parsed.c2s.qty),
152 trd_side: None,
153 acc_id: Some(parsed.c2s.header.acc_id),
154 mutation_no_exposure: false,
155 currency: futu_auth::market_to_currency(market).map(String::from),
156 };
157 let now = chrono::Utc::now();
158 let outcome = state
159 .counters
160 .check_full_skip_rate(&rec.id, rec.as_ref(), &ctx, now);
161 if let Some(reason) = outcome.reason() {
162 return Err(limit_reject_response(
163 "/api/combo-order",
164 &rec,
165 &reason,
166 outcome.http_status_code(),
167 ));
168 }
169 }
170 let idem_key = idempotency_key_from_headers(&headers);
171 adapter::proto_request_with_idempotency::<
172 trd_place_combo_order::Request,
173 trd_place_combo_order::Response,
174 >(
175 &state,
176 proto_id::TRD_PLACE_COMBO_ORDER,
177 Some(body),
178 idem_key,
179 )
180 .await
181}
182
183pub async fn modify_order(
201 State(state): State<RestState>,
202 rec: Option<Extension<Arc<KeyRecord>>>,
203 headers: HeaderMap,
204 Json(mut body): Json<Value>,
205) -> ApiResult {
206 crate::adapter::normalize_json_keys_snake_case(&mut body);
208 authorize_trade_write(
209 &state,
210 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
211 &body,
212 "/api/modify-order",
213 )?;
214 let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
218 extract_and_resolve_card_num_into_acc_id(
219 &state,
220 rec_ref_for_card_num,
221 &mut body,
222 "/api/modify-order",
223 )?;
224 validate_header_trd_market_write(&body, "/api/modify-order")?;
229 if let Some(Extension(rec)) = rec
230 && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
231 {
232 let market = trd_market_str(parsed.c2s.header.trd_market);
233 const MODIFY_OP_NORMAL: i32 = 1;
237 let (order_value, mutation_no_exposure) = if parsed.c2s.modify_order_op == MODIFY_OP_NORMAL
238 {
239 let v = match (parsed.c2s.qty, parsed.c2s.price) {
240 (Some(q), Some(pr)) => Some(q * pr),
241 _ => None, };
243 (v, false)
244 } else {
245 (None, true)
246 };
247 let ctx = CheckCtx {
248 market: market.to_string(),
249 symbol: String::new(),
250 order_value,
251 trd_side: None,
252 acc_id: Some(parsed.c2s.header.acc_id), mutation_no_exposure,
254 currency: futu_auth::market_to_currency(market).map(String::from),
256 };
257 let now = chrono::Utc::now();
258 let outcome = state
260 .counters
261 .check_full_skip_rate(&rec.id, rec.as_ref(), &ctx, now);
262 if let Some(reason) = outcome.reason() {
263 return Err(limit_reject_response(
264 "/api/modify-order",
265 &rec,
266 &reason,
267 outcome.http_status_code(),
268 ));
269 }
270 }
271 let idem_key = idempotency_key_from_headers(&headers);
272 adapter::proto_request_with_idempotency::<trd_modify_order::Request, trd_modify_order::Response>(
273 &state,
274 proto_id::TRD_MODIFY_ORDER,
275 Some(body),
276 idem_key,
277 )
278 .await
279}
280
281pub async fn cancel_order(
290 State(state): State<RestState>,
291 rec: Option<Extension<Arc<KeyRecord>>>,
292 headers: HeaderMap,
293 Json(mut body): Json<Value>,
294) -> ApiResult {
295 crate::adapter::normalize_json_keys_snake_case(&mut body);
296 adapter::normalize_cancel_order_env_alias(&mut body).map_err(cancel_order_alias_error)?;
297 authorize_trade_write(
298 &state,
299 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
300 &body,
301 "/api/cancel-order",
302 )?;
303 adapter::normalize_endpoint_local_request_aliases_for_rest_path("/api/cancel-order", &mut body)
304 .map_err(cancel_order_alias_error)?;
305 let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
306 extract_and_resolve_card_num_into_acc_id(
307 &state,
308 rec_ref_for_card_num,
309 &mut body,
310 "/api/cancel-order",
311 )?;
312 adapter::maybe_wrap_flat_body_as_c2s(&mut body);
315 adapter::normalize_endpoint_local_request_aliases_for_rest_path("/api/cancel-order", &mut body)
316 .map_err(cancel_order_alias_error)?;
317 adapter::maybe_expand_flat_trd_header(&mut body);
318 validate_header_trd_market_write(&body, "/api/cancel-order")?;
319 if let Some(Extension(rec)) = rec
320 && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
321 {
322 let market = trd_market_str(parsed.c2s.header.trd_market);
323 let ctx = CheckCtx {
324 market: market.to_string(),
325 symbol: String::new(),
326 order_value: None,
327 trd_side: None,
328 acc_id: Some(parsed.c2s.header.acc_id),
329 mutation_no_exposure: true,
330 currency: futu_auth::market_to_currency(market).map(String::from),
331 };
332 let now = chrono::Utc::now();
333 let outcome = state
334 .counters
335 .check_full_skip_rate(&rec.id, rec.as_ref(), &ctx, now);
336 if let Some(reason) = outcome.reason() {
337 return Err(limit_reject_response(
338 "/api/cancel-order",
339 &rec,
340 &reason,
341 outcome.http_status_code(),
342 ));
343 }
344 }
345 let cancel_spec = surface_spec_or_internal_error("/api/cancel-order", "CancelOrder")?;
346 let idem_key = idempotency_key_from_headers(&headers);
347 adapter::proto_request_with_surface_spec_and_idempotency::<
348 trd_modify_order::Request,
349 trd_modify_order::Response,
350 >(
351 &state,
352 proto_id::TRD_MODIFY_ORDER,
353 Some(body),
354 idem_key,
355 cancel_spec,
356 )
357 .await
358}
359
360fn cancel_order_alias_error(message: String) -> (StatusCode, Json<Value>) {
361 (
362 StatusCode::BAD_REQUEST,
363 Json(serde_json::json!({
364 "ret_type": -1,
365 "ret_msg": message,
366 "error": message,
367 })),
368 )
369}
370
371pub async fn reconfirm_order(
379 State(state): State<RestState>,
380 rec: Option<Extension<Arc<KeyRecord>>>,
381 headers: HeaderMap,
382 Json(mut body): Json<Value>,
383) -> ApiResult {
384 crate::adapter::normalize_json_keys_snake_case(&mut body);
385 authorize_trade_write(
386 &state,
387 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
388 &body,
389 "/api/reconfirm-order",
390 )?;
391 normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/reconfirm-order")?;
392 read_handler_acc_id_check(
393 &state,
394 rec.as_deref().map(|r| r.as_ref()),
395 &body,
396 "/api/reconfirm-order",
397 )?;
398 validate_header_trd_market_write(&body, "/api/reconfirm-order")?;
401 if let Some(Extension(rec)) = rec
402 && let Ok(parsed) = serde_json::from_value::<trd_reconfirm_order::Request>(body.clone())
403 {
404 let market = trd_market_str(parsed.c2s.header.trd_market);
405 let ctx = CheckCtx {
406 market: market.to_string(),
407 symbol: String::new(),
408 order_value: None,
409 trd_side: None,
410 acc_id: Some(parsed.c2s.header.acc_id),
411 mutation_no_exposure: true,
412 currency: futu_auth::market_to_currency(market).map(String::from),
413 };
414 let now = chrono::Utc::now();
415 let outcome = state
416 .counters
417 .check_full_skip_rate(&rec.id, rec.as_ref(), &ctx, now);
418 if let Some(reason) = outcome.reason() {
419 return Err(limit_reject_response(
420 "/api/reconfirm-order",
421 &rec,
422 &reason,
423 outcome.http_status_code(),
424 ));
425 }
426 }
427 let reconfirm_spec = surface_spec_or_internal_error("/api/reconfirm-order", "ReconfirmOrder")?;
428 let idem_key = idempotency_key_from_headers(&headers);
429 adapter::proto_request_with_surface_spec_and_idempotency::<
430 trd_reconfirm_order::Request,
431 trd_reconfirm_order::Response,
432 >(
433 &state,
434 proto_id::TRD_RECONFIRM_ORDER,
435 Some(body),
436 idem_key,
437 reconfirm_spec,
438 )
439 .await
440}