Skip to main content

futucli/cmd/proto_json/
contract.rs

1//! Proto-JSON c2s 合同层: generated `C2S` JSON 解析 / surface-spec 合同校验 /
2//! combo 必填字段与写市场校验(v1.8.0 large-file split, 纯搬移自 `proto_json.rs`).
3
4use 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}