Skip to content

Commit 32ded48

Browse files
committed
#152 Add rate limiting to API requests with new RateLimiter class
Introduced a `RateLimiter` class to enforce method-level request rate limits as per Tinkoff API guidelines. Updated the core API request flow to utilize the rate limiter for both client-side and server-enforced rate limit handling. Also added predefined `TKS_METHOD_LIMITS` for rate configurations.
1 parent f683e08 commit 32ded48

File tree

2 files changed

+171
-8
lines changed

2 files changed

+171
-8
lines changed

tksbrokerapi/TKSBrokerAPI.py

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
7. Profit!
7171
"""
7272

73-
# Copyright (c) 2022 Gilmillin Timur Mansurovich
73+
# Copyright (c) 2025 Gilmillin Timur Mansurovich
7474
#
7575
# Licensed under the Apache License, Version 2.0 (the "License");
7676
# you may not use this file except in compliance with the License.
@@ -91,8 +91,10 @@
9191
import re
9292
import json
9393
import traceback as tb
94-
from time import sleep
94+
import threading
95+
from time import sleep, monotonic
9596
from argparse import ArgumentParser
97+
from collections import defaultdict
9698
from importlib.metadata import version
9799
from multiprocessing import cpu_count
98100

@@ -139,6 +141,112 @@
139141
CPU_USAGES = CPU_COUNT - 1 if CPU_COUNT > 1 else 1 # how many CPUs will be used for parallel calculations
140142

141143

144+
class RateLimiter:
145+
def __init__(self, methodLimits: dict[str, int]):
146+
"""
147+
Initialize RateLimiter with given method limits (requests per minute).
148+
149+
:param methodLimits: dict[str, int] with per-method RPM limits.
150+
"""
151+
self.methodLimits = methodLimits
152+
"""Per-method RPM limits."""
153+
154+
self.counters = defaultdict(int)
155+
"""Number of requests sent per method in the current window."""
156+
157+
self.timestamps = defaultdict(float)
158+
"""Timestamp (monotonic) of the start of current window per method."""
159+
160+
self.events = defaultdict(threading.Event)
161+
"""Synchronization event for each method to coordinate waiting."""
162+
163+
self.moreDebug = False
164+
"""Enables more debug information in this class. `False` by default."""
165+
166+
def CheckRateLimit(self, methodName: str) -> None:
167+
"""
168+
Check if the request for the method can proceed. Wait if needed.
169+
170+
:param methodName: str with the method name.
171+
"""
172+
now = monotonic()
173+
limit = self.methodLimits.get(methodName, self.methodLimits["default"])
174+
175+
if self.timestamps[methodName] == 0.0:
176+
self.timestamps[methodName] = now
177+
elapsed = 0.0
178+
179+
else:
180+
elapsed = now - self.timestamps[methodName]
181+
182+
if self.moreDebug:
183+
uLogger.debug(f"[RateLimiter] Checking method='{methodName}': counter={self.counters[methodName]}, elapsed={elapsed:.2f}s, limit={limit}")
184+
185+
# Reset counters if the time window has passed:
186+
if elapsed > 60.0:
187+
if self.moreDebug:
188+
uLogger.debug(f"[RateLimiter] Resetting window for method='{methodName}' after {elapsed:.2f}s.")
189+
190+
self.counters[methodName] = 0
191+
self.timestamps[methodName] = now
192+
self.events[methodName].set()
193+
self.events[methodName].clear()
194+
195+
# If the limit is exceeded, wait cooperatively:
196+
if self.counters[methodName] >= limit:
197+
event = self.events[methodName]
198+
199+
if event.is_set():
200+
if self.moreDebug:
201+
uLogger.debug(f"[RateLimiter] Method='{methodName}' is waiting on event from another thread...")
202+
203+
event.wait()
204+
205+
else:
206+
waitTime = max(0.0, 60.0 - elapsed)
207+
208+
uLogger.debug(f"[RateLimiter] Limit reached for method='{methodName}'. Waiting {waitTime:.2f}s...")
209+
210+
sleep(waitTime)
211+
212+
self.counters[methodName] = 0
213+
self.timestamps[methodName] = monotonic()
214+
215+
event.set()
216+
event.clear()
217+
218+
self.counters[methodName] += 1
219+
220+
def HandleServerRateLimit(self, methodName: str, waitTime: int) -> None:
221+
"""
222+
Handle server-enforced rate limit using cooperative waiting.
223+
224+
:param methodName: str with the method name.
225+
:param waitTime: int time in seconds to wait before retrying the request.
226+
"""
227+
uLogger.debug(f"[RateLimiter] Server-enforced limit for method='{methodName}', waitTime={waitTime}s.")
228+
229+
event = self.events[methodName]
230+
231+
if event.is_set():
232+
if self.moreDebug:
233+
uLogger.debug(f"[RateLimiter] Method='{methodName}' waiting on event triggered by server limit...")
234+
235+
event.wait()
236+
237+
else:
238+
if self.moreDebug:
239+
uLogger.debug(f"[RateLimiter] Sleeping {waitTime}s due to server rate limit for method='{methodName}'.")
240+
241+
sleep(waitTime)
242+
243+
self.counters[methodName] = 0
244+
self.timestamps[methodName] = monotonic()
245+
246+
event.set()
247+
event.clear()
248+
249+
142250
class TinkoffBrokerServer:
143251
"""
144252
This class implements methods to work with Tinkoff broker server.
@@ -251,7 +359,7 @@ def __init__(self, token: str, accountId: str = None, useCache: bool = True, def
251359
"""
252360

253361
self.pause = 3
254-
"""Sleep time in seconds between retries, in all network requests 5 seconds by default.
362+
"""Sleep time in seconds between retries, in all network requests 3 seconds by default.
255363
256364
See also: `SendAPIRequest()`.
257365
"""
@@ -448,6 +556,9 @@ def __init__(self, token: str, accountId: str = None, useCache: bool = True, def
448556
See also: `LoadHistory()`, `ShowHistoryChart()` and the PriceGenerator project: https://github.com/Tim55667757/PriceGenerator
449557
"""
450558

559+
self.rateLimiter = RateLimiter(methodLimits=TKS_METHOD_LIMITS) # init RateLimiter object to work with `TKS_METHOD_LIMITS`
560+
"""RateLimiter object to work with given method limits (requests per minute) from `TKS_METHOD_LIMITS`."""
561+
451562
@property
452563
def tag(self) -> str:
453564
"""Identification TKSBrokerAPI tag in log messages to simplify debugging when platform instances runs in parallel mode. Default: `""` (empty string)."""
@@ -571,6 +682,9 @@ def SendAPIRequest(self, url: str, reqType: str = "GET") -> dict:
571682
currentPause = self.pause # initial pause
572683

573684
while not response and counter <= self.retry:
685+
methodName = url.split("/")[-1].split("?")[0] # method name in `TKS_METHOD_LIMITS`
686+
self.rateLimiter.CheckRateLimit(methodName) # checking rate limits...
687+
574688
try:
575689
# try to send REST-request:
576690
if reqType == "GET":
@@ -616,9 +730,7 @@ def SendAPIRequest(self, url: str, reqType: str = "GET") -> dict:
616730
if response and response.headers and response.headers.get("x-ratelimit-remaining") == "0":
617731
rateLimitWait = int(response.headers["x-ratelimit-reset"])
618732

619-
uLogger.debug("Rate limit exceeded. Waiting {} sec. for reset rate limit and then repeat again...".format(rateLimitWait))
620-
621-
sleep(rateLimitWait)
733+
self.rateLimiter.HandleServerRateLimit(methodName, rateLimitWait) # wait if broker returns rate limit
622734

623735
# handling 4xx client errors: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
624736
if response and 400 <= response.status_code < 500:
@@ -5117,7 +5229,8 @@ def Main(**kwargs):
51175229
# --- set some options:
51185230

51195231
if args.more:
5120-
trader.moreDebug = True
5232+
trader.moreDebug = True # enables more debug information in class TinkoffBrokerServer class
5233+
trader.rateLimiter.moreDebug = True # enables more debug information in RateLimiter class
51215234
uLogger.warning("More debug mode is enabled! See network requests, responses and its headers in the full log or run TKSBrokerAPI platform with the `--verbosity 10` to show theres in console.")
51225235

51235236
if args.html:

tksbrokerapi/TKSEnums.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- **Open account for trading:** https://tinkoff.ru/sl/AaX1Et1omnH
1818
"""
1919

20-
# Copyright (c) 2022 Gilmillin Timur Mansurovich
20+
# Copyright (c) 2025 Gilmillin Timur Mansurovich
2121
#
2222
# Licensed under the Apache License, Version 2.0 (the "License");
2323
# you may not use this file except in compliance with the License.
@@ -32,6 +32,56 @@
3232
# limitations under the License.
3333

3434

35+
TKS_METHOD_LIMITS = {
36+
# Default
37+
"default": 50, # Default fallback for unknown or uncategorized endpoints
38+
39+
# Orders
40+
"PostOrder": 50, # Place order (POST /orders)
41+
"CancelOrder": 50, # Cancel order (POST /orders/cancel)
42+
"GetOrders": 50, # Get active orders (GET /orders)
43+
44+
# Stop-orders
45+
"PostStopOrder": 25, # Place stop-order (POST /stop-orders)
46+
"CancelStopOrder": 25, # Cancel stop-order (POST /stop-orders/cancel)
47+
"GetStopOrders": 25, # Get stop-orders (GET /stop-orders)
48+
49+
# Portfolio & positions
50+
"GetPortfolio": 100, # Get portfolio (GET /operations/portfolio)
51+
"GetPositions": 100, # Get positions (GET /operations/positions)
52+
"GetWithdrawLimits": 100, # Get withdrawal limits (GET /operations/withdraw-limits)
53+
54+
# Market data
55+
"GetCandles": 150, # Get candles (GET /market/candles)
56+
"GetOrderBook": 150, # Get orderbook (GET /market/orderbook)
57+
"GetLastPrices": 150, # Get last prices (GET /market/last-prices)
58+
"GetTradingStatus": 150, # Get trading status (GET /market/trading-status)
59+
"GetClosePrices": 150, # Get close prices (GET /market/close-prices)
60+
61+
# History zip-archive
62+
"getHistory": 30, # Get history (GET history-data) https://tinkoff.github.io/investAPI/get_history/
63+
64+
# Instruments
65+
"SearchByFigi": 100, # Search by FIGI (GET /market/search/by-figi)
66+
"SearchByTicker": 100, # Search by ticker (GET /market/search/by-ticker)
67+
"GetInstrumentBy": 100, # Generic search endpoint (GET /market/search)
68+
69+
# Operations
70+
"GetOperations": 100, # Get operations (GET /operations)
71+
72+
# Users
73+
"GetAccounts": 50, # Get accounts (GET /user/accounts)
74+
"GetInfo": 50, # Get user info (GET /user/info)
75+
"GetMarginAttributes": 50, # Margin info (GET /user/margin-attributes)
76+
"GetUserTariff": 50, # Get tariff info (GET /user/tariff)
77+
78+
# Reports
79+
"GetBrokerReport": 100, # Get broker report (POST /operations/broker-report)
80+
"GetDividendsForeignIssuer": 100 # Dividends from foreign issuers (POST /operations/dividends/foreign)
81+
}
82+
"""Unary method limits (per minute) from Tinkoff API: https://tinkoff.github.io/investAPI/limits/"""
83+
84+
3585
TKS_DATE_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
3686
"""Date and time string format used by Tinkoff Open API. Default: `"%Y-%m-%dT%H:%M:%SZ"`."""
3787

0 commit comments

Comments
 (0)