Skip to main content

futu_rest/routes/qot/
mod.rs

1//! 行情 REST API 路由
2//!
3//! 所有行情相关接口通过 proto_request 适配到现有 handler。
4
5use axum::extract::Json;
6use axum::http::StatusCode;
7use bytes::Bytes;
8use prost::Message;
9use serde_json::Value;
10
11use futu_codec::header::ProtoFmtType;
12use futu_proto::qot_get_basic_qot;
13use futu_proto::qot_get_broker;
14use futu_proto::qot_get_capital_distribution;
15use futu_proto::qot_get_capital_flow;
16use futu_proto::qot_get_code_change;
17use futu_proto::qot_get_company_executive_background;
18use futu_proto::qot_get_company_executives;
19use futu_proto::qot_get_company_operational_efficiency;
20use futu_proto::qot_get_company_profile;
21use futu_proto::qot_get_corporate_actions_buybacks;
22use futu_proto::qot_get_corporate_actions_dividends;
23use futu_proto::qot_get_corporate_actions_stock_splits;
24use futu_proto::qot_get_daily_short_volume;
25use futu_proto::qot_get_financials_earnings_price_history;
26use futu_proto::qot_get_financials_earnings_price_move;
27use futu_proto::qot_get_financials_revenue_breakdown;
28use futu_proto::qot_get_financials_statements;
29use futu_proto::qot_get_future_info;
30use futu_proto::qot_get_holding_change_list;
31use futu_proto::qot_get_insider_holder_list;
32use futu_proto::qot_get_insider_trade_list;
33use futu_proto::qot_get_ipo_list;
34use futu_proto::qot_get_kl;
35use futu_proto::qot_get_market_state;
36use futu_proto::qot_get_option_chain;
37use futu_proto::qot_get_option_exercise_probability;
38use futu_proto::qot_get_option_expiration_date;
39use futu_proto::qot_get_option_quote;
40use futu_proto::qot_get_option_strategy;
41use futu_proto::qot_get_option_strategy_analysis;
42use futu_proto::qot_get_option_strategy_spread;
43use futu_proto::qot_get_option_volatility;
44use futu_proto::qot_get_order_book;
45use futu_proto::qot_get_owner_plate;
46use futu_proto::qot_get_plate_security;
47use futu_proto::qot_get_plate_set;
48use futu_proto::qot_get_price_reminder;
49use futu_proto::qot_get_reference;
50use futu_proto::qot_get_research_analyst_consensus;
51use futu_proto::qot_get_research_morningstar_report;
52use futu_proto::qot_get_research_rating_summary;
53use futu_proto::qot_get_rt;
54use futu_proto::qot_get_security_snapshot;
55use futu_proto::qot_get_shareholders_holder_detail;
56use futu_proto::qot_get_shareholders_holding_changes;
57use futu_proto::qot_get_shareholders_institutional;
58use futu_proto::qot_get_shareholders_overview;
59use futu_proto::qot_get_short_interest;
60use futu_proto::qot_get_static_info;
61use futu_proto::qot_get_sub_info;
62use futu_proto::qot_get_suspend;
63use futu_proto::qot_get_ticker;
64use futu_proto::qot_get_top_ten_buy_sell_brokers;
65use futu_proto::qot_get_user_security;
66use futu_proto::qot_get_valuation_detail;
67use futu_proto::qot_get_valuation_plate_stock_list;
68use futu_proto::qot_get_warrant;
69use futu_proto::qot_modify_user_security;
70use futu_proto::qot_option_screen;
71use futu_proto::qot_request_history_kl;
72use futu_proto::qot_request_history_kl_quota;
73use futu_proto::qot_request_rehab;
74use futu_proto::qot_request_trade_date;
75use futu_proto::qot_set_price_reminder;
76use futu_proto::qot_stock_filter;
77use futu_proto::qot_stock_screen;
78use futu_proto::qot_sub;
79use futu_proto::qot_warrant_screen;
80use futu_proto::skill_wrap_api;
81use futu_proto::used_quota;
82use futu_server::conn::IncomingRequest;
83
84use crate::adapter::{self, JsonRequestMode, RestState, decode_json_request};
85
86type ApiResult = Result<Json<Value>, (StatusCode, Json<Value>)>;
87type RawApiResult = Result<adapter::RawJson, (StatusCode, Json<Value>)>;
88
89/// v1.4.90 P0-B: REST 全 endpoint 共享 conn_id(替代之前每次请求 `next_conn_id()`
90/// 自增 → 永久泄漏 sub-quota 直到耗尽 4000 上限)。
91///
92/// **背景**:v1.4.81 一直到 v1.4.89 之前,REST `proto_request` 每次都调
93/// `state.next_conn_id()` 拿新 virtual conn_id(10_000_000 自增)。`SubscriptionManager`
94/// 按 conn_id 记账:subscribe 把 (security, sub_type) 挂在 conn_id 下、quota
95/// +=1;unsubscribe 从同一 conn_id 删除、quota -=1。但 REST `subscribe` 用 conn_id=X
96/// → backend 订阅 ✓,下一次 REST `unsubscribe` 用 conn_id=Y → 找不到任何挂载 →
97/// 静默 no-op,quota 永不释放。日积月累 4000 quota 耗尽,整个 daemon 不能再
98/// 订阅任何东西。
99///
100/// **修法**:所有 REST sub-related endpoint(`subscribe` / `unsubscribe` /
101/// `get_sub_info` / `query_subscription`)锁定到 `REST_SHARED_CONN`. lifecycle
102/// 由 daemon 进程管,不随 REST 请求生灭。
103///
104/// **REST 视角合理性**:REST 是 stateless API,调用方不持续持有 daemon TCP
105/// 连接,"连接 ID" 在 REST 层面没物理意义。把所有 REST 流量当作"REST gateway"
106/// 这一个虚拟客户端的多次调用,对应一个固定 conn_id 是最干净的语义。v1.4.106
107/// 起 quote / kline / orderbook / ticker / rt handler 也会检查
108/// `SubscriptionManager::is_qot_subscribed(conn_id, ...)`,所以这些订阅门禁
109/// 读路径也必须锁定到同一 `REST_SHARED_CONN`,否则 REST subscribe 后下一次
110/// REST read 看不到同一个 conn 的订阅。
111///
112/// **取值选择**:`0xFFFF_FFFE_u64`(4_294_967_294)。`next_conn_id` 起点
113/// 10_000_000,要 ~4.28B 次调用才碰到此值,daemon 早已重启。`0xFFFF_FFFF_u64`
114/// 留给未来可能的 sentinel(如"all REST"广播)。
115///
116/// 对齐 MCP 路径行为:MCP 用单 `FutuClient` 复用底层 TCP,所有 MCP 请求自然
117/// 共享同一个 daemon 分配的 conn_id,不存在该 bug。REST 现在显式实现同语义。
118pub const REST_SHARED_CONN: u64 = 0xFFFF_FFFE;
119
120/// v1.4.90 P0-B: 用 REST_SHARED_CONN 而非 `state.next_conn_id()` 派发请求。
121///
122/// 复刻 `adapter::proto_request_with_idempotency` 的 JSON normalize → encode →
123/// dispatch → decode → JSON 流程,唯一差别:dispatch 时 `conn_id =
124/// REST_SHARED_CONN`. 用于 sub-related endpoint 防 quota 泄漏(见
125/// `REST_SHARED_CONN` 注释)。
126///
127/// 不复用 adapter::proto_request 是因为该函数硬编码 `state.next_conn_id()`,
128/// 改 adapter 会越权(v1.4.90 多 agent 并行约定 agent C 改 adapter,不交叉)。
129///
130/// **codex 0522 F3 v1.4.106 (option B)**: 接 `Option<&CallerContext>` 让
131/// gateway handler 知道 REST caller key 身份, 即使 conn_id 是
132/// `REST_SHARED_CONN` 全局共享 (per-key 订阅配额 / cleanup / 审计). QOT
133/// 行情 path 不直接受 `allowed_acc_ids` 约束, 但 `caller_key_id` 让未来
134/// per-key 订阅 owner 模型 (e.g. QotSubscriptionState owner) 可识别 caller
135/// 而不是看作 "REST 全局".
136async fn proto_request_shared_conn<Req, Rsp>(
137    state: &RestState,
138    proto_id: u32,
139    json_body: Option<Value>,
140    ctx: Option<&crate::caller_context::CallerContext>,
141) -> ApiResult
142where
143    Req: Message + Default + serde::de::DeserializeOwned,
144    Rsp: Message + Default + serde::Serialize,
145{
146    // 1. JSON → protobuf 请求(与 adapter::proto_request_with_idempotency 同源)。
147    // QOT shared-conn 不做 TRD header expansion, 但空 body 同样必须经过
148    // EndpointSpec validation, 防止 required field 被 `Req::default()` 静默绕过。
149    let req_msg: Req = decode_json_request(proto_id, json_body, JsonRequestMode::QotSharedConn)?;
150
151    // 2. encode
152    let body = Bytes::from(req_msg.encode_to_vec());
153
154    // 3. dispatch with REST_SHARED_CONN(区别点)
155    // codex 0522 F3 v1.4.106: 同时填 caller_key_id (per-call snapshot) 让
156    // gateway handler 识别 REST caller 身份. allowed_acc_ids 仍 None
157    // (QOT 行情不直接 acc-bound), 真填走 ctx.caller_allowed_acc_ids_arc.
158    let incoming = IncomingRequest::builder(
159        REST_SHARED_CONN,
160        proto_id,
161        state.next_serial(),
162        ProtoFmtType::Protobuf,
163        body,
164    )
165    .with_caller_scope(
166        ctx.and_then(|c| c.caller_allowed_acc_ids_arc()),
167        ctx.and_then(|c| c.caller_key_id()),
168    )
169    .build();
170    let resp_bytes = state
171        .router
172        .dispatch(REST_SHARED_CONN, &incoming)
173        .await
174        .ok_or_else(|| {
175            (
176                StatusCode::INTERNAL_SERVER_ERROR,
177                Json(serde_json::json!({
178                    "error": "handler returned no response"
179                })),
180            )
181        })?;
182
183    // 4. decode
184    let rsp_msg = Rsp::decode(Bytes::from(resp_bytes)).map_err(|e| {
185        (
186            StatusCode::INTERNAL_SERVER_ERROR,
187            Json(serde_json::json!({
188                "error": format!("failed to decode response: {e}")
189            })),
190        )
191    })?;
192
193    // 5. serialize JSON
194    let mut json_rsp = serde_json::to_value(&rsp_msg).map_err(|e| {
195        (
196            StatusCode::INTERNAL_SERVER_ERROR,
197            Json(serde_json::json!({
198                "error": format!("failed to serialize response: {e}")
199            })),
200        )
201    })?;
202
203    // 6. err_code 前缀(与通用 adapter path 同源,避免 shared-conn REST
204    //    订阅路径和普通 REST 路径的错误契约漂移)
205    adapter::maybe_wrap_err_code_prefix(&mut json_rsp);
206
207    Ok(Json(json_rsp))
208}
209
210async fn proto_request_shared_conn_raw<Req, Rsp>(
211    state: &RestState,
212    proto_id: u32,
213    json_body: Option<Value>,
214    ctx: Option<&crate::caller_context::CallerContext>,
215) -> RawApiResult
216where
217    Req: Message + Default + serde::de::DeserializeOwned,
218    Rsp: Message + Default + serde::Serialize,
219{
220    let req_msg: Req = decode_json_request(proto_id, json_body, JsonRequestMode::QotSharedConn)?;
221    let body = Bytes::from(req_msg.encode_to_vec());
222
223    let incoming = IncomingRequest::builder(
224        REST_SHARED_CONN,
225        proto_id,
226        state.next_serial(),
227        ProtoFmtType::Protobuf,
228        body,
229    )
230    .with_caller_scope(
231        ctx.and_then(|c| c.caller_allowed_acc_ids_arc()),
232        ctx.and_then(|c| c.caller_key_id()),
233    )
234    .build();
235    let resp_bytes = state
236        .router
237        .dispatch(REST_SHARED_CONN, &incoming)
238        .await
239        .ok_or_else(|| {
240            (
241                StatusCode::INTERNAL_SERVER_ERROR,
242                Json(serde_json::json!({
243                    "error": "handler returned no response"
244                })),
245            )
246        })?;
247
248    let rsp_msg = Rsp::decode(Bytes::from(resp_bytes)).map_err(|e| {
249        (
250            StatusCode::INTERNAL_SERVER_ERROR,
251            Json(serde_json::json!({
252                "error": format!("failed to decode response: {e}")
253            })),
254        )
255    })?;
256
257    adapter::raw_json_from_proto_response(&rsp_msg)
258}
259
260#[cfg(test)]
261fn map_surface_spec_error(
262    spec: &'static futu_surface_spec::EndpointSpec,
263    err: futu_surface_spec::DispatchError,
264) -> (StatusCode, Json<Value>) {
265    let proto_id = spec
266        .proto_id()
267        .map(|id| id.to_string())
268        .unwrap_or_else(|| "daemon-local".to_string());
269    let ret_msg = format!(
270        "{} (endpoint: {}, proto_id: {})",
271        err, spec.canonical_name, proto_id
272    );
273    let mut body = validation_error_body(ret_msg);
274    let machine_error_field = spec.runtime.error.machine_error_field;
275    if let Some(obj) = body.as_object_mut() {
276        obj.insert(
277            machine_error_field.to_string(),
278            serde_json::json!({
279                "kind": "validation_error",
280                "message": err.to_string(),
281                "endpoint": spec.canonical_name,
282                "proto_id": proto_id,
283            }),
284        );
285    }
286    (StatusCode::BAD_REQUEST, Json(body))
287}
288
289#[cfg(test)]
290fn validation_error_body(message: impl Into<String>) -> Value {
291    let message = message.into();
292    serde_json::json!({
293        "ret_type": -1,
294        "ret_msg": message,
295        "error": message,
296    })
297}
298
299/// POST /api/subscribe — 订阅/退订行情
300///
301/// **v1.4.90 P0-B fix**: 用 `REST_SHARED_CONN` 替代 `state.next_conn_id()`,
302/// 杜绝 quota 永久泄漏(详见 `REST_SHARED_CONN` 注释).
303///
304/// **v1.4.104 external reviewer S-005 (P1) fix**: REST 层显式检查 `is_sub_or_un_sub` 字段
305/// 在 raw JSON body 是否 present. proto bool 字段 missing 时 prost 默认为
306/// false → handler 走 unsub 路径; 但 unsub 路径对 invalid ticker silent
307/// success (handler 注释 line 187-189: unsub by-design 不做 backend 解析).
308/// agent 调用方默认只传 `symbols`, 不写 boolean → silent 没订阅.
309///
310/// 修法: REST adapter 入口先检查 `is_sub_or_un_sub` 在 body 里 (snake-case
311/// 归一化后) 是否 present. 不 present → 400 提示用户必须显式传字段.
312///
313/// **v1.4.104 codex round 2 F5 (P2) fix**: REST 全部 caller 共用一个虚拟连接
314/// `REST_SHARED_CONN=0xFFFFFFFE` (v1.4.90 P0-B 防 quota 泄漏的设计). 因此
315/// `is_unsub_all=true` 调用会**清掉所有 REST callers** 的 QOT 订阅 (因为大家
316/// 共享同一 conn_id 的 subscription bucket). 对于多 REST client 部署:
317///
318///   - 当前合约: `is_unsub_all=true` 在 REST 显式 reject, 仅返 400 解释
319///     "REST is_unsub_all 是 process-wide 操作, 跨 caller 影响, 默认禁用".
320///   - 可用替代: 显式列 `security_list` + `sub_type_list` 做单 symbol 退订;
321///     或改用 MCP / gRPC / WS 这类 per-conn surface 执行 unsub_all.
322///   - REST 目前没有 process-wide opt-in query, 也没有 admin clear endpoint;
323///     后续若要支持 REST per-key unsub_all, 必须先补独立状态模型与公开契约。
324///
325/// 当前实装: 选项 A 保守 (拒+提示), 防止 caller A 意外清掉 caller B 的 subs.
326/// MCP / gRPC / WS 各 caller 有自己 conn_id, 不受影响.
327// Split: 1408 行 → 5 子文件 (contiguous fn 段)
328mod misc;
329mod quotes;
330mod reference;
331mod snapshot;
332mod subscribe;
333
334#[cfg(test)]
335mod tests;
336
337#[cfg(test)]
338use misc::inject_default_is_req_all_conn;
339#[cfg(test)]
340use quotes::{annotate_quote_cache_miss, orderbook_loud_unsub_hint};
341#[cfg(test)]
342use snapshot::{
343    augment_snapshot_with_exchange_code, augment_static_info_with_exchange_code,
344    check_static_info_input,
345};
346#[cfg(test)]
347use subscribe::body_has_sub_or_unsub_flag;
348
349pub use misc::{
350    get_risk_free_rate, get_spread_table, get_ticker_statistic, get_ticker_statistic_detail,
351    list_plates, query_subscription, unsubscribe,
352};
353pub use quotes::{get_basic_qot, get_broker, get_kl, get_order_book, get_rt, get_ticker};
354pub use reference::{
355    get_capital_distribution, get_capital_flow, get_code_change, get_company_executive_background,
356    get_company_executives, get_company_operational_efficiency, get_company_profile,
357    get_corporate_actions_buybacks, get_corporate_actions_dividends,
358    get_corporate_actions_stock_splits, get_daily_short_volume, get_derivative_unusual,
359    get_financial_unusual, get_financials_earnings_price_history,
360    get_financials_earnings_price_move, get_financials_revenue_breakdown,
361    get_financials_statements, get_future_info, get_holding_change, get_insider_holder_list,
362    get_insider_trade_list, get_ipo_list, get_market_state, get_option_chain,
363    get_option_exercise_probability, get_option_expiration_date, get_option_quote,
364    get_option_strategy, get_option_strategy_analysis, get_option_strategy_spread,
365    get_option_volatility, get_owner_plate, get_plate_security, get_plate_set, get_price_reminder,
366    get_reference, get_research_analyst_consensus, get_research_morningstar_report,
367    get_research_rating_summary, get_shareholders_holder_detail, get_shareholders_holding_changes,
368    get_shareholders_institutional, get_shareholders_overview, get_short_interest, get_suspend,
369    get_technical_unusual, get_top_ten_buy_sell_brokers, get_used_quota, get_user_security,
370    get_valuation_detail, get_valuation_plate_stock_list, get_warrant, modify_user_security,
371    option_screen, request_history_kl, request_history_kl_quota, request_rehab,
372    request_trading_days, set_price_reminder, stock_filter, stock_screen, warrant_screen,
373};
374pub use snapshot::{get_snapshot, get_static_info};
375pub use subscribe::{get_sub_info, subscribe};