Skip to main content

futucli/cmd/analysis/
warrant_ipo.rs

1//! v1.4.110+ split (from cmd/analysis.rs): warrant_ipo domain.
2//!
3//! pub items: run_warrant, run_ipo_list.
4
5use anyhow::{Result, anyhow, bail};
6use futu_qot::ipo_calendar::{
7    collect_ipo_calendar_events, is_supported_ipo_event_type, is_yyyymmdd,
8};
9use prost::Message;
10use serde::Serialize;
11use tabled::Tabled;
12
13use crate::common::{connect_gateway, parse_symbol};
14use crate::output::OutputFormat;
15
16use super::trading::parse_ipo_market;
17
18#[derive(Tabled)]
19struct WarrantRow {
20    #[tabled(rename = "Code")]
21    code: String,
22    #[tabled(rename = "Name")]
23    name: String,
24    #[tabled(rename = "Owner")]
25    owner: String,
26    #[tabled(rename = "Cur Price")]
27    cur_price: String,
28    #[tabled(rename = "Strike")]
29    strike: String,
30    #[tabled(rename = "Maturity")]
31    maturity: String,
32}
33
34#[derive(Serialize)]
35struct WarrantJson {
36    code: String,
37    name: String,
38    owner_code: String,
39    cur_price: f64,
40    strike_price: f64,
41    maturity_time: String,
42}
43
44pub async fn run_warrant(
45    gateway: &str,
46    owner_symbol: Option<&str>,
47    begin: i32,
48    num: i32,
49    format: OutputFormat,
50) -> Result<()> {
51    // v1.4.111 codex: 暴露 begin (分页), 不静默 clamp num.
52    // 越界 (begin<0 / num∉[0, 200]) 走 Err; num=0 对齐 C++ 为空页请求.
53    let bounds = futu_core::qot_page_bounds::validate_begin_num(begin, num, 200, "warrant")
54        .map_err(|e| anyhow!("{}", e))?;
55    let owner = match owner_symbol {
56        Some(s) => Some(parse_symbol(s)?),
57        None => None,
58    };
59    let (client, _rx) = connect_gateway(gateway, "futucli-warrant").await?;
60    let req = build_warrant_request(
61        bounds.begin,
62        bounds.num,
63        owner.map(|s| futu_proto::qot_common::Security {
64            market: s.market as i32,
65            code: s.code,
66        }),
67    );
68    let body = req.encode_to_vec();
69    let frame = client
70        .request(futu_core::proto_id::QOT_GET_WARRANT, body)
71        .await?;
72    let resp = futu_proto::qot_get_warrant::Response::decode(frame.body.as_ref())
73        .map_err(|e| anyhow!("decode warrant: {e}"))?;
74    if resp.ret_type != 0 {
75        bail!("warrant ret_type={} msg={:?}", resp.ret_type, resp.ret_msg);
76    }
77    let s2c = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
78    let mut rows = Vec::new();
79    let mut jsons = Vec::new();
80    for w in &s2c.warrant_data_list {
81        rows.push(WarrantRow {
82            code: w.stock.code.clone(),
83            name: w.name.clone(),
84            owner: w.owner.code.clone(),
85            cur_price: format!("{:.3}", w.cur_price),
86            strike: format!("{:.3}", w.strike_price),
87            maturity: w.maturity_time.clone(),
88        });
89        jsons.push(WarrantJson {
90            code: w.stock.code.clone(),
91            name: w.name.clone(),
92            owner_code: w.owner.code.clone(),
93            cur_price: w.cur_price,
94            strike_price: w.strike_price,
95            maturity_time: w.maturity_time.clone(),
96        });
97    }
98    format.print_rows(&rows, &jsons)?;
99    Ok(())
100}
101
102pub(super) fn build_warrant_request(
103    begin: i32,
104    num: i32,
105    owner: Option<futu_proto::qot_common::Security>,
106) -> futu_proto::qot_get_warrant::Request {
107    futu_proto::qot_get_warrant::Request {
108        c2s: futu_proto::qot_get_warrant::C2s {
109            begin,
110            num,
111            // Ref: Qot_Common.SortField_Volume = 10. Public sort enum values
112            // are translated to CMD20334 FieldId by the gateway.
113            sort_field: 10,
114            ascend: false,
115            owner,
116            type_list: vec![],
117            issuer_list: vec![],
118            maturity_time_min: None,
119            maturity_time_max: None,
120            ipo_period: None,
121            price_type: None,
122            status: None,
123            cur_price_min: None,
124            cur_price_max: None,
125            strike_price_min: None,
126            strike_price_max: None,
127            street_min: None,
128            street_max: None,
129            conversion_min: None,
130            conversion_max: None,
131            vol_min: None,
132            vol_max: None,
133            premium_min: None,
134            premium_max: None,
135            leverage_ratio_min: None,
136            leverage_ratio_max: None,
137            delta_min: None,
138            delta_max: None,
139            implied_min: None,
140            implied_max: None,
141            recovery_price_min: None,
142            recovery_price_max: None,
143            price_recovery_ratio_min: None,
144            price_recovery_ratio_max: None,
145            header: None,
146        },
147    }
148}
149
150#[derive(Tabled)]
151struct IpoRow {
152    #[tabled(rename = "Code")]
153    code: String,
154    #[tabled(rename = "Name")]
155    name: String,
156    #[tabled(rename = "List Time")]
157    list_time: String,
158}
159
160#[derive(Serialize)]
161struct IpoJson {
162    code: String,
163    name: String,
164    list_time: Option<String>,
165}
166
167#[derive(Tabled)]
168struct IpoCalendarRow {
169    #[tabled(rename = "Event")]
170    event_type: String,
171    #[tabled(rename = "Code")]
172    code: String,
173    #[tabled(rename = "Name")]
174    name: String,
175    #[tabled(rename = "Date")]
176    date: String,
177    #[tabled(rename = "Time")]
178    time: String,
179}
180
181fn validate_yyyymmdd(field: &str, value: Option<&str>) -> Result<()> {
182    if let Some(value) = value
183        && !is_yyyymmdd(value)
184    {
185        bail!("{field} must be YYYYMMDD, got {value:?}");
186    }
187    Ok(())
188}
189
190pub async fn run_ipo_calendar(
191    gateway: &str,
192    market: &str,
193    events: &[String],
194    begin_date: Option<&str>,
195    end_date: Option<&str>,
196    format: OutputFormat,
197) -> Result<()> {
198    validate_yyyymmdd("begin_date", begin_date)?;
199    validate_yyyymmdd("end_date", end_date)?;
200    if let (Some(begin), Some(end)) = (begin_date, end_date)
201        && begin > end
202    {
203        bail!("begin_date must be earlier than or equal to end_date");
204    }
205    if let Some(event) = events
206        .iter()
207        .find(|event| !is_supported_ipo_event_type(event))
208    {
209        bail!("unsupported IPO calendar event_type {event:?}");
210    }
211    let m = parse_ipo_market(market)?;
212    let (client, _rx) = connect_gateway(gateway, "futucli-ipo-calendar").await?;
213    let req = futu_proto::qot_get_ipo_list::Request {
214        c2s: futu_proto::qot_get_ipo_list::C2s {
215            market: m,
216            header: None,
217        },
218    };
219    let body = req.encode_to_vec();
220    let frame = client
221        .request(futu_core::proto_id::QOT_GET_IPO_LIST, body)
222        .await?;
223    let resp = futu_proto::qot_get_ipo_list::Response::decode(frame.body.as_ref())
224        .map_err(|e| anyhow!("decode ipo_calendar: {e}"))?;
225    if resp.ret_type != 0 {
226        bail!(
227            "ipo_calendar ret_type={} msg={:?}",
228            resp.ret_type,
229            resp.ret_msg
230        );
231    }
232    let s2c = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
233    let calendar_events = collect_ipo_calendar_events(&s2c, events, begin_date, end_date);
234    let rows: Vec<IpoCalendarRow> = calendar_events
235        .iter()
236        .map(|event| IpoCalendarRow {
237            event_type: event.event_type.to_string(),
238            code: event.code.clone(),
239            name: event.name.clone(),
240            date: event.date.clone().unwrap_or_default(),
241            time: event.time.clone().unwrap_or_default(),
242        })
243        .collect();
244    format.print_rows(&rows, &calendar_events)?;
245    Ok(())
246}
247
248pub async fn run_ipo_list(gateway: &str, market: &str, format: OutputFormat) -> Result<()> {
249    let m = parse_ipo_market(market)?;
250    let (client, _rx) = connect_gateway(gateway, "futucli-ipo-list").await?;
251    let req = futu_proto::qot_get_ipo_list::Request {
252        c2s: futu_proto::qot_get_ipo_list::C2s {
253            market: m,
254            header: None, // v1.4.110 codex Slice 1 schema 占位
255        },
256    };
257    let body = req.encode_to_vec();
258    let frame = client
259        .request(futu_core::proto_id::QOT_GET_IPO_LIST, body)
260        .await?;
261    let resp = futu_proto::qot_get_ipo_list::Response::decode(frame.body.as_ref())
262        .map_err(|e| anyhow!("decode ipo_list: {e}"))?;
263    if resp.ret_type != 0 {
264        bail!("ipo_list ret_type={} msg={:?}", resp.ret_type, resp.ret_msg);
265    }
266    let s2c = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
267    let mut rows = Vec::new();
268    let mut jsons = Vec::new();
269    for i in &s2c.ipo_list {
270        rows.push(IpoRow {
271            code: i.basic.security.code.clone(),
272            name: i.basic.name.clone(),
273            list_time: i.basic.list_time.clone().unwrap_or_else(|| "-".into()),
274        });
275        jsons.push(IpoJson {
276            code: i.basic.security.code.clone(),
277            name: i.basic.name.clone(),
278            list_time: i.basic.list_time.clone(),
279        });
280    }
281    format.print_rows(&rows, &jsons)?;
282    Ok(())
283}