1use std::sync::Arc;
2
3use anyhow::{Result, bail};
4use futu_backend::proto_internal::realtime_asset_log;
5use futu_net::client::FutuClient;
6use prost::Message as _;
7use serde::Serialize;
8
9use super::parse_trd_env;
10
11#[derive(Serialize)]
26struct CashLogLabelOut {
27 label: String,
28 label_desc: String,
29}
30
31#[derive(Serialize)]
32struct CashLogEntryOut {
33 log_id: String,
34 title: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 label: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 label_desc: Option<String>,
39 created_time: String,
40 created_timestamp: u32,
41 cash_change: String,
42 balance: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 stock_name: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 icon_url: Option<String>,
47 symbol_code: String,
48 symbol_name: String,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 label_list: Vec<CashLogLabelOut>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 stock_id: Option<u64>,
53 #[serde(skip_serializing_if = "Vec::is_empty")]
54 stock_name_label_list: Vec<CashLogLabelOut>,
55 biz_type_id: u32,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 contract_direction: Option<i32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 contract_direction_label: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 contract_symbol_name: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 currency: Option<String>,
64}
65
66fn cash_log_label_out_from_proto(label: realtime_asset_log::Label) -> CashLogLabelOut {
67 CashLogLabelOut {
68 label: label.label.unwrap_or_default(),
69 label_desc: label.label_desc.unwrap_or_default(),
70 }
71}
72
73fn cash_log_entry_out_from_proto(c: realtime_asset_log::CashLog) -> CashLogEntryOut {
74 CashLogEntryOut {
75 log_id: c.log_id.unwrap_or_default(),
76 title: c.title.unwrap_or_default(),
77 label: c.label,
78 label_desc: c.label_desc,
79 created_time: c.created_time.unwrap_or_default(),
80 created_timestamp: c.created_timestamp.unwrap_or(0),
81 cash_change: c.cash_change.unwrap_or_default(),
82 balance: c.balance.unwrap_or_default(),
83 stock_name: c.stock_name,
84 icon_url: c.icon_url,
85 symbol_code: c.symbol_code.unwrap_or_default(),
86 symbol_name: c.symbol_name.unwrap_or_default(),
87 label_list: c
88 .label_list
89 .into_iter()
90 .map(cash_log_label_out_from_proto)
91 .collect(),
92 stock_id: c.stock_id,
93 stock_name_label_list: c
94 .stock_name_label_list
95 .into_iter()
96 .map(cash_log_label_out_from_proto)
97 .collect(),
98 biz_type_id: c.biz_type_id.unwrap_or(0),
99 contract_direction: c.contract_direction,
100 contract_direction_label: c.contract_direction_label,
101 contract_symbol_name: c.contract_symbol_name,
102 currency: c.currency,
103 }
104}
105
106#[derive(Serialize)]
107struct CashLogMonthlyOut {
108 period_desc: String,
109 in_value: String,
110 out_value: String,
111 entries: Vec<CashLogEntryOut>,
112}
113
114#[derive(Serialize)]
115struct CashLogOut {
116 monthly_logs: Vec<CashLogMonthlyOut>,
117 has_more: bool,
118 next_log_id: String,
119}
120
121pub struct CashLogInput<'a> {
129 pub env: &'a str,
130 pub acc_id: u64,
131 pub begin_time: Option<u64>,
132 pub end_time: Option<u64>,
133 pub biz_group_id: Option<u32>,
134 pub biz_sub_group_id: Option<u32>,
135 pub in_out: Option<u32>,
136 pub keyword: Option<String>,
137 pub symbol: Option<String>,
138 pub stock_id: Option<u64>,
139 pub log_id: Option<String>,
140 pub max_cnt: Option<u32>,
141 pub currency: Option<String>,
142}
143
144pub async fn get_cash_log(client: &Arc<FutuClient>, input: CashLogInput<'_>) -> Result<String> {
145 let trd_env = parse_trd_env(input.env)?;
146 if input.acc_id == 0 {
147 bail!("acc_id 必填 (call futu_list_accounts to discover)");
148 }
149
150 let inner = realtime_asset_log::GetCashLogReq {
157 market: None, account_id: None, biz_group_id: input.biz_group_id,
160 in_out: input.in_out,
161 begin_time: input.begin_time,
162 end_time: input.end_time,
163 need_stock_name: Some(true),
164 log_id: input.log_id,
165 max_cnt: input.max_cnt,
166 keyword: input.keyword,
167 biz_sub_group_id: input.biz_sub_group_id,
168 currency: input.currency,
169 symbol: input.symbol,
170 stock_id: input.stock_id,
171 };
172
173 let req = realtime_asset_log::DaemonGetCashLogReq {
175 c2s: realtime_asset_log::daemon_get_cash_log_req::C2s {
176 header: realtime_asset_log::DaemonGetCashLogHeader {
177 acc_id: input.acc_id,
178 trd_env: Some(trd_env as i32),
179 },
180 inner: Some(inner),
181 },
182 };
183
184 let body = req.encode_to_vec();
185 let frame = client
186 .request(futu_core::proto_id::TRD_GET_CASH_LOG, body)
187 .await?;
188 let resp =
189 <realtime_asset_log::DaemonGetCashLogRsp as prost::Message>::decode(frame.body.as_ref())
190 .map_err(|e| anyhow::anyhow!("decode DaemonGetCashLogRsp: {e}"))?;
191 if resp.ret_type != 0 {
192 bail!(
193 "GetCashLog ret_type={} msg={:?} (fallback: try futu_get_acc_cash_flow)",
194 resp.ret_type,
195 resp.ret_msg
196 );
197 }
198
199 let inner_rsp = resp
200 .s2c
201 .and_then(|s| s.inner)
202 .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetCashLogRsp"))?;
203
204 let monthly_logs: Vec<CashLogMonthlyOut> = inner_rsp
205 .monthly_log_list
206 .into_iter()
207 .map(|m| CashLogMonthlyOut {
208 period_desc: m
209 .monthly_info
210 .as_ref()
211 .map(|mi| mi.period_desc.clone().unwrap_or_default())
212 .unwrap_or_default(),
213 in_value: m
214 .monthly_info
215 .as_ref()
216 .map(|mi| mi.in_value.clone().unwrap_or_default())
217 .unwrap_or_default(),
218 out_value: m
219 .monthly_info
220 .as_ref()
221 .map(|mi| mi.out_value.clone().unwrap_or_default())
222 .unwrap_or_default(),
223 entries: m
224 .cash_log_list
225 .into_iter()
226 .map(cash_log_entry_out_from_proto)
227 .collect(),
228 })
229 .collect();
230
231 let out = CashLogOut {
232 monthly_logs,
233 has_more: inner_rsp.has_more.unwrap_or(false),
234 next_log_id: inner_rsp.next_log_id.unwrap_or_default(),
235 };
236 Ok(serde_json::to_string_pretty(&out)?)
237}
238
239#[derive(Serialize)]
240struct CashDetailOut {
241 title: String,
242 sub_title: String,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 title_detail: Option<DetailItemOut>,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 sub_title_detail: Option<DetailItemOut>,
247 sections: Vec<Vec<DetailItemOut>>,
248 external_links: Vec<DetailItemOut>,
249}
250
251#[derive(Clone, Debug, Serialize)]
252struct DetailItemOut {
253 title: String,
254 value: String,
255 url: String,
256 old_value: String,
257 icon_url_id: String,
258 type_id: u32,
259}
260
261fn detail_item_out_from_proto(d: realtime_asset_log::Detail) -> Option<DetailItemOut> {
262 let title = d.title.unwrap_or_default();
263 if title.is_empty() {
264 return None;
265 }
266 Some(DetailItemOut {
267 title,
268 value: d.value.unwrap_or_default(),
269 url: d.url.unwrap_or_default(),
270 old_value: d.old_value.unwrap_or_default(),
271 icon_url_id: d.icon_url_id.unwrap_or_default(),
272 type_id: d.type_id.unwrap_or_default(),
273 })
274}
275
276fn cash_detail_out_from_inner_rsp(
277 inner_rsp: realtime_asset_log::GetCashDetailRsp,
278) -> CashDetailOut {
279 let title_detail = inner_rsp.detail_title.and_then(detail_item_out_from_proto);
280 let sub_title_detail = inner_rsp
281 .sub_detail_title
282 .and_then(detail_item_out_from_proto);
283
284 let title_str = title_detail
285 .as_ref()
286 .map(|d| d.title.clone())
287 .unwrap_or_default();
288 let sub_title_str = sub_title_detail
289 .as_ref()
290 .map(|d| d.title.clone())
291 .unwrap_or_default();
292
293 let sections: Vec<Vec<DetailItemOut>> = inner_rsp
294 .section_list
295 .into_iter()
296 .filter_map(|s| {
297 let details: Vec<DetailItemOut> = s
298 .detail_list
299 .into_iter()
300 .filter_map(detail_item_out_from_proto)
301 .collect();
302 if details.is_empty() {
303 None
304 } else {
305 Some(details)
306 }
307 })
308 .collect();
309
310 let mut external_links: Vec<DetailItemOut> = inner_rsp
311 .all_external_link
312 .into_iter()
313 .filter_map(detail_item_out_from_proto)
314 .collect();
315 if external_links.is_empty()
316 && let Some(single) = inner_rsp.external_link.and_then(detail_item_out_from_proto)
317 {
318 external_links.push(single);
319 }
320
321 CashDetailOut {
322 title: title_str,
323 sub_title: sub_title_str,
324 title_detail,
325 sub_title_detail,
326 sections,
327 external_links,
328 }
329}
330
331pub async fn get_cash_detail(
333 client: &Arc<FutuClient>,
334 env: &str,
335 acc_id: u64,
336 log_id: String,
337) -> Result<String> {
338 if log_id.is_empty() {
339 bail!("log_id 必填 (从 futu_get_cash_log 响应里取)");
340 }
341 let trd_env = parse_trd_env(env)?;
342 if acc_id == 0 {
343 bail!("acc_id 必填");
344 }
345
346 let inner = realtime_asset_log::GetCashDetailReq {
349 market: None,
350 account_id: None,
351 log_id: Some(log_id),
352 };
353 let req = realtime_asset_log::DaemonGetCashDetailReq {
354 c2s: realtime_asset_log::daemon_get_cash_detail_req::C2s {
355 header: realtime_asset_log::DaemonGetCashLogHeader {
356 acc_id,
357 trd_env: Some(trd_env as i32),
358 },
359 inner: Some(inner),
360 },
361 };
362
363 let body = req.encode_to_vec();
364 let frame = client
365 .request(futu_core::proto_id::TRD_GET_CASH_DETAIL, body)
366 .await?;
367 let resp =
368 <realtime_asset_log::DaemonGetCashDetailRsp as prost::Message>::decode(frame.body.as_ref())
369 .map_err(|e| anyhow::anyhow!("decode DaemonGetCashDetailRsp: {e}"))?;
370 if resp.ret_type != 0 {
371 bail!(
372 "GetCashDetail ret_type={} msg={:?}",
373 resp.ret_type,
374 resp.ret_msg
375 );
376 }
377
378 let inner_rsp = resp
379 .s2c
380 .and_then(|s| s.inner)
381 .ok_or_else(|| anyhow::anyhow!("empty s2c.inner"))?;
382
383 let out = cash_detail_out_from_inner_rsp(inner_rsp);
384 Ok(serde_json::to_string_pretty(&out)?)
385}
386
387#[derive(Serialize)]
388struct BizGroupItemOut {
389 id: u32,
390 name: String,
391 sub_groups: Vec<BizSubGroupOut>,
392}
393
394#[derive(Serialize)]
395struct BizSubGroupOut {
396 id: u32,
397 name: String,
398}
399
400#[derive(Serialize)]
401struct CurrencyConfigOut {
402 currency: String,
403 name: String,
404}
405
406#[derive(Serialize)]
407struct DirectionOut {
408 side: u32,
409 name: String,
410}
411
412#[derive(Serialize)]
413struct BizGroupOut {
414 biz_groups: Vec<BizGroupItemOut>,
415 currencies: Vec<CurrencyConfigOut>,
416 directions: Vec<DirectionOut>,
417}
418
419pub async fn get_biz_group(client: &Arc<FutuClient>, env: &str, acc_id: u64) -> Result<String> {
423 let trd_env = parse_trd_env(env)?;
424 if acc_id == 0 {
425 bail!("acc_id 必填");
426 }
427
428 let inner = realtime_asset_log::GetBizGroupReq { market: None };
430 let req = realtime_asset_log::DaemonGetBizGroupReq {
431 c2s: realtime_asset_log::daemon_get_biz_group_req::C2s {
432 header: realtime_asset_log::DaemonGetCashLogHeader {
433 acc_id,
434 trd_env: Some(trd_env as i32),
435 },
436 inner: Some(inner),
437 },
438 };
439
440 let body = req.encode_to_vec();
441 let frame = client
442 .request(futu_core::proto_id::TRD_GET_BIZ_GROUP, body)
443 .await?;
444 let resp =
445 <realtime_asset_log::DaemonGetBizGroupRsp as prost::Message>::decode(frame.body.as_ref())
446 .map_err(|e| anyhow::anyhow!("decode DaemonGetBizGroupRsp: {e}"))?;
447 if resp.ret_type != 0 {
448 bail!(
449 "GetBizGroup ret_type={} msg={:?}",
450 resp.ret_type,
451 resp.ret_msg
452 );
453 }
454
455 let inner_rsp = resp
456 .s2c
457 .and_then(|s| s.inner)
458 .ok_or_else(|| anyhow::anyhow!("empty s2c.inner"))?;
459
460 let biz_groups: Vec<BizGroupItemOut> = inner_rsp
461 .biz_group_list
462 .into_iter()
463 .map(|g| BizGroupItemOut {
464 id: g.id.unwrap_or(0),
465 name: g.name.unwrap_or_default(),
466 sub_groups: g
467 .biz_sub_group_list
468 .into_iter()
469 .map(|s| BizSubGroupOut {
470 id: s.id.unwrap_or(0),
471 name: s.name.unwrap_or_default(),
472 })
473 .collect(),
474 })
475 .collect();
476
477 let currencies: Vec<CurrencyConfigOut> = inner_rsp
478 .currency_config_list
479 .into_iter()
480 .map(|c| CurrencyConfigOut {
481 currency: c.currency.unwrap_or_default(),
482 name: c.name.unwrap_or_default(),
483 })
484 .collect();
485
486 let directions: Vec<DirectionOut> = inner_rsp
487 .direction_list
488 .into_iter()
489 .map(|d| DirectionOut {
490 side: d.side.unwrap_or(0),
491 name: d.name.unwrap_or_default(),
492 })
493 .collect();
494
495 let out = BizGroupOut {
496 biz_groups,
497 currencies,
498 directions,
499 };
500 Ok(serde_json::to_string_pretty(&out)?)
501}
502
503#[cfg(test)]
504mod tests;