1use std::collections::{HashMap, VecDeque};
4use std::time::{Duration, Instant};
5
6use parking_lot::Mutex;
7use prost::Message;
8
9use crate::conn::IncomingRequest;
10use futu_core::proto_id::*;
11
12const FREQ_WINDOW: Duration = Duration::from_secs(30);
14const CPP_ACC_DEFAULT_BUCKET: u64 = 0;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17enum FreqScope {
18 CppDefaultAccount,
26}
27
28impl FreqScope {
29 fn bucket_scope_id(self, _conn_id: u64) -> u64 {
30 match self {
31 Self::CppDefaultAccount => CPP_ACC_DEFAULT_BUCKET,
32 }
33 }
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37enum PageCursorKind {
38 None,
39 BytesNextReqKey,
40 StringNextKey,
41 StringPage,
42 Offset,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46struct ProtoFreqRule {
47 proto_id: u32,
48 limit: u32,
49 scope: FreqScope,
50 page_cursor: PageCursorKind,
51}
52
53impl ProtoFreqRule {
54 const fn cpp_default(proto_id: u32, limit: u32, page_cursor: PageCursorKind) -> Self {
55 Self {
56 proto_id,
57 limit,
58 scope: FreqScope::CppDefaultAccount,
59 page_cursor,
60 }
61 }
62
63 const fn non_pageable(proto_id: u32, limit: u32) -> Self {
64 Self::cpp_default(proto_id, limit, PageCursorKind::None)
65 }
66
67 fn is_pageable(self) -> bool {
68 self.page_cursor != PageCursorKind::None
69 }
70}
71
72const PROTO_FREQ_RULES: &[ProtoFreqRule] = &[
78 ProtoFreqRule::non_pageable(GET_USER_INFO, 10),
79 ProtoFreqRule::non_pageable(VERIFICATION, 10),
80 ProtoFreqRule::non_pageable(TRD_UNLOCK_TRADE, 10),
81 ProtoFreqRule::non_pageable(TRD_GET_MARGIN_RATIO, 10),
86 ProtoFreqRule::non_pageable(TRD_GET_COMBO_MAX_TRD_QTYS, 40),
87 ProtoFreqRule::non_pageable(QOT_GET_SECURITY_SNAPSHOT, 60),
88 ProtoFreqRule::non_pageable(QOT_GET_PLATE_SET, 10),
89 ProtoFreqRule::non_pageable(QOT_GET_PLATE_SECURITY, 10),
90 ProtoFreqRule::non_pageable(QOT_GET_OWNER_PLATE, 10),
91 ProtoFreqRule::non_pageable(QOT_GET_HOLDING_CHANGE_LIST, 10),
92 ProtoFreqRule::non_pageable(QOT_GET_OPTION_CHAIN, 10),
93 ProtoFreqRule::non_pageable(QOT_GET_OPTION_QUOTE, 60),
94 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY_ANALYSIS, 60),
95 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY, 60),
96 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY_SPREAD, 60),
97 ProtoFreqRule::cpp_default(QOT_REQUEST_HISTORY_KL, 60, PageCursorKind::BytesNextReqKey),
98 ProtoFreqRule::non_pageable(QOT_GET_WARRANT, 60),
99 ProtoFreqRule::non_pageable(QOT_REQUEST_REHAB, 60),
100 ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_FLOW, 30),
101 ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_DISTRIBUTION, 60),
102 ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY, 10),
103 ProtoFreqRule::non_pageable(QOT_MODIFY_USER_SECURITY, 10),
104 ProtoFreqRule::non_pageable(QOT_STOCK_FILTER, 10),
105 ProtoFreqRule::non_pageable(QOT_STOCK_SCREEN, 10),
106 ProtoFreqRule::non_pageable(QOT_OPTION_SCREEN, 10),
107 ProtoFreqRule::non_pageable(QOT_WARRANT_SCREEN, 60),
108 ProtoFreqRule::non_pageable(QOT_GET_IPO_LIST, 10),
109 ProtoFreqRule::non_pageable(QOT_GET_FUTURE_INFO, 30),
110 ProtoFreqRule::non_pageable(QOT_REQUEST_TRADE_DATE, 30),
111 ProtoFreqRule::non_pageable(QOT_SET_PRICE_REMINDER, 60),
112 ProtoFreqRule::non_pageable(QOT_GET_PRICE_REMINDER, 10),
113 ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY_GROUP, 10),
114 ProtoFreqRule::non_pageable(QOT_GET_MARKET_STATE, 10),
115 ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXPIRATION_DATE, 60),
116 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_MOVE, 30),
117 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_HISTORY, 30),
118 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_STATEMENTS, 30),
119 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_REVENUE_BREAKDOWN, 30),
120 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_ANALYST_CONSENSUS, 30),
121 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_RATING_SUMMARY, 30),
122 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_MORNINGSTAR_REPORT, 30),
123 ProtoFreqRule::non_pageable(QOT_GET_VALUATION_DETAIL, 30),
124 ProtoFreqRule::non_pageable(QOT_GET_VALUATION_PLATE_STOCK_LIST, 30),
125 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_DIVIDENDS, 30),
126 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_BUYBACKS, 30),
127 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_STOCK_SPLITS, 30),
128 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_OVERVIEW, 30),
129 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_HOLDING_CHANGES, 30),
130 ProtoFreqRule::cpp_default(
131 QOT_GET_SHAREHOLDERS_HOLDER_DETAIL,
132 30,
133 PageCursorKind::StringNextKey,
134 ),
135 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_INSTITUTIONAL, 30),
136 ProtoFreqRule::cpp_default(
137 QOT_GET_INSIDER_HOLDER_LIST,
138 30,
139 PageCursorKind::StringNextKey,
140 ),
141 ProtoFreqRule::cpp_default(
142 QOT_GET_INSIDER_TRADE_LIST,
143 30,
144 PageCursorKind::StringNextKey,
145 ),
146 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_PROFILE, 30),
147 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVES, 30),
148 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVE_BACKGROUND, 30),
149 ProtoFreqRule::cpp_default(
150 QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY,
151 30,
152 PageCursorKind::StringNextKey,
153 ),
154 ProtoFreqRule::non_pageable(QOT_GET_TOP_TEN_BUY_SELL_BROKERS, 30),
155 ProtoFreqRule::non_pageable(QOT_GET_DAILY_SHORT_VOLUME, 30),
156 ProtoFreqRule::non_pageable(QOT_GET_SHORT_INTEREST, 30),
157 ProtoFreqRule::non_pageable(QOT_GET_OPTION_VOLATILITY, 30),
158 ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXERCISE_PROBABILITY, 30),
159 ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_CALENDAR, 60),
161 ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_LIST, 60),
162 ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_HISTORY, 60),
163 ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_TARGET_RATE, 60),
164 ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_DOT_PLOT, 60),
165 ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_BEAT_RANK, 60),
166 ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_RANK, 60),
167 ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_CALENDAR, 60),
168 ProtoFreqRule::non_pageable(QOT_GET_ECONOMIC_CALENDAR, 60),
169 ProtoFreqRule::cpp_default(QOT_GET_US_PRE_MARKET_RANK, 60, PageCursorKind::Offset),
170 ProtoFreqRule::cpp_default(QOT_GET_US_AFTER_HOURS_RANK, 60, PageCursorKind::Offset),
171 ProtoFreqRule::cpp_default(QOT_GET_US_OVERNIGHT_RANK, 60, PageCursorKind::Offset),
172 ProtoFreqRule::cpp_default(QOT_GET_TOP_MOVERS_RANK, 60, PageCursorKind::Offset),
173 ProtoFreqRule::cpp_default(QOT_GET_HOT_LIST, 60, PageCursorKind::Offset),
174 ProtoFreqRule::cpp_default(QOT_GET_SHORT_SELLING_RANK, 60, PageCursorKind::Offset),
175 ProtoFreqRule::cpp_default(QOT_GET_PERIOD_CHANGE_RANK, 60, PageCursorKind::Offset),
176 ProtoFreqRule::cpp_default(QOT_GET_HIGH_DIVIDEND_SOE_RANK, 60, PageCursorKind::Offset),
177 ProtoFreqRule::cpp_default(QOT_GET_INSTITUTION_LIST, 60, PageCursorKind::StringPage),
178 ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_PROFILE, 60),
179 ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_DISTRIBUTION, 60),
180 ProtoFreqRule::cpp_default(
181 QOT_GET_INSTITUTION_HOLDING_CHANGE,
182 60,
183 PageCursorKind::StringPage,
184 ),
185 ProtoFreqRule::cpp_default(
186 QOT_GET_INSTITUTION_HOLDING_LIST,
187 60,
188 PageCursorKind::StringPage,
189 ),
190 ProtoFreqRule::cpp_default(QOT_GET_ARK_FUND_HOLDING, 60, PageCursorKind::StringPage),
191 ProtoFreqRule::non_pageable(QOT_GET_ARK_STOCK_DYNAMIC, 60),
192 ProtoFreqRule::cpp_default(
193 QOT_GET_ARK_ACTIVE_TRANSACTION,
194 60,
195 PageCursorKind::StringPage,
196 ),
197 ProtoFreqRule::cpp_default(QOT_GET_RATING_CHANGE, 60, PageCursorKind::StringPage),
198 ProtoFreqRule::cpp_default(
199 QOT_GET_INDUSTRIAL_CHAIN_LIST,
200 60,
201 PageCursorKind::StringPage,
202 ),
203 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_DETAIL, 60),
204 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE, 60),
205 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_PLATE_INFO, 60),
206 ProtoFreqRule::cpp_default(
207 QOT_GET_INDUSTRIAL_PLATE_STOCK,
208 60,
209 PageCursorKind::StringPage,
210 ),
211 ProtoFreqRule::cpp_default(QOT_GET_HEAT_MAP_DATA, 60, PageCursorKind::StringPage),
212 ProtoFreqRule::non_pageable(QOT_GET_RISE_FALL_DISTRIBUTION, 60),
213];
214
215struct ConnFreqRecord {
217 last_serial: u32,
219}
220
221impl ConnFreqRecord {
222 fn new() -> Self {
223 Self { last_serial: 0 }
224 }
225}
226
227#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
228struct FreqBucketKey {
229 scope_id: u64,
230 proto_id: u32,
231}
232
233pub struct ProtectionManager {
235 records: Mutex<HashMap<u64, ConnFreqRecord>>,
236 freq_buckets: Mutex<HashMap<FreqBucketKey, VecDeque<Instant>>>,
237 proto_limits: HashMap<u32, ProtoFreqRule>,
238}
239
240impl ProtectionManager {
241 pub fn new() -> Self {
244 Self {
245 records: Mutex::new(HashMap::new()),
246 freq_buckets: Mutex::new(HashMap::new()),
247 proto_limits: PROTO_FREQ_RULES
248 .iter()
249 .map(|rule| (rule.proto_id, *rule))
250 .collect(),
251 }
252 }
253
254 pub fn check_freq_limit(&self, conn_id: u64, proto_id: u32) -> bool {
258 self.check_freq_limit_page(conn_id, proto_id, true)
259 }
260
261 pub fn check_request_freq_limit(&self, conn_id: u64, request: &IncomingRequest) -> bool {
266 let mapped_proto = map_proto_to_freq_limit_group(request.proto_id);
267 let is_first_page = self
268 .proto_limits
269 .get(&mapped_proto)
270 .filter(|rule| rule.is_pageable())
271 .and_then(|_| request_is_first_page_for_limit(mapped_proto, request.body.as_ref()))
272 .unwrap_or(true);
273 self.check_freq_limit_page(conn_id, request.proto_id, is_first_page)
274 }
275
276 pub fn check_freq_limit_page(&self, conn_id: u64, proto_id: u32, is_first_page: bool) -> bool {
281 let mapped_proto = map_proto_to_freq_limit_group(proto_id);
282 let item = match self.proto_limits.get(&mapped_proto) {
283 Some(item) => *item,
284 None => return false, };
286
287 if item.is_pageable() && !is_first_page {
288 return false;
289 }
290
291 let mut buckets = self.freq_buckets.lock();
292 let times = buckets
293 .entry(freq_bucket_key(conn_id, mapped_proto, item.scope))
294 .or_default();
295 let now = Instant::now();
296
297 while times
299 .front()
300 .is_some_and(|t| now.duration_since(*t) > FREQ_WINDOW)
301 {
302 times.pop_front();
303 }
304
305 if times.len() as u32 >= item.limit {
306 true } else {
308 times.push_back(now);
309 false
310 }
311 }
312
313 pub fn check_replay(&self, conn_id: u64, serial_no: u32) -> bool {
317 let mut records = self.records.lock();
318 let record = records.entry(conn_id).or_insert_with(ConnFreqRecord::new);
319
320 if serial_no <= record.last_serial {
321 true } else {
323 record.last_serial = serial_no;
324 false
325 }
326 }
327
328 pub fn on_disconnect(&self, conn_id: u64) {
330 self.records.lock().remove(&conn_id);
331 self.freq_buckets
332 .lock()
333 .retain(|key, _| key.scope_id != conn_id);
334 }
335}
336
337fn map_proto_to_freq_limit_group(proto_id: u32) -> u32 {
338 if proto_id == futu_core::proto_id::TRD_PLACE_COMBO_ORDER {
339 futu_core::proto_id::TRD_PLACE_ORDER
340 } else {
341 proto_id
342 }
343}
344
345fn freq_bucket_key(conn_id: u64, proto_id: u32, scope: FreqScope) -> FreqBucketKey {
346 FreqBucketKey {
347 scope_id: scope.bucket_scope_id(conn_id),
348 proto_id,
349 }
350}
351
352fn request_is_first_page_for_limit(proto_id: u32, body: &[u8]) -> Option<bool> {
353 match proto_id {
354 QOT_REQUEST_HISTORY_KL => {
355 decode_request::<futu_proto::qot_request_history_kl::Request, _>(body, |req| {
356 bytes_cursor_is_empty(&req.c2s.next_req_key)
357 })
358 }
359 QOT_GET_SHAREHOLDERS_HOLDER_DETAIL => {
360 decode_request::<futu_proto::qot_get_shareholders_holder_detail::Request, _>(
361 body,
362 |req| string_cursor_is_empty(&req.c2s.next_key),
363 )
364 }
365 QOT_GET_INSIDER_HOLDER_LIST => {
366 decode_request::<futu_proto::qot_get_insider_holder_list::Request, _>(body, |req| {
367 string_cursor_is_empty(&req.c2s.next_key)
368 })
369 }
370 QOT_GET_INSIDER_TRADE_LIST => {
371 decode_request::<futu_proto::qot_get_insider_trade_list::Request, _>(body, |req| {
372 string_cursor_is_empty(&req.c2s.next_key)
373 })
374 }
375 QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY => {
376 decode_request::<futu_proto::qot_get_company_operational_efficiency::Request, _>(
377 body,
378 |req| string_cursor_is_empty(&req.c2s.next_key),
379 )
380 }
381 QOT_GET_US_PRE_MARKET_RANK => decode_request::<
382 futu_proto::qot_get_us_pre_market_rank::Request,
383 _,
384 >(body, |req| offset_is_first_page(req.c2s.offset)),
385 QOT_GET_US_AFTER_HOURS_RANK => decode_request::<
386 futu_proto::qot_get_us_after_hours_rank::Request,
387 _,
388 >(body, |req| offset_is_first_page(req.c2s.offset)),
389 QOT_GET_US_OVERNIGHT_RANK => decode_request::<
390 futu_proto::qot_get_us_overnight_rank::Request,
391 _,
392 >(body, |req| offset_is_first_page(req.c2s.offset)),
393 QOT_GET_TOP_MOVERS_RANK => {
394 decode_request::<futu_proto::qot_get_top_movers_rank::Request, _>(body, |req| {
395 offset_is_first_page(req.c2s.offset)
396 })
397 }
398 QOT_GET_HOT_LIST => {
399 decode_request::<futu_proto::qot_get_hot_list::Request, _>(body, |req| {
400 offset_is_first_page(req.c2s.offset)
401 })
402 }
403 QOT_GET_SHORT_SELLING_RANK => decode_request::<
404 futu_proto::qot_get_short_selling_rank::Request,
405 _,
406 >(body, |req| offset_is_first_page(req.c2s.offset)),
407 QOT_GET_PERIOD_CHANGE_RANK => decode_request::<
408 futu_proto::qot_get_period_change_rank::Request,
409 _,
410 >(body, |req| offset_is_first_page(req.c2s.offset)),
411 QOT_GET_HIGH_DIVIDEND_SOE_RANK => {
412 decode_request::<futu_proto::qot_get_high_dividend_soe_rank::Request, _>(body, |req| {
413 offset_is_first_page(req.c2s.offset)
414 })
415 }
416 QOT_GET_INSTITUTION_LIST => decode_request::<
417 futu_proto::qot_get_institution_list::Request,
418 _,
419 >(body, |req| string_cursor_is_empty(&req.c2s.page)),
420 QOT_GET_INSTITUTION_HOLDING_CHANGE => {
421 decode_request::<futu_proto::qot_get_institution_holding_change::Request, _>(
422 body,
423 |req| string_cursor_is_empty(&req.c2s.page),
424 )
425 }
426 QOT_GET_INSTITUTION_HOLDING_LIST => {
427 decode_request::<futu_proto::qot_get_institution_holding_list::Request, _>(
428 body,
429 |req| string_cursor_is_empty(&req.c2s.page),
430 )
431 }
432 QOT_GET_ARK_FUND_HOLDING => decode_request::<
433 futu_proto::qot_get_ark_fund_holding::Request,
434 _,
435 >(body, |req| string_cursor_is_empty(&req.c2s.page)),
436 QOT_GET_ARK_ACTIVE_TRANSACTION => {
437 decode_request::<futu_proto::qot_get_ark_active_transaction::Request, _>(body, |req| {
438 string_cursor_is_empty(&req.c2s.page)
439 })
440 }
441 QOT_GET_RATING_CHANGE => {
442 decode_request::<futu_proto::qot_get_rating_change::Request, _>(body, |req| {
443 string_cursor_is_empty(&req.c2s.page)
444 })
445 }
446 QOT_GET_INDUSTRIAL_CHAIN_LIST => {
447 decode_request::<futu_proto::qot_get_industrial_chain_list::Request, _>(body, |req| {
448 string_cursor_is_empty(&req.c2s.page)
449 })
450 }
451 QOT_GET_INDUSTRIAL_PLATE_STOCK => {
452 decode_request::<futu_proto::qot_get_industrial_plate_stock::Request, _>(body, |req| {
453 string_cursor_is_empty(&req.c2s.page)
454 })
455 }
456 QOT_GET_HEAT_MAP_DATA => {
457 decode_request::<futu_proto::qot_get_heat_map_data::Request, _>(body, |req| {
458 string_cursor_is_empty(&req.c2s.page)
459 })
460 }
461 _ => None,
462 }
463}
464
465#[cfg(test)]
466fn has_first_page_decoder(proto_id: u32) -> bool {
467 matches!(
468 proto_id,
469 QOT_REQUEST_HISTORY_KL
470 | QOT_GET_SHAREHOLDERS_HOLDER_DETAIL
471 | QOT_GET_INSIDER_HOLDER_LIST
472 | QOT_GET_INSIDER_TRADE_LIST
473 | QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY
474 | QOT_GET_US_PRE_MARKET_RANK
475 | QOT_GET_US_AFTER_HOURS_RANK
476 | QOT_GET_US_OVERNIGHT_RANK
477 | QOT_GET_TOP_MOVERS_RANK
478 | QOT_GET_HOT_LIST
479 | QOT_GET_SHORT_SELLING_RANK
480 | QOT_GET_PERIOD_CHANGE_RANK
481 | QOT_GET_HIGH_DIVIDEND_SOE_RANK
482 | QOT_GET_INSTITUTION_LIST
483 | QOT_GET_INSTITUTION_HOLDING_CHANGE
484 | QOT_GET_INSTITUTION_HOLDING_LIST
485 | QOT_GET_ARK_FUND_HOLDING
486 | QOT_GET_ARK_ACTIVE_TRANSACTION
487 | QOT_GET_RATING_CHANGE
488 | QOT_GET_INDUSTRIAL_CHAIN_LIST
489 | QOT_GET_INDUSTRIAL_PLATE_STOCK
490 | QOT_GET_HEAT_MAP_DATA
491 )
492}
493
494fn decode_request<M, F>(body: &[u8], is_first_page: F) -> Option<bool>
495where
496 M: Message + Default,
497 F: FnOnce(M) -> bool,
498{
499 M::decode(body).ok().map(is_first_page)
500}
501
502fn bytes_cursor_is_empty(cursor: &Option<Vec<u8>>) -> bool {
503 cursor.as_ref().is_none_or(Vec::is_empty)
504}
505
506fn string_cursor_is_empty(cursor: &Option<String>) -> bool {
507 cursor.as_deref().unwrap_or("").is_empty()
508}
509
510fn offset_is_first_page(offset: Option<i32>) -> bool {
511 offset.unwrap_or(0) == 0
513}
514
515impl Default for ProtectionManager {
516 fn default() -> Self {
517 Self::new()
518 }
519}
520
521#[cfg(test)]
522mod tests;