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();
183 let upper = trimmed.to_ascii_uppercase();
184 let m = match upper.as_str() {
185 "HK" | "1" => TrdMarket::HK,
186 "US" | "2" => TrdMarket::US,
187 "CN" | "3" => TrdMarket::CN,
188 "HKCC" | "4" => TrdMarket::HKCC,
189 "FUTURES" | "5" => TrdMarket::Futures,
190 "SG" | "6" => TrdMarket::SG,
191 "AU" | "8" => TrdMarket::AU,
192 "JP" | "15" => TrdMarket::JP,
193 "MY" | "111" => TrdMarket::MY,
194 "CA" | "112" => TrdMarket::CA,
195 "HKFUND" | "HK_FUND" | "113" => bail!(
197 "trd market HKFUND (113) 仅支持 view-only read endpoints \
198 (positions/funds/cash-log/history-orders/history-fills); write 路径 \
199 (place_order/modify_order/cancel_order) 用主市场 HK=1, daemon 自动按 \
200 持仓 broker 路由. v1.4.102 codex 26 F1 fix"
201 ),
202 "USFUND" | "US_FUND" | "123" => bail!(
203 "trd market USFUND (123) 仅支持 view-only read endpoints \
204 (positions/funds/cash-log/history-orders/history-fills); write 路径 \
205 (place_order/modify_order/cancel_order) 用主市场 US=2, daemon 自动按 \
206 持仓 broker 路由. v1.4.102 codex 26 F1 fix"
207 ),
208 other => bail!(
209 "unknown trd market {other:?} \
210 (write path 接 HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA \
211 or int 1/2/3/4/5/6/8/15/111/112 per Trd_Common.proto). \
212 fund market HKFUND/USFUND 仅 read path 支持."
213 ),
214 };
215 Ok(m)
216}
217
218pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
219 let e = match s.trim().to_ascii_lowercase().as_str() {
220 "simulate" | "sim" => TrdEnv::Simulate,
221 "real" => TrdEnv::Real,
222 other => bail!("unknown trd env {other:?} (real|simulate)"),
223 };
224 Ok(e)
225}
226
227pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
228 let v = match s.trim().to_ascii_uppercase().as_str() {
229 "BUY" => TrdSide::Buy,
230 "SELL" => TrdSide::Sell,
231 "SELL_SHORT" | "SHORT" => TrdSide::SellShort,
232 "BUY_BACK" | "COVER" => TrdSide::BuyBack,
233 other => bail!("unknown trd side {other:?} (BUY|SELL|SELL_SHORT|BUY_BACK)"),
234 };
235 Ok(v)
236}
237
238pub fn parse_order_type(s: &str) -> Result<OrderType> {
239 let trimmed = s.trim();
245 let upper = trimmed.to_ascii_uppercase();
246 let v = match upper.as_str() {
247 "NORMAL" | "LIMIT" | "1" => OrderType::Normal,
248 "MARKET" | "2" => OrderType::Market,
249 "ABSOLUTE_LIMIT" | "ABSOLUTELIMIT" | "5" => OrderType::AbsoluteLimit,
250 "AUCTION" | "6" => OrderType::Auction,
251 "AUCTION_LIMIT" | "AUCTIONLIMIT" | "7" => OrderType::AuctionLimit,
252 "SPECIAL_LIMIT" | "SPECIALLIMIT" | "8" => OrderType::SpecialLimit,
253 "SPECIAL_LIMIT_ALL" | "SPECIALLIMITALL" | "9" => OrderType::SpecialLimitAll,
254 "STOP" | "10" => OrderType::Stop,
256 "STOP_LIMIT" | "STOP-LIMIT" | "STOPLIMIT" | "11" => OrderType::StopLimit,
257 "MIT" | "MARKET_IF_TOUCHED" | "MARKETIFTOUCHED" | "12" => OrderType::MarketifTouched,
258 "LIT" | "LIMIT_IF_TOUCHED" | "LIMITIFTOUCHED" | "13" => OrderType::LimitifTouched,
259 "TRAIL" | "TRAILING_STOP" | "TRAILING-STOP" | "TRAILINGSTOP" | "14" => {
260 OrderType::TrailingStop
261 }
262 "TRAIL_LIMIT" | "TRAILING_STOP_LIMIT" | "TRAILINGSTOPLIMIT" | "15" => {
263 OrderType::TrailingStopLimit
264 }
265 "TWAP_MARKET" | "TWAPMARKET" | "16" => OrderType::TwapMarket,
267 "TWAP_LIMIT" | "TWAPLIMIT" | "17" => OrderType::TwapLimit,
268 "VWAP_MARKET" | "VWAPMARKET" | "18" => OrderType::VwapMarket,
269 "VWAP_LIMIT" | "VWAPLIMIT" | "19" => OrderType::VwapLimit,
270 other => bail!(
271 "unknown order type {other:?} \
272 (NORMAL|MARKET|ABSOLUTE_LIMIT|AUCTION|AUCTION_LIMIT|\
273 SPECIAL_LIMIT|SPECIAL_LIMIT_ALL|STOP|STOP_LIMIT|MIT|LIT|\
274 TRAILING_STOP|TRAILING_STOP_LIMIT|TWAP_MARKET|TWAP_LIMIT|\
275 VWAP_MARKET|VWAP_LIMIT \
276 or int 1/2/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19 per Trd_Common.proto)"
277 ),
278 };
279 Ok(v)
280}
281
282pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
283 let v = match s.trim().to_ascii_uppercase().as_str() {
284 "NORMAL" | "MODIFY" => ModifyOrderOp::Normal,
285 "CANCEL" => ModifyOrderOp::Cancel,
286 "DISABLE" => ModifyOrderOp::Disable,
287 "ENABLE" => ModifyOrderOp::Enable,
288 "DELETE" => ModifyOrderOp::Delete,
289 other => bail!("unknown modify op {other:?} (NORMAL|CANCEL|DISABLE|ENABLE|DELETE)"),
290 };
291 Ok(v)
292}
293
294fn build_header(env: &str, acc_id: u64, market: &str) -> Result<TrdHeader> {
295 Ok(TrdHeader {
296 trd_env: parse_trd_env(env)?,
297 acc_id,
298 trd_market: parse_trd_market(market)?,
299 jp_acc_type: None,
300 })
301}
302
303#[derive(Serialize)]
306struct PlaceOut {
307 order_id: u64,
308 env: &'static str,
309 market: String,
310 acc_id: u64,
311 side: String,
312 order_type: String,
313 code: String,
314 qty: f64,
315 price: Option<f64>,
316}
317
318pub struct PlaceOrderInput<'a> {
319 pub env: &'a str,
320 pub acc_id: u64,
321 pub market: &'a str,
322 pub side: &'a str,
323 pub order_type: &'a str,
324 pub code: &'a str,
325 pub qty: f64,
326 pub price: Option<f64>,
327 pub idempotency_key: Option<String>,
328 pub stop_price: Option<f64>,
330 pub trail_type: Option<i32>,
331 pub trail_value: Option<f64>,
332 pub trail_spread: Option<f64>,
333}
334
335pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
336 let header = build_header(input.env, input.acc_id, input.market)?;
337 let trd_side = parse_trd_side(input.side)?;
338 let ord_type = parse_order_type(input.order_type)?;
339
340 let params = PlaceOrderParams {
341 header: header.clone(),
342 trd_side,
343 order_type: ord_type,
344 code: input.code.to_string(),
345 qty: input.qty,
346 price: input.price,
347 adjust_price: None,
348 adjust_side_and_limit: None,
349 idempotency_key: input.idempotency_key,
350 aux_price: input.stop_price,
352 trail_type: input.trail_type,
353 trail_value: input.trail_value,
354 trail_spread: input.trail_spread,
355 };
356 let res = futu_trd::order::place_order(client, ¶ms).await?;
357
358 let out = PlaceOut {
359 order_id: res.order_id,
360 env: match header.trd_env {
361 TrdEnv::Simulate => "simulate",
362 TrdEnv::Real => "real",
363 _ => "unknown",
364 },
365 market: input.market.to_ascii_uppercase(),
366 acc_id: input.acc_id,
367 side: input.side.to_ascii_uppercase(),
368 order_type: input.order_type.to_ascii_uppercase(),
369 code: input.code.to_string(),
370 qty: input.qty,
371 price: input.price,
372 };
373 Ok(serde_json::to_string_pretty(&out)?)
374}
375
376#[derive(Serialize)]
379struct ModifyOut {
380 order_id: u64,
381 op: String,
382 env: &'static str,
383 qty: Option<f64>,
384 price: Option<f64>,
385}
386
387pub struct ModifyOrderInput<'a> {
388 pub env: &'a str,
389 pub acc_id: u64,
390 pub market: &'a str,
391 pub order_id: &'a str,
392 pub op: &'a str,
393 pub qty: Option<f64>,
394 pub price: Option<f64>,
395 pub idempotency_key: Option<String>,
396}
397
398struct ResolvedOrderIdArg {
399 order_id: u64,
400 order_id_ex: Option<String>,
401}
402
403fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
404 let trimmed = raw.trim();
405 if trimmed.is_empty() {
406 bail!("order_id must not be empty");
407 }
408
409 if trimmed.bytes().all(|b| b.is_ascii_digit()) {
413 return Ok(ResolvedOrderIdArg {
414 order_id: trimmed.parse::<u64>()?,
415 order_id_ex: None,
416 });
417 }
418
419 Ok(ResolvedOrderIdArg {
420 order_id: 0,
421 order_id_ex: Some(trimmed.to_string()),
422 })
423}
424
425fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
426 let trimmed = raw.trim();
427 if trimmed.is_empty() {
428 bail!("{field} must not be empty");
429 }
430 if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
431 bail!(
432 "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
433 orderIDEx is not supported by Trd_ReconfirmOrder"
434 );
435 }
436 Ok(trimmed.parse::<u64>()?)
437}
438
439pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
440 let header = build_header(input.env, input.acc_id, input.market)?;
441 let mop = parse_modify_op(input.op)?;
442 let resolved_order_id = resolve_order_id_arg(input.order_id)?;
443
444 let params = ModifyOrderParams {
445 header: header.clone(),
446 order_id: resolved_order_id.order_id,
447 order_id_ex: resolved_order_id.order_id_ex,
448 modify_order_op: mop,
449 qty: input.qty,
450 price: input.price,
451 for_all: None,
452 idempotency_key: input.idempotency_key,
453 };
454 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
455
456 let out = ModifyOut {
457 order_id: returned_id,
458 op: input.op.to_ascii_uppercase(),
459 env: match header.trd_env {
460 TrdEnv::Simulate => "simulate",
461 TrdEnv::Real => "real",
462 _ => "unknown",
463 },
464 qty: input.qty,
465 price: input.price,
466 };
467 Ok(serde_json::to_string_pretty(&out)?)
468}
469
470#[derive(Serialize)]
473struct CancelOut {
474 order_id: u64,
475 op: &'static str,
476 env: &'static str,
477}
478
479pub async fn cancel_order(
480 client: &Arc<FutuClient>,
481 env: &str,
482 acc_id: u64,
483 market: &str,
484 order_id: &str,
485 idempotency_key: Option<String>,
486) -> Result<String> {
487 let header = build_header(env, acc_id, market)?;
488 let resolved_order_id = resolve_order_id_arg(order_id)?;
489 let params = ModifyOrderParams {
492 header: header.clone(),
493 order_id: resolved_order_id.order_id,
494 order_id_ex: resolved_order_id.order_id_ex,
495 modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
496 qty: None,
497 price: None,
498 for_all: None,
499 idempotency_key,
500 };
501 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
502 let out = CancelOut {
503 order_id: returned_id,
504 op: "CANCEL",
505 env: match header.trd_env {
506 TrdEnv::Simulate => "simulate",
507 TrdEnv::Real => "real",
508 _ => "unknown",
509 },
510 };
511 Ok(serde_json::to_string_pretty(&out)?)
512}
513
514#[derive(Serialize)]
517struct ReconfirmOut {
518 order_id: u64,
519 reason: i32,
520 env: &'static str,
521}
522
523pub struct ReconfirmOrderInput<'a> {
524 pub env: &'a str,
525 pub acc_id: u64,
526 pub market: &'a str,
527 pub order_id: &'a str,
528 pub reason: i32,
529}
530
531pub async fn reconfirm_order(
532 client: &Arc<FutuClient>,
533 input: ReconfirmOrderInput<'_>,
534) -> Result<String> {
535 let header = build_header(input.env, input.acc_id, input.market)?;
536 let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
537 let returned_id =
538 futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
539 let out = ReconfirmOut {
540 order_id: returned_id,
541 reason: input.reason,
542 env: match header.trd_env {
543 TrdEnv::Simulate => "simulate",
544 TrdEnv::Real => "real",
545 _ => "unknown",
546 },
547 };
548 Ok(serde_json::to_string_pretty(&out)?)
549}
550
551#[derive(Serialize)]
552struct CancelAllOut {
553 op: &'static str,
554 env: &'static str,
555 acc_id: u64,
556 market: String,
557}
558
559pub async fn cancel_all_order(
563 client: &Arc<FutuClient>,
564 env: &str,
565 acc_id: u64,
566 market: &str,
567) -> Result<String> {
568 let header = build_header(env, acc_id, market)?;
569 let params = ModifyOrderParams {
570 header: header.clone(),
571 order_id: 0,
572 order_id_ex: None,
573 modify_order_op: ModifyOrderOp::Cancel,
574 qty: None,
575 price: None,
576 for_all: Some(true),
577 idempotency_key: None,
578 };
579 futu_trd::order::modify_order(client, ¶ms).await?;
580 let out = CancelAllOut {
581 op: "CANCEL_ALL",
582 env: match header.trd_env {
583 TrdEnv::Simulate => "simulate",
584 TrdEnv::Real => "real",
585 _ => "unknown",
586 },
587 acc_id,
588 market: market.to_string(),
589 };
590 Ok(serde_json::to_string_pretty(&out)?)
591}
592
593pub fn is_real_env(env: &str) -> bool {
597 matches!(env.trim().to_ascii_lowercase().as_str(), "real")
598}
599
600#[cfg(test)]
603mod tests;