Skip to main content

futu_mcp/tool_args/qot/
price_reminder.rs

1use rmcp::schemars;
2use serde::Deserialize;
3
4use futu_core::qot_price_reminder;
5
6use crate::tool_enums;
7
8fn parse_price_reminder_market(value: i32) -> Option<i32> {
9    qot_price_reminder::is_price_reminder_market(value).then_some(value)
10}
11
12fn parse_price_reminder_market_str(value: &str) -> Option<i32> {
13    let trimmed = value.trim();
14    if let Ok(value) = trimmed.parse::<i32>() {
15        return parse_price_reminder_market(value);
16    }
17    qot_price_reminder::price_reminder_market_from_str_alias(trimmed)
18}
19
20fn deser_price_reminder_market_as_option_i32<'de, D>(
21    deserializer: D,
22) -> Result<Option<i32>, D::Error>
23where
24    D: serde::Deserializer<'de>,
25{
26    let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
27    match raw {
28        None | Some(serde_json::Value::Null) => Ok(None),
29        Some(serde_json::Value::Number(number)) => {
30            let value = number.as_i64().ok_or_else(|| {
31                serde::de::Error::custom(format!("price reminder market number invalid: {number}"))
32            })? as i32;
33            parse_price_reminder_market(value).map(Some).ok_or_else(|| {
34                serde::de::Error::custom(format!(
35                    "unknown price reminder market int {value}: valid = {}",
36                    qot_price_reminder::PRICE_REMINDER_MARKET_VALID_VALUES
37                ))
38            })
39        }
40        Some(serde_json::Value::String(text)) => parse_price_reminder_market_str(&text)
41            .map(Some)
42            .ok_or_else(|| {
43                serde::de::Error::custom(format!(
44                    "unknown price reminder market {text:?}: valid = {}",
45                    qot_price_reminder::PRICE_REMINDER_MARKET_VALID_VALUES
46                ))
47            }),
48        Some(other) => Err(serde::de::Error::custom(format!(
49            "price reminder market must be int or string, got: {other}"
50        ))),
51    }
52}
53
54#[derive(Debug, Deserialize, schemars::JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct SetPriceReminderReq {
57    #[schemars(description = "Security symbol (e.g. \"HK.00700\"). Field aliases: \
58                       `code` / `stock` (deprecated — prefer canonical `symbol`).")]
59    #[serde(alias = "code", alias = "stock")]
60    pub symbol: String,
61    #[schemars(
62        description = "Op: 1=Add / SetAdd, 2=Del / SetDel, 3=Enable / SetEnable, \
63                       4=Disable / SetDisable, 5=Modify, 6=DeleteAll / DelAll. \
64                       Accepts integer code OR string form (e.g. 1 or \"Add\"). \
65                       Aliases for the field name: `op_type` / `operation` \
66                       (deprecated — prefer canonical `op`)."
67    )]
68    #[serde(
69        alias = "op_type",
70        alias = "operation",
71        deserialize_with = "tool_enums::deser_price_reminder_op_as_i32"
72    )]
73    pub op: i32,
74    #[schemars(
75        description = "Reminder key (from get_price_reminder; required for modify/del/enable/disable)"
76    )]
77    #[serde(default)]
78    pub key: Option<i64>,
79    #[schemars(description = "Qot_Common::PriceReminderType: \
80                       1=PriceUp, 2=PriceDown, 3=ChangeRateUp, 4=ChangeRateDown, \
81                       5=5MinChangeRateUp, 6=5MinChangeRateDown, 7=VolumeUp, 8=TurnoverUp, \
82                       9=TurnoverRateUp, 10=BidPriceUp, 11=AskPriceDown, 12=BidVolUp, \
83                       13=AskVolUp, 14=3MinChangeRateUp, 15=3MinChangeRateDown.")]
84    #[serde(default)]
85    pub reminder_type: Option<i32>,
86    #[schemars(
87        description = "Qot_Common::PriceReminderFreq: 1=Always, 2=OncePerDay, 3=Once. \
88                       Required for op=1 (Add) — the gateway rejects Add without freq. \
89                       Optional for op=5 (Modify) / 2/3/4 (Del/Enable/Disable) where the \
90                       backend preserves the existing value when omitted."
91    )]
92    #[serde(default)]
93    pub freq: Option<i32>,
94    #[schemars(description = "Threshold value (required for Add/Modify)")]
95    #[serde(default)]
96    pub value: Option<f64>,
97    #[schemars(
98        description = "User note (optional). Maximum length: 40 half-width bytes \
99                       (~20 CN characters or 40 ASCII characters) using UTF-16 \
100                       half-/full-width counting (each ASCII code unit = 1 byte, \
101                       each non-ASCII code unit = 2 bytes)."
102    )]
103    #[serde(default)]
104    pub note: Option<String>,
105    /// Reminder session list controlling which trading sessions trigger the alert.
106    ///
107    /// `Qot_Common::PriceReminderMarketStatus`: 1=Open, 2=USPre, 3=USAfter, 4=USOverNight.
108    /// US stocks (with pre-/after-/overnight sessions) default to `[Open, USPre, USAfter]`
109    /// when this list is empty. Non-US securities ignore the list entirely.
110    #[schemars(
111        description = "Reminder session list (PriceReminderMarketStatus: 1=Open, 2=USPre, \
112                       3=USAfter, 4=USOverNight). For US stocks, an empty list defaults to \
113                       [Open, USPre, USAfter]; for non-US securities the list is cleared."
114    )]
115    #[serde(default)]
116    pub reminder_session_list: Vec<i32>,
117}
118
119impl SetPriceReminderReq {
120    /// Runtime validation for op-conditional required fields.
121    ///
122    /// Required fields by op:
123    /// - op=1 (Add): `reminder_type` + `freq` + `value`
124    /// - op=5 (Modify): `key` (other fields are optional and the backend
125    ///   preserves the existing value when omitted)
126    /// - op=2/3/4 (Del / Enable / Disable): `key`
127    /// - op=6 (DeleteAll): no extra required fields
128    ///
129    /// Returns `Err(String)` with a human-readable hint for the agent /
130    /// SDK user when validation fails.
131    pub fn validate(&self) -> Result<(), String> {
132        match self.op {
133            1 => {
134                if self.reminder_type.is_none() {
135                    return Err(
136                        "SetPriceReminderReq op=1 (Add): `reminder_type` is required \
137                         (PriceReminderType enum 1-15)"
138                            .to_string(),
139                    );
140                }
141                // v1.4.106 codex 0450 F2 (P2): freq schema/runtime sync —
142                // gateway 对齐 C++ NN_PriceReminderFreq_None check 在 Add 路径
143                // 强制要求 freq, MCP/REST schema 必须同步要求, 否则 caller 按
144                // schema 不传 freq 会触发 runtime reject (silent ship-blocker
145                // 见 pitfall #54 schema-only fix).
146                if self.freq.is_none() {
147                    return Err("SetPriceReminderReq op=1 (Add): `freq` is required \
148                         (PriceReminderFreq enum 1=Always / 2=OncePerDay / 3=Once)"
149                        .to_string());
150                }
151                if self.value.is_none() {
152                    return Err(
153                        "SetPriceReminderReq op=1 (Add): `value` is required (threshold \
154                         value for reminder_type)"
155                            .to_string(),
156                    );
157                }
158            }
159            2..=4 => {
160                if self.key.is_none() {
161                    return Err(format!(
162                        "SetPriceReminderReq op={} ({}): `key` is required (from \
163                         get_price_reminder response)",
164                        self.op,
165                        match self.op {
166                            2 => "Del",
167                            3 => "Enable",
168                            _ => "Disable",
169                        }
170                    ));
171                }
172            }
173            5 => {
174                if self.key.is_none() {
175                    return Err(
176                        "SetPriceReminderReq op=5 (Modify): `key` is required (from \
177                         get_price_reminder response)"
178                            .to_string(),
179                    );
180                }
181            }
182            6 => {} // DeleteAll: 无必填
183            _ => {
184                return Err(format!(
185                    "SetPriceReminderReq: unknown op={}, expected 1=Add|2=Del|3=Enable|\
186                     4=Disable|5=Modify|6=DeleteAll",
187                    self.op
188                ));
189            }
190        }
191        // v1.4.106 codex 0450 F5 (P3): strict reject invalid session list values
192        // (defense-in-depth — gateway also rejects but agent-side feedback is
193        // faster than wire round-trip). Deliberate deviation from C++ silent
194        // drop (see handler comment).
195        for &session in &self.reminder_session_list {
196            if !matches!(session, 1..=4) {
197                return Err(format!(
198                    "SetPriceReminderReq: invalid reminder_session_list entry \
199                     {session} (PriceReminderMarketStatus 1=Open / 2=USPre / \
200                     3=USAfter / 4=USOverNight)"
201                ));
202            }
203        }
204        Ok(())
205    }
206}
207
208#[derive(Debug, Deserialize, schemars::JsonSchema)]
209#[serde(deny_unknown_fields)]
210pub struct GetPriceReminderReq {
211    #[schemars(description = "Security symbol (MARKET.CODE, e.g. HK.00700). \
212                       **Exactly one of `symbol` or `market` must be set.** \
213                       Passing both: symbol wins. Passing neither returns an error. \
214                       The exactly-one rule is enforced at runtime. \
215                       Alias: code / stock")]
216    // v1.4.84 §5 B1
217    #[serde(default, alias = "code", alias = "stock")]
218    pub symbol: Option<String>,
219    #[schemars(
220        description = "Market code — price-reminder QotMarket subset. Accept int 1=HK, 2=HK_FUTURE, 11=US, 21=SH/CN, 22=SZ, 31=SG, 41=JP, 61=MY, 91=CC \
221                       OR string (\"HK\" / \"HK_FUTURE\" / \"US\" / \"SH\" / \"SZ\" / \"CN\" / \"SG\" / \"JP\" / \"MY\" / \"CC\"). \
222                       **Exactly one of `symbol` or `market` required** (see symbol field doc)."
223    )]
224    // v1.4.84 §5 B2 field migration (Option variant)
225    #[serde(
226        default,
227        deserialize_with = "deser_price_reminder_market_as_option_i32"
228    )]
229    pub market: Option<i32>,
230}
231
232impl GetPriceReminderReq {
233    /// Runtime validation: symbol XOR market is required.
234    ///
235    /// Schema-level oneOf is not enforceable through this serde shape, so the
236    /// handler returns a clear runtime validation error.
237    pub fn validate(&self) -> Result<(), String> {
238        if self.symbol.is_none() && self.market.is_none() {
239            return Err(
240                "GetPriceReminderReq: exactly one of `symbol` or `market` is required \
241                 (neither provided)"
242                    .to_string(),
243            );
244        }
245        // symbol + market 同时传: symbol wins (schema 已说明), 不 error
246        Ok(())
247    }
248}
249
250#[derive(Debug, Deserialize, schemars::JsonSchema)]
251#[serde(deny_unknown_fields)]
252pub struct OptionExpirationDateReq {
253    #[schemars(
254        description = "Underlying stock symbol (HK/US equities + HSI/HSCEI only); alias: symbol / owner / code / stock"
255    )]
256    // v1.4.84 §5 B1
257    #[serde(alias = "symbol", alias = "owner", alias = "code", alias = "stock")]
258    pub owner_symbol: String,
259    #[schemars(description = "For index options only: Qot_Common::IndexOptionType (optional)")]
260    #[serde(default)]
261    pub index_option_type: Option<i32>,
262}