Skip to main content

futu_rest/routes/qot/reference/
ipo.rs

1//! IPO list and IPO-calendar REST adapter plus public projection.
2
3use futu_core::qot_endpoint_market;
4use futu_domain_qot_ipo::{
5    self as ipo_domain, collect_ipo_calendar_events, is_supported_ipo_event_type, is_yyyymmdd,
6};
7use serde::Deserialize;
8
9use super::*;
10
11/// POST /api/ipo-list — 获取 IPO 列表。
12pub async fn get_ipo_list(State(state): State<RestState>, Json(body): Json<Value>) -> RawApiResult {
13    adapter::proto_request_raw::<qot_get_ipo_list::Request, qot_get_ipo_list::Response>(
14        &state,
15        proto_id::QOT_GET_IPO_LIST,
16        Some(body),
17    )
18    .await
19}
20
21#[derive(Debug, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct IpoCalendarRestReq {
24    market: i32,
25    #[serde(default, alias = "eventTypes")]
26    event_types: Vec<String>,
27    #[serde(default, alias = "beginDate")]
28    begin_date: Option<String>,
29    #[serde(default, alias = "endDate")]
30    end_date: Option<String>,
31}
32
33fn validate_ipo_calendar_req(req: &IpoCalendarRestReq) -> Result<(), (StatusCode, Json<Value>)> {
34    if !qot_endpoint_market::is_ipo_market(req.market) {
35        return Err((
36            StatusCode::BAD_REQUEST,
37            Json(serde_json::json!({
38                "error": format!(
39                    "market must be {}",
40                    qot_endpoint_market::QOT_IPO_MARKET_VALID_VALUES
41                )
42            })),
43        ));
44    }
45    for (field, value) in [
46        ("begin_date", req.begin_date.as_deref()),
47        ("end_date", req.end_date.as_deref()),
48    ] {
49        if let Some(value) = value
50            && !is_yyyymmdd(value)
51        {
52            return Err((
53                StatusCode::BAD_REQUEST,
54                Json(serde_json::json!({
55                    "error": format!("{field} must be YYYYMMDD")
56                })),
57            ));
58        }
59    }
60    if let (Some(begin), Some(end)) = (&req.begin_date, &req.end_date)
61        && begin > end
62    {
63        return Err((
64            StatusCode::BAD_REQUEST,
65            Json(serde_json::json!({
66                "error": "begin_date must be earlier than or equal to end_date"
67            })),
68        ));
69    }
70    if let Some(event) = req
71        .event_types
72        .iter()
73        .find(|event| !is_supported_ipo_event_type(event))
74    {
75        return Err((
76            StatusCode::BAD_REQUEST,
77            Json(serde_json::json!({
78                "error": format!("unsupported IPO calendar event_type {event:?}")
79            })),
80        ));
81    }
82    Ok(())
83}
84
85/// POST /api/ipo-calendar — 将 IPO 列表投影为事件日历。
86pub async fn get_ipo_calendar(
87    State(state): State<RestState>,
88    Json(req): Json<IpoCalendarRestReq>,
89) -> RawApiResult {
90    validate_ipo_calendar_req(&req)?;
91    let req_msg = futu_proto::qot_get_ipo_list::Request {
92        c2s: futu_proto::qot_get_ipo_list::C2s {
93            market: req.market,
94            header: None,
95        },
96    };
97    let conn_id = state.next_conn_id();
98    let serial_no = state.next_serial();
99    let incoming = IncomingRequest::builder(
100        conn_id,
101        proto_id::QOT_GET_IPO_LIST,
102        serial_no,
103        ProtoFmtType::Protobuf,
104        Bytes::from(req_msg.encode_to_vec()),
105    )
106    .build();
107    let resp_bytes = state
108        .router
109        .dispatch(incoming.conn_id, &incoming)
110        .await
111        .ok_or_else(|| {
112            (
113                StatusCode::INTERNAL_SERVER_ERROR,
114                Json(serde_json::json!({"error": "handler returned no response"})),
115            )
116        })?;
117    let resp =
118        futu_proto::qot_get_ipo_list::Response::decode(resp_bytes.as_slice()).map_err(|e| {
119            (
120                StatusCode::INTERNAL_SERVER_ERROR,
121                Json(serde_json::json!({"error": format!("failed to decode response: {e}")})),
122            )
123        })?;
124    if resp.ret_type != 0 {
125        let raw = serde_json::to_vec(&serde_json::json!({
126            "ret_type": resp.ret_type,
127            "ret_msg": resp.ret_msg,
128            "err_code": resp.err_code,
129            "events": []
130        }))
131        .map_err(|e| {
132            (
133                StatusCode::INTERNAL_SERVER_ERROR,
134                Json(serde_json::json!({"error": format!("failed to serialize response: {e}")})),
135            )
136        })?;
137        return Ok(adapter::RawJson::new(raw));
138    }
139    let s2c = resp.s2c.as_ref().ok_or_else(|| {
140        (
141            StatusCode::INTERNAL_SERVER_ERROR,
142            Json(serde_json::json!({"error": "missing s2c"})),
143        )
144    })?;
145    let ipo_list = project_public_ipo_list(s2c);
146    let events = collect_ipo_calendar_events(
147        &ipo_list,
148        &req.event_types,
149        req.begin_date.as_deref(),
150        req.end_date.as_deref(),
151    );
152    let raw = serde_json::to_vec(&serde_json::json!({
153        "market": req.market,
154        "event_types": req.event_types,
155        "begin_date": req.begin_date,
156        "end_date": req.end_date,
157        "events": events
158    }))
159    .map_err(|e| {
160        (
161            StatusCode::INTERNAL_SERVER_ERROR,
162            Json(serde_json::json!({"error": format!("failed to serialize response: {e}")})),
163        )
164    })?;
165    Ok(adapter::RawJson::new(raw))
166}
167
168fn project_public_ipo_list(
169    s2c: &futu_proto::qot_get_ipo_list::S2c,
170) -> Vec<ipo_domain::ProjectedIpoData> {
171    s2c.ipo_list.iter().map(project_public_ipo_item).collect()
172}
173
174fn project_public_ipo_item(
175    item: &futu_proto::qot_get_ipo_list::IpoData,
176) -> ipo_domain::ProjectedIpoData {
177    ipo_domain::ProjectedIpoData {
178        basic: ipo_domain::ProjectedBasicIpoData {
179            security: ipo_domain::IpoSecurity {
180                market: item.basic.security.market,
181                code: item.basic.security.code.clone(),
182            },
183            name: item.basic.name.clone(),
184            list_time: item.basic.list_time.clone(),
185            list_timestamp: item.basic.list_timestamp,
186        },
187        cn_ex_data: item.cn_ex_data.as_ref().map(project_public_cn_ipo_ex),
188        hk_ex_data: item.hk_ex_data.as_ref().map(project_public_hk_ipo_ex),
189        us_ex_data: item.us_ex_data.as_ref().map(project_public_us_ipo_ex),
190        sg_ex_data: item.sg_ex_data.as_ref().map(project_public_sg_ipo_ex),
191        my_ex_data: item.my_ex_data.as_ref().map(project_public_my_ipo_ex),
192        jp_ex_data: item.jp_ex_data.as_ref().map(project_public_jp_ipo_ex),
193    }
194}
195
196fn project_public_cn_ipo_ex(
197    data: &futu_proto::qot_get_ipo_list::CnIpoExData,
198) -> ipo_domain::ProjectedCnIpoExData {
199    ipo_domain::ProjectedCnIpoExData {
200        apply_code: data.apply_code.clone(),
201        issue_size: data.issue_size,
202        online_issue_size: data.online_issue_size,
203        apply_upper_limit: data.apply_upper_limit,
204        apply_limit_market_value: data.apply_limit_market_value,
205        is_estimate_ipo_price: data.is_estimate_ipo_price,
206        ipo_price: data.ipo_price,
207        industry_pe_rate: data.industry_pe_rate,
208        is_estimate_winning_ratio: data.is_estimate_winning_ratio,
209        winning_ratio: data.winning_ratio,
210        issue_pe_rate: data.issue_pe_rate,
211        apply_time: data.apply_time.clone(),
212        apply_timestamp: data.apply_timestamp,
213        winning_time: data.winning_time.clone(),
214        winning_timestamp: data.winning_timestamp,
215        is_has_won: data.is_has_won,
216        winning_num_data: data
217            .winning_num_data
218            .iter()
219            .map(|item| ipo_domain::ProjectedWinningNumData {
220                winning_name: item.winning_name.clone(),
221                winning_info: item.winning_info.clone(),
222            })
223            .collect(),
224    }
225}
226
227fn project_public_hk_ipo_ex(
228    data: &futu_proto::qot_get_ipo_list::HkIpoExData,
229) -> ipo_domain::ProjectedHkIpoExData {
230    ipo_domain::ProjectedHkIpoExData {
231        ipo_price_min: data.ipo_price_min,
232        ipo_price_max: data.ipo_price_max,
233        list_price: data.list_price,
234        lot_size: data.lot_size,
235        entrance_price: data.entrance_price,
236        is_subscribe_status: data.is_subscribe_status,
237        apply_end_time: data.apply_end_time.clone(),
238        apply_end_timestamp: data.apply_end_timestamp,
239    }
240}
241
242fn project_public_us_ipo_ex(
243    data: &futu_proto::qot_get_ipo_list::UsIpoExData,
244) -> ipo_domain::ProjectedUsIpoExData {
245    ipo_domain::ProjectedUsIpoExData {
246        ipo_price_min: data.ipo_price_min,
247        ipo_price_max: data.ipo_price_max,
248        issue_size: data.issue_size,
249    }
250}
251
252fn project_public_sg_ipo_ex(
253    data: &futu_proto::qot_get_ipo_list::SgIpoExData,
254) -> ipo_domain::ProjectedSgIpoExData {
255    ipo_domain::ProjectedSgIpoExData {
256        ipo_price_min: data.ipo_price_min,
257        ipo_price_max: data.ipo_price_max,
258        issue_size: data.issue_size,
259        apply_start_time: data.apply_start_time.clone(),
260        apply_start_timestamp: data.apply_start_timestamp,
261        apply_end_time: data.apply_end_time.clone(),
262        apply_end_timestamp: data.apply_end_timestamp,
263        winning_time: data.winning_time.clone(),
264        winning_timestamp: data.winning_timestamp,
265    }
266}
267
268fn project_public_my_ipo_ex(
269    data: &futu_proto::qot_get_ipo_list::MyIpoExData,
270) -> ipo_domain::ProjectedMyIpoExData {
271    ipo_domain::ProjectedMyIpoExData {
272        offer_price: data.offer_price,
273        issue_size: data.issue_size,
274        apply_start_time: data.apply_start_time.clone(),
275        apply_start_timestamp: data.apply_start_timestamp,
276        apply_end_time: data.apply_end_time.clone(),
277        apply_end_timestamp: data.apply_end_timestamp,
278        winning_time: data.winning_time.clone(),
279        winning_timestamp: data.winning_timestamp,
280    }
281}
282
283fn project_public_jp_ipo_ex(
284    data: &futu_proto::qot_get_ipo_list::JpIpoExData,
285) -> ipo_domain::ProjectedJpIpoExData {
286    ipo_domain::ProjectedJpIpoExData {
287        ipo_price_min: data.ipo_price_min,
288        ipo_price_max: data.ipo_price_max,
289        issue_size: data.issue_size,
290        lot_size: data.lot_size,
291        eqty_issued_shares: data.eqty_issued_shares,
292        isin: data.isin.clone(),
293        issued_shares: data.issued_shares,
294        industry: data.industry.clone(),
295        market_segment: data.market_segment.clone(),
296        approval_time: data.approval_time.clone(),
297        approval_timestamp: data.approval_timestamp,
298        issue_confirm_time: data.issue_confirm_time.clone(),
299        issue_confirm_timestamp: data.issue_confirm_timestamp,
300        price_confirm_start_time: data.price_confirm_start_time.clone(),
301        price_confirm_start_timestamp: data.price_confirm_start_timestamp,
302        price_confirm_end_time: data.price_confirm_end_time.clone(),
303        price_confirm_end_timestamp: data.price_confirm_end_timestamp,
304        inquiry_start_time: data.inquiry_start_time.clone(),
305        inquiry_start_timestamp: data.inquiry_start_timestamp,
306        inquiry_end_time: data.inquiry_end_time.clone(),
307        inquiry_end_timestamp: data.inquiry_end_timestamp,
308        apply_start_time: data.apply_start_time.clone(),
309        apply_start_timestamp: data.apply_start_timestamp,
310        apply_end_time: data.apply_end_time.clone(),
311        apply_end_timestamp: data.apply_end_timestamp,
312        draw_time: data.draw_time.clone(),
313        draw_timestamp: data.draw_timestamp,
314        winning_time: data.winning_time.clone(),
315        winning_timestamp: data.winning_timestamp,
316        etf_info: data.etf_info.as_ref().map(project_public_jp_etf_brief),
317    }
318}
319
320fn project_public_jp_etf_brief(
321    data: &futu_proto::qot_get_ipo_list::JpetfBriefInfo,
322) -> ipo_domain::ProjectedJpEtfBriefInfo {
323    ipo_domain::ProjectedJpEtfBriefInfo {
324        management_fee_rates: data.management_fee_rates,
325        dividend_times: data.dividend_times,
326        dividend_frequency_type: data.dividend_frequency_type,
327        investing_risk_type: data.investing_risk_type,
328        index_name: data.index_name.clone(),
329        company_name: data.company_name.clone(),
330        company_introduction_link: data.company_introduction_link.clone(),
331        company_interview_link: data.company_interview_link.clone(),
332        etf_pamphlet_link: data.etf_pamphlet_link.clone(),
333        etf_introduction_link: data.etf_introduction_link.clone(),
334    }
335}