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