1use 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
11pub 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
85pub 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}