Coverage for adrs/oms/rate_limit/exchange_limit_profiles.py: 99%

67 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 05:07 +0800

1from decimal import Decimal 

2from pydantic import BaseModel, Field 

3from typing import Any, Dict, Deque 

4from enum import Enum, auto 

5 

6from cybotrade.models import Exchange 

7 

8 

9# Marks an endpoint whose weight depends on request parameters; the limiter 

10# resolves it per call rather than reading a fixed number out of the cost table. 

11DYNAMIC_WEIGHT = -1 

12 

13# REQUEST_WEIGHT tiers for Binance USDⓈ-M /fapi/v1/depth, as (max limit, weight). 

14# Source: https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api/Order-Book 

15BINANCE_DEPTH_WEIGHTS: tuple[tuple[int, int], ...] = ( 

16 (50, 2), 

17 (100, 5), 

18 (500, 10), 

19 (1000, 20), 

20) 

21 

22# What Binance charges when no limit is sent, i.e. the tier a caller that omits 

23# the parameter silently lands in. The OMS always sends OMS_DEPTH_LIMIT below, so 

24# this is only the cost of *forgetting* to — which is why it stays the default 

25# argument of get_depth_weight(): an unparameterised charge must overcount, never 

26# undercount. 

27BINANCE_DEFAULT_DEPTH_LIMIT = 500 

28 

29# The OMS only ever reads the best bid/ask, so it asks for the shallowest depth 

30# Binance offers. limit<=50 costs weight 2 against 10 for the server default of 

31# 500 — a 5x cut on the OMS's largest weight consumer. This is the single source 

32# for both the request parameter and the weight charged; if they ever diverge the 

33# limiter undercounts and the IP gets banned. 

34OMS_DEPTH_LIMIT = 5 

35 

36 

37def get_depth_weight(limit: int = BINANCE_DEFAULT_DEPTH_LIMIT) -> int: 

38 """Calculates REQUEST_WEIGHT for /fapi/v1/depth""" 

39 for max_limit, weight in BINANCE_DEPTH_WEIGHTS: 

40 if limit <= max_limit: 

41 return weight 

42 # Above the largest documented tier, charge the most Binance ever charges. 

43 return BINANCE_DEPTH_WEIGHTS[-1][1] 

44 

45 

46class Endpoints(Enum): 

47 # Market Data 

48 GET_SERVER_TIME = auto() 

49 GET_SYMBOL_INFO = auto() 

50 GET_ORDERBOOK_SNAPSHOT = auto() 

51 

52 # Account & Trade 

53 PLACE_ORDER = auto() 

54 CANCEL_ORDER = auto() 

55 GET_ORDER_DETAILS = auto() 

56 

57 # Account Info 

58 GET_WALLET_BALANCE = auto() 

59 GET_POSITION = auto() 

60 

61 # Order Info 

62 GET_OPEN_ORDERS = auto() # Getting open orders for one symbol 

63 GET_OPEN_ORDERS_ALL = auto() # Getting open orders for all symbols at once 

64 

65 

66# Weights are per IP; order counts are per account. Both are charged on the 

67# request, whether or not it succeeds. 

68BINANCE_FUTURES_COSTS: dict[Endpoints, dict[str, int]] = { 

69 # /fapi/v1/exchangeInfo really is weight 1. It was charged 20, and 

70 # update_symbol_info() takes a guard per symbol even though cybotrade caches 

71 # exchangeInfo on the client for 10 minutes and only the first call in a 

72 # refresh actually reaches Binance. Overcharging was harmless while each 

73 # process claimed the whole IP budget; against a budget divided across a 

74 # shard, a phantom 20-per-symbol charge can exhaust a tenant's entire 

75 # minute and block every read behind it. 

76 Endpoints.GET_SYMBOL_INFO: {"weight": 1, "orders": 0}, 

77 # Was charged as 1 while really costing 10, a 10x undercount on the hottest 

78 # read path in the OMS. Resolved per call from the depth limit instead, so 

79 # the charge follows OMS_DEPTH_LIMIT rather than a hardcoded tier. 

80 Endpoints.GET_ORDERBOOK_SNAPSHOT: {"weight": DYNAMIC_WEIGHT, "orders": 0}, 

81 Endpoints.PLACE_ORDER: {"weight": 0, "orders": 1}, 

82 Endpoints.CANCEL_ORDER: {"weight": 1, "orders": 1}, 

83 Endpoints.GET_ORDER_DETAILS: {"weight": 1, "orders": 0}, 

84 Endpoints.GET_WALLET_BALANCE: {"weight": 5, "orders": 0}, 

85 Endpoints.GET_POSITION: {"weight": 5, "orders": 0}, 

86 Endpoints.GET_OPEN_ORDERS: {"weight": 1, "orders": 0}, 

87 Endpoints.GET_OPEN_ORDERS_ALL: {"weight": 40, "orders": 0}, 

88 Endpoints.GET_SERVER_TIME: {"weight": 1, "orders": 0}, 

89} 

