Coverage for adrs/oms/config.py: 60%

149 statements  

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

1from abc import abstractmethod 

2import json 

3import time 

4 

5from typing import TYPE_CHECKING 

6 

7from cybotrade import Symbol 

8from pydantic import BaseModel, Field, ValidationError 

9from decimal import Decimal 

10 

11from cybotrade.models import Exchange, SymbolInfo 

12from cybotrade.io import ExchangeClient, ExchangeEvent 

13from cybotrade.bybit import BybitLinearClient, BybitPrivateWS, BybitPublicWS 

14from cybotrade.binance import BinanceLinearClient, BinancePrivateWS, BinancePublicWS 

15from cybotrade.kucoin import KucoinLinearClient, KucoinPrivateWS 

16from cybotrade.edgex import EdgeXClient, EdgeXPrivateWS 

17from cybotrade.hyperliquid import ( 

18 HyperliquidClient, 

19 HyperliquidPrivateWS, 

20 HyperliquidPublicWS, 

21) 

22 

23from adrs.oms.logging import PrefixedLogger 

24from adrs.oms.rate_limit.exchange_limit_profiles import Endpoints 

25from adrs.oms.rate_limit.error_policy import ( 

26 ExchangeErrorPolicy, 

27 BybitErrorPolicy, 

28 BinanceErrorPolicy, 

29 DefaultErrorPolicy, 

30 HyperliquidErrorPolicy, 

31) 

32 

33if TYPE_CHECKING: 

34 from adrs.oms.rate_limit.rate_limiter import RateLimiter 

35 

36# Symbol filters (tick size, lot step, notional bounds) change rarely; a 

37# stale read self-heals via the order-reject path, so refreshing faster 

38# than this only burns request weight 

39SYMBOL_INFO_TTL_SEC = 15 * 60 

40 

41 

42class Credentials(BaseModel): 

43 exchange: Exchange 

44 api_key: str 

45 api_secret: str 

46 api_passphrase: str | None = None 

47 testnet: bool = False 

48 demo: bool = False 

49 

50 def to_exchange_client(self) -> ExchangeClient: 

51 match self.exchange: 

52 case Exchange.BYBIT_LINEAR: 

53 return BybitLinearClient( 

54 api_key=self.api_key, 

55 api_secret=self.api_secret, 

56 testnet=self.testnet, 

57 demo=self.demo, 

58 ) 

59 case Exchange.BINANCE_LINEAR: 

60 return BinanceLinearClient( 

61 api_key=self.api_key, 

62 api_secret=self.api_secret, 

63 testnet=self.testnet, 

64 ) 

65 case Exchange.KUCOIN_LINEAR: 

66 if self.api_passphrase is None: 

67 raise ValueError("'api_passphrase' is required for Kucoin") 

68 

69 return KucoinLinearClient( 

70 api_key=self.api_key, 

71 api_secret=self.api_secret, 

72 api_passphrase=self.api_passphrase, 

73 sandbox=self.testnet, 

74 ) 

75 case Exchange.EDGEX: 

76 return EdgeXClient( 

77 account_id=self.api_key, 

78 private_key=self.api_secret, 

79 ) 

80 case Exchange.HYPERLIQUID: 

81 # api_key is the MASTER account address, not a key. cybotrade 

82 # can resolve it from userRole, but that is an awaited call and 

83 # to_exchange_event() below is synchronous, so adrs requires it 

84 # up front -- as Kucoin requires api_passphrase. Putting the 

85 # agent wallet's own address here makes every state read return 

86 # empty while orders still place, so it is validated, not 

87 # defaulted. 

88 if not self.api_key: 

89 raise ValueError( 

90 "'api_key' must be the Hyperliquid master account address" 

91 ) 

92 return HyperliquidClient( 

93 private_key=self.api_secret, 

94 account_address=self.api_key, 

95 vault_address=self.api_passphrase, 

96 testnet=self.testnet, 

97 ) 

98 case _: 

99 raise Exception(f"Unsupported exchange {self.exchange}") 

100 

101 def to_exchange_event(self) -> ExchangeEvent: 

102 match self.exchange: 

103 case Exchange.BYBIT_LINEAR: 

104 return BybitPrivateWS( 

105 api_key=self.api_key, 

106 api_secret=self.api_secret, 

107 testnet=self.testnet, 

108 demo=self.demo, 

109 # Bybit only pushes topics that were subscribed, unlike 

110 # Binance's listenKey stream which carries every event type. 

111 topics=["order", "position"], 

112 ) 

113 case Exchange.BINANCE_LINEAR: 

114 return BinancePrivateWS( 

115 api_key=self.api_key, 

116 api_secret=self.api_secret, 

117 testnet=self.testnet, 

118 ) 

119 case Exchange.KUCOIN_LINEAR: 

120 if self.api_passphrase is None: 

