1use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU32, Ordering};
7
8use anyhow::{Result, anyhow, bail};
9use futu_net::client::FutuClient;
10use prost::Message;
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13
14use crate::tool_enums::{ToolEnum, TrdMarketEnum};
15
16static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
17
18#[derive(Debug, Clone)]
19pub struct ComboTradeContext {
20 pub env: &'static str,
21 pub acc_id: u64,
22 pub market: String,
23 pub order_value: Option<f64>,
24}
25
26pub fn parse_c2s_json<T>(label: &str, json: &str) -> Result<T>
27where
28 T: DeserializeOwned,
29{
30 let mut value: serde_json::Value =
31 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s_json: {err}"))?;
32 validate_proto_json_contract(label, &mut value)?;
33 serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
34}
35
36fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
37 let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
38 return Ok(());
39 };
40 futu_surface_spec::validate_and_normalize(spec, value)
41 .map_err(|err| anyhow!("{label} c2s_json: {err}"))
42}
43
44pub fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
45 parse_combo_c2s_json("combo-max-trd-qtys", json)
46}
47
48pub fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
49 parse_combo_c2s_json("combo-order", json)
50}
51
52fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
53where
54 T: DeserializeOwned,
55{
56 parse_c2s_json_with_required_paths(
57 label,
58 json,
59 &[
60 (["header", "trd_env"].as_slice(), "header.trd_env"),
61 (["header", "acc_id"].as_slice(), "header.acc_id"),
62 (["header", "trd_market"].as_slice(), "header.trd_market"),
63 (["combo_legs"].as_slice(), "combo_legs"),
64 (["qty"].as_slice(), "qty"),
65 (["order_type"].as_slice(), "order_type"),
66 ],
67 Some(validate_combo_trade_contract),
68 )
69}
70
71fn parse_c2s_json_with_required_paths<T>(
72 label: &str,
73 json: &str,
74 required_paths: &[(&[&str], &'static str)],
75 extra_validator: Option<fn(&str, &serde_json::Value) -> Result<()>>,
76) -> Result<T>
77where
78 T: DeserializeOwned,
79{
80 let value: serde_json::Value =
81 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s_json: {err}"))?;
82 for (path, name) in required_paths {
83 if json_path(&value, path).is_none() {
84 bail!("{label} c2s_json missing required field {name}");
85 }
86 }
87 if let Some(validate) = extra_validator {
88 validate(label, &value)?;
89 }
90 serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
91}
92
93fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
94 let mut current = value;
95 for segment in path {
96 current = current.get(*segment)?;
97 }
98 Some(current)
99}
100
101fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
102 match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
103 Some(acc_id) if acc_id > 0 => {}
104 _ => bail!("{label} c2s_json header.acc_id must be a positive integer"),
105 }
106
107 let legs = json_path(value, &["combo_legs"])
108 .and_then(serde_json::Value::as_array)
109 .ok_or_else(|| anyhow!("{label} c2s_json combo_legs must be an array"))?;
110 if legs.len() < 2 {
111 bail!("{label} c2s_json combo_legs must contain at least two legs");
112 }
113
114 match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
115 Some(qty) if qty > 0.0 => {}
116 _ => bail!("{label} c2s_json qty must be positive"),
117 }
118
119 let order_type = json_path(value, &["order_type"])
120 .and_then(serde_json::Value::as_i64)
121 .ok_or_else(|| anyhow!("{label} c2s_json order_type must be an integer"))?;
122 if i32::try_from(order_type).is_err() {
123 bail!("{label} c2s_json order_type={order_type} is out of range");
124 }
125
126 Ok(())
127}
128
129pub fn combo_max_context(c2s: &futu_proto::trd_get_combo_max_trd_qtys::C2s) -> Result<u64> {
130 if c2s.header.acc_id == 0 {
131 bail!("combo-max-trd-qtys header.acc_id is required");
132 }
133 trd_env_label(c2s.header.trd_env)?;
134 trd_write_market_label("combo-max-trd-qtys", c2s.header.trd_market)?;
135 Ok(c2s.header.acc_id)
136}
137
138pub fn place_combo_context(
139 c2s: &futu_proto::trd_place_combo_order::C2s,
140) -> Result<ComboTradeContext> {
141 if c2s.header.acc_id == 0 {
142 bail!("combo-order header.acc_id is required");
143 }
144 Ok(ComboTradeContext {
145 env: trd_env_label(c2s.header.trd_env)?,
146 acc_id: c2s.header.acc_id,
147 market: trd_write_market_label("combo-order", c2s.header.trd_market)?,
148 order_value: c2s.price.map(|price| price * c2s.qty),
149 })
150}
151
152pub async fn option_quote(
153 client: &Arc<FutuClient>,
154 c2s: futu_proto::qot_get_option_quote::C2s,
155) -> Result<String> {
156 let response: futu_proto::qot_get_option_quote::Response = send_proto(
157 client,
158 futu_core::proto_id::QOT_GET_OPTION_QUOTE,
159 futu_proto::qot_get_option_quote::Request { c2s },
160 )
161 .await?;
162 finish_response(
163 "option-quote",
164 response.ret_type,
165 response.ret_msg.as_deref(),
166 response.err_code,
167 &response,
168 )
169}
170
171pub async fn option_strategy(
172 client: &Arc<FutuClient>,
173 c2s: futu_proto::qot_get_option_strategy::C2s,
174) -> Result<String> {
175 let response: futu_proto::qot_get_option_strategy::Response = send_proto(
176 client,
177 futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
178 futu_proto::qot_get_option_strategy::Request { c2s },
179 )
180 .await?;
181 finish_response(
182 "option-strategy",
183 response.ret_type,
184 response.ret_msg.as_deref(),
185 response.err_code,
186 &response,
187 )
188}
189
190pub async fn option_strategy_analysis(
191 client: &Arc<FutuClient>,
192 c2s: futu_proto::qot_get_option_strategy_analysis::C2s,
193) -> Result<String> {
194 let response: futu_proto::qot_get_option_strategy_analysis::Response = send_proto(
195 client,
196 futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
197 futu_proto::qot_get_option_strategy_analysis::Request { c2s },
198 )
199 .await?;
200 finish_response(
201 "option-strategy-analysis",
202 response.ret_type,
203 response.ret_msg.as_deref(),
204 response.err_code,
205 &response,
206 )
207}
208
209pub async fn option_strategy_spread(
210 client: &Arc<FutuClient>,
211 c2s: futu_proto::qot_get_option_strategy_spread::C2s,
212) -> Result<String> {
213 let response: futu_proto::qot_get_option_strategy_spread::Response = send_proto(
214 client,
215 futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
216 futu_proto::qot_get_option_strategy_spread::Request { c2s },
217 )
218 .await?;
219 finish_response(
220 "option-strategy-spread",
221 response.ret_type,
222 response.ret_msg.as_deref(),
223 response.err_code,
224 &response,
225 )
226}
227
228pub async fn combo_max_trd_qtys(
229 client: &Arc<FutuClient>,
230 c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s,
231) -> Result<String> {
232 let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
233 client,
234 futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
235 futu_proto::trd_get_combo_max_trd_qtys::Request { c2s },
236 )
237 .await?;
238 finish_response(
239 "combo-max-trd-qtys",
240 response.ret_type,
241 response.ret_msg.as_deref(),
242 response.err_code,
243 &response,
244 )
245}
246
247pub async fn place_combo_order(
248 client: &Arc<FutuClient>,
249 mut c2s: futu_proto::trd_place_combo_order::C2s,
250 idempotency_key: Option<String>,
251) -> Result<String> {
252 c2s.packet_id = match idempotency_key.as_deref() {
253 Some(key) => packet_id_for_idempotency_key(key),
254 None => {
255 let conn_id = client
256 .conn_id()
257 .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
258 next_packet_id(conn_id)
259 }
260 };
261
262 let response: futu_proto::trd_place_combo_order::Response = send_proto(
263 client,
264 futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
265 futu_proto::trd_place_combo_order::Request { c2s },
266 )
267 .await?;
268 finish_response(
269 "combo-order",
270 response.ret_type,
271 response.ret_msg.as_deref(),
272 response.err_code,
273 &response,
274 )
275}
276
277async fn send_proto<Req, Resp>(
278 client: &Arc<FutuClient>,
279 proto_id: u32,
280 request: Req,
281) -> Result<Resp>
282where
283 Req: Message,
284 Resp: Message + Default,
285{
286 let frame = client.request(proto_id, request.encode_to_vec()).await?;
287 Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
288}
289
290fn finish_response<T: Serialize>(
291 label: &str,
292 ret_type: i32,
293 ret_msg: Option<&str>,
294 err_code: Option<i32>,
295 response: &T,
296) -> Result<String> {
297 if ret_type != 0 {
298 bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}");
299 }
300 Ok(serde_json::to_string_pretty(response)?)
301}
302
303fn trd_env_label(trd_env: i32) -> Result<&'static str> {
304 match trd_env {
305 0 => Ok("simulate"),
306 1 => Ok("real"),
307 other => {
308 bail!("unsupported combo-order header.trd_env={other}; expected 0 simulate or 1 real")
309 }
310 }
311}
312
313fn trd_write_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
314 if let Some(label) = futu_trd::market::view_only_fund_market_label(trd_market) {
315 bail!(
316 "{endpoint} header.trd_market={trd_market} ({label}) is view-only; \
317 use a write-capable main market for combo trade paths"
318 );
319 }
320 trd_market_label(endpoint, trd_market)
321}
322
323fn trd_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
324 let market = TrdMarketEnum::from_i32(trd_market)
325 .ok_or_else(|| anyhow!("unsupported {endpoint} header.trd_market={trd_market}"))?;
326 let int_values = TrdMarketEnum::all_int_values();
327 let string_values = TrdMarketEnum::all_string_values();
328 let idx = int_values
329 .iter()
330 .position(|&value| value == market.as_i32())
331 .ok_or_else(|| anyhow!("{endpoint} trd_market has no canonical label"))?;
332 Ok(string_values[idx].to_string())
333}
334
335fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
336 let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
337 futu_proto::common::PacketId { conn_id, serial_no }
338}
339
340fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
341 let mut hasher = DefaultHasher::new();
342 key.hash(&mut hasher);
343 futu_proto::common::PacketId {
344 conn_id: hasher.finish(),
345 serial_no: 0,
346 }
347}
348
349#[cfg(test)]
350mod tests;