90 

91 

92class OpenOrdersFetchProfile(BaseModel): 

93 """Per-exchange strategy for fetching an all-symbols open-orders snapshot.""" 

94 

95 # Symbol count at which one bulk call beats per-symbol calls 

96 min_symbols_for_bulk: int = 1 

97 # Extra kwargs for get_open_orders(symbol=None), e.g. Bybit's settleCoin 

98 bulk_kwargs: dict[str, Any] = Field(default_factory=dict) 

99 # A response with this many rows may be truncated by the exchange's page 

100 # limit; the caller must fall back to per-symbol fetches 

101 truncation_row_limit: int | None = None 

102 

103 

104OPEN_ORDERS_FETCH_PROFILES: dict[Exchange, OpenOrdersFetchProfile] = { 

105 # Rate limit is per request, so bulk wins from the second symbol onward. 

106 # /v5/order/realtime without symbol requires settleCoin and pages at 50. 

107 Exchange.BYBIT_LINEAR: OpenOrdersFetchProfile( 

108 min_symbols_for_bulk=2, 

109 bulk_kwargs={"settleCoin": "USDT", "limit": 50}, 

110 truncation_row_limit=50, 

111 ), 

112 # All-symbols openOrders costs weight 40 vs 1 per symbol and returns 

113 # everything in one page; only worth it past the break-even symbol count 

114 Exchange.BINANCE_LINEAR: OpenOrdersFetchProfile(min_symbols_for_bulk=40), 

115} 

116# Exchanges without a profile (Kucoin, EdgeX) always fetch per symbol. 

117 

118 

119class BinanceLimitProfile(BaseModel): 

120 request_weight_limit_per_minute: int 

121 order_limit_per_10_sec: int 

122 order_limit_per_minute: int 

123 

124 

125class BybitRateLimitPool(Enum): 

126 IP_GLOBAL = auto() 

127 

128 UID_PLACE = auto() 

129 UID_CANCEL = auto() 

130 UID_POSITION = auto() 

131 UID_WALLET = auto() 

132 UID_OPEN_ORDERS = auto() 

133 

134 

135# Bootstrap-only defaults: used before the first response for a UID_* pool 

136# has been seen (see BybitRateLimiter._uid_pool_snapshot), and permanently 

137# for IP_GLOBAL, which has no rate-limit response header. 

138# 

139# UID_PLACE/UID_CANCEL deliberately use the *lowest* documented tier 

140# (Futures "Default", 10/s), not a per-account guess: VIP tiers get 20-60/s 

141# (https://bybit-exchange.github.io/docs/v5/rate-limit/rules-for-vips), and 

142# an account's real tier is only discoverable from its own response headers, 

143# not knowable in advance. Bootstrapping too low just under-admits briefly 

144# and self-corrects up on the first response; bootstrapping too high (e.g. 

145# a VIP number, or "20/s" for a non-VIP account that's actually capped at 

146# 10/s) risks real 403/10006s before that first response ever lands - 

147# confirmed live during PR #35 review. This way every tier is handled 

148# without any per-account/tier configuration. 

149DEFAULT_HARD_LIMITS = { 

150 BybitRateLimitPool.IP_GLOBAL: 120, 

151 BybitRateLimitPool.UID_PLACE: 10, 

152 BybitRateLimitPool.UID_CANCEL: 10, 

153 BybitRateLimitPool.UID_POSITION: 50, 

154 BybitRateLimitPool.UID_WALLET: 50, 

155 BybitRateLimitPool.UID_OPEN_ORDERS: 50, 

156} 

157 

158 

159class BybitLimitProfile(BaseModel): 

160 limits: Dict[BybitRateLimitPool, int] = Field( 

161 default_factory=lambda: DEFAULT_HARD_LIMITS.copy() 

162 ) 

163 interval: int = 1 

164 

165 @classmethod 

166 def with_buffer( 

167 cls, 

168 buffer_pct: Decimal = Decimal("0.2"), 

169 interval: int = 1, 

170 tenants_per_egress_ip: int = 1, 

171 ): 

