Skip to main content

futu_rest/routes/qot/
misc.rs

1//! Split from routes/qot.rs: misc.
2
3use axum::extract::{Extension, Json, State};
4use axum::http::StatusCode;
5use futu_auth::KeyRecord;
6use serde_json::Value;
7use std::sync::Arc;
8
9use crate::caller_context::CallerContext;
10use futu_core::proto_id;
11
12use super::*;
13
14use super::subscribe::body_requests_unsub_all;
15
16pub(super) fn normalize_financial_calendar_rest_body_for_internal_proto(body: Value) -> Value {
17    match body {
18        Value::Object(mut obj) if obj.len() == 1 => match obj.remove("c2s") {
19            Some(Value::Object(c2s)) => Value::Object(c2s),
20            Some(c2s) => {
21                let mut restored = serde_json::Map::new();
22                restored.insert("c2s".to_string(), c2s);
23                Value::Object(restored)
24            }
25            None => Value::Object(obj),
26        },
27        other => other,
28    }
29}
30
31/// POST /api/unsubscribe-all — 反订阅(或 unsub_all)
32///
33/// 复用 qot_sub proto,需要用户传 `is_sub_or_un_sub=false` 或
34/// `is_unsub_all=true`。这是给 REST 直接调用者的便捷端点。
35///
36/// **v1.4.90 P0-B fix**: 用 `REST_SHARED_CONN` 替代 `state.next_conn_id()` —
37/// 必须与 subscribe path 用同一 conn_id, 否则 SubscriptionManager 找不到任何
38/// 挂载, unsubscribe 静默 no-op, quota 永不释放.
39///
40/// **v1.4.104 codex round 3 F1 (P1) fix**: `/api/unsubscribe` 是 sibling
41/// route 也复用 QOT_SUB proto + REST_SHARED_CONN, 之前 F5 fix 只在
42/// `subscribe()` 加 `body_requests_unsub_all()` reject — 用户切到
43/// `/api/unsubscribe` 仍可 process-wide unsub all REST callers' subs.
44/// 真 runtime path bypass. 现在两条 REST 路径**统一**走相同 reject.
45pub async fn unsubscribe(
46    State(state): State<RestState>,
47    rec: Option<Extension<Arc<KeyRecord>>>,
48    Json(body): Json<Value>,
49) -> RawApiResult {
50    // codex round 3 F1: 拒绝 REST is_unsub_all=true (process-wide cross-caller
51    // 影响, REST_SHARED_CONN 共享 bucket). 与 subscribe() route 行为一致.
52    if body_requests_unsub_all(&body) {
53        return Err((
54            StatusCode::BAD_REQUEST,
55            Json(serde_json::json!({
56                "error": "/api/unsubscribe with is_unsub_all=true is **REST process-wide** — \
57                          all REST callers share REST_SHARED_CONN (v1.4.90 P0-B), so \
58                          清掉一个 = 清掉**所有 REST clients** 的 qot 订阅 (跨 caller \
59                          影响). v1.4.104 codex round 3 F1 P1 fix: 默认 reject (与 \
60                          /api/subscribe is_unsub_all reject 一致, 防 sibling-route bypass). \
61                          替代方案:\n  \
62                          (a) 单 symbol unsubscribe: 列具体 sec_list + sub_type_list + \
63                              is_sub_or_un_sub=false (per-key safe);\n  \
64                          (b) MCP / gRPC / WS surface 调 unsub_all (各 caller 有自己 conn_id). \
65                          REST 当前没有 process-wide opt-in 或 admin clear endpoint.",
66                "v1.4.104_audit_round3_f1_fix": true,
67                "alternatives": [
68                    "use explicit security_list + sub_type_list + is_sub_or_un_sub=false",
69                    "use MCP / gRPC / WS for per-caller unsub_all",
70                ],
71            })),
72        ));
73    }
74    // codex 0522 F3 v1.4.106: build CallerContext per-call.
75    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
76    proto_request_shared_conn_raw::<qot_sub::Request, qot_sub::Response>(
77        &state,
78        proto_id::QOT_SUB,
79        Some(body),
80        Some(&ctx),
81    )
82    .await
83}
84
85/// v1.4.74 A2 BUG-013 fix: POST /api/query-subscription — 查订阅状态
86///
87/// 对齐 MCP `futu_query_subscription`。body 可含 `is_req_all_conn: bool` 决定
88/// 查当前连接 or 所有连接。
89///
90/// **v1.4.83 §7 fix**(双 tester v1.4.81 §7 tracking 撒谎根治):**REST 默认
91/// `is_req_all_conn=true`**(REST stateless,每次请求分配新 virtual conn_id,
92/// 只查当前 conn_id 的订阅总是空 → silent confusing)。用户显式传
93/// `{"is_req_all_conn": false}` 限制到当前 conn_id。
94///
95/// 对齐 v1.4.78 B3 `/api/sub-info` 文档化:sub-info per-conn by design;
96/// query-subscription **推荐生产用**,因为 REST 用户没有长期 conn_id 概念.
97///
98/// 返回结构与 `/api/sub-info` 类似,但 `/api/sub-info` 是 GET 不传 body
99/// (v1.4.83 起 GET 也默认 all-conn),POST query-subscription 更灵活。
100pub async fn query_subscription(
101    State(state): State<RestState>,
102    rec: Option<Extension<Arc<KeyRecord>>>,
103    Json(mut body): Json<Value>,
104) -> RawApiResult {
105    inject_default_is_req_all_conn(&mut body, true);
106    // v1.4.90 P0-B: 用 REST_SHARED_CONN — is_req_all_conn=false 时返本 conn_id
107    // 订阅, 必须与 subscribe 用同一 conn_id 才有非空结果.
108    // codex 0522 F3 v1.4.106: 接 ctx 让 handler 识别 caller key.
109    let ctx = CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
110    proto_request_shared_conn_raw::<qot_get_sub_info::Request, qot_get_sub_info::Response>(
111        &state,
112        proto_id::QOT_GET_SUB_INFO,
113        Some(body),
114        Some(&ctx),
115    )
116    .await
117}
118
119/// v1.4.83 §7: inject default `is_req_all_conn=true` 到 body 的 c2s 嵌套层。
120/// 若用户已显式传(任何值),保留用户值不覆盖。
121pub(super) fn inject_default_is_req_all_conn(body: &mut Value, default: bool) {
122    let obj = match body.as_object_mut() {
123        Some(o) => o,
124        None => return, // 非 object body (比如 null / 数组) 不动
125    };
126    // 优先处理 c2s 嵌套
127    if let Some(Value::Object(c2s)) = obj.get_mut("c2s") {
128        c2s.entry("is_req_all_conn").or_insert(Value::Bool(default));
129        return;
130    }
131    // flat body (adapter maybe_wrap_flat_body_as_c2s 之前状态): 直接在顶层
132    // 加 is_req_all_conn, 稍后 wrap 时会进入 c2s
133    obj.entry("is_req_all_conn").or_insert(Value::Bool(default));
134}
135
136/// v1.4.74 A2 BUG-013 fix: POST /api/list-plates — 列板块
137///
138/// 是 `/api/plate-set` 的 alias(REST 早期 endpoint 名,对齐 MCP `futu_list_plates`)。
139pub async fn list_plates(State(state): State<RestState>, Json(body): Json<Value>) -> RawApiResult {
140    adapter::proto_request_raw::<
141        futu_proto::qot_get_plate_set::Request,
142        futu_proto::qot_get_plate_set::Response,
143    >(&state, proto_id::QOT_GET_PLATE_SET, Some(body))
144    .await
145}
146
147// ===================================================================
148// v1.4.98 (mobile-source-audit Phase 2) — quote 类新 endpoint REST routes
149// ===================================================================
150
151/// v1.4.98 T2-2: 无风险利率 (期权定价).
152///
153/// **POST /api/risk-free-rate** (无 body / 可选 `c2s.rate_time`).
154///
155/// 返 HK/US/JP 3 市场无风险利率 (百分比 + raw uint64). backend cmd 20231
156/// (注释明标"无加密"). 期权 trader 做 Black-Scholes 定价必备数据.
157pub async fn get_risk_free_rate(
158    State(state): State<RestState>,
159    body: Option<Json<Value>>,
160) -> RawApiResult {
161    let body_val = body.map(|Json(v)| v);
162    adapter::proto_request_raw::<
163        futu_backend::proto_internal::risk_free_rate::DaemonGetRiskFreeRateReq,
164        futu_backend::proto_internal::risk_free_rate::DaemonGetRiskFreeRateRsp,
165    >(&state, proto_id::QOT_GET_RISK_FREE_RATE, body_val)
166    .await
167}
168
169/// v1.4.115: 财报日历视图 (mobile/moomoo read-only extension).
170///
171/// **POST /api/financial-calendar** with
172/// `financial_calendar::GetFinancialStatementCalendarViewReq` JSON shape.
173/// `begin_date <= financial date < end_date`; dates use `YYYYMMDD`.
174pub async fn get_financial_calendar(
175    State(state): State<RestState>,
176    Json(body): Json<Value>,
177) -> RawApiResult {
178    adapter::proto_request_raw_spec_body::<
179        futu_backend::proto_internal::financial_calendar::GetFinancialStatementCalendarViewReq,
180        futu_backend::proto_internal::financial_calendar::GetFinancialStatementCalendarViewRsp,
181    >(
182        &state,
183        futu_core::proto_id::QOT_GET_FINANCIAL_CALENDAR_VIEW_INTERNAL,
184        Some(normalize_financial_calendar_rest_body_for_internal_proto(
185            body,
186        )),
187    )
188    .await
189}
190
191/// v1.4.115: 指定股票财报日历搜索 (mobile/moomoo read-only extension).
192///
193/// **POST /api/financial-calendar-target** with
194/// `financial_calendar::SearchTargetStockFinancialCalendarReq` JSON shape.
195pub async fn search_target_financial_calendar(
196    State(state): State<RestState>,
197    Json(body): Json<Value>,
198) -> RawApiResult {
199    adapter::proto_request_raw_spec_body::<
200        futu_backend::proto_internal::financial_calendar::SearchTargetStockFinancialCalendarReq,
201        futu_backend::proto_internal::financial_calendar::SearchTargetStockFinancialCalendarRsp,
202    >(
203        &state,
204        futu_core::proto_id::QOT_SEARCH_TARGET_FINANCIAL_CALENDAR,
205        Some(normalize_financial_calendar_rest_body_for_internal_proto(
206            body,
207        )),
208    )
209    .await
210}
211
212/// v1.4.98 T2-1: 摆盘步长 SpreadTable (cmd 6503).
213///
214/// **POST /api/spread-table** (无 body / 可选 reserved). 返全部价位表 list,
215/// 每条含 spread_code + spread_item_list (price_from/to + value, 价格已 / 1e9
216/// 还原成 f64). 客户端 ModifyOrder/PlaceOrder 校验价格合法性必备.
217pub async fn get_spread_table(
218    State(state): State<RestState>,
219    body: Option<Json<Value>>,
220) -> RawApiResult {
221    let body_val = body.map(|Json(v)| v);
222    adapter::proto_request_raw::<
223        futu_backend::proto_internal::spread_table_6503::DaemonGetSpreadTableReq,
224        futu_backend::proto_internal::spread_table_6503::DaemonGetSpreadTableRsp,
225    >(&state, proto_id::QOT_GET_SPREAD_TABLE, body_val)
226    .await
227}
228
229/// v1.4.98 T2-3: 逐笔统计 TickerStatistic (cmd 6365).
230///
231/// **POST /api/ticker-statistic** (`{"c2s": {"symbol": "HK.00700", ...}}`).
232/// daemon 内部 static_cache 解析 stock_id, 然后调 backend cmd 6365. 返均价 /
233/// 成交量 / 主买/主卖/中性量等统计概览.
234///
235/// **前置**: symbol 必须先 subscribe / get_static_info 触发 static_cache 填充.
236pub async fn get_ticker_statistic(
237    State(state): State<RestState>,
238    Json(body): Json<Value>,
239) -> RawApiResult {
240    adapter::proto_request_raw::<
241        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticReq,
242        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticRsp,
243    >(&state, proto_id::QOT_GET_TICKER_STATISTIC, Some(body))
244    .await
245}
246
247/// v1.4.106 codex 0500 ζ23-redo: 逐笔统计 Detail (cmd 6366).
248///
249/// **POST /api/ticker-statistic-detail** (`{"c2s": {"symbol": "HK.00700",
250/// "ticker_time": <u64 from /api/ticker-statistic>, "select_num": 0,
251/// "data_from": 0, "data_max_count": 20, ...}}`).
252/// daemon 内部 static_cache 解析 stock_id, 然后调 backend cmd 6366 拿
253/// 价位级 detail 列表 (DetailItem with price / volume / ratio).
254///
255/// **前置**: symbol 必须先 subscribe / get_static_info 触发 static_cache 填充.
256/// 配套 cmd 6365: 客户端先 call /api/ticker-statistic 拿 ticker_time,
257/// 再 call /api/ticker-statistic-detail 同 ticker_time 拿这个时点的价位
258/// 分布. 也可省略 ticker_time 用 backend 默认 (latest available).
259pub async fn get_ticker_statistic_detail(
260    State(state): State<RestState>,
261    Json(body): Json<Value>,
262) -> RawApiResult {
263    adapter::proto_request_raw::<
264        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq,
265        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticDetailRsp,
266    >(
267        &state,
268        proto_id::QOT_GET_TICKER_STATISTIC_DETAIL,
269        Some(body),
270    )
271    .await
272}