121 raise ValueError("'api_passphrase' is required for Kucoin") 

122 

123 return KucoinPrivateWS( 

124 api_key=self.api_key, 

125 api_secret=self.api_secret, 

126 api_passphrase=self.api_passphrase, 

127 topics=["/contractMarket/tradeOrders"], 

128 ) 

129 case Exchange.EDGEX: 

130 return EdgeXPrivateWS( 

131 account_id=self.api_key, 

132 private_key=self.api_secret, 

133 ) 

134 case Exchange.HYPERLIQUID: 

135 # Takes an address, not credentials: Hyperliquid keys user 

136 # subscriptions on the address and that data is public. 

137 if not self.api_key: 

138 raise ValueError( 

139 "'api_key' must be the Hyperliquid master account address" 

140 ) 

141 return HyperliquidPrivateWS(address=self.api_key, testnet=self.testnet) 

142 case _: 

143 raise Exception(f"Unsupported exchange {self.exchange}") 

144 

145 def to_public_exchange_event(self, symbols: list[str]) -> ExchangeEvent | None: 

146 """ 

147 The public top-of-book adapter for this exchange, or None where there is 

148 no adapter. 

149 

150 None is a supported configuration, not an error: Kucoin and EdgeX have no 

151 public feed and read prices from REST exactly as every exchange did 

152 before the feed existed. Raising here would break them. 

153 

154 testnet must match the credentials — the feed and the REST fallback have 

155 to price against the same book. 

156 """ 

157 match self.exchange: 

158 case Exchange.BYBIT_LINEAR: 

159 return BybitPublicWS( 

160 symbols=symbols, testnet=self.testnet, demo=self.demo 

161 ) 

162 case Exchange.BINANCE_LINEAR: 

163 return BinancePublicWS(symbols=symbols, testnet=self.testnet) 

164 case Exchange.HYPERLIQUID: 

165 # HyperliquidPublicWS resolves each symbol to a coin via 

166 # base_from_symbol(), which needs a real Symbol -- a plain 

167 # string resolves to the whole pair as the coin name with no 

168 # error raised, silently subscribing to nothing on the book. 

169 return HyperliquidPublicWS( 

170 symbols=[Symbol(s) for s in symbols], testnet=self.testnet 

171 ) 

172 case _: 

173 return None 

174 

175 def to_exchange_topic(self) -> str: 

176 match self.exchange: 

177 case Exchange.BYBIT_LINEAR: 

178 return "bybit-linear" 

179 case Exchange.BINANCE_LINEAR: 

180 return "binance-linear" 

181 # TODO Get actual values 

182 case Exchange.KUCOIN_LINEAR: 

183 return "kucoin-linear" 

184 case Exchange.EDGEX: 

185 return "edgex" 

186 case Exchange.HYPERLIQUID: 

187 return "hyperliquid" 

188 case _: 

189 raise Exception(f"Unsupported exchange {self.exchange}") 

190 

191 def to_error_policy(self) -> ExchangeErrorPolicy: 

192 match self.exchange: 

193 case Exchange.BYBIT_LINEAR: 

194 return BybitErrorPolicy() 

195 case Exchange.BINANCE_LINEAR: 

196 return BinanceErrorPolicy() 

197 case Exchange.HYPERLIQUID: 

198 return HyperliquidErrorPolicy() 

199 case _: 

200 # No exchange-specific policy: keep legacy retry-everything behaviour 

201 return DefaultErrorPolicy() 

202 

203 

204class Config(BaseModel): 

205 oms_id: str 

206 portfolio_id: str 

207 

208 initial_balance: Decimal 

209 leverage: Decimal 

210 base_asset_to_symbol_table: dict[str, str] 

211 

212 credentials: Credentials 

213 

214 order_placement_interval: int 

215 expiry_check: int 

216 replace_best_bid_ask_time: int 

217 max_limit_replace_interval: int 

218 min_limit_replace_interval: int 

219 # Attempts a backlogged order/cancel gets before it is abandoned. The retry 

220 # cron runs every 2s and backs off by min(2 * attempt, 30)s, so 10 attempts 

221 # is roughly 108 seconds of trying. Defaulted to the value production 

222 # already runs, so a config that omits it behaves the same as the fleet 

223 # rather than failing to load. 

224 max_retries_allowed: int = 10 

225 soft_limit_percent: Decimal 

226 

227 # How many OMS processes share this process's egress IP address. 

228 # 

229 # Exchanges meter their heaviest limits per source IP: Binance's 

230 # REQUEST_WEIGHT (2400/min) and Bybit's IP_GLOBAL pool. A rate limiter is 

231 # per process, so every tenant behind one NAT gateway used to claim the 

232 # whole IP budget for itself — three tenants on a shard admitted 2.4x what 

233 # the IP allows, and a full 14-tenant shard about 11x. They then banned each 

