futucli/cmd/proto_json/
contract.rs1use anyhow::{Result, anyhow, bail};
5use futu_core::{diagnostic_text::proto_json_numeric_qot_market_hint, trade_market};
6use serde::de::DeserializeOwned;
7
8pub(super) fn parse_c2s<T>(label: &str, json: &str) -> Result<T>
9where
10 T: DeserializeOwned,
11{
12 let mut value: serde_json::Value =
13 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
14 validate_proto_json_contract(label, &mut value)?;
15 deserialize_c2s_value(label, value)
16}
17
18fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
19 let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
20 return Ok(());
21 };
22 futu_surface_spec::validate_and_normalize(spec, value)
23 .map_err(|err| anyhow!("{label} c2s json: {err}"))
24}
25
26pub(super) fn parse_combo_max_c2s_json(
27 json: &str,
28) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
29 let c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s =
30 parse_combo_c2s_json("combo-max-trd-qtys", json)?;
31 ensure_write_trd_market("combo-max-trd-qtys", c2s.header.trd_market)?;
32 Ok(c2s)
33}
34
35pub(super) fn parse_place_combo_c2s_json(
36 json: &str,
37) -> Result<futu_proto::trd_place_combo_order::C2s> {
38 let c2s: futu_proto::trd_place_combo_order::C2s = parse_combo_c2s_json("combo-order", json)?;
39 ensure_write_trd_market("combo-order", c2s.header.trd_market)?;
40 Ok(c2s)
41}
42
43fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
44where
45 T: DeserializeOwned,
46{
47 parse_c2s_with_required_paths(
48 label,
49 json,
50 &[
51 (["header", "trd_env"].as_slice(), "header.trd_env"),
52 (["header", "acc_id"].as_slice(), "header.acc_id"),
53 (["header", "trd_market"].as_slice(), "header.trd_market"),
54 (["combo_legs"].as_slice(), "combo_legs"),
55 (["qty"].as_slice(), "qty"),
56 (["order_type"].as_slice(), "order_type"),
57 ],
58 Some(validate_combo_trade_contract),
59 )
60}
61
62fn parse_c2s_with_required_paths<T>(
63 label: &str,
64 json: &str,
65 required_paths: &[(&[&str], &'static str)],
66 extra_validator: Option<fn(&str, &serde_json::Value) -> Result<()>>,
67) -> Result<T>
68where
69 T: DeserializeOwned,
70{
71 let value: serde_json::Value =
72 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
73 for (path, name) in required_paths {
74 if json_path(&value, path).is_none() {
75 bail!("{label} c2s json missing required field {name}");
76 }
77 }
78 if let Some(validate) = extra_validator {
79 validate(label, &value)?;
80 }
81 deserialize_c2s_value(label, value)
82}
83
84fn deserialize_c2s_value<T>(label: &str, value: serde_json::Value) -> Result<T>
85where
86 T: DeserializeOwned,
87{
88 serde_json::from_value(value.clone()).map_err(|err| {
89 anyhow!(
90 "{label} c2s json: {err}{}",
91 proto_json_deserialize_hint(&value)
92 )
93 })
94}
95
96fn proto_json_deserialize_hint(value: &serde_json::Value) -> String {
97 if has_string_market_field(value) {
98 format!(
99 "; hint: {}",
100 proto_json_numeric_qot_market_hint().text_with_code()
101 )
102 } else {
103 String::new()
104 }
105}
106
107fn has_string_market_field(value: &serde_json::Value) -> bool {
108 match value {
109 serde_json::Value::Object(map) => map.iter().any(|(key, nested)| {
110 (key == "market" && nested.is_string()) || has_string_market_field(nested)
111 }),
112 serde_json::Value::Array(items) => items.iter().any(has_string_market_field),
113 _ => false,
114 }
115}
116
117fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
118 let mut current = value;
119 for segment in path {
120 current = current.get(*segment)?;
121 }
122 Some(current)
123}
124
125fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
126 match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
127 Some(acc_id) if acc_id > 0 => {}
128 _ => bail!("{label} c2s json header.acc_id must be a positive integer"),
129 }
130
131 let legs = json_path(value, &["combo_legs"])
132 .and_then(serde_json::Value::as_array)
133 .ok_or_else(|| anyhow!("{label} c2s json combo_legs must be an array"))?;
134 if legs.len() < 2 {
135 bail!("{label} c2s json combo_legs must contain at least two legs");
136 }
137
138 match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
139 Some(qty) if qty > 0.0 => {}
140 _ => bail!("{label} c2s json qty must be positive"),
141 }
142
143 let order_type = json_path(value, &["order_type"])
144 .and_then(serde_json::Value::as_i64)
145 .ok_or_else(|| anyhow!("{label} c2s json order_type must be an integer"))?;
146 if i32::try_from(order_type).is_err() {
147 bail!("{label} c2s json order_type={order_type} is out of range");
148 }
149
150 Ok(())
151}
152
153fn ensure_write_trd_market(label: &str, trd_market: i32) -> Result<()> {
154 if let Some(fund_label) = trade_market::canonical_fund_trd_market_label(trd_market) {
155 bail!(
156 "{label} header.trd_market={trd_market} ({fund_label}) is view-only; \
157 use a write-capable main market for combo trade paths"
158 );
159 }
160 if trade_market::trd_market_label(trd_market).is_none() {
161 bail!("{label} unsupported header.trd_market={trd_market}");
162 }
163 Ok(())
164}