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
« 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
6from cybotrade.models import Exchange
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
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)
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
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
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]
46class Endpoints(Enum):
47 # Market Data
48 GET_SERVER_TIME = auto()
49 GET_SYMBOL_INFO = auto()
50 GET_ORDERBOOK_SNAPSHOT = auto()
52 # Account & Trade
53 PLACE_ORDER = auto()
54 CANCEL_ORDER = auto()
55 GET_ORDER_DETAILS = auto()
57 # Account Info
58 GET_WALLET_BALANCE = auto()
59 GET_POSITION = auto()
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
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}
92class OpenOrdersFetchProfile(BaseModel):
93 """Per-exchange strategy for fetching an all-symbols open-orders snapshot."""
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
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.
119class BinanceLimitProfile(BaseModel):
120 request_weight_limit_per_minute: int
121 order_limit_per_10_sec: int
122 order_limit_per_minute: int
125class BybitRateLimitPool(Enum):
126 IP_GLOBAL = auto()
128 UID_PLACE = auto()
129 UID_CANCEL = auto()
130 UID_POSITION = auto()
131 UID_WALLET = auto()
132 UID_OPEN_ORDERS = auto()
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}
159class BybitLimitProfile(BaseModel):
160 limits: Dict[BybitRateLimitPool, int] = Field(
161 default_factory=lambda: DEFAULT_HARD_LIMITS.copy()
162 )
163 interval: int = 1
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.
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)
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
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
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}
225# ---------------------------------------------------------------------------
226# Hyperliquid
227# ---------------------------------------------------------------------------
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
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
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
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
261def exchange_request_weight(batch_length: int = 1) -> int:
262 """
263 Weight Hyperliquid charges for one POST /exchange call.
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
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()
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}
321class HyperliquidLimitProfile(BaseModel):
322 request_weight_limit_per_minute: int
323 address_action_buffer: int = HYPERLIQUID_ADDRESS_ACTION_BUFFER