Skip to main content

futu_rest/routes/qot/
quotes.rs

1//! Split from routes/qot.rs: quotes.
2//!
3//! pub items (REST handlers): get_basic_qot,get_kl,get_order_book,get_broker,get_ticker,get_rt.
4
5use axum::extract::{Extension, Json, State};
6use futu_auth::KeyRecord;
7use serde_json::Value;
8use std::sync::Arc;
9
10use crate::caller_context::CallerContext;
11use futu_core::proto_id;
12
13use super::*;
14
15pub async fn get_basic_qot(
16    State(state): State<RestState>,
17    rec: Option<Extension<Arc<KeyRecord>>>,
18    Json(body): Json<Value>,
19) -> ApiResult {
20    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
21    let resp =
22        proto_request_shared_conn::<qot_get_basic_qot::Request, qot_get_basic_qot::Response>(
23            &state,
24            proto_id::QOT_GET_BASIC_QOT,
25            Some(body),
26            Some(&ctx),
27        )
28        .await?;
29    Ok(Json(annotate_quote_cache_miss(resp.0)))
30}
31
32/// v1.4.90 P2-E helper: 检测 `/api/quote` 响应的 cache miss 并加 loud hint.
33///
34/// cache miss 判定: ret_type=0 + s2c 是 object + basic_qot_list 是空 array.
35/// 加 ret_msg 引导用户订阅 + 重试. 其他情况 (ret_type≠0 / s2c 缺省 / s2c
36/// 不是 object / list 缺省) 原样返回, 防止误改 mock 测试或 handler 异常路径.
37pub(super) fn annotate_quote_cache_miss(mut v: Value) -> Value {
38    let should_trigger = (|v: &Value| -> bool {
39        let ret_ok = v
40            .as_object()
41            .and_then(|o| o.get("ret_type"))
42            .and_then(|t| t.as_i64())
43            == Some(0);
44        if !ret_ok {
45            return false;
46        }
47        let s2c_obj = match v.get("s2c").and_then(|s| s.as_object()) {
48            Some(o) => o,
49            None => return false,
50        };
51        let list_arr = match s2c_obj.get("basic_qot_list").and_then(|l| l.as_array()) {
52            Some(a) => a,
53            None => return false,
54        };
55        list_arr.is_empty()
56    })(&v);
57    if !should_trigger {
58        return v;
59    }
60    if let Some(obj) = v.as_object_mut() {
61        let hint = "Quote cache miss (无 push 数据). \
62            如果未订阅, 先 POST /api/subscribe with sub_type=1 (Basic). \
63            若已订阅, 等首条 push 到达 (~1-3s) 再重试 /api/quote. \
64            (push_cache 全局, REST/MCP/WS 任一 conn 订阅都会填同一 cache)";
65        obj.insert("ret_msg".to_string(), Value::String(hint.to_string()));
66    }
67    v
68}
69
70/// POST /api/kline — 获取K线数据
71pub async fn get_kl(
72    State(state): State<RestState>,
73    rec: Option<Extension<Arc<KeyRecord>>>,
74    Json(body): Json<Value>,
75) -> ApiResult {
76    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
77    proto_request_shared_conn::<qot_get_kl::Request, qot_get_kl::Response>(
78        &state,
79        proto_id::QOT_GET_KL,
80        Some(body),
81        Some(&ctx),
82    )
83    .await
84}
85
86/// POST /api/orderbook — 获取摆盘
87///
88/// **v1.4.90 P2-F fix**: 未订阅 silent empty → 改 loud ret=-1 hint, 对齐
89/// C++ OpenD "please subscribe first" 行为.
90///
91/// 背景:`get_order_book` handler 从 global QotCache 读. 未订阅 → cache 空 →
92/// 返 ret_type=0 + 空 ask/bid lists. C++ OpenD 在 handler 入口检测 sub_info
93/// 为空 → 直接返 ret=-1. Rust gateway handler 走 silent-success(CLAUDE.md
94/// 坑 #45)— 这是 "ret_type:0 前必须有真实 downstream effect" 反模式的变种
95/// (读路径上的 silent empty).
96///
97/// **修法选择**: gateway handler 改动会触碰 cross-channel forward (MCP /
98/// gRPC / WS 都共享同一 handler), v1.4.90 多 agent 并行约束本 agent 只改
99/// REST. 在 REST 层 post-process: 检测 ask_list 和 bid_list 都空 → 重写
100/// ret_type=-1 + loud hint. 对调用方等价于 C++ "please subscribe first" 体感.
101///
102/// **判定边界**: ask_list AND bid_list 都空 = 未订阅 (即使深度无价位时
103/// C++/backend 也会返至少 1 条占位). 仅一边空 = 订阅了但单边无报价 (合法,
104/// 不改). 注意此 heuristic 在"订阅了但 push 还没到"时可能误报为"未订阅"
105/// — hint 文本明示这两种情况, 用户区分.
106pub async fn get_order_book(
107    State(state): State<RestState>,
108    rec: Option<Extension<Arc<KeyRecord>>>,
109    Json(body): Json<Value>,
110) -> ApiResult {
111    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
112    let resp =
113        proto_request_shared_conn::<qot_get_order_book::Request, qot_get_order_book::Response>(
114            &state,
115            proto_id::QOT_GET_ORDER_BOOK,
116            Some(body),
117            Some(&ctx),
118        )
119        .await?;
120    Ok(Json(orderbook_loud_unsub_hint(resp.0)))
121}
122
123/// v1.4.90 P2-F helper: 检测 orderbook 完全空时改 ret=-1 + loud hint.
124///
125/// 仅 ret_type=0 + s2c 是 object + ask_list/bid_list 都是空数组时触发.
126/// 其他情况原样返回:
127/// - s2c 不存在 / null = mock 测试或 handler 异常, 不重写
128/// - ask_list/bid_list 字段缺省 = handler 没填 = 不假设是 unsub, 不重写
129/// - 任一边非空 = 已订阅且有报价
130pub(super) fn orderbook_loud_unsub_hint(mut v: Value) -> Value {
131    // 把所有"是否触发"判断收敛到一个 closure 里, 借用范围限定在 closure
132    // 内部, 防止跨 borrow + return v 报错.
133    let should_trigger = (|v: &Value| -> bool {
134        let ret_ok = v
135            .as_object()
136            .and_then(|o| o.get("ret_type"))
137            .and_then(|t| t.as_i64())
138            == Some(0);
139        if !ret_ok {
140            return false;
141        }
142        let s2c_obj = match v.get("s2c").and_then(|s| s.as_object()) {
143            Some(o) => o,
144            None => return false,
145        };
146        // ask_list 和 bid_list 必须都是 array 才视作"handler 实际跑过 cache
147        // 读取"路径; 任一字段不是 array (如 null / 缺省) 视作 mock / 异常,
148        // 不重写.
149        let ask_arr = match s2c_obj
150            .get("order_book_ask_list")
151            .and_then(|l| l.as_array())
152        {
153            Some(a) => a,
154            None => return false,
155        };
156        let bid_arr = match s2c_obj
157            .get("order_book_bid_list")
158            .and_then(|l| l.as_array())
159        {
160            Some(a) => a,
161            None => return false,
162        };
163        ask_arr.is_empty() && bid_arr.is_empty()
164    })(&v);
165    if !should_trigger {
166        return v;
167    }
168    // 改写 ret_type=-1 + ret_msg hint, 对齐 C++ OpenD 未订阅返 ret=-1 行为.
169    let hint = "OrderBook 未订阅或 push 未到. \
170        Please call POST /api/subscribe with sub_type=2 (OrderBook) first \
171        for the requested security. \
172        若已订阅, 等首条 push 到达 (~1-3s) 再重试 /api/orderbook. \
173        对齐 C++ OpenD: 未订阅 OrderBook 返 ret=-1.";
174    if let Some(obj) = v.as_object_mut() {
175        obj.insert("ret_type".to_string(), Value::from(-1_i64));
176        obj.insert("ret_msg".to_string(), Value::String(hint.to_string()));
177    }
178    // 触发 [err_code=none] 包装 (对齐 v1.4.34 BUG-2b REST 错误响应规约).
179    // 已经经过 adapter::proto_request 的 maybe_wrap_err_code_prefix, 但那次
180    // 是 ret_type=0 早 return; 现在 ret_type 改成 -1 后需要重跑包装.
181    wrap_err_code_prefix_inline(&mut v);
182    v
183}
184
185/// POST /api/broker — 获取经纪队列
186pub async fn get_broker(State(state): State<RestState>, Json(body): Json<Value>) -> ApiResult {
187    adapter::proto_request::<qot_get_broker::Request, qot_get_broker::Response>(
188        &state,
189        proto_id::QOT_GET_BROKER,
190        Some(body),
191    )
192    .await
193}
194
195/// POST /api/ticker — 获取逐笔
196pub async fn get_ticker(
197    State(state): State<RestState>,
198    rec: Option<Extension<Arc<KeyRecord>>>,
199    Json(body): Json<Value>,
200) -> ApiResult {
201    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
202    proto_request_shared_conn::<qot_get_ticker::Request, qot_get_ticker::Response>(
203        &state,
204        proto_id::QOT_GET_TICKER,
205        Some(body),
206        Some(&ctx),
207    )
208    .await
209}
210
211/// POST /api/rt — 获取分时数据
212pub async fn get_rt(
213    State(state): State<RestState>,
214    rec: Option<Extension<Arc<KeyRecord>>>,
215    Json(body): Json<Value>,
216) -> ApiResult {
217    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
218    proto_request_shared_conn::<qot_get_rt::Request, qot_get_rt::Response>(
219        &state,
220        proto_id::QOT_GET_RT,
221        Some(body),
222        Some(&ctx),
223    )
224    .await
225}