172 """ 

173 Creates a profile with limits reduced by the buffer percentage. 

174 

175 IP_GLOBAL is additionally split across the processes sharing this egress 

176 IP, because Bybit meters it per source address and — unlike the UID 

177 pools — exposes no response header for it, so a process has no way to 

178 observe what its co-tenants have already spent. The UID_* pools are per 

179 API key and so are left whole. 

180 """ 

181 multiplier = Decimal("1.0") - buffer_pct 

182 ip_scoped = {BybitRateLimitPool.IP_GLOBAL} 

183 safe_limits = { 

184 pool: max( 

185 1, 

186 int( 

187 limit 

188 * multiplier 

189 / (tenants_per_egress_ip if pool in ip_scoped else 1) 

190 ), 

191 ) 

192 for pool, limit in DEFAULT_HARD_LIMITS.items() 

193 } 

194 return cls(limits=safe_limits, interval=interval) 

195 

196 

197class BybitLimitState(BaseModel): 

198 # IP_GLOBAL only: Bybit exposes no header for the account-wide IP limit, 

199 # so it still needs a local rolling window. 

200 timestamps: Deque[int] # time 

201 

202 # UID_* pools: mirrored from the exchange's own X-Bapi-Limit / 

203 # X-Bapi-Limit-Status / X-Bapi-Limit-Reset-Timestamp response headers. 

204 # None until the first response for that pool has been seen. 

205 remaining: int | None = None 

206 limit: int | None = None 

207 reset_ts: int = 0 

208 

209 

210# Follows per endpoint uid rolling window, also has a ip rate limit. 

211# IP limit is 600 requests per 5 *seconds* (not minutes), i.e. the 120/s that 

212# DEFAULT_HARD_LIMITS[IP_GLOBAL] tracks on a one-second rolling window. 

213# Source: https://bybit-exchange.github.io/docs/v5/rate-limit 

214BYBIT_FUTURES_COSTS: dict[Endpoints, BybitRateLimitPool] = { 

215 Endpoints.PLACE_ORDER: BybitRateLimitPool.UID_PLACE, 

216 Endpoints.CANCEL_ORDER: BybitRateLimitPool.UID_CANCEL, 

217 Endpoints.GET_POSITION: BybitRateLimitPool.UID_POSITION, 

218 Endpoints.GET_WALLET_BALANCE: BybitRateLimitPool.UID_WALLET, 

219 Endpoints.GET_OPEN_ORDERS: BybitRateLimitPool.UID_OPEN_ORDERS, 

220 Endpoints.GET_OPEN_ORDERS_ALL: BybitRateLimitPool.UID_OPEN_ORDERS, 

221 Endpoints.GET_ORDER_DETAILS: BybitRateLimitPool.UID_OPEN_ORDERS, 

222} 

223 

224 

225# --------------------------------------------------------------------------- 

226# Hyperliquid 

227# --------------------------------------------------------------------------- 

228 

229# Hyperliquid rate-limits on two independent axes. This module prices the 

230# first: an aggregated per-IP weight budget. The second -- a cumulative 

231# address allowance of one request per USDC traded, with an initial buffer -- 

232# is not a rate and is handled reactively by HyperliquidErrorPolicy; see the 

233# design doc for why it is not modelled here. 

234HYPERLIQUID_IP_WEIGHT_PER_MINUTE = 1200 

235HYPERLIQUID_WEIGHT_WINDOW_SEC = 60 

236 

237# Requests an address may make before it has traded anything. Not enforced -- 

238# the limiter counts against it only to warn, because the true allowance grows 

239# with traded volume and is not observable without paying weight to poll it. 

240HYPERLIQUID_ADDRESS_ACTION_BUFFER = 10_000 

241 

242# Cooldown armed on any Hyperliquid rate-limit signal, whichever axis produced 

243# it. There are two axes and the signal does not say which one fired: an 

244# address that has exhausted its cumulative allowance is throttled to one 

245# request every 10s, while an overrun of the 1200-per-minute IP weight budget 

246# needs up to a full 60s window to drain. Neither the message needles ("rate 

247# limit", "too many requests") nor an HTTP 429 distinguishes them, so the 

248# longer hold is taken for both: over-holding costs this process some latency, 

249# whereas under-holding resumes mid-ban and renews the ban for every tenant 

250# sharing the egress IP. 65s is one weight window plus a margin -- the same 

251# figure, for the same reason, as RateLimiter's blind fallback. 

