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