1use std::sync::Arc;
7
8use anyhow::{Result, bail};
9use futu_core::account_locator;
10use futu_net::client::FutuClient;
11use futu_trd::types::{
12 ModifyOrderOp, ModifyOrderParams, OrderType, PlaceOrderParams, TrdEnv, TrdHeader, TrdMarket,
13 TrdSide,
14};
15use serde::Serialize;
16
17pub(crate) fn match_card_num_in_accounts(
35 accs: &[futu_trd::account::TrdAcc],
36 card_num: &str,
37 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
38) -> Vec<u64> {
39 account_locator::match_card_num_in_records(accs, card_num, caller_allowed_acc_ids)
40 .unwrap_or_default()
41}
42
43pub async fn resolve_card_num_via_get_acc_list(
60 client: &Arc<FutuClient>,
61 card_num: &str,
62 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
63) -> std::result::Result<u64, String> {
64 let trimmed = match account_locator::validate_card_num_query(card_num) {
65 Ok(v) => v,
66 Err(e) => {
67 return Err(format!(
68 "card_num 格式无效 — 必须 4 位末尾 (App 显示) 或 16 位完整, 纯数字; got len={}",
69 e.len()
70 ));
71 }
72 };
73 let accs = futu_trd::account::get_acc_list_for_account_discovery(client)
74 .await
75 .map_err(|e| format!("get_acc_list (resolve card_num) failed: {e}"))?;
76 let matches = match_card_num_in_accounts(&accs, trimmed, caller_allowed_acc_ids);
77 let visible_count = accs
80 .iter()
81 .filter(|a| account_locator::acc_id_visible_to_caller(a.acc_id, caller_allowed_acc_ids))
82 .count();
83 match account_locator::CardNumResolution::from_acc_ids(matches) {
84 account_locator::CardNumResolution::NotFound => Err(format!(
85 "card_num '{}' 找不到对应账户 (你这个 key 可见 {} 个账户). 检查 daemon 是否登录正确平台 (futunn vs moomoo) + card_num 是否正确 + key 的 allowed_acc_ids 配置",
86 account_locator::redact_card_num(trimmed),
87 visible_count
88 )),
89 account_locator::CardNumResolution::Resolved(only) => Ok(only),
90 account_locator::CardNumResolution::Ambiguous(many) => Err(format!(
91 "card_num '{}' 匹配 {} 个账户 (ambiguous) — 4 位 suffix 在多账户下可能碰撞. 改用 16 位完整卡号 (`futu_list_accounts` 查 card_num 字段)或直接传 acc_id",
92 account_locator::redact_card_num(trimmed),
93 many.len()
94 )),
95 }
96}
97
98pub async fn resolve_acc_id_with_card_num(
125 client: &Arc<FutuClient>,
126 acc_id: u64,
127 card_num: Option<&str>,
128 allowed_card_nums: Option<&[String]>,
129 caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
130) -> std::result::Result<u64, String> {
131 match card_num {
132 None => {
133 if acc_id == 0 {
134 Err(
135 "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(),
136 )
137 } else {
138 Ok(acc_id)
139 }
140 }
141 Some(cn) => {
142 if let Some(allowed) = allowed_card_nums
146 && !allowed.is_empty()
147 {
148 let trimmed = cn.trim();
149 if !account_locator::card_num_allowed_by_whitelist(trimmed, allowed) {
150 return Err(
151 "card_num 不在你这个 API key 的 allowed_card_nums 白名单里. \
152 检查 keys.json 你的 key 配置, 或改用 acc_id 直接传."
153 .to_string(),
154 );
155 }
156 }
157 let resolved =
158 resolve_card_num_via_get_acc_list(client, cn, caller_allowed_acc_ids).await?;
159 if acc_id == 0 || acc_id == resolved {
160 Ok(resolved)
161 } else {
162 Err(format!(
163 "acc_id ({acc_id}) and card_num resolution ({resolved}) mismatch — pass only one or ensure they reference the same account"
164 ))
165 }
166 }
167 }
168}
169
170pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
173 let trimmed = s.trim();
181 let upper = trimmed.to_ascii_uppercase();
182 let m = match upper.as_str() {
183 "HK" | "1" => TrdMarket::HK,
184 "US" | "2" => TrdMarket::US,
185 "CN" | "3" => TrdMarket::CN,
186 "HKCC" | "4" => TrdMarket::HKCC,
187 "FUTURES" | "5" => TrdMarket::Futures,
188 "SG" | "6" => TrdMarket::SG,
189 "CRYPTO" | "7" => TrdMarket::Crypto,
190 "AU" | "8" => TrdMarket::AU,
191 "FUTURES_SIMULATE_HK" | "FUTURESSIMULATEHK" | "10" => TrdMarket::FuturesSimulateHK,
192 "FUTURES_SIMULATE_US" | "FUTURESSIMULATEUS" | "11" => TrdMarket::FuturesSimulateUS,
193 "FUTURES_SIMULATE_SG" | "FUTURESSIMULATESG" | "12" => TrdMarket::FuturesSimulateSG,
194 "FUTURES_SIMULATE_JP" | "FUTURESSIMULATEJP" | "13" => TrdMarket::FuturesSimulateJP,
195 "JP" | "15" => TrdMarket::JP,
196 "MY" | "111" => TrdMarket::MY,
197 "CA" | "112" => TrdMarket::CA,
198 "HKFUND" | "HK_FUND" | "113" => return bail_fund_market("HKFund"),
199 "USFUND" | "US_FUND" | "123" => return bail_fund_market("USFund"),
200 "SGFUND" | "SG_FUND" | "124" => return bail_fund_market("SGFund"),
201 "MYFUND" | "MY_FUND" | "125" => return bail_fund_market("MYFund"),
202 "JPFUND" | "JP_FUND" | "126" => return bail_fund_market("JPFund"),
203 other => bail!(
204 "unknown trd market {other:?} \
205 (write path 接 HK|US|CN|HKCC|FUTURES|SG|CRYPTO|AU|FUTURES_SIMULATE_HK|\
206 FUTURES_SIMULATE_US|FUTURES_SIMULATE_SG|FUTURES_SIMULATE_JP|JP|MY|CA \
207 or official non-fund TrdMarket int). fund markets 仅 read path 支持."
208 ),
209 };
210 Ok(m)
211}
212
213fn bail_fund_market(label: &'static str) -> Result<TrdMarket> {
214 bail!(
215 "trd market {label} 仅支持 view-only read endpoints \
216 (positions/funds/cash-log/history-orders/history-fills); write 路径 \
217 (place_order/modify_order/cancel_order) 用对应主市场, daemon 自动按 \
218 持仓 broker 路由. v1.4.102 audit 26 F1 fix"
219 )
220}
221
222pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
223 let e = match s.trim().to_ascii_lowercase().as_str() {
224 "simulate" | "sim" => TrdEnv::Simulate,
225 "real" => TrdEnv::Real,
226 other => bail!("unknown trd env {other:?} (real|simulate)"),
227 };
228 Ok(e)
229}
230
231pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
232 let v = match s.trim().to_ascii_uppercase().as_str() {
233 "BUY" => TrdSide::Buy,
234 "SELL" => TrdSide::Sell,
235 "SELL_SHORT" | "SHORT" => TrdSide::SellShort,
236 "BUY_BACK" | "COVER" => TrdSide::BuyBack,
237 other => bail!("unknown trd side {other:?} (BUY|SELL|SELL_SHORT|BUY_BACK)"),
238 };
239 Ok(v)
240}
241
242pub fn parse_order_type(s: &str) -> Result<OrderType> {
243 let trimmed = s.trim();
249 let upper = trimmed.to_ascii_uppercase();
250 let v = match upper.as_str() {
251 "NORMAL" | "LIMIT" | "1" => OrderType::Normal,
252 "MARKET" | "2" => OrderType::Market,
253 "ABSOLUTE_LIMIT" | "ABSOLUTELIMIT" | "5" => OrderType::AbsoluteLimit,
254 "AUCTION" | "6" => OrderType::Auction,
255 "AUCTION_LIMIT" | "AUCTIONLIMIT" | "7" => OrderType::AuctionLimit,
256 "SPECIAL_LIMIT" | "SPECIALLIMIT" | "8" => OrderType::SpecialLimit,
257 "SPECIAL_LIMIT_ALL" | "SPECIALLIMITALL" | "9" => OrderType::SpecialLimitAll,
258 "STOP" | "10" => OrderType::Stop,
260 "STOP_LIMIT" | "STOP-LIMIT" | "STOPLIMIT" | "11" => OrderType::StopLimit,
261 "MIT" | "MARKET_IF_TOUCHED" | "MARKETIFTOUCHED" | "12" => OrderType::MarketifTouched,
262 "LIT" | "LIMIT_IF_TOUCHED" | "LIMITIFTOUCHED" | "13" => OrderType::LimitifTouched,
263 "TRAIL" | "TRAILING_STOP" | "TRAILING-STOP" | "TRAILINGSTOP" | "14" => {
264 OrderType::TrailingStop
265 }
266 "TRAIL_LIMIT" | "TRAILING_STOP_LIMIT" | "TRAILINGSTOPLIMIT" | "15" => {
267 OrderType::TrailingStopLimit
268 }
269 "TWAP_MARKET" | "TWAPMARKET" | "16" => OrderType::TwapMarket,
271 "TWAP_LIMIT" | "TWAPLIMIT" | "17" => OrderType::TwapLimit,
272 "VWAP_MARKET" | "VWAPMARKET" | "18" => OrderType::VwapMarket,
273 "VWAP_LIMIT" | "VWAPLIMIT" | "19" => OrderType::VwapLimit,
274 other => bail!(
275 "unknown order type {other:?} \
276 (NORMAL|MARKET|ABSOLUTE_LIMIT|AUCTION|AUCTION_LIMIT|\
277 SPECIAL_LIMIT|SPECIAL_LIMIT_ALL|STOP|STOP_LIMIT|MIT|LIT|\
278 TRAILING_STOP|TRAILING_STOP_LIMIT|TWAP_MARKET|TWAP_LIMIT|\
279 VWAP_MARKET|VWAP_LIMIT \
280 or int 1/2/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19 per Trd_Common.proto)"
281 ),
282 };
283 Ok(v)
284}
285
286pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
287 let v = match s.trim().to_ascii_uppercase().as_str() {
288 "NORMAL" | "MODIFY" => ModifyOrderOp::Normal,
289 "CANCEL" => ModifyOrderOp::Cancel,
290 "DISABLE" => ModifyOrderOp::Disable,
291 "ENABLE" => ModifyOrderOp::Enable,
292 "DELETE" => ModifyOrderOp::Delete,
293 other => bail!("unknown modify op {other:?} (NORMAL|CANCEL|DISABLE|ENABLE|DELETE)"),
294 };
295 Ok(v)
296}
297
298fn build_header(
299 env: &str,
300 acc_id: u64,
301 market: &str,
302 jp_acc_type: Option<i32>,
303) -> Result<TrdHeader> {
304 Ok(TrdHeader {
305 trd_env: parse_trd_env(env)?,
306 acc_id,
307 trd_market: parse_trd_market(market)?,
308 jp_acc_type,
309 })
310}
311
312#[derive(Serialize)]
315struct PlaceOut {
316 order_id: u64,
317 env: &'static str,
318 market: String,
319 acc_id: u64,
320 side: String,
321 order_type: String,
322 code: String,
323 qty: f64,
324 price: Option<f64>,
325}
326
327pub struct PlaceOrderInput<'a> {
328 pub env: &'a str,
329 pub acc_id: u64,
330 pub market: &'a str,
331 pub side: &'a str,
332 pub order_type: &'a str,
333 pub code: &'a str,
334 pub qty: f64,
335 pub price: Option<f64>,
336 pub jp_acc_type: Option<i32>,
337 pub idempotency_key: Option<String>,
338 pub stop_price: Option<f64>,
340 pub trail_type: Option<i32>,
341 pub trail_value: Option<f64>,
342 pub trail_spread: Option<f64>,
343}
344
345pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
346 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
347 let trd_side = parse_trd_side(input.side)?;
348 let ord_type = parse_order_type(input.order_type)?;
349
350 let params = PlaceOrderParams {
351 header: header.clone(),
352 trd_side,
353 order_type: ord_type,
354 code: input.code.to_string(),
355 qty: input.qty,
356 price: input.price,
357 adjust_price: None,
358 adjust_side_and_limit: None,
359 idempotency_key: input.idempotency_key,
360 aux_price: input.stop_price,
362 trail_type: input.trail_type,
363 trail_value: input.trail_value,
364 trail_spread: input.trail_spread,
365 };
366 let res = futu_trd::order::place_order(client, ¶ms).await?;
367
368 let out = PlaceOut {
369 order_id: res.order_id,
370 env: match header.trd_env {
371 TrdEnv::Simulate => "simulate",
372 TrdEnv::Real => "real",
373 _ => "unknown",
374 },
375 market: input.market.to_ascii_uppercase(),
376 acc_id: input.acc_id,
377 side: input.side.to_ascii_uppercase(),
378 order_type: input.order_type.to_ascii_uppercase(),
379 code: input.code.to_string(),
380 qty: input.qty,
381 price: input.price,
382 };
383 Ok(serde_json::to_string_pretty(&out)?)
384}
385
386#[derive(Serialize)]
389struct ModifyOut {
390 order_id: u64,
391 op: String,
392 env: &'static str,
393 qty: Option<f64>,
394 price: Option<f64>,
395}
396
397pub struct ModifyOrderInput<'a> {
398 pub env: &'a str,
399 pub acc_id: u64,
400 pub market: &'a str,
401 pub order_id: &'a str,
402 pub op: &'a str,
403 pub qty: Option<f64>,
404 pub price: Option<f64>,
405 pub jp_acc_type: Option<i32>,
406 pub idempotency_key: Option<String>,
407}
408
409struct ResolvedOrderIdArg {
410 order_id: u64,
411 order_id_ex: Option<String>,
412}
413
414fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
415 let trimmed = raw.trim();
416 if trimmed.is_empty() {
417 bail!("order_id must not be empty");
418 }
419
420 if trimmed.bytes().all(|b| b.is_ascii_digit()) {
424 return Ok(ResolvedOrderIdArg {
425 order_id: trimmed.parse::<u64>()?,
426 order_id_ex: None,
427 });
428 }
429
430 Ok(ResolvedOrderIdArg {
431 order_id: 0,
432 order_id_ex: Some(trimmed.to_string()),
433 })
434}
435
436fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
437 let trimmed = raw.trim();
438 if trimmed.is_empty() {
439 bail!("{field} must not be empty");
440 }
441 if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
442 bail!(
443 "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
444 orderIDEx is not supported by Trd_ReconfirmOrder"
445 );
446 }
447 Ok(trimmed.parse::<u64>()?)
448}
449
450pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
451 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
452 let mop = parse_modify_op(input.op)?;
453 let resolved_order_id = resolve_order_id_arg(input.order_id)?;
454
455 let params = ModifyOrderParams {
456 header: header.clone(),
457 order_id: resolved_order_id.order_id,
458 order_id_ex: resolved_order_id.order_id_ex,
459 modify_order_op: mop,
460 qty: input.qty,
461 price: input.price,
462 for_all: None,
463 idempotency_key: input.idempotency_key,
464 };
465 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
466
467 let out = ModifyOut {
468 order_id: returned_id,
469 op: input.op.to_ascii_uppercase(),
470 env: match header.trd_env {
471 TrdEnv::Simulate => "simulate",
472 TrdEnv::Real => "real",
473 _ => "unknown",
474 },
475 qty: input.qty,
476 price: input.price,
477 };
478 Ok(serde_json::to_string_pretty(&out)?)
479}
480
481#[derive(Serialize)]
484struct CancelOut {
485 order_id: u64,
486 op: &'static str,
487 env: &'static str,
488}
489
490pub async fn cancel_order(
491 client: &Arc<FutuClient>,
492 env: &str,
493 acc_id: u64,
494 market: &str,
495 order_id: &str,
496 jp_acc_type: Option<i32>,
497 idempotency_key: Option<String>,
498) -> Result<String> {
499 let header = build_header(env, acc_id, market, jp_acc_type)?;
500 let resolved_order_id = resolve_order_id_arg(order_id)?;
501 let params = ModifyOrderParams {
504 header: header.clone(),
505 order_id: resolved_order_id.order_id,
506 order_id_ex: resolved_order_id.order_id_ex,
507 modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
508 qty: None,
509 price: None,
510 for_all: None,
511 idempotency_key,
512 };
513 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
514 let out = CancelOut {
515 order_id: returned_id,
516 op: "CANCEL",
517 env: match header.trd_env {
518 TrdEnv::Simulate => "simulate",
519 TrdEnv::Real => "real",
520 _ => "unknown",
521 },
522 };
523 Ok(serde_json::to_string_pretty(&out)?)
524}
525
526#[derive(Serialize)]
529struct ReconfirmOut {
530 order_id: u64,
531 reason: i32,
532 env: &'static str,
533}
534
535pub struct ReconfirmOrderInput<'a> {
536 pub env: &'a str,
537 pub acc_id: u64,
538 pub market: &'a str,
539 pub order_id: &'a str,
540 pub reason: i32,
541 pub jp_acc_type: Option<i32>,
542}
543
544pub async fn reconfirm_order(
545 client: &Arc<FutuClient>,
546 input: ReconfirmOrderInput<'_>,
547) -> Result<String> {
548 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
549 let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
550 let returned_id =
551 futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
552 let out = ReconfirmOut {
553 order_id: returned_id,
554 reason: input.reason,
555 env: match header.trd_env {
556 TrdEnv::Simulate => "simulate",
557 TrdEnv::Real => "real",
558 _ => "unknown",
559 },
560 };
561 Ok(serde_json::to_string_pretty(&out)?)
562}
563
564#[derive(Serialize)]
565struct CancelAllOut {
566 op: &'static str,
567 env: &'static str,
568 acc_id: u64,
569 market: String,
570}
571
572pub async fn cancel_all_order(
576 client: &Arc<FutuClient>,
577 env: &str,
578 acc_id: u64,
579 market: &str,
580) -> Result<String> {
581 let header = build_header(env, acc_id, market, None)?;
582 let params = ModifyOrderParams {
583 header: header.clone(),
584 order_id: 0,
585 order_id_ex: None,
586 modify_order_op: ModifyOrderOp::Cancel,
587 qty: None,
588 price: None,
589 for_all: Some(true),
590 idempotency_key: None,
591 };
592 futu_trd::order::modify_order(client, ¶ms).await?;
593 let out = CancelAllOut {
594 op: "CANCEL_ALL",
595 env: match header.trd_env {
596 TrdEnv::Simulate => "simulate",
597 TrdEnv::Real => "real",
598 _ => "unknown",
599 },
600 acc_id,
601 market: market.to_string(),
602 };
603 Ok(serde_json::to_string_pretty(&out)?)
604}
605
606pub fn is_real_env(env: &str) -> bool {
610 matches!(env.trim().to_ascii_lowercase().as_str(), "real")
611}
612
613#[cfg(test)]
616mod tests;