From 93cafe81604719e2368028569c138a0e113b3933 Mon Sep 17 00:00:00 2001 From: Azimkin <112006710+Azimkin@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:14:46 +0200 Subject: [PATCH] Add Binance Testnet exchange gateway --- README.md | 27 +- config.example.toml | 16 + pyproject.toml | 1 + src/llm_trade_bot/agent.py | 270 +++---- src/llm_trade_bot/binance_client.py | 241 ++++++ src/llm_trade_bot/binance_gateway.py | 1053 +++++++++++++++++++++++++ src/llm_trade_bot/bybit_gateway.py | 294 ++++++- src/llm_trade_bot/cli.py | 2 +- src/llm_trade_bot/config.py | 75 +- src/llm_trade_bot/database.py | 61 +- src/llm_trade_bot/exchange_factory.py | 16 + src/llm_trade_bot/exchange_gateway.py | 116 +++ src/llm_trade_bot/service.py | 108 ++- tests/test_binance_client.py | 109 +++ tests/test_binance_gateway.py | 162 ++++ tests/test_config.py | 63 ++ tests/test_database.py | 42 + uv.lock | 79 ++ 18 files changed, 2505 insertions(+), 230 deletions(-) create mode 100644 src/llm_trade_bot/binance_client.py create mode 100644 src/llm_trade_bot/binance_gateway.py create mode 100644 src/llm_trade_bot/exchange_factory.py create mode 100644 src/llm_trade_bot/exchange_gateway.py create mode 100644 tests/test_binance_client.py create mode 100644 tests/test_binance_gateway.py diff --git a/README.md b/README.md index d0a5749..931d228 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,32 @@ # LLM Trade Bot -Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** аккаунта. Бот анализирует Spot и Linear USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram. +Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram. -Сервис принципиально не поддерживает production endpoint Bybit. Размер позиции и все лимиты проверяются обычным кодом непосредственно перед отправкой ордера. +`linear` — публичное имя USDⓈ-M Futures: оно намеренно сохранено в инструментах агента и промптах. Production endpoints не поддерживаются. Для Binance адреса Spot Testnet и USDⓈ-M Futures Demo Testnet зафиксированы в коде и не настраиваются через TOML. ## Возможности - новая stateless-сессия `micro-agents` для каждого анализа; - долговременные `note` и односторонние `ask_admin` через SQLite/Telegram; +- общий exchange gateway с нормализованными ордерами, сделками и приватными WebSocket-событиями; - сканер ликвидных Spot и Linear инструментов, свечи 1h/4h/1d, стакан и индикаторы; - Brave Search и безопасное чтение выбранных публичных HTTPS-страниц; - максимум 2% equity риска на идею, 40% gross exposure, пять идей и 3x Linear leverage; - обязательный exchange-side stop-loss, TP или trailing для Linear, TP/SL для Spot; -- Telegram-команды, приватный Bybit WebSocket, аудит и восстановление состояния через REST. +- на Binance Spot — одна атомарная OTOCO-конструкция: Limit-вход и ожидающая рыночная TP/SL OCO-пара; если Testnet её отклоняет, вход не создаётся; +- на Binance USDⓈ-M — one-way + isolated margin, плечо перед входом и обязательный stop через `/fapi/v1/algoOrder`; при ошибке установки stop-loss позиция аварийно закрывается; +- отдельные снимки и лимиты 2%/40% для Spot и Futures кошельков Binance, при общем лимите пяти идей; +- Telegram-команды, приватные WebSocket-потоки, аудит и REST-reconciliation. ## Подготовка -1. Создайте ключ в основном аккаунте Bybit после переключения в **Demo Trading**, а не на testnet. -2. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. Для Spot margin borrowing не используется. -3. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram. -4. Получите DeepSeek API key и Brave Search API key. -5. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`. -6. Замените секреты в `config.toml`. Локальные конфиги исключены из Git. +1. Выберите `exchange.provider` в `config.toml`. +2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. +3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом. +4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram. +5. Получите DeepSeek API key и Brave Search API key. +6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`. +7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git. ```powershell Copy-Item config.example.toml config.toml @@ -50,7 +55,7 @@ uv run llm-trade-bot --config config.toml - `/run` для внепланового анализа; - `/help`. -`/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на Bybit. +`/pause` запрещает новые агентские сессии и входы, но уже размещённые защитные ордера остаются на выбранной бирже. ## Тесты @@ -58,4 +63,4 @@ uv run llm-trade-bot --config config.toml uv run pytest ``` -Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском рекомендуется пополнить Demo Trading только USDT и проверить `/status`, `/positions` и `/orders`. +Тесты не используют реальные ключи и не создают ордера. Перед первым полноценным запуском проверьте `/status`, `/positions` и `/orders` на выбранном Testnet/Demo-кошельке. Для Binance отдельно проверьте Spot Limit OTOCO и Futures Market-вход с защитой. diff --git a/config.example.toml b/config.example.toml index 0e9405e..c6c7718 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,11 +5,27 @@ model = "deepseek-flash" thinking = true max_tool_rounds = 256 +# Choose exactly one provider for this process. Binance is permanently restricted +# to its Spot and USDⓈ-M Futures Testnet endpoints in application code. +[exchange] +provider = "binance" # binance | bybit + [bybit] api_key = "replace-me" api_secret = "replace-me" settle_coin = "USDT" +[binance.spot] +# Create these keys at https://testnet.binance.vision/ . They are not shared +# with the USDⓈ-M Futures demo environment below. +api_key = "replace-me" +api_secret = "replace-me" + +[binance.usdm] +# Create these keys in the USDⓈ-M Futures Testnet/Demo environment. +api_key = "replace-me" +api_secret = "replace-me" + [telegram] api_id = 123456 api_hash = "replace-me" diff --git a/pyproject.toml b/pyproject.toml index ee9c1d3..c76ab27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pybit>=5.12,<6", "tgcrypto>=1.2.5,<2", "tzdata>=2026.1", + "websockets>=15,<17", ] [project.scripts] diff --git a/src/llm_trade_bot/agent.py b/src/llm_trade_bot/agent.py index 623b387..f506657 100644 --- a/src/llm_trade_bot/agent.py +++ b/src/llm_trade_bot/agent.py @@ -24,9 +24,9 @@ from micro_agents import ( ToolManager, ) -from llm_trade_bot.bybit_gateway import BybitError, BybitGateway from llm_trade_bot.config import AppConfig from llm_trade_bot.database import Database, utc_now +from llm_trade_bot.exchange_gateway import ExchangeGateway, normalized_order from llm_trade_bot.models import Category, OpenTradeRequest, OrderType, Side from llm_trade_bot.prompts import PromptStore from llm_trade_bot.risk import RiskEngine @@ -34,10 +34,11 @@ from llm_trade_bot.web_tools import SafeWebTools class AgentTools: - def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway): + def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway): self.config = config self.database = database - self.bybit = bybit + self.exchange = exchange + self.bybit = exchange # compatibility for integrations that inspected this attribute self.risk = RiskEngine(config.risk) self.web = SafeWebTools(config.brave.api_key) self._dedup: dict[str, dict[str, Any]] = {} @@ -70,7 +71,7 @@ class AgentTools: async def get_account_state(self) -> dict[str, Any]: """Return current wallet, spot assets, linear positions, and open orders.""" - return await self.bybit.account_state() + return await self.exchange.account_state() async def get_market_data( self, @@ -80,19 +81,19 @@ class AgentTools: ) -> dict[str, Any]: """Return ticker, order book, candles, and indicators for a current scanner candidate.""" self._require_candidate(category, symbol, context) - return await self.bybit.market_context(category, symbol) + return await self.exchange.market_context(category, symbol) async def get_open_orders(self, category: str | None = None) -> list[dict[str, Any]]: """Return active normal and protective orders.""" - return await self.bybit.open_orders(category) + return await self.exchange.open_orders(category) async def get_order_history(self, category: Literal["spot", "linear"], limit: int = 20) -> list[dict[str, Any]]: - """Return recent Bybit order history for spot or linear.""" - return await self.bybit.order_history(category, limit) + """Return recent normalized order history for spot or linear.""" + return await self.exchange.order_history(category, limit) async def get_trade_history(self, category: Literal["spot", "linear"], limit: int = 20) -> list[dict[str, Any]]: """Return recent executions for spot or linear.""" - return await self.bybit.executions(category, limit) + return await self.exchange.executions(category, limit) async def open_trade( self, @@ -125,11 +126,11 @@ class AgentTools: category_value = Category(category) side_value = Side(side) order_type_value = OrderType(order_type) - state = await self.bybit.account_state() + state = await self.exchange.account_state() if _has_existing_idea(state, category, symbol): raise ValueError(f"An active position or opening order already exists for {category}:{symbol}") - market = await self.bybit.market_context(category, symbol) + market = await self.exchange.market_context(category, symbol) ticker = market.get("ticker", {}) reference_price = Decimal(str(ticker.get("lastPrice") or "0")) request = OpenTradeRequest( @@ -146,48 +147,78 @@ class AgentTools: leverage=Decimal(str(leverage)), rationale=rationale[:1000], ) - account = await self.bybit.account_snapshot() - rules = await self.bybit.instrument_rules(category, symbol) + try: + account = await self.exchange.account_snapshot(category) + except TypeError: # old test doubles and third-party Bybit adapters + account = await self.exchange.account_snapshot() + rules = await self.exchange.instrument_rules(category, symbol) validated = self.risk.validate_open(request, account, rules) - if category_value is Category.LINEAR: - try: - await self.bybit.set_leverage(symbol.upper(), str(validated.leverage)) - except BybitError as error: - if "not modified" not in str(error).lower() and "110043" not in str(error): - raise - order_link_id = f"ltb-{run_id}-{uuid4().hex[:12]}"[:36] - parameters: dict[str, Any] = { - "category": category, - "symbol": symbol.upper(), - "side": side, - "orderType": order_type, - "qty": _plain(validated.quantity), - "orderLinkId": order_link_id, - } - if order_type_value is OrderType.LIMIT: - parameters.update(price=_plain(validated.entry_price), timeInForce="GTC") - if category_value is Category.SPOT: - parameters.update( - isLeverage=0, - takeProfit=_plain(validated.take_profit), - stopLoss=_plain(validated.stop_loss), - tpOrderType="Market", - slOrderType="Market", - ) + if hasattr(self.exchange, "open_trade"): + try: + result = await self.exchange.open_trade(request, validated, order_link_id) + except Exception as error: + if "emergency-closed" in str(error): + await self.database.queue_message( + f"🚨 {self.exchange.provider}: обязательный stop-loss не установлен; " + f"позиция {symbol.upper()} аварийно закрыта. {error}" + ) + raise else: - parameters.update( - positionIdx=0, - stopLoss=_plain(validated.stop_loss), - tpslMode="Partial", - slOrderType="Market", + # Transitional compatibility for simple legacy gateways. + if category_value is Category.LINEAR: + try: + await self.exchange.set_leverage(symbol.upper(), str(validated.leverage)) # type: ignore[attr-defined] + except Exception as error: + if "not modified" not in str(error).lower() and "110043" not in str(error): + raise + parameters: dict[str, Any] = { + "category": category, + "symbol": symbol.upper(), + "side": side, + "orderType": order_type, + "qty": _plain(validated.quantity), + "orderLinkId": order_link_id, + } + if order_type_value is OrderType.LIMIT: + parameters.update(price=_plain(validated.entry_price), timeInForce="GTC") + if category_value is Category.SPOT: + parameters.update( + isLeverage=0, + takeProfit=_plain(validated.take_profit), + stopLoss=_plain(validated.stop_loss), + tpOrderType="Market", + slOrderType="Market", + ) + else: + parameters.update( + positionIdx=0, + stopLoss=_plain(validated.stop_loss), + tpslMode="Partial", + slOrderType="Market", + ) + if validated.take_profit is not None: + parameters.update(takeProfit=_plain(validated.take_profit), tpOrderType="Market") + response = await self.exchange.place_order(**parameters) # type: ignore[attr-defined] + raw_result = response.get("result", {}) + result = normalized_order( + provider=getattr(self.exchange, "provider", "bybit"), + category=category, + symbol=symbol.upper(), + side=side, + status="Created", + quantity=_plain(validated.quantity), + price=_plain(validated.entry_price), + client_id=order_link_id, + order_id=raw_result.get("orderId"), + protection={ + "stop_loss": _plain(validated.stop_loss), + "take_profit": _plain(validated.take_profit), + "trailing_distance": _plain(validated.trailing_distance), + }, + raw=response, ) - if validated.take_profit is not None: - parameters.update(takeProfit=_plain(validated.take_profit), tpOrderType="Market") - - response = await self.bybit.place_order(**parameters) - result = response.get("result", {}) expires_at = ( utc_now() + timedelta(hours=self.config.risk.limit_order_ttl_hours) if order_type_value is OrderType.LIMIT @@ -195,18 +226,28 @@ class AgentTools: ) await self.database.upsert_managed_order( order_link_id, - order_id=str(result.get("orderId") or "") or None, + order_id=str(result.get("order_id") or "") or None, + provider=getattr(self.exchange, "provider", self.config.exchange.provider), category=category, symbol=symbol.upper(), side=side, - status="Created", + status=str(result.get("status") or "Created"), expires_at=expires_at, trailing_distance=_plain(validated.trailing_distance), - raw={"request": parameters, "response": response, "rationale": rationale}, + entry_order_id=str(result.get("entry_id") or result.get("order_id") or "") or None, + protection_order_ids=[str(item) for item in result.get("protection_ids", [])], + raw={"result": result, "rationale": rationale}, ) output = { "ok": True, - "order_id": result.get("orderId"), + **result, + "provider": getattr(self.exchange, "provider", self.config.exchange.provider), + "category": category, + "symbol": symbol.upper(), + "side": side, + "status": result.get("status", "Created"), + "order_id": result.get("order_id"), + "client_id": result.get("client_id") or order_link_id, "order_link_id": order_link_id, "quantity": _plain(validated.quantity), "notional": _plain(validated.notional), @@ -231,71 +272,27 @@ class AgentTools: """Reduce or close an existing spot holding or linear position.""" if not 0 < percent <= 100: raise ValueError("percent must be in (0, 100]") - rules = await self.bybit.instrument_rules(category, symbol) - quantity: Decimal - side: str - if category == "linear": - positions = await self.bybit.positions() - position = next((item for item in positions if item.get("symbol") == symbol.upper() and Decimal(str(item.get("size") or 0)) > 0), None) - if position is None: - raise ValueError("No linear position found") - quantity = Decimal(str(position["size"])) * Decimal(str(percent)) / Decimal(100) - side = "Sell" if position.get("side") == "Buy" else "Buy" - elif category == "spot": - for order in await self.bybit.open_orders("spot"): - if order.get("symbol") != symbol.upper(): - continue - stop_type = str(order.get("stopOrderType") or "").lower() - order_filter = order.get("orderFilter") - if order_filter in {"tpslOrder", "OcoOrder", "BidirectionalTpslOrder"} or any( - marker in stop_type for marker in ("takeprofit", "stoploss", "trailing", "oco") - ): - await self.bybit.cancel_order( - category="spot", - symbol=symbol.upper(), - orderId=order.get("orderId"), - orderFilter=order_filter or "tpslOrder", - ) - state = await self.bybit.account_state() - wallet = (state.get("wallet") or [{}])[0] - base_coin = symbol.upper().removesuffix(self.config.scanner.quote_coin) - coin = next((item for item in wallet.get("coin", []) if item.get("coin") == base_coin), None) - if coin is None: - raise ValueError("No spot holding found") - quantity = Decimal(str(coin.get("walletBalance") or 0)) * Decimal(str(percent)) / Decimal(100) - side = "Sell" - else: - raise ValueError("category must be spot or linear") - quantity = (quantity // rules.qty_step) * rules.qty_step - if quantity < rules.min_qty: - raise ValueError("Close quantity is below instrument minimum") - parameters: dict[str, Any] = { - "category": category, - "symbol": symbol.upper(), - "side": side, - "orderType": order_type, - "qty": _plain(quantity), - "orderLinkId": f"ltb-close-{uuid4().hex[:16]}"[:36], - } - if order_type == "Limit": - if limit_price is None: - raise ValueError("Limit close requires limit_price") - parameters.update(price=str(limit_price), timeInForce="GTC") - if category == "linear": - parameters.update(reduceOnly=True, positionIdx=0) - else: - parameters.update(isLeverage=0, marketUnit="baseCoin") - response = await self.bybit.place_order(**parameters) - output = {"ok": True, "result": response.get("result", {}), "quantity": _plain(quantity)} + client_id = f"ltb-close-{uuid4().hex[:16]}"[:32] + result = await self.exchange.close_trade( + category, + symbol.upper(), + percent, + order_type, + str(limit_price) if limit_price is not None else None, + client_id, + ) + output = {"ok": True, **result} run_id = int(str(context.get("run_id", 0))) if context else None await self.database.add_event("trade_close_accepted", output, run_id=run_id) - await self.database.queue_message(_trade_message("Закрытие", category, symbol, side, output)) + await self.database.queue_message( + _trade_message("Закрытие", category, symbol, str(result.get("side") or ""), output) + ) return output async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict[str, Any]: - """Cancel an active order by Bybit order ID.""" - response = await self.bybit.cancel_order(category=category, symbol=symbol.upper(), orderId=order_id) - return {"ok": True, "result": response.get("result", {})} + """Cancel an active order by its exchange order ID.""" + response = await self.exchange.cancel_order(category, symbol.upper(), order_id=order_id) + return {"ok": True, **response} async def update_protection( self, @@ -307,20 +304,12 @@ class AgentTools: """Update exchange-side protection for an existing linear position.""" if take_profit is None and trailing_distance is None: raise ValueError("take_profit or trailing_distance is required") - parameters: dict[str, Any] = { - "category": "linear", - "symbol": symbol.upper(), - "positionIdx": 0, - "tpslMode": "Full", - "stopLoss": str(stop_loss), - "slTriggerBy": "MarkPrice", - } - if take_profit is not None: - parameters.update(takeProfit=str(take_profit), tpTriggerBy="MarkPrice") - if trailing_distance is not None: - parameters["trailingStop"] = str(trailing_distance) - response = await self.bybit.set_trading_stop(**parameters) - return {"ok": True, "result": response.get("result", {})} + return await self.exchange.update_protection( + symbol.upper(), + str(stop_loss), + str(take_profit) if take_profit is not None else None, + str(trailing_distance) if trailing_distance is not None else None, + ) @staticmethod def _require_candidate(category: str, symbol: str, context: SessionContext | None) -> None: @@ -358,17 +347,18 @@ class AuditSubscriber(EventSubscriber): class AgentRunner: - def __init__(self, config: AppConfig, database: Database, bybit: BybitGateway): + def __init__(self, config: AppConfig, database: Database, exchange: ExchangeGateway): self.config = config self.database = database - self.bybit = bybit + self.exchange = exchange + self.bybit = exchange self.prompts = PromptStore(config.prompts.file_path) llm = OpenAIBasedLLM( api_url=config.deepseek.base_url, api_key=config.deepseek.api_key, model=config.deepseek.model, ) - self.agent_tools = AgentTools(config, database, bybit) + self.agent_tools = AgentTools(config, database, exchange) self.aggregator = Aggregator( llm, ToolManager(self.agent_tools.tools()), @@ -391,6 +381,12 @@ class AgentRunner: runtime_context = json.dumps( { "trigger": trigger, + "exchange": { + "provider": self.exchange.provider, + "environment": self.exchange.environment, + "spot_entries": "Limit OTOCO" if self.exchange.provider == "binance" else "Limit with TP/SL", + "linear_name": "USDⓈ-M Futures" if self.exchange.provider == "binance" else "Linear USDT", + }, "account": account, "scanner_candidates": candidates, "durable_notes": notes, @@ -428,7 +424,7 @@ class AgentRunner: raise async def _initial_context(self) -> tuple[list[dict[str, str]], dict[str, Any], list[str]]: - candidates_raw = await self.bybit.scan_markets() + candidates_raw = await self.exchange.scan_markets() candidates = [ { "category": item.category, @@ -439,7 +435,7 @@ class AgentRunner: } for item in candidates_raw ] - account = await self.bybit.account_state() + account = await self.exchange.account_state() notes = await self.database.recent_notes( self.config.storage.max_notes, self.config.storage.max_notes_chars ) @@ -464,7 +460,8 @@ def _signature(values: dict[str, Any], excluded: set[str]) -> str: def _has_existing_idea(state: dict[str, Any], category: str, symbol: str) -> bool: symbol = symbol.upper() if category == "linear" and any( - item.get("symbol") == symbol and Decimal(str(item.get("size") or 0)) > 0 + item.get("symbol") == symbol + and Decimal(str(item.get("size") or item.get("positionAmt") or 0)).copy_abs() > 0 for item in state.get("positions", []) ): return True @@ -476,10 +473,17 @@ def _has_existing_idea(state: dict[str, Any], category: str, symbol: str) -> boo for item in wallets[0].get("coin", []) ): return True + spot_balances = state.get("wallets", {}).get("spot", {}).get("balances", []) + if any( + item.get("asset") == base_coin + and Decimal(str(item.get("free") or 0)) + Decimal(str(item.get("locked") or 0)) > 0 + for item in spot_balances + ): + return True return any( item.get("symbol") == symbol and item.get("category", category) == category - and not bool(item.get("reduceOnly")) + and not bool(item.get("reduceOnly") or item.get("protective")) for item in state.get("open_orders", []) ) diff --git a/src/llm_trade_bot/binance_client.py b/src/llm_trade_bot/binance_client.py new file mode 100644 index 0000000..38f7a1d --- /dev/null +++ b/src/llm_trade_bot/binance_client.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from typing import Any +from urllib.parse import urlencode + +import httpx + +SPOT_REST_URL = "https://testnet.binance.vision" +SPOT_WS_API_URL = "wss://ws-api.testnet.binance.vision/ws-api/v3" +USDM_REST_URL = "https://demo-fapi.binance.com" +USDM_WS_URL = "wss://demo-fstream.binance.com" + + +class BinanceError(RuntimeError): + """A normalized Binance API failure.""" + + def __init__(self, message: str, *, status_code: int | None = None, code: int | None = None): + super().__init__(message) + self.status_code = status_code + self.code = code + + +class BinanceUnknownResult(BinanceError): + """The exchange may have accepted a mutating request whose response was lost.""" + + +@dataclass(frozen=True, slots=True) +class BinanceCredentials: + api_key: str + api_secret: str + + +def encode_parameters(parameters: Mapping[str, Any]) -> str: + values: list[tuple[str, str]] = [] + for key in sorted(parameters): + value = parameters[key] + if value is None: + continue + if isinstance(value, bool): + encoded = "true" if value else "false" + else: + encoded = str(value) + values.append((key, encoded)) + return urlencode(values) + + +def sign_parameters(parameters: Mapping[str, Any], secret: str) -> str: + payload = encode_parameters(parameters) + return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + +class BinanceRestClient: + """Small async Binance REST client with fixed Testnet URL supplied by its caller.""" + + def __init__( + self, + base_url: str, + credentials: BinanceCredentials, + *, + http_client: httpx.AsyncClient | None = None, + time_path: str = "/api/v3/time", + recv_window: int = 5000, + max_retries: int = 3, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + clock: Callable[[], float] = time.time, + ): + if base_url not in {SPOT_REST_URL, USDM_REST_URL}: + raise ValueError("BinanceRestClient only accepts the built-in Testnet endpoints") + self.base_url = base_url + self.credentials = credentials + self.time_path = time_path + self.recv_window = recv_window + self.max_retries = max_retries + self._sleep = sleep + self._clock = clock + self._time_offset_ms = 0 + self._owns_http = http_client is None + self._http = http_client or httpx.AsyncClient(base_url=base_url, timeout=httpx.Timeout(10.0)) + + @property + def timestamp_ms(self) -> int: + return int(self._clock() * 1000) + self._time_offset_ms + + async def close(self) -> None: + if self._owns_http: + await self._http.aclose() + + async def sync_time(self) -> int: + before = int(self._clock() * 1000) + payload = await self.request("GET", self.time_path) + after = int(self._clock() * 1000) + try: + server_time = int(payload["serverTime"]) + except (KeyError, TypeError, ValueError) as error: + raise BinanceError("Binance time response has no valid serverTime") from error + self._time_offset_ms = server_time - ((before + after) // 2) + return self._time_offset_ms + + async def request( + self, + method: str, + path: str, + parameters: Mapping[str, Any] | None = None, + *, + signed: bool = False, + api_key: bool = False, + ) -> Any: + method = method.upper() + mutating = method in {"POST", "PUT", "DELETE"} + synchronized = False + last_error: Exception | None = None + for attempt in range(self.max_retries + 1): + params = dict(parameters or {}) + if signed: + params["recvWindow"] = self.recv_window + params["timestamp"] = self.timestamp_ms + params["signature"] = sign_parameters(params, self.credentials.api_secret) + headers = {"X-MBX-APIKEY": self.credentials.api_key} if signed or api_key else {} + wire_params: Any = params + if signed: + wire_params = [ + (key, "true" if value is True else "false" if value is False else str(value)) + for key, value in sorted(params.items()) + if key != "signature" and value is not None + ] + wire_params.append(("signature", str(params["signature"]))) + try: + response = await self._http.request(method, path, params=wire_params, headers=headers) + except httpx.RequestError as error: + last_error = error + if mutating: + raise BinanceUnknownResult(f"Binance request result is unknown: {error}") from error + if attempt >= self.max_retries: + raise BinanceError(f"Binance request failed: {error}") from error + await self._sleep(min(2**attempt, 8)) + continue + + payload = _response_payload(response) + code = _error_code(payload) + if response.status_code in {418, 429}: + if attempt >= self.max_retries: + raise BinanceError( + _error_message(payload, response), status_code=response.status_code, code=code + ) + await self._sleep(_retry_delay(response, payload, attempt, self._clock())) + continue + if code == -1021 and signed and not synchronized: + await self.sync_time() + synchronized = True + continue + if response.status_code >= 500: + if mutating: + raise BinanceUnknownResult( + _error_message(payload, response), status_code=response.status_code, code=code + ) + if attempt < self.max_retries: + await self._sleep(min(2**attempt, 8)) + continue + if response.is_error or (code is not None and code < 0): + raise BinanceError( + _error_message(payload, response), status_code=response.status_code, code=code + ) + return payload + raise BinanceError(f"Binance request failed: {last_error or 'retry limit reached'}") + + async def mutate_with_recovery( + self, + method: str, + path: str, + parameters: Mapping[str, Any], + *, + lookup_path: str, + lookup_parameters: Mapping[str, Any], + ) -> Any: + """Resolve an ambiguous mutation by querying its stable client ID before returning.""" + + try: + return await self.request(method, path, parameters, signed=True) + except BinanceUnknownResult as unknown: + try: + return await self.request("GET", lookup_path, lookup_parameters, signed=True) + except BinanceError as lookup_error: + if lookup_error.code in {-2011, -2013}: + raise unknown + raise + + +def _response_payload(response: httpx.Response) -> Any: + try: + return response.json() + except (json.JSONDecodeError, ValueError): + return {"msg": response.text or response.reason_phrase} + + +def _error_code(payload: Any) -> int | None: + if not isinstance(payload, dict): + return None + try: + return int(payload["code"]) + except (KeyError, TypeError, ValueError): + return None + + +def _error_message(payload: Any, response: httpx.Response) -> str: + if isinstance(payload, dict): + message = payload.get("msg") or payload.get("message") + if message: + return f"Binance error {_error_code(payload) or response.status_code}: {message}" + return f"Binance HTTP {response.status_code}: {response.reason_phrase}" + + +def _retry_delay(response: httpx.Response, payload: Any, attempt: int, clock: Callable[[], float]) -> float: + header = response.headers.get("Retry-After") + if header: + try: + return max(0.0, float(header)) + except ValueError: + try: + return max(0.0, parsedate_to_datetime(header).timestamp() - clock()) + except (TypeError, ValueError): + pass + if isinstance(payload, dict): + retry_after = payload.get("retryAfter") + if retry_after is None and isinstance(payload.get("data"), dict): + retry_after = payload["data"].get("retryAfter") + try: + value = float(retry_after) + if value > 10_000_000_000: + return max(0.0, value / 1000 - clock()) + return max(0.0, value) + except (TypeError, ValueError): + pass + return float(min(2**attempt, 8)) diff --git a/src/llm_trade_bot/binance_gateway.py b/src/llm_trade_bot/binance_gateway.py new file mode 100644 index 0000000..77f59f6 --- /dev/null +++ b/src/llm_trade_bot/binance_gateway.py @@ -0,0 +1,1053 @@ +from __future__ import annotations + +import asyncio +import json +import time +from contextlib import suppress +from decimal import ROUND_DOWN, Decimal, InvalidOperation +from typing import Any +from uuid import uuid4 + +import websockets + +from llm_trade_bot.binance_client import ( + SPOT_REST_URL, + SPOT_WS_API_URL, + USDM_REST_URL, + USDM_WS_URL, + BinanceCredentials, + BinanceError, + BinanceRestClient, + sign_parameters, +) +from llm_trade_bot.config import BinanceConfig, ScannerConfig +from llm_trade_bot.exchange_gateway import PrivateEventHandler, normalized_order +from llm_trade_bot.market import technical_indicators +from llm_trade_bot.models import ( + AccountSnapshot, + Category, + InstrumentRules, + MarketCandidate, + OpenTradeRequest, + OrderType, + Side, + ValidatedOrder, +) + +_INTERVALS = {"60": "1h", "240": "4h", "D": "1d"} +_STABLE_COINS = {"USDT", "USDC", "FDUSD", "BUSD", "DAI", "USDE"} + + +class BinanceGateway: + """Binance Spot and USDⓈ-M gateway, deliberately locked to API Testnet.""" + + provider = "binance" + environment = "testnet" + + def __init__( + self, + config: BinanceConfig, + scanner: ScannerConfig, + *, + spot_client: BinanceRestClient | None = None, + usdm_client: BinanceRestClient | None = None, + ): + self.config = config + self.scanner = scanner + self.spot = spot_client or BinanceRestClient( + SPOT_REST_URL, + BinanceCredentials(config.spot.api_key, config.spot.api_secret), + time_path="/api/v3/time", + ) + self.usdm = usdm_client or BinanceRestClient( + USDM_REST_URL, + BinanceCredentials(config.usdm.api_key, config.usdm.api_secret), + time_path="/fapi/v1/time", + ) + self._instrument_cache: dict[str, dict[str, Any]] = {} + self._known_symbols: dict[str, set[str]] = {"spot": set(), "linear": set()} + self._pending_protection: dict[str, tuple[OpenTradeRequest, ValidatedOrder]] = {} + + async def close(self) -> None: + await asyncio.gather(self.spot.close(), self.usdm.close()) + + async def startup_check(self) -> dict[str, Any]: + await asyncio.gather(self.spot.sync_time(), self.usdm.sync_time()) + position_mode, spot_account, futures_account = await asyncio.gather( + self.usdm.request("GET", "/fapi/v1/positionSide/dual", signed=True), + self.spot.request("GET", "/api/v3/account", signed=True), + self.usdm.request("GET", "/fapi/v3/account", signed=True), + ) + dual_side = position_mode.get("dualSidePosition") + is_dual_side = str(dual_side).lower() == "true" if isinstance(dual_side, str) else bool(dual_side) + if is_dual_side: + raise BinanceError("Binance USDⓈ-M account must use one-way position mode") + return { + "provider": self.provider, + "environment": self.environment, + "one_way": True, + "spot_account_type": spot_account.get("accountType", "SPOT"), + "usdm_available": futures_account.get("availableBalance"), + } + + async def positions(self) -> list[dict[str, Any]]: + data = await self.usdm.request("GET", "/fapi/v3/positionRisk", signed=True) + result: list[dict[str, Any]] = [] + for item in data: + amount = _decimal(item.get("positionAmt")) + result.append( + { + "provider": self.provider, + "category": "linear", + "symbol": item.get("symbol"), + "side": "Buy" if amount > 0 else "Sell" if amount < 0 else "None", + "size": _plain(abs(amount)), + "entry_price": str(item.get("entryPrice") or "0"), + "mark_price": str(item.get("markPrice") or "0"), + "notional": str(item.get("notional") or "0"), + "leverage": str(item.get("leverage") or "1"), + "margin_type": item.get("marginType"), + "position_side": item.get("positionSide", "BOTH"), + } + ) + return result + + async def _spot_account(self) -> dict[str, Any]: + return await self.spot.request("GET", "/api/v3/account", signed=True) + + async def _usdm_account(self) -> dict[str, Any]: + return await self.usdm.request("GET", "/fapi/v3/account", signed=True) + + async def account_state(self) -> dict[str, Any]: + spot_account, futures_account, positions, orders = await asyncio.gather( + self._spot_account(), self._usdm_account(), self.positions(), self.open_orders() + ) + spot_balances = [ + { + "asset": item.get("asset"), + "free": str(item.get("free") or "0"), + "locked": str(item.get("locked") or "0"), + } + for item in spot_account.get("balances", []) + if _decimal(item.get("free")) + _decimal(item.get("locked")) > 0 + ] + return { + "provider": self.provider, + "environment": self.environment, + "wallets": { + "spot": {"balances": spot_balances}, + "linear": { + "total_equity": str(futures_account.get("totalMarginBalance") or "0"), + "available_balance": str(futures_account.get("availableBalance") or "0"), + "assets": futures_account.get("assets", []), + }, + }, + "positions": positions, + "open_orders": orders, + } + + async def account_snapshot(self, category: str | None = None) -> AccountSnapshot: + state = await self.account_state() + active = _active_ideas(state, self.scanner.quote_coin) + if category == "linear": + wallet = state["wallets"]["linear"] + gross = sum( + (abs(_decimal(item.get("notional"))) for item in state["positions"]), Decimal(0) + ) + gross += sum( + (_decimal(item.get("quantity")) * _decimal(item.get("price"))) + for item in state["open_orders"] + if item.get("category") == "linear" and not item.get("protective") + ) + return AccountSnapshot( + _decimal(wallet.get("total_equity")), + _decimal(wallet.get("available_balance")), + gross, + len(active), + ) + + spot = state["wallets"]["spot"] + prices = await self.spot.request("GET", "/api/v3/ticker/price") + price_map = {str(item.get("symbol")): _decimal(item.get("price")) for item in prices} + equity = Decimal(0) + available = Decimal(0) + gross = Decimal(0) + for balance in spot["balances"]: + asset = str(balance.get("asset")) + total = _decimal(balance.get("free")) + _decimal(balance.get("locked")) + free = _decimal(balance.get("free")) + if asset == self.scanner.quote_coin: + equity += total + available += free + else: + value = total * price_map.get(f"{asset}{self.scanner.quote_coin}", Decimal(0)) + equity += value + gross += value + gross += sum( + (_decimal(item.get("quantity")) - _decimal(item.get("filled_quantity"))) + * _decimal(item.get("price")) + for item in state["open_orders"] + if item.get("category") == "spot" and not item.get("protective") + ) + if category is None: + linear = await self.account_snapshot("linear") + return AccountSnapshot( + equity + linear.equity, + available + linear.available_balance, + gross + linear.gross_exposure, + len(active), + ) + return AccountSnapshot(equity, available, gross, len(active)) + + async def scan_markets(self) -> list[MarketCandidate]: + candidates: list[MarketCandidate] = [] + for category in self.scanner.categories: + client, prefix = self._market(category) + exchange_info, tickers, books = await asyncio.gather( + client.request("GET", f"{prefix}/exchangeInfo"), + client.request("GET", f"{prefix}/ticker/24hr"), + client.request("GET", f"{prefix}/ticker/bookTicker"), + ) + tradable = { + str(item.get("symbol")) + for item in exchange_info.get("symbols", []) + if item.get("status") == "TRADING" + and item.get("quoteAsset") == self.scanner.quote_coin + and (category != "linear" or item.get("contractType") == "PERPETUAL") + } + ticker_map = {str(item.get("symbol")): item for item in tickers} + book_map = {str(item.get("symbol")): item for item in books} + selected: list[MarketCandidate] = [] + for symbol in tradable: + ticker = ticker_map.get(symbol, {}) + book = book_map.get(symbol, {}) + bid = _decimal(book.get("bidPrice")) + ask = _decimal(book.get("askPrice")) + last = _decimal(ticker.get("lastPrice")) + turnover = _decimal(ticker.get("quoteVolume")) + if min(bid, ask, last) <= 0 or turnover < Decimal(str(self.scanner.min_turnover_usdt)): + continue + spread = (ask - bid) / ((ask + bid) / Decimal(2)) * Decimal(10_000) + if spread <= Decimal(str(self.scanner.max_spread_bps)): + selected.append(MarketCandidate(category, symbol, last, turnover, spread)) + selected.sort(key=lambda item: item.turnover_24h, reverse=True) + candidates.extend(selected[: self.scanner.top_n_per_category]) + self._known_symbols[category].update(item.symbol for item in selected[: self.scanner.top_n_per_category]) + return candidates + + async def market_context(self, category: str, symbol: str) -> dict[str, Any]: + client, prefix = self._market(category) + symbol = symbol.upper() + self._known_symbols[category].add(symbol) + ticker, book = await asyncio.gather( + client.request("GET", f"{prefix}/ticker/24hr", {"symbol": symbol}), + client.request("GET", f"{prefix}/depth", {"symbol": symbol, "limit": 25}), + ) + frames: dict[str, Any] = {} + for requested in self.scanner.candle_intervals: + interval = _INTERVALS.get(requested) + if interval is None: + raise BinanceError(f"Unsupported Binance candle interval: {requested}") + rows = await client.request( + "GET", + f"{prefix}/klines", + {"symbol": symbol, "interval": interval, "limit": self.scanner.candle_limit}, + ) + closes = [_decimal(row[4]) for row in rows] + highs = [_decimal(row[2]) for row in rows] + lows = [_decimal(row[3]) for row in rows] + volumes = [_decimal(row[5]) for row in rows] + frames[requested] = { + "exchange_interval": interval, + "indicators": technical_indicators(closes, highs, lows, volumes), + "recent_candles": rows[-20:], + } + return { + "provider": self.provider, + "environment": self.environment, + "category": category, + "symbol": symbol, + "ticker": ticker, + "orderbook": book, + "timeframes": frames, + } + + async def instrument_rules(self, category: str, symbol: str) -> InstrumentRules: + symbol = symbol.upper() + self._known_symbols[category].add(symbol) + key = f"{category}:{symbol}" + item = self._instrument_cache.get(key) + if item is None: + client, prefix = self._market(category) + response = await client.request("GET", f"{prefix}/exchangeInfo", {"symbol": symbol}) + matches = [entry for entry in response.get("symbols", []) if entry.get("symbol") == symbol] + if not matches: + raise BinanceError(f"Instrument not found: {category} {symbol}") + item = matches[0] + self._instrument_cache[key] = item + filters = {entry.get("filterType"): entry for entry in item.get("filters", [])} + price_filter = filters.get("PRICE_FILTER", {}) + lot = filters.get("LOT_SIZE", {}) + notional = filters.get("NOTIONAL") or filters.get("MIN_NOTIONAL") or {} + return InstrumentRules( + tick_size=_decimal(price_filter.get("tickSize")), + qty_step=_decimal(lot.get("stepSize")), + min_qty=_decimal(lot.get("minQty")), + min_notional=_decimal(notional.get("minNotional") or notional.get("notional")), + max_leverage=Decimal(1) if category == "spot" else Decimal(125), + ) + + async def open_orders(self, category: str | None = None) -> list[dict[str, Any]]: + categories = [category] if category else list(self.scanner.categories) + result: list[dict[str, Any]] = [] + for current in categories: + if current == "spot": + orders = await self.spot.request("GET", "/api/v3/openOrders", signed=True) + self._known_symbols["spot"].update(str(item.get("symbol")) for item in orders if item.get("symbol")) + result.extend(_normalize_spot_order(item) for item in orders) + elif current == "linear": + orders, algo = await asyncio.gather( + self.usdm.request("GET", "/fapi/v1/openOrders", signed=True), + self.usdm.request("GET", "/fapi/v1/openAlgoOrders", signed=True), + ) + self._known_symbols["linear"].update( + str(item.get("symbol")) for item in [*orders, *algo] if item.get("symbol") + ) + result.extend(_normalize_futures_order(item) for item in orders) + result.extend(_normalize_futures_algo(item) for item in algo) + else: + raise ValueError("category must be spot or linear") + return result + + async def order_history(self, category: str, limit: int = 20) -> list[dict[str, Any]]: + if category == "spot": + # Binance requires a symbol for allOrders; scanner/context establish this bounded set. + symbols = set(self._known_symbols["spot"]) + symbols.update(item["symbol"] for item in await self.open_orders("spot")) + result: list[dict[str, Any]] = [] + for symbol in sorted(symbols): + rows = await self.spot.request( + "GET", "/api/v3/allOrders", {"symbol": symbol, "limit": min(limit, 50)}, signed=True + ) + result.extend(_normalize_spot_order(item) for item in rows) + return result[-limit:] + symbols = set(self._known_symbols["linear"]) + symbols.update(item["symbol"] for item in await self.open_orders("linear")) + result = [] + for symbol in sorted(symbols): + rows = await self.usdm.request( + "GET", "/fapi/v1/allOrders", {"symbol": symbol, "limit": min(limit, 50)}, signed=True + ) + result.extend(_normalize_futures_order(item) for item in rows) + return result[-limit:] + + async def executions(self, category: str, limit: int = 20) -> list[dict[str, Any]]: + if category == "spot": + symbols = set(self._known_symbols["spot"]) + symbols.update(item["symbol"] for item in await self.open_orders("spot")) + result: list[dict[str, Any]] = [] + for symbol in sorted(symbols): + rows = await self.spot.request( + "GET", "/api/v3/myTrades", {"symbol": symbol, "limit": min(limit, 100)}, signed=True + ) + result.extend(_normalize_spot_trade(item, symbol) for item in rows) + return result[-limit:] + symbols = set(self._known_symbols["linear"]) + symbols.update(item["symbol"] for item in await self.open_orders("linear")) + result = [] + for symbol in sorted(symbols): + rows = await self.usdm.request( + "GET", "/fapi/v1/userTrades", {"symbol": symbol, "limit": min(limit, 100)}, signed=True + ) + result.extend(_normalize_futures_trade(item) for item in rows) + return result[-limit:] + + async def open_trade( + self, request: OpenTradeRequest, order: ValidatedOrder, client_id: str + ) -> dict[str, Any]: + self._known_symbols[request.category.value].add(request.symbol) + if request.category is Category.SPOT: + return await self._open_spot(request, order, client_id) + return await self._open_futures(request, order, client_id) + + async def _open_spot( + self, request: OpenTradeRequest, order: ValidatedOrder, client_id: str + ) -> dict[str, Any]: + if request.order_type is not OrderType.LIMIT or request.side is not Side.BUY: + raise BinanceError("Binance Spot entries must be Buy Limit OTOCO orders") + info = await self._instrument(request.category.value, request.symbol) + if info.get("otoAllowed") is False or info.get("ocoAllowed") is False: + raise BinanceError(f"Binance Testnet does not advertise OTOCO support for {request.symbol}") + if order.take_profit is None: + raise BinanceError("Spot OTOCO requires take-profit") + list_id = _client_id(client_id, "-list", 36) + parameters = { + "symbol": request.symbol, + "listClientOrderId": list_id, + "workingType": "LIMIT", + "workingSide": "BUY", + "workingClientOrderId": _client_id(client_id, "-entry", 36), + "workingPrice": _plain(order.entry_price), + "workingQuantity": _plain(order.quantity), + "workingTimeInForce": "GTC", + "pendingSide": "SELL", + "pendingQuantity": _plain(order.quantity), + "pendingAboveType": "TAKE_PROFIT", + "pendingAboveClientOrderId": _client_id(client_id, "-tp", 36), + "pendingAboveStopPrice": _plain(order.take_profit), + "pendingBelowType": "STOP_LOSS", + "pendingBelowClientOrderId": _client_id(client_id, "-sl", 36), + "pendingBelowStopPrice": _plain(order.stop_loss), + "newOrderRespType": "RESULT", + } + response = await self.spot.mutate_with_recovery( + "POST", + "/api/v3/orderList/otoco", + parameters, + lookup_path="/api/v3/orderList", + lookup_parameters={"origClientOrderId": list_id}, + ) + reports = response.get("orderReports") or response.get("orders") or [] + entry = next( + (item for item in reports if item.get("clientOrderId") == parameters["workingClientOrderId"]), + reports[0] if reports else {}, + ) + return normalized_order( + provider=self.provider, + category="spot", + symbol=request.symbol, + side="Buy", + status=_status(entry.get("status") or response.get("listOrderStatus") or "NEW"), + quantity=order.quantity, + filled_quantity=entry.get("executedQty", "0"), + price=order.entry_price, + client_id=list_id, + order_id=entry.get("orderId") or response.get("orderListId"), + protective=False, + entry_id=str(entry.get("orderId") or "") or None, + protection_ids=[ + str(item.get("orderId")) + for item in reports + if item.get("clientOrderId") != parameters["workingClientOrderId"] and item.get("orderId") is not None + ], + order_list_id=str(response.get("orderListId") or "") or None, + protection={"type": "OTOCO", "stop_loss": _plain(order.stop_loss), "take_profit": _plain(order.take_profit)}, + raw=response, + ) + + async def _open_futures( + self, request: OpenTradeRequest, order: ValidatedOrder, client_id: str + ) -> dict[str, Any]: + callback_rate = _callback_rate(order.trailing_distance, order.entry_price) + await self._prepare_futures_symbol(request.symbol, order.leverage) + params: dict[str, Any] = { + "symbol": request.symbol, + "side": request.side.value.upper(), + "type": request.order_type.value.upper(), + "quantity": _plain(order.quantity), + "newClientOrderId": _client_id(client_id, "", 32), + "newOrderRespType": "RESULT", + "positionSide": "BOTH", + } + if request.order_type is OrderType.LIMIT: + params.update(price=_plain(order.entry_price), timeInForce="GTC") + response = await self.usdm.mutate_with_recovery( + "POST", + "/fapi/v1/order", + params, + lookup_path="/fapi/v1/order", + lookup_parameters={"symbol": request.symbol, "origClientOrderId": params["newClientOrderId"]}, + ) + filled = _decimal(response.get("executedQty") or response.get("cumQty")) + protection: dict[str, Any] | None = None + if filled > 0: + protection = await self._install_futures_protection(request, order, client_id, callback_rate) + else: + self._pending_protection[str(params["newClientOrderId"])] = (request, order) + return normalized_order( + provider=self.provider, + category="linear", + symbol=request.symbol, + side=request.side.value, + status=_status(response.get("status") or "NEW"), + quantity=order.quantity, + filled_quantity=filled, + price=response.get("avgPrice") or response.get("price") or order.entry_price, + client_id=params["newClientOrderId"], + order_id=response.get("orderId"), + protective=False, + protection=protection or {"status": "pending_first_fill"}, + protection_ids=(protection or {}).get("order_ids", []), + raw=response, + ) + + async def _prepare_futures_symbol(self, symbol: str, leverage: Decimal) -> None: + try: + await self.usdm.request( + "POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"}, signed=True + ) + except BinanceError as error: + if error.code != -4046 and "No need to change" not in str(error): + raise + await self.usdm.request( + "POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": int(leverage)}, signed=True + ) + + async def _install_futures_protection( + self, + request: OpenTradeRequest, + order: ValidatedOrder, + client_id: str, + callback_rate: Decimal | None = None, + ) -> dict[str, Any]: + side = "SELL" if request.side is Side.BUY else "BUY" + stop_id = _client_id(client_id, "-sl", 32) + stop_params = { + "algoType": "CONDITIONAL", + "symbol": request.symbol, + "side": side, + "positionSide": "BOTH", + "type": "STOP_MARKET", + "triggerPrice": _plain(order.stop_loss), + "workingType": "MARK_PRICE", + "closePosition": True, + "clientAlgoId": stop_id, + } + try: + stop = await self.usdm.mutate_with_recovery( + "POST", + "/fapi/v1/algoOrder", + stop_params, + lookup_path="/fapi/v1/algoOrder", + lookup_parameters={"clientAlgoId": stop_id}, + ) + except Exception as error: + await self._emergency_close(request.symbol, request.side, order.quantity, client_id) + raise BinanceError( + f"Mandatory stop-loss failed; position was emergency-closed: {error}" + ) from error + + secondary: dict[str, Any] + if order.take_profit is not None: + tp_id = _client_id(client_id, "-tp", 32) + secondary_params = { + "algoType": "CONDITIONAL", + "symbol": request.symbol, + "side": side, + "positionSide": "BOTH", + "type": "TAKE_PROFIT_MARKET", + "triggerPrice": _plain(order.take_profit), + "workingType": "MARK_PRICE", + "closePosition": True, + "clientAlgoId": tp_id, + } + else: + rate = callback_rate if callback_rate is not None else _callback_rate(order.trailing_distance, order.entry_price) + trail_id = _client_id(client_id, "-trail", 32) + secondary_params = { + "algoType": "CONDITIONAL", + "symbol": request.symbol, + "side": side, + "positionSide": "BOTH", + "type": "TRAILING_STOP_MARKET", + "callbackRate": _plain(rate), + "workingType": "MARK_PRICE", + "quantity": _plain(order.quantity), + "reduceOnly": True, + "clientAlgoId": trail_id, + } + secondary = await self.usdm.mutate_with_recovery( + "POST", + "/fapi/v1/algoOrder", + secondary_params, + lookup_path="/fapi/v1/algoOrder", + lookup_parameters={"clientAlgoId": secondary_params["clientAlgoId"]}, + ) + return { + "status": "active", + "stop_loss": _plain(order.stop_loss), + "take_profit": _plain(order.take_profit), + "callback_rate": _plain(callback_rate), + "order_ids": [str(stop.get("algoId")), str(secondary.get("algoId"))], + "client_ids": [stop_id, str(secondary_params["clientAlgoId"])], + } + + async def _emergency_close(self, symbol: str, entry_side: Side, quantity: Decimal, client_id: str) -> None: + with suppress(Exception): + await self.usdm.request( + "POST", + "/fapi/v1/order", + { + "symbol": symbol, + "side": "SELL" if entry_side is Side.BUY else "BUY", + "type": "MARKET", + "quantity": _plain(quantity), + "reduceOnly": True, + "positionSide": "BOTH", + "newClientOrderId": _client_id(client_id, "-panic", 32), + "newOrderRespType": "RESULT", + }, + signed=True, + ) + + async def close_trade( + self, + category: str, + symbol: str, + percent: float, + order_type: str, + limit_price: str | None, + client_id: str, + ) -> dict[str, Any]: + symbol = symbol.upper() + rules = await self.instrument_rules(category, symbol) + if category == "linear": + positions = await self.positions() + position = next((item for item in positions if item["symbol"] == symbol and _decimal(item["size"]) > 0), None) + if position is None: + raise BinanceError("No linear position found") + quantity = _round_down(_decimal(position["size"]) * Decimal(str(percent)) / 100, rules.qty_step) + side = "SELL" if position["side"] == "Buy" else "BUY" + params: dict[str, Any] = { + "symbol": symbol, + "side": side, + "type": order_type.upper(), + "quantity": _plain(quantity), + "reduceOnly": True, + "positionSide": "BOTH", + "newClientOrderId": _client_id(client_id, "", 32), + "newOrderRespType": "RESULT", + } + if order_type == "Limit": + if limit_price is None: + raise ValueError("Limit close requires limit_price") + params.update(price=limit_price, timeInForce="GTC") + response = await self.usdm.mutate_with_recovery( + "POST", "/fapi/v1/order", params, lookup_path="/fapi/v1/order", + lookup_parameters={"symbol": symbol, "origClientOrderId": params["newClientOrderId"]} + ) + if _status(response.get("status")) == "Filled": + await self._cancel_all_algo(symbol) + return _normalize_futures_order(response) + + account = await self._spot_account() + base = symbol.removesuffix(self.scanner.quote_coin) + balance = next((item for item in account.get("balances", []) if item.get("asset") == base), None) + if balance is None: + raise BinanceError("No spot holding found") + quantity = _round_down(_decimal(balance.get("free")) * Decimal(str(percent)) / 100, rules.qty_step) + if quantity < rules.min_qty: + raise BinanceError("Close quantity is below instrument minimum") + await self._cancel_spot_protection(symbol) + params = { + "symbol": symbol, + "side": "SELL", + "type": order_type.upper(), + "quantity": _plain(quantity), + "newClientOrderId": _client_id(client_id, "", 36), + "newOrderRespType": "RESULT", + } + if order_type == "Limit": + if limit_price is None: + raise ValueError("Limit close requires limit_price") + params.update(price=limit_price, timeInForce="GTC") + response = await self.spot.mutate_with_recovery( + "POST", "/api/v3/order", params, lookup_path="/api/v3/order", + lookup_parameters={"symbol": symbol, "origClientOrderId": params["newClientOrderId"]} + ) + return _normalize_spot_order(response) + + async def cancel_order( + self, + category: str, + symbol: str, + order_id: str | None = None, + client_id: str | None = None, + ) -> dict[str, Any]: + symbol = symbol.upper() + if category == "spot": + params: dict[str, Any] = {"symbol": symbol} + if client_id: + params["listClientOrderId"] = ( + client_id if client_id.endswith("-list") else _client_id(client_id, "-list", 36) + ) + elif order_id: + # A user-facing order_id identifies the working order. Binance cancels its list with it. + response = await self.spot.request( + "DELETE", "/api/v3/order", {"symbol": symbol, "orderId": order_id}, signed=True + ) + return _normalize_spot_order(response) + else: + raise ValueError("order_id or client_id is required") + response = await self.spot.request("DELETE", "/api/v3/orderList", params, signed=True) + reports = response.get("orderReports") or [] + first = reports[0] if reports else {} + return normalized_order( + provider=self.provider, + category=category, + symbol=symbol, + side=_side(first.get("side")), + status="Cancelled", + quantity=first.get("origQty") or "0", + filled_quantity=first.get("executedQty") or "0", + price=first.get("price") or "0", + client_id=params.get("listClientOrderId"), + order_id=response.get("orderListId") or first.get("orderId"), + protective=False, + raw=response, + ) + params = {"symbol": symbol} + if client_id: + params["origClientOrderId"] = client_id + elif order_id: + params["orderId"] = order_id + else: + raise ValueError("order_id or client_id is required") + try: + response = await self.usdm.request("DELETE", "/fapi/v1/order", params, signed=True) + return _normalize_futures_order(response) + except BinanceError as error: + if error.code not in {-2011, -2013}: + raise + algo_params = {"algoId": order_id} if order_id else {"clientAlgoId": client_id} + response = await self.usdm.request("DELETE", "/fapi/v1/algoOrder", algo_params, signed=True) + return _normalize_futures_algo(response) + + async def update_protection( + self, + symbol: str, + stop_loss: str, + take_profit: str | None, + trailing_distance: str | None, + ) -> dict[str, Any]: + positions = await self.positions() + position = next((item for item in positions if item["symbol"] == symbol.upper() and _decimal(item["size"]) > 0), None) + if position is None: + raise BinanceError("No linear position found") + entry_price = _decimal(position["entry_price"]) + request = OpenTradeRequest( + Category.LINEAR, + symbol.upper(), + Side(position["side"]), + OrderType.MARKET, + entry_price, + None, + Decimal(stop_loss), + Decimal(take_profit) if take_profit is not None else None, + Decimal(trailing_distance) if trailing_distance is not None else None, + Decimal(1), + _decimal(position["leverage"]), + "protection update", + ) + order = ValidatedOrder( + entry_price, + _decimal(position["size"]), + _decimal(position["size"]) * entry_price, + request.stop_loss, + request.take_profit, + request.trailing_distance, + request.leverage, + ) + old = await self.usdm.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol.upper()}, signed=True) + replacement_id = f"ltb-protect-{uuid4().hex[:12]}" + protection = await self._install_futures_protection(request, order, replacement_id) + new_ids = set(protection["order_ids"]) + for item in old: + algo_id = str(item.get("algoId") or "") + if algo_id and algo_id not in new_ids: + await self.usdm.request("DELETE", "/fapi/v1/algoOrder", {"algoId": algo_id}, signed=True) + return {"ok": True, "provider": self.provider, "category": "linear", "protective": protection} + + async def reconcile(self) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for client_id, (request, order) in list(self._pending_protection.items()): + try: + status = await self.usdm.request( + "GET", "/fapi/v1/order", {"symbol": request.symbol, "origClientOrderId": client_id}, signed=True + ) + except BinanceError as error: + if error.code == -2013: + continue + raise + if _decimal(status.get("executedQty") or status.get("cumQty")) <= 0: + continue + protection = await self._install_futures_protection(request, order, client_id) + self._pending_protection.pop(client_id, None) + events.append({"kind": "protection_activated", "client_id": client_id, "protective": protection}) + positions = await self.positions() + open_symbols = {item["symbol"] for item in positions if _decimal(item["size"]) > 0} + algo = await self.usdm.request("GET", "/fapi/v1/openAlgoOrders", signed=True) + for item in algo: + if item.get("symbol") not in open_symbols: + await self.usdm.request("DELETE", "/fapi/v1/algoOrder", {"algoId": item.get("algoId")}, signed=True) + events.append({"kind": "orphan_protection_cancelled", "order_id": str(item.get("algoId"))}) + return events + + async def private_stream(self, handler: PrivateEventHandler, stop_event: asyncio.Event) -> None: + spot_task = asyncio.create_task(self._spot_stream(handler, stop_event), name="binance-spot-user-stream") + futures_task = asyncio.create_task(self._futures_stream(handler, stop_event), name="binance-usdm-user-stream") + try: + await asyncio.gather(spot_task, futures_task) + finally: + spot_task.cancel() + futures_task.cancel() + await asyncio.gather(spot_task, futures_task, return_exceptions=True) + + async def _spot_stream(self, handler: PrivateEventHandler, stop_event: asyncio.Event) -> None: + backoff = 1 + while not stop_event.is_set(): + try: + async with websockets.connect(SPOT_WS_API_URL, ping_interval=None) as websocket: + timestamp = self.spot.timestamp_ms + params = { + "apiKey": self.config.spot.api_key, + "recvWindow": 5000, + "timestamp": timestamp, + } + params["signature"] = sign_parameters(params, self.config.spot.api_secret) + await websocket.send(json.dumps({"id": "subscribe", "method": "userDataStream.subscribe.signature", "params": params})) + reply = json.loads(await asyncio.wait_for(websocket.recv(), timeout=10)) + if int(reply.get("status", 500)) != 200: + raise BinanceError(f"Spot user stream subscription failed: {reply}") + backoff = 1 + deadline = time.monotonic() + 23 * 60 * 60 + while not stop_event.is_set() and time.monotonic() < deadline: + try: + raw = await asyncio.wait_for(websocket.recv(), timeout=60) + except TimeoutError: + continue + message = json.loads(raw) + if "event" in message: + await handler(_normalize_spot_event(message["event"])) + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - every stream fault must trigger a reconnect + await handler({"provider": self.provider, "category": "spot", "kind": "stream_disconnected", "error": str(error)}) + if not stop_event.is_set(): + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60) + await handler({"provider": self.provider, "category": "spot", "kind": "reconcile_required"}) + + async def _futures_stream(self, handler: PrivateEventHandler, stop_event: asyncio.Event) -> None: + backoff = 1 + while not stop_event.is_set(): + keepalive: asyncio.Task[None] | None = None + try: + data = await self.usdm.request("POST", "/fapi/v1/listenKey", api_key=True) + listen_key = str(data["listenKey"]) + keepalive = asyncio.create_task(self._keepalive_listen_key(listen_key, stop_event)) + async with websockets.connect(f"{USDM_WS_URL}/ws/{listen_key}") as websocket: + backoff = 1 + deadline = time.monotonic() + 23 * 60 * 60 + while not stop_event.is_set() and time.monotonic() < deadline: + try: + raw = await asyncio.wait_for(websocket.recv(), timeout=60) + except TimeoutError: + continue + message = json.loads(raw) + await handler(_normalize_futures_event(message)) + if message.get("e") == "listenKeyExpired": + break + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - every stream fault must trigger a reconnect + await handler({"provider": self.provider, "category": "linear", "kind": "stream_disconnected", "error": str(error)}) + finally: + if keepalive: + keepalive.cancel() + await asyncio.gather(keepalive, return_exceptions=True) + if not stop_event.is_set(): + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60) + await handler({"provider": self.provider, "category": "linear", "kind": "reconcile_required"}) + + async def _keepalive_listen_key(self, listen_key: str, stop_event: asyncio.Event) -> None: + while not stop_event.is_set(): + try: + await asyncio.wait_for(stop_event.wait(), timeout=50 * 60) + return + except TimeoutError: + await self.usdm.request( + "PUT", "/fapi/v1/listenKey", {"listenKey": listen_key}, api_key=True + ) + + async def _instrument(self, category: str, symbol: str) -> dict[str, Any]: + await self.instrument_rules(category, symbol) + return self._instrument_cache[f"{category}:{symbol.upper()}"] + + def _market(self, category: str) -> tuple[BinanceRestClient, str]: + if category == "spot": + return self.spot, "/api/v3" + if category == "linear": + return self.usdm, "/fapi/v1" + raise ValueError("category must be spot or linear") + + async def _cancel_all_algo(self, symbol: str) -> None: + with suppress(BinanceError): + await self.usdm.request("DELETE", "/fapi/v1/algoOpenOrders", {"symbol": symbol}, signed=True) + + async def _cancel_spot_protection(self, symbol: str) -> None: + lists = await self.spot.request("GET", "/api/v3/openOrderList", signed=True) + for item in lists: + if item.get("symbol") == symbol: + await self.spot.request( + "DELETE", "/api/v3/orderList", {"symbol": symbol, "orderListId": item.get("orderListId")}, signed=True + ) + + +def _normalize_spot_order(item: dict[str, Any]) -> dict[str, Any]: + order_type = str(item.get("type") or item.get("orderType") or "") + return normalized_order( + provider="binance", + category="spot", + symbol=str(item.get("symbol") or ""), + side=_side(item.get("side")), + status=_status(item.get("status") or item.get("listOrderStatus")), + quantity=item.get("origQty") or item.get("quantity") or "0", + filled_quantity=item.get("executedQty") or "0", + price=item.get("price") or item.get("stopPrice") or "0", + client_id=item.get("clientOrderId") or item.get("listClientOrderId"), + order_id=item.get("orderId") or item.get("orderListId"), + protective=order_type in {"STOP_LOSS", "STOP_LOSS_LIMIT", "TAKE_PROFIT", "TAKE_PROFIT_LIMIT"}, + order_type=order_type, + ) + + +def _normalize_futures_order(item: dict[str, Any]) -> dict[str, Any]: + return normalized_order( + provider="binance", + category="linear", + symbol=str(item.get("symbol") or ""), + side=_side(item.get("side")), + status=_status(item.get("status")), + quantity=item.get("origQty") or item.get("quantity") or "0", + filled_quantity=item.get("executedQty") or item.get("cumQty") or "0", + price=item.get("avgPrice") or item.get("price") or "0", + client_id=item.get("clientOrderId"), + order_id=item.get("orderId"), + protective=bool(item.get("reduceOnly")), + order_type=item.get("type"), + ) + + +def _normalize_futures_algo(item: dict[str, Any]) -> dict[str, Any]: + return normalized_order( + provider="binance", + category="linear", + symbol=str(item.get("symbol") or ""), + side=_side(item.get("side")), + status=_status(item.get("algoStatus") or item.get("status")), + quantity=item.get("quantity") or "0", + filled_quantity=item.get("executedQty") or "0", + price=item.get("triggerPrice") or item.get("price") or "0", + client_id=item.get("clientAlgoId"), + order_id=item.get("algoId"), + protective=True, + order_type=item.get("orderType") or item.get("type"), + ) + + +def _normalize_spot_trade(item: dict[str, Any], symbol: str) -> dict[str, Any]: + return { + "provider": "binance", "category": "spot", "symbol": symbol, + "side": "Buy" if item.get("isBuyer") else "Sell", "quantity": str(item.get("qty") or "0"), + "price": str(item.get("price") or "0"), "order_id": str(item.get("orderId") or ""), + "trade_id": str(item.get("id") or ""), "commission": str(item.get("commission") or "0"), + } + + +def _normalize_futures_trade(item: dict[str, Any]) -> dict[str, Any]: + return { + "provider": "binance", "category": "linear", "symbol": item.get("symbol"), + "side": _side(item.get("side")), "quantity": str(item.get("qty") or "0"), + "price": str(item.get("price") or "0"), "order_id": str(item.get("orderId") or ""), + "trade_id": str(item.get("id") or ""), "commission": str(item.get("commission") or "0"), + } + + +def _normalize_spot_event(event: dict[str, Any]) -> dict[str, Any]: + if event.get("e") == "executionReport": + return normalized_order( + provider="binance", category="spot", symbol=str(event.get("s") or ""), side=_side(event.get("S")), + status=_status(event.get("X")), quantity=event.get("q") or "0", filled_quantity=event.get("z") or "0", + price=event.get("p") or event.get("L") or "0", client_id=event.get("c"), order_id=event.get("i"), + protective=str(event.get("o")) in {"STOP_LOSS", "STOP_LOSS_LIMIT", "TAKE_PROFIT", "TAKE_PROFIT_LIMIT"}, + kind="order", execution_type=event.get("x"), raw=event, + ) + return {"provider": "binance", "category": "spot", "kind": "account", "event_type": event.get("e"), "raw": event} + + +def _normalize_futures_event(event: dict[str, Any]) -> dict[str, Any]: + if event.get("e") in {"ORDER_TRADE_UPDATE", "ALGO_UPDATE"}: + order = event.get("o") or event.get("a") or {} + if event.get("e") == "ALGO_UPDATE": + normalized = _normalize_futures_algo(order) + else: + normalized = normalized_order( + provider="binance", category="linear", symbol=str(order.get("s") or ""), side=_side(order.get("S")), + status=_status(order.get("X")), quantity=order.get("q") or "0", filled_quantity=order.get("z") or "0", + price=order.get("ap") or order.get("p") or "0", client_id=order.get("c"), order_id=order.get("i"), + protective=bool(order.get("R")), order_type=order.get("o"), + ) + normalized.update(kind="order", raw=event) + return normalized + return {"provider": "binance", "category": "linear", "kind": "account", "event_type": event.get("e"), "raw": event} + + +def _active_ideas(state: dict[str, Any], quote_coin: str) -> set[str]: + result = { + f"linear:{item['symbol']}" for item in state.get("positions", []) if _decimal(item.get("size")) > 0 + } + for balance in state.get("wallets", {}).get("spot", {}).get("balances", []): + asset = str(balance.get("asset")) + if asset not in _STABLE_COINS and _decimal(balance.get("free")) + _decimal(balance.get("locked")) > 0: + result.add(f"spot:{asset}{quote_coin}") + result.update( + f"{item['category']}:{item['symbol']}" + for item in state.get("open_orders", []) + if not item.get("protective") + ) + return result + + +def _callback_rate(distance: Decimal | None, entry: Decimal) -> Decimal | None: + if distance is None: + return None + rate = (distance / entry * Decimal(100)).quantize(Decimal("0.1"), rounding=ROUND_DOWN) + if rate < Decimal("0.1") or rate > Decimal(10): + raise BinanceError("Binance trailing callbackRate must be between 0.1 and 10 percent") + return rate + + +def _client_id(base: str, suffix: str, maximum: int) -> str: + return f"{base[: maximum - len(suffix)]}{suffix}" + + +def _status(value: Any) -> str: + return { + "NEW": "New", "PENDING_NEW": "New", "PARTIALLY_FILLED": "PartiallyFilled", + "FILLED": "Filled", "CANCELED": "Cancelled", "CANCELLED": "Cancelled", + "EXPIRED": "Expired", "EXPIRED_IN_MATCH": "Expired", "REJECTED": "Rejected", + "EXECUTING": "New", "TRIGGERED": "Filled", + }.get(str(value or "").upper(), str(value or "Unknown")) + + +def _side(value: Any) -> str: + return "Buy" if str(value).upper() == "BUY" else "Sell" if str(value).upper() == "SELL" else str(value or "") + + +def _decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except (InvalidOperation, ValueError) as error: + raise BinanceError(f"Invalid numeric value from Binance: {value!r}") from error + + +def _plain(value: Any) -> str | None: + if value is None: + return None + return format(_decimal(value), "f") + + +def _round_down(value: Decimal, step: Decimal) -> Decimal: + return (value / step).to_integral_value(rounding=ROUND_DOWN) * step diff --git a/src/llm_trade_bot/bybit_gateway.py b/src/llm_trade_bot/bybit_gateway.py index 2edf410..06359d1 100644 --- a/src/llm_trade_bot/bybit_gateway.py +++ b/src/llm_trade_bot/bybit_gateway.py @@ -2,14 +2,24 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from contextlib import suppress from decimal import Decimal, InvalidOperation from typing import Any -from pybit.unified_trading import HTTP +from pybit.unified_trading import HTTP, WebSocket from llm_trade_bot.config import BybitConfig, ScannerConfig +from llm_trade_bot.exchange_gateway import PrivateEventHandler, normalized_order from llm_trade_bot.market import technical_indicators -from llm_trade_bot.models import AccountSnapshot, InstrumentRules, MarketCandidate +from llm_trade_bot.models import ( + AccountSnapshot, + Category, + InstrumentRules, + MarketCandidate, + OpenTradeRequest, + OrderType, + ValidatedOrder, +) class BybitError(RuntimeError): @@ -19,6 +29,9 @@ class BybitError(RuntimeError): class BybitGateway: """Async facade over pybit's synchronous Unified Trading HTTP client.""" + provider = "bybit" + environment = "demo" + def __init__(self, config: BybitConfig, scanner: ScannerConfig): self.config = config self.scanner = scanner @@ -29,6 +42,9 @@ class BybitGateway: api_secret=config.api_secret, ) + async def close(self) -> None: + return None + async def startup_check(self) -> dict[str, Any]: account = await self._call(self._http.get_account_info) margin_mode = str(_result(account).get("marginMode", "")) @@ -62,10 +78,10 @@ class BybitGateway: for order in _result(response).get("list", []): order = dict(order) order.setdefault("category", item_category) - result.append(order) + result.append(_normalize_bybit_order(order, item_category)) return result - async def account_snapshot(self) -> AccountSnapshot: + async def account_snapshot(self, category: str | None = None) -> AccountSnapshot: wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders()) accounts = _result(wallet).get("list", []) if not accounts: @@ -101,6 +117,8 @@ class BybitGateway: async def account_state(self) -> dict[str, Any]: wallet, positions, orders = await asyncio.gather(self.wallet(), self.positions(), self.open_orders()) return { + "provider": self.provider, + "environment": self.environment, "wallet": _result(wallet).get("list", []), "positions": positions, "open_orders": orders, @@ -154,6 +172,8 @@ class BybitGateway: "recent_candles": raw_candles[-20:], } return { + "provider": self.provider, + "environment": self.environment, "category": category, "symbol": symbol, "ticker": (_result(ticker_response).get("list") or [{}])[0], @@ -183,16 +203,44 @@ class BybitGateway: async def order_history(self, category: str, limit: int = 20) -> list[dict[str, Any]]: response = await self._call(self._http.get_order_history, category=category, limit=min(limit, 50)) - return list(_result(response).get("list", [])) + return [_normalize_bybit_order(item, category) for item in _result(response).get("list", [])] async def executions(self, category: str, limit: int = 20) -> list[dict[str, Any]]: response = await self._call(self._http.get_executions, category=category, limit=min(limit, 100)) - return list(_result(response).get("list", [])) + return [ + { + **item, + "provider": self.provider, + "category": category, + "symbol": item.get("symbol"), + "side": item.get("side"), + "quantity": str(item.get("execQty") or "0"), + "price": str(item.get("execPrice") or "0"), + "order_id": item.get("orderId"), + "client_id": item.get("orderLinkId"), + } + for item in _result(response).get("list", []) + ] async def place_order(self, **parameters: Any) -> dict[str, Any]: return await self._call(self._http.place_order, **parameters) - async def cancel_order(self, **parameters: Any) -> dict[str, Any]: + async def cancel_order( + self, + category: str | None = None, + symbol: str | None = None, + order_id: str | None = None, + client_id: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + if category is not None: + parameters.setdefault("category", category) + if symbol is not None: + parameters.setdefault("symbol", symbol) + if order_id is not None: + parameters.setdefault("orderId", order_id) + if client_id is not None: + parameters.setdefault("orderLinkId", client_id) return await self._call(self._http.cancel_order, **parameters) async def set_leverage(self, symbol: str, leverage: str) -> dict[str, Any]: @@ -207,6 +255,206 @@ class BybitGateway: async def set_trading_stop(self, **parameters: Any) -> dict[str, Any]: return await self._call(self._http.set_trading_stop, **parameters) + async def open_trade( + self, request: OpenTradeRequest, order: ValidatedOrder, client_id: str + ) -> dict[str, Any]: + if request.category is Category.LINEAR: + try: + await self.set_leverage(request.symbol, _plain(order.leverage)) + except BybitError as error: + if "not modified" not in str(error).lower() and "110043" not in str(error): + raise + parameters: dict[str, Any] = { + "category": request.category.value, + "symbol": request.symbol, + "side": request.side.value, + "orderType": request.order_type.value, + "qty": _plain(order.quantity), + "orderLinkId": client_id, + } + if request.order_type is OrderType.LIMIT: + parameters.update(price=_plain(order.entry_price), timeInForce="GTC") + if request.category is Category.SPOT: + parameters.update( + isLeverage=0, + takeProfit=_plain(order.take_profit), + stopLoss=_plain(order.stop_loss), + tpOrderType="Market", + slOrderType="Market", + ) + else: + parameters.update( + positionIdx=0, + stopLoss=_plain(order.stop_loss), + tpslMode="Partial", + slOrderType="Market", + ) + if order.take_profit is not None: + parameters.update(takeProfit=_plain(order.take_profit), tpOrderType="Market") + response = await self.place_order(**parameters) + result = response.get("result", {}) + return normalized_order( + provider=self.provider, + category=request.category.value, + symbol=request.symbol, + side=request.side.value, + status="Created", + quantity=order.quantity, + price=order.entry_price, + client_id=client_id, + order_id=result.get("orderId"), + protective=False, + protection={ + "stop_loss": _plain(order.stop_loss), + "take_profit": _plain(order.take_profit), + "trailing_distance": _plain(order.trailing_distance), + }, + raw=response, + ) + + async def close_trade( + self, + category: str, + symbol: str, + percent: float, + order_type: str, + limit_price: str | None, + client_id: str, + ) -> dict[str, Any]: + rules = await self.instrument_rules(category, symbol) + if category == "linear": + position = next( + ( + item + for item in await self.positions() + if item.get("symbol") == symbol and _decimal(item.get("size")) > 0 + ), + None, + ) + if position is None: + raise BybitError("No linear position found") + quantity = _decimal(position.get("size")) * Decimal(str(percent)) / Decimal(100) + side = "Sell" if position.get("side") == "Buy" else "Buy" + elif category == "spot": + for active in await self.open_orders("spot"): + if active.get("symbol") != symbol or not ( + active.get("protective") or _is_protective_order(active) + ): + continue + with suppress(BybitError): + await self.cancel_order( + category="spot", + symbol=symbol, + order_id=str(active.get("orderId") or "") or None, + orderFilter=str(active.get("orderFilter") or "tpslOrder"), + ) + state = await self.account_state() + wallet = (state.get("wallet") or [{}])[0] + base_coin = symbol.removesuffix(self.scanner.quote_coin) + coin = next((item for item in wallet.get("coin", []) if item.get("coin") == base_coin), None) + if coin is None: + raise BybitError("No spot holding found") + quantity = _decimal(coin.get("walletBalance")) * Decimal(str(percent)) / Decimal(100) + side = "Sell" + else: + raise ValueError("category must be spot or linear") + quantity = (quantity // rules.qty_step) * rules.qty_step + parameters: dict[str, Any] = { + "category": category, + "symbol": symbol, + "side": side, + "orderType": order_type, + "qty": _plain(quantity), + "orderLinkId": client_id, + } + if order_type == "Limit": + if limit_price is None: + raise ValueError("Limit close requires limit_price") + parameters.update(price=limit_price, timeInForce="GTC") + if category == "linear": + parameters.update(reduceOnly=True, positionIdx=0) + else: + parameters.update(isLeverage=0, marketUnit="baseCoin") + response = await self.place_order(**parameters) + result = response.get("result", {}) + return normalized_order( + provider=self.provider, + category=category, + symbol=symbol, + side=side, + status="Created", + quantity=quantity, + price=limit_price or "0", + client_id=client_id, + order_id=result.get("orderId"), + protective=category == "linear", + raw=response, + ) + + async def update_protection( + self, + symbol: str, + stop_loss: str, + take_profit: str | None, + trailing_distance: str | None, + ) -> dict[str, Any]: + parameters: dict[str, Any] = { + "category": "linear", + "symbol": symbol, + "positionIdx": 0, + "tpslMode": "Full", + "stopLoss": stop_loss, + "slTriggerBy": "MarkPrice", + } + if take_profit is not None: + parameters.update(takeProfit=take_profit, tpTriggerBy="MarkPrice") + if trailing_distance is not None: + parameters["trailingStop"] = trailing_distance + response = await self.set_trading_stop(**parameters) + return {"ok": True, "provider": self.provider, "category": "linear", "raw": response} + + async def private_stream(self, handler: PrivateEventHandler, stop_event: asyncio.Event) -> None: + loop = asyncio.get_running_loop() + queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue() + + def callback(kind: str): + def receive(message: dict[str, Any]) -> None: + loop.call_soon_threadsafe(queue.put_nowait, (kind, message)) + + return receive + + websocket = WebSocket( + testnet=False, + demo=True, + channel_type="private", + api_key=self.config.api_key, + api_secret=self.config.api_secret, + ) + websocket.order_stream(callback=callback("order")) + websocket.execution_stream(callback=callback("execution")) + websocket.position_stream(callback=callback("position")) + websocket.wallet_stream(callback=callback("wallet")) + try: + while not stop_event.is_set(): + try: + kind, message = await asyncio.wait_for(queue.get(), timeout=30) + except TimeoutError: + continue + await handler( + { + "provider": self.provider, + "category": _bybit_event_category(message), + "kind": kind, + "raw": message, + } + ) + finally: + with suppress(Exception): + websocket.exit() + + async def reconcile(self) -> list[dict[str, Any]]: + return [] + async def _call(self, method: Callable[..., Any], **kwargs: Any) -> dict[str, Any]: try: response = await asyncio.to_thread(method, **kwargs) @@ -239,3 +487,35 @@ def _is_protective_order(order: dict[str, Any]) -> bool: return True stop_type = str(order.get("stopOrderType") or "").lower() return any(marker in stop_type for marker in ("takeprofit", "stoploss", "trailing", "oco")) + + +def _plain(value: Any) -> str | None: + if value is None: + return None + return format(_decimal(value), "f") + + +def _bybit_event_category(message: dict[str, Any]) -> str: + data = message.get("data") + if isinstance(data, list) and data and isinstance(data[0], dict): + return str(data[0].get("category") or "unknown") + return "unknown" + + +def _normalize_bybit_order(item: dict[str, Any], category: str) -> dict[str, Any]: + return { + **item, + **normalized_order( + provider="bybit", + category=category, + symbol=str(item.get("symbol") or ""), + side=str(item.get("side") or ""), + status=str(item.get("orderStatus") or "Unknown"), + quantity=item.get("qty") or "0", + filled_quantity=item.get("cumExecQty") or "0", + price=item.get("avgPrice") or item.get("price") or "0", + client_id=item.get("orderLinkId"), + order_id=item.get("orderId"), + protective=_is_protective_order(item), + ), + } diff --git a/src/llm_trade_bot/cli.py b/src/llm_trade_bot/cli.py index 574b8de..d540316 100644 --- a/src/llm_trade_bot/cli.py +++ b/src/llm_trade_bot/cli.py @@ -11,7 +11,7 @@ from llm_trade_bot.service import TradeBotService def main() -> None: - parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo") + parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo or Binance Testnet") parser.add_argument("--config", default="config.toml", help="Path to TOML configuration") parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR")) args = parser.parse_args() diff --git a/src/llm_trade_bot/config.py b/src/llm_trade_bot/config.py index fb7f31f..7d3d3b8 100644 --- a/src/llm_trade_bot/config.py +++ b/src/llm_trade_bot/config.py @@ -1,7 +1,7 @@ from __future__ import annotations import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import time from pathlib import Path from typing import Any @@ -28,6 +28,23 @@ class BybitConfig: settle_coin: str = "USDT" +@dataclass(frozen=True, slots=True) +class BinanceAccountConfig: + api_key: str + api_secret: str + + +@dataclass(frozen=True, slots=True) +class BinanceConfig: + spot: BinanceAccountConfig + usdm: BinanceAccountConfig + + +@dataclass(frozen=True, slots=True) +class ExchangeConfig: + provider: str = "bybit" + + @dataclass(frozen=True, slots=True) class TelegramConfig: api_id: int @@ -83,7 +100,7 @@ class StorageConfig: @dataclass(frozen=True, slots=True) class AppConfig: deepseek: DeepSeekConfig - bybit: BybitConfig + bybit: BybitConfig | None telegram: TelegramConfig brave: BraveConfig prompts: PromptsConfig @@ -91,6 +108,8 @@ class AppConfig: scanner: ScannerConfig risk: RiskConfig storage: StorageConfig + exchange: ExchangeConfig = field(default_factory=ExchangeConfig) + binance: BinanceConfig | None = None def load_config(path: str | Path) -> AppConfig: @@ -103,7 +122,18 @@ def load_config(path: str | Path) -> AppConfig: raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error deepseek_raw = _section(raw, "deepseek") - bybit_raw = _section(raw, "bybit") + exchange_raw = raw.get("exchange") + if exchange_raw is None: + if not isinstance(raw.get("bybit"), dict): + raise ConfigError("Missing [exchange] section (legacy configs require [bybit])") + exchange = ExchangeConfig("bybit") + elif not isinstance(exchange_raw, dict): + raise ConfigError("[exchange] must be a TOML table") + else: + provider = _string(exchange_raw, "provider").lower() + if provider not in {"bybit", "binance"}: + raise ConfigError("exchange.provider must be binance or bybit") + exchange = ExchangeConfig(provider) telegram_raw = _section(raw, "telegram") brave_raw = _section(raw, "brave") prompts_raw = _section(raw, "prompts") @@ -122,11 +152,26 @@ def load_config(path: str | Path) -> AppConfig: if deepseek.base_url != "https://api.deepseek.com": raise ConfigError("deepseek.base_url must be https://api.deepseek.com") - bybit = BybitConfig( - api_key=_secret(bybit_raw, "api_key"), - api_secret=_secret(bybit_raw, "api_secret"), - settle_coin=_string(bybit_raw, "settle_coin", "USDT").upper(), - ) + bybit: BybitConfig | None = None + binance: BinanceConfig | None = None + if exchange.provider == "bybit": + bybit_raw = _section(raw, "bybit") + bybit = BybitConfig( + api_key=_secret(bybit_raw, "api_key"), + api_secret=_secret(bybit_raw, "api_secret"), + settle_coin=_string(bybit_raw, "settle_coin", "USDT").upper(), + ) + else: + binance_raw = _section(raw, "binance") + _reject_binance_endpoint_overrides(binance_raw) + spot_raw = _section(binance_raw, "spot") + usdm_raw = _section(binance_raw, "usdm") + _reject_binance_endpoint_overrides(spot_raw) + _reject_binance_endpoint_overrides(usdm_raw) + binance = BinanceConfig( + spot=BinanceAccountConfig(_secret(spot_raw, "api_key"), _secret(spot_raw, "api_secret")), + usdm=BinanceAccountConfig(_secret(usdm_raw, "api_key"), _secret(usdm_raw, "api_secret")), + ) admin_ids = telegram_raw.get("admin_user_ids") if not isinstance(admin_ids, list) or not admin_ids or any(not isinstance(item, int) for item in admin_ids): @@ -183,7 +228,19 @@ def load_config(path: str | Path) -> AppConfig: max_notes=_integer(storage_raw, "max_notes", 50, minimum=1, maximum=1000), max_notes_chars=_integer(storage_raw, "max_notes_chars", 20_000, minimum=100, maximum=200_000), ) - return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage) + return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance) + + +def _reject_binance_endpoint_overrides(section: dict[str, Any]) -> None: + forbidden = { + key + for key in section + if key.lower() in {"base_url", "rest_url", "ws_url", "websocket_url", "endpoint", "testnet"} + or key.lower().endswith("_url") + } + if forbidden: + names = ", ".join(sorted(forbidden)) + raise ConfigError(f"Binance endpoints are fixed to Testnet and cannot be configured: {names}") def _section(raw: dict[str, Any], name: str) -> dict[str, Any]: diff --git a/src/llm_trade_bot/database.py b/src/llm_trade_bot/database.py index 36cf2cc..eed3cb5 100644 --- a/src/llm_trade_bot/database.py +++ b/src/llm_trade_bot/database.py @@ -93,12 +93,15 @@ class Database: CREATE TABLE IF NOT EXISTS managed_orders ( order_link_id TEXT PRIMARY KEY, order_id TEXT, + provider TEXT NOT NULL DEFAULT 'bybit', category TEXT NOT NULL, symbol TEXT NOT NULL, side TEXT NOT NULL, status TEXT NOT NULL, expires_at TEXT, trailing_distance TEXT, + entry_order_id TEXT, + protection_order_ids_json TEXT NOT NULL DEFAULT '[]', raw_json TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -109,6 +112,18 @@ class Database: ); """ ) + cursor = await self.connection.execute("PRAGMA table_info(managed_orders)") + columns = {str(row["name"]) for row in await cursor.fetchall()} + if "provider" not in columns: + await self.connection.execute( + "ALTER TABLE managed_orders ADD COLUMN provider TEXT NOT NULL DEFAULT 'bybit'" + ) + if "entry_order_id" not in columns: + await self.connection.execute("ALTER TABLE managed_orders ADD COLUMN entry_order_id TEXT") + if "protection_order_ids_json" not in columns: + await self.connection.execute( + "ALTER TABLE managed_orders ADD COLUMN protection_order_ids_json TEXT NOT NULL DEFAULT '[]'" + ) await self.connection.commit() async def add_note(self, text: str) -> int: @@ -244,52 +259,74 @@ class Database: expires_at: datetime | None, trailing_distance: str | None, raw: Any, + provider: str = "bybit", + entry_order_id: str | None = None, + protection_order_ids: list[str] | None = None, ) -> None: raw_json = json.dumps(raw, ensure_ascii=False, default=str, separators=(",", ":")) + protection_json = json.dumps(protection_order_ids or [], separators=(",", ":")) await self.connection.execute( """ INSERT INTO managed_orders( - order_link_id, order_id, category, symbol, side, status, - expires_at, trailing_distance, raw_json, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + order_link_id, order_id, provider, category, symbol, side, status, + expires_at, trailing_distance, entry_order_id, protection_order_ids_json, + raw_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_link_id) DO UPDATE SET - order_id=excluded.order_id, status=excluded.status, + order_id=excluded.order_id, provider=excluded.provider, status=excluded.status, expires_at=excluded.expires_at, trailing_distance=excluded.trailing_distance, + entry_order_id=excluded.entry_order_id, + protection_order_ids_json=excluded.protection_order_ids_json, raw_json=excluded.raw_json, updated_at=excluded.updated_at """, ( order_link_id, order_id, + provider, category, symbol, side, status, expires_at.isoformat() if expires_at else None, trailing_distance, + entry_order_id, + protection_json, raw_json, utc_now().isoformat(), ), ) await self.connection.commit() - async def expired_managed_orders(self, now: datetime | None = None) -> list[dict[str, str]]: + async def expired_managed_orders( + self, now: datetime | None = None, *, provider: str | None = None + ) -> list[dict[str, str]]: + provider_clause = " AND provider=?" if provider else "" + parameters: list[str] = [(now or utc_now()).isoformat()] + if provider: + parameters.append(provider) cursor = await self.connection.execute( - """ - SELECT order_link_id, order_id, category, symbol FROM managed_orders + f""" + SELECT order_link_id, order_id, provider, category, symbol FROM managed_orders WHERE status IN ('New', 'Created', 'PartiallyFilled') AND expires_at IS NOT NULL AND expires_at<=? + {provider_clause} """, - ((now or utc_now()).isoformat(),), + parameters, ) return [dict(row) for row in await cursor.fetchall()] - async def managed_orders_with_pending_trailing(self) -> list[dict[str, str | None]]: + async def managed_orders_with_pending_trailing( + self, *, provider: str | None = None + ) -> list[dict[str, str | None]]: + provider_clause = " AND provider=?" if provider else "" cursor = await self.connection.execute( - """ - SELECT order_link_id, order_id, category, symbol, trailing_distance + f""" + SELECT order_link_id, order_id, provider, category, symbol, trailing_distance FROM managed_orders WHERE category='linear' AND trailing_distance IS NOT NULL AND status IN ('Created', 'New', 'PartiallyFilled') - """ + {provider_clause} + """, + (provider,) if provider else (), ) return [dict(row) for row in await cursor.fetchall()] diff --git a/src/llm_trade_bot/exchange_factory.py b/src/llm_trade_bot/exchange_factory.py new file mode 100644 index 0000000..d68fb02 --- /dev/null +++ b/src/llm_trade_bot/exchange_factory.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from llm_trade_bot.binance_gateway import BinanceGateway +from llm_trade_bot.bybit_gateway import BybitGateway +from llm_trade_bot.config import AppConfig +from llm_trade_bot.exchange_gateway import ExchangeGateway + + +def create_exchange_gateway(config: AppConfig) -> ExchangeGateway: + if config.exchange.provider == "binance": + if config.binance is None: + raise ValueError("Binance is selected but its configuration is missing") + return BinanceGateway(config.binance, config.scanner) + if config.bybit is None: + raise ValueError("Bybit is selected but its configuration is missing") + return BybitGateway(config.bybit, config.scanner) diff --git a/src/llm_trade_bot/exchange_gateway.py b/src/llm_trade_bot/exchange_gateway.py new file mode 100644 index 0000000..f348cdc --- /dev/null +++ b/src/llm_trade_bot/exchange_gateway.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any, Protocol, runtime_checkable + +from llm_trade_bot.models import ( + AccountSnapshot, + InstrumentRules, + MarketCandidate, + OpenTradeRequest, + ValidatedOrder, +) + +PrivateEventHandler = Callable[[dict[str, Any]], Awaitable[None]] + + +@runtime_checkable +class ExchangeGateway(Protocol): + """Exchange-neutral contract consumed by the agent and service.""" + + provider: str + environment: str + + async def startup_check(self) -> dict[str, Any]: ... + + async def close(self) -> None: ... + + async def account_snapshot(self, category: str | None = None) -> AccountSnapshot: ... + + async def account_state(self) -> dict[str, Any]: ... + + async def scan_markets(self) -> list[MarketCandidate]: ... + + async def market_context(self, category: str, symbol: str) -> dict[str, Any]: ... + + async def instrument_rules(self, category: str, symbol: str) -> InstrumentRules: ... + + async def open_orders(self, category: str | None = None) -> list[dict[str, Any]]: ... + + async def order_history(self, category: str, limit: int = 20) -> list[dict[str, Any]]: ... + + async def executions(self, category: str, limit: int = 20) -> list[dict[str, Any]]: ... + + async def open_trade( + self, + request: OpenTradeRequest, + order: ValidatedOrder, + client_id: str, + ) -> dict[str, Any]: ... + + async def close_trade( + self, + category: str, + symbol: str, + percent: float, + order_type: str, + limit_price: str | None, + client_id: str, + ) -> dict[str, Any]: ... + + async def cancel_order( + self, + category: str, + symbol: str, + order_id: str | None = None, + client_id: str | None = None, + ) -> dict[str, Any]: ... + + async def update_protection( + self, + symbol: str, + stop_loss: str, + take_profit: str | None, + trailing_distance: str | None, + ) -> dict[str, Any]: ... + + async def private_stream( + self, + handler: PrivateEventHandler, + stop_event: asyncio.Event, + ) -> None: ... + + async def reconcile(self) -> list[dict[str, Any]]: ... + + +def normalized_order( + *, + provider: str, + category: str, + symbol: str, + side: str, + status: str, + quantity: Any, + filled_quantity: Any = "0", + price: Any = "0", + client_id: Any = None, + order_id: Any = None, + protective: bool = False, + **extra: Any, +) -> dict[str, Any]: + result = { + "provider": provider, + "category": category, + "symbol": symbol, + "side": side, + "status": status, + "quantity": str(quantity or "0"), + "filled_quantity": str(filled_quantity or "0"), + "price": str(price or "0"), + "client_id": None if client_id in (None, "") else str(client_id), + "order_id": None if order_id in (None, "") else str(order_id), + "protective": bool(protective), + } + result.update(extra) + return result diff --git a/src/llm_trade_bot/service.py b/src/llm_trade_bot/service.py index 133ecec..3e44acb 100644 --- a/src/llm_trade_bot/service.py +++ b/src/llm_trade_bot/service.py @@ -3,16 +3,13 @@ from __future__ import annotations import asyncio import json import logging -from contextlib import suppress from datetime import UTC, datetime from typing import Any -from pybit.unified_trading import WebSocket - from llm_trade_bot.agent import AgentRunner -from llm_trade_bot.bybit_gateway import BybitGateway from llm_trade_bot.config import AppConfig from llm_trade_bot.database import Database +from llm_trade_bot.exchange_factory import create_exchange_gateway from llm_trade_bot.scheduler import next_scheduled_time from llm_trade_bot.telegram_bot import TelegramBot @@ -23,27 +20,27 @@ class TradeBotService: def __init__(self, config: AppConfig): self.config = config self.database = Database(config.storage.database_path) - self.bybit = BybitGateway(config.bybit, config.scanner) - self.agent = AgentRunner(config, self.database, self.bybit) + self.exchange = create_exchange_gateway(config) + self.bybit = self.exchange # compatibility with Telegram extensions + self.agent = AgentRunner(config, self.database, self.exchange) self.telegram = TelegramBot(config.telegram, self) self._run_lock = asyncio.Lock() self._paused = False self._tasks: set[asyncio.Task[Any]] = set() self._stopping = asyncio.Event() - self._websocket: WebSocket | None = None - self._loop: asyncio.AbstractEventLoop | None = None async def start(self) -> None: await self.database.open() self._paused = (await self.database.get_state("paused", "false")) == "true" - await self.bybit.startup_check() + await self.exchange.startup_check() await self.telegram.start() - self._loop = asyncio.get_running_loop() - self._start_private_websocket() + self._spawn(self.exchange.private_stream(self._record_exchange_event, self._stopping), "private-stream") self._spawn(self._scheduler_loop(), "scheduler") self._spawn(self._outbox_loop(), "outbox") self._spawn(self._reconcile_loop(), "reconciler") - await self.database.queue_message("🤖 LLM Trade Bot запущен в режиме Bybit Demo.") + await self.database.queue_message( + f"🤖 LLM Trade Bot запущен: {self.exchange.provider} {self.exchange.environment}." + ) async def stop(self) -> None: self._stopping.set() @@ -51,11 +48,14 @@ class TradeBotService: task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) - if self._websocket is not None: - with suppress(Exception): - self._websocket.exit() - with suppress(Exception): + try: await self.telegram.stop() + except Exception: + LOGGER.exception("Telegram shutdown failed") + try: + await self.exchange.close() + except Exception: + LOGGER.exception("Exchange gateway shutdown failed") await self.database.close() async def wait(self) -> None: @@ -95,7 +95,7 @@ class TradeBotService: async def pause(self) -> str: self._paused = True await self.database.set_state("paused", "true") - return "Новые анализы и входы приостановлены. Защитные ордера Bybit остаются активны." + return f"Новые анализы и входы приостановлены. Защитные ордера {self.exchange.provider} остаются активны." async def resume(self) -> str: self._paused = False @@ -106,7 +106,8 @@ class TradeBotService: next_run = next_scheduled_time(self.config.schedule) last = await self.database.last_run() return ( - f"Режим: Bybit Demo\n" + f"Биржа: {self.exchange.provider}\n" + f"Среда: {self.exchange.environment}\n" f"Состояние: {'пауза' if self._paused else 'активен'}\n" f"Агент: {'работает' if self._run_lock.locked() else 'ожидает'}\n" f"Следующий запуск: {next_run.isoformat()}\n" @@ -114,13 +115,13 @@ class TradeBotService: ) async def positions_text(self) -> str: - state = await self.bybit.account_state() + state = await self.exchange.account_state() return "Позиции и активы:\n" + json.dumps( - {"wallet": state["wallet"], "positions": state["positions"]}, ensure_ascii=False, default=str + {"wallet": state.get("wallet", state.get("wallets")), "positions": state["positions"]}, ensure_ascii=False, default=str )[:3800] async def orders_text(self) -> str: - orders = await self.bybit.open_orders() + orders = await self.exchange.open_orders() return "Открытые ордера:\n" + json.dumps(orders, ensure_ascii=False, default=str)[:3800] async def last_run_text(self) -> str: @@ -164,19 +165,25 @@ class TradeBotService: while not self._stopping.is_set(): try: await self._cancel_expired_orders() - await self._apply_pending_trailing_stops() + if self.exchange.provider == "bybit": + await self._apply_pending_trailing_stops() + for event in await self.exchange.reconcile(): + await self._record_exchange_event(event) except Exception: - LOGGER.exception("Bybit reconciliation failed") + LOGGER.exception("%s reconciliation failed", self.exchange.provider) try: await asyncio.wait_for(self._stopping.wait(), timeout=60) except TimeoutError: pass async def _cancel_expired_orders(self) -> None: - for order in await self.database.expired_managed_orders(): + for order in await self.database.expired_managed_orders(provider=self.exchange.provider): try: - await self.bybit.cancel_order( - category=order["category"], symbol=order["symbol"], orderId=order["order_id"] + await self.exchange.cancel_order( + order["category"], + order["symbol"], + order_id=order["order_id"], + client_id=order["order_link_id"] if self.exchange.provider == "binance" else None, ) await self.database.mark_managed_order_status(order["order_link_id"], "Cancelled") await self.database.queue_message( @@ -187,8 +194,8 @@ class TradeBotService: raise async def _apply_pending_trailing_stops(self) -> None: - for managed in await self.database.managed_orders_with_pending_trailing(): - history = await self.bybit.order_history("linear", 50) + for managed in await self.database.managed_orders_with_pending_trailing(provider="bybit"): + history = await self.exchange.order_history("linear", 50) matching = next( (item for item in history if item.get("orderLinkId") == managed["order_link_id"]), None ) @@ -199,7 +206,7 @@ class TradeBotService: trailing_distance = managed["trailing_distance"] if order_link_id is None or symbol is None or trailing_distance is None: continue - await self.bybit.set_trading_stop( + await self.exchange.set_trading_stop( # type: ignore[attr-defined] category="linear", symbol=symbol, positionIdx=0, @@ -211,38 +218,25 @@ class TradeBotService: f"🛡 Trailing-stop установлен: {symbol} distance={trailing_distance}" ) - def _start_private_websocket(self) -> None: - websocket = WebSocket( - testnet=False, - demo=True, - channel_type="private", - api_key=self.config.bybit.api_key, - api_secret=self.config.bybit.api_secret, - ) - websocket.order_stream(callback=self._websocket_callback("order")) - websocket.execution_stream(callback=self._websocket_callback("execution")) - websocket.position_stream(callback=self._websocket_callback("position")) - websocket.wallet_stream(callback=self._websocket_callback("wallet")) - self._websocket = websocket - - def _websocket_callback(self, kind: str): - def callback(message: dict[str, Any]) -> None: - if self._loop is None or self._loop.is_closed(): - return - future = asyncio.run_coroutine_threadsafe(self._record_ws_event(kind, message), self._loop) - future.add_done_callback(lambda completed: completed.exception() if not completed.cancelled() else None) - - return callback - - async def _record_ws_event(self, kind: str, message: dict[str, Any]) -> None: - await self.database.add_event(f"bybit_ws_{kind}", message) - if kind == "order": - for order in message.get("data", []): + async def _record_exchange_event(self, message: dict[str, Any]) -> None: + kind = str(message.get("kind") or "event") + await self.database.add_event(f"{self.exchange.provider}_ws_{kind}", message) + if kind == "reconcile_required": + for event in await self.exchange.reconcile(): + await self.database.add_event(f"{self.exchange.provider}_reconcile", event) + return + client_id = message.get("client_id") + status = message.get("status") + if isinstance(client_id, str) and client_id.startswith("ltb-") and status: + await self.database.mark_managed_order_status(client_id, str(status)) + if self.exchange.provider == "bybit" and kind == "order": + raw = message.get("raw", {}) + for order in raw.get("data", []) if isinstance(raw, dict) else []: order_link_id = order.get("orderLinkId") order_status = order.get("orderStatus") if order_link_id and order_link_id.startswith("ltb-") and order_status: await self.database.mark_managed_order_status(order_link_id, order_status) if kind in {"order", "execution"}: await self.database.queue_message( - f"Bybit {kind}: {json.dumps(message.get('data', message), ensure_ascii=False, default=str)[:3000]}" + f"{self.exchange.provider} {kind}: {json.dumps(message, ensure_ascii=False, default=str)[:3000]}" ) diff --git a/tests/test_binance_client.py b/tests/test_binance_client.py new file mode 100644 index 0000000..0ddef95 --- /dev/null +++ b/tests/test_binance_client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import hashlib +import hmac + +import httpx +import pytest + +from llm_trade_bot.binance_client import ( + SPOT_REST_URL, + BinanceCredentials, + BinanceRestClient, + encode_parameters, + sign_parameters, +) + + +def test_hmac_signature_uses_sorted_urlencoded_parameters(): + parameters = { + "symbol": "BTCUSDT", + "side": "BUY", + "quantity": "0.01000000", + "recvWindow": 100, + "timestamp": 1645423376532, + } + payload = encode_parameters(parameters) + assert payload == "quantity=0.01000000&recvWindow=100&side=BUY&symbol=BTCUSDT×tamp=1645423376532" + assert sign_parameters(parameters, "secret") == hmac.new( + b"secret", payload.encode(), hashlib.sha256 + ).hexdigest() + + +async def test_signed_request_resynchronizes_time_and_resigns(): + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + if request.url.path == "/api/v3/time": + return httpx.Response(200, json={"serverTime": 1_000_100}, request=request) + if len([item for item in seen if item.url.path == "/api/v3/account"]) == 1: + return httpx.Response(400, json={"code": -1021, "msg": "outside recvWindow"}, request=request) + query = dict(request.url.params) + unsigned = {key: value for key, value in query.items() if key != "signature"} + assert query["signature"] == sign_parameters(unsigned, "secret") + assert query["recvWindow"] == "5000" + return httpx.Response(200, json={"ok": True}, request=request) + + client = BinanceRestClient( + SPOT_REST_URL, + BinanceCredentials("key", "secret"), + http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)), + clock=lambda: 1000.0, + ) + assert await client.request("GET", "/api/v3/account", signed=True) == {"ok": True} + assert [request.url.path for request in seen] == ["/api/v3/account", "/api/v3/time", "/api/v3/account"] + + +async def test_rate_limit_honors_retry_after(): + attempts = 0 + pauses: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(429, headers={"Retry-After": "0.25"}, json={"code": -1003}, request=request) + return httpx.Response(200, json={"value": "ok"}, request=request) + + async def sleep(delay: float) -> None: + pauses.append(delay) + + client = BinanceRestClient( + SPOT_REST_URL, + BinanceCredentials("key", "secret"), + http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)), + sleep=sleep, + ) + assert await client.request("GET", "/api/v3/ping") == {"value": "ok"} + assert pauses == [0.25] + + +async def test_ambiguous_mutation_queries_client_id_instead_of_retrying_post(): + methods: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + methods.append(request.method) + if request.method == "POST": + return httpx.Response(500, json={"msg": "backend timeout"}, request=request) + return httpx.Response(200, json={"orderId": 42, "clientOrderId": "ltb-1"}, request=request) + + client = BinanceRestClient( + SPOT_REST_URL, + BinanceCredentials("key", "secret"), + http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)), + ) + result = await client.mutate_with_recovery( + "POST", + "/api/v3/order", + {"symbol": "BTCUSDT", "newClientOrderId": "ltb-1"}, + lookup_path="/api/v3/order", + lookup_parameters={"symbol": "BTCUSDT", "origClientOrderId": "ltb-1"}, + ) + assert result["orderId"] == 42 + assert methods == ["POST", "GET"] + + +def test_production_url_is_not_accepted(): + with pytest.raises(ValueError, match="Testnet"): + BinanceRestClient("https://api.binance.com", BinanceCredentials("key", "secret")) diff --git a/tests/test_binance_gateway.py b/tests/test_binance_gateway.py new file mode 100644 index 0000000..329191a --- /dev/null +++ b/tests/test_binance_gateway.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +import pytest + +from llm_trade_bot.binance_gateway import BinanceError, BinanceGateway +from llm_trade_bot.config import BinanceAccountConfig, BinanceConfig, ScannerConfig +from llm_trade_bot.models import ( + Category, + OpenTradeRequest, + OrderType, + Side, + ValidatedOrder, +) + + +class FakeBinanceClient: + def __init__(self, exchange_info_path: str, mutation_results: list[dict[str, Any]]): + self.exchange_info_path = exchange_info_path + self.mutation_results = mutation_results + self.requests: list[tuple[str, str, dict[str, Any] | None, dict[str, Any]]] = [] + self.mutations: list[tuple[str, str, dict[str, Any]]] = [] + + async def request(self, method: str, path: str, parameters=None, **kwargs): + self.requests.append((method, path, parameters, kwargs)) + if path == self.exchange_info_path: + return { + "symbols": [ + { + "symbol": "BTCUSDT", + "status": "TRADING", + "otoAllowed": True, + "ocoAllowed": True, + "filters": [ + {"filterType": "PRICE_FILTER", "tickSize": "0.1"}, + {"filterType": "LOT_SIZE", "minQty": "0.001", "stepSize": "0.001"}, + {"filterType": "MIN_NOTIONAL", "minNotional": "5"}, + ], + } + ] + } + if path == "/fapi/v1/positionSide/dual": + return {"dualSidePosition": False} + return {"ok": True} + + async def mutate_with_recovery(self, method: str, path: str, parameters: dict[str, Any], **kwargs): + self.mutations.append((method, path, parameters)) + return self.mutation_results.pop(0) + + async def close(self) -> None: + return None + + +def gateway(spot: FakeBinanceClient, usdm: FakeBinanceClient) -> BinanceGateway: + return BinanceGateway( + BinanceConfig(BinanceAccountConfig("spot-key", "spot-secret"), BinanceAccountConfig("future-key", "future-secret")), + ScannerConfig(), + spot_client=spot, # type: ignore[arg-type] + usdm_client=usdm, # type: ignore[arg-type] + ) + + +def request(category: Category, **changes: Any) -> OpenTradeRequest: + values = { + "category": category, + "symbol": "BTCUSDT", + "side": Side.BUY, + "order_type": OrderType.LIMIT if category is Category.SPOT else OrderType.MARKET, + "reference_price": Decimal(100), + "limit_price": Decimal(100) if category is Category.SPOT else None, + "stop_loss": Decimal(90), + "take_profit": Decimal(120), + "trailing_distance": None, + "risk_pct": Decimal(2), + "leverage": Decimal(3), + "rationale": "test", + } + values.update(changes) + return OpenTradeRequest(**values) + + +def order(**changes: Any) -> ValidatedOrder: + values = { + "entry_price": Decimal(100), + "quantity": Decimal(2), + "notional": Decimal(200), + "stop_loss": Decimal(90), + "take_profit": Decimal(120), + "trailing_distance": None, + "leverage": Decimal(3), + } + values.update(changes) + return ValidatedOrder(**values) + + +async def test_spot_uses_single_atomic_otoco_with_market_tp_and_sl(): + spot = FakeBinanceClient( + "/api/v3/exchangeInfo", + [ + { + "orderListId": 7, + "listOrderStatus": "EXECUTING", + "orderReports": [{"orderId": 11, "clientOrderId": "ltb-1-entry", "status": "NEW"}], + } + ], + ) + usdm = FakeBinanceClient("/fapi/v1/exchangeInfo", []) + result = await gateway(spot, usdm).open_trade(request(Category.SPOT), order(), "ltb-1") + assert result["provider"] == "binance" + assert result["category"] == "spot" + assert result["order_id"] == "11" + assert len(spot.mutations) == 1 + method, path, parameters = spot.mutations[0] + assert (method, path) == ("POST", "/api/v3/orderList/otoco") + assert parameters["workingType"] == "LIMIT" + assert parameters["workingSide"] == "BUY" + assert parameters["pendingSide"] == "SELL" + assert parameters["pendingAboveType"] == "TAKE_PROFIT" + assert parameters["pendingBelowType"] == "STOP_LOSS" + + +async def test_futures_installs_stop_and_take_profit_after_market_fill(): + spot = FakeBinanceClient("/api/v3/exchangeInfo", []) + usdm = FakeBinanceClient( + "/fapi/v1/exchangeInfo", + [ + {"orderId": 44, "status": "FILLED", "executedQty": "2", "avgPrice": "100"}, + {"algoId": 45}, + {"algoId": 46}, + ], + ) + result = await gateway(spot, usdm).open_trade(request(Category.LINEAR), order(), "ltb-2") + assert result["status"] == "Filled" + assert result["protection"]["status"] == "active" + assert [path for _, path, _ in usdm.mutations] == [ + "/fapi/v1/order", + "/fapi/v1/algoOrder", + "/fapi/v1/algoOrder", + ] + stop = usdm.mutations[1][2] + take_profit = usdm.mutations[2][2] + assert stop["type"] == "STOP_MARKET" + assert stop["closePosition"] is True + assert take_profit["type"] == "TAKE_PROFIT_MARKET" + assert take_profit["closePosition"] is True + assert any(path == "/fapi/v1/marginType" for _, path, _, _ in usdm.requests) + assert any(path == "/fapi/v1/leverage" for _, path, _, _ in usdm.requests) + + +async def test_trailing_rate_outside_binance_range_rejects_before_opening(): + spot = FakeBinanceClient("/api/v3/exchangeInfo", []) + usdm = FakeBinanceClient("/fapi/v1/exchangeInfo", []) + with pytest.raises(BinanceError, match="callbackRate"): + await gateway(spot, usdm).open_trade( + request(Category.LINEAR, take_profit=None, trailing_distance=Decimal("0.05")), + order(take_profit=None, trailing_distance=Decimal("0.05")), + "ltb-3", + ) + assert not usdm.requests + assert not usdm.mutations diff --git a/tests/test_config.py b/tests/test_config.py index 21d6073..eef1e6c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -75,3 +75,66 @@ def test_rejects_missing_prompts_file(tmp_path: Path): path.write_text(CONFIG, encoding="utf-8") with pytest.raises(ConfigError, match="Prompts file not found"): load_config(path) + + +def test_legacy_bybit_config_selects_bybit(tmp_path: Path): + config = load_config(write_config(tmp_path)) + assert config.exchange.provider == "bybit" + assert config.bybit is not None + assert config.binance is None + + +def test_binance_config_only_requires_binance_credentials(tmp_path: Path): + content = CONFIG.replace( + '[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n', + '''[exchange] +provider = "binance" +[binance.spot] +api_key = "spot-key" +api_secret = "spot-secret" +[binance.usdm] +api_key = "usdm-key" +api_secret = "usdm-secret" +''', + ) + config = load_config(write_config(tmp_path, content)) + assert config.exchange.provider == "binance" + assert config.bybit is None + assert config.binance is not None + assert config.binance.spot.api_key == "spot-key" + + +def test_binance_rejects_endpoint_override(tmp_path: Path): + content = CONFIG.replace( + '[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n', + '''[exchange] +provider = "binance" +[binance] +base_url = "https://api.binance.com" +[binance.spot] +api_key = "spot-key" +api_secret = "spot-secret" +[binance.usdm] +api_key = "usdm-key" +api_secret = "usdm-secret" +''', + ) + with pytest.raises(ConfigError, match="fixed to Testnet"): + load_config(write_config(tmp_path, content)) + + +def test_binance_requires_both_selected_wallet_credentials(tmp_path: Path): + content = CONFIG.replace( + '[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n', + '''[exchange] +provider = "binance" +[binance.spot] +api_key = "spot-key" +api_secret = "spot-secret" +[binance.usdm] +api_key = "replace-me" +api_secret = "replace-me" +''', + ) + with pytest.raises(ConfigError, match="placeholder"): + load_config(write_config(tmp_path, content)) diff --git a/tests/test_database.py b/tests/test_database.py index c953857..71e191f 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,3 +1,4 @@ +import sqlite3 from pathlib import Path from llm_trade_bot.database import Database @@ -35,3 +36,44 @@ async def test_run_lifecycle(tmp_path: Path): assert last["summary"] == "idle" finally: await database.close() + + +async def test_managed_orders_migrate_legacy_rows_to_bybit(tmp_path: Path): + path = tmp_path / "legacy.db" + connection = sqlite3.connect(path) + connection.execute( + """ + CREATE TABLE managed_orders ( + order_link_id TEXT PRIMARY KEY, order_id TEXT, category TEXT NOT NULL, + symbol TEXT NOT NULL, side TEXT NOT NULL, status TEXT NOT NULL, + expires_at TEXT, trailing_distance TEXT, raw_json TEXT NOT NULL, updated_at TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + INSERT INTO managed_orders VALUES ( + 'ltb-old', 'old-1', 'linear', 'BTCUSDT', 'Buy', 'Created', + '2000-01-01T00:00:00+00:00', NULL, '{}', '2000-01-01T00:00:00+00:00' + ) + """ + ) + connection.commit() + connection.close() + + database = Database(path) + await database.open() + try: + rows = await database.expired_managed_orders(provider="bybit") + assert rows == [ + { + "order_link_id": "ltb-old", + "order_id": "old-1", + "provider": "bybit", + "category": "linear", + "symbol": "BTCUSDT", + } + ] + assert await database.expired_managed_orders(provider="binance") == [] + finally: + await database.close() diff --git a/uv.lock b/uv.lock index 5569dff..5b9a516 100644 --- a/uv.lock +++ b/uv.lock @@ -411,6 +411,7 @@ dependencies = [ { name = "pybit" }, { name = "tgcrypto" }, { name = "tzdata" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -430,6 +431,7 @@ requires-dist = [ { name = "pybit", specifier = ">=5.12,<6" }, { name = "tgcrypto", specifier = ">=1.2.5,<2" }, { name = "tzdata", specifier = ">=2026.1" }, + { name = "websockets", specifier = ">=15,<17" }, ] [package.metadata.requires-dev] @@ -831,3 +833,80 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393 wheels = [ { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +]