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, PlaceOrderOptions, PlaceOrderParams, TrdEnv,
13 TrdHeader, TrdMarket, 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 m = futu_trd::market::parse_trd_market(s).ok_or_else(|| {
174 anyhow::anyhow!(
175 "unknown trd market {:?} (write path 接 {}). fund markets 仅 read path 支持.",
176 s.trim().to_ascii_uppercase(),
177 futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
178 )
179 })?;
180 if let Some(label) = futu_trd::market::canonical_fund_trd_market_label(m) {
181 return bail_fund_market(label);
182 }
183 Ok(m)
184}
185
186fn bail_fund_market(label: &'static str) -> Result<TrdMarket> {
187 bail!(
188 "trd market {label} 仅支持 view-only read endpoints \
189 (positions/funds/cash-log/history-orders/history-fills); write 路径 \
190 (place_order/modify_order/cancel_order) 用对应主市场, daemon 自动按 \
191 持仓 broker 路由. v1.4.102 audit 26 F1 fix"
192 )
193}
194
195pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
196 futu_trd::parsing::parse_trd_env(s).ok_or_else(|| {
197 anyhow::anyhow!(
198 "unknown trd env {:?} ({})",
199 s.trim().to_ascii_lowercase(),
200 futu_trd::parsing::TRD_ENV_PARSE_CHOICES
201 )
202 })
203}
204
205pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
206 futu_trd::parsing::parse_trd_side(s).ok_or_else(|| {
207 anyhow::anyhow!(
208 "unknown trd side {:?} ({})",
209 s.trim().to_ascii_uppercase(),
210 futu_trd::parsing::TRD_SIDE_PARSE_CHOICES
211 )
212 })
213}
214
215pub fn parse_order_type(s: &str) -> Result<OrderType> {
216 futu_trd::parsing::parse_order_type(s).ok_or_else(|| {
217 anyhow::anyhow!(
218 "unknown order type {:?} ({})",
219 s.trim().to_ascii_uppercase(),
220 futu_trd::parsing::ORDER_TYPE_PARSE_CHOICES
221 )
222 })
223}
224
225pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
226 futu_trd::parsing::parse_modify_op(s).ok_or_else(|| {
227 anyhow::anyhow!(
228 "unknown modify op {:?} ({})",
229 s.trim().to_ascii_uppercase(),
230 futu_trd::parsing::MODIFY_OP_PARSE_CHOICES
231 )
232 })
233}
234
235fn build_header(
236 env: &str,
237 acc_id: u64,
238 market: &str,
239 jp_acc_type: Option<i32>,
240) -> Result<TrdHeader> {
241 Ok(TrdHeader {
242 trd_env: parse_trd_env(env)?,
243 acc_id,
244 trd_market: parse_trd_market(market)?,
245 jp_acc_type,
246 })
247}
248
249#[derive(Serialize)]
252struct PlaceOut {
253 order_id: u64,
254 env: &'static str,
255 market: String,
256 acc_id: u64,
257 side: String,
258 order_type: String,
259 code: String,
260 qty: f64,
261 price: Option<f64>,
262 time_in_force: Option<i32>,
263 fill_outside_rth: Option<bool>,
264 session: Option<i32>,
265 expire_time: Option<String>,
266}
267
268pub struct PlaceOrderInput<'a> {
269 pub env: &'a str,
270 pub acc_id: u64,
271 pub market: &'a str,
272 pub side: &'a str,
273 pub order_type: &'a str,
274 pub code: &'a str,
275 pub qty: f64,
276 pub price: Option<f64>,
277 pub time_in_force: Option<i32>,
278 pub fill_outside_rth: Option<bool>,
279 pub session: Option<i32>,
280 pub expire_time: Option<&'a str>,
281 pub jp_acc_type: Option<i32>,
282 pub idempotency_key: Option<String>,
283 pub stop_price: Option<f64>,
285 pub trail_type: Option<i32>,
286 pub trail_value: Option<f64>,
287 pub trail_spread: Option<f64>,
288}
289
290pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
291 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
292 let trd_side = parse_trd_side(input.side)?;
293 let ord_type = parse_order_type(input.order_type)?;
294
295 let params = PlaceOrderParams {
296 header: header.clone(),
297 trd_side,
298 order_type: ord_type,
299 code: input.code.to_string(),
300 qty: input.qty,
301 price: input.price,
302 adjust_price: None,
303 adjust_side_and_limit: None,
304 idempotency_key: input.idempotency_key,
305 aux_price: input.stop_price,
307 trail_type: input.trail_type,
308 trail_value: input.trail_value,
309 trail_spread: input.trail_spread,
310 };
311 let options = PlaceOrderOptions {
312 time_in_force: input.time_in_force,
313 fill_outside_rth: input.fill_outside_rth,
314 session: input.session,
315 expire_time: input.expire_time.map(str::to_string),
316 };
317 let res = futu_trd::place_order_with_options(client, ¶ms, &options).await?;
318
319 let out = PlaceOut {
320 order_id: res.order_id,
321 env: match header.trd_env {
322 TrdEnv::Simulate => "simulate",
323 TrdEnv::Real => "real",
324 _ => "unknown",
325 },
326 market: input.market.to_ascii_uppercase(),
327 acc_id: input.acc_id,
328 side: input.side.to_ascii_uppercase(),
329 order_type: input.order_type.to_ascii_uppercase(),
330 code: input.code.to_string(),
331 qty: input.qty,
332 price: input.price,
333 time_in_force: input.time_in_force,
334 fill_outside_rth: input.fill_outside_rth,
335 session: input.session,
336 expire_time: input.expire_time.map(str::to_string),
337 };
338 Ok(serde_json::to_string_pretty(&out)?)
339}
340
341#[derive(Serialize)]
344struct ModifyOut {
345 order_id: u64,
346 op: String,
347 env: &'static str,
348 qty: Option<f64>,
349 price: Option<f64>,
350}
351
352pub struct ModifyOrderInput<'a> {
353 pub env: &'a str,
354 pub acc_id: u64,
355 pub market: &'a str,
356 pub order_id: &'a str,
357 pub op: &'a str,
358 pub qty: Option<f64>,
359 pub price: Option<f64>,
360 pub jp_acc_type: Option<i32>,
361 pub idempotency_key: Option<String>,
362}
363
364struct ResolvedOrderIdArg {
365 order_id: u64,
366 order_id_ex: Option<String>,
367}
368
369fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
370 let trimmed = raw.trim();
371 if trimmed.is_empty() {
372 bail!("order_id must not be empty");
373 }
374
375 if trimmed.bytes().all(|b| b.is_ascii_digit()) {
379 return Ok(ResolvedOrderIdArg {
380 order_id: trimmed.parse::<u64>()?,
381 order_id_ex: None,
382 });
383 }
384
385 Ok(ResolvedOrderIdArg {
386 order_id: 0,
387 order_id_ex: Some(trimmed.to_string()),
388 })
389}
390
391fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
392 let trimmed = raw.trim();
393 if trimmed.is_empty() {
394 bail!("{field} must not be empty");
395 }
396 if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
397 bail!(
398 "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
399 orderIDEx is not supported by Trd_ReconfirmOrder"
400 );
401 }
402 Ok(trimmed.parse::<u64>()?)
403}
404
405pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
406 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
407 let mop = parse_modify_op(input.op)?;
408 let resolved_order_id = resolve_order_id_arg(input.order_id)?;
409
410 let params = ModifyOrderParams {
411 header: header.clone(),
412 order_id: resolved_order_id.order_id,
413 order_id_ex: resolved_order_id.order_id_ex,
414 modify_order_op: mop,
415 qty: input.qty,
416 price: input.price,
417 for_all: None,
418 idempotency_key: input.idempotency_key,
419 };
420 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
421
422 let out = ModifyOut {
423 order_id: returned_id,
424 op: input.op.to_ascii_uppercase(),
425 env: match header.trd_env {
426 TrdEnv::Simulate => "simulate",
427 TrdEnv::Real => "real",
428 _ => "unknown",
429 },
430 qty: input.qty,
431 price: input.price,
432 };
433 Ok(serde_json::to_string_pretty(&out)?)
434}
435
436#[derive(Serialize)]
439struct CancelOut {
440 order_id: u64,
441 op: &'static str,
442 env: &'static str,
443}
444
445pub async fn cancel_order(
446 client: &Arc<FutuClient>,
447 env: &str,
448 acc_id: u64,
449 market: &str,
450 order_id: &str,
451 jp_acc_type: Option<i32>,
452 idempotency_key: Option<String>,
453) -> Result<String> {
454 let header = build_header(env, acc_id, market, jp_acc_type)?;
455 let resolved_order_id = resolve_order_id_arg(order_id)?;
456 let params = ModifyOrderParams {
459 header: header.clone(),
460 order_id: resolved_order_id.order_id,
461 order_id_ex: resolved_order_id.order_id_ex,
462 modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
463 qty: None,
464 price: None,
465 for_all: None,
466 idempotency_key,
467 };
468 let returned_id = futu_trd::order::modify_order(client, ¶ms).await?;
469 let out = CancelOut {
470 order_id: returned_id,
471 op: "CANCEL",
472 env: match header.trd_env {
473 TrdEnv::Simulate => "simulate",
474 TrdEnv::Real => "real",
475 _ => "unknown",
476 },
477 };
478 Ok(serde_json::to_string_pretty(&out)?)
479}
480
481#[derive(Serialize)]
484struct ReconfirmOut {
485 order_id: u64,
486 reason: i32,
487 env: &'static str,
488}
489
490pub struct ReconfirmOrderInput<'a> {
491 pub env: &'a str,
492 pub acc_id: u64,
493 pub market: &'a str,
494 pub order_id: &'a str,
495 pub reason: i32,
496 pub jp_acc_type: Option<i32>,
497}
498
499pub async fn reconfirm_order(
500 client: &Arc<FutuClient>,
501 input: ReconfirmOrderInput<'_>,
502) -> Result<String> {
503 let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
504 let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
505 let returned_id =
506 futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
507 let out = ReconfirmOut {
508 order_id: returned_id,
509 reason: input.reason,
510 env: match header.trd_env {
511 TrdEnv::Simulate => "simulate",
512 TrdEnv::Real => "real",
513 _ => "unknown",
514 },
515 };
516 Ok(serde_json::to_string_pretty(&out)?)
517}
518
519#[derive(Serialize)]
520struct CancelAllOut {
521 op: &'static str,
522 env: &'static str,
523 acc_id: u64,
524 market: String,
525}
526
527pub async fn cancel_all_order(
531 client: &Arc<FutuClient>,
532 env: &str,
533 acc_id: u64,
534 market: &str,
535) -> Result<String> {
536 let header = build_header(env, acc_id, market, None)?;
537 let params = ModifyOrderParams {
538 header: header.clone(),
539 order_id: 0,
540 order_id_ex: None,
541 modify_order_op: ModifyOrderOp::Cancel,
542 qty: None,
543 price: None,
544 for_all: Some(true),
545 idempotency_key: None,
546 };
547 futu_trd::order::modify_order(client, ¶ms).await?;
548 let out = CancelAllOut {
549 op: "CANCEL_ALL",
550 env: match header.trd_env {
551 TrdEnv::Simulate => "simulate",
552 TrdEnv::Real => "real",
553 _ => "unknown",
554 },
555 acc_id,
556 market: market.to_string(),
557 };
558 Ok(serde_json::to_string_pretty(&out)?)
559}
560
561pub fn is_real_env(env: &str) -> bool {
565 matches!(env.trim().to_ascii_lowercase().as_str(), "real")
566}
567
568#[cfg(test)]
571mod tests;