|
70 | 70 | 7. Profit! |
71 | 71 | """ |
72 | 72 |
|
73 | | -# Copyright (c) 2022 Gilmillin Timur Mansurovich |
| 73 | +# Copyright (c) 2025 Gilmillin Timur Mansurovich |
74 | 74 | # |
75 | 75 | # Licensed under the Apache License, Version 2.0 (the "License"); |
76 | 76 | # you may not use this file except in compliance with the License. |
|
91 | 91 | import re |
92 | 92 | import json |
93 | 93 | import traceback as tb |
94 | | -from time import sleep |
| 94 | +import threading |
| 95 | +from time import sleep, monotonic |
95 | 96 | from argparse import ArgumentParser |
| 97 | +from collections import defaultdict |
96 | 98 | from importlib.metadata import version |
97 | 99 | from multiprocessing import cpu_count |
98 | 100 |
|
|
139 | 141 | CPU_USAGES = CPU_COUNT - 1 if CPU_COUNT > 1 else 1 # how many CPUs will be used for parallel calculations |
140 | 142 |
|
141 | 143 |
|
| 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 | + |
142 | 250 | class TinkoffBrokerServer: |
143 | 251 | """ |
144 | 252 | 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 |
251 | 359 | """ |
252 | 360 |
|
253 | 361 | 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. |
255 | 363 |
|
256 | 364 | See also: `SendAPIRequest()`. |
257 | 365 | """ |
@@ -448,6 +556,9 @@ def __init__(self, token: str, accountId: str = None, useCache: bool = True, def |
448 | 556 | See also: `LoadHistory()`, `ShowHistoryChart()` and the PriceGenerator project: https://github.com/Tim55667757/PriceGenerator |
449 | 557 | """ |
450 | 558 |
|
| 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 | + |
451 | 562 | @property |
452 | 563 | def tag(self) -> str: |
453 | 564 | """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: |
571 | 682 | currentPause = self.pause # initial pause |
572 | 683 |
|
573 | 684 | 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 | + |
574 | 688 | try: |
575 | 689 | # try to send REST-request: |
576 | 690 | if reqType == "GET": |
@@ -616,9 +730,7 @@ def SendAPIRequest(self, url: str, reqType: str = "GET") -> dict: |
616 | 730 | if response and response.headers and response.headers.get("x-ratelimit-remaining") == "0": |
617 | 731 | rateLimitWait = int(response.headers["x-ratelimit-reset"]) |
618 | 732 |
|
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 |
622 | 734 |
|
623 | 735 | # handling 4xx client errors: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes |
624 | 736 | if response and 400 <= response.status_code < 500: |
@@ -5117,7 +5229,8 @@ def Main(**kwargs): |
5117 | 5229 | # --- set some options: |
5118 | 5230 |
|
5119 | 5231 | 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 |
5121 | 5234 | 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.") |
5122 | 5235 |
|
5123 | 5236 | if args.html: |
|
0 commit comments