Skip to main content

futucli/cmd/analysis/
price_reminder.rs

1//! v1.4.110+ split (from cmd/analysis.rs): price_reminder domain.
2//!
3//! pub items: SetPriceReminderCommand,run_set_price_reminder,run_get_price_reminder,run_option_expiration_date.
4
5use anyhow::{Result, anyhow, bail};
6use prost::Message;
7use serde::Serialize;
8use tabled::Tabled;
9
10use crate::common::{connect_gateway, parse_symbol};
11use crate::output::OutputFormat;
12
13pub struct SetPriceReminderCommand<'a> {
14    pub gateway: &'a str,
15    pub symbol: &'a str,
16    pub op: i32,
17    pub key: Option<i64>,
18    pub reminder_type: Option<i32>,
19    pub freq: Option<i32>,
20    pub value: Option<f64>,
21    pub note: Option<&'a str>,
22    pub reminder_session_list: &'a [i32],
23}
24
25pub async fn run_set_price_reminder(input: SetPriceReminderCommand<'_>) -> Result<()> {
26    let sec = parse_symbol(input.symbol)?;
27    let (client, _rx) = connect_gateway(input.gateway, "futucli-set-price-reminder").await?;
28    let req = futu_proto::qot_set_price_reminder::Request {
29        c2s: futu_proto::qot_set_price_reminder::C2s {
30            security: futu_proto::qot_common::Security {
31                market: sec.market as i32,
32                code: sec.code,
33            },
34            op: input.op,
35            key: input.key,
36            r#type: input.reminder_type,
37            freq: input.freq,
38            value: input.value,
39            note: input.note.map(String::from),
40            // v1.4.106 codex 1116 F4 [P2]: 透传 user 提供的 session list, daemon
41            // 在 gateway 层统一处理美股默认 [Open, USPre, USAfter] / 非美股清空.
42            reminder_session_list: input.reminder_session_list.to_vec(),
43            header: None, // v1.4.110 codex Slice 1 schema 占位
44        },
45    };
46    let body = req.encode_to_vec();
47    let frame = client
48        .request(futu_core::proto_id::QOT_SET_PRICE_REMINDER, body)
49        .await?;
50    let resp = futu_proto::qot_set_price_reminder::Response::decode(frame.body.as_ref())
51        .map_err(|e| anyhow!("decode: {e}"))?;
52    if resp.ret_type != 0 {
53        bail!(
54            "set_price_reminder ret_type={} msg={:?}",
55            resp.ret_type,
56            resp.ret_msg
57        );
58    }
59    let key_out = resp.s2c.map(|s| s.key);
60    println!("✅ set_price_reminder ok: op={} key={key_out:?}", input.op);
61    Ok(())
62}
63
64pub(super) fn parse_price_reminder_market(raw: &str) -> Result<i32> {
65    if let Ok(value) = raw.parse::<i32>() {
66        return match value {
67            1 | 6 | 11 | 21 | 22 => Ok(value),
68            _ => bail!(
69                "unknown price reminder market {value}: valid = 1(HK), 6(HK_FUTURE), 11(US), 21(SH/CN), 22(SZ)"
70            ),
71        };
72    }
73
74    match raw.trim().to_ascii_uppercase().as_str() {
75        "HK" => Ok(1),
76        "HK_FUTURE" | "HKFUTURE" => Ok(6),
77        "US" => Ok(11),
78        "CN" | "SH" => Ok(21),
79        "SZ" => Ok(22),
80        other => bail!(
81            "unknown price reminder market {other:?}: valid = HK/HK_FUTURE/US/SH/SZ/CN or integer 1/6/11/21/22"
82        ),
83    }
84}
85
86#[derive(Tabled)]
87struct ReminderRow {
88    #[tabled(rename = "Key")]
89    key: i64,
90    #[tabled(rename = "Type")]
91    r#type: i32,
92    #[tabled(rename = "Value")]
93    value: String,
94    #[tabled(rename = "Freq")]
95    freq: i32,
96    #[tabled(rename = "Enable")]
97    enable: bool,
98    #[tabled(rename = "Note")]
99    note: String,
100}
101
102#[derive(Serialize)]
103struct ReminderJson {
104    symbol: String,
105    name: Option<String>,
106    key: i64,
107    reminder_type: i32,
108    value: f64,
109    freq: i32,
110    is_enable: bool,
111    note: String,
112}
113
114pub async fn run_get_price_reminder(
115    gateway: &str,
116    symbol: Option<&str>,
117    market: Option<&str>,
118    format: OutputFormat,
119) -> Result<()> {
120    let (security, market_code) = match symbol {
121        Some(s) => {
122            let sec = parse_symbol(s)?;
123            (
124                Some(futu_proto::qot_common::Security {
125                    market: sec.market as i32,
126                    code: sec.code,
127                }),
128                None,
129            )
130        }
131        None => (None, market.map(parse_price_reminder_market).transpose()?),
132    };
133    if security.is_none() && market_code.is_none() {
134        bail!("need either --symbol or --market");
135    }
136    let (client, _rx) = connect_gateway(gateway, "futucli-price-reminder").await?;
137    let req = futu_proto::qot_get_price_reminder::Request {
138        c2s: futu_proto::qot_get_price_reminder::C2s {
139            security,
140            market: market_code,
141            header: None,
142        },
143    };
144    let body = req.encode_to_vec();
145    let frame = client
146        .request(futu_core::proto_id::QOT_GET_PRICE_REMINDER, body)
147        .await?;
148    let resp = futu_proto::qot_get_price_reminder::Response::decode(frame.body.as_ref())
149        .map_err(|e| anyhow!("decode: {e}"))?;
150    if resp.ret_type != 0 {
151        bail!(
152            "price_reminder ret_type={} msg={:?}",
153            resp.ret_type,
154            resp.ret_msg
155        );
156    }
157    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
158    let mut rows = Vec::new();
159    let mut jsons = Vec::new();
160    for pr in &s.price_reminder_list {
161        let sym = format!("{}.{}", pr.security.market, pr.security.code);
162        for r in &pr.item_list {
163            rows.push(ReminderRow {
164                key: r.key,
165                r#type: r.r#type,
166                value: format!("{:.3}", r.value),
167                freq: r.freq,
168                enable: r.is_enable,
169                note: r.note.clone(),
170            });
171            jsons.push(ReminderJson {
172                symbol: sym.clone(),
173                name: pr.name.clone(),
174                key: r.key,
175                reminder_type: r.r#type,
176                value: r.value,
177                freq: r.freq,
178                is_enable: r.is_enable,
179                note: r.note.clone(),
180            });
181        }
182    }
183    format.print_rows(&rows, &jsons)?;
184    Ok(())
185}
186
187#[derive(Tabled)]
188struct OptionExpiryRow {
189    #[tabled(rename = "Strike Time")]
190    strike_time: String,
191    #[tabled(rename = "Distance (days)")]
192    distance: i32,
193    #[tabled(rename = "Cycle")]
194    cycle: String,
195}
196
197#[derive(Serialize)]
198struct OptionExpiryJson {
199    strike_time: Option<String>,
200    distance_days: i32,
201    cycle: Option<i32>,
202}
203
204pub async fn run_option_expiration_date(
205    gateway: &str,
206    owner: &str,
207    index_type: Option<i32>,
208    format: OutputFormat,
209) -> Result<()> {
210    let sec = parse_symbol(owner)?;
211    let (client, _rx) = connect_gateway(gateway, "futucli-option-expiry").await?;
212    let req = futu_proto::qot_get_option_expiration_date::Request {
213        c2s: futu_proto::qot_get_option_expiration_date::C2s {
214            owner: futu_proto::qot_common::Security {
215                market: sec.market as i32,
216                code: sec.code,
217            },
218            index_option_type: index_type,
219            header: None, // v1.4.110 codex Slice 1 schema 占位
220        },
221    };
222    let body = req.encode_to_vec();
223    let frame = client
224        .request(futu_core::proto_id::QOT_GET_OPTION_EXPIRATION_DATE, body)
225        .await?;
226    let resp = futu_proto::qot_get_option_expiration_date::Response::decode(frame.body.as_ref())
227        .map_err(|e| anyhow!("decode: {e}"))?;
228    if resp.ret_type != 0 {
229        bail!(
230            "option_expiration_date ret_type={} msg={:?}",
231            resp.ret_type,
232            resp.ret_msg
233        );
234    }
235    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
236    let mut rows = Vec::new();
237    let mut jsons = Vec::new();
238    for d in &s.date_list {
239        rows.push(OptionExpiryRow {
240            strike_time: d.strike_time.clone().unwrap_or_default(),
241            distance: d.option_expiry_date_distance,
242            cycle: d.cycle.map(|c| c.to_string()).unwrap_or_else(|| "-".into()),
243        });
244        jsons.push(OptionExpiryJson {
245            strike_time: d.strike_time.clone(),
246            distance_days: d.option_expiry_date_distance,
247            cycle: d.cycle,
248        });
249    }
250    format.print_rows(&rows, &jsons)?;
251    Ok(())
252}