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