252HYPERLIQUID_RATE_LIMIT_COOLDOWN_MS = 65_000 

253 

254# Info calls in Hyperliquid's cheap tier (weight 2): l2Book, allMids, 

255# clearinghouseState, orderStatus, spotClearinghouseState, exchangeStatus. 

256# Everything else documented is 20, and userRole is 60. 

257_HL_CHEAP_INFO_WEIGHT = 2 

258_HL_INFO_WEIGHT = 20 

259 

260 

261def exchange_request_weight(batch_length: int = 1) -> int: 

262 """ 

263 Weight Hyperliquid charges for one POST /exchange call. 

264 

265 `1 + floor(batch_length / 40)`, so a single order costs 1 and batching is 

266 close to free. The OMS places one order per call today; the formula lives 

267 here so that batching later cannot silently undercount. 

268 """ 

269 return 1 + batch_length // 40 

270 

271 

272class HyperliquidRateLimitPool(Enum): 

273 # Hyperliquid publishes one aggregated weight budget rather than 

274 # per-endpoint pools, so there is exactly one contended pool. The enum 

275 # exists so _pool_key() returns something self-describing in logs, matching 

276 # BybitRateLimitPool. 

277 IP_WEIGHT = auto() 

278 

279 

280# Weight is per IP; actions are per account address. Both are charged on the 

281# request whether or not it succeeds. Every Endpoints member is listed: a 

282# missing entry would be charged as zero, and undercounting is what gets an IP 

283# banned. 

284HYPERLIQUID_COSTS: dict[Endpoints, dict[str, int]] = { 

285 # metaAndAssetCtxs -- needed rather than plain `meta` because the 

286 # Hyperliquid tick size is derived from the current mark price. Weight 20 

287 # per real call, but update_symbol_info() guards per symbol while cybotrade 

288 # caches the metadata, so only the first symbol in a sweep reaches the 

289 # exchange. This is the per-call price; HyperliquidRateLimiter amortises the 

290 # sweep down to one charge per weight window (see _effective_weight there). 

291 Endpoints.GET_SYMBOL_INFO: {"weight": _HL_INFO_WEIGHT, "actions": 0}, 

292 # l2Book, the cheap tier 

293 Endpoints.GET_ORDERBOOK_SNAPSHOT: { 

294 "weight": _HL_CHEAP_INFO_WEIGHT, 

295 "actions": 0, 

296 }, 

297 # POST /exchange, single-order batch 

298 Endpoints.PLACE_ORDER: {"weight": exchange_request_weight(), "actions": 1}, 

299 Endpoints.CANCEL_ORDER: {"weight": exchange_request_weight(), "actions": 1}, 

300 # Priced for the call adrs actually makes, not the cheapest one the enum 

301 # member could stand for. Both guarded call sites (oms.py, ops/ 

302 # order_placement_manager.py) call get_order_details_from_history, which is 

303 # `historicalOrders` -- the weight-20 tier. The weight-2 `orderStatus` path 

304 # (get_order_details) is never called anywhere in adrs. Endpoints is shared 

305 # with the other exchanges so a second member for the cheap path is out of 

306 # scope; overcharging an unused path is harmless, while undercharging the 

307 # used one by 10x on a repeated reconciliation loop is what bans the IP. 

308 Endpoints.GET_ORDER_DETAILS: {"weight": _HL_INFO_WEIGHT, "actions": 0}, 

309 # both read clearinghouseState, the cheap tier 

310 Endpoints.GET_WALLET_BALANCE: {"weight": _HL_CHEAP_INFO_WEIGHT, "actions": 0}, 

311 Endpoints.GET_POSITION: {"weight": _HL_CHEAP_INFO_WEIGHT, "actions": 0}, 

312 # frontendOpenOrders is not in the cheap tier 

313 Endpoints.GET_OPEN_ORDERS: {"weight": _HL_INFO_WEIGHT, "actions": 0}, 

314 Endpoints.GET_OPEN_ORDERS_ALL: {"weight": _HL_INFO_WEIGHT, "actions": 0}, 

315 # Hyperliquid has no server-time endpoint. Nonces are client-generated and 

316 # the reference SDK uses the local clock, so nothing is ever requested here. 

317 Endpoints.GET_SERVER_TIME: {"weight": 0, "actions": 0}, 

318} 

319 

320 

321class HyperliquidLimitProfile(BaseModel): 

322 request_weight_limit_per_minute: int 

323 address_action_buffer: int = HYPERLIQUID_ADDRESS_ACTION_BUFFER