Skip to main content

futu_backend/
indicator_metadata.rs

1use std::sync::Arc;
2
3use bytes::Bytes;
4use prost::Message as _;
5
6use futu_core::error::{FutuError, Result};
7use futu_domain_qot_indicator::{
8    CatalogPushResult, IndicatorInfo, IndicatorInputValue, IndicatorLanguage, IndicatorParam,
9    IndicatorPushOp, IndicatorRefreshOwner, IndicatorRefreshTicket, RefreshProgress,
10};
11
12use crate::proto_internal::{
13    ft_index_info_common as common, ft_python_indicator_info as py_info,
14    ft_python_indicator_param as py_param,
15};
16use crate::{command_runtime::execute_indicator_read, conn::BackendConn};
17
18use futu_command_spec::IndicatorOperation;
19
20#[must_use]
21pub fn build_mylang_info_request(uid: u64, start_guid: &str) -> common::FTindexDownloadReq {
22    common::FTindexDownloadReq {
23        uid: Some(uid),
24        guid: Vec::new(),
25        last_updated_time: None,
26        start_guid: non_empty(start_guid),
27    }
28}
29
30#[must_use]
31pub fn build_mylang_param_request(uid: u64, start_guid: &str) -> common::FtIndexParamDownloadReq {
32    common::FtIndexParamDownloadReq {
33        uid: Some(uid),
34        guid: Vec::new(),
35        last_updated_time: None,
36        start_guid: non_empty(start_guid),
37    }
38}
39
40#[must_use]
41pub fn build_python_info_request(start_guid: &str) -> py_info::PythonIndicatorInfoDownloadReq {
42    py_info::PythonIndicatorInfoDownloadReq {
43        start_guid: non_empty(start_guid),
44    }
45}
46
47#[must_use]
48pub fn build_python_param_request(start_guid: &str) -> py_param::PythonIndicatorParamDownloadReq {
49    py_param::PythonIndicatorParamDownloadReq {
50        guid: Vec::new(),
51        start_guid: non_empty(start_guid),
52    }
53}
54
55#[must_use]
56pub fn adapt_mylang_info(mut item: common::IndexInfo) -> Option<IndicatorInfo> {
57    // C++ `CIndicatorDataManager::UpdateSvrMyLangInfo` normalizes the data-layer
58    // object before parser creation/cache publication
59    // (`CIndicatorDataManager.cpp:637-649`,
60    // `CIndicatorDataManagerDetail.cpp:285-306`). Both download and push share
61    // this adapter, so the typed script and retained field-complete payload see
62    // exactly the same normalized representation.
63    if let Some(script) = item.index_script.as_mut() {
64        script.make_ascii_uppercase();
65    }
66    for parameter in &mut item.index_func_param {
67        if let Some(name) = parameter.name.as_mut() {
68            name.make_ascii_uppercase();
69        }
70    }
71    if let Some(short_name) = item
72        .title
73        .as_mut()
74        .and_then(|title| title.short_cut.as_mut())
75    {
76        short_name.make_ascii_uppercase();
77    }
78    let raw_payload = item.encode_to_vec();
79    let guid = item.guid.clone().filter(|guid| !guid.is_empty())?;
80    let title = item.title.as_ref();
81    let short_name = title
82        .and_then(|title| title.short_cut.as_ref())
83        .filter(|name| !name.is_empty())
84        .cloned()
85        .unwrap_or_else(|| cpp_guid_short_name(&guid));
86    Some(IndicatorInfo {
87        language: IndicatorLanguage::MyLang,
88        guid,
89        short_name: Some(short_name),
90        // Full-name resolution depends on FTString.id translation and current
91        // app language. The sync layer owns neither, so it retains the complete
92        // title in raw_payload and leaves projection unresolved.
93        full_name: None,
94        script: item.index_script,
95        revision: item.update_timestamp.or(item.create_timestamp),
96        raw_payload,
97    })
98}
99
100#[must_use]
101pub fn adapt_python_info(item: common::PythonIndicatorInfo) -> Option<IndicatorInfo> {
102    let raw_payload = item.encode_to_vec();
103    let guid = item.guid.clone().filter(|guid| !guid.is_empty())?;
104    Some(IndicatorInfo {
105        language: IndicatorLanguage::Python,
106        guid,
107        // The backend schema carries no structured Python display metadata.
108        // Task 12 must not infer it by scanning arbitrary script text.
109        short_name: None,
110        full_name: None,
111        script: item.script,
112        revision: item.update_timestamp.or(item.version.map(i64::from)),
113        raw_payload,
114    })
115}
116
117#[must_use]
118pub fn adapt_mylang_param(item: common::IndexParam) -> Option<IndicatorParam> {
119    let raw_payload = item.encode_to_vec();
120    let guid = item.guid.clone().filter(|guid| !guid.is_empty())?;
121    Some(IndicatorParam {
122        language: IndicatorLanguage::MyLang,
123        guid,
124        input_values: item
125            .param_float_val
126            .iter()
127            .map(|value| IndicatorInputValue::Float(format_float(*value)))
128            .collect(),
129        output_styles: item
130            .color_val
131            .iter()
132            .map(|value| Some(i64::from(*value)))
133            .collect(),
134        revision: None,
135        raw_payload,
136    })
137}
138
139#[must_use]
140pub fn adapt_python_param(item: common::PythonIndicatorParam) -> Option<IndicatorParam> {
141    let raw_payload = item.encode_to_vec();
142    let guid = item.guid.clone().filter(|guid| !guid.is_empty())?;
143    Some(IndicatorParam {
144        language: IndicatorLanguage::Python,
145        guid,
146        input_values: item.input.iter().map(python_input_value).collect(),
147        // Full style fields remain in raw_payload; this typed summary preserves
148        // one stable entry per declared output without parsing script text.
149        output_styles: item
150            .output_line
151            .iter()
152            .map(|style| style.line_type.map(i64::from))
153            .collect(),
154        revision: None,
155        raw_payload,
156    })
157}
158
159#[must_use]
160pub const fn decode_indicator_push_op(value: i32) -> Option<IndicatorPushOp> {
161    match value {
162        1 => Some(IndicatorPushOp::Add),
163        2 => Some(IndicatorPushOp::Update),
164        3 => Some(IndicatorPushOp::Delete),
165        _ => None,
166    }
167}
168
169pub async fn refresh_indicator_metadata(
170    backend: &BackendConn,
171    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
172    uid: u64,
173    owner: IndicatorRefreshOwner,
174    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
175) -> Result<Arc<futu_domain_qot_indicator::IndicatorCatalogSnapshot>> {
176    ensure_current_owner(owner, owner_is_current)?;
177    let ticket = cache.begin_refresh(owner);
178    refresh_indicator_metadata_with_ticket(backend, cache, uid, ticket, owner_is_current).await
179}
180
181pub async fn refresh_indicator_metadata_with_ticket(
182    backend: &BackendConn,
183    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
184    uid: u64,
185    ticket: IndicatorRefreshTicket,
186    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
187) -> Result<Arc<futu_domain_qot_indicator::IndicatorCatalogSnapshot>> {
188    if let Err(error) = ensure_current_owner(ticket.owner(), owner_is_current) {
189        cache.fail_stream(ticket, IndicatorLanguage::MyLang, true);
190        return Err(error);
191    }
192    let result = tokio::try_join!(
193        download_mylang_info(backend, cache, ticket, uid, owner_is_current),
194        download_mylang_param(backend, cache, ticket, uid, owner_is_current),
195        download_python_info(backend, cache, ticket, owner_is_current),
196        download_python_param(backend, cache, ticket, owner_is_current),
197    );
198    if let Err(error) = result {
199        cache.fail_stream(ticket, IndicatorLanguage::MyLang, true);
200        return Err(error);
201    }
202    let snapshot = cache.snapshot().ok_or_else(|| {
203        FutuError::Codec("indicator four-stream refresh finished without publication".into())
204    })?;
205    if snapshot.owner != ticket.owner() {
206        return Err(FutuError::Codec(
207            "indicator refresh was superseded before composite publication".into(),
208        ));
209    }
210    Ok(snapshot)
211}
212
213pub fn apply_mylang_info_push(
214    cache: &futu_cache::indicator::IndicatorMetadataCache,
215    owner: IndicatorRefreshOwner,
216    body: &[u8],
217) -> Result<CatalogPushResult> {
218    let push = common::InfoPush::decode(body).map_err(FutuError::Proto)?;
219    let op = push_op(push.opr)?;
220    let item = push
221        .item
222        .and_then(adapt_mylang_info)
223        .ok_or_else(|| FutuError::Codec("MyLang info push missing guid item".into()))?;
224    cache
225        .apply_info_push(owner, op, item)
226        .map_err(indicator_state_error)
227}
228
229pub fn apply_mylang_param_push(
230    cache: &futu_cache::indicator::IndicatorMetadataCache,
231    owner: IndicatorRefreshOwner,
232    body: &[u8],
233) -> Result<CatalogPushResult> {
234    let push = common::ParamPush::decode(body).map_err(FutuError::Proto)?;
235    let op = push_op(push.opr)?;
236    let item = push
237        .item
238        .and_then(adapt_mylang_param)
239        .ok_or_else(|| FutuError::Codec("MyLang param push missing guid item".into()))?;
240    cache
241        .apply_param_push(owner, op, item)
242        .map_err(indicator_state_error)
243}
244
245pub fn apply_python_info_push(
246    cache: &futu_cache::indicator::IndicatorMetadataCache,
247    owner: IndicatorRefreshOwner,
248    body: &[u8],
249) -> Result<CatalogPushResult> {
250    let push = py_info::PythonIndicatorInfoPush::decode(body).map_err(FutuError::Proto)?;
251    let op = push_op(push.opr)?;
252    let item = push
253        .item
254        .and_then(adapt_python_info)
255        .ok_or_else(|| FutuError::Codec("Python info push missing guid item".into()))?;
256    cache
257        .apply_info_push(owner, op, item)
258        .map_err(indicator_state_error)
259}
260
261pub fn apply_python_param_push(
262    cache: &futu_cache::indicator::IndicatorMetadataCache,
263    owner: IndicatorRefreshOwner,
264    body: &[u8],
265) -> Result<CatalogPushResult> {
266    let push = py_param::PythonParamPush::decode(body).map_err(FutuError::Proto)?;
267    let op = push_op(push.opr)?;
268    let item = push
269        .item
270        .and_then(adapt_python_param)
271        .ok_or_else(|| FutuError::Codec("Python param push missing guid item".into()))?;
272    cache
273        .apply_param_push(owner, op, item)
274        .map_err(indicator_state_error)
275}
276
277async fn download_mylang_info(
278    backend: &BackendConn,
279    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
280    ticket: IndicatorRefreshTicket,
281    uid: u64,
282    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
283) -> Result<()> {
284    let mut cursor = String::new();
285    loop {
286        let response = execute_indicator_read(
287            backend,
288            IndicatorOperation::MyLangInfoDownload,
289            Bytes::from(build_mylang_info_request(uid, &cursor).encode_to_vec()),
290        )
291        .await?;
292        let page =
293            common::FTindexDownloadRsp::decode(response.body.as_ref()).map_err(FutuError::Proto)?;
294        ensure_page_success(page.ret_code, page.ret_msg.as_deref(), "MyLang info")?;
295        ensure_current_owner(ticket.owner(), owner_is_current)?;
296        let progress = cache
297            .apply_info_page(
298                ticket,
299                IndicatorLanguage::MyLang,
300                page.items
301                    .into_iter()
302                    .filter_map(adapt_mylang_info)
303                    .collect(),
304                page.next_start_guid.as_deref(),
305            )
306            .map_err(indicator_state_error)?;
307        match progress {
308            RefreshProgress::Continue(next) => cursor = next,
309            RefreshProgress::Pending | RefreshProgress::Published(_) => return Ok(()),
310        }
311    }
312}
313
314async fn download_mylang_param(
315    backend: &BackendConn,
316    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
317    ticket: IndicatorRefreshTicket,
318    uid: u64,
319    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
320) -> Result<()> {
321    let mut cursor = String::new();
322    loop {
323        let response = execute_indicator_read(
324            backend,
325            IndicatorOperation::MyLangParamDownload,
326            Bytes::from(build_mylang_param_request(uid, &cursor).encode_to_vec()),
327        )
328        .await?;
329        let page = common::FtIndexParamDownloadRsp::decode(response.body.as_ref())
330            .map_err(FutuError::Proto)?;
331        ensure_page_success(page.ret_code, page.ret_msg.as_deref(), "MyLang param")?;
332        ensure_current_owner(ticket.owner(), owner_is_current)?;
333        let progress = cache
334            .apply_param_page(
335                ticket,
336                IndicatorLanguage::MyLang,
337                page.items
338                    .into_iter()
339                    .filter_map(adapt_mylang_param)
340                    .collect(),
341                page.next_start_guid.as_deref(),
342            )
343            .map_err(indicator_state_error)?;
344        match progress {
345            RefreshProgress::Continue(next) => cursor = next,
346            RefreshProgress::Pending | RefreshProgress::Published(_) => return Ok(()),
347        }
348    }
349}
350
351async fn download_python_info(
352    backend: &BackendConn,
353    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
354    ticket: IndicatorRefreshTicket,
355    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
356) -> Result<()> {
357    let mut cursor = String::new();
358    loop {
359        let response = execute_indicator_read(
360            backend,
361            IndicatorOperation::PythonInfoDownload,
362            Bytes::from(build_python_info_request(&cursor).encode_to_vec()),
363        )
364        .await?;
365        let page = py_info::PythonIndicatorInfoDownloadRsp::decode(response.body.as_ref())
366            .map_err(FutuError::Proto)?;
367        ensure_page_success(page.ret_code, page.ret_msg.as_deref(), "Python info")?;
368        ensure_current_owner(ticket.owner(), owner_is_current)?;
369        let progress = cache
370            .apply_info_page(
371                ticket,
372                IndicatorLanguage::Python,
373                page.items
374                    .into_iter()
375                    .filter_map(adapt_python_info)
376                    .collect(),
377                page.next_start_guid.as_deref(),
378            )
379            .map_err(indicator_state_error)?;
380        match progress {
381            RefreshProgress::Continue(next) => cursor = next,
382            RefreshProgress::Pending | RefreshProgress::Published(_) => return Ok(()),
383        }
384    }
385}
386
387async fn download_python_param(
388    backend: &BackendConn,
389    cache: &Arc<futu_cache::indicator::IndicatorMetadataCache>,
390    ticket: IndicatorRefreshTicket,
391    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
392) -> Result<()> {
393    let mut cursor = String::new();
394    loop {
395        let response = execute_indicator_read(
396            backend,
397            IndicatorOperation::PythonParamDownload,
398            Bytes::from(build_python_param_request(&cursor).encode_to_vec()),
399        )
400        .await?;
401        let page = py_param::PythonIndicatorParamDownloadRsp::decode(response.body.as_ref())
402            .map_err(FutuError::Proto)?;
403        ensure_page_success(page.ret_code, page.ret_msg.as_deref(), "Python param")?;
404        ensure_current_owner(ticket.owner(), owner_is_current)?;
405        let progress = cache
406            .apply_param_page(
407                ticket,
408                IndicatorLanguage::Python,
409                page.items
410                    .into_iter()
411                    .filter_map(adapt_python_param)
412                    .collect(),
413                page.next_start_guid.as_deref(),
414            )
415            .map_err(indicator_state_error)?;
416        match progress {
417            RefreshProgress::Continue(next) => cursor = next,
418            RefreshProgress::Pending | RefreshProgress::Published(_) => return Ok(()),
419        }
420    }
421}
422
423fn ensure_current_owner(
424    owner: IndicatorRefreshOwner,
425    owner_is_current: &(impl Fn(IndicatorRefreshOwner) -> bool + Sync),
426) -> Result<()> {
427    if owner_is_current(owner) {
428        Ok(())
429    } else {
430        Err(FutuError::Codec(
431            "indicator refresh belongs to a retired auth/connection generation".into(),
432        ))
433    }
434}
435
436fn ensure_page_success(code: Option<i32>, message: Option<&str>, label: &str) -> Result<()> {
437    // Proto2 scalar absence reads as the declared default 0 in C++
438    // (`NNBiz_Qot_IndicatorInfo.cpp:68-76`,
439    // `NNBiz_Qot_IndicatorParam.cpp:72-80`). This is wire-default parity, not
440    // a fabricated business success value.
441    match code.unwrap_or(0) {
442        0 => Ok(()),
443        code => Err(FutuError::ServerError {
444            ret_type: code,
445            msg: message
446                .map(str::to_string)
447                .unwrap_or_else(|| format!("{label} backend rejected request")),
448        }),
449    }
450}
451
452fn push_op(value: Option<i32>) -> Result<IndicatorPushOp> {
453    value
454        .and_then(decode_indicator_push_op)
455        .ok_or_else(|| FutuError::Codec("indicator push has invalid operation".into()))
456}
457
458fn indicator_state_error(error: futu_domain_qot_indicator::IndicatorApplyError) -> FutuError {
459    FutuError::Codec(format!(
460        "indicator metadata state rejected update: {error:?}"
461    ))
462}
463
464fn non_empty(value: &str) -> Option<String> {
465    (!value.is_empty()).then(|| value.to_string())
466}
467
468fn cpp_guid_short_name(guid: &str) -> String {
469    // C++ `std::string::substr(0, 8)` in
470    // `CIndicatorDataManagerBuild.cpp:56-69,335-347`. Indicator GUIDs are
471    // protocol-defined ASCII hex; retain a character-safe fallback only for an
472    // invalid non-ASCII protobuf string rather than panic in diagnostics.
473    if guid.is_ascii() {
474        guid[..guid.len().min(8)].to_string()
475    } else {
476        guid.chars().take(8).collect()
477    }
478}
479
480fn format_float(value: f32) -> String {
481    let mut text = format!("{value}");
482    if text.contains('.') {
483        while text.ends_with('0') {
484            text.pop();
485        }
486        if text.ends_with('.') {
487            text.pop();
488        }
489    }
490    text
491}
492
493fn python_input_value(value: &common::PythonIndicatorInputParam) -> IndicatorInputValue {
494    let Some(raw_kind) = value.r#type else {
495        return IndicatorInputValue::Missing {
496            declared_type: None,
497        };
498    };
499    let Ok(kind) = common::IndicatorInputType::try_from(raw_kind) else {
500        return IndicatorInputValue::UnknownType(raw_kind);
501    };
502    let missing = || IndicatorInputValue::Missing {
503        declared_type: Some(raw_kind),
504    };
505    match kind {
506        common::IndicatorInputType::InputInt => value
507            .int_value
508            .map(IndicatorInputValue::Int)
509            .unwrap_or_else(missing),
510        common::IndicatorInputType::InputFloat => value
511            .float_value
512            .map(|value| IndicatorInputValue::Float(format_float(value)))
513            .unwrap_or_else(missing),
514        common::IndicatorInputType::InputString => value
515            .string_value
516            .clone()
517            .map(IndicatorInputValue::String)
518            .unwrap_or_else(missing),
519        common::IndicatorInputType::InputBool => value
520            .bool_value
521            .map(IndicatorInputValue::Bool)
522            .unwrap_or_else(missing),
523        common::IndicatorInputType::InputColor => value
524            .int_value
525            .map(IndicatorInputValue::Color)
526            .unwrap_or_else(missing),
527        common::IndicatorInputType::InputShape => value
528            .int_value
529            .map(IndicatorInputValue::Shape)
530            .unwrap_or_else(missing),
531        common::IndicatorInputType::InputLine => value
532            .int_value
533            .map(IndicatorInputValue::Line)
534            .unwrap_or_else(missing),
535    }
536}