234 # other, which is what made this look like a multi-tenancy problem rather 

235 # than an accounting one. 

236 # 

237 # Set this to the shard's tenant cap (prime's COMPUTE_MAX_TENANTS_PER_SHARD) 

238 # so IP-scoped budgets are divided that many ways. Account-scoped budgets — 

239 # Binance's order counts, Bybit's UID pools — are per API key and are never 

240 # divided. Dedicated-tier tenants own their IP, so 1 is correct there. 

241 # 

242 # This is a static worst-case split. On Binance the limiter also reconciles 

243 # against x-mbx-used-weight-1m, which is itself IP-scoped and so already 

244 # accounts for co-tenant traffic; this bound is what stops a process 

245 # overshooting before its first response comes back. 

246 tenants_per_egress_ip: int = Field(default=1, ge=1) 

247 

248 

249class ConfigManager: 

250 # Subclasses override to validate against an extended config schema, 

251 # e.g. class BqFileConfigManager(FileConfigManager): config_cls = BqConfig 

252 config_cls: type[Config] = Config 

253 

254 config: Config 

255 exchange: ExchangeClient 

256 symbol_infos: dict[Symbol, SymbolInfo] 

257 

258 def __init__(self, prefix: str = "[ConfigManager]"): 

259 self.logger: PrefixedLogger = PrefixedLogger(prefix=prefix, name=__name__) 

260 self.symbol_infos = {} 

261 self._symbol_info_refreshed_at: float = 0.0 # monotonic, 0 = never 

262 

263 @abstractmethod 

264 async def setup(self, *args, **kwars): 

265 pass 

266 

267 @abstractmethod 

268 async def load(self) -> Config: 

269 pass 

270 

271 async def refresh(self): 

272 old_credentials = self.config.credentials if hasattr(self, "config") else None 

273 self.config = await self.load() 

274 if old_credentials != self.config.credentials: 

275 self.exchange = self.config.credentials.to_exchange_client() 

276 

277 async def update_symbol_info( 

278 self, rate_limiter: "RateLimiter | None" = None, force: bool = False 

279 ): 

280 """ 

281 Refreshes at most once per SYMBOL_INFO_TTL_SEC unless forced 

282 (config/symbol-table changes must not wait out the TTL). 

283 """ 

284 if ( 

285 not force 

286 and self.symbol_infos 

287 and time.monotonic() - self._symbol_info_refreshed_at < SYMBOL_INFO_TTL_SEC 

288 ): 

289 return 

290 for symbol in self.config.base_asset_to_symbol_table.values(): 

291 if rate_limiter is not None: 

292 async with rate_limiter.guard(endpoint=Endpoints.GET_SYMBOL_INFO): 

293 self.symbol_infos[ 

294 Symbol(symbol) 

295 ] = await self.exchange.get_symbol_info(symbol=Symbol(symbol)) 

296 else: # bootstrap (setup) — no limiter yet, single startup burst 

297 self.symbol_infos[Symbol(symbol)] = await self.exchange.get_symbol_info( 

298 symbol=Symbol(symbol) 

299 ) 

300 # Only stamped after a full pass: a mid-loop failure propagates and 

301 # leaves the TTL expired, so the next tick retries 

302 self._symbol_info_refreshed_at = time.monotonic() 

303 

304 

305class FileConfigManager(ConfigManager): 

306 def __init__( 

307 self, 

308 path: str, 

309 ): 

310 super().__init__(prefix="[FileConfigManager]") 

311 self.path = path 

312 

313 async def setup(self): 

314 await self.refresh() 

315 await self.update_symbol_info() 

316 

317 async def load(self): 

318 try: 

319 with open(self.path, "r") as f: 

320 data = json.load(f) 

321 return self.config_cls.model_validate(obj=data) 

322 except FileNotFoundError: 

323 self.logger.error("Config file does not exist") 

324 raise 

325 except json.JSONDecodeError: 

326 self.logger.error("Config file contains invalid json data") 

327 raise 

328 except ValidationError as e: 

329 self.logger.error(f"'{self.path}' does not match the Config schema: {e}") 

330 raise 

331 except Exception as e: 

332 self.logger.error(f"An unexpected error occurred: {e}") 

333 raise 

334 

335 

336class NATSConfigManager(ConfigManager): 

337 def __init__( 

338 self, 

339 url: str, 

340 bucket: str, 

341 key: str, 

342 user: str | None = None, 

343 password: str | None = None, 

344 ): 

345 super().__init__(prefix="[NATSConfigManager]") 

346 self.url = url 

347 self.bucket = bucket 

348 self.key = key 

349 

350 async def setup(self): 

351 await self.refresh() 

352 

353 async def load(self): 

354 raise NotImplementedError( 

355 "NATSConfigManager.load is not implemented. " 

356 "Subclass NATSConfigManager and override load() to fetch config from NATS." 

357 )