1use std::sync::Arc;
7
8use anyhow::{Result, bail};
9use futu_core::{account_locator, trade_market, trade_parsing};
10use futu_net::client::FutuClient;
11use futu_trd::types::{
12 ModifyOrderOp, ModifyOrderParams, OrderType, PlaceOrderOptions, PlaceOrderParams, TrdEnv,
13 TrdHeader, TrdMarket, TrdSide,
14};
15use serde::Serialize;
16
17use crate::trd_sdk_adapter;
18
19pub(crate) fn match_card_num_in_accounts(
37 accs: &[futu_trd::account::TrdAcc],
38 card_num: &str,
39 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
40) -> Vec<u64> {
41 account_locator::match_card_num_in_records(accs, card_num, caller_allowed_acc_ids)
42 .unwrap_or_default()
43}
44
45pub async fn resolve_card_num_via_get_acc_list(
62 client: &Arc<FutuClient>,
63 card_num: &str,
64 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
65) -> std::result::Result<u64, String> {
66 let trimmed = match account_locator::validate_card_num_query(card_num) {
67 Ok(v) => v,
68 Err(e) => {
69 return Err(format!(
70 "card_num 格式无效 — 必须 4 位末尾 (App 显示) 或 16 位完整, 纯数字; got len={}",
71 e.len()
72 ));
73 }
74 };
75 let accs = futu_trd::account::get_acc_list_for_account_discovery(client)
76 .await
77 .map_err(|e| format!("get_acc_list (resolve card_num) failed: {e}"))?;
78 let matches = match_card_num_in_accounts(&accs, trimmed, caller_allowed_acc_ids);
79 let visible_count = accs
82 .iter()
83 .filter(|a| account_locator::acc_id_visible_to_caller(a.acc_id, caller_allowed_acc_ids))
84 .count();
85 match account_locator::CardNumResolution::from_acc_ids(matches) {
86 account_locator::CardNumResolution::NotFound => Err(format!(
87 "card_num '{}' 找不到对应账户 (你这个 key 可见 {} 个账户). 检查 daemon 是否登录正确平台 (futunn vs moomoo) + card_num 是否正确 + key 的 allowed_acc_ids 配置",
88 account_locator::redact_card_num(trimmed),
89 visible_count
90 )),
91 account_locator::CardNumResolution::Resolved(only) => Ok(only),
92 account_locator::CardNumResolution::Ambiguous(many) => Err(format!(
93 "card_num '{}' 匹配 {} 个账户 (ambiguous) — 4 位 suffix 在多账户下可能碰撞. 改用 16 位完整卡号 (`futu_list_accounts` 查 card_num 字段)或直接传 acc_id",
94 account_locator::redact_card_num(trimmed),
95 many.len()
96 )),
97 }
98}
99
100pub async fn resolve_acc_id_with_card_num(
127 client: &Arc<FutuClient>,
128 acc_id: u64,
129 card_num: Option<&str>,
130 allowed_card_nums: Option<&[String]>,
131 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
132) -> std::result::Result<u64, String> {
133 match card_num {
134 None => {
135 if acc_id == 0 {
136 Err(
137 "either acc_id or card_num is required — pass acc_id (call futu_list_accounts to discover) or card_num (4-digit App suffix or 16-digit full)".to_string(),
138 )
139 } else {
140 Ok(acc_id)
141 }
142 }
143 Some(cn) => {
144 if let Some(allowed) = allowed_card_nums
148 && !allowed.is_empty()
149 {
150 let trimmed = cn.trim();
151 if !account_locator::card_num_allowed_by_whitelist(trimmed, allowed) {
152 return Err(
153 "card_num 不在你这个 API key 的 allowed_card_nums 白名单里. \
154 检查 keys.json 你的 key 配置, 或改用 acc_id 直接传."
155 .to_string(),
156 );
157 }
158 }
159 let resolved =
160 resolve_card_num_via_get_acc_list(client, cn, caller_allowed_acc_ids).await?;
161 if acc_id == 0 || acc_id == resolved {
162 Ok(resolved)
163 } else {
164 Err(format!(
165 "acc_id ({acc_id}) and card_num resolution ({resolved}) mismatch — pass only one or ensure they reference the same account"
166 ))
167 }
168 }
169 }
170}
171
172pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
175 let market = trade_market::parse_non_fund_trd_market_id(s).ok_or_else(|| {
176 anyhow::anyhow!(
177 "unknown trd market {:?} (write path 接 {}). fund markets 仅 read path 支持.",
178 s.trim().to_ascii_uppercase(),
179 trade_market::TRD_MARKET_NON_FUND_PARSE_CHOICES
180 )
181 })?;
182 trd_sdk_adapter::trd_market_from_id(market).ok_or_else(|| {
183 anyhow::anyhow!("unknown trd market id {market} after core parser accepted it")
184 })
185}
186
187pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
188 let env = trade_parsing::parse_trd_env_id(s).ok_or_else(|| {
189 anyhow::anyhow!(
190 "unknown trd env {:?} ({})",
191 s.trim().to_ascii_lowercase(),
192 trade_parsing::TRD_ENV_PARSE_CHOICES
193 )
194 })?;
195 trd_sdk_adapter::trd_env_from_id(env)
196 .ok_or_else(|| anyhow::anyhow!("unknown trd env id {env} after core parser accepted it"))
197}
198
199pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
200 let side = trade_parsing::parse_trd_side_id(s).ok_or_else(|| {
201 anyhow::anyhow!(
202 "unknown trd side {:?} ({})",
203 s.trim().to_ascii_uppercase(),
204 trade_parsing::TRD_SIDE_PARSE_CHOICES
205 )
206 })?;
207 trd_sdk_adapter::trd_side_from_id(side)
208 .ok_or_else(|| anyhow::anyhow!("unknown trd side id {side} after core parser accepted it"))
209}
210
211pub fn parse_order_type(s: &str) -> Result<OrderType> {
212 let order_type = trade_parsing::parse_order_type_id(s).ok_or_else(|| {
213 anyhow::anyhow!(
214 "unknown order type {:?} ({})",
215 s.trim().to_ascii_uppercase(),
216 trade_parsing::ORDER_TYPE_PARSE_CHOICES
217 )
218 })?;
219 trd_sdk_adapter::order_type_from_id(order_type).ok_or_else(|| {
220 anyhow::anyhow!("unknown order type id {order_type} after core parser accepted it")
221 })
222}
223
224pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
225 let modify_op = trade_parsing::parse_modify_op_id(s).ok_or_else(|| {
226 anyhow::anyhow!(
227 "unknown modify op {:?} ({})",
228 s.trim().to_ascii_uppercase(),
229 trade_parsing::MODIFY_OP_PARSE_CHOICES
230 )
231 })?;
232 trd_sdk_adapter::modify_order_op_from_id(modify_op).ok_or_else(|| {
233 anyhow::anyhow!("unknown modify op id {modify_op} after core parser accepted it")
234 })
235}
236
237fn build_header(
238 env: &str,
239 acc_id: u64,
240 market: &str,
241 jp_acc_type: Option<i32>,
242) -> Result<TrdHeader> {
243 Ok(TrdHeader {
244 trd_env: parse_trd_env(env)?,
245 acc_id,
246 trd_market: parse_trd_market(market)?,
247 jp_acc_type,
248 })
249}
250
251#[derive(Serialize)]
254struct PlaceOut {
255 order_id: u64,
256 env: &'static str,
257 market: String,
258 acc_id: u64,
259 side: String,
260 order_type: String,
261 code: String,
262 qty: f64,
263 price: Option<f64>,
264 time_in_force: Option<i32>,
265 fill_outside_rth: Option<bool>,
266 session: Option<i32>,
267 expire_time: Option<String>,
268}
269
270pub struct PlaceOrderInput<'a> {
271 pub env: &'a str,
272 pub acc_id: u64,
273 pub market: &'a str,
274 pub side: &'a str,
275 pub order_type: &'a str,
276 pub code: &'a str,
277 pub qty: f64,
278 pub price: Option<f64>,
279 pub amount: Option<f64>,
280 pub pred_side: Option<i32>,
281 pub time_in_force: Option<i32>,
282 pub fill_outside_rth: Option<bool>,
283 pub session: Option<i32>,
284 pub expire_time: Option<&'a str>,
285 pub jp_acc_type: Option<i32>,
286 pub idempotency_key: Option<String>,
287 pub stop_price: Option<f64>,
289 pub trail_type: Option<i32>,
290 pub trail_value: Option<f64>,
291 pub trail_spread: Option<f64>,
292}
293
294fn place_order_options_from_input(input: &PlaceOrderInput<'_>) -> PlaceOrderOptions {
295 PlaceOrderOptions {
296 time_in_force: input.time_in_force,
297 fill_outside_rth: input.fill_outside_rth,
298 session: input.session,
299 expire_time: input.expire_time.map(str::to_string),
300 amount: input.amount,
301 pred_side: input.pred_side,
302 }
303}
304
305pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
306 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
307 let trd_side = parse_trd_side(input.side)?;
308 let ord_type = parse_order_type(input.order_type)?;
309 let options = place_order_options_from_input(&input);
310
311 let params = PlaceOrderParams {
312 header: header.clone(),
313 trd_side,
314 order_type: ord_type,
315 code: input.code.to_string(),
316 qty: input.qty,
317 price: input.price,
318 adjust_price: None,
319 adjust_side_and_limit: None,
320 idempotency_key: input.idempotency_key,
321 aux_price: input.stop_price,
323 trail_type: input.trail_type,
324 trail_value: input.trail_value,
325 trail_spread: input.trail_spread,
326 };
327 let res = futu_trd::place_order_with_options(client, ¶ms, &options).await?;
328
329 let out = PlaceOut {
330 order_id: res.order_id,
331 env: match header.trd_env {
332 TrdEnv::Simulate => "simulate",
333 TrdEnv::Real => "real",
334 _ => "unknown",
335 },
336 market: input.market.to_ascii_uppercase(),
337 acc_id: input.acc_id,
338 side: input.side.to_ascii_uppercase(),
339 order_type: input.order_type.to_ascii_uppercase(),
340 code: input.code.to_string(),
341 qty: input.qty,
342 price: input.price,
343 time_in_force: input.time_in_force,
344 fill_outside_rth: input.fill_outside_rth,
345 session: input.session,
346 expire_time: input.expire_time.map(str::to_string),
347 };
348 Ok(serde_json::to_string_pretty(&out)?)
349}
350
351#[derive(Serialize)]
354struct ModifyOut {
355 order_id: u64,
356 op: String,
357 env: &'static str,
358 qty: Option<f64>,
359 price: Option<f64>,
360}
361
362pub struct ModifyOrderInput<'a> {
363 pub env: &'a str,
364 pub acc_id: u64,
365 pub market: &'a str,
366 pub order_id: &'a str,
367 pub op: &'a str,
368 pub qty: Option<f64>,
369 pub price: Option<f64>,
370 pub jp_acc_type: Option<i32>,
371 pub idempotency_key: Option<String>,
372}
373
374struct ResolvedOrderIdArg {
375 order_id: u64,
376 order_id_ex: Option<String>,
377}
378
379fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
380 let trimmed = raw.trim();
381 if trimmed.is_empty() {
382 bail!("order_id must not be empty");
383 }
384
385 if trimmed.bytes().all(|b| b.is_ascii_digit()) {
389 return Ok(ResolvedOrderIdArg {
390 order_id: trimmed.parse::<u64>()?,
391 order_id_ex: None,
392 });
393 }
394
395 Ok(ResolvedOrderIdArg {
396 order_id: 0,
397 order_id_ex: Some(trimmed.to_string()),
398 })
399}
400
401fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
402 let trimmed = raw.trim();
403 if trimmed.is_empty() {
404 bail!("{field} must not be empty");
405 }
406 if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
407 bail!(
408 "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
409 orderIDEx is not supported by Trd_ReconfirmOrder"
410 );
411 }
412 Ok(trimmed.parse::<u64>()?)
413}
414
415pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
416 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
417 let mop = parse_modify_op(input.op)?;
418 let resolved_order_id = resolve_order_id_arg(input.order_id)?;
419
420 let params = ModifyOrderParams {
421 header: header.clone(),
422 order_id: resolved_order_id.order_id,
423 order_id_ex: resolved_order_id.order_id_ex,
424 modify_order_op: mop,
425 qty: input.qty,
426 price: input.price,
427 for_all: None,
428 idempotency_key: input.idempotency_key,
429 };
430 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
431
432 let out = ModifyOut {
433 order_id: returned_id,
434 op: input.op.to_ascii_uppercase(),
435 env: match header.trd_env {
436 TrdEnv::Simulate => "simulate",
437 TrdEnv::Real => "real",
438 _ => "unknown",
439 },
440 qty: input.qty,
441 price: input.price,
442 };
443 Ok(serde_json::to_string_pretty(&out)?)
444}
445
446#[derive(Serialize)]
449struct CancelOut {
450 order_id: u64,
451 op: &'static str,
452 env: &'static str,
453}
454
455pub async fn cancel_order(
456 client: &Arc<FutuClient>,
457 env: &str,
458 acc_id: u64,
459 market: &str,
460 order_id: &str,
461 jp_acc_type: Option<i32>,
462 idempotency_key: Option<String>,
463) -> Result<String> {
464 let header = build_header(env, acc_id, market, jp_acc_type)?;
465 let resolved_order_id = resolve_order_id_arg(order_id)?;
466 let params = ModifyOrderParams {
469 header: header.clone(),
470 order_id: resolved_order_id.order_id,
471 order_id_ex: resolved_order_id.order_id_ex,
472 modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
473 qty: None,
474 price: None,
475 for_all: None,
476 idempotency_key,
477 };
478 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
479 let out = CancelOut {
480 order_id: returned_id,
481 op: "CANCEL",
482 env: match header.trd_env {
483 TrdEnv::Simulate => "simulate",
484 TrdEnv::Real => "real",
485 _ => "unknown",
486 },
487 };
488 Ok(serde_json::to_string_pretty(&out)?)
489}
490
491#[derive(Serialize)]
494struct ReconfirmOut {
495 order_id: u64,
496 reason: i32,
497 env: &'static str,
498}
499
500pub struct ReconfirmOrderInput<'a> {
501 pub env: &'a str,
502 pub acc_id: u64,
503 pub market: &'a str,
504 pub order_id: &'a str,
505 pub reason: i32,
506 pub jp_acc_type: Option<i32>,
507}
508
509pub async fn reconfirm_order(
510 client: &Arc<FutuClient>,
511 input: ReconfirmOrderInput<'_>,
512) -> Result<String> {
513 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
514 let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
515 let returned_id =
516 futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
517 let out = ReconfirmOut {
518 order_id: returned_id,
519 reason: input.reason,
520 env: match header.trd_env {
521 TrdEnv::Simulate => "simulate",
522 TrdEnv::Real => "real",
523 _ => "unknown",
524 },
525 };
526 Ok(serde_json::to_string_pretty(&out)?)
527}
528
529#[derive(Serialize)]
530struct CancelAllOut {
531 op: &'static str,
532 env: &'static str,
533 acc_id: u64,
534 market: String,
535}
536
537pub async fn cancel_all_order(
541 client: &Arc<FutuClient>,
542 env: &str,
543 acc_id: u64,
544 market: &str,
545) -> Result<String> {
546 let header = build_header(env, acc_id, market, None)?;
547 let params = ModifyOrderParams {
548 header: header.clone(),
549 order_id: 0,
550 order_id_ex: None,
551 modify_order_op: ModifyOrderOp::Cancel,
552 qty: None,
553 price: None,
554 for_all: Some(true),
555 idempotency_key: None,
556 };
557 futu_trd::order::modify_order(client, ¶ms).await?;
558 let out = CancelAllOut {
559 op: "CANCEL_ALL",
560 env: match header.trd_env {
561 TrdEnv::Simulate => "simulate",
562 TrdEnv::Real => "real",
563 _ => "unknown",
564 },
565 acc_id,
566 market: market.to_string(),
567 };
568 Ok(serde_json::to_string_pretty(&out)?)
569}
570
571pub fn is_real_env(env: &str) -> bool {
575 matches!(env.trim().to_ascii_lowercase().as_str(), "real")
576}
577
578#[cfg(test)]
581mod tests;